Streaming as a decorator, first half. Each invocation of reach gets an id and every event it emits is tagged with it, which is what will let a detached run's log be read back and what correlates the lines of a run that streamed for nine minutes. No command signature changed and no command imports core.jobs — that is the point, per the D-263 amendment: a command must not know jobs exist, because the alternative is call-site discipline wearing a different hat. A ContextVar rather than a module global. A global is correct only until something runs two invocations in one process — which a test harness or a future batch verb does immediately, and which would then interleave two jobs' events under one id with nothing reporting an error. The job context is the OUTERMOST wrapper, and it has to be. @logged emits from its finally and @handle_errors emits its verdict while unwinding, so a context established inside either would already be reset by the time the two most important events are written — leaving them the only untagged lines in the log, and they are precisely the ones a detached run gets read back for. Fixed in passing: the job id used local time while every event's ts is UTC, so an id read 155327 beside its own first log line reading 13:53:27. Two hours apart reads as a logging bug every time someone correlates them by eye. New conformance invariant — nothing outside core/ may import core.jobs. My first version of it inspected only the module path, so it missed `from tooling.core import jobs`, where the name is in the import LIST and which is the form anyone would actually write. It passed while checking nothing. Rewritten to catch all three reachable forms and then verified by committing a real violation, which it named by file and line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
67 lines
2.5 KiB
Python
67 lines
2.5 KiB
Python
"""`@command` — the whole contract in one decorator (D-263).
|
|
|
|
Cross-cutting concerns are decorators, never call-site discipline. The point of
|
|
composing them here is that a command author **cannot apply half the contract**:
|
|
there is no way to get logging without error handling, or to remember one and
|
|
forget the other on the 40th command of a long porting session. That failure
|
|
mode is the reason D-263 makes these decorators rather than conventions.
|
|
|
|
Usage, and it goes UNDER the Typer registration so it wraps the function Typer
|
|
will call:
|
|
|
|
@app.command("client-version")
|
|
@command
|
|
def client_version() -> None:
|
|
...
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
from collections.abc import Callable
|
|
from typing import Any, TypeVar
|
|
|
|
from tooling.core import jobs
|
|
from tooling.core.errors import handle_errors
|
|
from tooling.core.logging import logged
|
|
|
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
|
|
# Set by @command and asserted by the conformance test. A marker attribute is
|
|
# used rather than inspecting the composition after the fact, because unwrapping
|
|
# functools.wraps chains to prove "this was decorated" is brittle in exactly the
|
|
# way a conformance test must not be.
|
|
MARKER = "__reach_command__"
|
|
|
|
|
|
def command(func: F) -> F:
|
|
"""Compose the invocation contract onto one command function.
|
|
|
|
**Order is load-bearing, in both directions.**
|
|
|
|
`handle_errors` wraps `logged`, not the reverse: the logger's `finally` then
|
|
sees the ORIGINAL exception and records its type as the outcome. Invert them
|
|
and the error handler converts everything to `SystemExit` first, so every
|
|
failure is logged as "SystemExit" and the record says nothing about what
|
|
actually went wrong — while still looking like it worked.
|
|
|
|
The **job context is outermost**, wrapping both. It has to be: `logged`
|
|
emits from its `finally` and `handle_errors` emits its verdict while
|
|
unwinding, so a context established inside either would already be gone by
|
|
the time the two most important events are written. Those would then be the
|
|
only untagged lines in the log — and they are precisely the ones a detached
|
|
run is read back for.
|
|
"""
|
|
inner = handle_errors(logged(func))
|
|
|
|
@functools.wraps(func)
|
|
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
token = jobs.begin()
|
|
try:
|
|
return inner(*args, **kwargs)
|
|
finally:
|
|
jobs.end(token)
|
|
|
|
setattr(wrapped, MARKER, True)
|
|
return wrapped # type: ignore[return-value]
|