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 .github/actions/environment/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ runs:

- name: Install Dependencies
shell: bash
run: uv sync
run: uv sync --no-group bench
2 changes: 1 addition & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:

steps:
- name: Run checkout
uses: actions/checkout@v5
uses: actions/checkout@v6

- name: Set up environment
uses: ./.github/actions/environment
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:

steps:
- name: Run checkout
uses: actions/checkout@v5
uses: actions/checkout@v6

- name: Set up environment
uses: ./.github/actions/environment
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: GitHub Pages

on:
push:
branches:
- prod

jobs:
delivery:
name: Delivery
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Run checkout
uses: actions/checkout@v6

- name: Configure Git
run: |
git config --global user.name "github-pages[bot]"
git config --global user.email "github-pages[bot]@users.noreply.github.com"

- name: Set up environment
uses: ./.github/actions/environment

- name: Deploy MkDocs
shell: bash
run: uv run mkdocs gh-deploy --force
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ pyright:

pytest:
uv run pytest

mkdocs:
uv run mkdocs serve
43 changes: 2 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,18 @@
[![PyPI - Downloads](https://img.shields.io/pypi/dm/python-injection.svg?color=blue)](https://pypistats.org/packages/python-injection)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)

Documentation: https://python-injection.remimd.dev

## Installation

⚠️ _Requires Python 3.12 or higher_

```bash
pip install python-injection
```

## Features

* Automatic dependency resolution based on type hints.
* Support for multiple dependency lifetimes: `transient`, `singleton`, `constant`, and `scoped`.
* Works seamlessly in both `async` and `sync` environments.
* Separation of dependency sets using modules.
* Runtime switching between different sets of dependencies.
* Centralized setup logic using entrypoints.
* Built-in type annotation for easy integration with [`FastAPI`](https://github.com/fastapi/fastapi).
* Lazy dependency resolution for optimized performance.

## Motivations

1. Easy to use
2. No impact on class and function definitions
3. No tedious configuration

## Quick start

Simply apply the decorators and the package takes care of the rest.

```python
from injection import injectable, inject, singleton

Expand Down Expand Up @@ -61,25 +44,3 @@ def main(service: Service):
if __name__ == "__main__":
main()
```

## Resources

> ⚠️ The package isn't threadsafe by default, for better performance in single-threaded applications and those using
> `asyncio`.
>
> Non-threadsafe functions are those that resolve dependencies or define scopes. They all come with an optional
> parameter `threadsafe`.
>
> You can set `PYTHON_INJECTION_THREADSAFE=1` in environment variables to make the package fully threadsafe. The
> environment variable is resolved at the **Python module level**, so be careful if the variable is defined dynamically.

* [**Basic usage**](https://github.com/100nm/python-injection/tree/prod/documentation/basic-usage.md)
* [**Scoped dependencies**](https://github.com/100nm/python-injection/tree/prod/documentation/scoped-dependencies.md)
* [**Testing**](https://github.com/100nm/python-injection/tree/prod/documentation/testing.md)
* [**Advanced usage**](https://github.com/100nm/python-injection/tree/prod/documentation/advanced-usage.md)
* [**Loaders**](https://github.com/100nm/python-injection/tree/prod/documentation/loaders.md)
* [**Entrypoint**](https://github.com/100nm/python-injection/tree/prod/documentation/entrypoint.md)
* [**Integrations**](https://github.com/100nm/python-injection/tree/prod/documentation/integrations)
* [**FastAPI**](https://github.com/100nm/python-injection/tree/prod/documentation/integrations/fastapi.md)
* [**What if my framework isn't listed?**](https://github.com/100nm/python-injection/tree/prod/documentation/integrations/unlisted-framework.md)
* [**Concrete example**](https://github.com/100nm/python-injection-example)
1 change: 1 addition & 0 deletions docs/CNAME
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python-injection.remimd.dev
73 changes: 73 additions & 0 deletions docs/guides/imports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Auto-imports

When using decorators to register dependencies, some implementations may never be explicitly imported in your project. This creates a problem: if a module is never imported, its decorators never execute, and the dependencies are never registered.

For example, if you register an implementation with `@injectable(on=AbstractDependency)` but never import that implementation module, the dependency won't be available for injection even though it's decorated.

To solve this, `python-injection` provides two solutions for automatically importing modules in a package.

!!! tip
Call auto-import functions early in your application startup, **before loading your profile** and before any dependency resolution occurs.

## load_packages

The `load_packages` function imports all modules from the specified packages. Packages can be passed as module objects or as strings:
```python
from injection.loaders import load_packages
from src import adapters, services

# Import all modules in adapters and services packages
load_packages(adapters, services)

# Or using string notation
load_packages("src.adapters", "src.services")
```

This is the simplest approach and works well when you want to import everything from specific packages.

## PythonModuleLoader

For more control over which modules get imported, use `PythonModuleLoader` with a custom predicate function. Like `load_packages`, it accepts both module objects and strings:
```python
from injection.loaders import PythonModuleLoader
from src import adapters, services

def predicate(module_name: str) -> bool:
# Only import modules containing "impl" in their name
return "impl" in module_name

PythonModuleLoader(predicate).load(adapters, services)
```

The predicate function receives the full module name (e.g., `"src.adapters.dependency_impl"`) and returns `True` if the module should be imported.

### Factory methods

`PythonModuleLoader` provides three convenient factory methods for common filtering patterns:

**Filter by prefix:**
```python
# Import only modules starting with "impl_"
PythonModuleLoader.startswith("impl_").load(adapters, services)
```

**Filter by suffix:**
```python
# Import only modules ending with "_impl"
PythonModuleLoader.endswith("_impl").load(adapters, services)
```

**Filter by keyword marker:**
```python
# Import only modules containing a specific comment
PythonModuleLoader.from_keywords("# auto-import").load(adapters, services)
```

This last approach is particularly useful for explicitly marking which modules should be auto-imported:
```python
# auto-import

@injectable(on=AbstractDependency)
class Dependency(AbstractDependency):
...
```
113 changes: 113 additions & 0 deletions docs/guides/main-functions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# 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.).

To solve this, `python-injection` provides **entrypoints**, a way to create custom decorators that encapsulate all your setup logic using a builder pattern.

## Creating an entrypoint

Use the `@entrypointmaker` decorator to define your setup logic once:
```python
from injection import adefine_scope
from injection.entrypoint import AsyncEntrypoint, Entrypoint, entrypointmaker
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)
)
```

Now you can use your custom `@entrypoint` decorator on any function:
```python
@entrypoint
async def main(dependency: Dependency):
# All setup is automatically applied
...

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.

### Automatic execution

The entrypoint decorator accepts an optional `autocall` parameter. When set to `True`, the decorated function is automatically called:
```python
@entrypoint(autocall=True)
async def main(dependency: Dependency):
# This function runs automatically when the module is executed
...
```

This is particularly convenient for scripts and CLI commands where you want the entry point to execute immediately.

## Integrating with ProfileLoader

If you're using a [`ProfileLoader`](profiles.md#profileloader) in your project, pass it to `@entrypointmaker` using the `profile_loader` parameter:
```python
from injection.entrypoint import Entrypoint, entrypointmaker
from injection.loaders import ProfileLoader, PythonModuleLoader

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)
)
```

The `load_profile` method accepts a profile name and loads it before the main function executes.

## 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:
```python
from dataclasses import dataclass
from injection import constant
from injection.entrypoint import Entrypoint, entrypointmaker
from injection.loaders import PythonModuleLoader
from os import getenv

@dataclass
class Config:
profile: Profile

@constant
def _config_factory() -> Config:
profile = Profile(getenv("PROFILE", "development"))
return Config(profile)

@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
suffixes = self.profile_loader.required_module_names(profile)
module_loader = PythonModuleLoader.endswith(*suffixes)
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.

!!! 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.
Loading