Skip to content

fix!: match scopes by enum member, not by integer value (#572) - #596

Merged
lesnik512 merged 3 commits into
mainfrom
md-572
Oct 5, 2026
Merged

lesnik512 merged 3 commits into
mainfrom
md-572

Conversation

@lesnik512

@lesnik512 lesnik512 commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Closes #572.

Summary

IntEnum members of different enums compare and hash by value, so a provider at a custom Tenancy.TENANT = 2 resolved and cached in a Scope.SESSION container. Scopes now match by enum member. Mixing enums in one tree stays supported, as the custom scopes docs show with a MyScope.TENANT child under Scope.APP.

  • The generated Factory resolvers, the ContextProvider resolver and the unwireable-factory resolver compare container.scope is scope.
  • _scope_map still hashes by value, so a lookup for Tenancy.TENANT can return the Scope.SESSION ancestor. The resolver treats a hit whose target.scope is not scope as a miss and calls _navigate, which raises the same scope error a missing scope raises, with the provider's resolution step prepended.
  • find_container applies the same identity rules.
  • _stamp_group_scope compares group default scopes by identity. Before this, two groups giving a shared provider Scope.SESSION and Tenancy.TENANT raised no GroupScopeConflictError, and a registered Scope.APP provider could be restamped to another enum's 1 member without ProviderScopeFrozenError.
  • validate() reports a new ScopeEnumMismatchError (a RegistrationError) for an edge whose dependency's effective scope has the same value as the parent's but is a different member. Such an edge can never resolve: a chain holds one container per value, since each child's value is higher than its parent's. The error names the provider, the parameter and both members with their enum (Scope.SESSION and Tenancy.TENANT), and draws the chain like InvalidScopeDependencyError. It has a troubleshooting page and appears in the errors page and nav.
  • Migration entry in docs/migration/to-4.x.md, covering resolution, GroupScopeConflictError and ProviderScopeFrozenError, and a paragraph in the custom scopes section of docs/providers/scopes.md.

Design decisions

  • The identity check sits on the scope-map path only: one attribute load and one is on a hit. The same-scope path swaps == for is, which costs nothing extra. Frame counts are unchanged, and the ADR-0001 frame budget tests pass as they are.
  • A same-valued scope from another enum lands in find_container's existing branches. Resolving Tenancy.TENANT from a SESSION or REQUEST container raises ScopeSkippedError, and resolving it from APP raises ScopeNotInitializedError. Resolution needs no new error class.
  • validate() flags only what can never resolve. A dependency on a shallower scope from another enum can resolve: a Scope.REQUEST provider that depends on a ConflictingScope.LOWER_THAN_REQUEST (value 2) provider works in an APP → LOWER_THAN_REQUEST → REQUEST chain, so it is not reported. A test covers that case. A dependency on a deeper scope from another enum is still InvalidScopeDependencyError.
  • Audited and left as they are: Container.__init__ and build_child_container (value ordering, as documented), next_deeper (already keyed by (type, member)), cache placement (per container, so correct once navigation is), and _scope_map construction. The map cannot hold two members with one value because a chain strictly increases by value.

Timings, best of 7 runs of 2,000,000 resolve calls on a cached factory from a REQUEST container (macOS, CPython from uv sync):

case before after
same scope (REQUEST resolving REQUEST) 128.9 ns 121.1 to 123.7 ns
cross scope (REQUEST resolving APP) 148.1 to 150.7 ns 155.2 to 157.1 ns

The cross-scope path costs about 5 ns more because of the target.scope is not scope check. Keying _scope_map by id(member), with the id baked into the resolver globals, would remove that cost. I kept the is check because the map then still holds the scope members themselves, which keeps it readable in a debugger and in tests.

Test plan

  • New failing-first tests in tests/test_custom_scope.py, using the existing ConflictingScope enum: cached and transient factories, ContextProvider and an unwireable factory at ConflictingScope.LOWER_THAN_REQUEST (value 2) raise ScopeSkippedError from a SESSION container and from a REQUEST child, and nothing is cached in the SESSION container. find_container matches by member. The documented Scope.APP plus MyScope.TENANT tree validates and resolves.
  • New failing-first validate() tests in tests/test_custom_scope.py: a SESSION factory that depends on a ConflictingScope.LOWER_THAN_REQUEST provider raises ValidationFailedError holding ScopeEnumMismatchError, the shallower cross-enum dependency validates and resolves, and a deeper one stays InvalidScopeDependencyError
  • Rendering test for ScopeEnumMismatchError in tests/test_error_rendering.py
  • New failing-first tests in tests/test_group.py for GroupScopeConflictError and ProviderScopeFrozenError with same-valued scopes from different enums
  • just lint-ci
  • just test-ci (622 passed, 100% coverage)
  • mkdocs build --strict

@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: 686a29e Previous: ed03e40 Ratio
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type 7054698.605661299 iter/sec (stddev: 7.213454438084297e-9) 3932685.1717558345 iter/sec (stddev: 1.0184531548305192e-8) 0.56
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry 6923417.448730081 iter/sec (stddev: 7.1530960902748884e-9) 4786498.703950583 iter/sec (stddev: 1.0412697941283034e-8) 0.69
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve 39178.70858377368 iter/sec (stddev: 0.00003921208273706643) 28985.44641661876 iter/sec (stddev: 0.0000453175564873903) 0.74
benchmarks/test_guard_cold.py::test_g8b_cold_first_resolve_cached 30989.22174732888 iter/sec (stddev: 0.00010987501939553956) 21029.14082419947 iter/sec (stddev: 0.00016926120529396892) 0.68
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] 872.4664941281187 iter/sec (stddev: 0.000027813889414004962) 531.962246153924 iter/sec (stddev: 0.00013413737354511183) 0.61
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] 754.4004161061914 iter/sec (stddev: 0.0001892115976950648) 499.77362753546674 iter/sec (stddev: 0.0002147016661294616) 0.66
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] 696.8248070084479 iter/sec (stddev: 0.0002980283495634352) 457.0261637631503 iter/sec (stddev: 0.0002509693314331106) 0.66
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] 2740.1889191312657 iter/sec (stddev: 0.00022596012273320466) 2316.8389204233918 iter/sec (stddev: 0.0001557263350180912) 0.85
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] 2723.9148831102916 iter/sec (stddev: 0.00012714994930978303) 2049.202793515347 iter/sec (stddev: 0.00014613918907789515) 0.75
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] 1912.482657965369 iter/sec (stddev: 0.00013648446723592687) 1316.371910518061 iter/sec (stddev: 0.00028483705830564907) 0.69
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[1] 3542.303038957335 iter/sec (stddev: 0.00013613484913974874) 2474.432054431746 iter/sec (stddev: 0.00015135110164106287) 0.70
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[2] 1625.933597520766 iter/sec (stddev: 0.00221193789536882) 1324.597655947015 iter/sec (stddev: 0.0016613915230447756) 0.81
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[4] 1453.3688951704307 iter/sec (stddev: 0.00020949961130841652) 910.6558573838981 iter/sec (stddev: 0.00025942806537605225) 0.63
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container 1817075.1368272228 iter/sec (stddev: 2.616330992070155e-8) 1127811.0796892864 iter/sec (stddev: 6.557108275066174e-8) 0.62
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope 1519743.7316939465 iter/sec (stddev: 6.166647441354265e-8) 1078313.5747205522 iter/sec (stddev: 4.4463135476439786e-8) 0.71
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch 4765.68343709727 iter/sec (stddev: 0.000006283191163736443) 2681.4902139929595 iter/sec (stddev: 0.000042732136607835196) 0.56
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control 109902.6520514945 iter/sec (stddev: 9.00315376726702e-7) 63941.860824470226 iter/sec (stddev: 0.000003045965513923877) 0.58
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale 78358.09130605997 iter/sec (stddev: 0.000001119604165868517) 43369.8574924983 iter/sec (stddev: 0.000002413013093489788) 0.55
benchmarks/test_guard_lifecycle.py::test_g13b_teardown_at_scale_async_no_finalizers 1025.3127767409253 iter/sec (stddev: 0.00007092263331675094) 627.9987325389316 iter/sec (stddev: 0.0001604562217980534) 0.61
benchmarks/test_guard_resolve.py::test_g1_transient_resolve 4023424.7814191696 iter/sec (stddev: 2.4795205076209527e-8) 2688994.463292887 iter/sec (stddev: 2.6881764347600583e-8) 0.67
benchmarks/test_guard_resolve.py::test_g2_cached_resolve 6807590.136286161 iter/sec (stddev: 8.502370780335337e-9) 4204452.077497704 iter/sec (stddev: 1.7418687215314142e-8) 0.62
benchmarks/test_guard_resolve.py::test_g3_deep_chain 1420865.213849033 iter/sec (stddev: 5.813523037519652e-8) 844328.406516134 iter/sec (stddev: 7.768253348533782e-8) 0.59
benchmarks/test_guard_resolve.py::test_g4_wide_resolve 834655.6365261488 iter/sec (stddev: 3.7339773939960124e-7) 506813.6662808018 iter/sec (stddev: 4.860412600362958e-7) 0.61
benchmarks/test_guard_resolve.py::test_g5_cross_scope 3651274.778672039 iter/sec (stddev: 1.8408555961135055e-8) 2720897.23219926 iter/sec (stddev: 2.2993349409977034e-8) 0.75
benchmarks/test_guard_resolve.py::test_g9_context_resolve 2748422.3368610023 iter/sec (stddev: 1.4595624703137366e-7) 1861513.451392689 iter/sec (stddev: 1.6009051862784957e-7) 0.68
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve 1401082.0977313295 iter/sec (stddev: 5.345558909402178e-8) 977159.4833714707 iter/sec (stddev: 4.46171615729783e-8) 0.70
benchmarks/test_guard_resolve.py::test_g18_alias_hop 6928925.095741165 iter/sec (stddev: 7.904821324776575e-9) 4921947.994809434 iter/sec (stddev: 1.2287537114567184e-8) 0.71
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain 51584.953721396916 iter/sec (stddev: 0.000013602537921896537) 33446.1591201105 iter/sec (stddev: 0.000032851454320323225) 0.65
benchmarks/test_guard_validate.py::test_g11_validate_wide 30151.70225963155 iter/sec (stddev: 0.000016647612165370002) 19356.395088714948 iter/sec (stddev: 0.00002563330554109403) 0.64

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

@lesnik512
lesnik512 merged commit f653f91 into main Oct 5, 2026
9 checks passed
@lesnik512
lesnik512 deleted the md-572 branch October 5, 2026 13:23
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.

Scopes from different IntEnums compare equal by value

1 participant