Skip to content

feat(provider): accumulate a scope with extend=True - #4

Merged
paqstd-dev merged 1 commit into
mainfrom
feat/extend-provider
Aug 9, 2026
Merged

feat(provider): accumulate a scope with extend=True#4
paqstd-dev merged 1 commit into
mainfrom
feat/extend-provider

Conversation

@paqstd-dev

Copy link
Copy Markdown
Owner

Summary

A request scope is rarely known at the boundary.
Middleware knows the request id, authentication adds the actor, the view names the action, and a receiver several frames below wants all of them.
Same-key providers shadow by design, so today the layers have three bad options: a provider per layer under its own key, which makes the consumer depend on how many layers happened to run; the ambient namespace, which nothing unwinds; or writing into the enclosing Namespace, which mutates an object sibling tasks are holding.

provider(name, extend=True, **values) lays a layer over the namespace the same name already holds.
The enclosing attributes are copied on entry and the new values go over the copy, so the outer namespace is never written to and a sibling task cannot have a later layer appear underneath it.
Exit is the ordinary token reset, so each block restores exactly the layer that was open before it, and the accumulated scope is still one key, one registry entry and one O(1) lookup.

with provider("audit", request_id=rid):
    with provider("audit", extend=True, actor_id=user.pk):
        with provider("audit", extend=True, reason=payload["reason"]):
            document.save()      # use("audit") sees all three

    # here use("audit") sees request_id and actor_id again

With nothing open under that name it behaves as a plain provider, which is the property the feature exists for: a layer needs no branch for being the first one.

Decisions worth reviewing

The copy is taken at enter rather than at provider(), which a provider object entered twice around different enclosing layers can tell apart.
That makes the layer a snapshot in both directions, and it is the one surprising rule the feature has; within a layer nothing is copied and mutation is shared as it is for any provided value.

Where the merge has no meaning it is refused rather than improvised.
On an instance or lazy target it would be dataclasses.replace with extra steps, and over a name holding something else it would have to fall back to shadowing, which is the bug the feature exists to prevent, so both raise and name the fix.
Freezing is not inherited, since it describes what a provider hands its consumers rather than a property the value carries; an outer frozen layer is read through its view and produces a writable one.

extend is the third name that cannot be prefill data, next to frozen and key, and that list is the running cost of the *args signature.
No public name is added, so __all__ still holds sixteen.

Tests

Three layers accumulating with each exit restoring one, a sibling asyncio task and a sibling thread pinned to the middle layer, a thread that copied the context extending its own copy, snapshot semantics in both directions, a provider object reused around a changing enclosing layer, the merge staying one level deep, frozen in four combinations, @inject(from_="audit") over an accumulated namespace, and the error paths.

The isolation tests were checked against a deliberately naive implementation that mutates the enclosing namespace in place: eleven of them fail, including both sibling tests.

Cost

One row added to the table.
An extending layer copies the enclosing namespace on top of the registry, so it grows with how many attributes have accumulated rather than with how many layers are open, measured at 1697 ns over eight attributes at depth one and 1677 ns at depth eight, against 1395 ns over one attribute.
The read rows are unchanged.

The branch count in provider() went over the ruff limit, so the target parsing moved into _target_of() rather than taking an ignore.

Checklist

  • make -k passes: lint, mypy, pyright, 100 percent branch coverage, docs, workflow audit.
  • There are tests for the new or changed behaviour.
  • Documentation is updated, including the reference page if the public API changed.
  • Prose uses semantic line breaks, one sentence per line.
  • A new ruff ignore, if any, lives in pyproject.toml with a comment saying why.
  • The description says so if this change was largely written by an AI assistant.

A request scope is rarely known at the boundary. Middleware knows the
request id, authentication adds the actor, the view names the action,
and a receiver several frames below wants all of them. Same-key
providers shadow by design, so the layers had three bad options: a
provider per layer under its own key, which makes the consumer depend on
how many layers happened to run; the ambient namespace, which nothing
unwinds; or writing into the enclosing Namespace, which mutates an
object sibling tasks are holding.

provider(name, extend=True, **values) lays a layer over the namespace
the same name already holds. The enclosing attributes are copied on
entry and the new values go over the copy, so the outer namespace is
never written to and a sibling task cannot have a later layer appear
underneath it. Exit is the ordinary token reset, so each block restores
exactly the layer that was open before it, and the accumulated scope is
still one key, one registry entry and one O(1) lookup. With nothing open
under that name it behaves as a plain provider, which is the property
the feature exists for: a layer needs no branch for being the first one.

The copy is taken at enter rather than at provider(), which a provider
object entered twice around different enclosing layers can tell apart,
and which makes the layer a snapshot in both directions. Within a layer
nothing is copied and mutation is shared as it is for any provided
value.

Where the merge has no meaning it is refused rather than improvised. On
an instance or lazy target it would be dataclasses.replace with extra
steps, and over a name holding something else it would have to fall back
to shadowing, which is the bug the feature exists to prevent. Freezing
is not inherited, since it describes what a provider hands its consumers
rather than a property the value carries; an outer frozen layer is read
through its view and produces a writable one.

The branch count in provider() went over the ruff limit, so the target
parsing moved into _target_of() instead of taking an ignore.

Docs carry the concept in the providers topic, the Django audit trail as
a how-to that runs as pasted, the parameter and its rules in the
reference, and the argument for a parameter over a second function in
the design notes. The benchmark table gains a row: an extending layer
copies the enclosing namespace on top of the registry, so it grows with
how many attributes have accumulated rather than with how many layers
are open.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (e980781) to head (b86a1dc).

Additional details and impacted files
@@            Coverage Diff            @@
##              main        #4   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            8         8           
  Lines          642       663   +21     
  Branches        82        84    +2     
=========================================
+ Hits           642       663   +21     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@paqstd-dev
paqstd-dev merged commit c6557be into main Aug 9, 2026
10 checks passed
@paqstd-dev
paqstd-dev deleted the feat/extend-provider branch August 9, 2026 23:04
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.

2 participants