anydi 0.70.1__py3-none-any.whl → 0.71.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
@@ -83,13 +83,13 @@ class Container:
83
83
  # Register providers
84
84
  providers = providers or []
85
85
  for provider in providers:
86
- self._register_provider(
86
+ self.register(
87
87
  provider.dependency_type,
88
88
  provider.factory,
89
- provider.scope,
90
- provider.from_context,
91
- False,
92
- None,
89
+ scope=provider.scope,
90
+ from_context=provider.from_context,
91
+ alias=provider.alias,
92
+ override=False,
93
93
  )
94
94
 
95
95
  # Register modules
anydi/_provider.py CHANGED
@@ -77,6 +77,7 @@ class ProviderDef:
77
77
  _: KW_ONLY
78
78
  from_context: bool = False
79
79
  scope: Scope = "singleton"
80
+ alias: Any = NOT_SET
80
81
  interface: Any = NOT_SET
81
82
  call: Callable[..., Any] = NOT_SET
82
83
 
anydi/_scanner.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import importlib
4
4
  import inspect
5
5
  import pkgutil
6
- from collections.abc import Callable, Iterable, Iterator
6
+ from collections.abc import Iterable, Iterator
7
7
  from dataclasses import dataclass
8
8
  from types import ModuleType
9
9
  from typing import TYPE_CHECKING, Any
@@ -30,8 +30,11 @@ class ScannedDependency:
30
30
 
31
31
 
32
32
  class Scanner:
33
+ _scanning_packages: set[str] = set()
34
+
33
35
  def __init__(self, container: Container) -> None:
34
36
  self._container = container
37
+ self._importing_modules: set[str] = set()
35
38
 
36
39
  def scan(
37
40
  self,
@@ -41,21 +44,75 @@ class Scanner:
41
44
  tags: Iterable[str] | None = None,
42
45
  ignore: PackageOrIterable | None = None,
43
46
  ) -> None:
44
- """Scan packages or modules for decorated members and inject dependencies."""
47
+ """Scan packages or modules for decorated members and inject dependencies.
48
+
49
+ Supports relative package paths (like Python's relative imports):
50
+ - "." scans the caller's package
51
+ - ".submodule" scans a submodule of the caller's package
52
+ - ".." scans the parent package
53
+ - "..sibling" scans a sibling package
54
+ """
55
+ if isinstance(packages, (ModuleType, str)):
56
+ packages = [packages]
57
+
58
+ # Resolve relative package paths
59
+ caller_package = self._get_caller_package(packages, ignore)
60
+ packages = self._resolve_relative_packages(packages, caller_package)
61
+ ignore = self._resolve_relative_packages(ignore, caller_package)
62
+
63
+ pkg_names = {p if isinstance(p, str) else p.__name__ for p in packages}
64
+ overlap = pkg_names & Scanner._scanning_packages
65
+ if overlap:
66
+ raise RuntimeError(
67
+ f"Circular import detected: scan() called recursively!\n\n"
68
+ f"Already scanning packages: {', '.join(sorted(overlap))}\n\n"
69
+ "This happens when a scanned module triggers container creation "
70
+ "(e.g., via lazy proxy).\n\n"
71
+ "Solutions:\n"
72
+ "- Add the problematic module to scan() ignore list\n"
73
+ "- Move container imports inside functions (lazy import)\n"
74
+ "- Avoid lazy container initialization in scanned modules"
75
+ )
76
+
77
+ Scanner._scanning_packages.update(pkg_names)
78
+ try:
79
+ self._do_scan(packages, tags=tags, ignore=ignore)
80
+ finally:
81
+ Scanner._scanning_packages -= pkg_names
82
+
83
+ def _do_scan( # noqa: C901
84
+ self,
85
+ packages: PackageOrIterable,
86
+ *,
87
+ tags: Iterable[str] | None = None,
88
+ ignore: PackageOrIterable | None = None,
89
+ ) -> None:
90
+ """Internal scan implementation."""
45
91
  if isinstance(packages, (ModuleType, str)):
46
92
  packages = [packages]
47
93
 
48
- tags_list = list(tags) if tags else []
94
+ tags_set: set[str] = set(tags) if tags else set()
49
95
  ignore_prefixes = self._normalize_ignore(ignore)
50
96
  provided_classes: list[type[Provided]] = []
51
97
  injectable_dependencies: list[ScannedDependency] = []
52
98
 
53
99
  # Single pass: collect both @provided classes and @injectable functions
54
100
  for module in self._iter_modules(packages, ignore_prefixes=ignore_prefixes):
55
- provided_classes.extend(self._scan_module_for_provided(module))
56
- injectable_dependencies.extend(
57
- self._scan_module_for_injectable(module, tags=tags_list)
58
- )
101
+ module_name = module.__name__
102
+ for name, member in vars(module).items():
103
+ if name.startswith("_"):
104
+ continue
105
+ if getattr(member, "__module__", None) != module_name:
106
+ continue
107
+
108
+ if inspect.isclass(member) and is_provided(member):
109
+ provided_classes.append(member)
110
+ elif callable(member) and is_injectable(member):
111
+ member_tags = set(member.__injectable__["tags"] or ())
112
+ if not tags_set or (tags_set & member_tags):
113
+ injectable_dependencies.append(
114
+ ScannedDependency(member=member, module=module)
115
+ )
59
116
 
60
117
  # First: register @provided classes
61
118
  for cls in provided_classes:
@@ -74,33 +131,124 @@ class Scanner:
74
131
  decorated = self._container.inject()(dependency.member)
75
132
  setattr(dependency.module, dependency.member.__name__, decorated)
76
133
 
77
- def _normalize_ignore(self, ignore: PackageOrIterable | None) -> list[str]:
78
- """Normalize ignore parameter to a list of module name prefixes."""
79
- if ignore is None:
134
+ def _has_relative_packages(self, *package_lists: PackageOrIterable | None) -> bool:
135
+ """Check if any package list contains relative paths."""
136
+ for packages in package_lists:
137
+ if packages is None:
138
+ continue
139
+ if isinstance(packages, str):
140
+ if packages.startswith("."):
141
+ return True
142
+ elif isinstance(packages, ModuleType):
143
+ continue
144
+ else:
145
+ for p in packages:
146
+ if isinstance(p, str) and p.startswith("."):
147
+ return True
148
+ return False
149
+
150
+ def _get_caller_package(
151
+ self,
152
+ packages: Iterable[Package],
153
+ ignore: PackageOrIterable | None,
154
+ ) -> str | None:
155
+ """Get the package name of the module that called scan()."""
156
+ if not self._has_relative_packages(packages, ignore):
157
+ return None
158
+
159
+ frame = inspect.currentframe()
160
+ try:
161
+ while frame is not None:
162
+ frame = frame.f_back
163
+ if frame is None:
164
+ break
165
+ module_name = frame.f_globals.get("__name__")
166
+ if module_name and not module_name.startswith("anydi"):
167
+ # Return package portion (remove module name if present)
168
+ if "." in module_name:
169
+ return module_name.rsplit(".", 1)[0]
170
+ return module_name
171
+ finally:
172
+ del frame
173
+
174
+ raise ValueError(
175
+ "Cannot use relative package paths: unable to determine caller package. "
176
+ "Use absolute package names instead."
177
+ )
178
+
179
+ def _resolve_relative_name(self, relative_name: str, base_package: str) -> str:
180
+ """Resolve a relative package name to absolute."""
181
+ num_dots = len(relative_name) - len(relative_name.lstrip("."))
182
+ remainder = relative_name[num_dots:]
183
+
184
+ package_parts = base_package.split(".")
185
+
186
+ # Navigate up for parent references (..)
187
+ if num_dots > 1:
188
+ levels_up = num_dots - 1
189
+ if levels_up >= len(package_parts):
190
+ raise ValueError(
191
+ f"Cannot resolve '{relative_name}': "
192
+ f"too many parent levels for base package '{base_package}'"
193
+ )
194
+ package_parts = package_parts[:-levels_up]
195
+
196
+ if remainder:
197
+ return ".".join(package_parts) + "." + remainder
198
+ return ".".join(package_parts)
199
+
200
+ def _resolve_relative_packages(
201
+ self,
202
+ packages: PackageOrIterable | None,
203
+ caller_package: str | None,
204
+ ) -> list[Package]:
205
+ """Resolve relative package names to absolute names."""
206
+ if packages is None:
80
207
  return []
81
208
 
209
+ if isinstance(packages, (ModuleType, str)):
210
+ packages = [packages]
211
+
212
+ resolved: list[Package] = []
213
+ for package in packages:
214
+ if isinstance(package, ModuleType):
215
+ resolved.append(package)
216
+ elif not package.startswith("."):
217
+ resolved.append(package)
218
+ else:
219
+ if caller_package is None:
220
+ raise ValueError(
221
+ "Cannot use relative package paths: "
222
+ "unable to determine caller package. "
223
+ "Use absolute package names instead."
224
+ )
225
+ resolved.append(self._resolve_relative_name(package, caller_package))
226
+
227
+ return resolved
228
+
229
+ def _normalize_ignore(self, ignore: PackageOrIterable | None) -> tuple[str, ...]:
230
+ """Normalize ignore parameter to a tuple of module name prefixes."""
231
+ if ignore is None:
232
+ return ()
233
+
82
234
  if isinstance(ignore, (ModuleType, str)):
83
235
  ignore = [ignore]
84
236
 
85
237
  prefixes: list[str] = []
86
238
  for item in ignore:
87
- if isinstance(item, ModuleType):
88
- prefixes.append(item.__name__)
89
- else:
90
- prefixes.append(item)
91
- return prefixes
239
+ name = item.__name__ if isinstance(item, ModuleType) else item
240
+ prefixes.append(name)
241
+ prefixes.append(name + ".") # For startswith check
242
+ return tuple(prefixes)
92
243
 
93
244
  def _should_ignore_module(
94
- self, module_name: str, ignore_prefixes: list[str]
245
+ self, module_name: str, ignore_prefixes: tuple[str, ...]
95
246
  ) -> bool:
96
247
  """Check if a module should be ignored based on ignore prefixes."""
97
- for prefix in ignore_prefixes:
98
- if module_name == prefix or module_name.startswith(prefix + "."):
99
- return True
100
- return False
248
+ return module_name.startswith(ignore_prefixes) if ignore_prefixes else False
101
249
 
102
250
  def _iter_modules(
103
- self, packages: Iterable[Package], *, ignore_prefixes: list[str]
251
+ self, packages: Iterable[Package], *, ignore_prefixes: tuple[str, ...]
104
252
  ) -> Iterator[ModuleType]:
105
253
  """Iterate over all modules in the given packages."""
106
254
  for package in packages:
@@ -118,43 +266,33 @@ class Scanner:
118
266
  package.__path__, prefix=package.__name__ + "."
119
267
  ):
120
268
  if not self._should_ignore_module(module_info.name, ignore_prefixes):
121
- yield importlib.import_module(module_info.name)
122
-
123
- def _scan_module_for_provided(self, module: ModuleType) -> list[type[Provided]]:
124
- """Scan a module for @provided classes."""
125
- provided_classes: list[type[Provided]] = []
126
-
127
- for _, member in inspect.getmembers(module, predicate=inspect.isclass):
128
- if getattr(member, "__module__", None) != module.__name__:
129
- continue
130
-
131
- if is_provided(member):
132
- provided_classes.append(member)
133
-
134
- return provided_classes
269
+ yield from self._import_module_with_tracking(module_info.name)
135
270
 
136
- def _scan_module_for_injectable(
137
- self, module: ModuleType, *, tags: list[str]
138
- ) -> list[ScannedDependency]:
139
- """Scan a module for @injectable functions."""
140
- dependencies: list[ScannedDependency] = []
141
-
142
- for _, member in inspect.getmembers(module, predicate=callable):
143
- if getattr(member, "__module__", None) != module.__name__:
144
- continue
145
-
146
- if self._should_include_member(member, tags=tags):
147
- dependencies.append(ScannedDependency(member=member, module=module))
148
-
149
- return dependencies
150
-
151
- @staticmethod
152
- def _should_include_member(member: Callable[..., Any], *, tags: list[str]) -> bool:
153
- """Determine if a member should be included based on tags or marker defaults."""
154
- if is_injectable(member):
155
- member_tags = set(member.__injectable__["tags"] or [])
156
- if tags:
157
- return bool(set(tags) & member_tags)
158
- return True # No tags passed → include all injectables
271
+ def _import_module_with_tracking(self, module_name: str) -> Iterator[ModuleType]:
272
+ """Import a module while tracking for circular imports."""
273
+ # Check if we're already importing this module (circular import)
274
+ if module_name in self._importing_modules:
275
+ import_chain = " -> ".join(sorted(self._importing_modules))
276
+ raise RuntimeError(
277
+ f"Circular import detected during container scanning!\n"
278
+ f"Module '{module_name}' is being imported while already "
279
+ f"in the import chain.\n"
280
+ f"Import chain: {import_chain} -> {module_name}\n\n"
281
+ f"This usually happens when:\n"
282
+ f"1. A scanned module imports the container at module level\n"
283
+ f"2. The container creation triggers scanning\n"
284
+ f"3. Scanning tries to import the module again\n\n"
285
+ f"Solutions:\n"
286
+ f"- Add '{module_name}' to the ignore list\n"
287
+ f"- Move container imports inside functions (lazy import)\n"
288
+ f"- Check for modules importing the container module"
289
+ )
159
290
 
160
- return False
291
+ # Track that we're importing this module
292
+ self._importing_modules.add(module_name)
293
+ try:
294
+ module = importlib.import_module(module_name)
295
+ yield module
296
+ finally:
297
+ # Always cleanup, even if import fails
298
+ self._importing_modules.discard(module_name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anydi
3
- Version: 0.70.1
3
+ Version: 0.71.0
4
4
  Summary: Dependency Injection library
5
5
  Keywords: dependency injection,dependencies,di,async,asyncio,application
6
6
  Author: Anton Ruhlov
@@ -1,16 +1,16 @@
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=7c96X4hrN9-OZglUedvdKjoKcYJKFMy7IeTAHF9dOUs,49203
4
+ anydi/_container.py,sha256=0Mhper1g4O4cmAaGOeXAkcfMeJhoJqcYTowhNKPQhhs,49237
5
5
  anydi/_context.py,sha256=ZQWxtBXWkrMsCk_L7K_A7-e09v5Mv9HApPH3LZ6ZF9k,3648
6
6
  anydi/_decorators.py,sha256=27AQBzp_P_ajO_bRk1ybgwuxTy9PMWGMJmfBvjriJ-8,5435
7
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
- anydi/_provider.py,sha256=5pMXyiGwBo2j6OHaoOktLQfiX2rkCf5aYXFlFi65JlQ,2898
11
+ anydi/_provider.py,sha256=1hUah9xmAVEVfE1AOFraodJiyfgRhO05egWq1aeRZCA,2923
12
12
  anydi/_resolver.py,sha256=xpBgFhUTMdHeNjqmUfOZMU5J8PviVkc5q_ckgwrdAhs,33603
13
- anydi/_scanner.py,sha256=gLs6Gdv12VVgT-J1vfCVOQ515drGtZQ4QNI8Bs0fqE0,5916
13
+ anydi/_scanner.py,sha256=Jmt2sXUcTij_lplFuEqqN2V_sP-MWQTPfsLSBKyLFVg,11826
14
14
  anydi/_types.py,sha256=QQY_WG6-e2VuqH2AqrStiM1bg2ACQMHaPtdOQVsDimU,1439
15
15
  anydi/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  anydi/ext/django/__init__.py,sha256=Ve8lncLU9dPY_Vjt4zihPgsSxwAtFHACn0XvBM5JG8k,367
@@ -23,7 +23,7 @@ anydi/ext/starlette/middleware.py,sha256=n_JJ7BcG2Mg2M5HwM_SBboxZ-mnnD6WWJn4khq7
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.70.1.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
27
- anydi-0.70.1.dist-info/entry_points.txt,sha256=oDl_yEX12KlWcDzsZBTg85GG1Jl1rpiYOG4C7EJvebs,87
28
- anydi-0.70.1.dist-info/METADATA,sha256=fSe7AsDMguaUHD6hCEELMRKVFx7Ge5B7gQjs3t5jbUA,8061
29
- anydi-0.70.1.dist-info/RECORD,,
26
+ anydi-0.71.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
27
+ anydi-0.71.0.dist-info/entry_points.txt,sha256=oDl_yEX12KlWcDzsZBTg85GG1Jl1rpiYOG4C7EJvebs,87
28
+ anydi-0.71.0.dist-info/METADATA,sha256=um8pPsgbpFdMq4htvL1PlnOB9OaYSJcRF4GnqAPlFFI,8061
29
+ anydi-0.71.0.dist-info/RECORD,,
File without changes