anydi 0.68.0__py3-none-any.whl → 0.70.0__py3-none-any.whl

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/_container.py CHANGED
@@ -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
- return dependency_type in self._providers
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[dependency_type]
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)
anydi/_decorators.py CHANGED
@@ -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
- @overload
38
- def provided(
39
- dependency_type: Any, /, *, scope: Scope, from_context: bool = False
40
- ) -> Callable[[ClassT], ClassT]: ...
41
-
42
-
43
- @overload
44
- def provided(
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
- dependency_type: Any = NOT_SET, /, *, scope: Scope, from_context: bool = False
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 dependency_type is not NOT_SET:
57
- metadata["dependency_type"] = dependency_type
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, /, *, dependency_type: Any = NOT_SET
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, /, *, dependency_type: Any = NOT_SET
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 dependency_type is not NOT_SET:
84
- metadata["dependency_type"] = dependency_type
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, /, *, dependency_type: Any = NOT_SET
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, /, *, dependency_type: Any = NOT_SET
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 dependency_type is not NOT_SET:
112
- metadata["dependency_type"] = dependency_type
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, /, *, dependency_type: Any = NOT_SET, from_context: bool = False
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
- dependency_type: Any = NOT_SET,
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 dependency_type is not NOT_SET:
144
- metadata["dependency_type"] = dependency_type
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]
anydi/_graph.py CHANGED
@@ -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
- providers.append(
131
- {
132
- "type": self._get_name(provider, full_path),
133
- "scope": provider.scope,
134
- "from_context": provider.from_context,
135
- "dependencies": dependencies,
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
- cls, provider: Provider, full_path: bool, param_name: str | None = None
181
+ self, provider: Provider, full_path: bool, param_name: str | None = None
173
182
  ) -> str:
174
- name = cls._get_name(provider, full_path)
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 = Graph._format_tree_node(dep_provider, full_path, param.name)
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
- Graph._render_tree_children(
214
+ self._render_tree_children(
204
215
  dep_provider, prefix + extension, visited, lines, full_path
205
216
  )
206
217
 
anydi/_resolver.py CHANGED
@@ -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 instance for a dependency type."""
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 instance for a dependency type."""
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(
anydi/_scanner.py CHANGED
@@ -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
- dependency_type = cls.__provided__.get("dependency_type", cls)
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
- dependency_type, cls, scope=scope, from_context=from_context
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:
@@ -3,34 +3,24 @@ from __future__ import annotations
3
3
  import importlib.util
4
4
  import inspect
5
5
  import logging
6
- from collections.abc import Callable, Iterator
7
- from typing import Any, cast
6
+ import warnings
7
+ from collections.abc import Generator
8
+ from typing import TYPE_CHECKING, Annotated, Any, cast, get_args, get_origin
8
9
 
9
10
  import pytest
10
11
  from anyio.pytest_plugin import extract_backend_and_options, get_runner
11
12
  from typing_extensions import get_annotations
12
13
 
13
14
  from anydi import Container, import_container
15
+ from anydi._marker import is_marker
14
16
 
15
- logger = logging.getLogger(__name__)
17
+ if TYPE_CHECKING:
18
+ from _pytest.fixtures import SubRequest
16
19
 
17
- # Storage for fixtures with inject markers
18
- _INJECTED_FIXTURES: dict[str, dict[str, Any]] = {}
20
+ logger = logging.getLogger(__name__)
19
21
 
20
22
 
21
23
  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
24
  parser.addini(
35
25
  "anydi_container",
36
26
  help=(
@@ -41,266 +31,165 @@ def pytest_addoption(parser: pytest.Parser) -> None:
41
31
  default=None,
42
32
  )
43
33
  parser.addini(
44
- "anydi_fixture_inject_enabled",
45
- help=(
46
- "Enable dependency injection into fixtures marked with @pytest.mark.inject"
47
- ),
34
+ "anydi_autoinject",
35
+ help="Automatically inject dependencies into all test functions",
48
36
  type="bool",
49
- default=False,
37
+ default=True,
50
38
  )
51
39
 
52
40
 
53
41
  def pytest_configure(config: pytest.Config) -> None:
54
42
  config.addinivalue_line(
55
43
  "markers",
56
- "inject: mark test as needing dependency injection",
44
+ "inject: mark test as needing dependency injection (deprecated)",
57
45
  )
58
46
 
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
47
 
70
48
  @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
49
+ def pytest_fixture_setup(
50
+ fixturedef: pytest.FixtureDef[Any], request: SubRequest
51
+ ) -> Generator[None]:
52
+ """Automatically enable test mode on the container fixture."""
178
53
  yield
179
-
180
- # Restore the original function
181
- fixturedef.func = original_fixture_func # type: ignore[misc]
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()
182
58
 
183
59
 
184
60
  @pytest.fixture(scope="session")
185
61
  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
62
+ """Container fixture."""
63
+ return _find_container(request)
225
64
 
226
65
 
227
66
  @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:
67
+ def _anydi_inject(request: pytest.FixtureRequest) -> None:
68
+ """Inject dependencies into sync test functions."""
69
+ if inspect.iscoroutinefunction(request.function):
236
70
  return
237
71
 
238
- parameters = list(_anydi_injected_parameter_iterator())
72
+ parameters, uses_deprecated = _get_injectable_params(request)
239
73
  if not parameters:
240
74
  return
241
75
 
242
76
  container = cast(Container, request.getfixturevalue("container"))
243
- resolvable = _select_resolvable_parameters(container, parameters)
244
- if not resolvable:
245
- return
246
77
 
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
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)
252
89
 
253
90
 
254
91
  @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
- ):
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:
266
101
  return
267
102
 
268
- # Skip if the anyio backend is not available
269
103
  if "anyio_backend" not in request.fixturenames:
270
- msg = (
104
+ pytest.fail(
271
105
  "To run async test functions with `anyio`, "
272
106
  "please configure the `anyio` pytest plugin.\n"
273
- "See: https://anyio.readthedocs.io/en/stable/testing.html"
107
+ "See: https://anyio.readthedocs.io/en/stable/testing.html",
108
+ pytrace=False,
274
109
  )
275
- pytest.fail(msg, pytrace=False)
276
-
277
- parameters = list(_anydi_injected_parameter_iterator())
278
- if not parameters:
279
- return
280
110
 
281
111
  container = cast(Container, request.getfixturevalue("container"))
282
- resolvable = _select_resolvable_parameters(container, parameters)
283
- if not resolvable:
284
- return
285
112
 
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
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
+ )
292
127
 
293
128
  anyio_backend = request.getfixturevalue("anyio_backend")
294
129
  backend_name, backend_options = extract_backend_and_options(anyio_backend)
295
130
 
296
131
  with get_runner(backend_name, backend_options) as runner:
297
- runner.run_fixture(_awrapper, {})
132
+ runner.run_fixture(_resolve, {})
298
133
 
299
134
 
300
- def _find_container(request: pytest.FixtureRequest) -> Container:
301
- """Find container."""
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
302
189
 
303
- # Look for 'anydi_container' defined in pytest.ini (highest priority)
190
+
191
+ def _find_container(request: pytest.FixtureRequest) -> Container:
192
+ """Find container from config or auto-detection."""
304
193
  container_path = cast(str | None, request.config.getini("anydi_container"))
305
194
  if container_path:
306
195
  try:
@@ -311,12 +200,10 @@ def _find_container(request: pytest.FixtureRequest) -> Container:
311
200
  f"'anydi_container={container_path}': {exc}"
312
201
  ) from exc
313
202
 
314
- # Detect pytest-django + anydi_django availability
315
203
  pluginmanager = request.config.pluginmanager
316
204
  if pluginmanager.hasplugin("django") and importlib.util.find_spec("anydi_django"):
317
205
  return import_container("anydi_django.container")
318
206
 
319
- # Neither fixture nor config found
320
207
  raise pytest.FixtureLookupError(
321
208
  None,
322
209
  request,
@@ -326,156 +213,10 @@ def _find_container(request: pytest.FixtureRequest) -> Container:
326
213
  )
327
214
 
328
215
 
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
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
  Metadata-Version: 2.4
2
2
  Name: anydi
3
- Version: 0.68.0
3
+ Version: 0.70.0
4
4
  Summary: Dependency Injection library
5
5
  Keywords: dependency injection,dependencies,di,async,asyncio,application
6
6
  Author: Anton Ruhlov
@@ -1,29 +1,29 @@
1
1
  anydi/__init__.py,sha256=KFX8OthKXwBuYDPCV61t-044DpJ88tAOzIxeUWRC5OA,633
2
2
  anydi/_async_lock.py,sha256=3dwZr0KthXFYha0XKMyXf8jMmGb1lYoNC0O5w29V9ic,1104
3
3
  anydi/_cli.py,sha256=0BhNvWPyuIGzUkDELIBm_nsEMWk7MtLi3oTvgXj5oko,2072
4
- anydi/_container.py,sha256=lxUkRYU35qSiwTZYXfydElD28n2imzS81qRAAIhiHZU,46677
4
+ anydi/_container.py,sha256=6dqMUlqvBEI0bghytOSqHK74EN9tfiEoQbJdNPbjLeE,48660
5
5
  anydi/_context.py,sha256=ZQWxtBXWkrMsCk_L7K_A7-e09v5Mv9HApPH3LZ6ZF9k,3648
6
- anydi/_decorators.py,sha256=7FvgmBDruv1w_iVqwloPzZwQQgyzQCklM6r9f6UVn-E,5728
7
- anydi/_graph.py,sha256=X7AYsTMcGZtnIUJD5CVc1t8-t54fSI8CY_xh6ijODWk,7945
6
+ anydi/_decorators.py,sha256=04M1xN7oHVskAnoEJdvJr3hXrrzruUp4mzlLOCuuZv0,5598
7
+ anydi/_graph.py,sha256=WN_N1nNNPp74YU1mqxM-dJlf0rWF9ooGNTIv87DJpKI,8619
8
8
  anydi/_injector.py,sha256=RvnPEYOgkg-WOIW1ItvVsoAZaSC9wmCnWQrfXad_86A,4507
9
9
  anydi/_marker.py,sha256=yXSPbIVU-X-jMSawtCHWFMKke5VpWMiBRZlEH8PlUqE,3373
10
10
  anydi/_module.py,sha256=2kN5uEXLd2Dsc58gz5IWK43wJewr_QgIVGSO3iWp798,2609
11
11
  anydi/_provider.py,sha256=5pMXyiGwBo2j6OHaoOktLQfiX2rkCf5aYXFlFi65JlQ,2898
12
- anydi/_resolver.py,sha256=CTYCjYkUXX35o2VMwvVwXyF8Kqp7h2tXituCNq6oydM,32746
13
- anydi/_scanner.py,sha256=rCPbpW_5OV9MwYQdwTfceV_MlwSMPfpTTKuxdcMmV98,5804
12
+ anydi/_resolver.py,sha256=xpBgFhUTMdHeNjqmUfOZMU5J8PviVkc5q_ckgwrdAhs,33603
13
+ anydi/_scanner.py,sha256=bCd1mBYXYBANBVoVEf2gHyw3Mr1DbTX5kV1b80-RljM,5898
14
14
  anydi/_types.py,sha256=lsShY_-_CM2EFajeknAYXvLl-rHfopBT8udnK5_BtS4,1161
15
15
  anydi/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  anydi/ext/django/__init__.py,sha256=Ve8lncLU9dPY_Vjt4zihPgsSxwAtFHACn0XvBM5JG8k,367
17
17
  anydi/ext/fastapi.py,sha256=NowHc-z_Sw069YDv9vP98Mehum5vHsIIcqvDkRmicmc,2964
18
18
  anydi/ext/faststream.py,sha256=_3FZ8vlgl1GVizVv_6ippvwSWu_Zor4qm6HaiAp24o4,2744
19
19
  anydi/ext/pydantic_settings.py,sha256=y6uz0MiLtqPvRO186bIdRZVQfezLvRUfT3KvvB5fCWk,1524
20
- anydi/ext/pytest_plugin.py,sha256=XoMy5nLY8jM-KyK7VuwDuF_3rE3ouxaR3jZ0S6A6mdw,16322
20
+ anydi/ext/pytest_plugin.py,sha256=rLZ6NZUYklSg1rYx0FPdSvVmEY-faZTYqEY5qUWqZXk,7336
21
21
  anydi/ext/starlette/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  anydi/ext/starlette/middleware.py,sha256=n_JJ7BcG2Mg2M5HwM_SBboxZ-mnnD6WWJn4khq7Bgbs,1860
23
23
  anydi/ext/typer.py,sha256=c7HapXQfKhnLJQcHNncJAGd8jZ3crX5it6-MRCJjyPM,6268
24
24
  anydi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
25
  anydi/testing.py,sha256=cHg3mMScZbEep9smRqSNQ81BZMQOkyugHe8TvKdPnEg,1347
26
- anydi-0.68.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
27
- anydi-0.68.0.dist-info/entry_points.txt,sha256=oDl_yEX12KlWcDzsZBTg85GG1Jl1rpiYOG4C7EJvebs,87
28
- anydi-0.68.0.dist-info/METADATA,sha256=3gYwUfTF6a368Qhb8H-_-85cIze1H_054Wlt-ExFLz0,8061
29
- anydi-0.68.0.dist-info/RECORD,,
26
+ anydi-0.70.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
27
+ anydi-0.70.0.dist-info/entry_points.txt,sha256=oDl_yEX12KlWcDzsZBTg85GG1Jl1rpiYOG4C7EJvebs,87
28
+ anydi-0.70.0.dist-info/METADATA,sha256=w2CzRSLiozxgqMa3gaGhpR1LdcAyGA7yVsJrCruZeEI,8061
29
+ anydi-0.70.0.dist-info/RECORD,,
File without changes