Skip to content

refactor(providers): derive scope from its declared source - #425

Merged
lesnik512 merged 4 commits into
mainfrom
refactor/provider-scope-provenance
Aug 22, 2026
Merged

refactor(providers): derive scope from its declared source#425
lesnik512 merged 4 commits into
mainfrom
refactor/provider-scope-provenance

Conversation

@lesnik512

@lesnik512 lesnik512 commented Aug 21, 2026

Copy link
Copy Markdown
Member

Why

AbstractProvider carried four fields to answer one question — what scope, and may a
group still change it?

field role
scope effective value
_scope_defaulted was it chosen explicitly
_stamping_group which group already stamped it
_registered may a resolver have captured it (the #407 freeze)

_scope_defaulted and _stamping_group are two encodings of the same fact: where the
current scope came from. _scope_defaulted exists only because the constructor collapses
UNSET → Scope.APP on its first line, erasing why a provider is APP-scoped, which is
exactly what the precedence rule needs (explicit scope= > group default > APP).

Separately, Alias and _ContainerProvider opted out of group stamping by passing a
concrete scope=Scope.APP in order to leave _scope_defaulted False. That is a side
effect standing in for an intent, and alias.py needed two comment lines to explain the
trick.

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 | None and is never
collapsed, so None alone suffices (dependency_source/factory.py).

self._explicit_scope: enum.IntEnum | None = scope if isinstance(scope, enum.IntEnum) else None
self._group_claim: tuple[enum.IntEnum, str] | None = None

@property
def scope(self) -> enum.IntEnum:
    if self._explicit_scope is not None:
        return self._explicit_scope
    if self._group_claim is not None:
        return self._group_claim[0]
    return Scope.APP

The property body is the precedence list from docs/providers/scopes.md, line for
line. _group_claim carries the stamping group's name because that is the only thing
GroupScopeConflictError needs it for. Each field answers exactly one question, and
scope cannot drift from its provenance because it is computed from it. Same slot count
as before; the _ExplicitScope sentinel class introduced in the first commit is deleted.

_takes_group_scope: ClassVar[bool] is the second half: Alias and _ContainerProvider
set it False and say so, where the old scope=Scope.APP was a placeholder doing double
duty as the opt-out.

The compiled context resolver

Making scope a property costs ~11ns per read, and _compile_context_provider was the
one branch that read it per resolve — it delegated to ContextProvider.resolve, which
called fetch_context_value, which read self.scope. That path is hit by the marker
injectors 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 the
path ends up faster than it started: 194ns → 162ns.

ContextProvider.resolve is removed with it. Its only caller was this branch, and the
polymorphic 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

  • Not moving effective scope off the provider and into registration data. That would
    delete _registered, ProviderScopeFrozenError, and the freeze suite outright, but
    Group.svc.scope reflecting the group default before any container exists is documented
    (docs/providers/scopes.md) and asserted, and provider.scope is read in suggester.py,
    dependency_graph.py, integrations.py, and exceptions.py where no registry is in hand.
  • Not moving the Scope.APP default from the provider to the Group (i.e. starting
    Group._default_scope at Scope.APP so every group stamps). It does not pay: a provider
    registered via add_providers with no group at all is supported and tested, so the
    fallback 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, turning
    test_group_may_restamp_provider_that_was_never_registered into a
    GroupScopeConflictError.
  • Not adopting dishka's stricter rule that an unset scope is an error
    (NoScopeSetInProvideError) rather than defaulting to APP. That would collapse the
    design to a single scope: IntEnum | None field, but it breaks
    add_providers(Factory(creator=Inner)) and is an ergonomics decision for a major
    version, not a refactor.
  • Not adopting dishka's copy-on-bind. There, a Provider is instantiated and each
    declaration's __get__ returns a fresh copy with the group scope filled in, so nothing is
    mutated 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 object Group.svc names.
  • Not changing the precedence rule, the freeze, or any error message. No docs change:
    every user-facing statement in docs/providers/scopes.md still holds verbatim.
  • _takes_group_scope stays 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.
  • Guard bench: flat. All 25 scenarios within noise across before/after
    (g7c_event_loop_floor_control, the control, moved -2.9%).
  • Direct ContextProvider resolve (no guard scenario covers it, so measured separately,
    min-of-11 × 3 processes): 194.3ns on main → 207.8ns with the property alone → 161.6ns
    with the inlined resolver. Bare provider.scope read: 6.9ns → 17.7ns.
  • Tests added:
    • test_direct_context_resolve_reads_the_scope_only_at_compile_time (INVARIANT) — pins the
      compile-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_raising and
      test_fetch_context_value_hops_to_the_provider_scope_reopening_a_closed_owner — the
      public accessor's contract, previously covered only incidentally through resolve.
    • From the first commit, two invariant tests in tests/test_group.py, both written and
      confirmed passing before the refactor:
      test_group_scope_alias_still_resolves_from_the_source_container and
      test_group_scope_does_not_stamp_the_container_provider.

…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.
@lesnik512 lesnik512 changed the title refactor(providers): single scope-source field; explicit group-stamp opt-out refactor(providers): derive scope from its declared source Aug 22, 2026
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
lesnik512 force-pushed the refactor/provider-scope-provenance branch from e4cff54 to 0a85329 Compare August 22, 2026 08:25
@lesnik512
lesnik512 merged commit 99c1b16 into main Aug 22, 2026
9 checks passed
@lesnik512
lesnik512 deleted the refactor/provider-scope-provenance branch August 22, 2026 08:28
lesnik512 added a commit that referenced this pull request Aug 22, 2026
Covers everything unreleased since the 3.3.0 tag: #425 (scope derived from its
declared source, plus the inlined compiled context resolver), #424 (docs/architecture
removal), #423 (comparative table republished at 3.3.0).

Versioned 3.4.0. #425 removes `ContextProvider.resolve` and makes `provider.scope`
read-only; both are removals of machinery that was never a designed extension point,
following the 2.29.0 precedent of shipping that class of break as a minor.
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