Skip to content

Commit 6b7fc31

Browse files
authored
Merge pull request #301 from python-ellar/feat/global-tag-registry-and-testing
feat: Add InjectByTag support and global TagRegistry for testing
2 parents b9b8a84 + 10081d2 commit 6b7fc31

File tree

14 files changed

+1799
-85
lines changed

14 files changed

+1799
-85
lines changed

docs/basics/testing.md

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

docs/overview/providers.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,5 +221,11 @@ class AModule(ModuleBase):
221221

222222
In the above example, we are tagging `Foo` as `first_foo` and `FooB` as `second_foo`. By doing this, we can resolve both services using their tag names, thus providing the possibility of resolving services by tag name or type.
223223

224-
Also, services can be injected as a dependency by using tags. To achieve this, the `InjectByTag` decorator is used as a `**constructor**` argument.
224+
Also, services can be injected as a dependency by using tags. To achieve this, `InjectByTag` is used as a `**constructor**` argument.
225225
This allows for more flexibility in managing dependencies and resolving services based on tags.
226+
227+
`InjectByTag` supports two syntaxes:
228+
- **Callable syntax**: `InjectByTag('tag_name')`
229+
- **Generic syntax**: `InjectByTag[T("tag_name")]` where T is from `ellar.common.types.T`
230+
231+
Both syntaxes work identically and can be used interchangeably based on your preference.

ellar/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Ellar - Python ASGI web framework for building fast, efficient, and scalable RESTful APIs and server-side applications."""
22

3-
__version__ = "0.9.0"
3+
__version__ = "0.9.2"

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/common/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,13 @@
9999
from .serializer import Serializer, serialize_object
100100
from .templating import TemplateResponse, render_template, render_template_string
101101

102+
103+
def T(tag: str) -> str:
104+
return tag
105+
106+
102107
__all__ = [
108+
"T",
103109
"AnonymousIdentity",
104110
"ControllerBase",
105111
"serialize_object",

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/core/modules/ref/base.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import typing as t
22
from abc import ABC, abstractmethod
3-
from functools import cached_property
43
from typing import Type
54

65
from ellar.auth.constants import POLICY_KEYS
@@ -66,16 +65,21 @@ def __init__(
6665
self._routers: t.List[t.Union[BaseRoute]] = []
6766
self._exports: t.List[t.Type] = []
6867
self._providers: t.Dict[t.Type, ProviderConfig] = {}
68+
self._module_execution_context: t.Optional[ModuleExecutionContext] = None
6969

7070
def __repr__(self) -> str:
7171
return f"<{self.__class__.__name__} name={self.name} module={self.module}>"
7272

7373
def __hash__(self) -> int:
7474
return hash(self.module)
7575

76-
@cached_property
76+
@property
7777
def module_context(self) -> ModuleExecutionContext:
78-
return ModuleExecutionContext(container=self.container, module=self.module)
78+
if self._module_execution_context is None:
79+
self._module_execution_context = ModuleExecutionContext(
80+
container=self.container, module=self.module
81+
)
82+
return self._module_execution_context
7983

8084
@property
8185
def tree_manager(self) -> ModuleTreeManager:

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())})"

0 commit comments

Comments
 (0)