Skip to content

docs: rework the web integrations pages - #665

Merged
lesnik512 merged 1 commit into
mainfrom
docs/rework-web
Oct 10, 2026
Merged

lesnik512 merged 1 commit into
mainfrom
docs/rework-web

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Reworks the five web framework pages (FastAPI, Starlette, Litestar, aiohttp, Flask) the same way the Quickstart, Introduction and Providers pages were done. Every behavioural claim was checked by running a script against modern-di at this branch and each integration's 4.0.0 source (fastapi 0.143.0, starlette 1.7.0, litestar 2.24.0, aiohttp 3.14.4, flask 3.1.3).

What was wrong

  • FastAPI websockets: the example built a fresh FastAPI() with no setup_di, so the first connection raised RuntimeError: No modern-di container found on the app.... Sybil passed it only because no request was ever sent.
  • Starlette class-based endpoints: the block rebound app = Starlette(...) without setup_di, so every request raised RuntimeError.
  • Litestar autowired_groups: list_users was decorated after the app was built and never registered (GET /users returned 404), and its body returned None for -> list[str].
  • Deployment warning (FastAPI, Starlette): it said the root container is "only opened from the lifespan" and then that it "is already open from construction". The root is open from construction, and the lifespan shutdown is the only thing that closes it. Checked: closed is False at construction, and a mounted sub-app or a no-lifespan run leaves it open with finalizers never run.
  • FastAPI API row: "appends a lifespan" became "wraps the lifespan". A user lifespan= stays the outer context and its yielded state reaches request.state (checked).
  • validate() comment on all five pages implied the order mattered for that example. It matters only when a factory takes the framework request. Validating such a factory before setup_di raises ValidationFailedError on every framework (checked); the comment became one sentence of prose.
  • aiohttp websocket example injected a connection parameter that was the same object as request (conn is request is True) and never used request_container.

What was added

  • FastAPI "Coming from Depends" gets a version note. Cleanup runs after the response from FastAPI 0.118.0. On 0.106.0 to 0.117.x it ran before the response, so a failing REQUEST finalizer gave a 500 (checked on 0.105.0: 200, 0.106.0, 0.110.0 and 0.117.1: 500, 0.118.0: 200). Depends(scope=...) exists from 0.121.0.
  • FastAPI: FromDI dependency caching and use_cache=False (two identical FromDI give one instance, use_cache=False gives another), and FromDI(Container) / build_di_container returning the same child.
  • Every page with websockets: one sentence on FromDI of a REQUEST-scoped provider in a websocket handler raising ScopeNotInitializedError (checked on all four).
  • Starlette: the provided Request/WebSocket is the middleware's own wrapper. It is a different object from the handler's but shares request.state.
  • Litestar: a di_container dependency section and API row; the lifespan-only root close; a warning not to name an autowired provider di_container (library bug below).
  • aiohttp: a new "Framework context objects" section. A factory taking web.Request by type cannot resolve in a websocket handler (ScopeNotInitializedError from the SESSION container, ContextValueNotSetError from a REQUEST child). It also shows the SESSION-scoped kwargs={"request": aiohttp_websocket_provider} wiring. Also covered: the consequence of upgrade headers on an HTTP-only handler (SESSION child, REQUEST provider fails, client sees 500), and add_subapp passing startup/cleanup to a sub-app (root closed, finalizer ran).
  • Flask: views must be sync (library bug below). auto_inject misses routes added after setup_di (TypeError: get_late_report() missing 1 required positional argument: 'report'). Finalizers run under close_sync(), so an async or failing finalizer raises FinalizerError out of teardown, which gives a 500 under the Werkzeug server. The root teardown example uses atexit.register(fetch_di_container(app).close_sync).
  • Hidden test-client checks after the examples. The integrations Sybil run now sends requests and websocket messages and asserts the responses, and it checks the root is closed after the lifespan.

Structure

All five pages now share one layout: intro, Installation, Usage, Injecting into handlers, Scopes and lifecycle, Websockets, Framework context objects, See also, API. FastAPI keeps "Coming from Depends" right after Usage, and Flask has no Websockets section. The old "How to use" H2 with numbered H3s is gone; nothing linked to those anchors. Class-based endpoints/views moved under "Injecting into handlers". The duplicated 12-line implicit/explicit context examples (with a fake timestamp) are merged into one group per page that shows both. Every group is named AppGroup. The "Requires 3.2.0 or later" notes are dropped, because every release that supports modern-di 4 is 4.0.0 or later.

Anchors kept

fastapi.md, litestar.md and starlette.md#framework-context-objects, aiohttp.md#api (all from providers/context.md), the page paths, the Litestar autowired_groups section (recipes/multi-group.md), the FastAPI lifespan-merge fact (recipes/async-lifespan.md), and a root-lifecycle statement on every page (providers/lifecycle.md).

Checks

  • just test-docs -q -p no:cacheprovider --no-cov: 152 passed, 21 skipped
  • just test-docs-integrations -q -p no:cacheprovider: 260 passed, 1 skipped (a NATS placeholder on another page)
  • just docs-build: no WARNING lines
  • just lint-ci: passed
  • lychee --offline over docs and README: 0 errors

Library issues found (not fixed here)

modern-di-litestar: an autowired provider named di_container breaks the app with RecursionError. It overwrites the plugin's container dependency, which every FromDI depends on.

import litestar
from litestar.di import NamedDependency
from modern_di import Container, Group, Scope, providers
from modern_di_litestar import ModernDIPlugin

class AppGroup(Group):
    di_container = providers.Factory(dict, scope=Scope.APP, bound_type=None)
    settings = providers.Factory(list, scope=Scope.APP)

@litestar.get("/")
async def index(settings: NamedDependency[list]) -> None: ...

litestar.Litestar(route_handlers=[index], plugins=[ModernDIPlugin(Container(groups=[AppGroup]), autowired_groups=[AppGroup])])
# UserWarning: Duplicate dependency name 'di_container' from group 'AppGroup'; overwriting.
# RecursionError: maximum recursion depth exceeded

The plugin should reserve the name, by raising or skipping it.

modern-di-flask: @inject on an async def view hands Flask a coroutine. The wrapper is a sync function, so Flask's ensure_sync never sees a coroutine function. auto_inject=True wraps async views the same way.

import typing
from flask import Flask
from modern_di import Container, Group, Scope, providers
from modern_di_flask import FromDI, inject, setup_di

class Dependencies(Group):
    greeting = providers.Factory(lambda: "hi", scope=Scope.REQUEST, bound_type=str)

app = Flask(__name__)

@app.route("/")
@inject
async def index(greeting: typing.Annotated[str, FromDI(str)]) -> str:
    return greeting

setup_di(app, Container(groups=[Dependencies]))
app.testing = True
app.test_client().get("/")
# TypeError: The view function did not return a valid response. ... but it was a coroutine.

modern-di-aiohttp stores the child container under a str request key. aiohttp 3.14 emits NotAppKeyWarning on the first such write ("It is recommended to use web.RequestKey instances for keys"). Under this repo's filterwarnings = ["error"] that fails the first request, so the hidden aiohttp checks ignore that warning class. Switching to web.RequestKey would fix it.

Out of this PR

  • docs/providers/context.md:253 links [aiohttp](../integrations/aiohttp.md#api). It can now point at #framework-context-objects like the other three. #api still works.
  • starlette.testclient warns when httpx2 is not installed (starlette 1.7). The hidden FastAPI/Starlette checks suppress it around the import. Adding httpx2 to docs_integration_extras in the justfile would remove the need.

Fix the examples that could not serve a request (FastAPI and Starlette websockets/endpoints without setup_di, the unregistered Litestar autowired route), add test-client checks to every page, document root-container lifecycle, websocket per-message scope, and the current Litestar di_container and Flask async-view limits.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark

Details
Benchmark suite Current: 7a40ffc Previous: 395e8fb Ratio
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type 3743340.410268023 iter/sec (stddev: 1.945965266562093e-8) 6139935.138982263 iter/sec (stddev: 1.8605923146541302e-8) 1.64
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry 3777787.0879038386 iter/sec (stddev: 2.129084541324819e-8) 6205832.315667191 iter/sec (stddev: 1.3805592208773412e-8) 1.64
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve 21650.433509002833 iter/sec (stddev: 0.000050224903225197576) 32024.886577255867 iter/sec (stddev: 0.000030238944431934) 1.48
benchmarks/test_guard_cold.py::test_g8b_cold_first_resolve_cached 17213.872956368352 iter/sec (stddev: 0.00017365589749788804) 24960.133332489408 iter/sec (stddev: 0.00011886986138363566) 1.45
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] 404.4407682868168 iter/sec (stddev: 0.00008288090366681325) 775.0388204206938 iter/sec (stddev: 0.00004473754687217169) 1.92
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] 388.23115413237906 iter/sec (stddev: 0.00007418928348295908) 764.2436132277375 iter/sec (stddev: 0.0001144571715558005) 1.97
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] 389.72389923006614 iter/sec (stddev: 0.00009724674879440644) 680.0345534095648 iter/sec (stddev: 0.0000803400475442327) 1.74
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] 12807.588923195353 iter/sec (stddev: 0.00000944576406838672) 10419.935559673875 iter/sec (stddev: 0.00001230746171334532) 0.81
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] 6256.8059057480905 iter/sec (stddev: 0.00002453665420051088) 5634.100392630293 iter/sec (stddev: 0.00003435893765767916) 0.90
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] 3353.087957878677 iter/sec (stddev: 0.00004019848984021132) 2968.9836953557647 iter/sec (stddev: 0.000061919224501018) 0.89
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[1] 9303.994530572103 iter/sec (stddev: 0.000010297066703511448) 12389.480670083722 iter/sec (stddev: 0.000019500905757513246) 1.33
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[2] 3969.031499435977 iter/sec (stddev: 0.000024729970659738838) 4843.535831204409 iter/sec (stddev: 0.00003981695650002605) 1.22
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[4] 1865.5908053284675 iter/sec (stddev: 0.000052658941734794635) 1868.0290700812566 iter/sec (stddev: 0.00007765838735564648) 1.00
benchmarks/test_guard_concurrency.py::test_g15c_worker_pool_floor_control[1] 36820.39899164147 iter/sec (stddev: 0.000007532203062333242) 24896.9783783031 iter/sec (stddev: 0.000017881784016521047) 0.68
benchmarks/test_guard_concurrency.py::test_g15c_worker_pool_floor_control[2] 14852.950222477191 iter/sec (stddev: 0.000014234321491713152) 11645.85197075752 iter/sec (stddev: 0.000015482573556939863) 0.78
benchmarks/test_guard_concurrency.py::test_g15c_worker_pool_floor_control[4] 5520.318544446068 iter/sec (stddev: 0.0000606538996928915) 5052.235696391264 iter/sec (stddev: 0.000048475453192008314) 0.92
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container 1573171.758111508 iter/sec (stddev: 3.187681883859335e-8) 2335541.2567186337 iter/sec (stddev: 3.860763352243666e-8) 1.48
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope 1677536.6850031943 iter/sec (stddev: 3.020559253939622e-8) 2505907.6773532387 iter/sec (stddev: 3.905850495221895e-8) 1.49
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch 2999.3841663281455 iter/sec (stddev: 0.000010003406613499351) 3317.2697581338502 iter/sec (stddev: 0.000052891745120707064) 1.11
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control 53276.610793483305 iter/sec (stddev: 0.0000024117576348446) 83100.58991551855 iter/sec (stddev: 0.0000014527703096612033) 1.56
benchmarks/test_guard_lifecycle.py::test_g7b_request_cycle_sync 376293.43105884764 iter/sec (stddev: 8.883184373131304e-8) 468518.9908558271 iter/sec (stddev: 8.515899938405845e-8) 1.25
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale 57026.50497532944 iter/sec (stddev: 0.0000017883777792193462) 78785.45603292529 iter/sec (stddev: 0.0000017671089125152717) 1.38
benchmarks/test_guard_lifecycle.py::test_g13b_teardown_at_scale_async_no_finalizers 516.0632813972103 iter/sec (stddev: 0.001598336720073683) 586.9668417253587 iter/sec (stddev: 0.0013708543619692236) 1.14
benchmarks/test_guard_resolve.py::test_g1_transient_resolve 2081404.240193595 iter/sec (stddev: 3.107292974359679e-8) 3091773.339921797 iter/sec (stddev: 3.1905368666696455e-8) 1.49
benchmarks/test_guard_resolve.py::test_g2_cached_resolve 3675588.3119108104 iter/sec (stddev: 2.1239145246793365e-8) 5585002.481125255 iter/sec (stddev: 1.7326975696337195e-8) 1.52
benchmarks/test_guard_resolve.py::test_g3_deep_chain 660777.1925859635 iter/sec (stddev: 5.367576369913599e-8) 1053634.7223852123 iter/sec (stddev: 4.376268176758002e-8) 1.59
benchmarks/test_guard_resolve.py::test_g4_wide_resolve 419183.5670648758 iter/sec (stddev: 4.159435992373088e-7) 602475.4027370019 iter/sec (stddev: 3.669391702377282e-7) 1.44
benchmarks/test_guard_resolve.py::test_g5_cross_scope 1990870.1680244368 iter/sec (stddev: 3.0860899831538185e-8) 2722743.1964451196 iter/sec (stddev: 2.2608711961763022e-8) 1.37
benchmarks/test_guard_resolve.py::test_g9_context_resolve 1372041.1760618126 iter/sec (stddev: 1.740983688619453e-7) 2095884.4060996436 iter/sec (stddev: 1.4539795764318154e-7) 1.53
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve 699092.7070003414 iter/sec (stddev: 6.297758575965574e-8) 1056924.0255533508 iter/sec (stddev: 4.539064934432262e-8) 1.51
benchmarks/test_guard_resolve.py::test_g18_alias_hop 3699818.893865855 iter/sec (stddev: 2.4412835385069577e-8) 5564689.374851157 iter/sec (stddev: 1.595292015837515e-8) 1.50
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain 29756.607318290702 iter/sec (stddev: 0.0002981952761153157) 23951.926949647954 iter/sec (stddev: 0.000031960404140896095) 0.80
benchmarks/test_guard_validate.py::test_g11_validate_wide 20555.335270865344 iter/sec (stddev: 0.00003015358771617824) 21724.253571453744 iter/sec (stddev: 0.00003130808206721572) 1.06

This comment was automatically generated by workflow using github-action-benchmark.

@lesnik512
lesnik512 merged commit 3efccfa into main Oct 10, 2026
12 checks passed
@lesnik512
lesnik512 deleted the docs/rework-web branch October 10, 2026 19:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant