Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 48 additions & 12 deletions src/madsci_common/madsci/common/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,76 @@
import contextlib
import contextvars
from collections.abc import Generator
from typing import Any
from typing import Any, Optional

from madsci.common.types.context_types import MadsciContext

global_madsci_context = MadsciContext()
"""
Global MADSci context
To change the context for a system component, set fields on this object.
This is then used by the madsci_context context manager to create temporary contexts as needed.
"""

_current_madsci_context = contextvars.ContextVar(
"current_madsci_context",
default=global_madsci_context,
class GlobalMadsciContext:
"""
A global MadsciContext object for the application.

This singleton can be accessed from anywhere in the codebase, but should
not be modified directly. Instead, use the madsci_context context manager
to temporarily override values in the context.
"""

_context: Optional[MadsciContext] = None

@classmethod
def get_context(cls) -> MadsciContext:
"""
Get the global context, creating it lazily if needed.

Returns:
The global MadsciContext instance.
"""
if cls._context is None:
cls._context = MadsciContext()
return cls._context

@classmethod
def set_context(cls, context: MadsciContext) -> None:
"""
Set the global context.

Args:
context: The MadsciContext instance to set as global.
"""
cls._context = context


_current_madsci_context: contextvars.ContextVar[Optional[MadsciContext]] = (
contextvars.ContextVar(
"current_madsci_context",
default=None,
)
)


@contextlib.contextmanager
def madsci_context(**overrides: dict[str, Any]) -> Generator[None, MadsciContext, None]:
"""Updates the current MadsciContext (as returned by get_current_madsci_context) with the provided overrides."""
prev_context = _current_madsci_context.get()
if prev_context is None:
prev_context = GlobalMadsciContext.get_context()
context = prev_context.model_copy()
for k, v in overrides.items():
setattr(context, k, v)
token = _current_madsci_context.set(context)
try:
yield _current_madsci_context.get()
yield _current_madsci_context.get() # type: ignore[misc]
finally:
_current_madsci_context.reset(token)


def get_current_madsci_context() -> MadsciContext:
"""Returns the current MadsciContext object."""
return _current_madsci_context.get()
context = _current_madsci_context.get()
if context is None:
context = GlobalMadsciContext.get_context()
_current_madsci_context.set(context)
return context


def set_current_madsci_context(context: MadsciContext) -> None:
Expand Down
72 changes: 50 additions & 22 deletions src/madsci_common/tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,67 @@
import threading

from madsci.common.context import (
GlobalMadsciContext,
get_current_madsci_context,
global_madsci_context,
madsci_context,
)
from madsci.common.types.context_types import MadsciContext


def test_global_madsci_context_default() -> None:
"""Test that global_madsci_context is an instance of MadsciContext by default."""
assert isinstance(global_madsci_context, MadsciContext)
"""Test that GlobalMadsciContext returns a MadsciContext instance."""
context = GlobalMadsciContext.get_context()
assert isinstance(context, MadsciContext)


def test_global_context_across_threads() -> None:
"""Tests that changes to global_madsci_context are consistent across threads."""
original_url = global_madsci_context.lab_server_url
"""Tests that changes to GlobalMadsciContext are consistent across threads."""
original_context = GlobalMadsciContext.get_context()
original_url = original_context.lab_server_url
test_url = "http://test-lab:8000"
global_madsci_context.lab_server_url = test_url
assert str(global_madsci_context.lab_server_url) == "http://test-lab:8000/"

# Create a new context with modified URL
new_context = original_context.model_copy()
new_context.lab_server_url = test_url
GlobalMadsciContext.set_context(new_context)
assert (
str(GlobalMadsciContext.get_context().lab_server_url) == "http://test-lab:8000/"
)

def check_context() -> None:
"""Function to check context in a separate thread."""
assert str(global_madsci_context.lab_server_url) == "http://test-lab:8000/"
global_madsci_context.lab_server_url = original_url
assert (
str(GlobalMadsciContext.get_context().lab_server_url)
== "http://test-lab:8000/"
)
# Restore original context
restore_context = GlobalMadsciContext.get_context().model_copy()
restore_context.lab_server_url = original_url
GlobalMadsciContext.set_context(restore_context)

# Run the check in a separate thread
thread = threading.Thread(target=check_context)
thread.start()
thread.join()
# Ensure the original state is restored
global_madsci_context.lab_server_url = original_url
final_context = GlobalMadsciContext.get_context().model_copy()
final_context.lab_server_url = original_url
GlobalMadsciContext.set_context(final_context)


def test_madsci_context_temporary_override() -> None:
"""Test that madsci_context temporarily overrides and restores context."""
original_lab_url = global_madsci_context.lab_server_url
original_event_url = global_madsci_context.event_server_url
original_context = GlobalMadsciContext.get_context()
original_lab_url = original_context.lab_server_url
original_event_url = original_context.event_server_url
test_lab_url = "http://test-lab:8000"
test_event_url = "http://test-event:8001"

global_madsci_context.lab_server_url = original_lab_url
global_madsci_context.event_server_url = original_event_url
# Ensure we start with known state
base_context = original_context.model_copy()
base_context.lab_server_url = original_lab_url
base_context.event_server_url = original_event_url
GlobalMadsciContext.set_context(base_context)

with madsci_context(
lab_server_url=test_lab_url, event_server_url=test_event_url
Expand All @@ -65,12 +85,16 @@ def test_madsci_context_temporary_override() -> None:

def test_madsci_context_partial_override() -> None:
"""Test that madsci_context only overrides specified fields."""
original_lab_url = global_madsci_context.lab_server_url
original_event_url = global_madsci_context.event_server_url
original_context = GlobalMadsciContext.get_context()
original_lab_url = original_context.lab_server_url
original_event_url = original_context.event_server_url
test_lab_url = "http://test-lab:8000"

global_madsci_context.lab_server_url = original_lab_url
global_madsci_context.event_server_url = original_event_url
# Ensure we start with known state
base_context = original_context.model_copy()
base_context.lab_server_url = original_lab_url
base_context.event_server_url = original_event_url
GlobalMadsciContext.set_context(base_context)

with madsci_context(lab_server_url=test_lab_url):
assert (
Expand All @@ -92,14 +116,18 @@ def test_get_current_madsci_context() -> None:

def test_nested_madsci_context() -> None:
"""Test that nested context managers work correctly."""
original_lab_url = global_madsci_context.lab_server_url
original_event_url = global_madsci_context.event_server_url
original_context = GlobalMadsciContext.get_context()
original_lab_url = original_context.lab_server_url
original_event_url = original_context.event_server_url
test_lab_url1 = "http://test-lab1:8000"
test_lab_url2 = "http://test-lab2:8000"
test_event_url = "http://test-event:8001"

global_madsci_context.lab_server_url = original_lab_url
global_madsci_context.event_server_url = original_event_url
# Ensure we start with known state
base_context = original_context.model_copy()
base_context.lab_server_url = original_lab_url
base_context.event_server_url = original_event_url
GlobalMadsciContext.set_context(base_context)

with madsci_context(lab_server_url=test_lab_url1, event_server_url=test_event_url):
assert (
Expand Down
Loading