Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions anydi/_generics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__", ())

Expand All @@ -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)
Expand All @@ -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


Expand All @@ -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


Expand Down
27 changes: 21 additions & 6 deletions anydi/_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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]
Expand Down
21 changes: 21 additions & 0 deletions tests/test_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
50 changes: 50 additions & 0 deletions tests/test_generics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down
51 changes: 48 additions & 3 deletions tests/test_scanner.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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"
Loading