diff --git a/anydi/_generics.py b/anydi/_generics.py index 3e99b1c..8c62cab 100644 --- a/anydi/_generics.py +++ b/anydi/_generics.py @@ -6,9 +6,12 @@ from typing import Annotated, Any, TypeVar, Union, get_args, get_origin -def build_typevar_map(cls: type[Any]) -> dict[TypeVar, type[Any]]: +def build_typevar_map( + cls: type[Any], known: dict[TypeVar, type[Any]] | None = None +) -> dict[TypeVar, type[Any]]: """Build a mapping from TypeVars to concrete types for a class.""" - typevar_map: dict[TypeVar, type[Any]] = {} + # `known` carries what the subclass resolved, for bases that renamed a param + typevar_map: dict[TypeVar, type[Any]] = dict(known or {}) orig_bases = getattr(cls, "__orig_bases__", ()) @@ -17,7 +20,7 @@ def build_typevar_map(cls: type[Any]) -> dict[TypeVar, type[Any]]: if origin is None: # Not a parameterized generic, check if it's a class with bases if isinstance(base, type): - typevar_map.update(build_typevar_map(base)) + typevar_map.update(build_typevar_map(base, typevar_map)) continue args = get_args(base) @@ -35,7 +38,7 @@ def build_typevar_map(cls: type[Any]) -> dict[TypeVar, type[Any]]: # Recursively process the origin class if isinstance(origin, type): - typevar_map.update(build_typevar_map(origin)) + typevar_map.update(build_typevar_map(origin, typevar_map)) return typevar_map @@ -57,7 +60,8 @@ def _map_typevars_to_args( if arg in typevar_map: new_map[type_param] = typevar_map[arg] else: - new_map[type_param] = arg + # Resolve TypeVars nested in the argument, like `Repo[Box[T]]` + new_map[type_param] = resolve_typevars(arg, typevar_map) return new_map diff --git a/anydi/_scanner.py b/anydi/_scanner.py index 67bdb9f..2fe7250 100644 --- a/anydi/_scanner.py +++ b/anydi/_scanner.py @@ -3,6 +3,7 @@ import importlib import inspect import pkgutil +import threading from collections.abc import Iterable, Iterator from dataclasses import dataclass from types import ModuleType @@ -17,6 +18,21 @@ Package = ModuleType | str PackageOrIterable = Package | Iterable[Package] +_scanning = threading.local() + + +def _scanning_packages() -> set[str]: + """Return the packages this thread is scanning.""" + packages: set[str] | None = getattr(_scanning, "packages", None) + if packages is None: + packages = _scanning.packages = set() + return packages + + +def _is_anydi_module(module_name: str) -> bool: + """Check if the module belongs to anydi itself.""" + return module_name == "anydi" or module_name.startswith("anydi.") + @dataclass(kw_only=True) class ScannedDependency: @@ -30,8 +46,6 @@ def __post_init__(self) -> None: class Scanner: - _scanning_packages: set[str] = set() - def __init__(self, container: Container) -> None: self._container = container self._importing_modules: set[str] = set() @@ -61,7 +75,8 @@ def scan( ignore = self._resolve_relative_packages(ignore, caller_package) pkg_names = {p if isinstance(p, str) else p.__name__ for p in packages} - overlap = pkg_names & Scanner._scanning_packages + scanning = _scanning_packages() + overlap = pkg_names & scanning if overlap: raise RuntimeError( f"Circular import detected: scan() called recursively!\n\n" @@ -74,11 +89,11 @@ def scan( "- Avoid lazy container initialization in scanned modules" ) - Scanner._scanning_packages.update(pkg_names) + scanning.update(pkg_names) try: self._do_scan(packages, tags=tags, ignore=ignore) finally: - Scanner._scanning_packages -= pkg_names + scanning -= pkg_names def _do_scan( # noqa: C901 self, @@ -163,7 +178,7 @@ def _get_caller_package( if frame is None: break module_name = frame.f_globals.get("__name__") - if module_name and not module_name.startswith("anydi"): + if module_name and not _is_anydi_module(module_name): # Return package portion (remove module name if present) if "." in module_name: return module_name.rsplit(".", 1)[0] diff --git a/tests/test_container.py b/tests/test_container.py index 8c89591..ad85784 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -4340,6 +4340,27 @@ class FinalService(MiddleUserService): assert isinstance(service, FinalService) assert isinstance(service.repo, UserRepository) + def test_resolve_generic_renamed_along_the_way(self) -> None: + """Test resolving when an intermediate class renames the parameter.""" + + class Service(Generic[ModelT]): + def __init__(self, repo: Repository[ModelT]) -> None: + self.repo = repo + + class MiddleService(Service[U], Generic[U]): # ty: ignore[invalid-type-arguments] + pass + + class UserService(MiddleService[User]): + pass + + container = Container() + container.register(UserRepository, alias=Repository[User]) + container.register(UserService) + + service = container.resolve(UserService) + + assert isinstance(service.repo, UserRepository) + def test_resolve_multiple_type_params(self) -> None: """Test resolving with multiple type parameters.""" diff --git a/tests/test_generics.py b/tests/test_generics.py index ab858ac..b3787ff 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -20,6 +20,7 @@ class Guest(Model): U = TypeVar("U", bound=Model) T_any = TypeVar("T_any") U_any = TypeVar("U_any") +V_any = TypeVar("V_any") class Repository(Generic[T]): @@ -61,6 +62,36 @@ class FullySpecialized(PartialSpecialized[Guest]): pass +# Inheritance that renames the parameter along the way +class RenamingService(BaseService[U_any], Generic[U_any]): + pass + + +class RenamedConcrete(RenamingService[User]): + pass + + +class RenamingAgain(RenamingService[V_any], Generic[V_any]): + pass + + +class RenamedTwice(RenamingAgain[Guest]): + pass + + +# A parameter nested inside the argument of a base +class Box(Generic[T_any]): + pass + + +class BoxedService(BaseService[Box[V_any]], Generic[V_any]): + pass + + +class BoxedConcrete(BoxedService[User]): + pass + + # Test build_typevar_map def test_build_typevar_map_basic() -> None: typevar_map = build_typevar_map(UserHandler) @@ -87,6 +118,25 @@ def test_build_typevar_map_multiple_params() -> None: assert typevar_map[U_any] is Guest +def test_build_typevar_map_renamed_param() -> None: + typevar_map = build_typevar_map(RenamedConcrete) + assert typevar_map[U_any] is User + assert typevar_map[T_any] is User + + +def test_build_typevar_map_renamed_param_twice() -> None: + typevar_map = build_typevar_map(RenamedTwice) + assert typevar_map[V_any] is Guest + assert typevar_map[U_any] is Guest + assert typevar_map[T_any] is Guest + + +def test_build_typevar_map_nested_arg() -> None: + typevar_map = build_typevar_map(BoxedConcrete) + assert typevar_map[V_any] is User + assert typevar_map[T_any] == Box[User] + + # Test resolve_typevars def test_resolve_typevar_directly() -> None: V = TypeVar("V") diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 84f9808..63ea0be 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1,11 +1,13 @@ import importlib import sys +import threading +from pathlib import Path import pytest from pytest_mock import MockerFixture from anydi import Container, singleton -from anydi._scanner import Scanner +from anydi._scanner import Scanner, _scanning_packages from tests.fixtures import Service from tests.scan_app import ScanAppModule @@ -294,7 +296,7 @@ def test_scan_detects_recursive_scan_call(self, container: Container) -> None: """Test that recursive scan() calls are detected (e.g., via lazy proxy).""" scanner = Scanner(container) # Simulate scan already in progress for this package - Scanner._scanning_packages.add("tests.scan_app") + _scanning_packages().add("tests.scan_app") try: with pytest.raises(RuntimeError) as exc_info: @@ -305,7 +307,29 @@ def test_scan_detects_recursive_scan_call(self, container: Container) -> None: assert "tests.scan_app" in error_message assert "Solutions:" in error_message finally: - Scanner._scanning_packages.discard("tests.scan_app") + _scanning_packages().discard("tests.scan_app") + + def test_scan_same_package_from_two_threads(self) -> None: + """Test that two containers can scan the same package at once.""" + barrier = threading.Barrier(2) + errors: list[Exception] = [] + + def scan() -> None: + container = Container() + container.register_module(ScanAppModule) + barrier.wait() + try: + container.scan("tests.scan_app") + except Exception as exc: + errors.append(exc) + + threads = [threading.Thread(target=scan) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert errors == [] class TestRelativePackageScanning: @@ -376,3 +400,24 @@ def test_has_relative_packages_detects_relative(self, container: Container) -> N assert scanner._has_relative_packages(".") assert not scanner._has_relative_packages(["absolute"]) assert not scanner._has_relative_packages(None) + + def test_caller_package_named_like_anydi(self, tmp_path: Path) -> None: + """Test that a package named like `anydi` is still seen as the caller.""" + package = tmp_path / "anydi_shop" + package.mkdir() + (package / "__init__.py").write_text("") + (package / "app.py").write_text( + "from anydi import Container\n" + "from anydi._scanner import Scanner\n" + "caller_package = Scanner(Container())._get_caller_package(['.'], None)\n" + ) + + sys.path.insert(0, str(tmp_path)) + try: + app = importlib.import_module("anydi_shop.app") + finally: + sys.path.remove(str(tmp_path)) + sys.modules.pop("anydi_shop.app", None) + sys.modules.pop("anydi_shop", None) + + assert app.caller_package == "anydi_shop"