Source code for shinobi.results

"""Execution results.

`BackendRun` is the raw, schema-agnostic outcome of a backend actually
running a command: exit status and captured console output, nothing more.
Backends return this; they never see a cab's output schema.

`StepResult` is the schema-aware result the dispatch layer builds on top:
it carries the validated `outputs` and effective `inputs` models plus the
same console output, and is what a step call (cab or recipe) returns.
"""

from __future__ import annotations

import signal
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel

from shinobi.resources import Resources

if TYPE_CHECKING:
    # `shinobi.cache` imports `StepResult` from here, so this one has to stay
    # type-only or the two modules deadlock at import.
    from shinobi.cache import ProvenanceKey


def explain_returncode(returncode: int) -> str:
    """Render an exit status for a human, naming the signal if there was one.

    A process killed by a signal surfaces as a *negative* returncode, and
    bare `-9` is the single least informative thing a failed step can say:
    it is what a step gets for exceeding a cgroup memory cap, and it looks
    identical to any other kill. Naming the signal -- and calling out the
    one that is almost always an out-of-memory kill -- is the difference
    between "it died" and "it died because it asked for more memory than it
    was allowed".

    Args:
        returncode: The process exit status.

    Returns:
        A string like `"-9 (SIGKILL -- killed by the OS, most often an
        out-of-memory kill)"`, or just the number when it is an ordinary
        exit status.
    """
    if returncode >= 0:
        return str(returncode)
    try:
        name = signal.Signals(-returncode).name
    except ValueError:
        return str(returncode)
    if name == "SIGKILL":
        return f"{returncode} (SIGKILL -- killed by the OS, most often an out-of-memory kill; check the step's declared resources against what it actually used)"
    return f"{returncode} ({name})"


[docs] @dataclass class BackendRun: """What a backend returns after running a command -- just the raw run outcome. Wrangling stdout/stderr into structured outputs and filling an `outputs_model` is the dispatch layer's job, not the backend's. """ returncode: int stdout: str = "" stderr: str = "" # Registry digest (``sha256:...``) of the image that actually ran, when # the backend could pin it (see ``backends.container._pin_image``); # ``None`` for native runs or images that couldn't be resolved to a # digest (local-built/untagged, offline, no skopeo). image_digest: str | None = None # Whether this run actually executed inside a container. True even when # ``image_digest`` is None (containerized but unpinnable) -- that pair is # exactly what makes a run report ``pinned: false``. Set by every backend # that wraps in a container runtime (incl. Slurm-under-apptainer, whose # backend *name* isn't a container-runtime name). containerized: bool = False # The virtualenv this ran in (its path), and a ``sha256`` of its sorted # ``name==version`` list -- set only by the ``venv`` backend, and the # digest only under ``pin=True``. The digest is a version-parity record, # not an OS-level pin, so a venv run is always reported unpinned; these # are informational provenance (see ``backends.venv``). venv: str | None = None venv_digest: str | None = None # How many lines of each stream were elided to keep the capture bounded # (``backends._stream.LineBuffer``). Zero for any run short enough to be # held whole, which is nearly all of them. Non-zero is not an error: the # dropped region is marked in the text with an elision marker, and was # echoed live if the run streamed. stdout_dropped: int = 0 stderr_dropped: int = 0 # True only when a line matching one of the cab's own wrangler patterns # was dropped anyway -- the buffer retains matches wherever they occur, # so this means the retained-match ceiling was hit too. Unlike the counts # above, this one can mean a *missing output value*, and dispatch says so. wrangler_lines_dropped: bool = False @property def success(self) -> bool: """Whether the run exited with return code 0.""" return self.returncode == 0
[docs] @dataclass class StepResult: """The outcome of running a step (a Cab or a Recipe). `outputs` is a validated instance of the step's `outputs_model`; `inputs` is a validated instance of the *effective* (post-override) inputs the step actually ran with. For a Recipe these aggregate from its sub-steps. """ name: str returncode: int outputs: BaseModel inputs: BaseModel stdout: str = "" stderr: str = "" # True when this result was synthesized from `shinobi.cache` (the step # itself never actually ran) rather than produced by a real backend run. cached: bool = False # True when this step belongs to an unrolled loop iteration that ran # after the loop had already converged (see `shinobi.steps.loops`): its # `outputs` are the same body step's outputs from the last iteration # that really ran. Distinct from `cached` -- nothing was looked up, and # distinct from `kind`, which still reports what the step *is*. skipped: bool = False # Provenance, for the run manifest (see `shinobi.provenance`). `kind` is # stamped explicitly at each construction site rather than inferred, so a # containerized pystep can never be mistaken for a plain one. `backend`, # `image`, and `image_digest` are set for steps that ran a container; # `sub_results` holds a recipe's per-step results in declaration order. kind: str = "cab" backend: str | None = None image: str | None = None image_digest: str | None = None # Whether the step ran inside a container (see BackendRun.containerized). # `pinned` requires a digest only for containerized steps, so this -- not # the backend name -- is what distinguishes a native cab (image is just # metadata) from a Slurm-under-apptainer run that must be pinned. containerized: bool = False # The venv this step ran in and its version-parity digest (see # BackendRun.venv/venv_digest). A step with `venv` set is always reported # unpinned in the manifest regardless of `venv_digest`. venv: str | None = None venv_digest: str | None = None # Whether the step ran with sandboxing enabled (see `shinobi.sandbox`). # Recorded for diagnostics -- sandbox state affects path anchoring but # not the cache key, so output paths are normalized before recording # (see `sandbox.relativize_path_outputs`). sandboxed: bool = False # What this step declared it needed (`Scope.resources`). Recorded purely # so a post-mortem can compare the declaration against what happened: # "killed by SIGKILL, declared 200GiB" is a diagnosis, whereas a bare # `-9` months later is not. Not restored on replay -- see # `provenance.apply_manifest_pins`. resources: Resources | None = None sub_results: "dict[str, StepResult] | None" = None # Provenance keys, for the cache's upstream-invalidation term (see # `shinobi.cache`). `cache_key` is this step's own key, set by `_dispatch` # whenever the step was cacheable; `output_keys` is the per-output-field # override a *Recipe* carries, since a recipe is never itself cached and # each of its declared outputs is really produced by a different sub-step. # Read them through `provenance_key`, never directly. cache_key: str | None = None output_keys: "dict[str, ProvenanceKey] | None" = None @property def success(self) -> bool: """Whether the step exited with return code 0.""" return self.returncode == 0
[docs] def provenance_key(self, field: str) -> Any: """The cache key identifying whatever produced output `field`. A leaf step produces all its outputs in one run, so every field resolves to that step's own `cache_key`. A `Recipe` fans out to `output_keys` instead -- each declared output comes from a distinct sub-step, and invalidating a downstream consumer because some *unrelated* sub-step re-ran would throw away most of the cache's value. `None` means "no provenance available" (caching disabled, or a step that isn't cacheable) -- callers must treat that as "contribute nothing", not as a key in its own right. """ if self.output_keys is not None: return self.output_keys.get(field) return self.cache_key
def __getattr__(self, name: str) -> Any: """Read through to `outputs` for convenience (`result.<output_field>`). Args: name: Attribute name, looked up on `self.outputs` if not a dataclass field. Returns: The corresponding attribute of `self.outputs`. Raises: AttributeError: If `name` isn't found on `self.outputs` either. """ # convenience: result.<output_field> reads through to outputs try: return getattr(self.__dict__["outputs"], name) except (KeyError, AttributeError): raise AttributeError(f"StepResult for '{self.__dict__.get('name')}' has no attribute '{name}'") from None