Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ foundation:
- asynchronous factories
- generator factories
- asynchronous generator factories
- context manager and asynchronous context manager factories
- nested dependencies
- callable objects
- functools.partial
Expand Down
52 changes: 52 additions & 0 deletions docs/adr/0020-context-manager-factories-normalized-at-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ADR 0020: Context manager factories are normalized at entry

- Status: Accepted
- Date: 2026-08-01

## Context

`@contextmanager` and `@asynccontextmanager` are the ordinary way to write a
resource in Python, and most real dependencies (database sessions, clients,
transactions) already exist in that form. FastDepends understands generator
and async-generator factories, but a decorated factory returns a context
manager object instead of yielding the dependency, so it was injected
unentered and never cleaned up.

`inspect.unwrap()` resolves the decorated form, but it follows every
`__wrapped__` chain, so it also strips unrelated decorators applied with
`functools.wraps`, including `functools.lru_cache`. Applying it only in
`wired()` also splits identity: the dependency registers under the wrapped
generator function while `override_dependency()` and
`override_web_dependency()` still key on the decorator helper, so overrides
silently do nothing.

## Decision

One private `_normalize_factory()` recognizes the two `contextlib`
decorators by the code object their helper closures share, and returns the
generator function the helper wraps. Anything else is returned untouched.

Normalization runs wherever a factory enters Wireme: `wired()`,
`override_dependency()` (both factories), and the bridged-adapter lookup in
`get_override_pairs()`. Declaration and override sites therefore agree on
one identity per dependency. In `get_override_pairs()` the direct FastAPI
pair keeps the callables as given, because a plain FastAPI dependency is
registered under the object passed to `Depends()`.

`wired()` gains overloads for `AbstractContextManager[R]` and
`AbstractAsyncContextManager[R]` so a decorated factory infers `R`, matching
the generator overloads.

## Consequences

- Positive: context managers behave exactly like the generator functions
they wrap, including cleanup order, caching, FastAPI request lifecycle,
and both override entry points.
- Positive: unrelated `functools.wraps` decorators and caches keep working,
which unconditional unwrapping broke.
- Negative: detection depends on the closure shape of `contextlib`'s two
decorators. This is stdlib behavior stable across supported Python
versions and is regression tested; a change there degrades to injecting
the unentered manager rather than misfiring on other callables.
- Neutral: third-party context manager decorators are not recognized. Pass
the underlying generator function, or wrap it in one.
2 changes: 2 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ defaults, member selection, and the documentation site). Decision 0018 was
recorded on 2026-07-17 to establish a strict DI-only boundary. Decision 0019
was recorded on 2026-07-18 to establish one versioned history, a cohesive
release tooling boundary, and immutable assets as the PyPI handoff.
Decision 0020 was recorded on 2026-08-01 to accept context manager
factories as dependencies through one normalization point.
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ uv run python examples/basic.py
| Class, instance, and method factories | `factories.py` |
| Process-wide singletons | `singletons.py` |
| Generator and async resource cleanup | `resources.py` |
| Context manager factories as dependencies | `context_managers.py` |
| Side-effect dependencies (`requires`) with injected context | `requires.py` |
| Wiring many methods with an apply combinator | `method_wiring.py` |
| Test overrides | `overrides.py` |
Expand All @@ -28,6 +29,7 @@ uv run python examples/basic.py
| FastAPI request-scoped resources | `fastapi_resources.py` |
| FastAPI nested-safe web overrides | `fastapi_overrides.py` |
| FastAPI endpoints wired directly | `fastapi_endpoints.py` |
| FastAPI context manager dependencies | `fastapi_context_managers.py` |

All examples run in CI. When a public capability is added, add or extend an
example and list it here.
89 changes: 89 additions & 0 deletions examples/context_managers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Context manager factories as dependencies.

A factory decorated with @contextmanager or @asynccontextmanager behaves
exactly like the generator function it wraps: it is entered before the
wired call and closed afterwards, in reverse order. This is what lets an
existing context manager, such as a database session, be reused as a
dependency without rewriting it as a bare generator.
"""

from __future__ import annotations

import asyncio
import sqlite3
from collections.abc import AsyncGenerator, Generator
from contextlib import asynccontextmanager, contextmanager
from typing import Annotated

from wireme import Wired, wire, wired

events: list[str] = []


@contextmanager
def get_connection() -> Generator[sqlite3.Connection]:
events.append("open connection")
connection = sqlite3.connect(":memory:")
try:
yield connection
finally:
connection.close()
events.append("close connection")


type ConnectionDep = Annotated[
sqlite3.Connection,
wired(get_connection),
]


@wire
def count_rows(*, connection: ConnectionDep = Wired()) -> int:
connection.execute("create table hero (name text)")
connection.executemany(
"insert into hero values (?)",
[("Deadpond",), ("Spider-Boy",)],
)

row = connection.execute("select count(*) from hero").fetchone()

return int(row[0])


class Client:
async def fetch(self, path: str) -> str:
return f"response from {path}"


@asynccontextmanager
async def get_client() -> AsyncGenerator[Client]:
events.append("open client")
try:
yield Client()
finally:
events.append("close client")


type ClientDep = Annotated[Client, wired(get_client)]


@wire
async def fetch(path: str, *, client: ClientDep = Wired()) -> str:
return await client.fetch(path)


async def main() -> None:
assert count_rows() == 2
assert await fetch("/heroes") == "response from /heroes"
assert events == [
"open connection",
"close connection",
"open client",
"close client",
]

print("\n".join(events))


if __name__ == "__main__":
asyncio.run(main())
77 changes: 77 additions & 0 deletions examples/fastapi_context_managers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Context manager factories bridged into FastAPI with FromWeb.

The dependency is declared once with wired(...) and reused in endpoints
through FromWeb. FastAPI owns the request lifecycle: the context manager is
entered when the request needs it and exited after the response finishes.
Tests replace it with override_web_dependency like any other factory.
"""

from __future__ import annotations

import sqlite3
from collections.abc import Generator
from contextlib import contextmanager
from typing import Annotated

from fastapi import FastAPI
from fastapi.testclient import TestClient

from wireme import wired
from wireme.fastapi import FromWeb, override_web_dependency

events: list[str] = []


@contextmanager
def get_connection() -> Generator[sqlite3.Connection]:
events.append("open connection")
connection = sqlite3.connect(":memory:")
connection.execute("create table hero (name text)")
connection.execute("insert into hero values ('Deadpond')")
try:
yield connection
finally:
connection.close()
events.append("close connection")


type ConnectionDep = Annotated[
sqlite3.Connection,
wired(get_connection),
]


app = FastAPI()


@app.get("/heroes")
def list_heroes(*, connection: FromWeb[ConnectionDep]) -> list[str]:
events.append("handle request")
return [name for (name,) in connection.execute("select name from hero")]


client = TestClient(app)

assert client.get("/heroes").json() == ["Deadpond"]
assert events == ["open connection", "handle request", "close connection"]

print("\n".join(events))


@contextmanager
def get_test_connection() -> Generator[sqlite3.Connection]:
connection = sqlite3.connect(":memory:")
connection.execute("create table hero (name text)")
connection.execute("insert into hero values ('Spider-Boy')")
try:
yield connection
finally:
connection.close()


with override_web_dependency(app, get_connection, get_test_connection):
assert client.get("/heroes").json() == ["Spider-Boy"]

assert client.get("/heroes").json() == ["Deadpond"]

print("override restored")
73 changes: 73 additions & 0 deletions src/wireme/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@

import contextlib
import inspect
import types
import typing
from collections.abc import (
AsyncGenerator,
AsyncIterator,
Awaitable,
Callable,
Generator,
Iterator,
Sequence,
)
from contextlib import AbstractAsyncContextManager, AbstractContextManager

from ._core import (
_build_call_model,
Expand All @@ -31,10 +34,17 @@ class _HasSignature(typing.Protocol):
__signature__: inspect.Signature


class _HasWrapped(typing.Protocol):
"""Represent a decorator helper exposing the callable it wraps."""

__wrapped__: Callable[..., object]


__all__ = (
"Wired",
"_HasSignature",
"_factory_model",
"_normalize_factory",
"_wire",
"override_dependency",
"wire",
Expand All @@ -44,15 +54,50 @@ class _HasSignature(typing.Protocol):
_MISSING = object()
_provider = _DiProvider()

# Both decorators return a helper closure, and every helper produced by one
# decorator shares that decorator's code object. The lambdas below are only
# wrapped, never called, so their bodies are irrelevant.
_CONTEXT_MANAGER_CODES: typing.Final[frozenset[types.CodeType]] = frozenset(
{
contextlib.contextmanager(
typing.cast("Callable[[], Generator[None]]", lambda: None)
).__code__,
contextlib.asynccontextmanager(
typing.cast("Callable[[], AsyncGenerator[None]]", lambda: None)
).__code__,
}
)


type _DependencyFactory[R] = (
Callable[..., Awaitable[R]]
| Callable[..., AsyncIterator[R]]
| Callable[..., Iterator[R]]
| Callable[..., AbstractContextManager[R]]
| Callable[..., AbstractAsyncContextManager[R]]
| Callable[..., R]
)


def _normalize_factory[F](factory: F, /) -> F:
"""Return the generator function behind a context manager decorator.

``@contextmanager`` and ``@asynccontextmanager`` return a helper that
builds a context manager object rather than yielding the dependency, so
FastDepends would inject the unentered manager. Both decorators produce
helpers sharing one code object, which identifies them precisely without
unwrapping unrelated ``functools.wraps`` decorators such as caches or
instrumentation.

Normalization is applied wherever a factory enters Wireme so declaration
and override sites agree on one identity for the same dependency.
"""
if getattr(factory, "__code__", None) in _CONTEXT_MANAGER_CODES:
return typing.cast("F", typing.cast("_HasWrapped", factory).__wrapped__)

return factory


def Wired() -> typing.Any:
"""Mark an annotated dependency as optional for static type checkers."""
return ...
Expand Down Expand Up @@ -532,6 +577,24 @@ def wired[**P, R](
) -> R: ...


@typing.overload
def wired[**P, R](
factory: Callable[P, AbstractAsyncContextManager[R]],
/,
*,
use_cache: bool = True,
) -> R: ...


@typing.overload
def wired[**P, R](
factory: Callable[P, AbstractContextManager[R]],
/,
*,
use_cache: bool = True,
) -> R: ...


@typing.overload
def wired[**P, R](
factory: Callable[P, R],
Expand All @@ -553,9 +616,14 @@ def wired(
PEP 695 aliases and postponed annotations in the factory's own
parameters work at any nesting depth.

Factories decorated with ``@contextmanager`` or ``@asynccontextmanager``
are resolved through the generator function they wrap, so they behave
exactly like the equivalent generator factory.

Raises:
TypeError: If the factory uses a FastDepends CustomField marker.
"""
factory = _normalize_factory(factory)
_resolve_factory_signature(factory, localns=_caller_locals())

return _Depends(
Expand All @@ -578,10 +646,15 @@ def override_dependency[R](
so use them for isolated tests and application setup, not concurrent
request-level mutation.

Either factory may be a context manager decorated with
``@contextmanager`` or ``@asynccontextmanager``.

Raises:
TypeError: If either factory uses a FastDepends CustomField marker.
"""
localns = _caller_locals()
original = _normalize_factory(original)
replacement = _normalize_factory(replacement)
_resolve_factory_signature(original, localns=localns)
_resolve_factory_signature(replacement, localns=localns)

Expand Down
Loading