Files
settled-reach/tooling/core/command.py
T
jpmschweitzerandClaude Opus 5 5d83e1d2eb feat(config): T-1276 — every invocation is a job, carried ambiently
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>
2026-08-31 15:59:39 +02:00

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]