anydi 0.68.0__tar.gz → 0.70.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {anydi-0.68.0 → anydi-0.70.0}/PKG-INFO +1 -1
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_container.py +44 -4
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_decorators.py +24 -26
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_graph.py +27 -16
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_resolver.py +19 -2
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_scanner.py +6 -4
- anydi-0.70.0/anydi/ext/pytest_plugin.py +222 -0
- {anydi-0.68.0 → anydi-0.70.0}/pyproject.toml +2 -1
- anydi-0.68.0/anydi/ext/pytest_plugin.py +0 -481
- {anydi-0.68.0 → anydi-0.70.0}/README.md +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/__init__.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_async_lock.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_cli.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_context.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_injector.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_marker.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_module.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_provider.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/_types.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/ext/__init__.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/ext/django/__init__.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/ext/fastapi.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/ext/faststream.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/ext/pydantic_settings.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/ext/starlette/__init__.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/ext/starlette/middleware.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/ext/typer.py +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/py.typed +0 -0
- {anydi-0.68.0 → anydi-0.70.0}/anydi/testing.py +0 -0
|
@@ -17,7 +17,7 @@ from typing import Any, Literal, TypeVar, get_args, get_origin, overload
|
|
|
17
17
|
from typing_extensions import ParamSpec, Self, type_repr
|
|
18
18
|
|
|
19
19
|
from ._context import InstanceContext
|
|
20
|
-
from ._decorators import is_provided
|
|
20
|
+
from ._decorators import get_alias_list, is_provided
|
|
21
21
|
from ._graph import Graph
|
|
22
22
|
from ._injector import Injector
|
|
23
23
|
from ._marker import Marker
|
|
@@ -49,6 +49,7 @@ class Container:
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
self._resources: dict[str, list[Any]] = defaultdict(list)
|
|
52
|
+
self._aliases: dict[Any, Any] = {} # alias_type → canonical_type
|
|
52
53
|
self._singleton_context = InstanceContext()
|
|
53
54
|
self._scoped_context: dict[str, ContextVar[InstanceContext]] = {}
|
|
54
55
|
|
|
@@ -95,6 +96,11 @@ class Container:
|
|
|
95
96
|
"""Get the registered providers."""
|
|
96
97
|
return self._providers
|
|
97
98
|
|
|
99
|
+
@property
|
|
100
|
+
def aliases(self) -> dict[Any, Any]:
|
|
101
|
+
"""Get the registered aliases."""
|
|
102
|
+
return self._aliases
|
|
103
|
+
|
|
98
104
|
@property
|
|
99
105
|
def ready(self) -> bool:
|
|
100
106
|
"""Check if the container is ready."""
|
|
@@ -370,9 +376,30 @@ class Container:
|
|
|
370
376
|
dependency_type, factory, scope, from_context, override, None
|
|
371
377
|
)
|
|
372
378
|
|
|
379
|
+
def alias(self, alias_type: Any, canonical_type: Any, /) -> None:
|
|
380
|
+
"""Register an alias for a dependency type."""
|
|
381
|
+
if self.ready:
|
|
382
|
+
raise RuntimeError(
|
|
383
|
+
"Cannot register aliases after build() has been called. "
|
|
384
|
+
"All aliases must be registered before building the container."
|
|
385
|
+
)
|
|
386
|
+
if alias_type == canonical_type:
|
|
387
|
+
raise ValueError("Alias type cannot be the same as canonical type.")
|
|
388
|
+
if alias_type in self._aliases:
|
|
389
|
+
raise ValueError(
|
|
390
|
+
f"Alias `{type_repr(alias_type)}` is already registered "
|
|
391
|
+
f"for `{type_repr(self._aliases[alias_type])}`."
|
|
392
|
+
)
|
|
393
|
+
self._aliases[alias_type] = canonical_type
|
|
394
|
+
|
|
395
|
+
def _resolve_alias(self, dependency_type: Any) -> Any:
|
|
396
|
+
"""Resolve an alias to its canonical type."""
|
|
397
|
+
return self._aliases.get(dependency_type, dependency_type)
|
|
398
|
+
|
|
373
399
|
def is_registered(self, dependency_type: Any, /) -> bool:
|
|
374
400
|
"""Check if a provider is registered for the specified dependency type."""
|
|
375
|
-
|
|
401
|
+
canonical = self._resolve_alias(dependency_type)
|
|
402
|
+
return canonical in self._providers
|
|
376
403
|
|
|
377
404
|
def has_provider_for(self, dependency_type: Any, /) -> bool:
|
|
378
405
|
"""Check if a provider exists for the specified dependency type."""
|
|
@@ -570,9 +597,10 @@ class Container:
|
|
|
570
597
|
return provider
|
|
571
598
|
|
|
572
599
|
def _get_provider(self, dependency_type: Any) -> Provider:
|
|
573
|
-
"""Get provider by dependency type."""
|
|
600
|
+
"""Get provider by dependency type, resolving aliases."""
|
|
601
|
+
canonical = self._resolve_alias(dependency_type)
|
|
574
602
|
try:
|
|
575
|
-
return self._providers[
|
|
603
|
+
return self._providers[canonical]
|
|
576
604
|
except KeyError:
|
|
577
605
|
raise LookupError(
|
|
578
606
|
f"The provider for `{type_repr(dependency_type)}` has "
|
|
@@ -597,6 +625,10 @@ class Container:
|
|
|
597
625
|
False,
|
|
598
626
|
defaults,
|
|
599
627
|
)
|
|
628
|
+
# Register aliases if specified
|
|
629
|
+
if not self.ready:
|
|
630
|
+
for alias_type in get_alias_list(dependency_type.__provided__):
|
|
631
|
+
self.alias(alias_type, dependency_type)
|
|
600
632
|
registered = True
|
|
601
633
|
else:
|
|
602
634
|
raise LookupError(
|
|
@@ -677,6 +709,9 @@ class Container:
|
|
|
677
709
|
False,
|
|
678
710
|
None,
|
|
679
711
|
)
|
|
712
|
+
# Register aliases if specified
|
|
713
|
+
for alias_type in get_alias_list(dependency_type.__provided__):
|
|
714
|
+
self._aliases[alias_type] = dependency_type
|
|
680
715
|
# Recursively ensure the @provided class is resolved
|
|
681
716
|
dep_provider = self._ensure_provider_resolved(
|
|
682
717
|
dep_provider, resolving
|
|
@@ -977,6 +1012,11 @@ class Container:
|
|
|
977
1012
|
False,
|
|
978
1013
|
None,
|
|
979
1014
|
)
|
|
1015
|
+
# Register aliases if specified
|
|
1016
|
+
for alias_type in get_alias_list(
|
|
1017
|
+
param_dependency_type.__provided__
|
|
1018
|
+
):
|
|
1019
|
+
self._aliases[alias_type] = param_dependency_type
|
|
980
1020
|
elif param.has_default:
|
|
981
1021
|
# Has default, can be missing
|
|
982
1022
|
resolved_params.append(param)
|
|
@@ -29,32 +29,30 @@ ModuleT = TypeVar("ModuleT", bound="Module")
|
|
|
29
29
|
class ProvidedMetadata(TypedDict):
|
|
30
30
|
"""Metadata for classes marked as provided by AnyDI."""
|
|
31
31
|
|
|
32
|
-
dependency_type: NotRequired[Any]
|
|
33
32
|
scope: Scope
|
|
33
|
+
alias: NotRequired[Any]
|
|
34
34
|
from_context: NotRequired[bool]
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*, scope: Scope, from_context: bool = False
|
|
46
|
-
) -> Callable[[ClassT], ClassT]: ...
|
|
37
|
+
def get_alias_list(provided: ProvidedMetadata) -> list[Any]:
|
|
38
|
+
"""Get alias list from __provided__ metadata, normalizing single to list."""
|
|
39
|
+
alias = provided.get("alias")
|
|
40
|
+
if alias is None:
|
|
41
|
+
return []
|
|
42
|
+
if isinstance(alias, list | tuple):
|
|
43
|
+
return list(alias) # type: ignore
|
|
44
|
+
return [alias]
|
|
47
45
|
|
|
48
46
|
|
|
49
47
|
def provided(
|
|
50
|
-
|
|
48
|
+
*, scope: Scope, alias: Any = NOT_SET, from_context: bool = False
|
|
51
49
|
) -> Callable[[ClassT], ClassT]:
|
|
52
50
|
"""Decorator for marking a class as provided by AnyDI with a specific scope."""
|
|
53
51
|
|
|
54
52
|
def decorator(cls: ClassT) -> ClassT:
|
|
55
53
|
metadata: ProvidedMetadata = {"scope": scope}
|
|
56
|
-
if
|
|
57
|
-
metadata["
|
|
54
|
+
if alias is not NOT_SET:
|
|
55
|
+
metadata["alias"] = alias
|
|
58
56
|
if from_context:
|
|
59
57
|
metadata["from_context"] = from_context
|
|
60
58
|
cls.__provided__ = metadata # type: ignore[attr-defined]
|
|
@@ -69,19 +67,19 @@ def singleton(cls: ClassT, /) -> ClassT: ...
|
|
|
69
67
|
|
|
70
68
|
@overload
|
|
71
69
|
def singleton(
|
|
72
|
-
cls: None = None, /, *,
|
|
70
|
+
cls: None = None, /, *, alias: Any = NOT_SET
|
|
73
71
|
) -> Callable[[ClassT], ClassT]: ...
|
|
74
72
|
|
|
75
73
|
|
|
76
74
|
def singleton(
|
|
77
|
-
cls: ClassT | None = None, /, *,
|
|
75
|
+
cls: ClassT | None = None, /, *, alias: Any = NOT_SET
|
|
78
76
|
) -> Callable[[ClassT], ClassT] | ClassT:
|
|
79
77
|
"""Decorator for marking a class as a singleton dependency."""
|
|
80
78
|
|
|
81
79
|
def decorator(c: ClassT) -> ClassT:
|
|
82
80
|
metadata: ProvidedMetadata = {"scope": "singleton"}
|
|
83
|
-
if
|
|
84
|
-
metadata["
|
|
81
|
+
if alias is not NOT_SET:
|
|
82
|
+
metadata["alias"] = alias
|
|
85
83
|
c.__provided__ = metadata # type: ignore[attr-defined]
|
|
86
84
|
return c
|
|
87
85
|
|
|
@@ -97,19 +95,19 @@ def transient(cls: ClassT, /) -> ClassT: ...
|
|
|
97
95
|
|
|
98
96
|
@overload
|
|
99
97
|
def transient(
|
|
100
|
-
cls: None = None, /, *,
|
|
98
|
+
cls: None = None, /, *, alias: Any = NOT_SET
|
|
101
99
|
) -> Callable[[ClassT], ClassT]: ...
|
|
102
100
|
|
|
103
101
|
|
|
104
102
|
def transient(
|
|
105
|
-
cls: ClassT | None = None, /, *,
|
|
103
|
+
cls: ClassT | None = None, /, *, alias: Any = NOT_SET
|
|
106
104
|
) -> Callable[[ClassT], ClassT] | ClassT:
|
|
107
105
|
"""Decorator for marking a class as a transient dependency."""
|
|
108
106
|
|
|
109
107
|
def decorator(c: ClassT) -> ClassT:
|
|
110
108
|
metadata: ProvidedMetadata = {"scope": "transient"}
|
|
111
|
-
if
|
|
112
|
-
metadata["
|
|
109
|
+
if alias is not NOT_SET:
|
|
110
|
+
metadata["alias"] = alias
|
|
113
111
|
c.__provided__ = metadata # type: ignore[attr-defined]
|
|
114
112
|
return c
|
|
115
113
|
|
|
@@ -125,7 +123,7 @@ def request(cls: ClassT, /, *, from_context: bool = False) -> ClassT: ...
|
|
|
125
123
|
|
|
126
124
|
@overload
|
|
127
125
|
def request(
|
|
128
|
-
cls: None = None, /, *,
|
|
126
|
+
cls: None = None, /, *, alias: Any = NOT_SET, from_context: bool = False
|
|
129
127
|
) -> Callable[[ClassT], ClassT]: ...
|
|
130
128
|
|
|
131
129
|
|
|
@@ -133,15 +131,15 @@ def request(
|
|
|
133
131
|
cls: ClassT | None = None,
|
|
134
132
|
/,
|
|
135
133
|
*,
|
|
136
|
-
|
|
134
|
+
alias: Any = NOT_SET,
|
|
137
135
|
from_context: bool = False,
|
|
138
136
|
) -> Callable[[ClassT], ClassT] | ClassT:
|
|
139
137
|
"""Decorator for marking a class as a request-scoped dependency."""
|
|
140
138
|
|
|
141
139
|
def decorator(c: ClassT) -> ClassT:
|
|
142
140
|
metadata: ProvidedMetadata = {"scope": "request"}
|
|
143
|
-
if
|
|
144
|
-
metadata["
|
|
141
|
+
if alias is not NOT_SET:
|
|
142
|
+
metadata["alias"] = alias
|
|
145
143
|
if from_context:
|
|
146
144
|
metadata["from_context"] = from_context
|
|
147
145
|
c.__provided__ = metadata # type: ignore[attr-defined]
|
|
@@ -19,6 +19,14 @@ class Graph:
|
|
|
19
19
|
def __init__(self, container: Container) -> None:
|
|
20
20
|
self._container = container
|
|
21
21
|
|
|
22
|
+
def _get_aliases_for(self, dependency_type: Any) -> list[str]:
|
|
23
|
+
"""Get list of alias names that point to a dependency type."""
|
|
24
|
+
aliases: list[str] = []
|
|
25
|
+
for alias, canonical in self._container.aliases.items():
|
|
26
|
+
if canonical == dependency_type:
|
|
27
|
+
aliases.append(type_repr(alias).rsplit(".", 1)[-1])
|
|
28
|
+
return aliases
|
|
29
|
+
|
|
22
30
|
def draw(
|
|
23
31
|
self,
|
|
24
32
|
output_format: Literal["tree", "mermaid", "dot", "json"] = "tree",
|
|
@@ -127,14 +135,16 @@ class Graph:
|
|
|
127
135
|
}
|
|
128
136
|
)
|
|
129
137
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
aliases = self._get_aliases_for(provider.dependency_type)
|
|
139
|
+
provider_data: dict[str, Any] = {
|
|
140
|
+
"type": self._get_name(provider, full_path),
|
|
141
|
+
"scope": provider.scope,
|
|
142
|
+
"from_context": provider.from_context,
|
|
143
|
+
"dependencies": dependencies,
|
|
144
|
+
}
|
|
145
|
+
if aliases:
|
|
146
|
+
provider_data["aliases"] = aliases
|
|
147
|
+
providers.append(provider_data)
|
|
138
148
|
|
|
139
149
|
return json.dumps({"providers": providers}, indent=ident)
|
|
140
150
|
|
|
@@ -167,19 +177,20 @@ class Graph:
|
|
|
167
177
|
|
|
168
178
|
return "\n".join(lines)
|
|
169
179
|
|
|
170
|
-
@classmethod
|
|
171
180
|
def _format_tree_node(
|
|
172
|
-
|
|
181
|
+
self, provider: Provider, full_path: bool, param_name: str | None = None
|
|
173
182
|
) -> str:
|
|
174
|
-
name =
|
|
183
|
+
name = self._get_name(provider, full_path)
|
|
175
184
|
scope_label = Graph._get_scope_label(provider.scope, provider.from_context)
|
|
176
185
|
context_marker = " [context]" if provider.from_context else ""
|
|
186
|
+
aliases = self._get_aliases_for(provider.dependency_type)
|
|
187
|
+
alias_marker = f" [alias: {', '.join(aliases)}]" if aliases else ""
|
|
177
188
|
if param_name:
|
|
178
|
-
return f"{param_name}: {name} ({scope_label}){context_marker}"
|
|
179
|
-
return f"{name} ({scope_label}){context_marker}"
|
|
189
|
+
return f"{param_name}: {name} ({scope_label}){context_marker}{alias_marker}"
|
|
190
|
+
return f"{name} ({scope_label}){context_marker}{alias_marker}"
|
|
180
191
|
|
|
181
|
-
@staticmethod
|
|
182
192
|
def _render_tree_children(
|
|
193
|
+
self,
|
|
183
194
|
provider: Provider,
|
|
184
195
|
prefix: str,
|
|
185
196
|
visited: set[Any],
|
|
@@ -197,10 +208,10 @@ class Graph:
|
|
|
197
208
|
continue
|
|
198
209
|
is_last = i == len(deps) - 1
|
|
199
210
|
connector = "└── " if is_last else "├── "
|
|
200
|
-
node_text =
|
|
211
|
+
node_text = self._format_tree_node(dep_provider, full_path, param.name)
|
|
201
212
|
lines.append(f"{prefix}{connector}{node_text}")
|
|
202
213
|
extension = " " if is_last else "│ "
|
|
203
|
-
|
|
214
|
+
self._render_tree_children(
|
|
204
215
|
dep_provider, prefix + extension, visited, lines, full_path
|
|
205
216
|
)
|
|
206
217
|
|
|
@@ -51,12 +51,24 @@ class Resolver:
|
|
|
51
51
|
return bool(self._overrides) or getattr(self._container, "_test_mode", False)
|
|
52
52
|
|
|
53
53
|
def add_override(self, dependency_type: Any, instance: Any) -> None:
|
|
54
|
-
"""Add an override
|
|
54
|
+
"""Add an override for a type, its canonical type, and all aliases."""
|
|
55
55
|
self._overrides[dependency_type] = instance
|
|
56
|
+
canonical = self._container.aliases.get(dependency_type)
|
|
57
|
+
if canonical is not None:
|
|
58
|
+
self._overrides[canonical] = instance
|
|
59
|
+
for alias, canon in self._container.aliases.items():
|
|
60
|
+
if canon == dependency_type:
|
|
61
|
+
self._overrides[alias] = instance
|
|
56
62
|
|
|
57
63
|
def remove_override(self, dependency_type: Any) -> None:
|
|
58
|
-
"""Remove an override
|
|
64
|
+
"""Remove an override for a type, its canonical type, and all aliases."""
|
|
59
65
|
self._overrides.pop(dependency_type, None)
|
|
66
|
+
canonical = self._container.aliases.get(dependency_type)
|
|
67
|
+
if canonical is not None:
|
|
68
|
+
self._overrides.pop(canonical, None)
|
|
69
|
+
for alias, canon in self._container.aliases.items():
|
|
70
|
+
if canon == dependency_type:
|
|
71
|
+
self._overrides.pop(alias, None)
|
|
60
72
|
|
|
61
73
|
def clear_caches(self) -> None:
|
|
62
74
|
"""Clear all cached resolvers."""
|
|
@@ -105,6 +117,11 @@ class Resolver:
|
|
|
105
117
|
# Store the compiled functions in the cache
|
|
106
118
|
cache[provider.dependency_type] = compiled
|
|
107
119
|
|
|
120
|
+
# Also store under all aliases that point to this type
|
|
121
|
+
for alias, canonical in self._container.aliases.items():
|
|
122
|
+
if canonical == provider.dependency_type:
|
|
123
|
+
cache[alias] = compiled
|
|
124
|
+
|
|
108
125
|
return compiled
|
|
109
126
|
|
|
110
127
|
def _add_override_check(
|
|
@@ -8,7 +8,7 @@ from dataclasses import dataclass
|
|
|
8
8
|
from types import ModuleType
|
|
9
9
|
from typing import TYPE_CHECKING, Any
|
|
10
10
|
|
|
11
|
-
from ._decorators import Provided, is_injectable, is_provided
|
|
11
|
+
from ._decorators import Provided, get_alias_list, is_injectable, is_provided
|
|
12
12
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
14
|
from ._container import Container
|
|
@@ -58,13 +58,15 @@ class Scanner:
|
|
|
58
58
|
|
|
59
59
|
# First: register @provided classes
|
|
60
60
|
for cls in provided_classes:
|
|
61
|
-
|
|
62
|
-
if not self._container.is_registered(dependency_type):
|
|
61
|
+
if not self._container.is_registered(cls):
|
|
63
62
|
scope = cls.__provided__["scope"]
|
|
64
63
|
from_context = cls.__provided__.get("from_context", False)
|
|
65
64
|
self._container.register(
|
|
66
|
-
|
|
65
|
+
cls, cls, scope=scope, from_context=from_context
|
|
67
66
|
)
|
|
67
|
+
# Create aliases if specified (alias → cls)
|
|
68
|
+
for alias_type in get_alias_list(cls.__provided__):
|
|
69
|
+
self._container.alias(alias_type, cls)
|
|
68
70
|
|
|
69
71
|
# Second: inject @injectable functions
|
|
70
72
|
for dependency in injectable_dependencies:
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
import warnings
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
from typing import TYPE_CHECKING, Annotated, Any, cast, get_args, get_origin
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from anyio.pytest_plugin import extract_backend_and_options, get_runner
|
|
12
|
+
from typing_extensions import get_annotations
|
|
13
|
+
|
|
14
|
+
from anydi import Container, import_container
|
|
15
|
+
from anydi._marker import is_marker
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from _pytest.fixtures import SubRequest
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
24
|
+
parser.addini(
|
|
25
|
+
"anydi_container",
|
|
26
|
+
help=(
|
|
27
|
+
"Path to container instance or factory "
|
|
28
|
+
"(e.g., 'myapp.container:container' or 'myapp.container.container')"
|
|
29
|
+
),
|
|
30
|
+
type="string",
|
|
31
|
+
default=None,
|
|
32
|
+
)
|
|
33
|
+
parser.addini(
|
|
34
|
+
"anydi_autoinject",
|
|
35
|
+
help="Automatically inject dependencies into all test functions",
|
|
36
|
+
type="bool",
|
|
37
|
+
default=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
42
|
+
config.addinivalue_line(
|
|
43
|
+
"markers",
|
|
44
|
+
"inject: mark test as needing dependency injection (deprecated)",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
49
|
+
def pytest_fixture_setup(
|
|
50
|
+
fixturedef: pytest.FixtureDef[Any], request: SubRequest
|
|
51
|
+
) -> Generator[None]:
|
|
52
|
+
"""Automatically enable test mode on the container fixture."""
|
|
53
|
+
yield
|
|
54
|
+
if fixturedef.argname == "container" and fixturedef.cached_result is not None:
|
|
55
|
+
container = fixturedef.cached_result[0]
|
|
56
|
+
if isinstance(container, Container):
|
|
57
|
+
container.enable_test_mode()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.fixture(scope="session")
|
|
61
|
+
def container(request: pytest.FixtureRequest) -> Container:
|
|
62
|
+
"""Container fixture."""
|
|
63
|
+
return _find_container(request)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture(autouse=True)
|
|
67
|
+
def _anydi_inject(request: pytest.FixtureRequest) -> None:
|
|
68
|
+
"""Inject dependencies into sync test functions."""
|
|
69
|
+
if inspect.iscoroutinefunction(request.function):
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
parameters, uses_deprecated = _get_injectable_params(request)
|
|
73
|
+
if not parameters:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
container = cast(Container, request.getfixturevalue("container"))
|
|
77
|
+
|
|
78
|
+
warned = False
|
|
79
|
+
for name, dependency_type in parameters:
|
|
80
|
+
if not container.has_provider_for(dependency_type):
|
|
81
|
+
continue
|
|
82
|
+
if uses_deprecated and not warned:
|
|
83
|
+
_warn_deprecated_marker(request.node.name)
|
|
84
|
+
warned = True
|
|
85
|
+
try:
|
|
86
|
+
request.node.funcargs[name] = container.resolve(dependency_type)
|
|
87
|
+
except Exception: # pragma: no cover
|
|
88
|
+
logger.warning("Failed to resolve '%s' for %s", name, request.node.nodeid)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.fixture(autouse=True)
|
|
92
|
+
def _anydi_ainject(request: pytest.FixtureRequest) -> None:
|
|
93
|
+
"""Inject dependencies into async test functions."""
|
|
94
|
+
if not inspect.iscoroutinefunction(
|
|
95
|
+
request.function
|
|
96
|
+
) and not inspect.isasyncgenfunction(request.function):
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
parameters, uses_deprecated = _get_injectable_params(request)
|
|
100
|
+
if not parameters:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if "anyio_backend" not in request.fixturenames:
|
|
104
|
+
pytest.fail(
|
|
105
|
+
"To run async test functions with `anyio`, "
|
|
106
|
+
"please configure the `anyio` pytest plugin.\n"
|
|
107
|
+
"See: https://anyio.readthedocs.io/en/stable/testing.html",
|
|
108
|
+
pytrace=False,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
container = cast(Container, request.getfixturevalue("container"))
|
|
112
|
+
|
|
113
|
+
async def _resolve() -> None:
|
|
114
|
+
warned = False
|
|
115
|
+
for name, dependency_type in parameters:
|
|
116
|
+
if not container.has_provider_for(dependency_type):
|
|
117
|
+
continue
|
|
118
|
+
if uses_deprecated and not warned:
|
|
119
|
+
_warn_deprecated_marker(request.node.name)
|
|
120
|
+
warned = True
|
|
121
|
+
try:
|
|
122
|
+
request.node.funcargs[name] = await container.aresolve(dependency_type)
|
|
123
|
+
except Exception: # pragma: no cover
|
|
124
|
+
logger.warning(
|
|
125
|
+
"Failed to resolve '%s' for %s", name, request.node.nodeid
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
anyio_backend = request.getfixturevalue("anyio_backend")
|
|
129
|
+
backend_name, backend_options = extract_backend_and_options(anyio_backend)
|
|
130
|
+
|
|
131
|
+
with get_runner(backend_name, backend_options) as runner:
|
|
132
|
+
runner.run_fixture(_resolve, {})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _get_injectable_params(
|
|
136
|
+
request: pytest.FixtureRequest,
|
|
137
|
+
) -> tuple[list[tuple[str, Any]], bool]:
|
|
138
|
+
"""Get injectable parameters for a test function.
|
|
139
|
+
|
|
140
|
+
Returns (parameters, uses_deprecated_marker) tuple.
|
|
141
|
+
"""
|
|
142
|
+
fixture_names = set(request.node._fixtureinfo.initialnames) - set(
|
|
143
|
+
request.node._fixtureinfo.name2fixturedefs.keys()
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
marker = request.node.get_closest_marker("inject")
|
|
147
|
+
autoinject = cast(bool, request.config.getini("anydi_autoinject"))
|
|
148
|
+
|
|
149
|
+
has_any_explicit = False
|
|
150
|
+
explicit_params: list[tuple[str, Any]] = []
|
|
151
|
+
all_params: list[tuple[str, Any]] = []
|
|
152
|
+
|
|
153
|
+
annotations = get_annotations(request.function, eval_str=True)
|
|
154
|
+
|
|
155
|
+
for name, annotation in annotations.items():
|
|
156
|
+
if name in ("request", "return"):
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
dependency_type, is_explicit = _extract_dependency_type(annotation)
|
|
160
|
+
|
|
161
|
+
if is_explicit:
|
|
162
|
+
has_any_explicit = True
|
|
163
|
+
explicit_params.append((name, dependency_type))
|
|
164
|
+
elif name in fixture_names:
|
|
165
|
+
all_params.append((name, dependency_type))
|
|
166
|
+
|
|
167
|
+
# Priority: explicit markers > deprecated @pytest.mark.inject > autoinject
|
|
168
|
+
if has_any_explicit:
|
|
169
|
+
return explicit_params, False
|
|
170
|
+
if marker is not None:
|
|
171
|
+
return all_params, True
|
|
172
|
+
if autoinject:
|
|
173
|
+
return all_params, False
|
|
174
|
+
return [], False
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _extract_dependency_type(annotation: Any) -> tuple[Any, bool]:
|
|
178
|
+
"""Extract the actual type and whether it has an explicit injection marker.
|
|
179
|
+
|
|
180
|
+
Handles Provide[T] and Annotated[T, Inject()].
|
|
181
|
+
Returns (unwrapped_type, is_explicit).
|
|
182
|
+
"""
|
|
183
|
+
if get_origin(annotation) is Annotated:
|
|
184
|
+
args = get_args(annotation)
|
|
185
|
+
for arg in args[1:]:
|
|
186
|
+
if is_marker(arg):
|
|
187
|
+
return args[0], True
|
|
188
|
+
return annotation, False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _find_container(request: pytest.FixtureRequest) -> Container:
|
|
192
|
+
"""Find container from config or auto-detection."""
|
|
193
|
+
container_path = cast(str | None, request.config.getini("anydi_container"))
|
|
194
|
+
if container_path:
|
|
195
|
+
try:
|
|
196
|
+
return import_container(container_path)
|
|
197
|
+
except ImportError as exc:
|
|
198
|
+
raise RuntimeError(
|
|
199
|
+
f"Failed to load container from config "
|
|
200
|
+
f"'anydi_container={container_path}': {exc}"
|
|
201
|
+
) from exc
|
|
202
|
+
|
|
203
|
+
pluginmanager = request.config.pluginmanager
|
|
204
|
+
if pluginmanager.hasplugin("django") and importlib.util.find_spec("anydi_django"):
|
|
205
|
+
return import_container("anydi_django.container")
|
|
206
|
+
|
|
207
|
+
raise pytest.FixtureLookupError(
|
|
208
|
+
None,
|
|
209
|
+
request,
|
|
210
|
+
"`container` fixture is not found and 'anydi_container' config is not set. "
|
|
211
|
+
"Either define a `container` fixture in your test module "
|
|
212
|
+
"or set 'anydi_container' in pytest.ini.",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _warn_deprecated_marker(test_name: str) -> None:
|
|
217
|
+
warnings.warn(
|
|
218
|
+
f"Using @pytest.mark.inject on test '{test_name}' is deprecated. "
|
|
219
|
+
"Use Provide[T] instead.",
|
|
220
|
+
DeprecationWarning,
|
|
221
|
+
stacklevel=4,
|
|
222
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "anydi"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.70.0"
|
|
4
4
|
description = "Dependency Injection library"
|
|
5
5
|
authors = [{ name = "Anton Ruhlov", email = "antonruhlov@gmail.com" }]
|
|
6
6
|
requires-python = ">=3.10.0, <3.15"
|
|
@@ -114,6 +114,7 @@ enableExperimentalFeatures = true
|
|
|
114
114
|
addopts = [
|
|
115
115
|
"--strict-config",
|
|
116
116
|
"--strict-markers",
|
|
117
|
+
"-p", "no:anydi",
|
|
117
118
|
]
|
|
118
119
|
xfail_strict = true
|
|
119
120
|
junit_family = "xunit2"
|
|
@@ -1,481 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import importlib.util
|
|
4
|
-
import inspect
|
|
5
|
-
import logging
|
|
6
|
-
from collections.abc import Callable, Iterator
|
|
7
|
-
from typing import Any, cast
|
|
8
|
-
|
|
9
|
-
import pytest
|
|
10
|
-
from anyio.pytest_plugin import extract_backend_and_options, get_runner
|
|
11
|
-
from typing_extensions import get_annotations
|
|
12
|
-
|
|
13
|
-
from anydi import Container, import_container
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
# Storage for fixtures with inject markers
|
|
18
|
-
_INJECTED_FIXTURES: dict[str, dict[str, Any]] = {}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
22
|
-
parser.addini(
|
|
23
|
-
"anydi_autoinject",
|
|
24
|
-
help="Automatically inject dependencies into all test functions",
|
|
25
|
-
type="bool",
|
|
26
|
-
default=False,
|
|
27
|
-
)
|
|
28
|
-
parser.addini(
|
|
29
|
-
"anydi_inject_all",
|
|
30
|
-
help="Deprecated: use 'anydi_autoinject' instead",
|
|
31
|
-
type="bool",
|
|
32
|
-
default=False,
|
|
33
|
-
)
|
|
34
|
-
parser.addini(
|
|
35
|
-
"anydi_container",
|
|
36
|
-
help=(
|
|
37
|
-
"Path to container instance or factory "
|
|
38
|
-
"(e.g., 'myapp.container:container' or 'myapp.container.container')"
|
|
39
|
-
),
|
|
40
|
-
type="string",
|
|
41
|
-
default=None,
|
|
42
|
-
)
|
|
43
|
-
parser.addini(
|
|
44
|
-
"anydi_fixture_inject_enabled",
|
|
45
|
-
help=(
|
|
46
|
-
"Enable dependency injection into fixtures marked with @pytest.mark.inject"
|
|
47
|
-
),
|
|
48
|
-
type="bool",
|
|
49
|
-
default=False,
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def pytest_configure(config: pytest.Config) -> None:
|
|
54
|
-
config.addinivalue_line(
|
|
55
|
-
"markers",
|
|
56
|
-
"inject: mark test as needing dependency injection",
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
# Enable fixture injection if configured
|
|
60
|
-
inject_fixtures_enabled = cast(bool, config.getini("anydi_fixture_inject_enabled"))
|
|
61
|
-
if inject_fixtures_enabled:
|
|
62
|
-
autoinject = cast(bool, config.getini("anydi_autoinject"))
|
|
63
|
-
inject_all = cast(bool, config.getini("anydi_inject_all"))
|
|
64
|
-
_patch_pytest_fixtures(autoinject=autoinject or inject_all)
|
|
65
|
-
logger.debug(
|
|
66
|
-
"Fixture injection enabled via anydi_fixture_inject_enabled config"
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
@pytest.hookimpl(hookwrapper=True)
|
|
71
|
-
def pytest_fixture_setup( # noqa: C901
|
|
72
|
-
fixturedef: pytest.FixtureDef[Any],
|
|
73
|
-
request: pytest.FixtureRequest,
|
|
74
|
-
) -> Iterator[None]:
|
|
75
|
-
"""Inject dependencies into fixtures marked with @pytest.mark.inject."""
|
|
76
|
-
# Check if this fixture has injection metadata
|
|
77
|
-
fixture_name = fixturedef.argname
|
|
78
|
-
if fixture_name not in _INJECTED_FIXTURES:
|
|
79
|
-
yield
|
|
80
|
-
return
|
|
81
|
-
|
|
82
|
-
# Get the metadata
|
|
83
|
-
fixture_info = _INJECTED_FIXTURES[fixture_name]
|
|
84
|
-
original_func = fixture_info["func"]
|
|
85
|
-
parameters: list[tuple[str, Any]] = fixture_info["parameters"]
|
|
86
|
-
|
|
87
|
-
# Get the container
|
|
88
|
-
try:
|
|
89
|
-
container = cast(Container, request.getfixturevalue("container"))
|
|
90
|
-
except pytest.FixtureLookupError:
|
|
91
|
-
yield
|
|
92
|
-
return
|
|
93
|
-
|
|
94
|
-
resolvable_params = _select_resolvable_parameters(container, parameters)
|
|
95
|
-
|
|
96
|
-
if not resolvable_params:
|
|
97
|
-
yield
|
|
98
|
-
return
|
|
99
|
-
|
|
100
|
-
target_name = f"fixture '{fixture_name}'"
|
|
101
|
-
|
|
102
|
-
def _prepare_sync_call_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
103
|
-
combined_kwargs = dict(kwargs)
|
|
104
|
-
combined_kwargs.update(
|
|
105
|
-
_resolve_dependencies_sync(container, resolvable_params, target=target_name)
|
|
106
|
-
)
|
|
107
|
-
return combined_kwargs
|
|
108
|
-
|
|
109
|
-
async def _prepare_async_call_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
110
|
-
combined_kwargs = dict(kwargs)
|
|
111
|
-
combined_kwargs.update(
|
|
112
|
-
await _resolve_dependencies_async(
|
|
113
|
-
container, resolvable_params, target=target_name
|
|
114
|
-
)
|
|
115
|
-
)
|
|
116
|
-
return combined_kwargs
|
|
117
|
-
|
|
118
|
-
def _ensure_anyio_backend() -> tuple[str, dict[str, Any]]:
|
|
119
|
-
try:
|
|
120
|
-
backend = request.getfixturevalue("anyio_backend")
|
|
121
|
-
except pytest.FixtureLookupError as exc: # pragma: no cover - defensive
|
|
122
|
-
msg = (
|
|
123
|
-
"To run async fixtures with AnyDI, please configure the `anyio` pytest "
|
|
124
|
-
"plugin (provide the `anyio_backend` fixture)."
|
|
125
|
-
)
|
|
126
|
-
pytest.fail(msg, pytrace=False)
|
|
127
|
-
raise RuntimeError from exc # Unreachable but satisfies type checkers
|
|
128
|
-
|
|
129
|
-
return extract_backend_and_options(backend)
|
|
130
|
-
|
|
131
|
-
# Replace the fixture function with one that mirrors the original's type and
|
|
132
|
-
# injects dependencies before delegating to the user-defined function.
|
|
133
|
-
original_fixture_func = fixturedef.func
|
|
134
|
-
|
|
135
|
-
if inspect.isasyncgenfunction(original_func):
|
|
136
|
-
|
|
137
|
-
def asyncgen_wrapper(*args: Any, **kwargs: Any) -> Iterator[Any]:
|
|
138
|
-
backend_name, backend_options = _ensure_anyio_backend()
|
|
139
|
-
|
|
140
|
-
async def _fixture() -> Any:
|
|
141
|
-
call_kwargs = await _prepare_async_call_kwargs(kwargs)
|
|
142
|
-
async for value in original_func(**call_kwargs):
|
|
143
|
-
yield value
|
|
144
|
-
|
|
145
|
-
with get_runner(backend_name, backend_options) as runner:
|
|
146
|
-
yield from runner.run_asyncgen_fixture(_fixture, {}) # type: ignore
|
|
147
|
-
|
|
148
|
-
fixturedef.func = asyncgen_wrapper # type: ignore[misc]
|
|
149
|
-
elif inspect.iscoroutinefunction(original_func):
|
|
150
|
-
|
|
151
|
-
def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
152
|
-
backend_name, backend_options = _ensure_anyio_backend()
|
|
153
|
-
|
|
154
|
-
async def _fixture() -> Any:
|
|
155
|
-
call_kwargs = await _prepare_async_call_kwargs(kwargs)
|
|
156
|
-
return await original_func(**call_kwargs)
|
|
157
|
-
|
|
158
|
-
with get_runner(backend_name, backend_options) as runner:
|
|
159
|
-
return runner.run_fixture(_fixture, {})
|
|
160
|
-
|
|
161
|
-
fixturedef.func = async_wrapper # type: ignore[misc]
|
|
162
|
-
elif inspect.isgeneratorfunction(original_func):
|
|
163
|
-
|
|
164
|
-
def generator_wrapper(*args: Any, **kwargs: Any) -> Iterator[Any]:
|
|
165
|
-
call_kwargs = _prepare_sync_call_kwargs(kwargs)
|
|
166
|
-
yield from original_func(**call_kwargs)
|
|
167
|
-
|
|
168
|
-
fixturedef.func = generator_wrapper # type: ignore[misc]
|
|
169
|
-
else:
|
|
170
|
-
|
|
171
|
-
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
172
|
-
call_kwargs = _prepare_sync_call_kwargs(kwargs)
|
|
173
|
-
return original_func(**call_kwargs)
|
|
174
|
-
|
|
175
|
-
fixturedef.func = sync_wrapper # type: ignore[misc]
|
|
176
|
-
|
|
177
|
-
# Let pytest execute the modified fixture
|
|
178
|
-
yield
|
|
179
|
-
|
|
180
|
-
# Restore the original function
|
|
181
|
-
fixturedef.func = original_fixture_func # type: ignore[misc]
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
@pytest.fixture(scope="session")
|
|
185
|
-
def container(request: pytest.FixtureRequest) -> Container:
|
|
186
|
-
"""Container fixture with testing mode enabled."""
|
|
187
|
-
container = _find_container(request)
|
|
188
|
-
container.enable_test_mode()
|
|
189
|
-
return container
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
@pytest.fixture
|
|
193
|
-
def _anydi_should_inject(request: pytest.FixtureRequest) -> bool:
|
|
194
|
-
marker = request.node.get_closest_marker("inject")
|
|
195
|
-
|
|
196
|
-
# Check new config option first
|
|
197
|
-
autoinject = cast(bool, request.config.getini("anydi_autoinject"))
|
|
198
|
-
|
|
199
|
-
# Check deprecated option for backward compatibility
|
|
200
|
-
inject_all = cast(bool, request.config.getini("anydi_inject_all"))
|
|
201
|
-
if inject_all:
|
|
202
|
-
logger.warning(
|
|
203
|
-
"Configuration option 'anydi_inject_all' is deprecated. "
|
|
204
|
-
"Please use 'anydi_autoinject' instead."
|
|
205
|
-
)
|
|
206
|
-
|
|
207
|
-
return marker is not None or autoinject or inject_all
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
@pytest.fixture
|
|
211
|
-
def _anydi_injected_parameter_iterator(
|
|
212
|
-
request: pytest.FixtureRequest,
|
|
213
|
-
) -> Callable[[], Iterator[tuple[str, Any]]]:
|
|
214
|
-
fixturenames = set(request.node._fixtureinfo.initialnames) - set(
|
|
215
|
-
request.node._fixtureinfo.name2fixturedefs.keys()
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
def _iterator() -> Iterator[tuple[str, Any]]:
|
|
219
|
-
for name, annotation in _iter_injectable_parameters(request.function):
|
|
220
|
-
if name not in fixturenames:
|
|
221
|
-
continue
|
|
222
|
-
yield name, annotation
|
|
223
|
-
|
|
224
|
-
return _iterator
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
@pytest.fixture(autouse=True)
|
|
228
|
-
def _anydi_inject(
|
|
229
|
-
request: pytest.FixtureRequest,
|
|
230
|
-
_anydi_should_inject: bool,
|
|
231
|
-
_anydi_injected_parameter_iterator: Callable[[], Iterator[tuple[str, Any]]],
|
|
232
|
-
) -> None:
|
|
233
|
-
"""Inject dependencies into the test function."""
|
|
234
|
-
|
|
235
|
-
if inspect.iscoroutinefunction(request.function) or not _anydi_should_inject:
|
|
236
|
-
return
|
|
237
|
-
|
|
238
|
-
parameters = list(_anydi_injected_parameter_iterator())
|
|
239
|
-
if not parameters:
|
|
240
|
-
return
|
|
241
|
-
|
|
242
|
-
container = cast(Container, request.getfixturevalue("container"))
|
|
243
|
-
resolvable = _select_resolvable_parameters(container, parameters)
|
|
244
|
-
if not resolvable:
|
|
245
|
-
return
|
|
246
|
-
|
|
247
|
-
resolved = _resolve_dependencies_sync(
|
|
248
|
-
container, resolvable, target=request.node.nodeid
|
|
249
|
-
)
|
|
250
|
-
for argname, value in resolved.items():
|
|
251
|
-
request.node.funcargs[argname] = value
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
@pytest.fixture(autouse=True)
|
|
255
|
-
def _anydi_ainject(
|
|
256
|
-
request: pytest.FixtureRequest,
|
|
257
|
-
_anydi_should_inject: bool,
|
|
258
|
-
_anydi_injected_parameter_iterator: Callable[[], Iterator[tuple[str, Any]]],
|
|
259
|
-
) -> None:
|
|
260
|
-
"""Inject dependencies into the test function."""
|
|
261
|
-
if (
|
|
262
|
-
not inspect.iscoroutinefunction(request.function)
|
|
263
|
-
and not inspect.isasyncgenfunction(request.function)
|
|
264
|
-
or not _anydi_should_inject
|
|
265
|
-
):
|
|
266
|
-
return
|
|
267
|
-
|
|
268
|
-
# Skip if the anyio backend is not available
|
|
269
|
-
if "anyio_backend" not in request.fixturenames:
|
|
270
|
-
msg = (
|
|
271
|
-
"To run async test functions with `anyio`, "
|
|
272
|
-
"please configure the `anyio` pytest plugin.\n"
|
|
273
|
-
"See: https://anyio.readthedocs.io/en/stable/testing.html"
|
|
274
|
-
)
|
|
275
|
-
pytest.fail(msg, pytrace=False)
|
|
276
|
-
|
|
277
|
-
parameters = list(_anydi_injected_parameter_iterator())
|
|
278
|
-
if not parameters:
|
|
279
|
-
return
|
|
280
|
-
|
|
281
|
-
container = cast(Container, request.getfixturevalue("container"))
|
|
282
|
-
resolvable = _select_resolvable_parameters(container, parameters)
|
|
283
|
-
if not resolvable:
|
|
284
|
-
return
|
|
285
|
-
|
|
286
|
-
async def _awrapper() -> None:
|
|
287
|
-
resolved = await _resolve_dependencies_async(
|
|
288
|
-
container, resolvable, target=request.node.nodeid
|
|
289
|
-
)
|
|
290
|
-
for argname, value in resolved.items():
|
|
291
|
-
request.node.funcargs[argname] = value
|
|
292
|
-
|
|
293
|
-
anyio_backend = request.getfixturevalue("anyio_backend")
|
|
294
|
-
backend_name, backend_options = extract_backend_and_options(anyio_backend)
|
|
295
|
-
|
|
296
|
-
with get_runner(backend_name, backend_options) as runner:
|
|
297
|
-
runner.run_fixture(_awrapper, {})
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
def _find_container(request: pytest.FixtureRequest) -> Container:
|
|
301
|
-
"""Find container."""
|
|
302
|
-
|
|
303
|
-
# Look for 'anydi_container' defined in pytest.ini (highest priority)
|
|
304
|
-
container_path = cast(str | None, request.config.getini("anydi_container"))
|
|
305
|
-
if container_path:
|
|
306
|
-
try:
|
|
307
|
-
return import_container(container_path)
|
|
308
|
-
except ImportError as exc:
|
|
309
|
-
raise RuntimeError(
|
|
310
|
-
f"Failed to load container from config "
|
|
311
|
-
f"'anydi_container={container_path}': {exc}"
|
|
312
|
-
) from exc
|
|
313
|
-
|
|
314
|
-
# Detect pytest-django + anydi_django availability
|
|
315
|
-
pluginmanager = request.config.pluginmanager
|
|
316
|
-
if pluginmanager.hasplugin("django") and importlib.util.find_spec("anydi_django"):
|
|
317
|
-
return import_container("anydi_django.container")
|
|
318
|
-
|
|
319
|
-
# Neither fixture nor config found
|
|
320
|
-
raise pytest.FixtureLookupError(
|
|
321
|
-
None,
|
|
322
|
-
request,
|
|
323
|
-
"`container` fixture is not found and 'anydi_container' config is not set. "
|
|
324
|
-
"Either define a `container` fixture in your test module "
|
|
325
|
-
"or set 'anydi_container' in pytest.ini.",
|
|
326
|
-
)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def _patch_pytest_fixtures(*, autoinject: bool) -> None: # noqa: C901
|
|
330
|
-
"""Patch pytest.fixture decorator to intercept fixtures with inject markers."""
|
|
331
|
-
from _pytest.fixtures import fixture as original_fixture_decorator
|
|
332
|
-
|
|
333
|
-
def patched_fixture(*args: Any, **kwargs: Any) -> Any: # noqa: C901
|
|
334
|
-
"""Patched fixture decorator that handles inject markers."""
|
|
335
|
-
|
|
336
|
-
def should_process(func: Callable[..., Any]) -> bool:
|
|
337
|
-
has_inject_marker = False
|
|
338
|
-
if hasattr(func, "pytestmark"):
|
|
339
|
-
markers = getattr(func, "pytestmark", [])
|
|
340
|
-
if not isinstance(markers, list):
|
|
341
|
-
markers = [markers]
|
|
342
|
-
|
|
343
|
-
has_inject_marker = any(
|
|
344
|
-
marker.name == "inject"
|
|
345
|
-
for marker in markers
|
|
346
|
-
if hasattr(marker, "name")
|
|
347
|
-
)
|
|
348
|
-
|
|
349
|
-
return autoinject or has_inject_marker
|
|
350
|
-
|
|
351
|
-
def register_fixture(func: Callable[..., Any]) -> Callable[..., Any] | None:
|
|
352
|
-
if not should_process(func):
|
|
353
|
-
return None
|
|
354
|
-
|
|
355
|
-
parameters = list(_iter_injectable_parameters(func))
|
|
356
|
-
if not parameters:
|
|
357
|
-
return None
|
|
358
|
-
|
|
359
|
-
sig = inspect.signature(func, eval_str=True)
|
|
360
|
-
has_request_param = "request" in sig.parameters
|
|
361
|
-
|
|
362
|
-
if has_request_param:
|
|
363
|
-
|
|
364
|
-
def wrapper_with_request(request: Any) -> Any:
|
|
365
|
-
return func
|
|
366
|
-
|
|
367
|
-
wrapper_func = wrapper_with_request
|
|
368
|
-
else:
|
|
369
|
-
|
|
370
|
-
def wrapper_no_request() -> Any:
|
|
371
|
-
return func
|
|
372
|
-
|
|
373
|
-
wrapper_func = wrapper_no_request
|
|
374
|
-
|
|
375
|
-
wrapper_func.__name__ = func.__name__
|
|
376
|
-
wrapper_func.__annotations__ = {}
|
|
377
|
-
|
|
378
|
-
fixture_name = func.__name__
|
|
379
|
-
_INJECTED_FIXTURES[fixture_name] = {
|
|
380
|
-
"func": func,
|
|
381
|
-
"parameters": parameters,
|
|
382
|
-
}
|
|
383
|
-
logger.debug(
|
|
384
|
-
"Registered injectable fixture '%s' with params: %s",
|
|
385
|
-
fixture_name,
|
|
386
|
-
[name for name, _ in parameters],
|
|
387
|
-
)
|
|
388
|
-
|
|
389
|
-
return wrapper_func
|
|
390
|
-
|
|
391
|
-
# Handle both @pytest.fixture and @pytest.fixture() usage
|
|
392
|
-
if len(args) == 1 and callable(args[0]) and not kwargs:
|
|
393
|
-
func = args[0]
|
|
394
|
-
wrapper_func = register_fixture(func)
|
|
395
|
-
if wrapper_func:
|
|
396
|
-
return original_fixture_decorator(wrapper_func)
|
|
397
|
-
|
|
398
|
-
return original_fixture_decorator(func)
|
|
399
|
-
else:
|
|
400
|
-
|
|
401
|
-
def decorator(func: Callable[..., Any]) -> Any:
|
|
402
|
-
wrapper_func = register_fixture(func)
|
|
403
|
-
if wrapper_func:
|
|
404
|
-
return original_fixture_decorator(*args, **kwargs)(wrapper_func)
|
|
405
|
-
|
|
406
|
-
return original_fixture_decorator(*args, **kwargs)(func)
|
|
407
|
-
|
|
408
|
-
return decorator
|
|
409
|
-
|
|
410
|
-
# Replace pytest.fixture
|
|
411
|
-
pytest.fixture = patched_fixture # type: ignore[assignment]
|
|
412
|
-
# Also patch _pytest.fixtures.fixture
|
|
413
|
-
import _pytest.fixtures
|
|
414
|
-
|
|
415
|
-
_pytest.fixtures.fixture = patched_fixture # type: ignore[assignment]
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
def _iter_injectable_parameters(
|
|
419
|
-
func: Callable[..., Any], *, skip: tuple[str, ...] = ("request",)
|
|
420
|
-
) -> Iterator[tuple[str, Any]]:
|
|
421
|
-
annotations = get_annotations(func, eval_str=True)
|
|
422
|
-
skip_names = set(skip)
|
|
423
|
-
for name, annotation in annotations.items():
|
|
424
|
-
if name in skip_names or name == "return":
|
|
425
|
-
continue
|
|
426
|
-
yield name, annotation
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
def _select_resolvable_parameters(
|
|
430
|
-
container: Container,
|
|
431
|
-
parameters: Iterator[tuple[str, Any]] | list[tuple[str, Any]],
|
|
432
|
-
) -> list[tuple[str, Any]]:
|
|
433
|
-
return [
|
|
434
|
-
(name, annotation)
|
|
435
|
-
for name, annotation in parameters
|
|
436
|
-
if container.has_provider_for(annotation)
|
|
437
|
-
]
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
def _resolve_dependencies_sync(
|
|
441
|
-
container: Container,
|
|
442
|
-
parameters: list[tuple[str, Any]],
|
|
443
|
-
*,
|
|
444
|
-
target: str,
|
|
445
|
-
) -> dict[str, Any]:
|
|
446
|
-
container.enable_test_mode()
|
|
447
|
-
resolved: dict[str, Any] = {}
|
|
448
|
-
for param_name, annotation in parameters:
|
|
449
|
-
try:
|
|
450
|
-
resolved[param_name] = container.resolve(annotation)
|
|
451
|
-
logger.debug("Resolved %s=%s for %s", param_name, annotation, target)
|
|
452
|
-
except Exception as exc: # pragma: no cover - defensive logging
|
|
453
|
-
logger.warning(
|
|
454
|
-
"Failed to resolve dependency for '%s' on %s.",
|
|
455
|
-
param_name,
|
|
456
|
-
target,
|
|
457
|
-
exc_info=exc,
|
|
458
|
-
)
|
|
459
|
-
return resolved
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
async def _resolve_dependencies_async(
|
|
463
|
-
container: Container,
|
|
464
|
-
parameters: list[tuple[str, Any]],
|
|
465
|
-
*,
|
|
466
|
-
target: str,
|
|
467
|
-
) -> dict[str, Any]:
|
|
468
|
-
container.enable_test_mode()
|
|
469
|
-
resolved: dict[str, Any] = {}
|
|
470
|
-
for param_name, annotation in parameters:
|
|
471
|
-
try:
|
|
472
|
-
resolved[param_name] = await container.aresolve(annotation)
|
|
473
|
-
logger.debug("Resolved %s=%s for async %s", param_name, annotation, target)
|
|
474
|
-
except Exception as exc: # pragma: no cover - defensive logging
|
|
475
|
-
logger.warning(
|
|
476
|
-
"Failed to resolve async dependency for '%s' on %s.",
|
|
477
|
-
param_name,
|
|
478
|
-
target,
|
|
479
|
-
exc_info=exc,
|
|
480
|
-
)
|
|
481
|
-
return resolved
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|