Four things the python SDK turned up, each fixed where every client sees it. A key no port declares is now an error rather than a silent drop, on the return, the yield and the emit alike — the contract the docs already stated. The SDK reads literal yields at sync time, so a typo fails before anything runs, and an emission of one fails the call rather than being logged where nobody looks. NaN and infinity are refused at the port. JSON cannot spell either, so one that travelled came back as a 500, a socket frame that stopped the canvas, or a metric batch the database dropped whole. An artifact input takes `@run:<id>.<output>` or a bare digest, resolved on the engine — so the CLI, the run dialog and a python caller mean the same thing, and a sweep can pass one at all. Node timeouts are off by default. The clock measured silence, which a training node is full of, and remote workers had already stopped enforcing it — their heartbeat reset it. Now a heartbeat proves the agent rather than the node, ninety seconds of nothing fails the call either way, and the engine touches work it is still running so a long node is not redelivered at sixty seconds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019V5bsYGNxcgPs4xXmTPx69
155 lines
5.1 KiB
Python
155 lines
5.1 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from fluksio.flow.messages import DType, MessageSpec, flow_of, qualify
|
|
|
|
|
|
def test_port_defaults_to_last_name_segment():
|
|
assert MessageSpec(name="temperature").port == "temperature"
|
|
assert MessageSpec(name="heating.temperature").port == "temperature"
|
|
assert MessageSpec(name="heating.temperature", port="t").port == "t"
|
|
|
|
|
|
def test_int_rejects_bool():
|
|
# bool is a subclass of int, but a flag is not a number here.
|
|
spec = MessageSpec(name="count", dtype=DType.INT)
|
|
spec.check(3)
|
|
with pytest.raises(TypeError):
|
|
spec.check(True)
|
|
|
|
|
|
def test_float_accepts_int_but_not_bool():
|
|
spec = MessageSpec(name="temp", dtype=DType.FLOAT)
|
|
spec.check(21)
|
|
spec.check(21.5)
|
|
with pytest.raises(TypeError):
|
|
spec.check(True)
|
|
with pytest.raises(TypeError):
|
|
spec.check("21")
|
|
|
|
|
|
def test_json_dtype_round_trips():
|
|
spec = MessageSpec(name="payload", dtype=DType.JSON)
|
|
value = {"a": [1, 2], "b": None}
|
|
spec.check(value)
|
|
assert json.loads(json.dumps(value)) == value
|
|
|
|
|
|
def test_record_takes_flat_scalars_only():
|
|
spec = MessageSpec(name="notice", dtype=DType.RECORD)
|
|
spec.check({"title": "Boiler", "count": 3, "hot": True, "detail": None})
|
|
for wrong in ({"a": {"nested": 1}}, {"a": [1]}, [], "x"):
|
|
with pytest.raises(TypeError):
|
|
spec.check(wrong)
|
|
|
|
|
|
def test_series_carries_lines_and_whatever_else_the_answer_echoes():
|
|
spec = MessageSpec(name="temps", dtype=DType.SERIES)
|
|
spec.check(
|
|
{
|
|
"range_s": 3600,
|
|
"interval_s": 60,
|
|
"lines": [{"label": "living", "points": [[1.0, 21.5], [2.0, 21.6]]}],
|
|
}
|
|
)
|
|
spec.check({"lines": []})
|
|
for wrong in (
|
|
{"lines": [{"label": "a", "points": [[1.0, 2.0, 3.0]]}]},
|
|
{"lines": [{"label": "a", "points": [["1", 2.0]]}]},
|
|
{"lines": [{"label": 1, "points": []}]},
|
|
{"lines": {}},
|
|
{},
|
|
):
|
|
with pytest.raises(TypeError):
|
|
spec.check(wrong)
|
|
|
|
|
|
def test_list_items_follow_the_declared_shape():
|
|
records = MessageSpec(name="agenda", dtype=DType.LIST)
|
|
records.check([{"title": "Dentist", "ts": 1.0}])
|
|
records.check([])
|
|
with pytest.raises(TypeError):
|
|
records.check([1.0])
|
|
|
|
numbers = MessageSpec(name="window", dtype=DType.LIST, item=DType.FLOAT)
|
|
numbers.check([21.4, 2])
|
|
for wrong in ([True], ["1"], 1.0):
|
|
with pytest.raises(TypeError):
|
|
numbers.check(wrong)
|
|
|
|
|
|
def test_the_failing_item_is_named():
|
|
spec = MessageSpec(name="window", dtype=DType.LIST, item=DType.FLOAT)
|
|
with pytest.raises(TypeError, match="index 1"):
|
|
spec.check([1.0, "x"])
|
|
|
|
|
|
def test_a_list_holds_one_declared_level():
|
|
for item in (DType.LIST, DType.SERIES):
|
|
with pytest.raises(ValueError):
|
|
MessageSpec(name="x", dtype=DType.LIST, item=item)
|
|
|
|
|
|
def test_coerce_parses_structured_text():
|
|
spec = MessageSpec(name="notice", dtype=DType.RECORD)
|
|
assert spec.coerce('{"title": "Boiler"}') == {"title": "Boiler"}
|
|
assert spec.coerce({"title": "Boiler"}) == {"title": "Boiler"}
|
|
|
|
|
|
def test_coerce_from_text():
|
|
assert MessageSpec(name="a", dtype=DType.FLOAT).coerce("2.5") == 2.5
|
|
assert MessageSpec(name="a", dtype=DType.INT).coerce("7") == 7
|
|
assert MessageSpec(name="a", dtype=DType.BOOL).coerce("yes") is True
|
|
assert MessageSpec(name="a", dtype=DType.BOOL).coerce("0") is False
|
|
|
|
|
|
def test_spec_serializes():
|
|
spec = MessageSpec(name="heating.temp", dtype=DType.FLOAT)
|
|
assert MessageSpec.model_validate_json(spec.model_dump_json()) == spec
|
|
|
|
|
|
def test_qualify_scopes_bare_names_only():
|
|
assert qualify("heating", "temp") == "heating.temp"
|
|
assert qualify("heating", "solar.power") == "solar.power"
|
|
assert qualify("heating", "") == ""
|
|
assert flow_of("heating.temp") == "heating"
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# NaN and infinity
|
|
#
|
|
# JSON cannot spell either, so one travelling through a port would come back as
|
|
# a response nobody can parse, a socket frame that stops a canvas, or a row the
|
|
# database rejects — a long way from the node that produced it.
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_float_port_refuses_nan_and_infinity():
|
|
spec = MessageSpec(name="score", dtype=DType.FLOAT)
|
|
spec.check(0.5)
|
|
for value in (float("nan"), float("inf"), float("-inf")):
|
|
with pytest.raises(TypeError, match="score"):
|
|
spec.check(value)
|
|
|
|
|
|
def test_a_json_port_refuses_a_nan_nested_in_it():
|
|
spec = MessageSpec(name="report", dtype=DType.JSON)
|
|
spec.check({"groups": [{"mean": 1.0}]})
|
|
with pytest.raises(TypeError, match="JSON"):
|
|
spec.check({"groups": [{"mean": float("nan")}]})
|
|
|
|
|
|
def test_a_series_refuses_a_nan_point():
|
|
spec = MessageSpec(name="curve", dtype=DType.SERIES)
|
|
lines = [{"label": "loss", "points": [[1.0, float("nan")]]}]
|
|
with pytest.raises(TypeError):
|
|
spec.check({"lines": lines})
|
|
|
|
|
|
def test_a_value_that_refers_to_itself_does_not_hang_the_check():
|
|
spec = MessageSpec(name="report", dtype=DType.JSON)
|
|
loop: dict = {}
|
|
loop["self"] = loop
|
|
spec.check(loop)
|