"""The ``provenance`` declaration vocabulary and its pure :class:`httk.core.Run` builder.
The declaration name is ``provenance``. Its document is an object whose members
are all optional:
.. code-block:: json
{
"workflow_declaration_uri": "https://schemas.httk.org/defs/v0.1/workflows/vasp-relax",
"inputs": {"initial_structure": {"type": "structures", "id": "<served id>"}},
"artifacts": {"relaxed_structure": {"type": "structures", "id": "..."}},
"outputs": {"total_energy": {"type": "_httk_records", "id": "..."}}
}
The object keys are edge labels, so labels are unique per side. Targets are
loose served-entry references. A document may be written as ``declared`` in
``JobSpec.declarations`` at scaffold time, which suits externally known inputs,
and/or as ``observed`` with ``Attempt.declare("provenance", ...)`` at collect
time, when produced-entry ids exist. Consumers choose one complete document;
they never merge the two.
"""
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Final
from httk.core import Run, RunEdge
from .collecting import JobRecord
__all__ = ["PROVENANCE_DECLARATION", "run_record"]
[docs]
PROVENANCE_DECLARATION: Final = "provenance"
def _error(identity: str, member: str, label: object, detail: str) -> ValueError:
return ValueError(f"{identity}: {member} label {label!r}: {detail}")
def _edges(identity: str, document: Mapping[str, object], member: str) -> tuple[RunEdge, ...]:
if member not in document:
return ()
raw = document[member]
if not isinstance(raw, Mapping):
raise _error(identity, member, member, "must be a mapping")
edges: list[RunEdge] = []
for label, target in raw.items():
if not isinstance(label, str):
raise _error(identity, member, label, "must be a string")
if not isinstance(target, Mapping):
raise _error(identity, member, label, "edge must be a mapping")
if set(target) != {"type", "id"}:
raise _error(identity, member, label, "edge must contain only 'type' and 'id'")
entry_type = target["type"]
entry_id = target["id"]
if not isinstance(entry_type, str) or not isinstance(entry_id, str):
raise _error(identity, member, label, "edge 'type' and 'id' must be strings")
edges.append(RunEdge(label, entry_type, entry_id))
return tuple(edges)
def _uri(identity: str, value: object, source: str, *, allow_none: bool) -> str | None:
if value is None:
if allow_none:
return None
raise ValueError(f"{identity}: {source} must be a non-empty string without surrounding whitespace")
if not isinstance(value, str) or not value or value != value.strip():
requirement = "None or a non-empty string" if allow_none else "a non-empty string"
raise ValueError(f"{identity}: {source} must be {requirement} without surrounding whitespace")
return value
def _workflow_uri(identity: str, declarations: Mapping[str, Mapping[str, object] | None]) -> str | None:
entry = declarations.get("workflow")
if not isinstance(entry, Mapping):
return None
for side in ("observed", "declared"):
document = entry.get(side)
if isinstance(document, Mapping) and "$id" in document:
return _uri(identity, document["$id"], "workflow $id", allow_none=False)
return None
def _timestamp(value: object) -> datetime | None:
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None or parsed.utcoffset() is None:
return None
return parsed
def _last_modified(record: JobRecord) -> datetime | None:
latest: datetime | None = None
activations = record.provenance.get("activations")
if not isinstance(activations, Sequence) or isinstance(activations, (str, bytes)):
return None
for activation in activations:
if not isinstance(activation, Mapping):
continue
attempts = activation.get("attempts")
if not isinstance(attempts, Sequence) or isinstance(attempts, (str, bytes)):
continue
for attempt in attempts:
if not isinstance(attempt, Mapping):
continue
parsed = _timestamp(attempt.get("finished_at"))
if parsed is not None and (latest is None or parsed > latest):
latest = parsed
return latest
[docs]
def run_record(record: JobRecord) -> Run:
"""Build the one :class:`httk.core.Run` represented by *record*.
The observed ``provenance`` document is selected wholesale when present;
otherwise the declared document is selected. Missing or ``None`` means no
edges. Its URI is used when that member is present, including explicit
``null``; otherwise the observed-then-declared ``workflow`` declaration's
``$id`` is used. Each edge member must map labels to exactly ``type`` and
``id`` string members, and insertion order is preserved.
``immutable_id`` is ``"<workspace_id>:<job_id>"``. ``last_modified`` is the
latest parseable aware ``finished_at`` timestamp in the attempt timeline;
absent or unparseable timestamps produce ``None``. Children are not folded
in: each child collects to its own ``Run``, while a parent can name child
products explicitly in its observed declaration. Runner identity, timeline,
and failure are deliberately not folded into ``Run``; the caller's
``JobRecord`` remains the extra-information channel.
:param record: Supply the mechanical readout of one collected job.
:return: The framework-owned run assembled from the selected declaration.
:raises ValueError: If a selected URI or edge violates the provenance
contract.
"""
identity = f"{record.workspace_id}:{record.job_id}"
declarations = record.declarations
entry = declarations.get(PROVENANCE_DECLARATION)
document: Mapping[str, object] | None = None
if isinstance(entry, Mapping):
observed = entry.get("observed")
declared = entry.get("declared")
chosen = observed if observed is not None else declared
if isinstance(chosen, Mapping):
document = chosen
if document is not None and "workflow_declaration_uri" in document:
workflow_uri = _uri(
identity,
document["workflow_declaration_uri"],
"provenance.workflow_declaration_uri",
allow_none=True,
)
else:
workflow_uri = _workflow_uri(identity, declarations)
return Run(
workflow_declaration_uri=workflow_uri,
inputs=() if document is None else _edges(identity, document, "inputs"),
artifacts=() if document is None else _edges(identity, document, "artifacts"),
outputs=() if document is None else _edges(identity, document, "outputs"),
immutable_id=identity,
last_modified=_last_modified(record),
)