Skip to content

fix: give inline providers in Factory kwargs the scope of the providers using them - #734

Merged
lesnik512 merged 3 commits into
mainfrom
fix/inline-provider-scope
Oct 19, 2026
Merged

lesnik512 merged 3 commits into
mainfrom
fix/inline-provider-scope

Conversation

@lesnik512

@lesnik512 lesnik512 commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Closes #732.

A provider written inline in a Factory's kwargs is not a class attribute, so no group stamped it and it always resolved at APP scope, whatever the scope of the provider using it. An explicit scope= on the parent did not reach it either.

When the container registers providers, each inline provider without its own scope= now takes the shallowest scope among the providers that use it, and that scope is frozen at that point.

Behaviour change

  • An inline Factory(..., cache=True) under a REQUEST parent used to be created once and shared by every request. It is now one instance per request. That sharing was the bug, but code that relied on it changes without an error. To keep an app-wide instance, set scope=Scope.APP on the inline provider.
  • An inline provider over a request-scoped source, such as Factory(lambda r: r.user, kwargs={"r": request}) in a REQUEST group, used to fail validate() with InvalidScopeDependencyError. It now validates and resolves.
  • An inline ContextProvider now reads the context of the parent's scope. Before, it looked in the APP container and raised ContextValueNotSetError.
  • A bare module-level provider without scope=, used inline, now takes its parents' scope. A library that means APP has to write scope=Scope.APP.
  • Until a container registers it, an inline provider's .scope still reads APP.

Design

  • Stamping happens in ProvidersRegistry._add, under the lock and after the duplicate-type check. It covers Container(groups=...) and container.add_providers(), and the resolve path does not change.
  • "Inline" means a value in a registered Factory's kwargs with _declared_as is None that is not itself in the batch. A class attribute of another group keeps its own scope wherever it is referenced.
  • The rule takes the shallowest scope because one inline object can sit under parents of different scopes. A module-level settings-field provider shared by an APP engine and a REQUEST session has to be APP. Copying each parent's scope made that pattern, which works on main, raise GroupScopeConflictError.
  • scope now checks, in order: explicit scope=, group default, inline scope, Scope.APP. Providers with _takes_group_scope = False (Alias, ContainerProvider) are never stamped.
  • Inline providers are marked registered when stamped. A group declared later that would change one raises ProviderScopeFrozenError, the same as for class attributes.

Differences from the issue

  • The issue suggested raising ProviderScopeFrozenError when a later add_providers() adds a shallower parent. Here the first registration fixes the scope, and the new parent fails validate() with InvalidScopeDependencyError. The existing check reports it, so no new exception class or troubleshooting page is needed.
  • Add provider.derive(fn) to inject a value computed from another provider #730 (provider.derive, merged as feat: add provider.derive(fn) to inject a value computed from another provider #733) landed first, so this PR adds the walk through _Derived. A derived provider has _takes_group_scope = False and is never stamped itself, but it passes its parents' scope down. In kwargs={"session": providers.Factory(DbSession, cache=True).derive(fn)}, the inline DbSession factory takes the scope of the provider that uses the derived one.

Tests and docs

  • tests/test_inline_scope.py covers the repro cases from the issue, the shared-parent pattern, nested inline providers, explicit inline scope, class attributes of another group, the freeze at first registration and the group re-stamp error, and an inline source behind an inline derive.
  • docs/providers/scopes.md has a new "Inline providers" section and an extra step in the priority list. docs/providers/factories.md links to it, and CONTEXT.md gains an "Inline provider" entry.

…rs using them

A provider written inline in a Factory's kwargs was never stamped and always
resolved at APP scope. An inline cache=True Factory under a REQUEST parent was
silently shared by every request, and inline providers over request-scoped
sources failed validate().

At registration, each inline provider without its own scope now takes the
shallowest scope among the providers that use it, and is frozen there.

Closes #732

@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: f4388f7 Previous: 53b159a Ratio
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type 6466805.306357793 iter/sec (stddev: 1.3121582456690594e-8) 3528043.340465543 iter/sec (stddev: 1.6201518205887247e-8) 0.55
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry 6621238.788767033 iter/sec (stddev: 1.090021254611569e-8) 3355243.408316017 iter/sec (stddev: 1.8164558621756206e-8) 0.51
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve 20255.543147894958 iter/sec (stddev: 0.000034777612357906735) 15985.466533123472 iter/sec (stddev: 0.00005295668444961533) 0.79
benchmarks/test_guard_cold.py::test_g8b_cold_first_resolve_cached 16520.339974807233 iter/sec (stddev: 0.00011371254902378454) 12554.9954318969 iter/sec (stddev: 0.00013370621165276512) 0.76
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] 824.3026484813439 iter/sec (stddev: 0.00009644152859828223) 473.79872455449373 iter/sec (stddev: 0.00014651882954410577) 0.57
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] 858.8399259443597 iter/sec (stddev: 0.000042017112523877864) 467.05031933576873 iter/sec (stddev: 0.000054803573838018285) 0.54
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] 772.2128132263979 iter/sec (stddev: 0.00005111972203707759) 434.21284255978355 iter/sec (stddev: 0.00007120359374410614) 0.56
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] 10954.606302438318 iter/sec (stddev: 0.000013141657363414782) 13420.107750030218 iter/sec (stddev: 0.000011020582165937664) 1.23
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] 8936.378791660734 iter/sec (stddev: 0.000009717501673477735) 6420.619605826631 iter/sec (stddev: 0.000022910120732793774) 0.72
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] 3008.0766104979375 iter/sec (stddev: 0.00007452439516799184) 3334.6056706576665 iter/sec (stddev: 0.000041783773314964245) 1.11
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[1] 16103.314572004729 iter/sec (stddev: 0.000016837219885167984) 10104.649646081618 iter/sec (stddev: 0.000010103075952638162) 0.63
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[2] 5944.2441784550665 iter/sec (stddev: 0.000016692793046936357) 4206.51536650179 iter/sec (stddev: 0.00003646404635374897) 0.71
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[4] 2097.003869024552 iter/sec (stddev: 0.00007946649561301732) 2004.621487785671 iter/sec (stddev: 0.00004199813555517138) 0.96
benchmarks/test_guard_concurrency.py::test_g15c_worker_pool_floor_control[1] 18828.94624121577 iter/sec (stddev: 0.0000030447117817926802) 40407.71383135906 iter/sec (stddev: 0.000005008385511484375) 2.15
benchmarks/test_guard_concurrency.py::test_g15c_worker_pool_floor_control[2] 13462.072629582195 iter/sec (stddev: 0.000010368811347900758) 13369.118005729773 iter/sec (stddev: 0.00001488410635264421) 0.99
benchmarks/test_guard_concurrency.py::test_g15c_worker_pool_floor_control[4] 5528.090924932034 iter/sec (stddev: 0.00004917509772077332) 5553.574780567612 iter/sec (stddev: 0.000035051702317751075) 1.00
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container 2565427.212579573 iter/sec (stddev: 3.529915917772928e-8) 1598547.7513419166 iter/sec (stddev: 3.169389972863768e-8) 0.62
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope 2627585.3633522335 iter/sec (stddev: 3.5873285997138694e-8) 1693646.884953151 iter/sec (stddev: 4.284472773928401e-8) 0.64
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch 4834.347210597371 iter/sec (stddev: 0.000010404173307677788) 3227.607832274615 iter/sec (stddev: 0.000010446514091967131) 0.67
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control 93912.97302121205 iter/sec (stddev: 0.0000011139490110642276) 58042.984227328125 iter/sec (stddev: 0.0000018588750309430483) 0.62
benchmarks/test_guard_lifecycle.py::test_g7b_request_cycle_sync 705034.329353943 iter/sec (stddev: 7.499719381263048e-8) 423107.39250427077 iter/sec (stddev: 7.008524311733466e-8) 0.60
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale 100305.79034639939 iter/sec (stddev: 0.0000010297399522293532) 63280.93450529563 iter/sec (stddev: 0.000002147406152624959) 0.63
benchmarks/test_guard_lifecycle.py::test_g13b_teardown_at_scale_async_no_finalizers 914.1726767279387 iter/sec (stddev: 0.0007995056740938146) 608.1669957544557 iter/sec (stddev: 0.0007670680174930931) 0.67
benchmarks/test_guard_resolve.py::test_g1_transient_resolve 3848836.5592788975 iter/sec (stddev: 2.9218306707786818e-8) 2203238.8713104604 iter/sec (stddev: 2.541051829664956e-8) 0.57
benchmarks/test_guard_resolve.py::test_g2_cached_resolve 6607718.767052108 iter/sec (stddev: 1.2853720116799686e-8) 3590759.037988996 iter/sec (stddev: 9.830744313484482e-9) 0.54
benchmarks/test_guard_resolve.py::test_g3_deep_chain 1349872.7390613668 iter/sec (stddev: 5.503561549712403e-8) 766487.7307444098 iter/sec (stddev: 1.9240825405249651e-7) 0.57
benchmarks/test_guard_resolve.py::test_g4_wide_resolve 803202.6581528357 iter/sec (stddev: 3.1577165671321265e-7) 467857.90463009063 iter/sec (stddev: 4.068391284538452e-7) 0.58
benchmarks/test_guard_resolve.py::test_g5_cross_scope 3365638.980029384 iter/sec (stddev: 2.0231344550955927e-8) 1989677.2559338638 iter/sec (stddev: 2.091452994252324e-8) 0.59
benchmarks/test_guard_resolve.py::test_g9_context_resolve 2472857.6058105812 iter/sec (stddev: 1.0744468996592735e-7) 1393524.4868091359 iter/sec (stddev: 1.3368616477698553e-7) 0.56
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve 1393061.9801836472 iter/sec (stddev: 2.9543775313354946e-8) 829711.1945406267 iter/sec (stddev: 7.339309737771445e-8) 0.60
benchmarks/test_guard_resolve.py::test_g18_alias_hop 6507185.103635749 iter/sec (stddev: 1.317585236791968e-8) 3431059.1621292867 iter/sec (stddev: 2.0227134209957854e-8) 0.53
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain 51211.62260735551 iter/sec (stddev: 0.000001726536954864777) 30402.232480488412 iter/sec (stddev: 0.00023546839534572183) 0.59
benchmarks/test_guard_validate.py::test_g11_validate_wide 31855.057280669324 iter/sec (stddev: 0.0000020868955471951034) 20553.440998470625 iter/sec (stddev: 0.000024398345850373547) 0.65

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

@lesnik512
lesnik512 merged commit e328cdb into main Oct 19, 2026
12 checks passed
@lesnik512
lesnik512 deleted the fix/inline-provider-scope branch October 19, 2026 06:26
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.

Inline providers in Factory kwargs always resolve at APP scope

1 participant