From cf02da2d8d62c4ee239599dd5e7681b1af107dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Mandart?= Date: Wed, 21 Oct 2026 15:36:47 +0200 Subject: [PATCH] docs: Fix inaccurate examples and document missing behaviours Fix examples that didn't run (positional scope name, undefined profiles, missing imports) and clarify entrypoints and constants. Document module locking, should_be_injectable, getter defaults and exceptions. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/imports.md | 2 +- docs/guides/main-functions.md | 81 ++++++++++++++++++---------- docs/guides/profiles.md | 20 +++++-- docs/guides/register-dependencies.md | 48 ++++++++++++++--- docs/guides/resolve-dependencies.md | 20 ++++++- docs/guides/scopes.md | 7 ++- docs/index.md | 8 +-- docs/integrations/fastapi.md | 6 ++- docs/next-steps.md | 4 ++ docs/testing/classes.md | 2 +- docs/testing/test-dependencies.md | 5 +- docs/threadsafety.md | 4 +- 12 files changed, 153 insertions(+), 54 deletions(-) diff --git a/docs/guides/imports.md b/docs/guides/imports.md index 729f233..69924bc 100644 --- a/docs/guides/imports.md +++ b/docs/guides/imports.md @@ -23,7 +23,7 @@ load_packages(adapters, services) load_packages("src.adapters", "src.services") ``` -This is the simplest approach and works well when you want to import everything from specific packages. +This is the simplest approach and works well when you want to import everything from specific packages. A `predicate` keyword argument can also be passed to filter the imported modules, as described below. ## PythonModuleLoader diff --git a/docs/guides/main-functions.md b/docs/guides/main-functions.md index b94f16e..2b1ce82 100644 --- a/docs/guides/main-functions.md +++ b/docs/guides/main-functions.md @@ -1,12 +1,40 @@ # Set up main functions -As we've seen throughout this guide, there can be quite a bit of setup around a main function: loading modules, defining scopes, injecting dependencies, loading profiles, etc. This becomes repetitive when you have multiple entry points in your project (CLI commands, etc.). +A main function often needs some setup before running: importing modules, loading a profile, defining scopes, injecting dependencies. This becomes repetitive when your project has several entry points (CLI commands, workers, scripts, etc.). -To solve this, `python-injection` provides **entrypoints**, a way to create custom decorators that encapsulate all your setup logic using a builder pattern. +To solve this, `python-injection` provides **entrypoints**: custom decorators that apply all your setup logic, built step by step. ## Creating an entrypoint -Use the `@entrypointmaker` decorator to define your setup logic once: +Use the `@entrypointmaker` decorator to define your setup logic once. The simplest entrypoint only injects dependencies: +```python +from injection.entrypoint import Entrypoint, entrypointmaker + + +@entrypointmaker +def entrypoint[**P, T](self: Entrypoint[P, T]) -> Entrypoint[P, T]: + return self.inject() +``` + +Then use your custom `@entrypoint` decorator on any function: +```python +@entrypoint +def main(dependency: Dependency = NotImplemented): ... + + +if __name__ == "__main__": + main() +``` + +The builder provides the following steps: + +- **`inject()`**: injects the dependencies of the main function. +- **`load_modules(loader, *packages)`**: imports modules with a [`PythonModuleLoader`](imports.md#pythonmoduleloader). Packages can be passed as strings. +- **`load_profile(name)`**: loads a [profile](profiles.md) (see [below](#integrating-with-profileloader)). +- **`decorate(decorator)`**: applies any decorator, such as a scope definition. +- **`async_to_sync(run=asyncio.run)`**: turns an async main function into a sync one. + +A complete setup for an async main function looks like this: ```python from injection import adefine_scope from injection.entrypoint import AsyncEntrypoint, Entrypoint, entrypointmaker @@ -15,23 +43,17 @@ from injection.loaders import PythonModuleLoader @entrypointmaker def entrypoint[**P, T](self: AsyncEntrypoint[P, T]) -> Entrypoint[P, T]: - import src - module_loader = PythonModuleLoader.endswith("_impl") return ( self.inject() .decorate(adefine_scope("lifespan", kind="shared")) .async_to_sync() - .load_modules(module_loader, src) + .load_modules(module_loader, "src") ) -``` -Now you can use your custom `@entrypoint` decorator on any function: -```python + @entrypoint -async def main(dependency: Dependency): - # All setup is automatically applied - ... +async def main(dependency: Dependency = NotImplemented): ... if __name__ == "__main__": @@ -39,14 +61,14 @@ if __name__ == "__main__": ``` !!! info "Builder execution order" - The builder instructions execute in **reverse order**. Each method call re-decorates the main function, so the last instruction in the chain is call first. In the example above, modules are loaded first, then the function is converted to sync, then the scope is defined, and finally dependencies are injected. + The builder steps execute in **reverse order**. Each step wraps the main function again, so the last step of the chain is called first. In the example above, modules are loaded first, then the function is converted to sync, then the scope is defined, and finally dependencies are injected. ### Automatic execution -The entrypoint decorator accepts an optional `autocall` parameter. When set to `True`, the decorated function is automatically called: +The entrypoint decorator accepts an optional `autocall` parameter. When set to `True`, the decorated function is called as soon as it is decorated: ```python @entrypoint(autocall=True) -async def main(dependency: Dependency): +async def main(dependency: Dependency = NotImplemented): # This function runs automatically when the module is executed ... ``` @@ -55,7 +77,7 @@ This is particularly convenient for scripts and CLI commands where you want the ## Integrating with ProfileLoader -If you're using a [`ProfileLoader`](profiles.md#profileloader) in your project, pass it to `@entrypointmaker` using the `profile_loader` parameter: +If you're using a [`ProfileLoader`](profiles.md#profileloader) in your project, pass it to `@entrypointmaker` using the `profile_loader` parameter, then load a profile with `load_profile`: ```python from injection.entrypoint import Entrypoint, entrypointmaker from injection.loaders import ProfileLoader, PythonModuleLoader @@ -65,28 +87,29 @@ profile_loader = ProfileLoader(...) @entrypointmaker(profile_loader=profile_loader) def entrypoint[**P, T](self: Entrypoint[P, T]) -> Entrypoint[P, T]: - import src - module_loader = PythonModuleLoader.endswith("_impl") return ( self.inject() - .load_profile(Profile.DEV) # Load a specific profile - .load_modules(module_loader, src) + # Profile is the StrEnum defined in the Profiles guide + .load_profile(Profile.DEV) + .load_modules(module_loader, "src") ) ``` -The `load_profile` method accepts a profile name and loads it before the main function executes. +The profile is loaded before the main function executes, and unloaded once it returns. ## Resolving dependencies in the setup -You can resolve dependencies from the setup function parameters. These dependencies must be registered in the default module (not in a profile-specific module) and should preferably be transient or constant. This is particularly useful for resolving configuration to determine which profile to load: +You can resolve dependencies from the setup function parameters. This is particularly useful for resolving configuration to determine which profile to load: ```python from dataclasses import dataclass from injection import constant from injection.entrypoint import Entrypoint, entrypointmaker -from injection.loaders import PythonModuleLoader +from injection.loaders import ProfileLoader, PythonModuleLoader from os import getenv +profile_loader = ProfileLoader(...) + @dataclass class Config: @@ -101,15 +124,15 @@ def _config_factory() -> Config: @entrypointmaker(profile_loader=profile_loader) def entrypoint[**P, T](self: Entrypoint[P, T], config: Config) -> Entrypoint[P, T]: - import src - - profile = config.profile # Use config to determine profile + profile = config.profile suffixes = self.profile_loader.required_module_names(profile) module_loader = PythonModuleLoader.endswith(*suffixes) - return self.inject().load_profile(profile).load_modules(module_loader, src) + return self.inject().load_profile(profile).load_modules(module_loader, "src") ``` -In this example, `config` is resolved from the default module and used to dynamically load the appropriate profile. +In this example, `config` is resolved from the default module and used to load the matching profile. + +The module loader relies on a naming convention: each module that registers dependencies for a profile ends with that profile's name. With the `development` profile, `src/mailer_development.py` is imported and `src/mailer_production.py` is not. Since `required_module_names` also returns the profile's [subsets](profiles.md#profileloader) and the default module (`__default__`), their modules are imported too. !!! warning - Dependencies resolved in the entrypoint setup function must be registered in the default module (not in a profile-specific module) and should be transient or constant to avoid state issues. + The setup function runs as soon as `@entrypointmaker` is applied, before any profile is loaded. Its dependencies must therefore be registered with the default decorators (not in a profile), and should be transient or constant: a singleton would lock the module and prevent the profile from loading (see [Singleton](register-dependencies.md#singleton)). diff --git a/docs/guides/profiles.md b/docs/guides/profiles.md index d50263f..3f20bb1 100644 --- a/docs/guides/profiles.md +++ b/docs/guides/profiles.md @@ -22,12 +22,18 @@ class Profile(StrEnum): dev_module = mod(Profile.DEV) ``` -Once you have a module, you can use it to register dependencies that should only be available when that profile is active. The module provides access to all the registration decorators we've seen earlier (`injectable`, `singleton`, `scoped`, `constant`) as well as the `set_constant` method. +Once you have a module, you can use it to register dependencies that should only be available when that profile is active. The module provides access to all the registration decorators we've seen earlier (`injectable`, `singleton`, `scoped`, `constant`, `should_be_injectable`) as well as the `set_constant` method. +```python +@mod(Profile.DEV).injectable(on=AbstractDependency) +class DevDependency(AbstractDependency): ... +``` + +When a profile is loaded, its dependencies take precedence over those registered with the default decorators, which act as a fallback for everything the profile doesn't register. ## Loading a profile !!! warning "Caution" - Always load your profile as early as possible in your program's execution, ideally at startup before any dependency resolution occurs. + Always load your profile as early as possible in your program's execution, ideally at startup before any dependency resolution occurs. Once a singleton has been created, loading a profile raises a `ModuleLockError` (see [Singleton](register-dependencies.md#singleton)). ### load_profile @@ -44,19 +50,25 @@ This is the simplest approach when each profile is independent and doesn't share For more complex scenarios where profiles share common subsets of dependencies, use `ProfileLoader`: ```python +from enum import StrEnum from injection.loaders import ProfileLoader + +class SubProfile(StrEnum): + STUB = "stub" + + profile_loader = ProfileLoader( { Profile.DEV: [SubProfile.STUB], - Profile.TEST: [SubProfile.STUB], + Profile.STAGING: [SubProfile.STUB], } ) profile_loader.load(Profile.DEV) ``` -In this example, both "dev" and "test" profiles load the "stub" module, allowing you to reuse common mock implementations or shared configurations across multiple profiles. +In this example, both "development" and "staging" profiles load the "stub" module, allowing you to reuse common mock implementations or shared configurations across multiple profiles. !!! danger Only create a single `ProfileLoader` instance for your entire application to avoid conflicts. It's recommended to instantiate it at the Python module level (as a global variable) to ensure uniqueness. diff --git a/docs/guides/register-dependencies.md b/docs/guides/register-dependencies.md index 8bfe9a7..9e226fb 100644 --- a/docs/guides/register-dependencies.md +++ b/docs/guides/register-dependencies.md @@ -26,6 +26,9 @@ from injection import singleton class Dependency: ... ``` +!!! warning "Registration is locked once a singleton is created" + As soon as a singleton instance is created, its module is **locked**: registering a new dependency or [loading a profile](profiles.md) then raises a `ModuleLockError`, because the existing instance could have been built with other dependencies. Register everything and load your profile before resolving any singleton. + ## Constant Register a pre-existing value as a dependency. @@ -51,7 +54,7 @@ type APIKey = str api_key = set_constant("", APIKey, alias=True) ``` -For lazy constants, use the `@constant` decorator: +For lazy constants, use the `@constant` decorator. The value is created on first resolution, then always reused. Unlike `@singleton`, its parameters are not resolved: the class or function must be callable without arguments. ```python from injection import constant @@ -78,6 +81,9 @@ def _dependency_factory() -> Dependency: return Dependency() ``` +!!! note + A dependency created by an async function can only be resolved from an async context (see [Resolve dependencies](resolve-dependencies.md#inject)). + ## Abstract classes Register an implementation for an abstract class or protocol. @@ -96,9 +102,11 @@ class AbstractDependency(ABC): ... class Dependency(AbstractDependency): ... ``` +The dependency is registered both on its own class and on the types passed to `on`. To register it only on the types passed to `on`, set `ignore_type_hint=True`. + ## Scoped -A single instance is created per scope. Using a `StrEnum` for scope names is recommended. +A single instance is created per scope (see [Scopes](scopes.md) to define one). ```python from injection import scoped @@ -107,9 +115,15 @@ from injection import scoped class Dependency: ... ``` +Several scope names can be passed: the instance is then stored in the first of these scopes that is defined. +```python +@scoped("", "") +class Dependency: ... +``` + ## Scoped with context manager -Scoped dependencies can be registered using generator functions (sync or async) to handle setup and teardown logic. +Scoped dependencies can be registered using generator functions (sync or async) to handle setup and teardown logic. The teardown runs when the scope closes. !!! note If you're unfamiliar with context managers, [see Python's context manager documentation](https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager). @@ -134,13 +148,31 @@ def dependency_factory() -> Iterator[Dependency]: dependency.close() ``` -## Profiles +## Advanced options -Register a dependency for a specific profile. Using a `StrEnum` for profile names is recommended. +### Required registrations + +`@should_be_injectable` declares a class that must be registered by another part of the code. Resolving it without an implementation raises an `InjectionError` explaining that the class should be an injectable, instead of a less explicit `NoInjectable`. ```python -from injection import mod +from injection import should_be_injectable -@mod("").injectable +@should_be_injectable class Dependency: ... -``` \ No newline at end of file +``` + +### Disabling parameter resolution + +By default, the parameters of a class or factory are resolved from their type annotations. Set `inject=False` to call it without arguments instead: +```python +from injection import injectable + + +@injectable(inject=False) +class Dependency: + def __init__(self, value: int = 0): ... +``` + +## Profiles + +Every decorator also exists on modules, to register a dependency for a specific profile only. See [Profiles](profiles.md). diff --git a/docs/guides/resolve-dependencies.md b/docs/guides/resolve-dependencies.md index 983d9f1..2699db8 100644 --- a/docs/guides/resolve-dependencies.md +++ b/docs/guides/resolve-dependencies.md @@ -14,7 +14,7 @@ def function(dependency: Dependency): ... function() # You can now call `function` without arguments ``` -When `@inject` is applied to an async function, it can resolve dependencies that require an async context. If an async dependency is required by a sync function decorated with `@inject`, a `RuntimeError` will be raised. +When `@inject` is applied to an async function, it can resolve dependencies that require an async context (created by an async function). If such a dependency is required by a sync function decorated with `@inject`, a `RuntimeError` is raised. !!! note "Performance note" The first call to an injected function is slower because it performs dependency resolution. Subsequent calls are faster as the resolution is cached. @@ -92,6 +92,11 @@ from injection import get_instance dependency = get_instance(Dependency) ``` +A custom value can be returned instead of `NotImplemented` with the `default` parameter. It is also accepted by `get_lazy_instance`, `LazyInstance` and the async variants. +```python +dependency = get_instance(Dependency, None) +``` + ### get_lazy_instance Returns an object with the `__invert__` operator implemented. Calling `~` on this object resolves and returns the instance, or `NotImplemented` if not found. @@ -139,3 +144,16 @@ class Class: !!! warning Dependencies requiring an async context are not compatible with `LazyInstance` since descriptors cannot be awaited. + +## Errors + +All the exceptions raised by `python-injection` inherit from `InjectionError` and live in `injection.exceptions`. The most common ones: + +| Exception | Raised when | +|---|---| +| `NoInjectable` | No dependency is registered for the requested type. | +| `ScopeUndefinedError` | A scoped dependency is resolved outside of its scope. | +| `EmptySlotError` | A [scope binding](scopes.md#defining-scopes-with-bindings) is resolved but wasn't set when defining the scope. | +| `ModuleLockError` | A dependency is registered, or a profile loaded, after a singleton was created (see [Singleton](register-dependencies.md#singleton)). | + +`get_instance`, `get_lazy_instance` and `LazyInstance` return `NotImplemented` instead of raising `NoInjectable`, `ScopeUndefinedError` and `EmptySlotError`. diff --git a/docs/guides/scopes.md b/docs/guides/scopes.md index 443bcbf..c7bfb0f 100644 --- a/docs/guides/scopes.md +++ b/docs/guides/scopes.md @@ -6,9 +6,11 @@ Scopes allow you to control the lifetime of dependencies within a specific conte The `define_scope` context manager creates a scope for the duration of the context. It takes two parameters: -- **scope_name** (required): The name of the scope. Using a `StrEnum` to store scope names is recommended. +- **name** (required, positional only): The name of the scope. Using a `StrEnum` to store scope names is recommended. - **kind** (optional): Either `"contextual"` (default) or `"shared"`. +When the context exits, the scoped instances are discarded and the teardown of those [registered with a context manager](register-dependencies.md#scoped-with-context-manager) runs. + ```python from injection import define_scope @@ -48,6 +50,7 @@ async with adefine_scope(""): `MappedScope` allows you to define scopes with associated data bindings. This is useful when you want to make specific values available within the scope context. **Bindings are injectable in all dependencies**, making them accessible throughout your dependency graph. ```python +from dataclasses import dataclass from injection import MappedScope type Locale = str @@ -65,6 +68,8 @@ with Bindings("fr_FR").scope.define(): ... ``` +Each annotated attribute of the class becomes a binding, resolved from its type annotation. An attribute set to `None` is left unset: resolving it raises an `EmptySlotError`. + The `define` method also accepts the `kind` parameter with the same `"contextual"` and `"shared"` options. For async scopes with bindings, use `adefine`: diff --git a/docs/index.md b/docs/index.md index 728962e..48e7642 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ ## Project motivations -Dependency injection in Python has long been a source of frustration. +Dependency injection in Python has long been a source of frustration. Existing solutions are often verbose, require extensive boilerplate, or fail to leverage Python's type hints effectively. `python-injection` was created to solve these problems once and for all by providing a simple, elegant, and powerful dependency injection framework that feels natural to Python developers. @@ -15,13 +15,13 @@ The goal is straightforward: make dependency injection so easy that you'll wonde - **Type-driven resolution**: Dependencies are automatically resolved using Python's type annotations. - **Decorator-based registration**: Register your dependencies with simple, readable decorators. -- **Flexible lifetimes**: Choose from 4 type of lifetimes to match your needs: +- **Flexible lifetimes**: Choose from four types of lifetimes to match your needs: - **Transient**: A new instance every time - **Singleton**: One instance for the entire application - **Scoped**: One instance per scope, with context manager support - - **Constant**: Register pre-existing values + - **Constant**: A value created once and never re-resolved, either pre-existing or computed lazily - **Profile support**: Ability to swap certain dependencies based on a profile. -- **Pull-based instantiation**: Dependencies are only created when needed, improving startup time and resource usage. +- **Pull-based instantiation**: Dependencies registered with decorators are only created when needed, improving startup time and resource usage. - **Full sync/async support**: Works seamlessly with both synchronous and asynchronous code. ## Installation diff --git a/docs/integrations/fastapi.md b/docs/integrations/fastapi.md index c314507..ff2d107 100644 --- a/docs/integrations/fastapi.md +++ b/docs/integrations/fastapi.md @@ -11,12 +11,14 @@ from injection.ext.fastapi import Inject async def endpoint(dependency: Inject[Dependency]): ... ``` +If your endpoints run in several threads, use `InjectThreadSafe` instead of `Inject` (see [Thread safety](../threadsafety.md)). + ## Useful scopes Two fairly common scopes in FastAPI: -- **Application lifespan scope**: associate with application lifespan. -- **Request scope**: associate with http request lifetime. +- **Application lifespan scope**: lives as long as the application. +- **Request scope**: lives as long as an HTTP request. _For a better understanding of the scopes, [here's the associated documentation](../guides/scopes.md)._ diff --git a/docs/next-steps.md b/docs/next-steps.md index 8478b41..ca3419c 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -1,5 +1,9 @@ # Next steps +## Changelog + +The changes of each version are listed in the [GitHub releases](https://github.com/100nm/python-injection/releases). + ## Related projects - **[python-cq](https://github.com/100nm/python-cq)**: Take your architecture further with CQRS. Built on top of python-injection, it lets you organize your code with Commands, Queries, and Events to clearly separate write and read operations. diff --git a/docs/testing/classes.md b/docs/testing/classes.md index 3fd87ff..7c776d8 100644 --- a/docs/testing/classes.md +++ b/docs/testing/classes.md @@ -37,4 +37,4 @@ class TestAsyncFeature: Use `LazyInstance` for sync dependencies (cleaner syntax) and `aget_lazy_instance` only when you need async dependencies. !!! warning - `LazyInstance` resolves the dependency on **every access**. With transient dependencies, you get a new instance each time. With singletons, you get the same instance. + Both resolve the dependency on **every access** (see [LazyInstance](../guides/resolve-dependencies.md#lazyinstance)): with a transient dependency, you get a new instance each time. diff --git a/docs/testing/test-dependencies.md b/docs/testing/test-dependencies.md index fd664d5..439f856 100644 --- a/docs/testing/test-dependencies.md +++ b/docs/testing/test-dependencies.md @@ -21,7 +21,7 @@ profile_loader = ProfileLoader(...) load_test_profile(profile_loader) ``` -`load_test_profile` can also be used as a context manager to automatically unload the test profile after test complete: +`load_test_profile` can also be used as a context manager to automatically unload the test profile after the test completes: ```python from injection.testing import load_test_profile @@ -38,6 +38,7 @@ All the registration decorators you've seen earlier have test equivalents in `in ```python from injection.testing import ( set_test_constant, + should_be_test_injectable, test_constant, test_injectable, test_scoped, @@ -116,7 +117,7 @@ class MockDependency(AbstractDependency): ... ## How test dependencies work -Test dependencies are registered in a separate registry and **take precedence over production dependencies** when tests are running. This means: +Test dependencies are registered in a separate registry and **take precedence over production dependencies** once the test profile is loaded. This means: - You can override production implementations with test versions - Production code remains unchanged diff --git a/docs/threadsafety.md b/docs/threadsafety.md index 0eb54cc..8c906ae 100644 --- a/docs/threadsafety.md +++ b/docs/threadsafety.md @@ -31,7 +31,7 @@ with define_scope("", threadsafe=True): ... ``` -The `threadsafe` parameter is available on all functions that resolve dependencies or define scopes. +The `threadsafe` parameter is available on all functions that resolve dependencies or define scopes. Thread-safe resolutions of a same module share a single lock, and so do thread-safe scope definitions. ## Global thread safety @@ -52,6 +52,8 @@ When this environment variable is set, all dependency resolution and scope opera !!! warning "Environment variable timing" The `PYTHON_INJECTION_THREADSAFE` environment variable is resolved at the Python module level when `injection` is first imported. If you set environment variables dynamically in your main function, they may not take effect. Set the environment variable before importing `injection` or before running your application. ```python +import os + # ❌ Too late - injection already imported import injection