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
2 changes: 1 addition & 1 deletion docs/guides/imports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
81 changes: 52 additions & 29 deletions docs/guides/main-functions.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,38 +43,32 @@ 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__":
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
...
```
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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)).
20 changes: 16 additions & 4 deletions docs/guides/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
48 changes: 40 additions & 8 deletions docs/guides/register-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -51,7 +54,7 @@ type APIKey = str
api_key = set_constant("<secret_api_key>", 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

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

Expand All @@ -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("<scope_name>", "<other_scope_name>")
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).
Expand All @@ -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("<profile_name>").injectable
@should_be_injectable
class Dependency: ...
```
```

### 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).
20 changes: 19 additions & 1 deletion docs/guides/resolve-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
7 changes: 6 additions & 1 deletion docs/guides/scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -48,6 +50,7 @@ async with adefine_scope("<scope_name>"):

`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
Expand All @@ -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`:
Expand Down
Loading
Loading