Skip to content

feat: carry the context out of the process, with export() and adopt() - #9

Open
paqstd-dev wants to merge 1 commit into
mainfrom
feat/portable-context
Open

feat: carry the context out of the process, with export() and adopt()#9
paqstd-dev wants to merge 1 commit into
mainfrom
feat/portable-context

Conversation

@paqstd-dev

Copy link
Copy Markdown
Owner

Summary

wrap and Executor carry the context into threads, and nothing carried it anywhere else.
The boundaries that matter in a real service are mostly elsewhere, a ProcessPoolExecutor, a Celery task, a message on a queue, an outgoing HTTP call, and every project hand-rolls the same four lines per boundary and gets them wrong differently each time.

with provider("trace", request_id=rid, tenant="acme"):
    payload = export("trace")          # {'v': 1, 'ctx': {'trace': {...}}}
    queue.enqueue(job, nodrill_ctx=payload)

with adopt(payload):                   # in the worker
    handle()                           # use("trace").request_id

Implements _backlog/planned/RFC-0006-portable-context.

The boundary names what travels, not the provider

The RFC proposed provider("trace", portable=True, ...), and that is not what this does.

A new provider() keyword has to pass the two tests in design.rst, and the second is that nothing at the call site can say the same thing.
export("trace", "audit") says it exactly, on the line where the developer is already thinking about serialisation.
What the flag was defended with was error attribution, the scalars-only check running at provider() time so a bad value is reported by the line that made it.
That does not survive the mutable case, since a Namespace stays writable while its block is open and ctx.tags.append(conn) is ordinary code, so an entry-time check can only say the value was portable once.

So provider() is untouched, keeps four reserved names, and no hot path changed, which is why the benchmark table is not part of this PR.

Rules

Nothing travels unless export() names it, so what leaves the process is one reviewable line rather than a mark spread across providers.

Values are checked as exactly str, int, float, bool, None, list or dict, all the way down.
Exactly, because a UUID, a StrEnum member or a tuple would arrive as a different type than the one provided, and a boundary that quietly changes a type is worse than one that refuses.
The message names the provider, the path to the value and its type.

The envelope is {"v": 1, "ctx": {...}}.
Two keys rather than the RFC's flat mapping, where a provider named v would collide with the version field, and both are frozen for every future version so any version can be read far enough to report its own number.

adopt() layers ordinary provider() calls through an ExitStack, so shadowing, unwinding on an exception, the debug() ledger and exception notes all come for free.
The namespaces are handed over by key, so a payload attribute named frozen or key stays data instead of becoming a parameter.

Class-keyed providers do not travel, since a class cannot be reopened on the other side without importing it.

An adopted payload is input, and the docs say so in those words.
adopt() checks that a payload is shaped like an envelope and cannot check that it is true.

Codecs

set_codec(dump=..., load=...) registers one process-wide pair that maps a namespace's values at each end, for what the JSON rule refuses.

A dump result is checked exactly as an unencoded value is, so a codec widens what you may provide and never what goes on the wire.
A pickle codec therefore renders itself as a string, which is the point rather than an obstacle, since the queue and header recipes need the envelope to survive json.dumps and the decision to run arbitrary code from a payload belongs where a reader can see it.
load runs after the payload has been checked, never before.

The how-to shows three, a tagged dataclass codec that runs on the standard library alone, an msgspec one that validates an incoming payload against the type it claims to be, and what pickle costs.

Public surface

Four names, 23 to 27.

export(*names)
adopt(payload)
set_codec(*, dump=None, load=None)
EnvelopeVersionError            # ValueError, carries version and supported

EnvelopeVersionError is a class of its own because the handling case is real, a consumer a release behind a producer wants to log and run without the context, and except ValueError around a with adopt(...) block would swallow whatever the body raises as well.

Not in this change

No Celery integration, no HTTP client, no OpenTelemetry bridge, so scope.rst still says what it said.
The adapters are how-to pages, and each program was extracted from its .rst and run before the page shipped.

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.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

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

Additional details and impacted files
@@            Coverage Diff             @@
##              main        #9    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           11        12     +1     
  Lines         1100      1214   +114     
  Branches       137       164    +27     
==========================================
+ Hits          1100      1214   +114     

☔ 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 self-assigned this Aug 12, 2026
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