Metadata-Version: 2.4
Name: pyInjection
Version: 2026.6.4.0
Summary: Dependency injection container for Python3
Author-email: Joshua Loader <pyInjection@joshloader.com>
License: MIT License
Keywords: dependency injection,Python3,utilities
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: attrs~=25.1
Dynamic: license-file

# pyInjection

A .NET-style dependency-injection container for Python 3, driven by type annotations.

- **Configure with modules, build once, resolve.** Registration happens on a builder; `Container.build([...])` validates the whole graph and returns a container you only resolve from.
- **Per-node lifetimes** — `transient`, `scoped`, `singleton` — honoured by each registration, not by the resolution entry point.
- **Constructor injection with no decorators.** Domain classes depend only on their collaborators' `ABC`s; the container wires them by reading `__init__` annotations.
- **Resolver-aware factories** (sync and async), **first-class decorators**, **collections + composites**, **scopes with sync/async disposal**.
- **Two-layer validation** — static generic typing at the call site plus an eager whole-graph check at `build()` — surfaced through a typed exception hierarchy.
- **Test doubles via container overrides**, no mocking library required.

> **Versioning.** pyInjection uses CalVer (`YYYY.M.D.MICRO`); install the latest from PyPI. This generation is a **breaking** redesign of the 2.x API — see [Migrating from 2.x](#migrating-from-2x).

## Table of contents

1. [Install](#install)
2. [Quick start](#quick-start)
3. [Defining injectables](#defining-injectables)
4. [Registration modules and building](#registration-modules-and-building)
5. [Builder vs container](#builder-vs-container)
6. [Lifetimes](#lifetimes)
7. [Registration intents](#registration-intents)
8. [Factories and the resolver seam](#factories-and-the-resolver-seam)
9. [Async factories](#async-factories)
10. [Collections and `resolve_all`](#collections-and-resolve_all)
11. [Decorators](#decorators)
12. [Scopes and disposal](#scopes-and-disposal)
13. [Testing with overrides](#testing-with-overrides)
14. [Validation and exceptions](#validation-and-exceptions)
15. [Migrating from 2.x](#migrating-from-2x)

---

## Install

```bash
uv add pyInjection      # or: pip install pyInjection
```

## Quick start

```python
from abc import ABC, abstractmethod

from pyInjection import Container, IContainer, IContainerBuilder, IRegistrationModule


class IGreeter(ABC):
    @abstractmethod
    def greet(self) -> str: ...


class EnglishGreeter(IGreeter):
    def greet(self) -> str:
        return "Hello"


class GreetingModule(IRegistrationModule):
    def register(self, container: IContainerBuilder) -> None:
        container.add_singleton(interface=IGreeter, implementation=EnglishGreeter)


container: IContainer = Container.build([GreetingModule()])
print(container.resolve(interface=IGreeter).greet())   # -> "Hello"
```

`Container.build` applies each module, validates the whole graph, and returns an `IContainer`. There is no global state — every `build` yields an isolated container.

## Defining injectables

Every wireable type is an `ABC` (treated as an interface). Concrete classes depend on those interfaces, and the container injects them by reading the constructor's annotations — **no decorator is required** on the class:

```python
class ICandleSource(ABC):
    @abstractmethod
    def latest(self) -> Candle: ...


class CandleRepository(ICandleSource):
    __connection: IDatabaseConnection

    def __init__(self, connection: IDatabaseConnection) -> None:
        self.__connection = connection

    def latest(self) -> Candle: ...
```

At resolution the container inspects `CandleRepository.__init__`, resolves `IDatabaseConnection`, and constructs the instance. Generic keys such as `ICalculator[Candle, Swing]` are supported and resolved as distinct registrations.

## Registration modules and building

A module groups related registrations. The composition root passes a list of modules to `Container.build`:

```python
container: IContainer = Container.build([
    RepositoryModule(),
    MessageQueueModule(),
    ApplicationModule(),
])
```

`build` is pure (no I/O), so calling it in a unit or CI test is a fast, deterministic check that the whole graph is sound before deploy.

## Builder vs container

The two phases are separate interfaces:

- **`IContainerBuilder`** — the *configuration* surface (`add_*` / `register_*`). It is what a module's `register` receives.
- **`IContainer`** — the *runtime* surface (`resolve`, `resolve_all`, `create_scope`, `dispose`, `dispose_async`, `with_overrides`). It is what `Container.build` returns.

Because the built container exposes no registration methods, it cannot be reconfigured after building — registration mistakes are caught at the type level, not at runtime.

## Lifetimes

A registration's lifetime is honoured **per node**, regardless of the path by which it is reached:

| Lifetime | Instance reused… | Resolvable from |
|---|---|---|
| `Lifetime.TRANSIENT` | never — a new instance every resolution | container or scope |
| `Lifetime.SCOPED` | once per scope | a scope only |
| `Lifetime.SINGLETON` | once per container | container or scope |

**Captive-dependency rules** (enforced at `build()`): a node may depend only on one that lives at least as long as itself — a transient may depend on anything, a scoped on scoped/singleton, and a **singleton only on singletons**. A violation raises `ScopeViolationError`.

## Registration intents

Each kind of binding has exactly one method, so intent is explicit:

```python
from pyInjection import Lifetime

container.add_transient(interface=ICandleSource, implementation=CandleRepository)
container.add_singleton(interface=IClock, implementation=SystemClock)
container.add_scoped(interface=IUnitOfWork, implementation=SqlUnitOfWork)

# A pre-built object — always singleton semantics, so no lifetime argument.
container.add_instance(interface=ICalculator[Candle, Swing], instance=SwingCalculator(candle_range=3))

# A callable — lifetime is a parameter (see Factories).
container.add_factory(interface=IImbalance, factory=lambda r: ImbalanceCalculator(0.5), lifetime=Lifetime.SINGLETON)
```

`add_transient` / `add_singleton` / `add_scoped` accept a **class**. Passing a pre-built object or a callable raises `RegistrationError` naming the method to use instead (`add_instance` or `add_factory`).

## Factories and the resolver seam

When construction needs explicit wiring, register a factory. It receives a narrow **`IResolver`** seam (only `resolve` / `resolve_all`) — never the full container — so it can wire dependencies without becoming a service locator:

```python
from pyInjection import IResolver

container.add_factory(
    interface=ISwingCalculator,
    factory=lambda resolver: SwingCandleAverageCalculator(
        candle_range=3,
        inner=resolver.resolve(interface=ICalculator[Candle, Swing]),
    ),
    lifetime=Lifetime.SINGLETON,
)
```

The factory's result is cached according to `lifetime`, honoured per node like any other registration.

## Async factories

Resources that must be awaited to become ready (a connected pool, say) use `add_async_factory` and are materialised once, eagerly, by `Container.build_async`. Resolution stays synchronous — `resolve` never awaits:

```python
from pyInjection import IResolver

async def build_pool(resolver: IResolver) -> IConnectionPool:
    pool = ConnectionPool(dsn=...)
    await pool.connect()
    return pool


class InfraModule(IRegistrationModule):
    def register(self, container: IContainerBuilder) -> None:
        container.add_async_factory(interface=IConnectionPool, factory=build_pool)


container: IContainer = await Container.build_async([InfraModule()])
pool: IConnectionPool = container.resolve(interface=IConnectionPool)   # already connected
```

An async-factory result is served as a singleton. Resolving one synchronously (via `build` rather than `build_async`) raises `ResolutionError`.

## Collections and `resolve_all`

Register many implementations under one base interface and retrieve them together. Members keep their own lifetimes and resolve in registration order:

```python
class HandlerModule(IRegistrationModule):
    def register(self, container: IContainerBuilder) -> None:
        container.add_to_collection(interface=IHandler, implementation=LoggingHandler)
        container.add_to_collection(interface=IHandler, implementation=MetricsHandler, lifetime=Lifetime.SINGLETON)


handlers: list[IHandler] = container.resolve_all(interface=IHandler)
```

`resolve_all` is independent of single-binding `resolve`: an interface with no collection yields an empty list.

### Auto-discovered collections

`register_collection` discovers **every loaded subclass** of the interface and registers them as the collection — no per-member call. Abstract classes, `exclusions`, and the `composite` are skipped; members not already registered take `member_lifetime`:

```python
class HandlerModule(IRegistrationModule):
    def register(self, container: IContainerBuilder) -> None:
        container.register_collection(
            interface=IHandler,
            exclusions=[InternalHandler],
            composite=HandlerPipeline,           # optional single binding over the members
            composite_lifetime=Lifetime.SINGLETON,
            member_lifetime=Lifetime.TRANSIENT,
        )
```

If a `composite` is given, it is registered as the single binding for the interface, with the discovered `list[interface]` injected into its matching constructor parameter (other parameters resolved normally); the composite is excluded from its own list.

> **Caveat.** Discovery is by `__subclasses__`, so only subclasses whose module has been **imported** are visible, and **every** loaded subclass is included — including test doubles and decorators. Use `exclusions` to trim, and note a discovered member with unmet constructor dependencies fails validation. Registering both a single binding **and** a collection for the same interface fails validation.

## Decorators

`register_decorator` wraps a registration in a class that implements the same interface and takes that interface as a constructor parameter (the wrapped inner instance). **Last registered = outermost**, and the chain inherits the base registration's lifetime:

```python
class LoggingModule(IRegistrationModule):
    def register(self, container: IContainerBuilder) -> None:
        container.add_transient(interface=IHandler, implementation=CoreHandler)
        container.register_decorator(interface=IHandler, decorator=LoggingHandler)
        container.register_decorator(interface=IHandler, decorator=RetryHandler)


# resolve(IHandler) -> RetryHandler(LoggingHandler(CoreHandler))
```

A decorator's other (non-wrapped) dependencies are injected normally. Decorators that take non-injected literal arguments aren't expressible here — register those as a factory instead.

## Scopes and disposal

A scope bounds `scoped` instances and their teardown. It is a context manager — use `with` for synchronous disposal and `async with` for awaited disposal:

```python
from pyInjection import IDisposable, IAsyncDisposable


class SqlUnitOfWork(IUnitOfWork, IAsyncDisposable):
    async def dispose_async(self) -> None:
        await self._connection.close()


with container.create_scope() as scope:
    work = scope.resolve(interface=IUnitOfWork)     # scoped: shared within this scope
    ...
# sync IDisposable.dispose() of scoped instances fires here

async with container.create_scope() as scope:
    work = scope.resolve(interface=IUnitOfWork)
    ...
# IAsyncDisposable.dispose_async() is awaited (and IDisposable.dispose() called) here
```

Within a scope, singletons resolve from the container's shared cache and transients are never cached. Scoped instances are distinct across scopes.

**Container teardown.** Singletons that hold resources are disposed when *you* tear the container down — call it once at application shutdown (nothing calls it for you):

```python
try:
    await application.run()
finally:
    await container.dispose_async()   # or container.dispose() for sync resources
```

Transients are **caller-owned**: the container does not track or dispose them. If something needs deterministic teardown, register it `scoped` or `singleton` so the container/scope owns it — or have the object implement its own `with`/`async with` and use it at the call site.

## Testing with overrides

Build the real container, swap selected bindings for hand-crafted `ABC` doubles, and discard it — no global state, no mocking library:

```python
class FixedClock(IClock):
    def now(self) -> datetime:
        return datetime(2026, 1, 1)


container: IContainer = Container.build([ApplicationModule()])
under_test: IContainer = container.with_overrides([(IClock, FixedClock())])
```

`with_overrides` returns a new, isolated, re-validated container; the original is untouched. A pre-built double is served as-is; a double passed as a class is constructor-injected and inherits the replaced binding's lifetime. An override that leaves the graph unsatisfiable fails here.

## Validation and exceptions

Validation is two complementary layers:

- **Layer 1 — static (IDE / mypy).** The generic signatures (`type[T]`, `T`, `Callable[[IResolver], T]`) make registering or resolving the wrong type an inline error at the call site, before anything runs.
- **Layer 2 — eager, at `build()`.** The whole graph is walked once: completeness (every dependency registered), no cycles, no duplicates, and the captive-dependency/scope rules. Because `build()` is pure, running it in a test is a fast graph-validity check.

All failures derive from `PyInjectionError`, so they are catchable individually or collectively:

```
PyInjectionError
├── RegistrationError
│   └── DuplicateRegistrationError
├── ResolutionError
│   ├── MissingRegistrationError
│   └── CircularDependencyError
└── ScopeViolationError
```

```python
from pyInjection import PyInjectionError

try:
    container = Container.build([ApplicationModule()])
except PyInjectionError as error:
    print(f"container is misconfigured: {error}")
```

## Migrating from 2.x

This generation is a deliberate breaking redesign. The main changes:

| 2.x | Now |
|---|---|
| `Container.*(..., container_identifier="...")` global facade | `container = Container.build([...modules...])` — a real object, no global registry or string ids |
| `@Container.inject` on every constructor | nothing — constructor injection works by signature inspection |
| overloaded `implementation=` for class / instance / lambda | one method each: `add_transient`/`add_singleton`/`add_scoped` (class), `add_instance` (object), `add_factory` (callable) |
| `add_transient(I, Impl())` (instance labelled transient) | `add_instance(I, Impl())` — honest singleton semantics |
| `lambda: Container.resolve(IDep, id=...)` reaching into a global | `add_factory(I, lambda resolver: ...resolver.resolve(IDep))` — a narrow seam |
| hand-built `list[...]` / composite lambdas | `add_to_collection` + `resolve_all`, or `register_collection` |
| nested-lambda decorator chains | `register_decorator` |
| `raise Exception(...)`, optional `validate()`, no cycle guard | typed `PyInjectionError` hierarchy, eager `build()`, `CircularDependencyError` |
| `transient→singleton` raised; `singleton→transient` warned | standard captive-dependency rules; lifetime honoured per node |
| registration and resolution on one `Container` | `IContainerBuilder` (configure) vs `IContainer` (resolve) |

Adopt it by moving registrations into `IRegistrationModule`s, replacing decorators and global reach-backs as above, and calling `Container.build([...])` in the composition root.
