Files
SciMesh/scimesh/worker/models.py
T

112 lines
3.7 KiB
Python

"""Value objects for one claimed task and its run result.
These are the wire types the Go worker agent hands to the per-task Python
entry point; the daemon lifecycle itself lives in the Go agent.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from uuid import UUID
def _required_string(value: object, field: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field} must be a non-empty string")
return value
def _coordinator_uri(value: object, field: str) -> str:
uri = _required_string(value, field)
parsed = urlsplit(uri)
if uri.startswith("/"):
# ``//host/path`` is a network-path reference: urljoin would resolve
# it to another origin. Dot segments are rejected for the same reason
# we reject unsafe local task identifiers.
if parsed.netloc or any(segment == ".." for segment in parsed.path.split("/")):
raise ValueError(f"{field} must be a safe coordinator path")
return uri
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError(f"{field} must be an absolute HTTP(S) URL or coordinator path")
return uri
def _sha256(value: object, field: str) -> str:
digest = _required_string(value, field).lower()
if len(digest) != 64 or any(
character not in "0123456789abcdef" for character in digest
):
raise ValueError(f"{field} must be a SHA-256 hex digest")
return digest
@dataclass(frozen=True)
class InputArtifact:
uri: str
sha256: str
@dataclass(frozen=True)
class ClaimedTask:
task_id: str
attempt: int
lease_expires_at: str
workload: str
input: InputArtifact
parameters: dict[str, Any]
@classmethod
def from_json(cls, data: dict[str, Any]) -> "ClaimedTask":
try:
input_data = data["input"]
if not isinstance(input_data, dict):
raise ValueError("input must be an object")
raw_attempt = data["attempt"]
if (
isinstance(raw_attempt, bool)
or not isinstance(raw_attempt, int)
or raw_attempt < 1
):
raise ValueError("attempt must be a positive integer")
task_id = str(UUID(_required_string(data["task_id"], "task_id")))
lease_expires_at = _required_string(
data["lease_expires_at"], "lease_expires_at"
)
if (
datetime.fromisoformat(lease_expires_at.replace("Z", "+00:00")).tzinfo
is None
):
raise ValueError("lease_expires_at must include a timezone")
parameters = data.get("parameters", {})
if not isinstance(parameters, dict):
raise ValueError("parameters must be an object")
return cls(
task_id=task_id,
attempt=raw_attempt,
lease_expires_at=lease_expires_at,
workload=_required_string(data["workload"], "workload"),
input=InputArtifact(
uri=_coordinator_uri(input_data["uri"], "input.uri"),
sha256=_sha256(input_data["sha256"], "input.sha256"),
),
parameters=parameters,
)
except (KeyError, TypeError, ValueError) as error:
raise ValueError("invalid claimed-task response") from error
@dataclass(frozen=True)
class ProducedArtifact:
path: Path
content_type: str
@dataclass(frozen=True)
class RunResult:
artifacts: tuple[ProducedArtifact, ...]
metrics: dict[str, int | float]