From b8bba190dc2142511155a00444fc644bd6d2705a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 7 Apr 2026 17:17:25 +0300 Subject: [PATCH] small fixes and improvements --- .gitignore | 1 + AGENTS.md | 124 --------------------- CLAUDE.md | 21 ++-- docs/introduction/resolving.md | 2 + docs/migration/to-2.x.md | 3 + docs/providers/factories.md | 14 ++- docs/testing/fixtures.md | 3 + modern_di/container.py | 3 +- modern_di/providers/context_provider.py | 2 +- modern_di/providers/factory.py | 2 +- modern_di/registries/cache_registry.py | 22 +++- modern_di/registries/context_registry.py | 2 +- modern_di/registries/overrides_registry.py | 2 +- modern_di/registries/providers_registry.py | 14 ++- modern_di/types_parser.py | 28 +++-- tests/providers/test_factory.py | 2 +- tests/providers/test_singleton.py | 66 +++++++++++ tests/test_types_parser.py | 8 +- 18 files changed, 151 insertions(+), 168 deletions(-) delete mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 8cf31db..708f278 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ dist/ .python-version .venv uv.lock +plan.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 0c8c410..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,124 +0,0 @@ -# Project Overview - -## Project Type -This is a Python monorepo containing a dependency injection (DI) framework called `modern-di` and its integrations with popular web frameworks. - -## Purpose -`modern-di` is a Python dependency injection framework that supports: -- Async and sync dependency resolution -- Scopes and granular context management -- Python 3.10+ support -- Fully typed and tested -- Integrations with `FastAPI`, `FastStream` and `LiteStar` - -## Architecture -The project follows a monorepo structure with multiple packages: -1. `modern-di` - The core dependency injection framework -2. `modern-di-fastapi` - Integration with FastAPI -3. `modern-di-faststream` - Integration with FastStream -4. `modern-di-litestar` - Integration with LiteStar - -Each package is independently versioned and published to PyPI. - -## Technologies -- Python 3.10+ -- uv for package management and virtual environments -- hatchling for building packages -- pytest for testing -- ruff for linting and formatting -- ty for type checking -- mkdocs with Material theme for documentation -- GitHub Actions for CI/CD - -# Building and Running - -## Development Setup -1. Install dependencies: - ```bash - just install - ``` - This command uses `uv` to install all dependencies for all packages. - -## Development Commands -The project uses `just` (a command runner) for common development tasks: - -### Linting and Formatting -```bash -just lint # Format and fix code with ruff, then run ty -just lint-ci # Check formatting and types without making changes -``` - -### Testing -```bash -just test # Run all tests -just test-core # Run tests for the core package -just test-fastapi # Run tests for FastAPI integration -just test-litestar # Run tests for LiteStar integration -just test-faststream # Run tests for FastStream integration -``` - -### Publishing -```bash -just publish # Build and publish a package to PyPI -``` - -## Manual Commands (without just) -If you don't have `just` installed, you can use uv directly: - -### Install dependencies -```bash -uv lock --upgrade -uv sync --all-extras --all-packages --frozen -``` - -### Linting and formatting -```bash -uv run ruff format . -uv run ruff check . --fix -uv run ty check -``` - -### Testing -```bash -uv run pytest # All tests -uv run --directory=packages/modern-di pytest # Core tests -uv run --directory=packages/modern-di-fastapi pytest # FastAPI tests -uv run --directory=packages/modern-di-litestar pytest # LiteStar tests -uv run --directory=packages/modern-di-faststream pytest # FastStream tests -``` - -# Development Conventions - -## Code Style -- Line length: 120 characters -- Strict type checking enabled -- Ruff is used for linting with most rules enabled except for a few explicitly ignored ones -- isort configuration for import sorting - -## Testing Practices -- Both synchronous and asynchronous tests are supported -- Tests use pytest with coverage reporting -- Each package has its own test suite in a `tests_*` directory -- Tests follow a pattern of testing container behavior, provider resolution, and scope management - -## Documentation -- Documentation is written in Markdown using MkDocs with Material theme -- Documentation is organized in a hierarchical structure covering quickstart, concepts, providers, integrations, testing, and development -- Code examples are included throughout the documentation - -## Package Structure -- Each package follows the standard Python package structure -- Source code is in a directory named after the package (e.g., `modern_di`) -- Tests are in a separate directory (e.g., `tests_core`) -- Each package has its own `pyproject.toml` file for dependencies and metadata - -## CI/CD -- GitHub Actions are used for continuous integration -- Separate workflows exist for linting, testing each package, and publishing -- Tests run on multiple Python versions (3.10 through 3.14) -- Publishing requires a PYPI_TOKEN secret - -## Versioning -- Packages are independently versioned -- Version numbers follow semantic versioning -- Alpha releases are supported (as seen in the FastAPI integration dependency on `modern-di>=1.0.0alpha`) diff --git a/CLAUDE.md b/CLAUDE.md index 214c135..0bd3ba7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -`modern-di` is a **zero-dependency** Python dependency injection framework that wires up object graphs from type annotations, manages lifetimes via hierarchical scopes, and supports both sync and async finalizers. This repo contains the core package plus framework integrations (FastAPI, FastStream, LiteStar), each independently versioned and published to PyPI. +`modern-di` is a **zero-dependency** Python dependency injection framework that wires up object graphs from type annotations, manages lifetimes via hierarchical scopes, and supports both sync and async finalizers. Framework integrations (FastAPI, FastStream, LiteStar) live in **separate repositories** and are published as separate PyPI packages. ## Commands @@ -18,14 +18,10 @@ just test # uv run pytest (with coverage by default) just test-branch # pytest with branch coverage ``` -Run a single test file: +`just test` passes extra args to pytest: ```bash -uv run pytest tests/providers/test_factory.py -``` - -Run a specific test by name: -```bash -uv run pytest tests/providers/test_factory.py -k test_name +just test tests/providers/test_factory.py +just test tests/providers/test_factory.py -k test_name ``` Without `just`: @@ -53,7 +49,9 @@ class MyGroup(Group): my_service = providers.Factory(scope=Scope.APP, creator=MyService) ``` -`Factory` parses the `creator`'s `__init__` type hints at declaration time via `types_parser.parse_creator()`. During resolution it looks up each parameter type in `providers_registry` and recursively resolves dependencies. +`Factory` parses the `creator`'s `__init__` type hints at declaration time via `types_parser.parse_creator()`. During resolution it looks up each parameter type in `providers_registry` and recursively resolves dependencies. There is no separate `Singleton` class — singleton behavior is `Factory(cache_settings=CacheSettings())`. Pass `kwargs={}` to supply static arguments that bypass type-based resolution. Pass `skip_creator_parsing=True` for callables whose signatures cannot be introspected. + +`ContextProvider` is for runtime values injected at container creation time (e.g. a request object). `container_provider` is an auto-registered singleton that resolves to the `Container` itself. ### Resolution flow @@ -76,10 +74,13 @@ class MyGroup(Group): ### Key files - `modern_di/container.py` — Container class, the main entry point -- `modern_di/providers/factory.py` — Factory provider with caching and finalizer support +- `modern_di/providers/factory.py` — Factory and CacheSettings (singleton pattern via caching + optional finalizer) +- `modern_di/providers/context_provider.py` — ContextProvider for runtime-injected values +- `modern_di/providers/container_provider.py` — auto-registered provider that resolves to the Container itself - `modern_di/types_parser.py` — Signature introspection engine (parses type hints for DI wiring) - `modern_di/scope.py` — Scope enum - `modern_di/group.py` — Group base class for provider namespaces +- `modern_di/errors.py` — Error message templates ### Testing patterns diff --git a/docs/introduction/resolving.md b/docs/introduction/resolving.md index f90d579..164990d 100644 --- a/docs/introduction/resolving.md +++ b/docs/introduction/resolving.md @@ -22,6 +22,8 @@ When a factory is created: 3. If a matching provider is found, it's automatically injected when the factory is resolved 4. If no matching provider is found and no default value is provided, an error is raised +For union-typed parameters (e.g. `dep: A | B`), the first type in the union that has a registered provider is used. If you need a specific type injected, use a concrete type annotation or supply the value explicitly via `kwargs`. + Example: ```python diff --git a/docs/migration/to-2.x.md b/docs/migration/to-2.x.md index a9d4ca1..70f77f6 100644 --- a/docs/migration/to-2.x.md +++ b/docs/migration/to-2.x.md @@ -230,6 +230,9 @@ instance = container.resolve_provider(provider) instance = container.resolve(SomeType) ``` +!!! note "Async finalizers are still supported" + Only *resolution* became sync-only in 2.x. Async *finalizers* (cleanup functions) are still fully supported via `CacheSettings(finalizer=async_cleanup_fn)` and `await container.close_async()`. The distinction: you cannot `await` during dependency resolution, but you can use async functions to clean up resources when a container is closed. + ## Migration Steps 1. **Update Dependencies**: Ensure all modern-di packages are updated to 2.x versions diff --git a/docs/providers/factories.md b/docs/providers/factories.md index e6fa32d..de87788 100644 --- a/docs/providers/factories.md +++ b/docs/providers/factories.md @@ -39,6 +39,10 @@ Use this to provide specific values for parameters or override automatically res Configuration for caching instances. Only applicable for cached factories. Use `providers.CacheSettings()` to enable caching with optional cleanup configuration. +### Union type parameters + +When a parameter is annotated with a union type (e.g. `dep: A | B`), Modern-DI resolves the **first registered type** that matches. The order is determined by how types appear in the union left-to-right. If you rely on a specific type being injected, prefer a concrete type annotation over a union. + ### skip_creator_parsing Disables automatic dependency resolution. When `True`: @@ -90,7 +94,15 @@ assert isinstance(instance2, IndependentFactory) Cached factories resolve the dependency only once and cache the resolved instance for future injections. -The caching mechanism is thread-safe, ensuring that even when multiple threads attempt to resolve the same cached factory simultaneously, only one instance will be created. +The caching mechanism is thread-safe by default, ensuring that even when multiple threads attempt to resolve the same cached factory simultaneously, only one instance will be created. + +If your application is single-threaded, you can disable the lock for a small performance gain: + +```python +container = Container(groups=[Dependencies], use_lock=False) +``` + +Do not set `use_lock=False` in multi-threaded applications — it removes the guarantee that only one instance is created per cached factory. ```python import random diff --git a/docs/testing/fixtures.md b/docs/testing/fixtures.md index 64afa1d..789013f 100644 --- a/docs/testing/fixtures.md +++ b/docs/testing/fixtures.md @@ -49,6 +49,9 @@ def mock_dependencies(di_container: modern_di.Container) -> typing.Iterator[None di_container.reset_override(Dependencies.simple_factory) ``` +!!! note "Overrides are global" + `container.override()` and `container.reset_override()` operate on the shared overrides registry, which is shared across all containers in the same tree (parent and all children). Calling `override()` on a child container affects every container in the tree for the duration of the override. Always call `reset_override()` in a `finally` block or use a fixture that guarantees cleanup. + ## 2. Use fixtures in tests: ```python diff --git a/modern_di/container.py b/modern_di/container.py index d40ae97..2acf812 100644 --- a/modern_di/container.py +++ b/modern_di/container.py @@ -45,8 +45,7 @@ def __init__( self.overrides_registry = parent_container.overrides_registry else: self.providers_registry = ProvidersRegistry() - container_provider.bound_type = type(self) - self.providers_registry.add_providers(container_provider) + self.providers_registry.register(type(self), container_provider) self.overrides_registry = OverridesRegistry() if groups: for one_group in groups: diff --git a/modern_di/providers/context_provider.py b/modern_di/providers/context_provider.py index 2b5e41c..46c90e6 100644 --- a/modern_di/providers/context_provider.py +++ b/modern_di/providers/context_provider.py @@ -17,7 +17,7 @@ def __init__( *, scope: Scope = Scope.APP, context_type: type[types.T_co], - bound_type: type | None = types.UNSET, # type: ignore[assignment] + bound_type: type | None = types.UNSET, # ty: ignore[invalid-parameter-default] ) -> None: super().__init__(scope=scope, bound_type=bound_type if bound_type != types.UNSET else context_type) self._context_type = context_type diff --git a/modern_di/providers/factory.py b/modern_di/providers/factory.py index 45bce30..920cb7f 100644 --- a/modern_di/providers/factory.py +++ b/modern_di/providers/factory.py @@ -31,7 +31,7 @@ def __init__( # noqa: PLR0913 *, scope: Scope = Scope.APP, creator: typing.Callable[..., types.T_co], - bound_type: type | None = types.UNSET, # type: ignore[assignment] + bound_type: type | None = types.UNSET, # ty: ignore[invalid-parameter-default] kwargs: dict[str, typing.Any] | None = None, cache_settings: CacheSettings[types.T_co] | None = None, skip_creator_parsing: bool = False, diff --git a/modern_di/registries/cache_registry.py b/modern_di/registries/cache_registry.py index adca9e6..7acb053 100644 --- a/modern_di/registries/cache_registry.py +++ b/modern_di/registries/cache_registry.py @@ -19,7 +19,7 @@ def _clear(self) -> None: async def close_async(self) -> None: if self.cache and self.settings and self.settings.finalizer: if self.settings.is_async_finalizer: - await self.settings.finalizer(self.cache) # type: ignore[misc] + await self.settings.finalizer(self.cache) # ty: ignore[invalid-await] else: self.settings.finalizer(self.cache) @@ -39,7 +39,7 @@ def close_sync(self) -> None: self._clear() -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +@dataclasses.dataclass(kw_only=True, slots=True) class CacheRegistry: _items: dict[str, CacheItem] = dataclasses.field(init=False, default_factory=dict) @@ -47,9 +47,23 @@ def fetch_cache_item(self, provider: Factory[types.T_co]) -> CacheItem: return self._items.setdefault(provider.provider_id, CacheItem(settings=provider.cache_settings)) async def close_async(self) -> None: + errors: list[BaseException] = [] for cache_item in self._items.values(): - await cache_item.close_async() + try: + await cache_item.close_async() + except Exception as e: # noqa: BLE001, PERF203 + errors.append(e) + if errors: + msg = f"Errors during async cleanup: {errors}" + raise RuntimeError(msg) def close_sync(self) -> None: + errors: list[BaseException] = [] for cache_item in self._items.values(): - cache_item.close_sync() + try: + cache_item.close_sync() + except Exception as e: # noqa: BLE001, PERF203 + errors.append(e) + if errors: + msg = f"Errors during sync cleanup: {errors}" + raise RuntimeError(msg) diff --git a/modern_di/registries/context_registry.py b/modern_di/registries/context_registry.py index 3c78256..360e547 100644 --- a/modern_di/registries/context_registry.py +++ b/modern_di/registries/context_registry.py @@ -4,7 +4,7 @@ from modern_di import types -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +@dataclasses.dataclass(kw_only=True, slots=True) class ContextRegistry: context: dict[type[typing.Any], typing.Any] diff --git a/modern_di/registries/overrides_registry.py b/modern_di/registries/overrides_registry.py index 3a69dd5..0992846 100644 --- a/modern_di/registries/overrides_registry.py +++ b/modern_di/registries/overrides_registry.py @@ -9,7 +9,7 @@ _UNSET = object() -@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +@dataclasses.dataclass(kw_only=True, slots=True) class OverridesRegistry: overrides: dict[str, typing.Any] = dataclasses.field(init=False, default_factory=dict) diff --git a/modern_di/registries/providers_registry.py b/modern_di/registries/providers_registry.py index e414cb4..9bfbcff 100644 --- a/modern_di/registries/providers_registry.py +++ b/modern_di/registries/providers_registry.py @@ -13,13 +13,15 @@ def __init__(self) -> None: def find_provider(self, dependency_type: type[types.T]) -> AbstractProvider[types.T] | None: return self._providers.get(dependency_type) + def register(self, provider_type: type, provider: AbstractProvider[typing.Any]) -> None: + if provider_type in self._providers: + raise RuntimeError(errors.PROVIDER_DUPLICATE_TYPE_ERROR.format(provider_type=provider_type)) + + self._providers[provider_type] = provider + def add_providers(self, *args: AbstractProvider[typing.Any]) -> None: for provider in args: - provider_type = provider.bound_type - if not provider_type: + if not provider.bound_type: continue - if provider_type in self._providers: - raise RuntimeError(errors.PROVIDER_DUPLICATE_TYPE_ERROR.format(provider_type=provider_type)) - - self._providers[provider_type] = provider + self.register(provider.bound_type, provider) diff --git a/modern_di/types_parser.py b/modern_di/types_parser.py index dbfc44a..e9010d2 100644 --- a/modern_di/types_parser.py +++ b/modern_di/types_parser.py @@ -2,6 +2,7 @@ import inspect import types import typing +import warnings from modern_di.types import UNSET @@ -14,26 +15,24 @@ class SignatureItem: default: object = UNSET @classmethod - def from_type(cls, type_: type, default: object = UNSET) -> "SignatureItem": # noqa: C901 + def from_type(cls, type_: type, default: object = UNSET) -> "SignatureItem": if type_ is types.NoneType: return cls() # typing.Annotated - if isinstance(type_, typing._AnnotatedAlias): # type: ignore[attr-defined] # noqa: SLF001 - type_ = type_.__args__[0] + if hasattr(type_, "__metadata__"): + type_ = typing.get_args(type_)[0] result: dict[str, typing.Any] = {"default": default} # union type - if isinstance(type_, (types.UnionType, typing._UnionGenericAlias)): # type: ignore[attr-defined] # noqa: SLF001 - args = [x.__origin__ if isinstance(x, types.GenericAlias) else x for x in type_.__args__] + if isinstance(type_, types.UnionType) or typing.get_origin(type_) is typing.Union: + args = [typing.get_origin(x) or x for x in typing.get_args(type_)] if types.NoneType in args: result["is_nullable"] = True args.remove(types.NoneType) - for i, arg in enumerate(args): - if isinstance(arg, (types.GenericAlias, typing._GenericAlias)): # type: ignore[attr-defined] # noqa: SLF001 - args[i] = arg.__origin__ + args = [typing.get_origin(arg) or arg for arg in args] if len(args) > 1: result["args"] = args @@ -41,9 +40,9 @@ def from_type(cls, type_: type, default: object = UNSET) -> "SignatureItem": # result["arg_type"] = args[0] # generic - elif isinstance(type_, (types.GenericAlias, typing._GenericAlias)): # type: ignore[attr-defined] # noqa: SLF001 - result["arg_type"] = type_.__origin__ - result["args"] = list(type_.__args__) + elif typing.get_origin(type_) is not None: + result["arg_type"] = typing.get_origin(type_) + result["args"] = list(typing.get_args(type_)) elif isinstance(type_, type): result["arg_type"] = type_ @@ -63,7 +62,12 @@ def parse_creator(creator: typing.Callable[..., typing.Any]) -> tuple[SignatureI type_hints = typing.get_type_hints(creator.__init__) else: type_hints = typing.get_type_hints(creator) - except NameError: + except NameError as e: + warnings.warn( + f"Failed to resolve type hints for {creator}: {e}. Dependency wiring will be skipped.", + UserWarning, + stacklevel=2, + ) type_hints = {} param_hints = {} diff --git a/tests/providers/test_factory.py b/tests/providers/test_factory.py index 73e6bd0..de3da99 100644 --- a/tests/providers/test_factory.py +++ b/tests/providers/test_factory.py @@ -26,7 +26,7 @@ def func_with_union(dep1: SimpleCreator | int) -> str: return str(dep1) -def func_with_broken_annotation(dep1: "SomeWrongClass") -> None: ... # type: ignore[name-defined] # noqa: F821 +def func_with_broken_annotation(dep1: "SomeWrongClass") -> None: ... # ty: ignore[unresolved-reference] # noqa: F821 class MyGroup(Group): diff --git a/tests/providers/test_singleton.py b/tests/providers/test_singleton.py index 94e2dce..908375c 100644 --- a/tests/providers/test_singleton.py +++ b/tests/providers/test_singleton.py @@ -86,6 +86,72 @@ def test_app_singleton_in_request_scope() -> None: assert singleton1 is singleton2 +def test_sync_finalizer_exception_does_not_abort_remaining_cleanup() -> None: + cleaned_up: list[str] = [] + + def failing_finalizer(_: SimpleCreator) -> None: + msg = "finalizer failed" + raise RuntimeError(msg) + + def good_finalizer(_: SimpleCreator) -> None: + cleaned_up.append("done") + + class BrokenGroup(Group): + first = providers.Factory( + creator=SimpleCreator, + kwargs={"dep1": "first"}, + cache_settings=providers.CacheSettings(finalizer=failing_finalizer), + ) + second = providers.Factory( + creator=SimpleCreator, + bound_type=None, + kwargs={"dep1": "second"}, + cache_settings=providers.CacheSettings(finalizer=good_finalizer), + ) + + app_container = Container(groups=[BrokenGroup]) + app_container.resolve_provider(BrokenGroup.first) + app_container.resolve_provider(BrokenGroup.second) + + with pytest.raises(RuntimeError, match="Errors during sync cleanup"): + app_container.close_sync() + + assert cleaned_up == ["done"] + + +async def test_async_finalizer_exception_does_not_abort_remaining_cleanup() -> None: + cleaned_up: list[str] = [] + + async def failing_finalizer(_: SimpleCreator) -> None: + msg = "async finalizer failed" + raise RuntimeError(msg) + + async def good_finalizer(_: SimpleCreator) -> None: + cleaned_up.append("done") + + class BrokenAsyncGroup(Group): + first = providers.Factory( + creator=SimpleCreator, + kwargs={"dep1": "first"}, + cache_settings=providers.CacheSettings(finalizer=failing_finalizer), + ) + second = providers.Factory( + creator=SimpleCreator, + bound_type=None, + kwargs={"dep1": "second"}, + cache_settings=providers.CacheSettings(finalizer=good_finalizer), + ) + + app_container = Container(groups=[BrokenAsyncGroup]) + app_container.resolve_provider(BrokenAsyncGroup.first) + app_container.resolve_provider(BrokenAsyncGroup.second) + + with pytest.raises(RuntimeError, match="Errors during async cleanup"): + await app_container.close_async() + + assert cleaned_up == ["done"] + + @pytest.mark.repeat(10) def test_singleton_threading_concurrency() -> None: calls: int = 0 diff --git a/tests/test_types_parser.py b/tests/test_types_parser.py index 8944b6b..466cf0d 100644 --- a/tests/test_types_parser.py +++ b/tests/test_types_parser.py @@ -34,11 +34,11 @@ def test_signature_item_parser(type_: type, result: SignatureItem) -> None: assert SignatureItem.from_type(type_) == result -def simple_func(arg1: int, arg2: str | None = None) -> int: ... # type: ignore[empty-body] +def simple_func(arg1: int, arg2: str | None = None) -> int: ... # ty: ignore[empty-body] def none_func(arg1: typing.Annotated[int, None], arg2: str | None = None) -> None: ... def args_kwargs_func(*args: int, **kwargs: str) -> None: ... def func_with_str_annotations(arg1: "list[int]", arg2: "str") -> None: ... -async def async_func(arg1: int = 1, arg2="str") -> int: ... # type: ignore[no-untyped-def,empty-body] # noqa: ANN001 +async def async_func(arg1: int = 1, arg2="str") -> int: ... # ty: ignore[empty-body] # noqa: ANN001 @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) @@ -61,11 +61,11 @@ class ClassWithStringAnnotations: def __init__(self, arg1: "str", arg2: "int") -> None: ... -def func_with_wrong_annotations(arg1: "Protocol", arg2: "str") -> None: ... # type: ignore[valid-type] +def func_with_wrong_annotations(arg1: "Protocol", arg2: "str") -> None: ... # ty: ignore[invalid-type-form] class ClassWithWrongAnnotations: - def __init__(self, arg1: "WrongType", arg2: "int") -> None: ... # type: ignore[name-defined] # noqa: F821 + def __init__(self, arg1: "WrongType", arg2: "int") -> None: ... # ty: ignore[unresolved-reference] # noqa: F821 @pytest.mark.parametrize(