Metadata-Version: 2.5
Name: providify
Version: 2.0.0
Summary: A Python dependency injection container inspired by Jakarta CDI and Spring. Supports sync and async resolution, multiple scopes, lifecycle hooks, and configuration modules.
Project-URL: Homepage, https://github.com/edoardoscarpaci/providify
Project-URL: Repository, https://github.com/edoardoscarpaci/providify
Project-URL: Documentation, https://github.com/edoardoscarpaci/providify#readme
Project-URL: Changelog, https://github.com/edoardoscarpaci/providify/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/edoardoscarpaci/providify/issues
Author-email: "edoardo.scarpaci" <edoardo.scarpaci@gmail.com>
License: Apache-2.0
License-File: LICENSE
Keywords: async,container,dependency injection,di,inversion of control,ioc,jakarta,spring
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.12
Provides-Extra: yaml
Requires-Dist: pyyaml>=6.0; extra == 'yaml'
Description-Content-Type: text/markdown

# providify

A Python dependency injection container inspired by Jakarta CDI and Spring.
Supports sync and async resolution, multiple scopes, lifecycle hooks, and configuration modules.

[![CI](https://github.com/edoardoscarpaci/providify/actions/workflows/ci.yml/badge.svg)](https://github.com/edoardoscarpaci/providify/actions/workflows/ci.yml)
[![PyPI version](https://img.shields.io/pypi/v/providify.svg)](https://pypi.org/project/providify/)

---

## Installation

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

Requires Python 3.12+.

---

## Quick start

```python
from providify import DIContainer, Component, Singleton

class Notifier:
    def send(self, msg: str) -> None: ...

@Component
class EmailNotifier(Notifier):
    def send(self, msg: str) -> None:
        print(f"email: {msg}")

@Singleton
class AlertService:
    def __init__(self, notifier: Notifier) -> None:
        self._notifier = notifier   # injected automatically

    def alert(self, msg: str) -> None:
        self._notifier.send(msg)

container = DIContainer()
container.bind(Notifier, EmailNotifier)
container.register(AlertService)

svc = container.get(AlertService)
svc.alert("hello")   # -> email: hello
```

---

## Core concepts

The container operates in two phases:

1. **Registration** — declare bindings via `bind()`, `register()`, `provide()`, `scan()`, or `install()`
2. **Resolution** — the first `get()` / `aget()` call validates all bindings, then resolves them

Dependencies can be declared in two places:

- **Constructor parameters** — `def __init__(self, svc: Service) -> None` — resolved automatically
- **Class-level annotations** — `svc: Inject[Service]` on the class body — resolved after the constructor runs

Both forms support `Inject[T]`, `Live[T]`, and `Lazy[T]`.

---

## Scope decorators

Mark a class so the container knows how to manage its lifetime.

```python
from providify import Component, Singleton, RequestScoped, SessionScoped

@Component        # new instance on every resolution (default)
class EmailSender: ...

@Singleton        # one instance for the lifetime of the container
class Database: ...

@RequestScoped    # one instance per active request context
class RequestLogger: ...

@SessionScoped    # one instance per active session context
class UserSession: ...
```

All scope decorators accept optional keyword arguments:

```python
@Singleton(qualifier="primary", priority=1, inherited=True)
class PrimaryDB(Database): ...
```

| Argument | Type | Meaning |
|----------|------|---------|
| `qualifier` | `str \| type \| None` | Named or typed qualifier — used to distinguish multiple bindings of the same type |
| `priority` | `int` | Higher number wins when multiple candidates match (default `0`) |
| `inherited` | `bool` | Subclasses inherit this metadata via MRO walk (default `False`) |
| `track` | `bool` | Track DEPENDENT instances for manual teardown via `flush_dependents()` (default `False`) |

`ApplicationScoped` is an alias for `@Singleton` — provided for Jakarta CDI parity:

```python
from providify import ApplicationScoped

@ApplicationScoped
class UserCache: ...   # exactly equivalent to @Singleton
```

---

## Typed qualifiers — @Qualifier and @Default

### @Qualifier

Define a **type** as a qualifier marker. The type itself (not a string) is used to filter bindings, giving you IDE completion, refactoring support, and no risk of typos.

```python
from providify import Component, Qualifier

@Qualifier
class Cloud: ...

@Qualifier
class OnPrem: ...

@Component(qualifier=Cloud)
class AwsNotifier(Notifier): ...

@Component(qualifier=OnPrem)
class SmtpNotifier(Notifier): ...

# Resolve with the type, not a string
cloud_svc = container.get(Notifier, qualifier=Cloud)
onprem_svc = container.get(Notifier, qualifier=OnPrem)
```

Any class decorated with `@Qualifier` can be used in `qualifier=` on any scope decorator, `@Provider`, and `container.get()` / `container.aget()`.

### @Default

Mark a binding as the explicit default. `@Default`-qualified beans are returned on **unqualified** lookups — the same as no qualifier at all. This mirrors Jakarta CDI semantics and is useful for documentation clarity:

```python
from providify import Component, Default

@Component(qualifier=Default)
class EmailSender(Sender): ...   # returned by container.get(Sender) with no qualifier

@Component(qualifier=Cloud)
class AwsSender(Sender): ...     # only returned by container.get(Sender, qualifier=Cloud)
```

> **Semantics note**: In providify `qualifier=None` in `_filter()` means "no filter — return all qualifiers". `@Default` is normalised to `qualifier=None` before the filter runs, so `@Default`-marked beans are visible in unqualified lookups just like undecorated ones.

---

## @Alternative — deployment-time bean replacement

`@Alternative` marks a bean that is **excluded from resolution by default**. Activate it explicitly per-container to replace the regular implementation — useful for mocks, staging overrides, and feature flags.

```python
from providify import Component, Alternative

@Component
class RealPaymentGateway(PaymentGateway): ...

@Alternative
@Component(priority=10)     # higher priority ensures it wins when enabled
class MockPaymentGateway(PaymentGateway): ...
```

```python
# Production container — MockPaymentGateway is invisible
container.bind(PaymentGateway, RealPaymentGateway)

# Test container — activate the mock
container.bind(PaymentGateway, RealPaymentGateway)
container.register(MockPaymentGateway)
container.enable_alternative(MockPaymentGateway)

gw = container.get(PaymentGateway)   # MockPaymentGateway ✅

# Revert
container.disable_alternative(MockPaymentGateway)
gw = container.get(PaymentGateway)   # RealPaymentGateway ✅
```

> **Priority rule**: an `@Alternative` bean must have a **higher `priority`** than the non-alternative beans of the same type to win selection. At equal priority (both `0`) the first-registered binding wins. Set `priority > 0` on alternatives to guarantee they override.

---

## @Profile — deployment-time bean activation

`@Profile` gates a class or `@Provider` function on the container's **active profile set** — the
providify equivalent of Spring's `@Profile`. A binding whose expression doesn't match the active
profiles is invisible to `get()`, `get_all()`, `is_resolvable()`, and `validate()`, exactly like a
non-enabled `@Alternative`.

```python
from providify import Component, DIContainer, Profile, Singleton

@Profile("prod")
@Singleton
class RealMailer(Mailer): ...

@Profile("dev", "test")            # OR — active in either
@Singleton
class ConsoleMailer(Mailer): ...

@Profile("!prod")                  # negation — active whenever "prod" is NOT active
@Component
class FakeClock(Clock): ...
```

The active profile set comes from an explicit `profiles=` argument, the `PROVIDIFY_PROFILES`
environment variable (comma-separated), or imperative calls — in that precedence order (an explicit
argument, even an empty one, always wins over the environment variable):

```python
container = DIContainer(profiles=("prod",))     # explicit wins
container = DIContainer()                        # reads PROVIDIFY_PROFILES=prod,eu if set
container.activate_profile("debug")              # imperative, mirrors enable_alternative()
container.deactivate_profile("debug")
container.active_profiles                        # frozenset({"prod", "eu", "debug"})
```

`@Profile` also acts as the **declarative activator for `@Alternative`**: an `@Alternative` class
(or `@Provider` function) that also carries `@Profile` is governed by its profile instead of
requiring an imperative `enable_alternative()` call — the profile is a hard AND-gate, so
`enable_alternative()` cannot bypass a non-matching profile.

> **Validation is profile-aware**: `container.validate()` reports `MISSING_BINDING` for an
> interface whose only provider is gated by a `@Profile` that isn't currently active — that's
> intentional (it validates the graph *as it will actually be wired*), but it means you should
> validate once per deployment configuration you care about, not just once with no profiles set.

> **Breaking change**: before this feature, `@Alternative` stamped on a `@Provider` **function**
> (as opposed to a class) was silently ignored — the provider was always active. That asymmetry is
> now fixed: `@Alternative` on a provider function is disabled by default, same as on a class. See
> `CHANGELOG.md`.

---

## @Stereotype — reusable composed annotations

`@Stereotype` bundles scope, qualifier, priority, and inherited into a single reusable decorator — the Python equivalent of Jakarta CDI `@Stereotype`.

```python
from providify import Stereotype, Singleton, Component
from providify.metadata import Scope

# Define the stereotype — reusable across the codebase
DomainRepository = Stereotype(scope=Scope.SINGLETON, priority=1)
ApplicationService = Stereotype(scope=Scope.SINGLETON, qualifier="app")

# Apply it — identical to @Singleton(priority=1)
@DomainRepository
class UserRepository:
    def find(self, id: int) -> User: ...

@DomainRepository
class OrderRepository:
    def find(self, id: int) -> Order: ...

# Explicit annotations always win over the stereotype defaults
@Component(qualifier="test")    # scope and priority taken from stereotype; qualifier overridden
@DomainRepository
class TestRepository: ...
```

---

## @Provider

`@Provider` is providify's name for Jakarta CDI's **`@Produces`** — a typed factory
function. The return type determines the resolved interface, and the function's own
parameters are injected from the container.

> **When to use it:** reach for `@Provider` only for types you *can't* annotate —
> third-party/stdlib classes, interfaces chosen at runtime, or values needing
> imperative construction. For classes you own, put `@Component` / `@Singleton`
> directly on the class instead. See [PROVIDERS.md](PROVIDERS.md) for the full
> decision rule.

```python
from providify import Provider

@Provider
def make_sender() -> EmailSender:
    return EmailSender(host="smtp.example.com")

# singleton=True caches the result — provider called only once
@Provider(singleton=True)
def make_db() -> Database:
    return Database(url=os.environ["DB_URL"])

# async providers are supported — resolve with aget()
@Provider(singleton=True)
async def make_pool() -> ConnectionPool:
    pool = ConnectionPool()
    await pool.connect()
    return pool
```

Providers also accept `qualifier=` and `priority=`.

`returns=` overrides the resolved interface explicitly, bypassing the return
annotation entirely — useful when the interface is only known at call time
(e.g. a generic alias built inside a loop):

```python
for model in (User, Order):
    def repo_factory(model=model):
        return InMemoryRepo(model)
    container.provide(repo_factory, returns=Repository[model])
```

---

## Container API

```python
from providify import DIContainer

container = DIContainer()

# ── Registration ──────────────────────────────────────────────────
container.bind(Interface, ConcreteClass)   # bind interface -> implementation
container.register(ConcreteClass)          # self-bind: interface == implementation
container.provide(factory_fn)              # register a @Provider function
container.scan("myapp.services")           # auto-discover decorated classes in a module
container.install(MyModule)                # install a @Configuration module (see below)

# ── Mutation ──────────────────────────────────────────────────────
container.override(Interface, MockImpl)    # replace all bindings for Interface in-place (for tests)
container.reset_binding(Interface)         # remove all bindings for Interface; returns count removed
container.enable_alternative(MockImpl)     # activate an @Alternative bean for this container
container.disable_alternative(MockImpl)    # deactivate an @Alternative bean
container.activate_profile("prod")         # activate a @Profile for this container
container.deactivate_profile("prod")       # deactivate a @Profile
container.active_profiles                  # frozenset of currently active profile names
container.add_interceptor(LoggingInterceptor)  # register an @Interceptor class

# ── Sync resolution ───────────────────────────────────────────────
svc  = container.get(Service)
svc  = container.get(Service, qualifier="primary")    # string qualifier
svc  = container.get(Service, qualifier=Cloud)        # type qualifier (@Qualifier class)
svc  = container.get(Service, priority=1)
svcs = container.get_all(Service)          # all matching bindings, sorted by priority ascending

# ── Async resolution ──────────────────────────────────────────────
svc  = await container.aget(Service)
svc  = await container.aget(Service, qualifier="primary")
svcs = await container.aget_all(Service)

# ── Introspection (no instantiation) ──────────────────────────────
binding  = container.get_binding(Service)             # best-match AnyBinding (raises LookupError if absent)
bindings = container.get_all_bindings(Service)        # all AnyBinding objects; [] if none registered
ok       = container.is_resolvable(Service)           # True if at least one binding matches

# ── Global singleton ──────────────────────────────────────────────
container = DIContainer.current()          # sync — thread-safe
container = await DIContainer.acurrent()   # async — never blocks the event loop
DIContainer.reset()                        # wipe global (useful in tests)

# ── Scoped global — swap in a fresh container for one block ───────
with DIContainer.scoped() as c:
    c.bind(...)
    c.get(Service)
# original global is restored on exit, even if an exception is raised

async with DIContainer.scoped() as c:
    await c.aget(Service)

# ── Scope utilities ───────────────────────────────────────────────
container.run_in_request(fn, *args, **kwargs)           # run fn inside a new request scope
await container.arun_in_request(fn, *args, **kwargs)    # async version
container.run_in_session("user-abc", fn, *args)         # run fn inside a named session scope
await container.arun_in_session("user-abc", fn, *args)  # async version

# ── DEPENDENT tracking ────────────────────────────────────────────
container.flush_dependents()        # call @PreDestroy on all track=True DEPENDENT instances
await container.aflush_dependents() # async version (context manager calls this automatically)

# ── Instance lifecycle ────────────────────────────────────────────
with container:                   # calls flush_dependents() + shutdown() on __exit__
    ...

async with container:             # calls aflush_dependents() + ashutdown() on __aexit__
    ...
```

---

## Injection types

### Plain type annotation

The simplest case — annotate the parameter with the type to inject.
Pylance / mypy see the real type directly; no special import needed.

```python
@Component
class OrderService:
    def __init__(self, db: Database) -> None:
        self.db = db
```

### Optional[T] and T | None — nullable injection

Use Python's standard `Optional[T]` or the pipe-union syntax `T | None` when
a dependency may not be registered. If no binding is found, the parameter
receives `None` instead of raising `LookupError`.

```python
from typing import Optional

@Component
class Notifier:
    def __init__(
        self,
        sms:   Optional[SmsService],    # None if SmsService is not registered
        push:  PushService | None,      # equivalent pipe-syntax form
    ) -> None:
        self._sms  = sms   # may be None at runtime
        self._push = push
```

No import from `providify` is needed — plain `Optional[T]` / `T | None` is
enough. The container detects the union at resolution time and handles the
missing-binding case automatically.

> **`Optional[T]` vs `Annotated[T, InjectMeta(optional=True)]`**: both inject
> `None` when the binding is absent, but the `Optional[T]` form is more
> idiomatic Python and works without importing `InjectMeta`.

### Union[T1, T2] — first-match injection

A `Union` with multiple non-`None` types is resolved by trying each candidate
in declaration order. The first type that has a registered binding is used;
`LookupError` is raised only if **no** candidate resolves.

```python
from typing import Union

@Component
class StorageService:
    def __init__(
        self,
        # Prefers S3Storage if registered; falls back to LocalStorage otherwise
        backend: Union[S3Storage, LocalStorage],
    ) -> None:
        self.backend = backend
```

Combining with `None` makes the whole injection optional:

```python
@Component
class AnalyticsCollector:
    def __init__(
        self,
        # Uses SegmentAnalytics if available, Mixpanel as fallback, skipped if neither
        tracker: Union[SegmentAnalytics, MixpanelAnalytics, None] = None,
    ) -> None:
        self.tracker = tracker
```

**Resolution rules:**

| Annotation | First candidate bound? | Second candidate bound? | Result |
|---|---|---|---|
| `Optional[T]` / `T \| None` | yes | — | T instance |
| `Optional[T]` / `T \| None` | no | — | `None` |
| `Union[T1, T2]` | yes | — | T1 instance |
| `Union[T1, T2]` | no | yes | T2 instance |
| `Union[T1, T2]` | no | no | raises `LookupError` |
| `Union[T1, T2, None]` | no | no | `None` |

### Inject[T] — subscript form (recommended)

Use `Inject[T]` when you want to be explicit that this parameter is managed
by the DI container. Linters and type checkers resolve `Inject[Database]`
directly to `Database`, so hover, completion, and type errors work normally.

```python
from providify import Inject

@Component
class OrderService:
    def __init__(self, db: Inject[Database]) -> None:
        self.db = db   # linter sees: db: Database ✅
```

### Annotated[T, InjectMeta(...)] — for qualifier / priority / optional (recommended)

When you need injection **options** (qualifier, priority, optional), use
`Annotated` with `InjectMeta` directly. This is the underlying form that
`Inject[T]` expands to at runtime, and it is fully valid Python — no
`# type: ignore` comment needed. Pylance hover shows the bare type `T`.

```python
from typing import Annotated
from providify import Inject, InjectMeta

@Component
class ReportService:
    def __init__(
        self,
        db:      Inject[Database],                                    # simple — no options needed
        cache:   Annotated[Cache,   InjectMeta(qualifier="redis")],   # named qualifier ✅
        metrics: Annotated[Metrics, InjectMeta(optional=True)],       # None if not bound ✅
        audit:   Annotated[AuditLog, InjectMeta(priority=1)],         # exact priority ✅
    ) -> None: ...
```

> **Why not `Inject(T, qualifier=...)`?**
> The call form `Inject(Cache, qualifier="redis")` works at runtime but is **not
> recommended** — type checkers (Pylance, mypy, pyright) flag it as invalid in
> annotation position and cannot infer the return type, so hover and completion
> show `Unknown` instead of `Cache`. Use `Annotated[T, InjectMeta(...)]` instead.
> It resolves identically and keeps the full type-checker experience intact.

### InjectInstances[T] — all bindings as a list

Inject every registered implementation of an interface, sorted by priority.
Pylance resolves `InjectInstances[Sender]` to `list[Sender]`.

```python
from providify import InjectInstances

@Component
class NotificationFanout:
    def __init__(self, senders: InjectInstances[Sender]) -> None:
        self.senders = senders   # linter sees: senders: list[Sender] ✅

    def notify(self, msg: str) -> None:
        for sender in self.senders:
            sender.send(msg)
```

For qualifier filtering on `InjectInstances`, use `Annotated` with `InjectMeta(all=True)`:

```python
from typing import Annotated
from providify import InjectMeta

@Component
class CloudFanout:
    def __init__(
        self,
        senders: Annotated[list[Sender], InjectMeta(all=True, qualifier="cloud")],
    ) -> None:
        self.senders = senders
```

### Multibinding — `multibind()` / `@Multibound` / bare `list[T]`

Inject every registered implementation of an interface using a plain `list[T]`
annotation — no wrapper type needed. Unlike `InjectInstances[T]`, this requires
the interface to be **explicitly declared a collection point** first, either
via `container.multibind()` or the `@Multibound` class decorator:

```python
from providify import Component, Multibound

@Multibound                       # declares Handler a collection point
class Handler(ABC):
    @abstractmethod
    def handle(self) -> str: ...

class HandlerA(Handler): ...      # contributions need no marker of their own —
class HandlerB(Handler): ...      # register normally with @Component/bind()/provide()

@Component
class Dispatcher:
    def __init__(self, handlers: list[Handler]) -> None:   # every active impl, priority-ascending
        self.handlers = handlers
```

Or declare the collection point on the container instead of the class (useful
for third-party interfaces you can't decorate):

```python
container.multibind(Handler)      # equivalent to @Multibound on Handler
container.bind(Handler, HandlerA)
container.bind(Handler, HandlerB)
handlers = container.get(list[Handler])   # [HandlerA(), HandlerB()]
```

- **A declared collection point with zero contributions injects `[]`**, not a
  `LookupError` — declaring the collection point *is* the statement that zero
  is a legal count.
- A `list[T]` annotation where `T` was **not** declared a collection point
  (and no literal `list[T]` binding exists) resolves to `_UNRESOLVED` — same
  as any other unbound type, unchanged from before this feature.
- If a literal binding for `list[T]` itself exists (e.g.
  `container.provide(fn, returns=list[Handler])`), that binding always wins
  over collection behaviour — `validate_bindings()` flags the ambiguity if
  both exist on the same type.

**Relationship to `InjectInstances[T]`:** `InjectInstances[T]` (see above)
needs no collection-point declaration — the injection site itself already
says "give me all of them" — but it keeps `get_all()`'s behaviour of raising
`LookupError` on zero matches. Use `list[T]` after declaring a collection
point as the default; keep `InjectInstances[T]` as the escape hatch for types
you cannot decorate and did not `multibind()`, or where the raise-on-empty
behaviour is what you want.

### Class-level attributes

Injection annotations can be placed directly on class-level attributes instead of (or alongside) constructor parameters. They are resolved and set on the instance **after** the constructor runs, and **before** `@PostConstruct` fires — so lifecycle hooks can access them.

```python
from providify import Inject, Live, Lazy

@Singleton
class ReportService:
    # Class-level — resolved after __init__ returns
    storage: Inject[StorageBackend]
    logger:  Live[RequestLogger]    # re-resolves per request (see Live[T] below)

    # Constructor parameters still work normally alongside class-level annotations
    def __init__(self, db: Database) -> None:
        self.db = db
```

All injection forms (`Inject[T]`, `Live[T]`, `Lazy[T]`, `Instance[T]`) work as class-level annotations.
For options (`qualifier=`, `priority=`, `optional=`), use `Annotated` + the corresponding meta type:

```python
from typing import Annotated
from providify import InjectMeta, LiveMeta, LazyMeta

@Singleton
class ReportService:
    storage:  Annotated[StorageBackend, InjectMeta(qualifier="primary")]
    logger:   Annotated[RequestLogger,  LiveMeta(qualifier="request")]
    slow_svc: Annotated[HeavyService,   LazyMeta(qualifier="heavy")]
```

#### `ClassVar[...]` form

All four injection types also accept the `ClassVar[...]` wrapper, which is useful when
a type checker or style guide requires class-level attributes to be explicitly typed as
class variables:

```python
from typing import ClassVar
from providify import Instance, Live, Lazy, Inject

@Singleton
class AlertService:
    # ClassVar form — treated identically to the plain form by the container
    emailer:  ClassVar[Instance[Emailer]]
    logger:   ClassVar[Live[RequestLogger]]
    config:   ClassVar[Lazy[AppConfig]]
    storage:  ClassVar[Inject[StorageBackend]]
```

The container unwraps `ClassVar[X]` to `X` before dispatching, so resolution,
scope-violation detection, and dependency-graph construction all work identically
to the plain annotation form.

> **Constructor takes priority** — if the same name appears both as a class-level annotation and as an `__init__` parameter, the constructor value is used and the class-level annotation is skipped.

### Inheritance and MRO

The two injection paths intentionally have **different MRO behaviour**:

| Injection path | MRO walk? | Why |
|---|---|---|
| `__init__` parameters | ❌ No | The declared signature is an explicit contract. If a child overrides `__init__`, it is asserting its own construction semantics. |
| Class-level annotations | ✅ Yes | The container resolves each class attribute's annotation across the full MRO — annotations declared on a parent class are inherited and injected automatically. |

**When `__init__` is *not* overridden** the parent's `__init__` is already picked up via Python's own MRO — no special handling is needed. The asymmetry only matters when the child *does* override `__init__`.

**Recommended patterns for inheritance:**

```python
# Option A — re-declare the parent dep in the child signature (explicit, zero magic)
class Base:
    def __init__(self, svc_a: Inject[ServiceA]) -> None:
        self.svc_a = svc_a

class Child(Base):
    def __init__(self, svc_a: Inject[ServiceA], svc_b: Inject[ServiceB]) -> None:
        super().__init__(svc_a)   # explicit hand-off — no surprise injections
        self.svc_b = svc_b

# Option B — use class-level annotations for inherited deps (MRO is walked)
class Base:
    svc_a: Inject[ServiceA]   # injected after construction; inherited by all subclasses

class Child(Base):
    def __init__(self, svc_b: Inject[ServiceB]) -> None:
        self.svc_b = svc_b
    # svc_a is still set on self via the class-var injection path ✓
```

> ⚠️ **Avoid injecting parent `__init__` params via MRO manually.** If the container were to merge parent and child `__init__` signatures automatically, it would conflict with any `super().__init__(arg)` call inside the child — the same instance could be resolved twice, producing two separate objects for what should be a single dep.

### Lazy[T] — deferred injection

Wraps the dependency in a `LazyProxy`. The real instance is **not resolved until `.get()` (or `.aget()`) is called for the first time**, after which the result is cached.

The primary use case is **breaking circular dependencies** — `A` can hold `Lazy[B]` while `B` holds `A` directly:

```python
from providify import Lazy

@Singleton
class ReportService:
    def __init__(self, repo: Lazy[ReportRepository]) -> None:
        self._repo = repo   # proxy — ReportRepository not resolved yet

    def run(self) -> Report:
        return self._repo.get().fetch_all()   # resolved here on first call

# Async resolution
async def run_async(self) -> Report:
    repo = await self._repo.aget()
    return await repo.fetch_all_async()
```

`Lazy` also accepts `qualifier=`, `priority=`, and `optional=` via `Annotated` + `LazyMeta`:

```python
from typing import Annotated
from providify import LazyMeta

repo: Annotated[Cache, LazyMeta(qualifier="redis", priority=1)]

# optional=True — proxy.get() returns None if T is not bound (instead of raising)
svc: Annotated[OptionalService, LazyMeta(optional=True)]
```

#### Optional lazy injection — `Lazy[T | None]`

Use the pipe-union form as shorthand for `optional=True`:

```python
from providify import Lazy

# Both are equivalent — proxy.get() returns None when T is not bound
svc: Lazy[OptionalService | None]                                   # pipe-union form
svc: Annotated[OptionalService, LazyMeta(optional=True)]            # explicit form
```

> ⚠️ **`Lazy[T]` is not scope-safe for `@RequestScoped` or `@SessionScoped` deps.**
> After the first `.get()` call the proxy caches the resolved instance — subsequent calls return the same (stale) object regardless of which request is active.
> Use **`Live[T]`** instead when a longer-lived component needs a scoped dep.

### Live[T] — always-fresh injection

Returns a `LiveProxy` that calls `container.get(T)` on **every `.get()` or `.aget()` invocation** — it never caches. The correct choice when a longer-lived component (`@Singleton`, `@SessionScoped`) holds a `@RequestScoped` or `@SessionScoped` dependency.

```python
from providify import Live

@Singleton
class AuthService:
    def __init__(self, token: Live[JsonWebToken]) -> None:
        self._token = token   # LiveProxy — not the token itself

    def get_user_id(self) -> str:
        # Re-resolves from the active request scope on every call
        return self._token.get().subject

    async def get_user_id_async(self) -> str:
        token = await self._token.aget()
        return token.subject
```

Works as a class-level annotation too:

```python
@Singleton
class AuthService:
    token: Live[JsonWebToken]   # set after construction, re-resolves per request
```

`Live` also accepts `qualifier=`, `priority=`, and `optional=` via `Annotated` + `LiveMeta`:

```python
from typing import Annotated
from providify import LiveMeta

token: Annotated[JsonWebToken, LiveMeta(qualifier="bearer")]

# optional=True — proxy.get() returns None if T is not bound
ctx: Annotated[OptionalContext, LiveMeta(optional=True)]
```

#### Optional live injection — `Live[T | None]`

Use the pipe-union form as shorthand for `optional=True`. Every `.get()` / `.aget()`
call returns `None` when T is not bound, instead of raising `LookupError`:

```python
from providify import Live

# Both are equivalent
ctx: Live[OptionalContext | None]                                    # pipe-union form
ctx: Annotated[OptionalContext, LiveMeta(optional=True)]             # explicit form
```

**`Lazy[T]` vs `Live[T]` at a glance:**

| | `Lazy[T]` | `Live[T]` |
|---|---|---|
| First `.get()` | Resolves and **caches** | Resolves (no cache) |
| Subsequent `.get()` | Returns **cached** instance | Re-resolves every time |
| Circular deps | ✅ Breaks A→B→A cycles | ❌ Does not help |
| Scoped deps in singletons | ❌ Stale after first access | ✅ Always fresh |

---

## Scope contexts

`@RequestScoped` and `@SessionScoped` bindings require an active scope context.

```python
# Sync request scope
with container.request():
    svc = container.get(RequestLogger)   # same instance within this block

# Async request scope
async with container.arequest():
    svc = await container.aget(RequestLogger)

# Session scope — provide a stable ID to share state across multiple requests
with container.session("user-abc"):
    profile = container.get(UserProfile)

# Resume the same session later
with container.session("user-abc"):
    profile = container.get(UserProfile)   # same cached instance

# Destroy a session on logout
container.invalidate_session("user-abc")

# scope_context property — still available for advanced use or direct cache access
with container.scope_context.request():   # equivalent to container.request()
    ...
```

> Resolving a `@RequestScoped` or `@SessionScoped` binding outside an active context
> raises `RuntimeError` immediately.

---

## Scope safety

The container detects scope leaks at `validate_bindings()` time (triggered by the first `get()` call) and raises before any instance is created.

A **scope leak** occurs when a longer-lived component holds a direct reference to a shorter-lived one, causing it to silently serve a stale instance across scope boundaries.

### LiveInjectionRequiredError

Raised when a `@Singleton` (or `@SessionScoped`) injects a `@RequestScoped` or `@SessionScoped` dep via `Inject[T]`, `Lazy[T]`, or a bare type annotation — all of which capture one instance at construction time:

```python
@Singleton
class Bad:
    def __init__(self, ctx: RequestContext) -> None:  # ❌ captured once, stale forever
        self.ctx = ctx
```

Fix: wrap with `Live[T]` so the dep is re-resolved on every access:

```python
@Singleton
class Good:
    def __init__(self, ctx: Live[RequestContext]) -> None:  # ✅ re-resolves per request
        self._ctx = ctx
```

Scope safety is checked for **both** constructor parameters and class-level annotations:

```python
@Singleton
class AlsoDetected:
    ctx: Inject[RequestContext]   # ❌ also caught — same rule applies to class-level attrs
```

### ScopeViolationDetectedError

Raised for other scope leaks — e.g. a `@Singleton` holding a `@Component` (DEPENDENT) dep directly. This is less critical but still signals a design issue: the singleton pins one `@Component` instance for its entire lifetime instead of getting a fresh one.

### AnnotationResolutionError

Annotations are resolved **per parameter / per class attribute**, not as a whole
signature. Raised when a parameter or class attribute that IS (or plausibly is) an
injection point — annotated `Inject[T]`, `Lazy[T]`, `Live[T]`, `Instance[T]`,
`InjectInstances[T]`, or a `ClassVar` wrapping one of those — cannot be evaluated at
runtime, naming the exact parameter or attribute. This can surface either during
scope-leak validation (`validate_bindings()` / the first `get()`/`aget()` call) or
directly at construction/provider-call time, since resolution and validation now
share the same per-parameter resolver.

An unresolvable annotation on a parameter that is **not** an injection point (a
`TYPE_CHECKING`-only import, a defaulted local type) never raises this — it has no
effect on anything else on the same signature.

Fix: import the annotated type at runtime instead of under `TYPE_CHECKING`, or move locally-defined types to module level.

---

## Lifecycle hooks

### @PostConstruct

Called by the container immediately after the instance is constructed and all
dependencies are injected. Both sync and async forms are supported.

```python
from providify import PostConstruct

@Singleton
class SearchIndex:
    @PostConstruct
    def build(self) -> None:
        self._load_from_disk()

    # Async — must resolve with aget()
    @PostConstruct
    async def async_build(self) -> None:
        await self._fetch_from_s3()
```

### @PreDestroy

Called when a cached instance is about to be discarded. Fires in two situations:

- **`shutdown()` / `ashutdown()`** — for every cached `@Singleton` instance.
- **Scope exit** — when a `request()` / `session()` block exits, `@PreDestroy` is called on every `@RequestScoped` / `@SessionScoped` instance cached in that scope.

`DEPENDENT` instances are not owned by the container and are never destroyed automatically.

```python
from providify import PreDestroy

@Singleton
class ConnectionPool:
    @PreDestroy
    def close(self) -> None:
        self._pool.close()

    # Async — use ashutdown() / arequest() / asession() to invoke
    @PreDestroy
    async def async_close(self) -> None:
        await self._pool.aclose()

@RequestScoped
class RequestLogger:
    @PreDestroy
    def flush(self) -> None:
        self._buffer.flush()   # called automatically when request() block exits
```

> **Sync scope exit + async `@PreDestroy`**: if an async `@PreDestroy` hook is registered
> on a scoped instance and the scope exits via the sync `request()` / `session()` context
> manager, the async hook is **skipped** (cannot be awaited in a sync context). Use
> `arequest()` / `asession()` when your scoped components have async teardown.

### Shutdown

```python
container.shutdown()         # calls @PreDestroy on all cached singletons, clears caches
await container.ashutdown()  # async — awaits async @PreDestroy hooks
```

- Singletons are torn down in **reverse dependency order** (dependents before the
  dependencies they may still reference), matching Spring/.NET/Quarkus — see
  [`docs/agents/usage-rules.md`](docs/agents/usage-rules.md) R11 for the ordering
  guarantee and `ShutdownError` aggregation semantics.

Calling `shutdown()` when any cached singleton has an `async @PreDestroy` raises `RuntimeError` —
use `ashutdown()` in that case.

### @Disposes — provider teardown

Define a teardown method for a `@Provider`-produced object inside a `@Configuration` module.
`@Disposes` is the CDI equivalent of combining `@PreDestroy` with a factory-produced bean.

```python
from providify import Disposes, Provider, Configuration
from providify.metadata import Scope

class Connection:
    def close(self) -> None: ...

@Configuration
class InfraModule:
    @Provider(scope=Scope.SINGLETON)
    def make_conn(self) -> Connection:
        return Connection()

    @Disposes(Connection)
    def close_conn(self, conn: Connection) -> None:
        conn.close()   # called by shutdown() — receives the cached instance

container.install(InfraModule)
conn = container.get(Connection)
container.shutdown()   # close_conn(conn) is called here ✅
```

`@Disposes` is only triggered for **SINGLETON-scoped providers** that have a cached instance. DEPENDENT providers are not tracked and their disposers are never called.

### DEPENDENT scope tracking — `track=True`

DEPENDENT beans (`@Component`) are not owned by the container and normally receive no teardown. Opt in with `track=True` to have the container collect them and call their `@PreDestroy` hooks on demand:

```python
from providify import Component, PreDestroy

@Component(track=True)
class TempFile:
    def __init__(self, path: str) -> None:
        self.path = path

    @PreDestroy
    def cleanup(self) -> None:
        os.remove(self.path)

with container:
    container.register(TempFile)
    f = container.get(TempFile)
    # __exit__ calls flush_dependents() automatically — cleanup() fires here ✅
```

Call manually when not using the context manager:

```python
container.flush_dependents()        # sync
await container.aflush_dependents() # async
```

---

## @Configuration modules

A `@Configuration` class is an **optional namespace for grouping related `@Provider`
methods** — the providify equivalent of a CDI bean that hosts several `@Produces`
methods. It is *not* a Spring-style "config bean" you reach for by default.

> **When to use it:** annotate the classes you own with `@Component` / `@Singleton`
> directly — that is the default. Use `@Provider` (and group them in
> `@Configuration`) only for types you can't annotate. See [PROVIDERS.md](PROVIDERS.md)
> for the full decision rule and the Jakarta CDI mapping.

Each `@Provider` method's parameters are injected from the container — no `__init__`
boilerplate required:

```python
from providify import Configuration
from providify.decorator.scope import Provider, Singleton

@Singleton
class AppConfig:
    db_url  = "postgresql://localhost/mydb"
    pool_size = 10

@Configuration
class DatabaseModule:
    @Provider(singleton=True)
    def connection_pool(self, config: AppConfig) -> ConnectionPool:
        return ConnectionPool(config.db_url, size=config.pool_size)

    @Provider
    def user_repo(self, pool: ConnectionPool) -> UserRepository:
        # `pool` is injected from the container → respects the singleton above.
        return UserRepository(pool)

container.register(AppConfig)
container.scan("myapp.infra")             # auto-installs the @Configuration, or…
container.install(DatabaseModule)         # …install it explicitly (sync)
await container.ainstall(DatabaseModule)  # async — use when module deps need aget()
```

> ⚠️ Don't call a sibling producer directly (`self.connection_pool()`) — that bypasses
> the container and re-runs the factory, defeating `singleton=` caching. Declare the
> dependency as a parameter instead, as `user_repo` does above.

All `@Provider` options (`qualifier=`, `priority=`, `singleton=`) work normally inside modules.

**Optional — shared config via `__init__`:** if many providers need the *same* injected
object, you can inject it once through the module's constructor (Spring-style) and reach
it via `self`. The constructor's deps are resolved eagerly at install/scan time:

```python
@Configuration
class DatabaseModule:
    def __init__(self, config: AppConfig) -> None:
        self._config = config   # injected at install() / scan() time

    @Provider(singleton=True)
    def connection_pool(self) -> ConnectionPool:
        return ConnectionPool(self._config.db_url, size=self._config.pool_size)
```

### Multi-module ordering — `depends_on=`

When one module's constructor (or `@Provider` method) needs a type produced by
**another** module, declare the ordering explicitly with `depends_on=` — don't rely
on `scan()`'s alphabetical discovery order:

```python
@Configuration
class InfraModule:
    @Provider(singleton=True)
    def pool(self) -> DatabasePool: ...
    @PreDestroy
    def close(self) -> None: ...          # runs last, after every singleton

@Configuration(depends_on=[InfraModule])
class RepoModule:
    def __init__(self, pool: DatabasePool): ...   # resolvable — Infra installs first
```

```
install order  :  InfraModule → RepoModule → ServiceModule
shutdown order :  singletons (reverse creation order)
                  then ServiceModule → RepoModule → InfraModule
```

`container.install(ServiceModule)` transitively installs its `depends_on` closure
first, in a deterministic order — deps before dependents, shared dependencies
installed exactly once. A cycle in `depends_on` raises `ModuleCycleError` (naming
every class in the cycle) **before** any module is instantiated, so a cycle leaves
the container completely untouched.

A module's `@PostConstruct` runs once, at install time, after `__init__` and before
its `@Provider` methods are registered. A module's `@PreDestroy` runs at
`shutdown()`/`ashutdown()` — in exact reverse install order, strictly **after**
every singleton has already been torn down (a module's `@PreDestroy` typically
releases a resource, like a pool, that every singleton consumer of it is already
gone by the time it runs). `ainstall()` mirrors `install()` for async-only module
constructor deps and async `@PostConstruct`/`@PreDestroy` hooks.

Installing the same module twice — explicitly, or via `install()` followed by
`scan()` covering it — registers its providers exactly once; the container is the
dedup authority, keyed on the module class.

### Field-level `@Provider` — `@property` pattern

Combine `@property` with `@Provider` to declare a provider as a property on the module class. The return type annotation determines the registered interface — identical to a regular `@Provider` method but allows `self.config` syntax for accessing the produced value within the module:

```python
from providify import Provider, Configuration
from providify.metadata import Scope as _Scope

@Configuration
class AppModule:
    @property
    @Provider
    def config(self) -> AppConfig:
        return AppConfig(dsn=os.environ["DATABASE_URL"])

    @property
    @Provider(scope=_Scope.SINGLETON)
    def cache(self) -> Cache:
        return Cache(url=os.environ["REDIS_URL"])
```

Both `@Provider` and `@Provider(scope=...)` work on properties. The inner `@Provider` must be the innermost decorator (closest to the function definition) and `@property` wraps it on the outside.

---

## @ConfigProperties — typed configuration binding

`@ConfigProperties` marks a class as a typed settings target bound from
environment variables, YAML, JSON, or TOML — removing the need to
hand-write a `@Provider` that reads `os.environ` yourself. It is **not**
`@Configuration` — see the next section for that distinction.

```python
from dataclasses import dataclass
from providify import DIContainer, ConfigProperties, EnvSource, YamlSource

@ConfigProperties(
    prefix="db",
    sources=(EnvSource(), YamlSource("config.yaml", required=False)),
)
@dataclass(frozen=True)
class DbSettings:
    url: str
    pool_size: int = 5
    replicas: tuple[str, ...] = ()

container = DIContainer()
container.bind_config(DbSettings)   # …or container.scan("myapp") discovers it
db = container.get(DbSettings)      # DbSettings(url=..., pool_size=..., ...)
```

`EnvSource()` reads `DB__URL`, `DB__POOL_SIZE`, `DB__REPLICAS=a,b` (`__` nests
by default); `YamlSource("config.yaml")` reads a `db:` section from a YAML
file. Sources are loaded in order and deep-merged — the **later** source
wins on a key conflict — then `prefix="db"` selects the `db` subtree before
binding. `JsonSource` and `TomlSource` work the same way for `.json` / `.toml`
files; `DictSource` is an in-memory source, handy for tests.

Declared fields are coerced to their annotated type (`str`, `int`, `float`,
`bool`, `pathlib.Path`, sequences, `dict`, `X | None`, `Enum`, `Literal`,
nested dataclasses) — env vars are always strings, so `pool_size: int` works
from a literal `"20"`. Every field failure is aggregated into one
`ConfigBindingError` instead of stopping at the first bad field:

```python
from providify import ConfigBindingError

try:
    container.get(DbSettings)
except ConfigBindingError as exc:
    for issue in exc.issues:
        log.error("%s: %s (%s)", issue.field, issue.message, issue.source)
```

`YamlSource` requires the optional `providify[yaml]` extra (`pip install
providify[yaml]`) — the core install stays dependency-free, and `YamlSource`
only imports PyYAML lazily, inside `.load()`, so nothing breaks until you
actually construct one without the extra installed.

### Pydantic hand-off — `model_validate`

If the target class exposes `model_validate` (pydantic v2, or anything
API-compatible), providify hands it the merged mapping **verbatim** and
performs **no coercion of its own** — the pydantic model owns validation
entirely, giving you full pydantic constraints (ranges, regexes,
cross-field rules) with zero coercion-table maintenance:

```python
from pydantic import BaseModel

@ConfigProperties(prefix="db", sources=(EnvSource(),))
class DbSettings(BaseModel):
    url: str
    pool_size: int = 5

container.bind_config(DbSettings)
container.get(DbSettings)   # cls.model_validate(merged_mapping) — pydantic validates
```

This is a **duck-typed** check (`hasattr(cls, "model_validate")`) — providify
never imports or pins pydantic, so it stays an optional *target* type, never
a runtime dependency of the library itself.

### When errors surface

Binding is **lazy**: sources are read and coercion/validation happens on the
**first** `container.get(DbSettings)` call, not at `bind_config()`
registration time. Call `container.warm_up()` to catch a misconfigured value
at startup instead of on first use — `container.validate()` does **not**
catch this, because it never instantiates anything (see
[`docs/agents/usage-rules.md`](docs/agents/usage-rules.md)).

---

## Autodiscovery — `scan()`

`scan()` inspects a module (or an entire package tree) and automatically registers every
class and function that carries a scope decorator or `@Provider` — no manual `bind()` /
`register()` / `provide()` call needed.

```python
container = DIContainer()

# Scan a single module by dotted name
container.scan("myapp.services")

# Scan a whole package and every sub-package inside it
container.scan("myapp", recursive=True)

# Pass an already-imported module object instead of a string
import myapp.repositories
container.scan(myapp.repositories)
```

### Auto-scan at construction time

Pass `scan=` to the `DIContainer` constructor to scan one or more modules
**immediately when the container is created** — before any `bind()` / `get()` call.
This keeps the bootstrap code declarative and ensures every decorated class is
registered before any other code interacts with the container.

```python
from providify import DIContainer

# Single package — all sub-packages walked recursively (default)
container = DIContainer(scan="myapp")

# Explicit list — both packages scanned, left-to-right
container = DIContainer(scan=["myapp.services", "myapp.repositories"])

# Opt out of recursive walking
container = DIContainer(scan="myapp.services", recursive=False)
```

| Parameter | Type | Default | Meaning |
|-----------|------|---------|---------|
| `scan` | `str \| list[str] \| None` | `None` | Module(s) to scan at construction. `None` = no auto-scan (backward-compatible). |
| `recursive` | `bool` | `True` | Walk sub-packages recursively. Forwarded to each `scan()` call. |

> **`ModuleNotFoundError`** is raised at construction time if a module name
> cannot be imported — errors surface at the point of misconfiguration rather
> than at the first `get()` call.

The constructor `scan=` is purely additive — you can still call `container.scan()`
manually afterward to register additional modules.

### What gets discovered

| Decorator on the member | What the scanner registers |
|-------------------------|---------------------------|
| `@Component` / `@Singleton` / `@RequestScoped` / `@SessionScoped` | The class, bound to every abstract base class it implements; self-bound if it has none |
| `@Provider` function | The function, equivalent to calling `container.provide(fn)` |
| `@Configuration` class | Auto-installed via `container.install()` — deduplicated by class identity, so scanning twice is safe. (Calling `install()` yourself remains valid for the no-scan path.) |

### Abstract base class auto-binding

When a scanned class implements one or more abstract base classes (ABCs), the scanner
automatically binds each ABC to the concrete class. You can then resolve by the interface
without writing any `bind()` call yourself.

```python
from abc import ABC, abstractmethod
from providify import Component

class IRepository(ABC):
    @abstractmethod
    def find_all(self) -> list: ...

@Component
class SqlRepository(IRepository):
    def find_all(self) -> list:
        return []

container.scan("myapp.repositories")
# Equivalent to: container.bind(IRepository, SqlRepository)

repo = container.get(IRepository)   # SqlRepository is resolved
```

### What the scanner skips

- **Private members** — anything whose name starts with `_`
- **Re-exports** — symbols imported *into* the scanned module from somewhere else;
  only members *defined* in that module are registered (prevents duplicate bindings)
- **Plain classes** — classes without a scope decorator are silently ignored

### Idempotency

Calling `scan()` multiple times on the same module is safe — the scanner checks for
existing bindings before registering and skips any class or provider that is already
registered.

```python
container.scan("myapp.services")
container.scan("myapp.services")   # no-op — bindings already present
```

### Recursive scanning

Pass `recursive=True` to discover every sub-package automatically. Sub-modules that
fail to import are logged as warnings and skipped rather than halting the entire scan.

```python
# Registers decorated members from myapp, myapp.services,
# myapp.repositories, myapp.utils, and so on
container.scan("myapp", recursive=True)
```

---

## Named qualifiers and priority

### @Named and @Priority decorators

Qualifiers and priorities can be applied inline via the scope decorator or as
separate `@Named` / `@Priority` modifiers on top of any scope decorator.

```python
from providify import Named, Priority

# Inline form — shorter, good for simple cases
@Singleton(qualifier="primary", priority=1)
class PrimaryDB(Database): ...

# Modifier form — useful when the qualifier or priority is a separate concern
@Singleton
@Named(name="replica")
@Priority(priority=2)
class ReplicaDB(Database): ...
```

`@Named` requires keyword argument `name=`. Both `@Named` (bare, no parens) and `@Named("smtp")` (positional string) raise `TypeError` with a message pointing to the correct form: `@Named(name="smtp")`.

Both modifiers work on `@Provider` functions too:

```python
@Provider(singleton=True)
@Named(name="readonly")
@Priority(priority=5)
def make_replica() -> Database:
    return ReplicaDB(url=os.environ["REPLICA_URL"])
```

### Resolving by qualifier and priority

```python
@Singleton(qualifier="primary")
class PrimaryDB(Database): ...

@Singleton(qualifier="replica", priority=1)
class ReplicaDB(Database): ...

# Resolve by name
db = container.get(Database, qualifier="primary")

# Resolve all, sorted by priority ascending (lowest value first, highest-priority binding last)
all_dbs = container.get_all(Database)
```

---

## Generic types

The container resolves parameterised generic types — bind and get `Repository[User]`
as a distinct interface from `Repository[Post]`.

```python
from typing import Generic, TypeVar
from abc import ABC, abstractmethod
from providify import Component

T = TypeVar("T")

class Repository(ABC, Generic[T]):
    @abstractmethod
    def find(self, id: int) -> T: ...

@Component
class UserRepository(Repository[User]):
    def find(self, id: int) -> User: ...

@Component
class PostRepository(Repository[Post]):
    def find(self, id: int) -> Post: ...

container.bind(Repository[User], UserRepository)
container.bind(Repository[Post], PostRepository)

user_repo = container.get(Repository[User])   # UserRepository
post_repo = container.get(Repository[Post])   # PostRepository
```

---

## Warm-up — eager singleton instantiation

By default singletons are created lazily on the first `get()` call.
Call `warm_up()` to pre-create them at startup so the first real request
doesn't pay the construction cost.

```python
# Sync — raises RuntimeError if any singleton has an async provider
container.warm_up()
container.warm_up(qualifier="db")    # only bindings with qualifier="db"
container.warm_up(priority=0)        # only bindings with priority=0

# Async — handles both sync and async singleton providers
await container.awarm_up()
await container.awarm_up(qualifier="db")
```

`warm_up()` is all-or-nothing: if any matching singleton is backed by an async
provider it raises **before** touching the cache, so the cache is never left
partially warmed.  Use `awarm_up()` when you have async providers.

---

## Startup validation — `validate()`

Call `container.validate()` **once, after all registration and before serving
traffic.** It walks the entire declared dependency graph — every constructor
parameter, class-level annotation, and `@Provider` parameter — without
instantiating anything, and reports every wiring defect in one shot: missing
bindings, ambiguous bindings, dependency cycles, scope leaks, `Live[T]`
violations, and unresolvable annotations.

```python
from providify import DIContainer, ContainerValidationError

container = DIContainer()
container.scan("myapp")

container.validate()   # raises ContainerValidationError if any ERROR-severity issue exists
```

Or inspect the report instead of raising:

```python
report = container.validate(raise_on_error=False)
if not report.ok:
    for issue in report.errors:
        log.error("%s", issue.message)
    for issue in report.warnings:
        log.warning("%s", issue.message)
```

`validate()` is a superset of `validate_bindings()` / `validate_all()`: those
two remain unchanged (scope-leak tier only, triggered automatically on the
first `get()`/`aget()`); `validate()` adds the whole-graph checks — missing
bindings, ambiguous bindings, and static cycle detection — that only a full
walk can catch, and never mutates state (no `create()`, no cache write, no
`@PostConstruct`). The report/issue types (`ValidationReport`,
`ValidationIssue`, `IssueKind`, `Severity`) live in `providify/validation.py`;
see the `validate()` docstring in `providify/container.py` for the full
per-issue-kind breakdown (which forms are `ERROR` vs `WARNING` and why).

---

## Dependency visualization

`container.describe()` returns a `DIContainerDescriptor` — a snapshot of the
entire binding registry with recursive dependency trees, scope information, and
scope-leak flags.  Use it to audit your wiring, generate documentation, or
build health-check endpoints.

> **Note:** `describe()` only resolves dependencies declared with `Inject[T]`,
> `Live[T]`, or `Lazy[T]`.  Plain type annotations (`dep: MyClass`) are
> invisible to the dependency graph layer.

```python
from providify.type import Inject

class Notifier: pass

@Singleton
class AlertService:
    def __init__(self, notifier: Inject[Notifier]) -> None:
        self.notifier = notifier

@Singleton
class EmailNotifier(Notifier): pass

container.bind(Notifier, EmailNotifier)
container.register(AlertService)

descriptor = container.describe()
print(descriptor)
# [SINGLETON]
# ├── Notifier [SINGLETON] → EmailNotifier
# ├── EmailNotifier [SINGLETON] → EmailNotifier
# └── AlertService [SINGLETON] → AlertService
#     └── Notifier [SINGLETON] → EmailNotifier
```

### Structured access

```python
# Bindings grouped by scope
descriptor.singleton_bindings   # list[BindingDescriptor]
descriptor.request_bindings
descriptor.session_bindings
descriptor.dependent_bindings

# Scope-leak detection — True when a longer-lived component holds a
# direct reference to a shorter-lived one (e.g. SINGLETON → DEPENDENT)
for b in descriptor.singleton_bindings:
    for dep in b.dependencies:
        if dep.scope_leak:
            print(f"⚠ Scope leak: {b.interface} → {dep.interface}")
```

### JSON export

```python
import json

data = container.describe().to_dict()
print(json.dumps(data, indent=2))
# {
#   "validated": false,
#   "singleton_bindings": [
#     {
#       "interface": "AlertService",
#       "implementation": "AlertService",
#       "scope": "SINGLETON",
#       "qualifier": null,
#       "scope_leak": false,
#       "priority": null,
#       "dependencies": [
#         {
#           "interface": "Notifier",
#           "implementation": "EmailNotifier",
#           "scope": "SINGLETON",
#           "scope_leak": false,
#           ...
#         }
#       ]
#     }
#   ],
#   ...
# }
```

---

## Circular dependency detection

The container detects circular dependencies at resolution time and raises
`CircularDependencyError` with a readable chain:

```
CircularDependencyError: Circular dependency detected: OrderService -> PaymentService -> OrderService
```

To break a cycle intentionally, use `Lazy[T]`:

```python
@Component
class A:
    def __init__(self, b: Lazy[B]) -> None:
        self._b = b   # proxy — B is not resolved during A's construction

@Component
class B:
    def __init__(self, a: A) -> None:
        self.a = a    # A is fully constructed here — no cycle
```

---

## Interceptors — @Interceptor / @AroundInvoke

Interceptors add cross-cutting behaviour (logging, transactions, metrics) to any bean without modifying its source code.

### Define an interceptor binding

`@InterceptorBinding` creates an annotation class that binds an interceptor to its targets:

```python
from providify import InterceptorBinding, Interceptor, AroundInvoke, InvocationContext

@InterceptorBinding
class Logged: ...       # use @Logged to mark targets

@InterceptorBinding
class Transactional: ...
```

### Implement the interceptor

```python
@Transactional
@Interceptor
class TxInterceptor:
    @AroundInvoke
    def around(self, ctx: InvocationContext) -> object:
        print(f"BEGIN tx for {ctx.method.__name__}")
        result = ctx.proceed()   # call the next interceptor or the real method
        print("COMMIT tx")
        return result
```

### Attach and activate

```python
@Transactional          # binds TxInterceptor to this bean
@Singleton
class OrderService:
    def place_order(self, order: Order) -> None: ...

container.register(OrderService)
container.add_interceptor(TxInterceptor)

svc = container.get(OrderService)
svc.place_order(order)
# Output:
# BEGIN tx for place_order
# COMMIT tx
```

### InvocationContext

`ctx.proceed()` calls the next interceptor in the chain, or the real method if no more interceptors remain.

| Attribute | Type | Description |
|-----------|------|-------------|
| `ctx.target` | `object` | The bean instance |
| `ctx.method` | `Callable` | The method being intercepted |
| `ctx.parameters` | `tuple` | Positional arguments |
| `ctx.kwargs` | `dict` | Keyword arguments |
| `ctx.proceed()` | `Any` | Continue the invocation chain |

> **Note**: `isinstance(proxy, TargetClass)` returns `False` on the wrapped proxy. Use `type(proxy._target)` if you need the real class.

### Field interceptors — `Advised` / `@AroundGet` / `@AroundSet`

> ⚠️ **This goes beyond Jakarta CDI, which is method-only.** Jakarta
> Interceptors 2.1 defines exactly five interception types (`@AroundInvoke`,
> `@AroundTimeout`, `@PostConstruct`, `@PreDestroy`, `@AroundConstruct`) —
> **none field-level**, in either the Lite or Full profile. This is **not** a
> CDI-parity feature. The reference model is AspectJ's `get`/`set` pointcuts,
> implemented here with Python's data-descriptor protocol (the same technique
> Django ORM fields, SQLAlchemy columns and Traitlets use) rather than
> AspectJ's compile-time bytecode weaving.

Only fields whose class body assigns `Advised(...)` are join points — every
other attribute costs nothing and is invisible to the interceptor chain:

```python
from providify import Advised, AroundGet, AroundSet, FieldAccessContext, InterceptorBinding, Interceptor, Component

@InterceptorBinding
class Audited: ...

@Audited
@Interceptor
class AuditInterceptor:
    @AroundSet
    def on_set(self, ctx: FieldAccessContext) -> None:
        print(f"{type(ctx.target).__name__}.{ctx.field} = {ctx.value!r}")
        ctx.proceed()          # let the write land; skip this call to veto it

    @AroundGet
    def on_get(self, ctx: FieldAccessContext) -> object:
        return ctx.proceed()   # can transform the returned value

@Audited
@Component
class Account:
    balance: float = Advised(0.0)   # declared join point
    owner: str = "anon"             # NOT advised — plain attribute

container.register(Account)
container.add_interceptor(AuditInterceptor)

acct = container.get(Account)
acct.balance = 10.0   # fires on_set
acct.balance          # fires on_get
```

- **Only fields declared `Advised(...)` are advised** — there is no
  `__getattribute__`/`__setattr__` override on the target class.
- **Advice is armed only for container-managed instances.** `Account()`
  built directly (outside the container) behaves like a plain attribute.
- **Advice is not armed during construction.** Writes performed by
  `__init__`, class-var injection, and `@PostConstruct` are never advised —
  the chain is attached only after `binding.create()` returns.
- **No `__delete__` advice** — `del obj.field` always passes through
  unadvised, matching AspectJ's `get`/`set`-only pointcuts.
- **`self.balance` inside the bean's own methods is advised** — unlike the
  method-interceptor `_InterceptorProxy`, which can only see calls made from
  *outside* the bean, `Advised` is a descriptor on the class itself.
- **Unsupported targets are rejected loudly.** A `dataclasses.is_dataclass()`
  class, a pydantic `BaseModel`, or an `attrs` class with an `Advised` field
  raises `TypeError` naming the class and field — a frozen dataclass's
  `object.__setattr__`-based `__init__` would otherwise silently bypass the
  descriptor and corrupt reads.

---

## @Decorator — interface delegation

`@Decorator` stacks additional behaviour on top of an existing bean without replacing it. The decorator receives the original bean via `Delegate[T]` injection — identical to the Jakarta CDI `@Decorator` pattern.

```python
from providify import Decorator, Delegate, Component
from typing import Annotated
from providify.type import DelegateMeta

class Notifier:
    def send(self, msg: str) -> None: ...

@Component
class EmailNotifier(Notifier):
    def send(self, msg: str) -> None:
        print(f"email: {msg}")

@Decorator
@Component(priority=10)   # higher priority ensures this wraps EmailNotifier
class LoggingNotifier(Notifier):
    def __init__(self, delegate: Annotated[Notifier, DelegateMeta()]) -> None:
        self._delegate = delegate   # receives EmailNotifier instance

    def send(self, msg: str) -> None:
        print(f"[LOG] sending: {msg}")
        self._delegate.send(msg)   # delegate to inner bean

container.bind(Notifier, EmailNotifier)
container.register(LoggingNotifier)

n = container.get(Notifier)
n.send("hello")
# [LOG] sending: hello
# email: hello
```

Multiple decorators can be stacked — they are applied in ascending priority order (lowest first, outermost last).

---

## Event bus — Event[T] / @Observes

Fire and observe typed events across beans without direct coupling.

### Fire an event

Inject `Event[T]` anywhere; call `.fire()` (or `await .afire()` for async):

```python
from providify import Component, Event
from typing import Annotated
from providify.type import EventMeta

class UserRegistered:
    def __init__(self, email: str) -> None:
        self.email = email

@Component
class RegistrationService:
    def __init__(self, user_event: Annotated[UserRegistered, EventMeta()]) -> None:
        self._event = user_event   # EventProxy[UserRegistered]

    def register(self, email: str) -> None:
        # ... create user ...
        self._event.fire(UserRegistered(email))     # sync
        # await self._event.afire(UserRegistered(email))  # async
```

### Observe an event

```python
from providify import Singleton, Observes

@Singleton
class AuditLog:
    @Observes(UserRegistered)
    def on_user_registered(self, event: UserRegistered) -> None:
        print(f"New user: {event.email}")

    @Observes(UserRegistered)
    async def async_on_registered(self, event: UserRegistered) -> None:
        await self._db.insert(event.email)
```

Observers are registered automatically when the bean is first instantiated. They are held via **weak references** — if the observer bean is garbage-collected, it is silently removed from the dispatch list.

> **Scope constraint**: observer beans should be SINGLETON, REQUEST, or SESSION scoped. DEPENDENT (`@Component`) observers may be GC'd before an event fires unless `track=True` is set.

### EventProxy API

| Method | Description |
|--------|-------------|
| `.fire(event)` | Dispatch synchronously to all registered observers |
| `await .afire(event)` | Dispatch — awaits async observers, calls sync ones directly |

Subtype events match supertype observers: if `AdminRegistered` extends `UserRegistered`, observers of `UserRegistered` will also receive `AdminRegistered` events.

---

## Observability — `container.add_hook()`

Observe what the container does — instance creation, disposal, and scope
transitions — with nanosecond timing, and bridge it to OpenTelemetry (or
structlog, statsd, Prometheus, ...) in your own code. providify emits plain
event objects; it does not create spans, a tracer, or propagate context —
that stays the consumer's job.

> **Not the same thing as `Event[T]` / `@Observes` above.** `add_hook()` is
> **container telemetry**: it delivers plain, immutable event objects to
> plain callables, describing what the *container itself* did (an instance
> was created, a scope frame closed). `Event[T]` / `@Observes` is an
> **application event bus**: it resolves *beans* through the container and
> dispatches *domain* events your application defines (`UserRegistered`,
> etc.). The two systems share no code and no state — never mix them: do
> not fire application events from a hook, and do not register a hook
> callback as an `@Observes` method.

```python
from providify import DIContainer, InstanceCreated, ScopeEntered

container = DIContainer()

def on_created(e: InstanceCreated) -> None:
    print(f"created {e.interface} in {e.duration_ns}ns (async={e.is_async})")

unsubscribe = container.add_hook(InstanceCreated, on_created)
container.add_hook(ScopeEntered, lambda e: print(f"{e.kind} scope {e.scope_id} entered"))
```

An OpenTelemetry bridge, in about 10 lines, from the plan this feature shipped from:

```python
from opentelemetry import trace
from providify import InstanceCreated, ScopeEntered

tracer = trace.get_tracer("providify")

def on_created(e: InstanceCreated) -> None:
    span = tracer.start_span("di.create", attributes={
        "di.interface": getattr(e.interface, "__name__", str(e.interface)),
        "di.scope": str(e.scope),
        "di.async": e.is_async,
    })
    span.end(end_time=None)          # duration also available as e.duration_ns

container.add_hook(InstanceCreated, on_created)
container.add_hook(ScopeEntered, lambda e: ...)
```

**providify takes no dependency on OpenTelemetry — not even `opentelemetry-api`.**
The container has zero runtime dependencies; the snippet above only works if
*you* install `opentelemetry-api`/an SDK yourself. Also note: at the time of
writing there is **no official OTel semantic convention for dependency-injection
or lifecycle spans** ([open-telemetry/semantic-conventions#2133](https://github.com/open-telemetry/semantic-conventions/issues/2133)
is still unresolved) — the attribute names in the recipe above (`di.interface`,
`di.scope`, `di.async`) are providify's own choice, not a standard, and may
need to change if/when a convention lands.

### Event types

| Event | Emitted when | Key fields |
|-------|---------------|------------|
| `InstanceCreated` | A binding produces a **new** instance (never on a cache hit) | `interface`, `implementation`, `scope`, `qualifier`, `duration_ns`, `is_async` |
| `InstanceDisposed` | A `@PreDestroy`/`@Disposes` teardown hook runs, at `shutdown()`/`ashutdown()` or scope exit | `interface`, `implementation`, `scope`, `owner`, `duration_ns`, `error` (`None` on success) |
| `ScopeEntered` | `container.request()` / `arequest()` / `session()` / `asession()` is entered | `kind` (`"request"` \| `"session"`), `scope_id` |
| `ScopeExited` | The matching scope frame exits — including when the block raises | `kind`, `scope_id`, `duration_ns` |

All four are `@dataclass(frozen=True, slots=True, kw_only=True)` — immutable,
hashable where all fields are hashable, and safe to hand to arbitrary
consumer code, including across threads.

### Registration and unsubscribing

```python
unsubscribe = container.add_hook(InstanceCreated, on_created)
...
unsubscribe()                                   # or:
container.remove_hook(InstanceCreated, on_created)
```

- Dispatch is by **exact type** — a hook registered for a supertype (e.g.
  `object`) never receives `InstanceCreated`/etc. events.
- Registering the same callback twice makes it fire twice per event.
- `add_hook()` returns a zero-argument unsubscribe closure; `remove_hook()`
  returns `True`/`False` and never raises.

### Rules hooks must follow

- **Hooks are synchronous and called inline**, in the caller's thread/task —
  they run on the hot path and must not block.
- **A hook that raises never breaks resolution or shutdown.** The exception
  is caught, logged at `WARNING`, and the remaining hooks for that event
  still run.
- **A hook must not call back into `container.get()`/`aget()`** for a
  different container, or resolve across threads — that re-entrancy is the
  caller's problem to avoid. (Re-resolving the *same* already-cached
  singleton from inside its own `InstanceCreated` hook is safe: emission
  happens after the cache write and outside the per-key lock.)
- With **no hooks registered, there is zero overhead**: no timer call, no
  event allocation, and `container.request()`/`session()` etc. return the
  underlying scope context manager unchanged.

---

## InjectionPoint — injection context metadata

Inject an `InjectionPoint` to receive metadata about _where_ a dependency is being injected — useful for dynamic configuration like per-class loggers.

```python
from providify import Singleton, InjectionPoint, Provider
from providify.decorator.module import Configuration
import logging

@Configuration
class LoggingModule:
    @Provider
    def logger(self, ip: InjectionPoint) -> logging.Logger:
        name = ip.declaring_class.__name__ if ip.declaring_class else "root"
        return logging.getLogger(name)

@Singleton
class OrderService:
    def __init__(self, log: logging.Logger) -> None:
        # log is getLogger("OrderService") — set by the InjectionPoint
        self._log = log
```

### InjectionPoint fields

| Field | Type | Description |
|-------|------|-------------|
| `declaring_class` | `type \| None` | The class being constructed at injection time (`None` for top-level calls) |
| `param_name` | `str` | The parameter name at the injection site |
| `qualifier` | `str \| type \| None` | The qualifier in effect at the injection site |
| `annotation` | `Any` | The full type annotation of the parameter |

---

## NamedMeta — name-based qualifier at the injection site

`NamedMeta` lets you specify a string qualifier directly in the type annotation — without having to pass it to `container.get()`. It is the injection-site dual of `@Named`:

```python
from typing import Annotated
from providify import Component, Singleton
from providify.type import NamedMeta

@Component(qualifier="primary")
class PrimaryDB(Database): ...

@Component(qualifier="replica")
class ReplicaDB(Database): ...

@Singleton
class ReportService:
    def __init__(
        self,
        primary: Annotated[Database, NamedMeta("primary")],
        replica: Annotated[Database, NamedMeta("replica")],
    ) -> None:
        self._primary = primary
        self._replica = replica
```

`NamedMeta("x")` at the injection site is equivalent to `container.get(Database, qualifier="x")`.

---

## Testing

Installing providify registers a `pytest11` plugin — no conftest
boilerplate needed to get working test fixtures:

```python
def test_checkout(di_container, di_overrides):
    di_container.scan("myapp")
    di_overrides.instance(Clock, FrozenClock("2026-01-01"))   # instance, not class
    di_overrides.bind(Notifier, FakeNotifier)                 # class swap
    di_overrides.remove(PaymentGateway)                       # unregister

    assert di_container.get(Checkout).run() == "ok"
    # every override is undone automatically at teardown — no reset_binding() calls
```

Fixtures (all function-scoped, `yield`-based, opt-in — none are autouse):

| Fixture | Yields | Teardown |
|---|---|---|
| `di_container` | A fresh, empty `DIContainer()` | `shutdown()` |
| `di_acontainer` | A fresh, empty `DIContainer()` (async) | `await ashutdown()` — requires pytest-asyncio/anyio |
| `di_overrides` | A `ContainerOverrides(di_container)`, already entered | restores every override made during the test |
| `di_global` | `di_container`, installed as `DIContainer.current()` | restores the previous global |

If your app already builds its own container, override `di_container` in
your own conftest — standard pytest fixture overriding, and it wins over
the plugin's default:

```python
@pytest.fixture
def di_container(app_container):
    return app_container.copy()   # isolated per test, no re-scan
```

The same machinery works without pytest — from `unittest`, a script, or a
REPL:

```python
from providify import ContainerOverrides

with ContainerOverrides(container) as ov:
    ov.instance(Clock, FrozenClock(...))
    ov.bind(Notifier, FakeNotifier)
# every override undone here
```

`ContainerOverrides` methods: `instance(iface, obj)`, `bind(iface, impl)`,
`factory(iface, fn)`, `remove(iface, *, qualifier=None)`, `profiles(*names)`,
`alternative(cls)`, and an explicit `reset()`. Every method returns `self`
for chaining.

⚠️ **Overrides restore configuration, not lifecycle.** Anything
instantiated *during* an override window is dropped without teardown
(no `@PreDestroy`/`@Disposes` runs for it) — `restore()` writes the
snapshotted singleton cache back wholesale. The default `di_container`
fixture sidesteps this because it is fresh per test with `shutdown()` in
its own teardown; only a consumer who points `di_container` at a
long-lived app container is exposed.

---

## Running tests

```bash
uv sync
uv run pytest
```

Tests are organised by feature — one file per subsystem:

| File | Covers |
|------|--------|
| `test_binding.py` | `ClassBinding`, `ProviderBinding` construction and errors |
| `test_container.py` | `bind`, `register`, `provide`, `get`, `get_all`, `current`, `scoped` |
| `test_scopes.py` | SINGLETON, DEPENDENT, REQUEST, SESSION, scope violation detection, class-level attr scope safety |
| `test_inject.py` | `Inject[T]`, `InjectInstances[T]`, `optional=True/False`, `Optional[T]` / `T \| None` / `Union[T1, T2]`, class-level attribute injection |
| `test_lazy.py` | `LazyProxy` unit tests, `Lazy[T]` injection, circular-via-lazy |
| `test_live.py` | `LiveProxy` unit tests, `Live[T]` injection, always-fresh resolution |
| `test_instance.py` | `InstanceProxy` unit tests, `Instance[T]` injection, `is_resolvable()`, scope-safety, async |
| `test_lifecycle.py` | `@PostConstruct`, `@PreDestroy`, `shutdown`, `ashutdown` |
| `test_async.py` | `aget`, `aget_all`, async providers, async context manager |
| `test_configuration.py` | `@Configuration`, `install()`, `ainstall()`, Spring-style injection |
| `test_circular.py` | `CircularDependencyError`, diamond dependency, `Lazy` cycle-break |
| `test_generics.py` | Generic[T] binding and resolution, parameterised interfaces |
| `test_scoped_providers.py` | `@Provider(scope=Scope.REQUEST/SESSION)` — factory result cached per scope |
| `test_warmup.py` | `warm_up()`, `awarm_up()`, all-or-nothing guard, qualifier/priority filter |
| `test_decorators.py` | `@Named`, `@Priority`, `@Inheritable`, stacking, error paths |
| `test_scanner.py` | `scan()`, recursive scan, ABC auto-binding, idempotency, `DIContainer(scan=...)` constructor |
| `test_describe.py` | `BindingDescriptor`, `ClassBinding.describe()`, ASCII tree output |
| `test_localns_cache.py` | `_build_localns()` caching and invalidation on `bind`/`register`/`provide` |
| `test_qualifier.py` | `@Qualifier` marker, type qualifier in binding and `get()`, string qualifier backward compat |
| `test_default.py` | `@Default` resolves same as no qualifier; `@Component(qualifier=Default)` visible on unqualified lookup |
| `test_alternative.py` | `@Alternative` excluded by default; activated by `enable_alternative()`; reverted by `disable_alternative()` |
| `test_profiles.py` | `@Profile` OR/negation matching, `PROVIDIFY_PROFILES` env var, `profiles=`/`activate_profile()`/`deactivate_profile()`, `@Profile` as `@Alternative` activator, profile-aware `validate()` |
| `test_stereotype.py` | `Stereotype(...)` applies scope/priority; explicit annotation wins over stereotype defaults |
| `test_interceptor.py` | `@Interceptor` wraps method; `ctx.proceed()` calls original; chain ordering by registration |
| `test_decorator_bean.py` | `@Decorator` wraps delegate; `DelegateMeta` receives inner implementation; multiple decorators stack |
| `test_events.py` | `fire()` reaches `@Observes` observer; `afire()` awaits async observer; subtype event matching; dead-ref cleanup |
| `test_injection_point.py` | `InjectionPoint` injected in constructor; `param_name` / `declaring_class` / `annotation` fields correct |
| `test_disposes.py` | `@Disposes` called on singleton shutdown; not called when instance never instantiated |
| `test_dependent_track.py` | `@PreDestroy` fires on `flush_dependents()`; context manager flushes on exit |
| `test_named_meta.py` | `NamedMeta("x")` resolves same binding as `qualifier="x"` |
| `test_field_provider.py` | `@property @Provider` registered at `install()` time; singleton property shares instance |
| `test_application_scoped.py` | `@ApplicationScoped` is identical to `@Singleton` |
| `test_run_in_scope.py` | `run_in_request` activates scope; `@RequestScoped` beans resolve; scope torn down after fn returns |

---

## Instance[T] — programmatic lookup handle

`Instance[T]` is the Jakarta CDI-inspired alternative when you need full
programmatic control over resolution at call time. Unlike `Inject[T]` (resolves
once, eager) or `InjectInstances[T]` (resolves all, eager), an `Instance[T]`
injects an `InstanceProxy` that defers every lookup to the call site — and
accepts qualifier / priority as **call-time arguments**, not annotation-time
metadata.

```python
from providify import Instance

@Singleton
class NotificationRouter:
    def __init__(self, senders: Instance[Sender]) -> None:
        self._senders = senders   # InstanceProxy — nothing resolved yet

    def route(self, msg: str, channel: str) -> None:
        # Qualifier chosen at runtime — same proxy, different filter each call
        sender = self._senders.get(qualifier=channel)
        sender.send(msg)

    def broadcast(self, msg: str) -> None:
        for sender in self._senders.get_all():
            sender.send(msg)

    def has_channel(self, channel: str) -> bool:
        # Side-effect-free check — never creates an instance
        return self._senders.resolvable(qualifier=channel)
```

### InstanceProxy methods

| Method | Description |
|--------|-------------|
| `.get(qualifier=None, priority=None)` | Resolve highest-priority match (sync) |
| `.get_all(qualifier=None)` | Resolve all matches sorted by priority (sync) |
| `.aget(qualifier=None, priority=None)` | Same as `.get()`, async |
| `.aget_all(qualifier=None)` | Same as `.get_all()`, async |
| `.resolvable(qualifier=None, priority=None)` | `True` if at least one binding matches — no instance created |

`get_all()` and `aget_all()` return `[]` (never raise) when no bindings match,
making them safe for the "zero or more" pattern.

### Scope safety

`Instance[T]` **always passes scope validation** — even `Instance[RequestScoped]`
inside a `@Singleton`. Because resolution is deferred to call time, the proxy
naturally fetches the current request's instance on each `.get()` call without
requiring an explicit `Live[T]` wrapper.

This exemption applies equally to the `ClassVar[Instance[T]]` form.

```python
@Singleton
class AuthGateway:
    # ✅ No LiveInjectionRequiredError — Instance[T] is inherently scope-safe
    def __init__(self, token: Instance[JwtToken]) -> None:
        self._token = token

    def verify(self) -> bool:
        return self._token.get().is_valid()   # re-resolves per request automatically
```

### `Lazy[T]` vs `Live[T]` vs `Instance[T]`

| | `Lazy[T]` | `Live[T]` | `Instance[T]` |
|---|---|---|---|
| Resolution time | First `.get()` call | Every `.get()` call | Every `.get()` call |
| Caches result | ✅ Yes | ❌ No | ❌ No |
| Qualifier at call time | ❌ Fixed at annotation | ❌ Fixed at annotation | ✅ Chosen per call |
| Breaks circular deps | ✅ Yes | ❌ No | ❌ No |
| Scope-safe in singletons | ❌ Stale after first access | ✅ Yes | ✅ Yes |
| `resolvable()` check | ❌ No | ❌ No | ✅ Yes |

---

## container.is_resolvable()

Check whether a type can be resolved **without creating any instances**:

```python
if container.is_resolvable(Notifier, qualifier="sms"):
    sms = container.get(Notifier, qualifier="sms")

# Reflects live binding state — re-evaluated on every call
container.bind(Notifier, SmsNotifier)
assert container.is_resolvable(Notifier) is True
```

---

## container.set_scoped()

Register a pre-built instance into the **currently active** scope cache.
Useful when an instance is created outside the container (e.g. deserialized
from a session cookie) and should be returned for subsequent `get()` calls
within the same scope block.

```python
with container.request():
    token = JwtToken.decode(raw_header)
    container.set_scoped(JwtToken, token)   # register into request cache

    # All code inside this block that resolves JwtToken gets this instance
    svc = container.get(AuthService)        # AuthService.token == token ✅
```

- Calling `set_scoped()` outside an active scope raises `RuntimeError` immediately.
- Calling it twice with the same type overwrites the cache entry.
- Works inside both `request()` and `session()` blocks.

---

## Scope reference

| Decorator | Lifetime |
|-----------|----------|
| `@Component` | New instance on every `get()` |
| `@Singleton` / `@ApplicationScoped` | One instance per container — shared for the container's lifetime |
| `@RequestScoped` | One instance per `container.request()` block |
| `@SessionScoped` | One instance per `container.session(id)` — survives across requests |

---

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, checks, coding standards, and
the versioning/deprecation policy.

## Security

See [SECURITY.md](SECURITY.md) for the supported-versions table and how to
report a vulnerability.
