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
36 changes: 11 additions & 25 deletions docs/adr/0001-caller-owns-the-root-container-lifecycle.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,13 @@
# The caller owns the root container's lifecycle

**Decision:** `setup_di` registers providers and hooks on the root container it is handed; it does
not open, validate, or close it. Constructing, validating, and closing the root container stay the
caller's, and we will not add an integration-owned lifecycle for it.

The recurring proposal is for `setup_di` to take that over the way the ASGI integrations do —
theirs wrap the root container in the framework's lifespan, entering it on startup and closing it
on shutdown, so a user never writes a teardown line.

Flask (WSGI) has no application startup or shutdown hook to attach the closing half to.
`before_request` and `teardown_appcontext` are per-request-ish, not per-process; in particular
`teardown_appcontext` fires on every app-context pop, including ones no request created, so it
cannot stand in for shutdown. An integration that took ownership here could therefore own only the
opening half: it would acquire `Scope.APP` resources it has no hook to finalize, and would hide
from the caller that finalization never happens — worse than the current split, which at least
tells the truth. Dishka's Flask integration reaches the same conclusion: it closes the per-request
child only, never the root.

The price is two lines the caller writes: `fetch_di_container(app).close_sync()` at whatever
process-shutdown point the application actually has, and — since modern-di 3.1 validates nowhere
implicitly — `container.validate()` if boot-time fail-fast is wanted, after `setup_di` rather than
before it, because `setup_di` is what registers `flask_request_provider`.

**Revisit trigger:** Flask grows an application-lifecycle hook — a startup/shutdown signal, or an
ASGI-style lifespan on the app object — that `setup_di` could attach a close to. Ownership becomes
symmetric at that moment and this should be reopened.
`setup_di` registers providers and hooks on the root container it is handed and never opens,
validates, or closes it. The ASGI integrations do take that over by wrapping the root container in
the framework's lifespan, but Flask (WSGI) has no application startup or shutdown hook to attach
the closing half to: `teardown_appcontext` fires on every app-context pop, including ones no
request created, so it cannot stand in for shutdown. An integration owning only the opening half
would acquire `Scope.APP` resources it has no hook to finalize and hide from the caller that
finalization never happens, which is worse than an honest split; Dishka's Flask integration closes
the per-request child only. The price is two caller-written lines:
`fetch_di_container(app).close_sync()` at the application's own shutdown point, and, since
modern-di validates nowhere implicitly, `container.validate()` after `setup_di`, which is what
registers `flask_request_provider`. Flask growing an application lifecycle hook would reopen this.
33 changes: 11 additions & 22 deletions docs/adr/0002-inject-does-not-rewrite-the-view-signature.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
# `inject` does not rewrite the view's signature

**Decision:** the wrapper `inject` returns keeps `*args, **kwargs` and `functools.wraps`; it does
not build a `__signature__` with the `FromDI`-annotated parameters stripped out.

The sibling adapters `modern-di-celery` and `modern-di-aiogram` do exactly that rewrite, so the
obvious move when porting a fix between integrations is to bring it here too. They need it because
their frameworks read the callable's signature to decide what to pass, and would otherwise try to
supply — or reject the call over — a parameter DI owns.

Flask never reads it. Its URL dispatcher calls `app.view_functions[endpoint](**url_args)` with the
arguments taken from the matched URL rule alone. A rewrite would change nothing about dispatch,
while adding a synthetic signature that has to be kept in step with the wrapped function and taking
the real one away from anything that introspects views.

What `functools.wraps` *is* load-bearing for is `__name__`, which Flask turns into the endpoint
name at `@app.route` time — an invariant, recorded as
`test_inject_preserves_the_view_name_flask_derives_endpoints_from` in `tests/test_inject.py`, not
as prose here.

**Revisit trigger:** something in the dispatch path starts reading view signatures — Flask itself
deriving arguments from them, or an extension within the supported `flask>=3,<4` range that wraps
`view_functions` and inspects what it wrapped. The injected parameters then become visible to a
caller that will try to fill them, and the rewrite earns its keep here too.
The wrapper `inject` returns keeps `*args, **kwargs` and `functools.wraps`; it does not build a
`__signature__` with the `FromDI`-annotated parameters stripped out. The sibling adapters
`modern-di-celery` and `modern-di-aiogram` do exactly that rewrite, so porting it here looks like
an obvious fix, but they need it because their frameworks read the callable's signature to decide
what to pass and would otherwise try to supply, or reject the call over, a parameter DI owns.
Flask never reads it: its URL dispatcher calls `app.view_functions[endpoint](**url_args)` with the
arguments taken from the matched URL rule alone, so a rewrite would change nothing about dispatch
while adding a synthetic signature to keep in step with the wrapped function and hiding the real
one from anything that introspects views. `functools.wraps` stays load-bearing for `__name__`,
which Flask turns into the endpoint name at `@app.route` time. Only something in the dispatch path
within the supported `flask>=3,<4` range reading view signatures would earn the rewrite.
Loading