Skip to content

docs: rework the recipes pages - #668

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

lesnik512 merged 1 commit into
mainfrom
docs/rework-recipes

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Reworks the seven recipe pages the same way as the Quickstart, Introduction and Providers pages. Every behavioural claim was checked by a script run against this checkout, with FastAPI, Litestar, SQLAlchemy 2.1.4, aiohttp and pydantic-ai-slim 2.55.0 installed. The transactional-test claims were checked against a local Postgres 17 container.

What was wrong

  • sqlalchemy.md said repositories are "one per request". The repository factory is uncached, so each resolve builds a new repository, and all of them share the request's cached session (checked with two resolves in one REQUEST child). The page also said expire_on_commit=True causes "session is closed" errors. On aiosqlite, reading an attribute after commit raises a StatementError wrapping sqlalchemy.exc.MissingGreenlet. The page never showed a handler, and nothing told the reader to call validate() after setup_di.
  • async-lifespan.md built a FastAPI app without setup_di. One pitfall said to drop the async with container the example used. Another said wrapping both "closes it twice". The second close actually runs no finalizers: an APP finalizer ran exactly once with both wrappers. A third pitfall described requests arriving after yield, which cannot happen because that code runs at shutdown.
  • testing-overrides.md resolved REQUEST-scoped providers (AsyncSession, PlaceOrder) from the root container, which raises ScopeNotInitializedError. Its fixtures were typed as their yield values. Pattern 3 used a di_container fixture the page never defined. integrations/pytest.md promised this page had a plugin-free worked example, and it had none.
  • request-scoped-engine.md used PrimaryEngine/ReplicaEngine subclasses as bound types, but the creators return plain AsyncEngine, so the annotations were false.
  • good-and-bad-practices.md had a garbled sentence in section 1. Section 6 said type-based resolution fails "silently". It actually warns at declaration, and resolving by type then raises ProviderNotRegisteredError. Section 5 referred to Dependencies.clock and fake_clock, which existed nowhere on the page.
  • pydantic-ai.md said to "open" the root container at startup, but a constructed container is already open. The second example's save_note body was ....

What changed

  • sqlalchemy.md: finalizers are now the unbound AsyncEngine.dispose / AsyncSession.close. Added a FromDI route and validate() after setup_di. Pitfalls are down to three: MissingGreenlet, async finalizers under close_sync() or a container nobody closes (Flask, gRPC, Typer, Celery), and the scope rule as a link. The multi-database variation now uses bound_type=None plus kwargs.
  • async-lifespan.md: the example uses setup_di, a FromDI route and validate(). The integration closes the container inside your lifespan, so APP finalizers still see the open session (checked with a finalizer that records session.closed). One short block covers the no-integration case. The two asyncpg paragraphs are merged into one.
  • multi-group.md: the example defines every class it uses and uses group-level scope=Scope.REQUEST. A second block shows that both repositories share the request's session. New pitfall: assigning one provider object to a second group raises DuplicateProviderTypeError. The Litestar section is shortened to the snippet plus a link.
  • testing-overrides.md: new sections "Override with a context manager", "Imperative override and reset", "Container fixtures without the plugin" (this fulfils the promise in pytest.md) and "Transactional database tests". The last builds a REQUEST child for the session, types fixtures as AsyncIterator, notes that pytest-asyncio needs asyncio_mode = "auto" for plain @pytest.fixture (strict mode failed), and recommends join_transaction_mode="create_savepoint". On Postgres 17, the default mode lost the test's earlier commit on session.rollback() and the teardown emitted SAWarning: transaction already deassociated from connection. With create_savepoint, commits and rollbacks inside a test worked and the next test saw an empty table. The modern-di-pytest pattern is replaced by links into pytest.md.
  • request-scoped-engine.md: bound_type=None on primary and replica, setup_di, validate() and a route. A hidden test checks that GET goes to the replica and POST to the primary. Added a pitfall: kwargs resolve both engines, so the first request creates both. validate() before setup_di reports ArgumentResolutionError, and an APP-scoped choice reports InvalidScopeDependencyError.
  • good-and-bad-practices.md: shortened sections 1 to 3 to links. Section 4 is retitled "Service location through an injected Container": the hidden dependency passes validate() and fails at resolve. Section 5 is now self-contained and uses with container.override(...). Section 6 is corrected. New section 7 covers an async finalizer under close_sync(), with a raises: FinalizerError block.
  • pydantic-ai.md: names the pydantic-ai-slim[openai] extra. The second example defines Session with an async finalizer and gives save_note a real body. Run with a FunctionModel issuing two concurrent tool calls, each call got its own Session and each was finalized.

Anchors kept

  • good-and-bad-practices.md#1-captive-dependency-a-wide-scoped-provider-holding-a-narrow-scoped-one (linked from scopes.md)
  • testing-overrides.md#pitfalls
  • All seven page URLs

Checks

  • just test-docs: 158 passed, 23 skipped
  • just test-docs-integrations: 255 passed, 1 skipped
  • just docs-build: no warnings
  • just lint-ci: clean
  • lychee offline: 0 errors
  • I ran the examples the docs tests can only define (pytest fixtures, the transactional fixtures on Postgres) as real pytest files in a scratch directory.

Library behaviour worth a look

Assigning one provider object to two groups, or listing one group twice, raises DuplicateProviderTypeError. Its hint, "Set bound_type=None on one of them", cannot be followed for a single object:

from modern_di import Container, Group, providers

class Engine: ...

class Database(Group):
    engine = providers.Factory(Engine, cache=True)

class Reuse(Group):
    engine = Database.engine

Container(groups=[Database, Reuse])  # DuplicateProviderTypeError listing "Factory (__main__:...)" twice

The same thing happens when modern_di_fastapi.setup_di is called twice on one container. The docs describe the current behaviour.

Needed outside this PR

  • providers/scopes.md says "Sharing the same provider instance with the same default scope across multiple groups is allowed." That holds when the classes are created, but Container(groups=[...]) with both groups raises DuplicateProviderTypeError.
  • troubleshooting/duplicate-type-error.md: the Fix example registers primary_repository and secondary_repository, both bound to Repository, so the example would itself raise DuplicateProviderTypeError in a container.
  • providers/lifecycle.md See also says async-lifespan.md shows "sync creator + async finalizer is the most common shape". That pattern is in sqlalchemy.md.
  • The mkdocs.yml nav label and the "Multi-Group organization" link text in celery.md, flask.md and group-instantiation-error.md do not match the H1 of multi-group.md.

Fix wrong claims (repository caching, MissingGreenlet, REQUEST-scoped resolves in tests), make every example define the names it uses, add a plugin-free pytest fixture example and a close_sync() footgun entry, and replace duplicated material with links.

@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: 1862d63 Previous: 395e8fb Ratio
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type 5106119.720224288 iter/sec (stddev: 6.296233292123535e-9) 6139935.138982263 iter/sec (stddev: 1.8605923146541302e-8) 1.20
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry 5131152.77813225 iter/sec (stddev: 5.853743537589257e-9) 6205832.315667191 iter/sec (stddev: 1.3805592208773412e-8) 1.21
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve 32853.52097388092 iter/sec (stddev: 0.00003595800534247451) 32024.886577255867 iter/sec (stddev: 0.000030238944431934) 0.97
benchmarks/test_guard_cold.py::test_g8b_cold_first_resolve_cached 26408.761566731722 iter/sec (stddev: 0.0000872307582729815) 24960.133332489408 iter/sec (stddev: 0.00011886986138363566) 0.95
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] 672.796407629877 iter/sec (stddev: 0.000010186054227702802) 775.0388204206938 iter/sec (stddev: 0.00004473754687217169) 1.15
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] 656.8745913210425 iter/sec (stddev: 0.000021451490487672628) 764.2436132277375 iter/sec (stddev: 0.0001144571715558005) 1.16
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] 602.9617038417476 iter/sec (stddev: 0.00013036483297890395) 680.0345534095648 iter/sec (stddev: 0.0000803400475442327) 1.13
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] 18178.141239615226 iter/sec (stddev: 0.0000068683166077245195) 10419.935559673875 iter/sec (stddev: 0.00001230746171334532) 0.57
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] 9008.07303501487 iter/sec (stddev: 0.00001845086365991487) 5634.100392630293 iter/sec (stddev: 0.00003435893765767916) 0.63
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] 4828.8970859345145 iter/sec (stddev: 0.00002614187195790505) 2968.9836953557647 iter/sec (stddev: 0.000061919224501018) 0.61
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[1] 11701.217599477875 iter/sec (stddev: 0.000012976400599150017) 12389.480670083722 iter/sec (stddev: 0.000019500905757513246) 1.06
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[2] 4992.131360953484 iter/sec (stddev: 0.000017798551032894882) 4843.535831204409 iter/sec (stddev: 0.00003981695650002605) 0.97
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[4] 2613.8710031125665 iter/sec (stddev: 0.0000303227064160822) 1868.0290700812566 iter/sec (stddev: 0.00007765838735564648) 0.71
benchmarks/test_guard_concurrency.py::test_g15c_worker_pool_floor_control[1] 47116.00943139982 iter/sec (stddev: 0.0000022436179366084376) 24896.9783783031 iter/sec (stddev: 0.000017881784016521047) 0.53
benchmarks/test_guard_concurrency.py::test_g15c_worker_pool_floor_control[2] 22334.145241504535 iter/sec (stddev: 0.00001048913178792468) 11645.85197075752 iter/sec (stddev: 0.000015482573556939863) 0.52
benchmarks/test_guard_concurrency.py::test_g15c_worker_pool_floor_control[4] 9296.029503110984 iter/sec (stddev: 0.00002518460475862088) 5052.235696391264 iter/sec (stddev: 0.000048475453192008314) 0.54
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container 1460151.2139914741 iter/sec (stddev: 6.395147792935572e-8) 2335541.2567186337 iter/sec (stddev: 3.860763352243666e-8) 1.60
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope 2065030.2816037873 iter/sec (stddev: 9.896972467487716e-8) 2505907.6773532387 iter/sec (stddev: 3.905850495221895e-8) 1.21
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch 4084.566832929413 iter/sec (stddev: 0.000006274625746576131) 3317.2697581338502 iter/sec (stddev: 0.000052891745120707064) 0.81
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control 75969.73130830468 iter/sec (stddev: 0.0000010307878458857364) 83100.58991551855 iter/sec (stddev: 0.0000014527703096612033) 1.09
benchmarks/test_guard_lifecycle.py::test_g7b_request_cycle_sync 529785.0871159357 iter/sec (stddev: 6.042547934333303e-8) 468518.9908558271 iter/sec (stddev: 8.515899938405845e-8) 0.88
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale 80609.93499638163 iter/sec (stddev: 9.819504660899682e-7) 78785.45603292529 iter/sec (stddev: 0.0000017671089125152717) 0.98
benchmarks/test_guard_lifecycle.py::test_g13b_teardown_at_scale_async_no_finalizers 746.3209639461878 iter/sec (stddev: 0.0008341959588484839) 586.9668417253587 iter/sec (stddev: 0.0013708543619692236) 0.79
benchmarks/test_guard_resolve.py::test_g1_transient_resolve 2903032.7547638807 iter/sec (stddev: 2.386215239817019e-8) 3091773.339921797 iter/sec (stddev: 3.1905368666696455e-8) 1.07
benchmarks/test_guard_resolve.py::test_g2_cached_resolve 4959172.3736368995 iter/sec (stddev: 7.873293603091878e-9) 5585002.481125255 iter/sec (stddev: 1.7326975696337195e-8) 1.13
benchmarks/test_guard_resolve.py::test_g3_deep_chain 990783.2634611722 iter/sec (stddev: 3.678123749372453e-8) 1053634.7223852123 iter/sec (stddev: 4.376268176758002e-8) 1.06
benchmarks/test_guard_resolve.py::test_g4_wide_resolve 592246.7380470355 iter/sec (stddev: 3.4297735603837854e-7) 602475.4027370019 iter/sec (stddev: 3.669391702377282e-7) 1.02
benchmarks/test_guard_resolve.py::test_g5_cross_scope 2586299.8138301414 iter/sec (stddev: 2.6619536755748323e-8) 2722743.1964451196 iter/sec (stddev: 2.2608711961763022e-8) 1.05
benchmarks/test_guard_resolve.py::test_g9_context_resolve 2022590.720278535 iter/sec (stddev: 1.30360407966388e-7) 2095884.4060996436 iter/sec (stddev: 1.4539795764318154e-7) 1.04
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve 1024537.047565218 iter/sec (stddev: 3.11438360703835e-8) 1056924.0255533508 iter/sec (stddev: 4.539064934432262e-8) 1.03
benchmarks/test_guard_resolve.py::test_g18_alias_hop 5015794.486054756 iter/sec (stddev: 7.453645023982295e-9) 5564689.374851157 iter/sec (stddev: 1.595292015837515e-8) 1.11
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain 44542.9252225048 iter/sec (stddev: 0.0001782201399104728) 23951.926949647954 iter/sec (stddev: 0.000031960404140896095) 0.54
benchmarks/test_guard_validate.py::test_g11_validate_wide 29102.106311425898 iter/sec (stddev: 0.00002195938065199246) 21724.253571453744 iter/sec (stddev: 0.00003130808206721572) 0.75

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

@lesnik512
lesnik512 merged commit 912afac into main Oct 10, 2026
12 checks passed
@lesnik512
lesnik512 deleted the docs/rework-recipes 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