refactor(providers): derive scope from its declared source - #425
Merged
Conversation
…opt-out Replace _scope_defaulted + _stamping_group with one _scope_source field, and give Alias and the container provider a declarative _takes_group_scope = False instead of relying on a placeholder explicit scope to dodge stamping.
Matches the casing every other tool-config file in the repo uses, and the `just` default. CLAUDE.md's link to it follows; leaving it pointing at the old casing is what `planning/links.py` reports as broken.
`scope` was collapsed to `Scope.APP` in `__init__`, which erased the one fact the group-default precedence rule needs: whether a scope was actually chosen. `_scope_defaulted`, and then the `_EXPLICIT_SCOPE` sentinel that replaced it, existed only to carry that erased bit back. Keep the sources instead and derive the answer. `_explicit_scope` holds what `scope=` gave (None when omitted); `_group_claim` holds `(scope, group name)` once a Group stamps it. `scope` becomes a property whose body is the documented precedence list line for line: explicit, else group, else APP. Each field now answers one question, and `scope` cannot drift from its provenance because it is derived from it. Same slot count; no behaviour change (511 tests unchanged).
`_compile_context_provider` delegated to `ContextProvider.resolve`, which read `self.scope` on every resolve. That was free while `scope` was a slot; as a derived property it costs ~11ns on a path the marker injectors hit once per marker per request. Inline the lookup into the compiled closure, as the folded context kwargs already do, with the scope read once at compile time -- the module docstring already asserts a registered ContextProvider's scope is fixed. This also drops the two delegated frames, so the path lands below where it started: a direct context resolve goes 194ns -> 162ns (min-of-11, three processes). `ContextProvider.resolve` goes with it: its only caller was this branch, and the polymorphic `provider.resolve(self)` dispatch it belonged to was retired in 2.29.0. `fetch_context_value`, public since 2.18.0, stays and gains the direct tests it never had.
lesnik512
force-pushed
the
refactor/provider-scope-provenance
branch
from
August 22, 2026 08:25
e4cff54 to
0a85329
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
AbstractProvidercarried four fields to answer one question — what scope, and may agroup still change it?
scope_scope_defaulted_stamping_group_registered_scope_defaultedand_stamping_groupare two encodings of the same fact: where thecurrent scope came from.
_scope_defaultedexists only because the constructor collapsesUNSET → Scope.APPon its first line, erasing why a provider is APP-scoped, which isexactly what the precedence rule needs (explicit
scope=> group default > APP).Separately,
Aliasand_ContainerProvideropted out of group stamping by passing aconcrete
scope=Scope.APPin order to leave_scope_defaultedFalse. That is a sideeffect standing in for an intent, and
alias.pyneeded two comment lines to explain thetrick.
Design
Stop erasing the sources, and derive the answer from them. This is dishka's shape adapted
to our identity model: there, a factory's scope is
BaseScope | Noneand is nevercollapsed, so
Nonealone suffices (dependency_source/factory.py).The property body is the precedence list from
docs/providers/scopes.md, line forline.
_group_claimcarries the stamping group's name because that is the only thingGroupScopeConflictErrorneeds it for. Each field answers exactly one question, andscopecannot drift from its provenance because it is computed from it. Same slot countas before; the
_ExplicitScopesentinel class introduced in the first commit is deleted._takes_group_scope: ClassVar[bool]is the second half:Aliasand_ContainerProviderset it False and say so, where the old
scope=Scope.APPwas a placeholder doing doubleduty as the opt-out.
The compiled context resolver
Making
scopea property costs ~11ns per read, and_compile_context_providerwas theone branch that read it per resolve — it delegated to
ContextProvider.resolve, whichcalled
fetch_context_value, which readself.scope. That path is hit by the markerinjectors once per marker per request.
So it now inlines the lookup and captures the scope at compile time, exactly as the folded
context kwargs already do; the module docstring already asserts a registered
ContextProvider's scope is fixed. Inlining also drops the two delegated frames, so thepath ends up faster than it started: 194ns → 162ns.
ContextProvider.resolveis removed with it. Its only caller was this branch, and thepolymorphic
provider.resolve(self)dispatch it belonged to was retired in 2.29.0(
planning/decisions/2026-07-17-custom-providers-retracted.md).fetch_context_value,public since 2.18.0, stays and gains the direct tests it never had.
Non-goals
delete
_registered,ProviderScopeFrozenError, and the freeze suite outright, butGroup.svc.scopereflecting the group default before any container exists is documented(
docs/providers/scopes.md) and asserted, andprovider.scopeis read insuggester.py,dependency_graph.py,integrations.py, andexceptions.pywhere no registry is in hand.Scope.APPdefault from the provider to theGroup(i.e. startingGroup._default_scopeatScope.APPso every group stamps). It does not pay: a providerregistered via
add_providerswith no group at all is supported and tested, so thefallback stays on the provider either way; no field disappears (explicit still beats
group, and the conflict error still needs the first group's name); and it would break the
documented behaviour that a group without
scope=stamps nothing, turningtest_group_may_restamp_provider_that_was_never_registeredinto aGroupScopeConflictError.(
NoScopeSetInProvideError) rather than defaulting to APP. That would collapse thedesign to a single
scope: IntEnum | Nonefield, but it breaksadd_providers(Factory(creator=Inner))and is an ergonomics decision for a majorversion, not a refactor.
Provideris instantiated and eachdeclaration's
__get__returns a fresh copy with the group scope filled in, so nothing ismutated and the conflict/freeze errors never need to exist. Here every registry, memo and
override is keyed on
provider_id, so a copy would not be the objectGroup.svcnames.every user-facing statement in
docs/providers/scopes.mdstill holds verbatim._takes_group_scopestays private. It is not a provider-extension seam.Verification
just test-ci— 514 passed, 100.00% line coverage (the gate).just lint-ci— ruff + ty + planning bundle checks all clean.(
g7c_event_loop_floor_control, the control, moved -2.9%).ContextProviderresolve (no guard scenario covers it, so measured separately,min-of-11 × 3 processes): 194.3ns on
main→ 207.8ns with the property alone → 161.6nswith the inlined resolver. Bare
provider.scoperead: 6.9ns → 17.7ns.test_direct_context_resolve_reads_the_scope_only_at_compile_time(INVARIANT) — pins thecompile-time capture with a counting property, and carries a positive control so it
cannot pass vacuously. Verified to fail when the closure is made to re-read
cp.scope.test_fetch_context_value_reports_an_absent_value_instead_of_raisingandtest_fetch_context_value_hops_to_the_provider_scope_reopening_a_closed_owner— thepublic accessor's contract, previously covered only incidentally through
resolve.tests/test_group.py, both written andconfirmed passing before the refactor:
test_group_scope_alias_still_resolves_from_the_source_containerandtest_group_scope_does_not_stamp_the_container_provider.