feat(provider): accumulate a scope with extend=True - #4
Merged
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
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.
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 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
lazytarget it would bedataclasses.replacewith 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.
extendis the third name that cannot be prefill data, next tofrozenandkey, and that list is the running cost of the*argssignature.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,
frozenin 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 -kpasses: lint, mypy, pyright, 100 percent branch coverage, docs, workflow audit.pyproject.tomlwith a comment saying why.