mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
key(run) is the sha256 the cloud derives as a run's key: benchmark, split, system, version, k, temperature, max_tokens, embedder, reader and the four digests, each as text and NUL-terminated. tests/test_measure.py holds the vectors hanzo-inc/cloud's apps/research TestKeyVector does. Run keeps the crate's fields; the key is computed, not carried. Research.measure(identity, fn, force=False) asks for a complete run under that key and returns the latest to end without calling fn. A miss, a partial run or force calls fn, records the run and returns it as read back. fn may not change an identity field. Arena.post_run follows the arena: it answers the latest run held per benchmark, or that nothing was queued, and spends nothing, so confirm, endpoint and attempts go. Admission becomes Measurements. Claude-Session: https://claude.ai/code/session_01M8RoubcPN71KgJuZ8LdBUL
548 lines
23 KiB
Python
548 lines
23 KiB
Python
"""`Research`: every route hanzo-inc/cloud `apps/research` serves at `/v1/research`.
|
|
|
|
Two planes on one client. The RECORD plane names what a number needs to be believed:
|
|
benchmarks (the task), runs (the execution, its completion derived by the server),
|
|
studies (the question) and papers (the writing that cites runs). The EXPERIMENT plane is
|
|
the older stream of one measured number per id plus its attempts and diary artifacts; the
|
|
`experiment()` handle files to it.
|
|
|
|
Everything a caller writes lands private, under the caller's org, in the token's project.
|
|
Visibility (private, org, public) and the training and commons consents are a separate
|
|
call, `grant`. Records are versioned and append-only: a correction is a new version under
|
|
the same id (`revise`), a withdrawal is a version marked retracted (`retract`), and every
|
|
earlier version stays retained.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
from typing import Any, Mapping, TypeVar, Callable, Iterable
|
|
|
|
from . import wire, canonical, provenance
|
|
from .wire import (
|
|
Ack,
|
|
Run,
|
|
Batch,
|
|
Paper,
|
|
Study,
|
|
Record,
|
|
Stored,
|
|
Totals,
|
|
Listing,
|
|
Summary,
|
|
Artifact,
|
|
Benchmark,
|
|
Comparison,
|
|
key,
|
|
dump,
|
|
)
|
|
from .errors import Error, Invalid, Missing
|
|
from .transport import Transport
|
|
|
|
__all__ = ["Research", "Experiment", "DEFAULT_LIBS"]
|
|
|
|
DEFAULT_LIBS = [
|
|
"hanzo-kernel",
|
|
"hanzo-ml",
|
|
"hanzo-engine",
|
|
"hanzo-research",
|
|
"enso-bench",
|
|
"datasets",
|
|
"huggingface-hub",
|
|
"torch",
|
|
"transformers",
|
|
]
|
|
VERDICTS = ("proven", "refuted", "inconclusive")
|
|
|
|
R = TypeVar("R", Benchmark, Run, Study, Paper)
|
|
# the batch key, and route, of each record kind
|
|
_KEYS: dict[type, str] = {Benchmark: "benchmarks", Run: "runs", Study: "studies", Paper: "papers"}
|
|
|
|
|
|
class Research:
|
|
"""A research client. Base URL, credential, project and acting org come from the environment
|
|
by default (`RESEARCH_BASE`, `HANZO_API_KEY` else `hanzo auth token`, the token's `project`
|
|
claim, the token's `owner`); `org` overrides the org, which the token must belong to.
|
|
`repo` is the checkout whose git state stamps the experiment plane (default: the git
|
|
toplevel of the working directory).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
base: str | None = None,
|
|
api_key: str | None = None,
|
|
project: str | None = None,
|
|
repo: str | None = None,
|
|
libs: list[str] | None = None,
|
|
timeout: float = 120.0,
|
|
org: str | None = None,
|
|
transport: Transport | None = None,
|
|
):
|
|
self.http = transport or Transport(base, api_key, org, project, timeout)
|
|
self.repo = repo or provenance.find_repo()
|
|
self.libs = libs if libs is not None else DEFAULT_LIBS
|
|
self._git: dict[str, Any] | None = None
|
|
|
|
@property
|
|
def base(self) -> str:
|
|
return self.http.base
|
|
|
|
@property
|
|
def project(self) -> str:
|
|
return self.http.project
|
|
|
|
@property
|
|
def org(self) -> str | None:
|
|
return self.http.org
|
|
|
|
@property
|
|
def arena(self) -> Any:
|
|
"""The `/v1/benchmark` arena on this client's credential and org."""
|
|
from .benchmark import Arena
|
|
|
|
return Arena(transport=self.http)
|
|
|
|
def url(self, run_id: str, org: str | None = None) -> str:
|
|
"""The address that reads one run of an org's public runs, with no credential: a claim's source."""
|
|
from urllib.parse import quote
|
|
|
|
org = org or self.org
|
|
if not org:
|
|
raise ValueError("no org to read the run from: pass org=")
|
|
return f"{self.base}/v1/research/runs?org={quote(org, safe='')}&id={quote(run_id, safe='')}"
|
|
|
|
# ── record plane: writes ─────────────────────────────────────────────────────
|
|
|
|
def record(
|
|
self,
|
|
benchmarks: Iterable[Benchmark | Mapping[str, Any]] = (),
|
|
runs: Iterable[Run | Mapping[str, Any]] = (),
|
|
studies: Iterable[Study | Mapping[str, Any]] = (),
|
|
papers: Iterable[Paper | Mapping[str, Any]] = (),
|
|
) -> dict[str, Batch]:
|
|
"""File records: one write a non-empty kind, at most 20,000 records each, benchmarks and
|
|
studies before the runs that name them and papers last. The answers by kind; nothing
|
|
given sends nothing. A `Run` (or any record) is sent with every field, a mapping as given.
|
|
"""
|
|
return self._write({"benchmarks": benchmarks, "studies": studies, "runs": runs, "papers": papers})
|
|
|
|
def _write(self, batches: Mapping[str, Iterable[Any]]) -> dict[str, Batch]:
|
|
out = {}
|
|
for key, rows in batches.items():
|
|
body = [dump(r) for r in rows]
|
|
if body:
|
|
batch = Batch.load(self.http.post(f"/v1/research/{key}", {key: body}))
|
|
self._filed(batch.project, key)
|
|
out[key] = batch
|
|
return out
|
|
|
|
def _filed(self, project: str, what: str) -> None:
|
|
# the crate's `filed`: the server files under the credential's project, whatever was asked
|
|
if wire.scope(project) != self.project:
|
|
raise Error(f"{what} were committed under project {project}, not {self.project}")
|
|
|
|
def _again(self, rec: Any, revision: str) -> Batch:
|
|
"""File `rec` as its next version, marked `revision`, in the project it lives in."""
|
|
if rec.project and wire.scope(rec.project) != self.project:
|
|
raise Invalid(f"{rec.id} is in project {rec.project}; this client files under {self.project}")
|
|
rec.revision = revision
|
|
key = _KEYS[type(rec)]
|
|
batch = self._write({key: [rec]})[key]
|
|
if batch.recorded == 0:
|
|
raise Invalid(f"the server already holds this version of {rec.id}: nothing was filed")
|
|
if isinstance(rec, Run) and (rec.visibility not in ("", "private") or rec.trainable or rec.publishable):
|
|
# a new version lands private; the run's grants carry over to it
|
|
self.grant(rec.id, visibility=rec.visibility or None, trainable=rec.trainable, publishable=rec.publishable)
|
|
return batch
|
|
|
|
def revise(self, record: R, notes: str | None = None) -> Batch:
|
|
"""File a correction: the record's next version, marked corrected, keeping the run's grants.
|
|
|
|
A record read from this client's project (`get_run`) or built here; one from another
|
|
project is refused, since the server would file the version beside it, not over it.
|
|
"""
|
|
if notes is not None and isinstance(record, Run):
|
|
record.notes = notes
|
|
return self._again(record, "corrected")
|
|
|
|
def retract(self, record: str | R, why: str) -> Batch:
|
|
"""Withdraw a record: its next version, marked retracted. A run id reads its canonical run first.
|
|
|
|
A retracted latest version takes the id out of every listing; the earlier versions stay
|
|
retained. `why` becomes the version's notes for a run (the only kind with notes).
|
|
"""
|
|
if not why.strip():
|
|
raise Invalid("a retraction says why")
|
|
rec: Any = self.get_run(record) if isinstance(record, str) else record
|
|
if isinstance(rec, Run):
|
|
rec.notes = why
|
|
return self._again(rec, "retracted")
|
|
|
|
def grant(
|
|
self,
|
|
id: str | None = None,
|
|
*,
|
|
sha256: str | None = None,
|
|
visibility: str | None = None,
|
|
trainable: bool | None = None,
|
|
publishable: bool | None = None,
|
|
project: str | None = None,
|
|
) -> int:
|
|
"""Set a run's visibility (private, org, public) and consents, or an artifact's visibility.
|
|
|
|
Lands on every retained version of the id. `None` leaves a field as it is. Returns how
|
|
many records it moved; none is `Missing`.
|
|
"""
|
|
body: dict[str, Any] = {"project": project or self.project}
|
|
if sha256:
|
|
body["sha256"] = sha256
|
|
else:
|
|
body["id"] = id
|
|
for k, v in (("visibility", visibility), ("trainable", trainable), ("publishable", publishable)):
|
|
if v is not None:
|
|
body[k] = v
|
|
return int(self.http.post("/v1/research/grants", body).get("updated", 0))
|
|
|
|
# ── record plane: reads ──────────────────────────────────────────────────────
|
|
|
|
def benchmarks(self, id: str | None = None, project: str | None = None) -> list[Benchmark]:
|
|
"""Canonical benchmark definitions of the org (`project` narrows; empty reads every project)."""
|
|
out = self.http.get("/v1/research/benchmarks", {"id": id, "project": project})
|
|
return [Benchmark.load(x) for x in out.get("data") or []]
|
|
|
|
def get_benchmark(self, id: str, project: str | None = None) -> Benchmark:
|
|
"""One benchmark's canonical version in a project (default this client's), or `Missing`."""
|
|
return _one(self.benchmarks(id=id, project=project or self.project), "benchmark", id)
|
|
|
|
def runs(
|
|
self,
|
|
id: str | None = None,
|
|
key: str | None = None,
|
|
benchmark: str | None = None,
|
|
system: str | None = None,
|
|
study: str | None = None,
|
|
completion: str | None = None,
|
|
since: str | None = None,
|
|
until: str | None = None,
|
|
project: str | None = None,
|
|
org: str | None = None,
|
|
) -> Listing:
|
|
"""Canonical runs, with the completion census and the fields runs leave unstated.
|
|
|
|
Without `org`, the caller's own org, private runs included. With `org`, that org's
|
|
PUBLIC runs, read with no credential: the server answers `?org=` only to a caller
|
|
that names no org of its own. `key` narrows to one measurement identity (`key(run)`);
|
|
`since`/`until` bound `when` (RFC 3339).
|
|
"""
|
|
q = {
|
|
"id": id,
|
|
"key": key,
|
|
"benchmark": benchmark,
|
|
"system": system,
|
|
"study": study,
|
|
"completion": completion,
|
|
"since": since,
|
|
"until": until,
|
|
"project": project,
|
|
"org": org,
|
|
}
|
|
out = Listing.load(self.http.get("/v1/research/runs", q, auth=not org))
|
|
out.data = [Run.load(x) for x in out.data]
|
|
return out
|
|
|
|
def get_run(self, id: str, org: str | None = None, project: str | None = None) -> Run:
|
|
"""One run's canonical version, or `Missing`: in a project of your org (default this
|
|
client's; an id is unique only within a project), or with `org`, of that org's public runs."""
|
|
project = project or (None if org else self.project)
|
|
return _one(self.runs(id=id, org=org, project=project).data, "run", id)
|
|
|
|
def measure(
|
|
self,
|
|
identity: Run | Mapping[str, Any],
|
|
fn: Callable[[], Run | Mapping[str, Any]],
|
|
force: bool = False,
|
|
) -> Run:
|
|
"""The run that measures `identity`: the org's complete one when it holds it, else `fn`'s.
|
|
|
|
`identity` is the run before it is measured: its id, its benchmark and whichever of
|
|
`IDENTITY` fix the measurement. Unless `force`, the org's runs are asked for a complete
|
|
one under `key(identity)` and the latest to end is returned; `fn` is not called and
|
|
nothing is spent. Otherwise `fn()` measures: it returns the results (measures,
|
|
questions, answered, ended and the rest) as a mapping laid over the identity, or the
|
|
whole `Run`. The run is recorded and returned as the server reads it back.
|
|
|
|
A partial run is not a hit. `fn` may not change an identity field: a result filed
|
|
under a configuration it did not measure is the false hit this exists to prevent.
|
|
"""
|
|
want = key(identity)
|
|
if not force:
|
|
# checked here too: a server that predates ?key= answers every run, and none is a hit
|
|
held = [r for r in self.runs(key=want, completion="complete").data if key(r) == want]
|
|
if held:
|
|
return max(held, key=lambda r: (r.ended, r.when))
|
|
got = fn()
|
|
run = Run.load(dump(got) if isinstance(got, Run) else {**dump(identity), **dump(got)})
|
|
if key(run) != want:
|
|
raise Invalid(f"measure: {run.id} changed its measurement identity while it was measured")
|
|
self.record(runs=[run])
|
|
return _one(self.runs(id=run.id, key=want, project=self.project).data, "run", run.id)
|
|
|
|
def studies(self, id: str | None = None, project: str | None = None) -> list[Study]:
|
|
out = self.http.get("/v1/research/studies", {"id": id, "project": project})
|
|
return [Study.load(x) for x in out.get("data") or []]
|
|
|
|
def papers(
|
|
self, id: str | None = None, study: str | None = None, cites: str | None = None, project: str | None = None
|
|
) -> list[Paper]:
|
|
out = self.http.get("/v1/research/papers", {"id": id, "study": study, "cites": cites, "project": project})
|
|
return [Paper.load(x) for x in out.get("data") or []]
|
|
|
|
def compare(self, a: str, b: str, project: str | None = None) -> Comparison:
|
|
"""Two canonical runs side by side: what blocks the comparison, the config they differ in, the deltas."""
|
|
return Comparison.load(self.http.get("/v1/research/compare", {"a": a, "b": b, "project": project}))
|
|
|
|
# ── experiment plane ─────────────────────────────────────────────────────────
|
|
|
|
def ingest(
|
|
self, experiments: list[Mapping[str, Any]] | None = None, attempts: list[Mapping[str, Any]] | None = None
|
|
) -> Ack:
|
|
"""File experiment and attempt versions in one batch, idempotent by content."""
|
|
ack = Ack.load(
|
|
self.http.post("/v1/research/experiments", {"experiments": experiments or [], "attempts": attempts or []})
|
|
)
|
|
self._filed(ack.project, "experiments and attempts")
|
|
return ack
|
|
|
|
def query(self, project: str | None = None, kind: str | None = None) -> list[Record]:
|
|
"""Canonical experiments of a project (default the client's), narrowed to one kind."""
|
|
out = self.http.get("/v1/research/experiments", {"project": project or self.project, "kind": kind})
|
|
return [Record.load(x) for x in out.get("data") or []]
|
|
|
|
def totals(self, project: str | None = None) -> Totals:
|
|
return Totals.load(self.http.get("/v1/research/totals", {"project": project}))
|
|
|
|
def projects(self) -> list[Summary]:
|
|
return [Summary.load(x) for x in self.http.get("/v1/research/projects").get("data") or []]
|
|
|
|
def artifact(self, art: Artifact | Mapping[str, Any]) -> Stored:
|
|
"""File an artifact record whose `content` is base64; the server hashes the bytes."""
|
|
return Stored.load(self.http.post("/v1/research/artifacts", dump(art)))
|
|
|
|
def upload_artifact(
|
|
self,
|
|
kind: str,
|
|
run_id: str,
|
|
data: bytes,
|
|
media_type: str = "application/octet-stream",
|
|
canonicalization: str = "raw",
|
|
compression: str = "none",
|
|
artifact_id: str | None = None,
|
|
) -> Stored:
|
|
"""File bytes as a `snapshot` or `report` of a run, stamped with this checkout's git state."""
|
|
sha = hashlib.sha256(data).hexdigest()
|
|
g = self._state()
|
|
return self.artifact(
|
|
Artifact(
|
|
sha256=sha,
|
|
content=base64.b64encode(data).decode(),
|
|
kind=kind,
|
|
run_id=run_id,
|
|
git_sha=g["git_sha"],
|
|
git_branch=g["git_branch"],
|
|
git_dirty=g["git_dirty"],
|
|
lib_versions=provenance.lib_versions(self.libs),
|
|
artifact_id=artifact_id or f"{run_id}:{kind}",
|
|
content_sha256=sha,
|
|
media_type=media_type,
|
|
canonicalization=canonicalization,
|
|
compression=compression,
|
|
uncompressed_size=len(data),
|
|
)
|
|
)
|
|
|
|
def upload_json_artifact(self, kind: str, run_id: str, value: Any) -> Stored:
|
|
"""File a value as RFC 8785 JSON; its SHA-256 is `canonical.digest(value)`."""
|
|
return self.upload_artifact(kind, run_id, canonical.canonicalize(value), "application/json", "rfc8785")
|
|
|
|
def artifacts(self, run: str | None = None, since: int | None = None, project: str | None = None) -> list[Artifact]:
|
|
"""The diary feed, newest first, as metadata; bytes are read by `get_artifact`."""
|
|
out = self.http.get("/v1/research/artifacts", {"run": run, "since": since, "project": project})
|
|
return [Artifact.load(x) for x in out.get("data") or []]
|
|
|
|
def get_artifact(self, sha256: str, project: str | None = None) -> bytes:
|
|
"""An artifact's bytes by content hash, within the caller's org and project."""
|
|
from urllib.parse import quote
|
|
|
|
return self.http.call(
|
|
"GET", f"/v1/research/artifacts/{quote(sha256, safe='')}", {"project": project}, None, True
|
|
)
|
|
|
|
def experiment(
|
|
self,
|
|
kind: str,
|
|
subject: str,
|
|
task: str,
|
|
metric: str = "accuracy",
|
|
n_total: int = 0,
|
|
note: str = "",
|
|
hypothesis: str = "",
|
|
predict: str = "",
|
|
) -> Experiment:
|
|
"""Get or create the experiment `kind:subject:task` and post it in flight (see `Experiment`)."""
|
|
return Experiment(
|
|
self, kind, subject, task, metric=metric, n_total=n_total, note=note, hypothesis=hypothesis, predict=predict
|
|
)
|
|
|
|
def _state(self) -> dict[str, Any]:
|
|
if self._git is None:
|
|
self._git = provenance.git_state(self.repo)
|
|
return self._git
|
|
|
|
def _last_run_sha(self, exp_id: str) -> str:
|
|
try:
|
|
return next((e.git_sha for e in self.query() if e.id == exp_id), "")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _one(rows: list[Any], what: str, id: str) -> Any:
|
|
if not rows:
|
|
raise Missing(f"no canonical {what} {id!r}", 404)
|
|
return rows[0]
|
|
|
|
|
|
class Experiment:
|
|
"""One experiment `kind:subject:task` on the experiment plane, structured as a falsifiable test.
|
|
|
|
Posts itself in flight on creation; `record` files attempts, `snapshot`/`report` file
|
|
artifacts, `log` keeps a running trail, `conclude`/`finish` seal it. The git state, the
|
|
commit subjects since its last recorded run, the installed versions and the host are
|
|
stamped without being asked for. A context manager seals it complete, or faulted on an
|
|
exception.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
client: Research,
|
|
kind: str,
|
|
subject: str,
|
|
task: str,
|
|
metric: str = "accuracy",
|
|
n_total: int = 0,
|
|
note: str = "",
|
|
hypothesis: str = "",
|
|
predict: str = "",
|
|
):
|
|
self.c = client
|
|
self.kind, self.subject, self.task, self.metric = kind, subject, task, metric
|
|
self.id = f"{kind}:{subject}:{task}"
|
|
self.n_total = n_total
|
|
self._ok = 0
|
|
self._n = 0
|
|
self.hypothesis = hypothesis
|
|
self.predict = predict
|
|
self.verdict = ""
|
|
self.because = ""
|
|
self._log: list[str] = []
|
|
self.prov = client._state()
|
|
self.doc = provenance.caller_doc()
|
|
self.note = note
|
|
self.commits = provenance.commit_narrative(client.repo, since_sha=client._last_run_sha(self.id))
|
|
self.libs = provenance.lib_versions(client.libs)
|
|
self.host = provenance.host()
|
|
self._post(status="running", value=0.0)
|
|
|
|
def log(self, text: str) -> Experiment:
|
|
"""Append to the running log that travels with the run."""
|
|
self._log.append(text)
|
|
return self
|
|
|
|
def conclude(self, verdict: str, because: str = "", value: float | None = None) -> Ack:
|
|
"""Seal with a verdict (proven, refuted or inconclusive) and the reasoning that earns it."""
|
|
if verdict not in VERDICTS:
|
|
raise ValueError(f"verdict must be proven|refuted|inconclusive, got {verdict!r}")
|
|
self.verdict = verdict
|
|
self.because = because
|
|
return self.finish(value=value)
|
|
|
|
def record(self, item: str, model: str, result: Mapping[str, Any]) -> Ack:
|
|
"""File one attempt: `result` holds answer, correct, and optionally response, gold, source, status.
|
|
A `faulted` or `failed` attempt is kept and left out of the score."""
|
|
a = {
|
|
"benchmark": self.task,
|
|
"item": item,
|
|
"model": model,
|
|
"answer": result.get("answer", ""),
|
|
"correct": bool(result.get("correct")),
|
|
"response": result.get("response", ""),
|
|
"gold": result.get("gold", ""),
|
|
"source": result.get("source", "hanzo-measured"),
|
|
"status": result.get("status", "complete"),
|
|
}
|
|
if a["status"] not in ("faulted", "failed"):
|
|
self._n += 1
|
|
self._ok += int(a["correct"])
|
|
return self.c.ingest(attempts=[a])
|
|
|
|
def snapshot(self, data: bytes | str, run_id: str | None = None) -> Stored:
|
|
"""File PNG bytes (or a path to them) as a snapshot of this run."""
|
|
raw = data if isinstance(data, (bytes, bytearray)) else open(data, "rb").read()
|
|
return self.c.upload_artifact(
|
|
"snapshot", run_id or self.id, bytes(raw), "image/png", artifact_id=f"{self.id}:snapshot"
|
|
)
|
|
|
|
def report(self, data: bytes | str, run_id: str | None = None) -> Stored:
|
|
"""File HTML or Markdown as a report of this run."""
|
|
raw = data if isinstance(data, (bytes, bytearray)) else str(data).encode()
|
|
return self.c.upload_artifact(
|
|
"report", run_id or self.id, bytes(raw), "text/plain", artifact_id=f"{self.id}:report"
|
|
)
|
|
|
|
def finish(self, value: float | None = None) -> Ack:
|
|
"""Seal complete with the final number (from the recorded attempts when not given). A stated
|
|
hypothesis with no verdict seals inconclusive: a finished run never reads as a proof by default."""
|
|
if value is None:
|
|
value = round(100.0 * self._ok / self._n, 2) if self._n else 0.0
|
|
if self.hypothesis and not self.verdict:
|
|
self.verdict = "inconclusive"
|
|
return self._post(status="complete", value=value)
|
|
|
|
def _post(self, status: str, value: float) -> Ack:
|
|
e = {
|
|
"id": self.id,
|
|
"kind": self.kind,
|
|
"subject": self.subject,
|
|
"task": self.task,
|
|
"metric": self.metric,
|
|
"value": value,
|
|
"n": self._n,
|
|
"n_total": self.n_total,
|
|
"status": status,
|
|
"git_sha": self.prov["git_sha"],
|
|
"git_branch": self.prov["git_branch"],
|
|
"git_dirty": self.prov["git_dirty"],
|
|
"lib_versions": self.libs,
|
|
"meta": {
|
|
"doc": self.doc,
|
|
"commits": self.commits,
|
|
"note": self.note,
|
|
"host": self.host,
|
|
"hypothesis": self.hypothesis,
|
|
"predict": self.predict,
|
|
"verdict": self.verdict,
|
|
"because": self.because,
|
|
"log": self._log,
|
|
},
|
|
}
|
|
return self.c.ingest(experiments=[e])
|
|
|
|
def __enter__(self) -> Experiment:
|
|
return self
|
|
|
|
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
if exc_type is not None:
|
|
self._post(status="faulted", value=0.0)
|
|
else:
|
|
self.finish()
|