"""Lightweight, decoupled progress reporting for long-running scheduler jobs. A job's work function calls `report(current, total, phase)`; the scheduler binds a sink (via `bind()`) that forwards it into the in-memory activity the admin dashboard polls. A contextvar holds the sink, so the sync code stays unaware of the scheduler and concurrent jobs (each in its own thread/context) never cross wires. When no sink is bound (e.g. a manual sync endpoint), `report` is a cheap no-op. """ import contextvars from typing import Callable, Literal, Optional Sink = Callable[[int, Optional[int], Optional[str]], None] _sink: contextvars.ContextVar[Optional[Sink]] = contextvars.ContextVar( "progress_sink", default=None ) def report(current: int, total: int | None = None, phase: str | None = None) -> None: """Report job progress to the bound sink, if any. `total=None` means indeterminate.""" sink = _sink.get() if sink is not None: sink(current, total, phase) class JobFailed(RuntimeError): """A job's own failure, said so the reader's language can name it: the client reads `scheduler.failure.` and interpolates `params` (keep them to values the reader may see — numbers, ids, short codes). The message is the English line the log and `last_error` keep. An upstream's own error (a timeout, a refused connection) is not one of these: it stays as raised, being diagnostic.""" def __init__(self, reason: str, message: str, **params: object): super().__init__(message) self.reason = reason self.params = params class bind: """Context manager that binds a progress sink for the duration of a job run.""" def __init__(self, sink: Sink): self._sink = sink self._token = None def __enter__(self) -> "bind": self._token = _sink.set(self._sink) return self # Literal[False], not bool: this never swallows the job's exception, and saying so is what lets # a type checker know a name assigned inside `with bind(...)` is bound after it. def __exit__(self, *exc) -> Literal[False]: if self._token is not None: _sink.reset(self._token) return False