Skip to content

Commit 1003c0f

Browse files
authored
Merge pull request #45 from modern-python/41-feature-attrgetter-provider
add attr getter provider
2 parents 2190756 + 80c3623 commit 1003c0f

6 files changed

Lines changed: 201 additions & 0 deletions

File tree

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
usage/key-concepts
1212
usage/inject-factories
1313
usage/context-data
14+
usage/application-settings
1415
usage/migration-from-that-depends
1516
1617
.. toctree::

docs/usage/application-settings.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Application settings
2+
For example, you have application settings in `pydantic_settings`
3+
```python
4+
import pydantic_settings
5+
6+
7+
class Settings(pydantic_settings.BaseSettings):
8+
service_name: str = "FastAPI template"
9+
debug: bool = False
10+
...
11+
```
12+
13+
You can register settings as `Singleton` in DI container
14+
15+
```python
16+
from modern_di import BaseGraph, Scope, providers
17+
18+
19+
class DIContainer(BaseGraph):
20+
settings = providers.Singleton(Scope.APP, Settings)
21+
some_factory = providers.Factory(Scope.APP, SomeFactory, service_name=settings.cast.service_name)
22+
```
23+
24+
And when `some_factory` is resolved it will receive `service_name` attribute from `Settings`

packages/modern-di/modern_di/helpers/__init__.py

Whitespace-only changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import typing
2+
from operator import attrgetter
3+
4+
5+
def get_value_from_object_by_dotted_path(obj: typing.Any, path: str) -> typing.Any: # noqa: ANN401
6+
attribute_getter = attrgetter(path)
7+
return attribute_getter(obj)

packages/modern-di/modern_di/providers/abstract.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
import typing
55
import uuid
66

7+
from typing_extensions import override
8+
79
from modern_di import Container
10+
from modern_di.helpers.attr_getter_helpers import get_value_from_object_by_dotted_path
811

912

1013
T_co = typing.TypeVar("T_co", covariant=True)
@@ -36,6 +39,22 @@ def _check_providers_scope(self, providers: typing.Iterable[typing.Any]) -> None
3639
msg = "Scope of dependency cannot be more than scope of dependent"
3740
raise RuntimeError(msg)
3841

42+
def __getattr__(self, attr_name: str) -> typing.Any: # noqa: ANN401
43+
"""Get an attribute from the resolve object.
44+
45+
Args:
46+
attr_name: name of attribute to get.
47+
48+
Returns:
49+
An `AttrGetter` provider that will get the attribute after resolving the current provider.
50+
51+
"""
52+
if attr_name.startswith("_"):
53+
msg = f"'{type(self)}' object has no attribute '{attr_name}'"
54+
raise AttributeError(msg)
55+
56+
return AttrGetter(provider=self, attr_name=attr_name)
57+
3958

4059
class AbstractOverrideProvider(AbstractProvider[T_co], abc.ABC):
4160
def override(self, override_object: object, container: Container) -> None:
@@ -87,3 +106,40 @@ async def _async_build_creator(self, container: Container) -> typing.Any: # noq
87106
*await self._async_resolve_args(container),
88107
**await self._async_resolve_kwargs(container),
89108
)
109+
110+
111+
class AttrGetter(AbstractProvider[T_co]):
112+
"""Provides an attribute after resolving the wrapped provider."""
113+
114+
__slots__ = [*AbstractProvider.BASE_SLOTS, "_attrs", "_provider"]
115+
116+
def __init__(self, provider: AbstractProvider[T_co], attr_name: str) -> None:
117+
"""Create a new AttrGetter instance.
118+
119+
Args:
120+
provider: provider to wrap.
121+
attr_name: attribute name to resolve when the provider is resolved.
122+
123+
"""
124+
super().__init__(scope=provider.scope)
125+
self._provider = provider
126+
self._attrs = [attr_name]
127+
128+
def __getattr__(self, attr: str) -> "AttrGetter[T_co]":
129+
if attr.startswith("_"):
130+
msg = f"'{type(self)}' object has no attribute '{attr}'"
131+
raise AttributeError(msg)
132+
self._attrs.append(attr)
133+
return self
134+
135+
@override
136+
async def async_resolve(self, container: Container) -> typing.Any:
137+
resolved_provider_object = await self._provider.async_resolve(container)
138+
attribute_path = ".".join(self._attrs)
139+
return get_value_from_object_by_dotted_path(resolved_provider_object, attribute_path)
140+
141+
@override
142+
def sync_resolve(self, container: Container) -> typing.Any:
143+
resolved_provider_object = self._provider.sync_resolve(container)
144+
attribute_path = ".".join(self._attrs)
145+
return get_value_from_object_by_dotted_path(resolved_provider_object, attribute_path)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import typing
2+
from dataclasses import dataclass, field
3+
4+
import pytest
5+
from modern_di import Container, Scope, providers
6+
7+
8+
@dataclass
9+
class Nested2:
10+
some_const = 144
11+
12+
13+
@dataclass
14+
class Nested1:
15+
nested2_attr: Nested2 = field(default_factory=Nested2)
16+
17+
18+
@dataclass
19+
class Settings:
20+
some_str_value: str = "some_string_value"
21+
some_int_value: int = 3453621
22+
nested1_attr: Nested1 = field(default_factory=Nested1)
23+
24+
25+
async def yield_settings_async() -> typing.AsyncIterator[Settings]:
26+
yield Settings()
27+
28+
29+
def yield_settings_sync() -> typing.Iterator[Settings]:
30+
yield Settings()
31+
32+
33+
@dataclass
34+
class NestingTestDTO: ...
35+
36+
37+
@pytest.fixture(
38+
params=[
39+
providers.Resource(Scope.APP, yield_settings_sync),
40+
providers.Singleton(Scope.APP, Settings),
41+
providers.Object(Scope.APP, Settings()),
42+
providers.Factory(Scope.APP, Settings),
43+
providers.Selector(Scope.APP, lambda: "sync", sync=providers.Factory(Scope.APP, Settings)),
44+
]
45+
)
46+
def some_sync_settings_provider(request: pytest.FixtureRequest) -> providers.AbstractProvider[Settings]:
47+
return typing.cast(providers.AbstractProvider[Settings], request.param)
48+
49+
50+
@pytest.fixture(
51+
params=[
52+
providers.Resource(Scope.APP, yield_settings_async),
53+
]
54+
)
55+
def some_async_settings_provider(request: pytest.FixtureRequest) -> providers.AbstractProvider[Settings]:
56+
return typing.cast(providers.AbstractProvider[Settings], request.param)
57+
58+
59+
@pytest.fixture
60+
async def di_container() -> typing.AsyncIterator[Container]:
61+
di_container_: typing.Final = Container(scope=Scope.APP)
62+
async with di_container_:
63+
yield di_container_
64+
65+
66+
def test_attr_getter_with_zero_attribute_depth_sync(
67+
some_sync_settings_provider: providers.AbstractProvider[Settings], di_container: Container
68+
) -> None:
69+
attr_getter = some_sync_settings_provider.some_str_value
70+
assert attr_getter.sync_resolve(di_container) == Settings().some_str_value
71+
72+
73+
async def test_attr_getter_with_zero_attribute_depth_async(
74+
some_async_settings_provider: providers.AbstractProvider[Settings], di_container: Container
75+
) -> None:
76+
attr_getter = some_async_settings_provider.some_str_value
77+
assert await attr_getter.async_resolve(di_container) == Settings().some_str_value
78+
79+
80+
def test_attr_getter_with_more_than_zero_attribute_depth_sync(
81+
some_sync_settings_provider: providers.AbstractProvider[Settings], di_container: Container
82+
) -> None:
83+
attr_getter = some_sync_settings_provider.nested1_attr.nested2_attr.some_const
84+
assert attr_getter.sync_resolve(di_container) == Nested2().some_const
85+
86+
87+
async def test_attr_getter_with_more_than_zero_attribute_depth_async(
88+
some_async_settings_provider: providers.AbstractProvider[Settings], di_container: Container
89+
) -> None:
90+
attr_getter = some_async_settings_provider.nested1_attr.nested2_attr.some_const
91+
assert await attr_getter.async_resolve(di_container) == Nested2().some_const
92+
93+
94+
def test_attr_getter_with_invalid_attribute_sync(
95+
some_sync_settings_provider: providers.AbstractProvider[Settings],
96+
) -> None:
97+
with pytest.raises(AttributeError):
98+
some_sync_settings_provider.nested1_attr.nested2_attr.__some_private__ # noqa: B018
99+
with pytest.raises(AttributeError):
100+
some_sync_settings_provider.nested1_attr.__another_private__ # noqa: B018
101+
with pytest.raises(AttributeError):
102+
some_sync_settings_provider.nested1_attr._final_private_ # noqa: B018
103+
104+
105+
async def test_attr_getter_with_invalid_attribute_async(
106+
some_async_settings_provider: providers.AbstractProvider[Settings],
107+
) -> None:
108+
with pytest.raises(AttributeError):
109+
some_async_settings_provider.nested1_attr.nested2_attr.__some_private__ # noqa: B018
110+
with pytest.raises(AttributeError):
111+
some_async_settings_provider.nested1_attr.__another_private__ # noqa: B018
112+
with pytest.raises(AttributeError):
113+
some_async_settings_provider.nested1_attr._final_private_ # noqa: B018

0 commit comments

Comments
 (0)