Skip to content

refactor: compose resolver templates and build resolution steps on the provider (#574) - #593

Merged
lesnik512 merged 2 commits into
mainfrom
md-574
Oct 5, 2026
Merged

lesnik512 merged 2 commits into
mainfrom
md-574

Conversation

@lesnik512

@lesnik512 lesnik512 commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Closes #574.

Summary

Internal cleanup with no behaviour change. The generated resolver source is byte-identical for every shape: I dumped _source and the line-to-argument map for arity 0 to 3, positional and keyword (including a non-identifier name), with and without static kwargs, cached and transient, before and after the change, and the two dumps match.

Each bullet of the issue, checked against current main (634b5d5):

  • Template duplication. _TRANSIENT and _CACHED are now concatenated from three module-level fragments: _NAVIGATE (scope lookup and closed check), _BUILD_ARGUMENTS (the argument try with both except clauses) and _CALL_CREATOR (the creator call and its handlers). Composition happens once at import, so the hot path is unchanged.
  • arg_lines by suffix matching. _source now returns (source, arg_lines). It knows which emitted build line holds each r{i}(target) call and offsets that by the template line of {build}. _code no longer scans the rendered lines.
  • _navigate(scope: typing.Any). Now annotated enum.IntEnum.
  • Module docstring cites performance.md. It now points at ADR-0001.
  • Three provider-to-ResolutionStep builders. There is one now: AbstractProvider._resolution_step(scope=None), which defaults to the provider's own scope. Factory._resolution_step is gone (the base method covers it), and rendering.py lost provider_step and redirect_steps, so it only draws. The callers are redirect_hops and build_cycle_error in dependency_graph.py, InvalidScopeDependencyError._render_body, and the alias and context-provider resolvers in the compiler.
  • Underscored helpers imported across modules. render_chain, render_suggestion_lines and render_suggestions are renamed in place in exceptions/rendering.py. deeper_members and next_deeper move out of the public modern_di.scope into a new private module, modern_di/_scope_algebra.py, which, like scope.py, imports only enum. The import-only-enum invariant test now covers both modules. Tests and one benchmark comment are updated to match. _scope_detail and _next_deeper_memo stay private because only their own module uses them.
  • CircularDependencyError.prepend_step docstring. It no longer mentions the interpreted and compiled paths. It says the cycle already names every node, so an outer frame has nothing to add.

Nothing in the issue had already been done by #583 to #592. Those PRs only moved line numbers and renamed the provider internals the issue refers to.

Design decisions

  • The step builder takes an optional scope because two callers need a scope other than the provider's own: redirect hops are drawn at their terminal's scope, and cycle steps at the effective scope. The compiled factory resolver still receives the bound method and calls it with no arguments, so CreatorCallError.from_type_error and _navigate keep their Callable[[], ResolutionStep] contract.
  • The context-provider resolver used to bind functools.partial(provider_step, cp, scope) at compile time. It now passes cp._resolution_step, which reads scope when the error is built. A registered provider's scope is frozen, so the result is the same.
  • The scope helpers live in a private module, so their names carry no underscore and no module imports an underscored name from another one. modern_di.scope exposes only Scope again.
  • InvalidScopeDependencyError still draws its redirect hops itself instead of calling dependency_graph.redirect_hops. exceptions cannot import dependency_graph without a cycle, so the rule that hops are drawn at the effective scope is written in both places.

Perf

Best of 7 runs of 1M resolve_provider calls from a REQUEST container, Python from uv run:

case before after
transient Factory, 2 cached APP args 326 ns 328 to 331 ns
cached REQUEST Factory, 2 args, cache hit 135 ns 137 ns

The generated code is identical, so this difference is run-to-run noise.

Test plan

  • just lint and just lint-ci clean
  • just test-ci: 616 passed, 100% line coverage, including test_resolve_costs_exactly_one_resolver_frame_per_node, test_alias_hop_costs_no_resolver_frame and test_every_resolver_shape_compiles_and_resolves
  • Generated source and arg_lines byte-identical before and after for every shape
  • just adr-check passes
  • mkdocs build --strict passes

@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: 3e7a791 Previous: 634b5d5 Ratio
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type 4981292.010641465 iter/sec (stddev: 9.487427844623191e-9) 5067365.557824091 iter/sec (stddev: 5.37767770073532e-9) 1.02
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry 5000452.040863807 iter/sec (stddev: 9.343735342179386e-9) 5043963.437488808 iter/sec (stddev: 7.963377171912855e-9) 1.01
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve 29805.53521449727 iter/sec (stddev: 0.000040084172147560215) 29133.821939481844 iter/sec (stddev: 0.000042652352964517597) 0.98
benchmarks/test_guard_cold.py::test_g8b_cold_first_resolve_cached 24411.353694888472 iter/sec (stddev: 0.00009057725546289804) 23840.930197388618 iter/sec (stddev: 0.00011952106627701537) 0.98
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] 621.8270278136808 iter/sec (stddev: 0.00005337979229519717) 644.2806973147967 iter/sec (stddev: 0.0000846566876096164) 1.04
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] 590.8287114448974 iter/sec (stddev: 0.000019976937052412193) 604.7983741696503 iter/sec (stddev: 0.00004472488954112076) 1.02
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] 532.5946580879837 iter/sec (stddev: 0.0000332363272962729) 546.54110039303 iter/sec (stddev: 0.00003286458174956396) 1.03
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] 2760.895223034518 iter/sec (stddev: 0.00012265734892447116) 2731.1064559265274 iter/sec (stddev: 0.00012718492159027194) 0.99
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] 2130.6096377266267 iter/sec (stddev: 0.00012692101576091707) 2149.5744093415256 iter/sec (stddev: 0.00012977853814105617) 1.01
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] 1458.6335003554445 iter/sec (stddev: 0.00013398828158956642) 1451.301452763851 iter/sec (stddev: 0.0001383443306096785) 0.99
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[1] 2755.205438331725 iter/sec (stddev: 0.00012765086047143004) 2738.208884638193 iter/sec (stddev: 0.00013448064777326195) 0.99
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[2] 1549.0911037121668 iter/sec (stddev: 0.001159125077254878) 1587.9677565870695 iter/sec (stddev: 0.0010877411031153322) 1.03
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[4] 1165.1157244881051 iter/sec (stddev: 0.0001506791128619846) 1174.8788002463164 iter/sec (stddev: 0.0001331872057788412) 1.01
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container 1300252.3139636046 iter/sec (stddev: 2.429005056612874e-8) 1216487.0736319197 iter/sec (stddev: 3.868375187039108e-8) 0.94
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope 1201379.7846842627 iter/sec (stddev: 1.9593283518912474e-8) 1167505.7238495366 iter/sec (stddev: 2.571346495064066e-8) 0.97
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch 3395.4306137881895 iter/sec (stddev: 0.000009881458759857972) 3209.149000072793 iter/sec (stddev: 0.0000057248097968980084) 0.95
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control 76690.1858587268 iter/sec (stddev: 8.819212568534428e-7) 72204.87359413861 iter/sec (stddev: 9.83895866943126e-7) 0.94
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale 59904.103836629845 iter/sec (stddev: 0.0000011106143068814483) 56546.36149726006 iter/sec (stddev: 0.000001241191563541163) 0.94
benchmarks/test_guard_lifecycle.py::test_g13b_teardown_at_scale_async_no_finalizers 793.1009913862559 iter/sec (stddev: 0.000018028956401860107) 737.6555880965986 iter/sec (stddev: 0.000013137261314336917) 0.93
benchmarks/test_guard_resolve.py::test_g1_transient_resolve 2913519.4586803494 iter/sec (stddev: 2.5197674338043362e-8) 2985272.2341429708 iter/sec (stddev: 1.923460761314064e-8) 1.02
benchmarks/test_guard_resolve.py::test_g2_cached_resolve 4827161.316815463 iter/sec (stddev: 1.2249410075070706e-8) 4923011.727339974 iter/sec (stddev: 1.413513816374172e-8) 1.02
benchmarks/test_guard_resolve.py::test_g3_deep_chain 1047346.8322012024 iter/sec (stddev: 2.9435513104908763e-8) 1038111.7556904309 iter/sec (stddev: 6.860766139525715e-8) 0.99
benchmarks/test_guard_resolve.py::test_g4_wide_resolve 616418.9474113911 iter/sec (stddev: 3.737441898263821e-7) 622698.1961204013 iter/sec (stddev: 4.1214097716581753e-7) 1.01
benchmarks/test_guard_resolve.py::test_g5_cross_scope 2746469.550720356 iter/sec (stddev: 2.1521869645192017e-8) 2823383.260179447 iter/sec (stddev: 1.7493167123526233e-8) 1.03
benchmarks/test_guard_resolve.py::test_g9_context_resolve 2064749.0860197744 iter/sec (stddev: 1.3770991094387001e-7) 1985004.1866217137 iter/sec (stddev: 1.3030298794803647e-7) 0.96
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve 1023456.1304999011 iter/sec (stddev: 4.846441082000878e-8) 1035928.6470580541 iter/sec (stddev: 2.5029174039763364e-8) 1.01
benchmarks/test_guard_resolve.py::test_g18_alias_hop 4134785.403819091 iter/sec (stddev: 4.0110506357322505e-8) 4939495.868006965 iter/sec (stddev: 8.04150562674476e-9) 1.19
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain 38828.88460007932 iter/sec (stddev: 0.000016732231345892298) 38243.11779742017 iter/sec (stddev: 0.000017162106924872293) 0.98
benchmarks/test_guard_validate.py::test_g11_validate_wide 22011.215697566455 iter/sec (stddev: 0.000021912780115256454) 21418.523692909832 iter/sec (stddev: 0.000024010257486729216) 0.97

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

@lesnik512
lesnik512 merged commit d989384 into main Oct 5, 2026
9 checks passed
@lesnik512
lesnik512 deleted the md-574 branch October 5, 2026 09:50
lesnik512 added a commit that referenced this pull request Oct 5, 2026
Republish the comparative tables from just bench-report (5 runs) at f300c2e and tie the 4.0 notes to #557, #559, #561, #585, #593, #596 and #597.
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.

Refactor: resolver compiler templates and resolution-step building

1 participant