feat: 新增单路流类型合同 stream-contract/1
冻结逐帧身份、采集时钟、测量头和事件信封:FrameIdentity、FrameClock、FrameStamp、MeasurementFrame、StreamEvent,并从包根导出。采用严格读取:未知字段和未列出的版本一律拒绝,任何字段变更都升版本,读取方在 SUPPORTED_CONTRACT_VERSIONS 中列出可读版本;未知事件 kind 仍兼容。事件可哈希,kind 为 lower_snake。合同写入 docs/stream-contract.zh.md;不接入现有运行时。窄套件 139 项、lint 通过。
This commit is contained in:
@@ -19,6 +19,18 @@ from .pipeline import (
|
||||
StreamPipeline,
|
||||
)
|
||||
from .sensor import Sensor
|
||||
from .stream_contract import (
|
||||
STREAM_CONTRACT_VERSION,
|
||||
SUPPORTED_CONTRACT_VERSIONS,
|
||||
TIME_BASES,
|
||||
FrameClock,
|
||||
TimeBasis,
|
||||
FrameIdentity,
|
||||
FrameStamp,
|
||||
MeasurementFrame,
|
||||
StreamEvent,
|
||||
)
|
||||
|
||||
#: The explicit force-readout session (``studio-force/1``) used by the Studio
|
||||
#: workflow. Imported after ``Sensor`` because it composes the sensor, model and
|
||||
#: output blocks; the historical ``Sensor.read_info``/``ForceReadout.apply``
|
||||
@@ -70,6 +82,17 @@ __all__ = [
|
||||
'ForceSession',
|
||||
'WORKFLOW_VERSION',
|
||||
|
||||
# frozen single-stream contract (stream-contract/1)
|
||||
'STREAM_CONTRACT_VERSION',
|
||||
'SUPPORTED_CONTRACT_VERSIONS',
|
||||
'TIME_BASES',
|
||||
'TimeBasis',
|
||||
'FrameIdentity',
|
||||
'FrameClock',
|
||||
'FrameStamp',
|
||||
'MeasurementFrame',
|
||||
'StreamEvent',
|
||||
|
||||
# configs utilities functions
|
||||
'list_config',
|
||||
'export_config',
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
"""Frozen public types for the single-stream SensorStream contract.
|
||||
|
||||
This module is the stage-1A **type contract** (task T03): the frame identity,
|
||||
capture clock, measurement header and event envelope that the per-sensor stream
|
||||
(T08), the rebuilt force session (T06) and the recording/analysis consumers
|
||||
(T11, T13-T15) share. It deliberately does not change any existing class:
|
||||
``Sensor``, ``StreamPipeline`` and the current ``ForceSession`` keep their
|
||||
published behavior, and the later tasks adopt these headers instead of growing
|
||||
per-layer variants.
|
||||
|
||||
The contract rules are documented in ``docs/stream-contract.zh.md``. In short:
|
||||
|
||||
* identity is per stream - ``sensor_id`` + ``session_id`` + source/original
|
||||
frame number and a separate contiguous output number;
|
||||
* a group identity (``acquisition_id`` + ``sync_group_id``) is optional and is
|
||||
never fabricated: both parts are present or neither is;
|
||||
* a capture time is a number of nanoseconds plus the clock domain and the basis
|
||||
it was derived from; a raw source PTS is carried separately and is never
|
||||
replaced by processing time;
|
||||
* two frames with the same timestamp are still different frames (identity, not
|
||||
the timestamp, is the deduplication key);
|
||||
* ``source_dt_s`` is the source-time interval to the previous *actually
|
||||
processed* frame of the same stream (``None`` for the first) and never a
|
||||
processing-speed measurement;
|
||||
* events round-trip through JSON with a JSON-safe payload.
|
||||
|
||||
Versioning follows one strict rule (policy A): **any new, renamed, removed or
|
||||
re-typed field bumps ``STREAM_CONTRACT_VERSION``**. A reader rejects unknown
|
||||
fields and only reads the versions it lists in
|
||||
``SUPPORTED_CONTRACT_VERSIONS``; a writer that needs a field must bump the
|
||||
version and both sides must list it. Event ``kind`` values are not schema
|
||||
fields: consumers must ignore kinds they do not know, so new kinds stay
|
||||
additive within the same version.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Literal
|
||||
|
||||
__all__ = [
|
||||
"STREAM_CONTRACT_VERSION",
|
||||
"SUPPORTED_CONTRACT_VERSIONS",
|
||||
"TIME_BASES",
|
||||
"FrameClock",
|
||||
"FrameIdentity",
|
||||
"FrameStamp",
|
||||
"MeasurementFrame",
|
||||
"StreamEvent",
|
||||
"TimeBasis",
|
||||
]
|
||||
|
||||
#: Version of the serialized contract (``to_dict()``/``from_dict()``).
|
||||
#: Policy A: bump it on every field addition, removal, rename or meaning
|
||||
#: change; there are no same-version field additions.
|
||||
STREAM_CONTRACT_VERSION = "stream-contract/1"
|
||||
|
||||
#: Serialized schemas this reader accepts, and the only ones it may read.
|
||||
#: A writer that bumps ``STREAM_CONTRACT_VERSION`` must add the new version
|
||||
#: here (and in every reader that must read it); old versions stay listed as
|
||||
#: long as the loader supports them.
|
||||
SUPPORTED_CONTRACT_VERSIONS: tuple[str, ...] = (STREAM_CONTRACT_VERSION,)
|
||||
|
||||
#: How ``FrameClock.capture_time_ns`` was derived.
|
||||
#:
|
||||
#: ``capture_monotonic`` - host monotonic capture/receive time of a live source.
|
||||
#: ``recorded_signal`` - signal time stored by the recording (playback/debug).
|
||||
#: ``video_pts`` - a real container PTS converted to nanoseconds.
|
||||
#: ``estimated_source`` - derived from the source frame number and a nominal
|
||||
#: rate because no trustworthy PTS exists; consumers must treat it as estimated.
|
||||
TimeBasis = Literal[
|
||||
"capture_monotonic",
|
||||
"recorded_signal",
|
||||
"video_pts",
|
||||
"estimated_source",
|
||||
]
|
||||
|
||||
TIME_BASES: tuple[TimeBasis, ...] = (
|
||||
"capture_monotonic",
|
||||
"recorded_signal",
|
||||
"video_pts",
|
||||
"estimated_source",
|
||||
)
|
||||
|
||||
#: Event kinds are a stable lower_snake identifier; this grammar is part of the
|
||||
#: frozen envelope contract (unknown *values* stay compatible, malformed ones do
|
||||
#: not).
|
||||
_EVENT_KIND_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
|
||||
|
||||
# --------------------------------------------------------------- validation
|
||||
def _require_str(value: Any, name: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{name} must be a string")
|
||||
if not value:
|
||||
raise ValueError(f"{name} must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
def _require_int(value: Any, name: str, *, minimum: int | None = None) -> int:
|
||||
# ``type`` rather than ``isinstance``: a bool is an int in Python but never a
|
||||
# frame number or generation.
|
||||
if type(value) is not int:
|
||||
raise TypeError(f"{name} must be an integer")
|
||||
if minimum is not None and value < minimum:
|
||||
raise ValueError(f"{name} must be >= {minimum}")
|
||||
return value
|
||||
|
||||
|
||||
def _require_optional_int(
|
||||
value: Any, name: str, *, minimum: int | None = None
|
||||
) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _require_int(value, name, minimum=minimum)
|
||||
|
||||
|
||||
def _require_finite_number(value: Any, name: str) -> int | float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError(f"{name} must be a real number")
|
||||
if not math.isfinite(float(value)):
|
||||
raise ValueError(f"{name} must be finite")
|
||||
return value
|
||||
|
||||
|
||||
def _require_time_basis(value: Any) -> TimeBasis:
|
||||
if value not in TIME_BASES:
|
||||
raise ValueError(
|
||||
f"capture_time_basis must be one of {list(TIME_BASES)}, got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _require_source_dt(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError("source_dt_s must be a real number or None")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise ValueError("source_dt_s must be finite")
|
||||
if number < 0.0:
|
||||
raise ValueError("source_dt_s must be >= 0")
|
||||
return number
|
||||
|
||||
|
||||
# ----------------------------------------------------------- JSON payloads
|
||||
def _freeze_json(value: Any, path: str) -> Any:
|
||||
"""Validate a JSON-serializable value and return an immutable deep copy.
|
||||
|
||||
Only JSON types are accepted (``None``/bool/int/finite float/str/mapping
|
||||
with string keys/list/tuple). ``NaN`` and infinities are rejected because
|
||||
they do not survive ``json.dumps``/``json.loads`` unchanged.
|
||||
"""
|
||||
if value is None or isinstance(value, (bool, str)):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f"{path} contains a non-finite float")
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
frozen: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
if not isinstance(key, str):
|
||||
raise TypeError(
|
||||
f"{path} keys must be strings, got {type(key).__name__}"
|
||||
)
|
||||
frozen[key] = _freeze_json(item, f"{path}.{key}")
|
||||
return MappingProxyType(frozen)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return tuple(
|
||||
_freeze_json(item, f"{path}[{index}]") for index, item in enumerate(value)
|
||||
)
|
||||
raise TypeError(f"{path} contains non-JSON value {type(value).__name__}")
|
||||
|
||||
|
||||
def _thaw_json(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return {key: _thaw_json(item) for key, item in value.items()}
|
||||
if isinstance(value, tuple):
|
||||
return [_thaw_json(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _mapping(
|
||||
data: Any, name: str, allowed: frozenset[str], required: frozenset[str]
|
||||
) -> Mapping:
|
||||
"""Read one serialized object under policy A (strict field set).
|
||||
|
||||
Unknown fields are rejected, not ignored: a same-version record may only
|
||||
contain the fields of that frozen version. A new field therefore requires a
|
||||
new ``STREAM_CONTRACT_VERSION`` and an entry in
|
||||
``SUPPORTED_CONTRACT_VERSIONS``; components without a schema (identity and
|
||||
clock) follow the same allow-list rule through their owners.
|
||||
"""
|
||||
if not isinstance(data, Mapping):
|
||||
raise TypeError(f"{name} must be a mapping")
|
||||
unknown = set(data) - allowed
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"{name} has unknown field(s) {sorted(unknown)}; adding a field "
|
||||
"requires a new STREAM_CONTRACT_VERSION"
|
||||
)
|
||||
missing = required - set(data)
|
||||
if missing:
|
||||
raise ValueError(f"{name} is missing field(s) {sorted(missing)}")
|
||||
return data
|
||||
|
||||
|
||||
def _require_schema(value: Any, name: str) -> str:
|
||||
"""Accept only the serialized versions this reader explicitly lists."""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{name}.schema must be a string")
|
||||
if value not in SUPPORTED_CONTRACT_VERSIONS:
|
||||
raise ValueError(
|
||||
f"{name}.schema must be one of {list(SUPPORTED_CONTRACT_VERSIONS)}, "
|
||||
f"got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# ------------------------------------------------------------- frame types
|
||||
@dataclass(frozen=True)
|
||||
class FrameIdentity:
|
||||
"""Identity of one frame in one stream.
|
||||
|
||||
``source_frame_id`` keeps the source's own frame number (``None`` when the
|
||||
source does not provide one); ``frame_id`` is the contiguous output number
|
||||
of this stream. Both are needed: the same timestamp does not identify a
|
||||
frame, and two different source frames may share a PTS.
|
||||
|
||||
``acquisition_id``/``sync_group_id`` describe an optional capture group
|
||||
(for example one hardware trigger spanning several sensors). They are
|
||||
both present or both ``None``; a missing group is not replaced with a
|
||||
fabricated value and does not imply synchronization.
|
||||
"""
|
||||
|
||||
sensor_id: str
|
||||
session_id: str
|
||||
frame_id: int
|
||||
source_frame_id: int | None
|
||||
acquisition_id: str | None = None
|
||||
sync_group_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_str(self.sensor_id, "sensor_id")
|
||||
_require_str(self.session_id, "session_id")
|
||||
_require_int(self.frame_id, "frame_id", minimum=0)
|
||||
_require_optional_int(self.source_frame_id, "source_frame_id", minimum=0)
|
||||
if (self.acquisition_id is None) != (self.sync_group_id is None):
|
||||
raise ValueError(
|
||||
"group identity is optional but must be complete: set both "
|
||||
"acquisition_id and sync_group_id, or leave both None"
|
||||
)
|
||||
if self.acquisition_id is not None:
|
||||
_require_str(self.acquisition_id, "acquisition_id")
|
||||
_require_str(self.sync_group_id, "sync_group_id")
|
||||
|
||||
@property
|
||||
def has_group(self) -> bool:
|
||||
"""Whether this frame carries a capture-group identity."""
|
||||
return self.acquisition_id is not None
|
||||
|
||||
@property
|
||||
def group_identity(self) -> tuple[str, str] | None:
|
||||
"""``(acquisition_id, sync_group_id)``, or ``None`` when absent."""
|
||||
if self.acquisition_id is None:
|
||||
return None
|
||||
return (str(self.acquisition_id), str(self.sync_group_id))
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"sensor_id": self.sensor_id,
|
||||
"session_id": self.session_id,
|
||||
"frame_id": self.frame_id,
|
||||
"source_frame_id": self.source_frame_id,
|
||||
"acquisition_id": self.acquisition_id,
|
||||
"sync_group_id": self.sync_group_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping) -> FrameIdentity:
|
||||
allowed = frozenset(
|
||||
{
|
||||
"sensor_id",
|
||||
"session_id",
|
||||
"frame_id",
|
||||
"source_frame_id",
|
||||
"acquisition_id",
|
||||
"sync_group_id",
|
||||
}
|
||||
)
|
||||
required = frozenset({"sensor_id", "session_id", "frame_id", "source_frame_id"})
|
||||
fields = _mapping(data, "FrameIdentity", allowed, required)
|
||||
return cls(
|
||||
sensor_id=fields["sensor_id"],
|
||||
session_id=fields["session_id"],
|
||||
frame_id=fields["frame_id"],
|
||||
source_frame_id=fields["source_frame_id"],
|
||||
acquisition_id=fields.get("acquisition_id"),
|
||||
sync_group_id=fields.get("sync_group_id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameClock:
|
||||
"""Capture time of one frame, with its clock domain and time basis.
|
||||
|
||||
``capture_time_ns`` is always nanoseconds, but what it means is declared by
|
||||
``capture_time_basis``; ``clock_id`` names the clock domain (for example
|
||||
``host_monotonic`` or a source/video clock identity). ``source_pts`` keeps
|
||||
the source's own presentation timestamp untouched (its unit is the source's
|
||||
declared timebase); it is never replaced by processing time or by an
|
||||
estimated value. A result's *published* time is recorded separately by the
|
||||
result envelope, not here.
|
||||
"""
|
||||
|
||||
capture_time_ns: int
|
||||
clock_id: str
|
||||
capture_time_basis: TimeBasis
|
||||
source_pts: int | float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_int(self.capture_time_ns, "capture_time_ns", minimum=0)
|
||||
_require_str(self.clock_id, "clock_id")
|
||||
_require_time_basis(self.capture_time_basis)
|
||||
if self.source_pts is not None:
|
||||
_require_finite_number(self.source_pts, "source_pts")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"capture_time_ns": self.capture_time_ns,
|
||||
"clock_id": self.clock_id,
|
||||
"capture_time_basis": self.capture_time_basis,
|
||||
"source_pts": self.source_pts,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping) -> FrameClock:
|
||||
allowed = frozenset(
|
||||
{"capture_time_ns", "clock_id", "capture_time_basis", "source_pts"}
|
||||
)
|
||||
required = frozenset({"capture_time_ns", "clock_id", "capture_time_basis"})
|
||||
fields = _mapping(data, "FrameClock", allowed, required)
|
||||
return cls(
|
||||
capture_time_ns=fields["capture_time_ns"],
|
||||
clock_id=fields["clock_id"],
|
||||
capture_time_basis=fields["capture_time_basis"],
|
||||
source_pts=fields.get("source_pts"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameStamp:
|
||||
"""Per-stream frame identity plus capture clock; the shared frame header.
|
||||
|
||||
The flat fields are the serialized contract. ``identity`` and ``clock``
|
||||
expose the two components for code that wants to carry them independently.
|
||||
"""
|
||||
|
||||
sensor_id: str
|
||||
session_id: str
|
||||
frame_id: int
|
||||
source_frame_id: int | None
|
||||
capture_time_ns: int
|
||||
clock_id: str
|
||||
capture_time_basis: TimeBasis
|
||||
source_pts: int | float | None = None
|
||||
acquisition_id: str | None = None
|
||||
sync_group_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Validate through the components so every frame header obeys the same
|
||||
# rules as a separately supplied identity/clock pair.
|
||||
self.identity
|
||||
self.clock
|
||||
|
||||
@classmethod
|
||||
def from_parts(cls, identity: FrameIdentity, clock: FrameClock) -> FrameStamp:
|
||||
"""Build a stamp from its two component types."""
|
||||
if not isinstance(identity, FrameIdentity):
|
||||
raise TypeError("identity must be a FrameIdentity")
|
||||
if not isinstance(clock, FrameClock):
|
||||
raise TypeError("clock must be a FrameClock")
|
||||
return cls(
|
||||
sensor_id=identity.sensor_id,
|
||||
session_id=identity.session_id,
|
||||
frame_id=identity.frame_id,
|
||||
source_frame_id=identity.source_frame_id,
|
||||
capture_time_ns=clock.capture_time_ns,
|
||||
clock_id=clock.clock_id,
|
||||
capture_time_basis=clock.capture_time_basis,
|
||||
source_pts=clock.source_pts,
|
||||
acquisition_id=identity.acquisition_id,
|
||||
sync_group_id=identity.sync_group_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def identity(self) -> FrameIdentity:
|
||||
return FrameIdentity(
|
||||
sensor_id=self.sensor_id,
|
||||
session_id=self.session_id,
|
||||
frame_id=self.frame_id,
|
||||
source_frame_id=self.source_frame_id,
|
||||
acquisition_id=self.acquisition_id,
|
||||
sync_group_id=self.sync_group_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def clock(self) -> FrameClock:
|
||||
return FrameClock(
|
||||
capture_time_ns=self.capture_time_ns,
|
||||
clock_id=self.clock_id,
|
||||
capture_time_basis=self.capture_time_basis,
|
||||
source_pts=self.source_pts,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": STREAM_CONTRACT_VERSION,
|
||||
**self.identity.to_dict(),
|
||||
**self.clock.to_dict(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping) -> FrameStamp:
|
||||
allowed = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"sensor_id",
|
||||
"session_id",
|
||||
"frame_id",
|
||||
"source_frame_id",
|
||||
"capture_time_ns",
|
||||
"clock_id",
|
||||
"capture_time_basis",
|
||||
"source_pts",
|
||||
"acquisition_id",
|
||||
"sync_group_id",
|
||||
}
|
||||
)
|
||||
required = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"sensor_id",
|
||||
"session_id",
|
||||
"frame_id",
|
||||
"source_frame_id",
|
||||
"capture_time_ns",
|
||||
"clock_id",
|
||||
"capture_time_basis",
|
||||
}
|
||||
)
|
||||
fields = _mapping(data, "FrameStamp", allowed, required)
|
||||
_require_schema(fields["schema"], "FrameStamp")
|
||||
return cls(
|
||||
sensor_id=fields["sensor_id"],
|
||||
session_id=fields["session_id"],
|
||||
frame_id=fields["frame_id"],
|
||||
source_frame_id=fields["source_frame_id"],
|
||||
capture_time_ns=fields["capture_time_ns"],
|
||||
clock_id=fields["clock_id"],
|
||||
capture_time_basis=fields["capture_time_basis"],
|
||||
source_pts=fields.get("source_pts"),
|
||||
acquisition_id=fields.get("acquisition_id"),
|
||||
sync_group_id=fields.get("sync_group_id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeasurementFrame:
|
||||
"""Minimal measurement header handed from the image block to force session.
|
||||
|
||||
``reference_generation`` is the optical-flow reference version that was in
|
||||
effect when this frame's flow was computed (the image thread writes it; it
|
||||
is the ``FrameSnapshot.tracker_reset_seq``/``request_reset`` target concept
|
||||
from stage 0). ``source_dt_s`` is the source-time distance to the previous
|
||||
*actually processed* frame of this stream, or ``None`` for the first one.
|
||||
A long interval is data for a quality marker and a warning; it never resets
|
||||
a reference, filter or zero by itself.
|
||||
|
||||
The measurement payload (flow/HHD/field/contact) and the force result are
|
||||
defined by T05/T06 and the result envelope; this header is the part frozen
|
||||
now.
|
||||
"""
|
||||
|
||||
stamp: FrameStamp
|
||||
source_dt_s: float | None
|
||||
reference_generation: int = 0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.stamp, FrameStamp):
|
||||
raise TypeError("stamp must be a FrameStamp")
|
||||
_require_source_dt(self.source_dt_s)
|
||||
_require_int(self.reference_generation, "reference_generation", minimum=0)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": STREAM_CONTRACT_VERSION,
|
||||
"stamp": self.stamp.to_dict(),
|
||||
"source_dt_s": None
|
||||
if self.source_dt_s is None
|
||||
else float(self.source_dt_s),
|
||||
"reference_generation": self.reference_generation,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping) -> MeasurementFrame:
|
||||
allowed = frozenset({"schema", "stamp", "source_dt_s", "reference_generation"})
|
||||
required = frozenset({"schema", "stamp", "source_dt_s"})
|
||||
fields = _mapping(data, "MeasurementFrame", allowed, required)
|
||||
_require_schema(fields["schema"], "MeasurementFrame")
|
||||
return cls(
|
||||
stamp=FrameStamp.from_dict(fields["stamp"]),
|
||||
source_dt_s=fields["source_dt_s"],
|
||||
reference_generation=fields.get("reference_generation", 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamEvent:
|
||||
"""One serializable event of a stream's lifecycle.
|
||||
|
||||
``kind`` is a stable lower_snake identifier matching
|
||||
``^[a-z][a-z0-9_]*$`` (for example ``reference_reset``,
|
||||
``zero_requested``, ``source_gap``); consumers must ignore kinds they do not
|
||||
know instead of failing, so new kinds are additive. ``stamp`` is the frame
|
||||
the event belongs to when there is one. ``payload`` must be JSON-safe and is
|
||||
frozen/validated on construction; the frozen payload is excluded from the
|
||||
hash so equal events hash equally while ``hash(event)`` stays usable.
|
||||
``event_version`` versions the envelope itself (``STREAM_CONTRACT_VERSION``
|
||||
versions the field contract).
|
||||
"""
|
||||
|
||||
kind: str
|
||||
stamp: FrameStamp | None = None
|
||||
payload: Mapping[str, Any] = field(default_factory=dict, hash=False)
|
||||
event_version: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_str(self.kind, "kind")
|
||||
if _EVENT_KIND_RE.fullmatch(self.kind) is None:
|
||||
raise ValueError(f"kind must match ^[a-z][a-z0-9_]*$, got {self.kind!r}")
|
||||
if self.stamp is not None and not isinstance(self.stamp, FrameStamp):
|
||||
raise TypeError("stamp must be a FrameStamp or None")
|
||||
_require_int(self.event_version, "event_version", minimum=1)
|
||||
if not isinstance(self.payload, Mapping):
|
||||
raise TypeError("payload must be a mapping")
|
||||
object.__setattr__(self, "payload", _freeze_json(self.payload, "payload"))
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": STREAM_CONTRACT_VERSION,
|
||||
"kind": self.kind,
|
||||
"event_version": self.event_version,
|
||||
"stamp": None if self.stamp is None else self.stamp.to_dict(),
|
||||
"payload": _thaw_json(self.payload),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping) -> StreamEvent:
|
||||
allowed = frozenset({"schema", "kind", "event_version", "stamp", "payload"})
|
||||
required = frozenset({"schema", "kind"})
|
||||
fields = _mapping(data, "StreamEvent", allowed, required)
|
||||
_require_schema(fields["schema"], "StreamEvent")
|
||||
stamp = fields.get("stamp")
|
||||
return cls(
|
||||
kind=fields["kind"],
|
||||
stamp=None if stamp is None else FrameStamp.from_dict(stamp),
|
||||
payload=fields.get("payload") or {},
|
||||
event_version=fields.get("event_version", 1),
|
||||
)
|
||||
Reference in New Issue
Block a user