Skip to content

Commit aa5f923

Browse files
authored
Add mapping on aggregates (#133)
* Add Mapping[Hashable, T] collection injection * Document and test Mapping[Hashable, T]
1 parent 80e23da commit aa5f923

6 files changed

Lines changed: 372 additions & 5 deletions

File tree

docs/pages/interfaces.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,67 @@ class CacheReporter:
212212

213213
`Sequence[T]` includes the default implementation, if present, plus any qualified implementations in registration order.
214214

215+
## Inject Implementations by Qualifier
216+
217+
When you want every implementation keyed by its qualifier, request them at once with `collections.abc.Mapping[Hashable, T]`.
218+
219+
```python
220+
from collections.abc import Hashable, Mapping
221+
from dataclasses import dataclass
222+
from typing import Protocol
223+
from wireup import create_sync_container, injectable
224+
225+
226+
class Cache(Protocol):
227+
def source(self) -> str: ...
228+
229+
230+
@injectable(as_type=Cache)
231+
class InMemoryCache:
232+
def source(self) -> str:
233+
return "memory"
234+
235+
236+
@injectable(as_type=Cache, qualifier="redis")
237+
class RedisCache:
238+
def source(self) -> str:
239+
return "redis"
240+
241+
242+
@injectable
243+
@dataclass
244+
class CacheRouter:
245+
caches: Mapping[Hashable, Cache]
246+
247+
def default(self) -> Cache:
248+
return self.caches[None]
249+
```
250+
251+
`Mapping[Hashable, Cache]` includes every implementation, keyed by its qualifier. The unqualified default is keyed under `None`.
252+
253+
!!! note "Mapping Type"
254+
255+
Only `collections.abc.Mapping[Hashable, T]` is supported. Requesting `typing.Mapping[K, V]` raises
256+
`UnknownServiceRequestedError` with a hint pointing at `collections.abc.Mapping[Hashable, T]`.
257+
258+
If you want to register your own factory for a `Mapping[Hashable, T]` (e.g., with custom keys or transformation
259+
logic), wrap it in a `NewType`:
260+
261+
```python
262+
from collections.abc import Hashable, Mapping
263+
from typing import Annotated, NewType
264+
from wireup import Inject, injectable
265+
266+
CacheMap = NewType("CacheMap", Mapping[Hashable, Cache])
267+
268+
269+
@injectable
270+
def make_cache_map(
271+
default: Cache,
272+
redis: Annotated[Cache, Inject(qualifier="redis")],
273+
) -> CacheMap:
274+
return CacheMap({None: default, "redis": redis})
275+
```
215276

216277
## `as_type` with Optional Types
217278

test/unit/test_container_collection_injection.py

Lines changed: 190 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from __future__ import annotations
22

33
import typing
4-
from collections.abc import Sequence
4+
from collections.abc import Hashable, Mapping, Sequence
55
from dataclasses import dataclass
6-
from typing import Optional, Protocol
6+
from typing import Annotated, NewType, Optional, Protocol
77

88
import pytest
9-
from wireup import Injected, create_sync_container, inject_from_container, injectable
9+
from wireup import Inject, Injected, create_sync_container, inject_from_container, injectable
1010
from wireup._annotations import InjectableDeclaration
1111
from wireup.errors import UnknownServiceRequestedError, WireupError
1212
from wireup.ioc.registry import ContainerRegistry
@@ -178,3 +178,190 @@ class Consumer:
178178
match=r"uses typing\.Sequence\[.*Cache.*\], but Wireup collection injection requires collections\.abc\.Sequence\[.*Cache.*\]", # noqa: E501
179179
):
180180
create_sync_container(injectables=[MemoryCache, RedisCache, Consumer])
181+
182+
183+
def test_injects_collection_mapping_for_as_type() -> None:
184+
container = create_sync_container(injectables=[MemoryCache, RedisCache])
185+
186+
@inject_from_container(container)
187+
def handler(caches: Injected[Mapping[Hashable, Cache]]) -> dict[Hashable, str]:
188+
return {key: cache.source() for key, cache in caches.items()}
189+
190+
assert handler() == {None: "memory", "redis": "redis"}
191+
assert container.get(Mapping[Hashable, Cache]) is container.get(Mapping[Hashable, Cache])
192+
193+
194+
def test_injects_collection_mapping_for_single_implementation() -> None:
195+
container = create_sync_container(injectables=[MemoryCache])
196+
res = container.get(Mapping[Hashable, Cache])
197+
198+
assert isinstance(res, dict)
199+
assert list(res.keys()) == [None]
200+
assert res[None].source() == "memory"
201+
202+
203+
def test_default_impl_appears_under_none_key() -> None:
204+
container = create_sync_container(injectables=[MemoryCache])
205+
res = container.get(Mapping[Hashable, Cache])
206+
207+
assert res[None].source() == "memory"
208+
209+
210+
def test_mapping_works_with_only_qualified_impls() -> None:
211+
@injectable(as_type=Cache, qualifier="memory")
212+
class QualifiedMemoryCache:
213+
def source(self) -> str:
214+
return "memory"
215+
216+
container = create_sync_container(injectables=[QualifiedMemoryCache, RedisCache])
217+
res = container.get(Mapping[Hashable, Cache])
218+
219+
assert set(res.keys()) == {"memory", "redis"}
220+
assert None not in res
221+
222+
223+
def test_mapping_supports_non_string_qualifiers() -> None:
224+
@injectable(as_type=Cache, qualifier=0)
225+
class ZeroCache:
226+
def source(self) -> str:
227+
return "zero"
228+
229+
@injectable(as_type=Cache, qualifier=1)
230+
class OneCache:
231+
def source(self) -> str:
232+
return "one"
233+
234+
container = create_sync_container(injectables=[ZeroCache, OneCache])
235+
res = container.get(Mapping[Hashable, Cache])
236+
237+
assert res[0].source() == "zero"
238+
assert res[1].source() == "one"
239+
240+
241+
def test_mapping_lifetime_is_smallest_member_lifetime() -> None:
242+
@injectable(as_type=Cache, qualifier="default")
243+
class DefaultCache:
244+
def source(self) -> str:
245+
return "default"
246+
247+
@injectable(as_type=Cache, qualifier="scoped", lifetime="scoped")
248+
class ScopedCache:
249+
def source(self) -> str:
250+
return "scoped"
251+
252+
@injectable
253+
@dataclass
254+
class Consumer:
255+
caches: Mapping[Hashable, Cache]
256+
257+
with pytest.raises(
258+
WireupError,
259+
match="depends on an injectable with a 'scoped' lifetime which is not supported",
260+
):
261+
create_sync_container(injectables=[DefaultCache, ScopedCache, Consumer])
262+
263+
264+
def test_can_override_mapping_directly() -> None:
265+
container = create_sync_container(injectables=[MemoryCache, RedisCache])
266+
override_map = {"override": RedisCache()}
267+
268+
assert set(container.get(Mapping[Hashable, Cache]).keys()) == {None, "redis"}
269+
with container.override.injectable(Mapping[Hashable, Cache], new=override_map):
270+
assert container.get(Mapping[Hashable, Cache]) is override_map
271+
assert set(container.get(Mapping[Hashable, Cache]).keys()) == {None, "redis"}
272+
273+
274+
def test_explicit_mapping_registration_takes_precedence_and_warns() -> None:
275+
mapping = {"custom": RedisCache()}
276+
277+
@injectable
278+
def make_custom_mapping() -> Mapping[Hashable, Cache]:
279+
return mapping
280+
281+
with pytest.warns(FutureWarning, match=r"Mapping\[Hashable, T\] is reserved for Wireup collection injection"):
282+
container = create_sync_container(injectables=[MemoryCache, make_custom_mapping])
283+
284+
assert container.get(Mapping[Hashable, Cache]) is mapping
285+
286+
287+
def test_injects_collection_mapping_when_registration_key_comes_from_factory_return_type() -> None:
288+
@injectable
289+
def make_default_cache() -> Cache:
290+
return MemoryCache()
291+
292+
@injectable(qualifier="redis")
293+
def make_redis_cache() -> Cache:
294+
return RedisCache()
295+
296+
container = create_sync_container(injectables=[make_default_cache, make_redis_cache])
297+
res = container.get(Mapping[Hashable, Cache])
298+
299+
assert isinstance(res, dict)
300+
assert {key: cache.source() for key, cache in res.items()} == {None: "memory", "redis": "redis"}
301+
302+
303+
def test_internal_extend_does_not_create_nested_mapping_collections() -> None:
304+
registry = ContainerRegistry(impls=[InjectableDeclaration(obj=MemoryCache, lifetime="singleton")])
305+
306+
@injectable
307+
class ExtraService:
308+
pass
309+
310+
registry.extend(impls=[InjectableDeclaration(obj=ExtraService, lifetime="singleton")])
311+
312+
assert Mapping[Hashable, Mapping[Hashable, MemoryCache]] not in registry.impls
313+
314+
315+
def test_mixed_real_and_optional_compat_registrations_only_include_real_members_in_mapping() -> None:
316+
@injectable
317+
def make_optional_default_cache() -> Cache | None:
318+
return None
319+
320+
@injectable(qualifier="redis")
321+
def make_redis_cache() -> Cache:
322+
return RedisCache()
323+
324+
container = create_sync_container(injectables=[make_optional_default_cache, make_redis_cache])
325+
326+
assert container.get(Mapping[Hashable, Cache]) == {"redis": container.get(Cache, qualifier="redis")}
327+
328+
329+
def test_typing_mapping_is_not_registered() -> None:
330+
container = create_sync_container(injectables=[MemoryCache, RedisCache])
331+
332+
with pytest.raises(
333+
UnknownServiceRequestedError,
334+
match=r"Wireup collection injection uses collections\.abc\.Mapping\[.*Cache.*\], not typing\.Mapping\[.*Cache.*\]", # noqa: E501
335+
):
336+
container.get(typing.Mapping[str, Cache])
337+
338+
339+
def test_typing_mapping_dependency_raises_helpful_error() -> None:
340+
@injectable
341+
@dataclass
342+
class Consumer:
343+
caches: typing.Mapping[str, Cache]
344+
345+
with pytest.raises(
346+
WireupError,
347+
match=r"uses typing\.Mapping\[.*Cache.*\], but Wireup collection injection requires collections\.abc\.Mapping\[.*Cache.*\]", # noqa: E501
348+
):
349+
create_sync_container(injectables=[MemoryCache, RedisCache, Consumer])
350+
351+
352+
CacheMap = NewType("CacheMap", Mapping[Hashable, Cache])
353+
354+
355+
def test_newtype_over_mapping_registers_as_distinct_collection() -> None:
356+
@injectable
357+
def make_cache_map(
358+
default: Cache,
359+
redis: Annotated[Cache, Inject(qualifier="redis")],
360+
) -> CacheMap:
361+
return CacheMap({None: default, "redis": redis})
362+
363+
container = create_sync_container(injectables=[MemoryCache, RedisCache, make_cache_map])
364+
res = container.get(CacheMap)
365+
366+
assert isinstance(res, dict)
367+
assert {key: cache.source() for key, cache in res.items()} == {None: "memory", "redis": "redis"}

test/unit/test_container_creation.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,26 @@ class Bar:
8080
wireup.create_sync_container(injectables=[FooImpl, Bar])
8181

8282

83+
def test_checks_typing_mapping_dependency_uses_helpful_error() -> None:
84+
class Foo(Protocol): ...
85+
86+
@wireup.injectable(as_type=Foo)
87+
class FooImpl:
88+
pass
89+
90+
@wireup.injectable
91+
@dataclass
92+
class Bar:
93+
foo: typing.Mapping[str, Foo]
94+
95+
with pytest.raises(
96+
WireupError,
97+
match=r"Parameter 'foo' of Type test\.unit\.test_container_creation\.Bar uses typing\.Mapping\[.*Foo.*\], "
98+
r"but Wireup collection injection requires collections\.abc\.Mapping\[.*Foo.*\]\.",
99+
):
100+
wireup.create_sync_container(injectables=[FooImpl, Bar])
101+
102+
83103
def test_lifetimes_match() -> None:
84104
@wireup.injectable(lifetime="scoped")
85105
class ScopedService: ...

wireup/errors.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import sys
4+
from collections.abc import Hashable
5+
from collections.abc import Mapping as AbcMapping
46
from collections.abc import Sequence as AbcSequence
57
from typing import TYPE_CHECKING, Any
68

@@ -24,6 +26,20 @@ def try_get_wireup_sequence_replacement(type_hint: Any) -> Any | None:
2426
return AbcSequence[args] if args else AbcSequence # type:ignore[valid-type]
2527

2628

29+
def try_get_wireup_mapping_replacement(type_hint: Any) -> Any | None:
30+
"""Return collection type replacement for typing.Mapping[K, V] if applicable."""
31+
if getattr(type_hint, "__module__", None) != "typing":
32+
return None
33+
34+
if get_origin(type_hint) is not AbcMapping:
35+
return None
36+
37+
args = get_args(type_hint)
38+
if not args:
39+
return AbcMapping
40+
return AbcMapping[Hashable, args[1]] # type:ignore[valid-type]
41+
42+
2743
class WireupError(Exception):
2844
"""Base type for all exceptions raised by wireup."""
2945

@@ -104,6 +120,13 @@ def __init__(self, klass: Any, qualifier: Qualifier | None = None) -> None:
104120
)
105121
return
106122

123+
if suggested_replacement_type := try_get_wireup_mapping_replacement(klass):
124+
super().__init__(
125+
f"Cannot create unknown injectable {format_name(klass, qualifier)}. "
126+
f"Wireup collection injection uses {suggested_replacement_type!r}, not {klass!r}."
127+
)
128+
return
129+
107130
msg = (
108131
f"Cannot create unknown injectable {format_name(klass, qualifier)}. "
109132
"Make sure it is registered with the container."

0 commit comments

Comments
 (0)