From 85781ee4b086b27ffdee105fe3fc6f179c8b42a2 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 15 Jul 2025 12:34:11 +0200 Subject: [PATCH 1/7] Created state provider. --- that_depends/providers/state.py | 26 ++++++++++++++++++++++++++ that_depends/utils.py | 16 ++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 that_depends/providers/state.py create mode 100644 that_depends/utils.py diff --git a/that_depends/providers/state.py b/that_depends/providers/state.py new file mode 100644 index 00000000..41f03ba2 --- /dev/null +++ b/that_depends/providers/state.py @@ -0,0 +1,26 @@ +from typing import TypeVar + +from typing_extensions import override + +from that_depends.providers import AbstractProvider +from that_depends.utils import UNSET, Unset + + +T_co = TypeVar("T_co", covariant=True) + +_UNSET = object() + + +class State(AbstractProvider[T_co]): + """Provides a state that can be resolved with an optional callback.""" + + def __init__(self) -> None: + """Initialize the State provider with an optional callback.""" + super().__init__() + self._state: T_co | Unset = UNSET + + @override + async def resolve(self) -> T_co: ... + + @override + def resolve_sync(self) -> T_co: ... diff --git a/that_depends/utils.py b/that_depends/utils.py new file mode 100644 index 00000000..c31f982c --- /dev/null +++ b/that_depends/utils.py @@ -0,0 +1,16 @@ +from typing import TypeGuard, TypeVar + + +T = TypeVar("T") + + +class Unset: + """Represents an unset value.""" + + +UNSET = Unset() + + +def is_set(value: T | Unset) -> TypeGuard[T]: + """Check if a value is set (not UNSET).""" + return value is not UNSET From b46bde0066c9924256e84d97f9e5eb758d3fd66b Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 15 Jul 2025 13:00:32 +0200 Subject: [PATCH 2/7] Fully implemented state provider with tests. --- tests/providers/test_state.py | 43 ++++++++++++++++++++++++++++++ that_depends/exceptions.py | 4 +++ that_depends/providers/__init__.py | 2 ++ that_depends/providers/state.py | 43 +++++++++++++++++++++++++----- 4 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 tests/providers/test_state.py diff --git a/tests/providers/test_state.py b/tests/providers/test_state.py new file mode 100644 index 00000000..a1110f04 --- /dev/null +++ b/tests/providers/test_state.py @@ -0,0 +1,43 @@ +import pytest + +from that_depends import BaseContainer +from that_depends.exceptions import StateNotInitializedError +from that_depends.providers.state import State + + +async def test_state_raises_if_not_set_async() -> None: + class _Container(BaseContainer): + state: State[str] = State() + + with pytest.raises(StateNotInitializedError): + await _Container.state.resolve() + + +def test_state_raises_if_not_set_sync() -> None: + class _Container(BaseContainer): + state: State[str] = State() + + with pytest.raises(StateNotInitializedError): + _Container.state.resolve_sync() + + +async def test_state_set_resolves_correctly_async() -> None: + class _Container(BaseContainer): + state: State[str] = State() + + state_value = "test_value" + + with _Container.state.init(state_value) as value: + assert value == state_value + assert await _Container.state.resolve() == state_value + + +def test_state_set_resolves_correctly_sync() -> None: + class _Container(BaseContainer): + state: State[str] = State() + + state_value = "test_value" + + with _Container.state.init(state_value) as value: + assert value == state_value + assert _Container.state.resolve_sync() == state_value diff --git a/that_depends/exceptions.py b/that_depends/exceptions.py index 83939758..c5972c65 100644 --- a/that_depends/exceptions.py +++ b/that_depends/exceptions.py @@ -1,2 +1,6 @@ class TypeNotBoundError(Exception): """Exception raised when a type is not bound to a provider.""" + + +class StateNotInitializedError(Exception): + """Exception raised when the state is not initialized.""" diff --git a/that_depends/providers/__init__.py b/that_depends/providers/__init__.py index a93588c0..b45b1054 100644 --- a/that_depends/providers/__init__.py +++ b/that_depends/providers/__init__.py @@ -13,6 +13,7 @@ from that_depends.providers.resources import Resource from that_depends.providers.selector import Selector from that_depends.providers.singleton import AsyncSingleton, Singleton +from that_depends.providers.state import State __all__ = [ @@ -29,6 +30,7 @@ "Resource", "Selector", "Singleton", + "State", "ThreadLocalSingleton", "container_context", ] diff --git a/that_depends/providers/state.py b/that_depends/providers/state.py index 41f03ba2..e1a46aea 100644 --- a/that_depends/providers/state.py +++ b/that_depends/providers/state.py @@ -1,26 +1,55 @@ +import typing +import uuid +from contextlib import contextmanager +from contextvars import ContextVar from typing import TypeVar from typing_extensions import override +from that_depends.exceptions import StateNotInitializedError from that_depends.providers import AbstractProvider -from that_depends.utils import UNSET, Unset +from that_depends.utils import UNSET, Unset, is_set -T_co = TypeVar("T_co", covariant=True) +T = TypeVar("T", covariant=False) -_UNSET = object() +_STATE_UNSET_ERROR_MESSAGE: typing.Final[str] = ( + "State has not been initialized.\n" + "Please use `with provider.init(state):` to initialize the state before resolving it." +) -class State(AbstractProvider[T_co]): +class State(AbstractProvider[T]): """Provides a state that can be resolved with an optional callback.""" def __init__(self) -> None: """Initialize the State provider with an optional callback.""" super().__init__() - self._state: T_co | Unset = UNSET + + self._state: ContextVar[T | Unset] = ContextVar(f"STATE_{uuid.uuid4()}", default=UNSET) + + @contextmanager + def init(self, state: T) -> typing.Iterator[T]: + """Initialize the state provider. + + Args: + state: value to store. + + Returns: + Iterator[T_co]: A context manager that yields the state value. + + """ + token = self._state.set(state) + yield state + self._state.reset(token) @override - async def resolve(self) -> T_co: ... + async def resolve(self) -> T: + return self.resolve_sync() @override - def resolve_sync(self) -> T_co: ... + def resolve_sync(self) -> T: + value = self._state.get() + if is_set(value): + return value + raise StateNotInitializedError(_STATE_UNSET_ERROR_MESSAGE) From d84c5670e80c08b16b89b24cb02356a257a7450c Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 15 Jul 2025 13:39:17 +0200 Subject: [PATCH 3/7] Added integration test for state provider. --- tests/providers/test_state.py | 38 ++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/providers/test_state.py b/tests/providers/test_state.py index a1110f04..27cb666b 100644 --- a/tests/providers/test_state.py +++ b/tests/providers/test_state.py @@ -1,8 +1,11 @@ +import asyncio +import random + import pytest from that_depends import BaseContainer from that_depends.exceptions import StateNotInitializedError -from that_depends.providers.state import State +from that_depends.providers import AsyncFactory, Factory, State async def test_state_raises_if_not_set_async() -> None: @@ -41,3 +44,36 @@ class _Container(BaseContainer): with _Container.state.init(state_value) as value: assert value == state_value assert _Container.state.resolve_sync() == state_value + + +async def test_state_correctly_manages_its_context() -> None: + async def _async_creator(x: int) -> int: + await asyncio.sleep(random.random()) + return x + + class _Container(BaseContainer): + state: State[int] = State() + dependent = Factory(lambda x: x, state.cast) + async_dependent = AsyncFactory(_async_creator, state.cast) + + async def _main(x: int) -> None: + with _Container.state.init(x): + dependent_value = await _Container.dependent.resolve() + assert dependent_value == x + async_dependent_value = await _Container.async_dependent.resolve() + assert async_dependent_value == x + new_x = x + random.randint(1, 1000) + with _Container.state.init(new_x): + dependent_value = await _Container.dependent.resolve() + assert dependent_value == new_x + async_dependent_value = await _Container.async_dependent.resolve() + assert async_dependent_value == new_x + + dependent_value = await _Container.dependent.resolve() + assert dependent_value == x + async_dependent_value = await _Container.async_dependent.resolve() + assert async_dependent_value == x + + tasks = [_main(x) for x in range(10)] + + await asyncio.gather(*tasks) From bf780155f5351bc1d99ffa8259c48f6ffe3ac5c0 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 15 Jul 2025 13:52:15 +0200 Subject: [PATCH 4/7] Added documentation for the state provider. --- docs/providers/state.md | 49 +++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 ++ 2 files changed, 51 insertions(+) create mode 100644 docs/providers/state.md diff --git a/docs/providers/state.md b/docs/providers/state.md new file mode 100644 index 00000000..4e100860 --- /dev/null +++ b/docs/providers/state.md @@ -0,0 +1,49 @@ +# State + +The `State` provider stores a value as part of a context. + +It is useful when you want to pass a value into your Container that other providers depend on. + + +## Creating a state provider + +The `State` provider does not accept any arguments when it created. +```python +from that_depends import BaseContainer, providers +class Container(BaseContainer): + my_state: providers.State[int] = providers.State() +``` + +## Initializing state + +=== "async" + ```python + + with Container.state.init(42): + print(await Container.state.resolve()) # 42 + + ``` + +=== "sync" + ```python + with Container.state.init(42): + print(Container.state.resolve_sync()) # 42 + + ``` + +> Note: If you try to resolve a `State` provider without initializing it first it will raise an `StateNotInitializedError`. + + +## Nested state + +The `State` provider will always resolve the last initialize value. + +```python +with Container.state.init(1): + print(Container.state.resolve_sync()) # 1 + + with Container.state.init(2): + print(Container.state.resolve_sync()) # 2 + + print(Container.state.resolve_sync()) # 1 +``` diff --git a/mkdocs.yml b/mkdocs.yml index 7f01f789..7ce31b69 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,8 @@ nav: - Resources: providers/resources.md - Selector: providers/selector.md - Singletons: providers/singleton.md + - State: providers/state.md + - Integrations: - FastAPI: integrations/fastapi.md - FastStream: integrations/faststream.md From 6038d6c7d3f4f794ca832bd8fb1eeb725dc63c3a Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 15 Jul 2025 14:02:51 +0200 Subject: [PATCH 5/7] Fixed documentation. --- docs/providers/state.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/providers/state.md b/docs/providers/state.md index 4e100860..98d75792 100644 --- a/docs/providers/state.md +++ b/docs/providers/state.md @@ -19,15 +19,15 @@ class Container(BaseContainer): === "async" ```python - with Container.state.init(42): - print(await Container.state.resolve()) # 42 + with Container.my_state.init(42): + print(await Container.my_state.resolve()) # 42 ``` === "sync" ```python - with Container.state.init(42): - print(Container.state.resolve_sync()) # 42 + with Container.my_state.init(42): + print(Container.my_state.resolve_sync()) # 42 ``` @@ -39,11 +39,11 @@ class Container(BaseContainer): The `State` provider will always resolve the last initialize value. ```python -with Container.state.init(1): - print(Container.state.resolve_sync()) # 1 +with Container.my_state.init(1): + print(Container.my_state.resolve_sync()) # 1 - with Container.state.init(2): - print(Container.state.resolve_sync()) # 2 + with Container.my_state.init(2): + print(Container.my_state.resolve_sync()) # 2 - print(Container.state.resolve_sync()) # 1 + print(Container.my_state.resolve_sync()) # 1 ``` From 25112ac7a7df8fd7e01f1698895e52cd258b5628 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 15 Jul 2025 14:18:25 +0200 Subject: [PATCH 6/7] Added tests for utils. --- tests/test_utils.py | 17 +++++++++++++++++ that_depends/utils.py | 13 +++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 tests/test_utils.py diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..d96dca02 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,17 @@ +from that_depends.utils import UNSET, Unset, is_set + + +def test_is_set_with_unset() -> None: + assert not is_set(UNSET) + + +def test_is_set_with_value() -> None: + assert is_set(42) + assert is_set("hello") + assert is_set([1, 2, 3]) + assert is_set(None) + + +def test_unset_is_singleton() -> None: + assert isinstance(UNSET, Unset) + assert UNSET is Unset() diff --git a/that_depends/utils.py b/that_depends/utils.py index c31f982c..dc287127 100644 --- a/that_depends/utils.py +++ b/that_depends/utils.py @@ -1,10 +1,19 @@ -from typing import TypeGuard, TypeVar +from typing import Any, ClassVar, TypeGuard, TypeVar T = TypeVar("T") -class Unset: +class _Singleton(type): + _instances: ClassVar = {} + + def __call__(cls, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401 + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] + + +class Unset(metaclass=_Singleton): """Represents an unset value.""" From 8c4670e0e82718a86ada1c210052e4faa581740f Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 15 Jul 2025 14:24:01 +0200 Subject: [PATCH 7/7] Minor docstring and type hint fixes. --- that_depends/providers/state.py | 6 +++--- that_depends/utils.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/that_depends/providers/state.py b/that_depends/providers/state.py index e1a46aea..ff0f778e 100644 --- a/that_depends/providers/state.py +++ b/that_depends/providers/state.py @@ -20,17 +20,17 @@ class State(AbstractProvider[T]): - """Provides a state that can be resolved with an optional callback.""" + """Provides a value that can be passed into the provider at runtime.""" def __init__(self) -> None: - """Initialize the State provider with an optional callback.""" + """Create a state provider.""" super().__init__() self._state: ContextVar[T | Unset] = ContextVar(f"STATE_{uuid.uuid4()}", default=UNSET) @contextmanager def init(self, state: T) -> typing.Iterator[T]: - """Initialize the state provider. + """Set the state provider's value. Args: state: value to store. diff --git a/that_depends/utils.py b/that_depends/utils.py index dc287127..545af83c 100644 --- a/that_depends/utils.py +++ b/that_depends/utils.py @@ -5,7 +5,7 @@ class _Singleton(type): - _instances: ClassVar = {} + _instances: ClassVar[dict[type, Any]] = {} def __call__(cls, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401 if cls not in cls._instances: