Skip to content

Commit 1973660

Browse files
committed
feat: Add InjectByTag support and global TagRegistry for testing
BREAKING CHANGE: Tag registry moved from per-container to global EllarInjector.tag_registry Features: - Add InjectByTag dependency resolution in Test.create_test_module() - Implement get_type_from_tag() to resolve tagged providers from module metadata - Add import string support for application_module parameter (e.g., app.module:ApplicationModule) - Add __repr__ and __str__ methods to ProviderConfig for better debugging Refactoring: - Extract tag management into new TagRegistry class - Make TagRegistry a class attribute of EllarInjector for global tag storage - Remove Container._bindings_by_tag and Container.get_interface_by_tag() - Tags now registered globally instead of per-container hierarchy Documentation: - Update testing.md with comprehensive usage examples - Add InjectByTag testing patterns - Document both override methods (override_provider and mock modules) - Add practical testing patterns (integration, partial mocking, isolation) - Include nested dependencies and error message examples
1 parent b9b8a84 commit 1973660

File tree

10 files changed

+1731
-73
lines changed

10 files changed

+1731
-73
lines changed

docs/basics/testing.md

Lines changed: 504 additions & 21 deletions
Large diffs are not rendered by default.

ellar/app/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ async def add_authentication_schemes(
279279

280280
def get_module_loaders(self) -> t.Generator[ModuleTemplating, None, None]:
281281
for loader in self._injector.get_templating_modules().values():
282-
yield loader
282+
yield t.cast(ModuleTemplating, loader)
283283

284284
def _create_jinja_environment(self) -> Environment:
285285
# TODO: rename to `create_jinja_environment`

ellar/core/modules/config.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,17 +147,17 @@ def exports(self) -> t.List[t.Type]:
147147
return list(reflect.get_metadata(MODULE_METADATA.EXPORTS, self.module) or [])
148148

149149
@cached_property
150-
def providers(self) -> t.Dict[t.Type, t.Type]:
150+
def providers(self) -> t.Dict[t.Type, ProviderConfig]:
151151
_providers = list(
152152
reflect.get_metadata(MODULE_METADATA.PROVIDERS, self.module) or []
153153
)
154-
res = {}
154+
res: t.Dict[t.Type, ProviderConfig] = {}
155155
for item in _providers:
156156
if isinstance(item, ProviderConfig):
157-
res.update({item: item.get_type()})
157+
res.update({item.get_type(): item})
158158
else:
159-
res.update({item: item})
160-
return res # type:ignore[return-value]
159+
res.update({item: ProviderConfig(item)})
160+
return res
161161

162162
@property
163163
def is_ready(self) -> bool:

ellar/di/injector/container.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ class Container(InjectorBinder):
3333
"injector",
3434
"_auto_bind",
3535
"_bindings",
36-
"_bindings_by_tag",
3736
"parent",
3837
"_aliases",
3938
"_exact_aliases",
@@ -43,7 +42,6 @@ class Container(InjectorBinder):
4342

4443
def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
4544
super().__init__(*args, **kwargs)
46-
self._bindings_by_tag: t.Dict[str, t.Type[t.Any]] = {}
4745

4846
@t.no_type_check
4947
def create_binding(
@@ -58,22 +56,13 @@ def create_binding(
5856
scope = scope.scope
5957
return Binding(interface, provider, scope)
6058

61-
def get_interface_by_tag(self, tag: str) -> t.Type[t.Any]:
62-
interface = self._bindings_by_tag.get(tag)
63-
if interface:
64-
return interface
65-
if isinstance(self.parent, Container):
66-
return self.parent.get_interface_by_tag(tag)
67-
68-
raise UnsatisfiedRequirement(None, t.cast(t.Any, tag))
69-
7059
def register_binding(
7160
self, interface: t.Type, binding: Binding, tag: t.Optional[str] = None
7261
) -> None:
7362
self._bindings[interface] = binding
7463

7564
if tag:
76-
self._bindings_by_tag[tag] = interface
65+
self.injector.tag_registry.register(tag, interface)
7766

7867
@t.no_type_check
7968
def register(

ellar/di/injector/ellar_injector.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
1+
from __future__ import annotations
2+
13
import sys
24
import typing as t
35
from functools import cached_property
46

57
from ellar.di.constants import MODULE_REF_TYPES, Tag, request_context_var
68
from ellar.di.injector.tree_manager import ModuleTreeManager
79
from ellar.di.logger import log
10+
from ellar.di.providers import InstanceProvider, Provider
11+
from ellar.di.types import T
812
from injector import Injector, Scope, ScopeDecorator
913
from typing_extensions import Annotated
1014

11-
from ..providers import InstanceProvider, Provider
12-
from ..types import T
1315
from .container import Container
16+
from .tag_registry import TagRegistry
1417

1518
if t.TYPE_CHECKING: # pragma: no cover
1619
from ellar.core.modules import (
1720
ModuleBase,
21+
ModuleForwardRef,
1822
ModuleRefBase,
19-
ModuleTemplateRef,
23+
ModuleSetup,
2024
)
2125

2226

@@ -62,6 +66,9 @@ class EllarInjector(Injector):
6266
"owner",
6367
)
6468

69+
# Global tag registry shared across all injector instances
70+
tag_registry: t.ClassVar[TagRegistry] = TagRegistry()
71+
6572
def __init__(
6673
self,
6774
auto_bind: bool = True,
@@ -101,9 +108,11 @@ def get_module(self, module: t.Type) -> t.Optional["ModuleRefBase"]:
101108

102109
def get_templating_modules(
103110
self,
104-
) -> t.Dict[t.Type["ModuleBase"], "ModuleTemplateRef"]:
111+
) -> dict[
112+
type[ModuleBase] | type, ModuleRefBase | "ModuleSetup" | ModuleForwardRef
113+
]:
105114
return {
106-
item.value.module: item.value # type:ignore[misc]
115+
item.value.module: item.value
107116
for item in self.tree_manager.get_by_ref_type(MODULE_REF_TYPES.TEMPLATE)
108117
}
109118

@@ -115,7 +124,7 @@ def get(
115124
) -> T:
116125
data = _tag_info_interface(interface)
117126
if data and data.supertype is Tag:
118-
interface = self.container.get_interface_by_tag(data.tag)
127+
interface = self.tag_registry.get_interface(data.tag)
119128

120129
binding, binder = self.container.get_binding(interface)
121130
scope = binding.scope

ellar/di/injector/tag_registry.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
Tag Registry for managing global tag-to-interface mappings
3+
"""
4+
5+
import typing as t
6+
7+
from ellar.di.exceptions import UnsatisfiedRequirement
8+
9+
10+
class TagRegistry:
11+
"""
12+
Centralized registry for managing tag-to-interface mappings.
13+
14+
This ensures that all tagged providers are registered globally and accessible
15+
across the entire injector hierarchy, rather than being scoped to individual containers.
16+
"""
17+
18+
def __init__(self) -> None:
19+
self._bindings_by_tag: t.Dict[str, t.Type[t.Any]] = {}
20+
21+
def register(self, tag: str, interface: t.Type[t.Any]) -> None:
22+
"""
23+
Register a tag-to-interface mapping.
24+
25+
:param tag: The tag string
26+
:param interface: The interface/type associated with this tag
27+
"""
28+
self._bindings_by_tag[tag] = interface
29+
30+
def get_interface(self, tag: str) -> t.Type[t.Any]:
31+
"""
32+
Get the interface associated with a tag.
33+
34+
:param tag: The tag string to lookup
35+
:return: The interface/type associated with the tag
36+
:raises UnsatisfiedRequirement: If the tag is not registered
37+
"""
38+
interface = self._bindings_by_tag.get(tag)
39+
if interface:
40+
return interface
41+
42+
raise UnsatisfiedRequirement(None, t.cast(t.Any, tag))
43+
44+
def has_tag(self, tag: str) -> bool:
45+
"""
46+
Check if a tag is registered.
47+
48+
:param tag: The tag string to check
49+
:return: True if the tag is registered, False otherwise
50+
"""
51+
return tag in self._bindings_by_tag
52+
53+
def clear(self) -> None:
54+
"""Clear all tag registrations. Useful for testing."""
55+
self._bindings_by_tag.clear()
56+
57+
def get_all_tags(self) -> t.Dict[str, t.Type[t.Any]]:
58+
"""Get a copy of all tag-to-interface mappings."""
59+
return self._bindings_by_tag.copy()
60+
61+
def __repr__(self) -> str:
62+
return f"TagRegistry(tags={list(self._bindings_by_tag.keys())})"

ellar/di/service_config.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,66 @@ def __init__(
116116
self.export = export
117117
self.core = core
118118

119+
def __repr__(self) -> str:
120+
"""Developer-friendly representation showing all configuration details"""
121+
parts = [f"base_type={self._type_repr(self.base_type)}"]
122+
123+
if self.use_value is not None:
124+
parts.append(f"use_value={self._type_repr(self.use_value)}")
125+
if self.use_class is not None:
126+
parts.append(f"use_class={self._type_repr(self.use_class)}")
127+
if self.scope != SingletonScope:
128+
parts.append(f"scope={self._type_repr(self.scope)}")
129+
if self.tag:
130+
parts.append(f"tag={self.tag!r}")
131+
if self.export:
132+
parts.append("export=True")
133+
if self.core:
134+
parts.append("core=True")
135+
136+
return f"ProviderConfig({', '.join(parts)})"
137+
138+
def __str__(self) -> str:
139+
"""User-friendly string representation"""
140+
base = self._type_name(self.base_type)
141+
142+
if self.use_class:
143+
impl = self._type_name(self.use_class)
144+
desc = f"{base} -> {impl}"
145+
elif self.use_value is not None:
146+
desc = f"{base} -> <value>"
147+
else:
148+
desc = base
149+
150+
if self.tag:
151+
desc = f"{desc} [tag:{self.tag}]"
152+
153+
return desc
154+
155+
@staticmethod
156+
def _type_repr(obj: t.Any) -> str:
157+
"""Get a repr-style string for a type or value"""
158+
if isinstance(obj, type):
159+
return (
160+
f"{obj.__module__}.{obj.__qualname__}"
161+
if hasattr(obj, "__module__")
162+
else obj.__qualname__
163+
)
164+
elif isinstance(obj, str):
165+
return repr(obj)
166+
else:
167+
return repr(obj)
168+
169+
@staticmethod
170+
def _type_name(obj: t.Any) -> str:
171+
"""Get a simple name for a type or value"""
172+
if isinstance(obj, type):
173+
return obj.__qualname__ if hasattr(obj, "__qualname__") else obj.__name__
174+
elif isinstance(obj, str):
175+
return obj
176+
else:
177+
return type(obj).__name__
178+
119179
def get_type(self) -> t.Type:
120180
return self._resolve_type(self.base_type)
121181

0 commit comments

Comments
 (0)