Skip to content

Commit 839b719

Browse files
authored
feat: add provider.derive(fn) to inject a value computed from another provider (#733)
settings.derive(lambda s: s.db_dsn) is a provider of fn(value). Type checkers infer s from the source. It is never cached, has no scope of its own and resolves at its source's effective scope, so it works inline in kwargs in any group and an override of the source reaches it. An exception raised by fn propagates unchanged, with a note naming the derived provider. The class is private (_Derived) and lives in providers/abstract.py, because the derive method on AbstractProvider needs it, and a separate module would create an import cycle. Closes #730
1 parent 5644031 commit 839b719

11 files changed

Lines changed: 401 additions & 17 deletions

File tree

‎CONTEXT.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ One band in the container hierarchy.
2323
_Avoid_: lifetime, layer
2424

2525
**Effective scope**:
26-
The band a provider actually resolves in, once any redirection to another provider is followed. The
27-
same as the provider's own scope unless it redirects, where the provider at the end of the chain
28-
governs.
26+
The band a provider actually resolves in, once any redirection to another provider, or a derived
27+
provider's source, is followed. The same as the provider's own scope unless it redirects or derives,
28+
where the provider at the end of the chain governs.
2929
_Avoid_: terminal scope
3030

3131
**Group**:
@@ -45,6 +45,11 @@ _Avoid_: mock, patch
4545
The type a provider is registered under.
4646
_Avoid_: registered type, return type
4747

48+
**Derived provider**:
49+
A provider of a function of one source provider's value, built by `provider.derive(fn)`. Never
50+
cached, with its source's effective scope.
51+
_Avoid_: computed (implies caching), selector (picks a provider by key in other libraries), attribute getter
52+
4853
**Reference-only provider**:
4954
A provider with no bound type, resolvable only by passing the provider itself.
5055
_Avoid_: unbound provider, anonymous provider

‎docs/migration/from-dependency-injector.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ Use this table as the index for the rest of the guide. "No direct equivalent" ro
8282
| `Delegate` and the `Delegated*` providers (`DelegatedFactory`, `DelegatedSingleton`, ...) | A creator parameter annotated `Container`, resolving the provider later | [§4](#4-migrate-the-dependency-graph) |
8383
| `Selector` | No direct equivalent | [§11](#11-no-direct-equivalent) |
8484
| `Aggregate` / `FactoryAggregate` | No direct equivalent | [§11](#11-no-direct-equivalent) |
85-
| `.provided` (attribute / item / method-call access on a provider) | No direct equivalent | [§11](#11-no-direct-equivalent) |
85+
| `.provided` (attribute / item / method-call access on a provider) | `provider.derive(fn)`, e.g. `provider.derive(lambda value: value.attr)` | [Derived values](../providers/factories.md#derived-values) |
8686
| `@inject` + `Provide[...]` + `container.wire(modules=[...])` (web) | `FromDI(T)` from the framework integration | [§6](#6-wiring-replacement), [§8](#8-framework-integration-and-routes) |
8787
| `@inject` + `Provide[...]` + `container.wire(modules=[...])` (non-web) | Explicit `container.resolve(T)` | [§6](#6-wiring-replacement) |
8888
| `DeclarativeContainer` | `Group` (schema) + `Container(groups=[...])` (runtime), checked with `.validate()` | [§2](#2-key-conceptual-shifts) |
@@ -358,7 +358,6 @@ A handful of `dependency-injector` features have no direct port. Workarounds:
358358
- For `ThreadLocalSingleton`, register an uncached `Factory` whose creator reads the object from a module-level `threading.local()` and creates and stores it there on a thread's first call. A cached `Factory` can't do this, because it caches one object for the whole container.
359359
- For `Selector`, write a creator function that takes whatever the selector depended on and returns the chosen object. If the choice is static (e.g. one implementation per environment), `Alias` may be cleaner.
360360
- For `Aggregate` / `FactoryAggregate`, resolve each candidate provider individually (by type or by reference) and dispatch on the key yourself in a small creator function, rather than injecting the whole aggregate object.
361-
- For `.provided` (attribute / item / method-call access on a provider, e.g. `service.provided.value`), resolve the parent inside the consuming creator and access the attribute, item, or method result there, or expose a dedicated `Factory` whose creator returns just that piece.
362361
- `modern-di` has no general-purpose injection decorator to replace `@inject` + `Provide[...]` on non-framework functions. Call `container.resolve(T)` explicitly at the call site, or expose the function through a framework integration and use `FromDI(T)`.
363362
- Providers are not partially applied callables. In `dependency-injector`, calling a provider with extra arguments (`container.some_factory(extra_arg)`) merges them with the declared ones for that one call. `modern-di`'s `resolve()` and `resolve_provider()` take no arguments, so every creator argument must come from its type, from `kwargs`, or from a default. Move a static value into `kwargs=`. For a value that varies per call site, use a `ContextProvider` set on the container you resolve from, or a deeper-scoped dependency.
364363

‎docs/migration/from-that-depends.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Use this table as the index for the rest of the guide.
7373
| `List` | `providers.Factory` with a creator that returns a list | [§4](#4-migrate-the-dependency-graph) |
7474
| `Dict` | `providers.Factory` with a creator that returns a dict | [§4](#4-migrate-the-dependency-graph) |
7575
| `Selector` | No direct equivalent | [§9](#9-no-direct-equivalent) |
76-
| `AttrGetter` (`provider.attr`) | No direct equivalent | [§9](#9-no-direct-equivalent) |
76+
| `AttrGetter` (`provider.attr`) | `provider.derive(lambda value: value.attr)` | [Derived values](../providers/factories.md#derived-values) |
7777
| `ThreadLocalSingleton` | No direct equivalent | [§9](#9-no-direct-equivalent) |
7878
| `State` | `ContextProvider` + `set_context` | [§5](#5-context-resources-and-request-scope) |
7979
| `provider.bind(T)` | `providers.Factory(..., bound_type=T)`; add an `Alias` per extra type | [§4](#4-migrate-the-dependency-graph) |
@@ -367,7 +367,6 @@ On routes, `FromDI(T)` replaces both `fastapi.Depends(Container.provider)` and `
367367
A handful of `that-depends` features have no direct port. Workarounds:
368368

369369
- For `Selector`, write a creator function that takes whatever the selector depended on and returns the chosen object. If the choice is static (e.g. one implementation per environment), `Alias` may be cleaner.
370-
- For `AttrGetter` (`provider.attr` syntax), resolve the parent inside the consuming creator and access the attribute there, or expose a dedicated `Factory` whose creator returns the attribute.
371370
- For `ThreadLocalSingleton`, register an uncached `Factory` whose creator reads the object from a module-level `threading.local()` and creates and stores it there on a thread's first call. A cached `Factory` can't do this, because it caches one object for the whole container.
372371
- `modern-di` has no general-purpose injection decorator to replace `@inject` + `Provide[...]` on non-framework functions. Call `container.resolve(T)` explicitly at the call site, or expose the function through a framework integration and use `FromDI(T)`.
373372
- For `BaseContainer.resolve(func)` and `resolver(func)`, which call `func` with its parameters filled by name, register `func` as a `Factory` with `bound_type=None` and call `container.resolve_provider(...)` on it. Its parameters are then wired by type, not by name.

‎docs/providers/factories.md‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ Values for creator parameters, by name. A parameter named in `kwargs` is not wir
110110
- A plain value is passed as is.
111111
- A provider is resolved when the factory resolves, and its result is passed. Use it to pick a
112112
specific provider for one parameter, or to wire arguments under `skip_creator_parsing=True`.
113+
To pass a piece of another provider's value, such as one settings field, use
114+
[`derive`](#derived-values).
113115
- A name the creator does not have raises `UnknownFactoryKwargError` when the `Factory` is
114116
declared.
115117

@@ -155,6 +157,59 @@ Without an explicit `bound_type`, `Factory(...)` emits a `UserWarning` saying th
155157
resolved by type. Pass `bound_type=SomeType` to register it, or `bound_type=None` to confirm it is
156158
resolved by reference only.
157159

160+
## Derived values
161+
162+
`provider.derive(fn)` returns a provider of `fn(value)`, where `value` is what `provider` resolves
163+
to. Use it in `kwargs` to pass a field of a settings object without writing a creator function for
164+
the component:
165+
166+
```python
167+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
168+
class Settings:
169+
db_dsn: str = "postgresql://localhost/app"
170+
db_pool_size: int = 5
171+
172+
173+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
174+
class Database:
175+
dsn: str
176+
pool_size: int
177+
178+
179+
class AppDependencies(Group):
180+
settings = providers.Factory(Settings, cache=True)
181+
database = providers.Factory(
182+
Database,
183+
kwargs={
184+
"dsn": settings.derive(lambda s: s.db_dsn),
185+
"pool_size": settings.derive(lambda s: s.db_pool_size),
186+
},
187+
cache=True,
188+
)
189+
190+
191+
container = Container(groups=[AppDependencies])
192+
container.override(AppDependencies.settings, Settings(db_dsn="postgresql://test/app"))
193+
assert container.resolve(Database).dsn == "postgresql://test/app"
194+
```
195+
196+
Type checkers infer `s` from `settings`, so mypy, pyright and ty all flag `s.db_dns`. `fn` can read
197+
an attribute or an item, or call a method, and `derive` calls chain.
198+
199+
A derived provider:
200+
201+
- Has no scope of its own. It resolves at its source's scope, and a group's default scope does not
202+
apply to it, so it is valid in `kwargs` in any group. `validate()` checks the consuming factory
203+
against the source's scope.
204+
- Is never cached: `fn` runs on every resolve, so an override of the source always reaches it. If
205+
the value is worth caching, give it a `Factory`.
206+
- Is not registered under any type. Use it in `kwargs`, or resolve it with
207+
`container.resolve_provider`.
208+
- Can be overridden itself, like any provider.
209+
210+
An exception raised by `fn` propagates unchanged, with a note naming the derived provider and the
211+
line `fn` is defined on.
212+
158213
## Resolution behavior
159214

160215
### Union type parameters
@@ -269,3 +324,4 @@ assert connection is container.resolve(Connection) # and is cached now
269324
- [Lifecycle](lifecycle.md): finalizers, closing, and `validate()`.
270325
- [Scopes](scopes.md): which container caches an instance.
271326
- [Alias](alias.md): making one type resolve to another type's provider, such as a `Protocol` to its implementation.
327+
- [Testing with overrides](../recipes/testing-overrides.md): replacing a settings object in tests.

‎modern_di/dependency_graph.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from modern_di import exceptions
1313
from modern_di.exceptions.rendering import provider_step
14-
from modern_di.providers.abstract import AbstractProvider
14+
from modern_di.providers.abstract import AbstractProvider, _Derived
1515
from modern_di.providers.alias import Alias
1616
from modern_di.providers.container_provider import container_provider
1717
from modern_di.providers.factory import Factory
@@ -88,6 +88,8 @@ def dependencies_of(
8888
source_type=source_type, reference_only_providers=reference_only_sources(source_type, registry)
8989
)
9090
return {"source": source}
91+
if type(provider) is _Derived:
92+
return {"source": provider._source} # noqa: SLF001
9193
return {}
9294

9395

@@ -110,9 +112,19 @@ def terminal_chain(
110112
return chain
111113

112114

115+
def scope_chain(
116+
provider: "AbstractProvider[typing.Any]", registry: "ProvidersRegistry"
117+
) -> "list[AbstractProvider[typing.Any]]":
118+
"""Extend ``terminal_chain`` through derived providers to their sources; the last one owns the scope."""
119+
chain = terminal_chain(provider, registry)
120+
while type(chain[-1]) is _Derived:
121+
chain += terminal_chain(chain[-1]._source, registry) # noqa: SLF001
122+
return chain
123+
124+
113125
def effective_scope(provider: "AbstractProvider[typing.Any]", registry: "ProvidersRegistry") -> enum.IntEnum:
114-
"""Return the scope a provider actually resolves at: its terminal's, once redirects are followed."""
115-
return terminal_chain(provider, registry)[-1].scope
126+
"""Return the scope a provider actually resolves at: that of the last provider in its `scope_chain`."""
127+
return scope_chain(provider, registry)[-1].scope
116128

117129

118130
def redirect_hops(
@@ -210,7 +222,7 @@ def collect_errors(registry: "ProvidersRegistry") -> list[Exception]:
210222
for event in walk(roots=roots, registry=registry):
211223
if type(event) is Edge:
212224
parent, name, dep = event
213-
dependency_chain = terminal_chain(dep, registry)
225+
dependency_chain = scope_chain(dep, registry)
214226
if dependency_chain[-1] is container_provider:
215227
continue
216228
dependency_scope = dependency_chain[-1].scope

‎modern_di/exceptions/resolution.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
from modern_di.providers.abstract import AbstractProvider
1919

2020

21+
def derived_note(step: ResolutionStep) -> str:
22+
"""Return the note for an exception raised by the function of the derived provider drawn as ``step``."""
23+
label = f"{step.name} ({step.location})" if step.location else step.name
24+
return f"raised by the function of {label}"
25+
26+
2127
class ResolutionError(ModernDIError):
2228
"""Base class for errors raised while resolving a provider.
2329

‎modern_di/providers/abstract.py‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class AbstractProvider(typing.Generic[types.T_co]):
2929
)
3030

3131
_takes_group_scope: typing.ClassVar[bool] = True
32-
"""Whether a Group-level default scope applies. False when the effective scope is derived."""
32+
"""Whether a Group-level default scope applies. False when the effective scope comes from another provider."""
3333

3434
def __init_subclass__(cls, **kwargs: object) -> None:
3535
if cls.__module__.partition(".")[0] != "modern_di":
@@ -46,16 +46,19 @@ def __init__(
4646
scope: enum.IntEnum | types.UnsetType,
4747
bound_type: type | types.UnsetType | None,
4848
inferred_bound_type: type | None = None,
49+
call_depth: int = 2,
4950
) -> None:
50-
"""Set the shared state; an unset ``bound_type`` falls back to ``inferred_bound_type``."""
51+
"""Set the shared state; an unset ``bound_type`` falls back to ``inferred_bound_type``.
52+
53+
``call_depth`` is the frame, counted from here, of the user code that built the provider.
54+
"""
5155
self._explicit_scope: enum.IntEnum | None = scope if isinstance(scope, enum.IntEnum) else None
5256
self._group_claim: tuple[enum.IntEnum, str] | None = None
5357
self._registered = False
5458
self._bound_type = inferred_bound_type if isinstance(bound_type, types.UnsetType) else bound_type
5559
self._provider_id = next(_provider_id_counter)
5660
self._declared_as: str | None = None
57-
# Frame 2 is whoever called the concrete provider's __init__, which calls this one directly.
58-
caller = sys._getframe(2) # noqa: SLF001
61+
caller = sys._getframe(call_depth) # noqa: SLF001
5962
self._call_module: str | None = caller.f_globals.get("__name__")
6063
self._call_line = caller.f_lineno
6164

@@ -127,3 +130,40 @@ def display_name(self) -> str:
127130
def definition_site(self) -> str | None:
128131
"""``module:line`` of the provider's declaration when known; None by default (no creator)."""
129132
return None
133+
134+
def derive(self, fn: typing.Callable[[types.T_co], types.T], /) -> "AbstractProvider[types.T]":
135+
"""Return a provider of ``fn(value)``, where ``value`` is what this provider resolves to.
136+
137+
The derived provider is never cached and has no scope of its own: it resolves at this
138+
provider's scope, so it is valid in ``kwargs`` of any group, and an override of this provider
139+
reaches it. It is not registered under any type. An exception raised by ``fn`` propagates
140+
unchanged, with a note naming the derived provider.
141+
"""
142+
return _Derived(self, fn)
143+
144+
145+
class _Derived(AbstractProvider[types.T_co]):
146+
__slots__ = ("_fn", "_source")
147+
148+
_takes_group_scope = False
149+
150+
def __init__(self, source: AbstractProvider[typing.Any], fn: typing.Callable[[typing.Any], types.T_co]) -> None:
151+
super().__init__(scope=types.UNSET, bound_type=None, call_depth=3)
152+
self._source = source
153+
self._fn = fn
154+
155+
def __repr__(self) -> str:
156+
return f"{self.display_name}({self._fn!r})"
157+
158+
@property
159+
def display_name(self) -> str:
160+
return f"{self._source.display_name}.derive"
161+
162+
@property
163+
def definition_site(self) -> str | None:
164+
"""``fn``'s declaration site as ``module:line``; else the ``derive`` call site."""
165+
module = getattr(self._fn, "__module__", None)
166+
code = getattr(self._fn, "__code__", None)
167+
if module is None or code is None:
168+
return self._call_site
169+
return f"{module}:{code.co_firstlineno}"

‎modern_di/resolver_compiler.py‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@
2323

2424
from modern_di import exceptions, types
2525
from modern_di.cache import CacheItem
26-
from modern_di.dependency_graph import redirect_hops, reference_only_sources, terminal_chain
26+
from modern_di.dependency_graph import effective_scope, redirect_hops, reference_only_sources, terminal_chain
2727
from modern_di.exceptions.rendering import UNKNOWN_SCOPE, provider_step
28-
from modern_di.providers.abstract import AbstractProvider
28+
from modern_di.exceptions.resolution import derived_note
29+
from modern_di.providers.abstract import AbstractProvider, _Derived
2930
from modern_di.providers.alias import Alias
3031
from modern_di.providers.container_provider import container_provider
3132
from modern_di.providers.context_provider import ContextProvider
@@ -52,6 +53,8 @@ def compile_resolver(provider: "AbstractProvider[typing.Any]", registry: "Provid
5253
return _compile_factory(provider, registry)
5354
if type(provider) is Alias:
5455
return _compile_alias(provider, registry)
56+
if type(provider) is _Derived:
57+
return _compile_derived(provider, registry)
5558
if provider is container_provider:
5659
return _resolve_to_container
5760
if type(provider) is ContextProvider:
@@ -289,6 +292,29 @@ def resolve(_: "Container") -> typing.Any:
289292
return resolve
290293

291294

295+
def _compile_derived(d: "_Derived[typing.Any]", registry: "ProvidersRegistry") -> "Resolver":
296+
"""Compile to ``fn`` over the source's resolver; the source navigates to its own scope."""
297+
source = d._source
298+
fn = d._fn
299+
source_resolver = registry.resolver_for(source)
300+
step = provider_step(d, effective_scope(d, registry))
301+
hops = redirect_hops(source, registry)
302+
303+
def resolve(container: "Container") -> typing.Any:
304+
try:
305+
value = source_resolver(container)
306+
except exceptions.ResolutionError as exc:
307+
exc._prepend_step(step, *hops)
308+
raise
309+
try:
310+
return fn(value)
311+
except Exception as exc:
312+
exc.add_note(derived_note(step))
313+
raise
314+
315+
return resolve
316+
317+
292318
def _resolve_to_container(container: "Container") -> typing.Any:
293319
return container
294320

0 commit comments

Comments
 (0)