"""The connector contract: polling, deduplication, health and discovery.""" import asyncio from typing import Any from fluksio.flow.connector import CONTRACT_VERSION, ConnectorNode from fluksio.flow.controller import NODE_TYPES from fluksio.flow.messages import DType, MessageSpec from fluksio.flow.nodes import Node from fluksio.flow.pipeline import Pipeline from fluksio.flow.plugins import load_plugins class Sensor(ConnectorNode): contract = CONTRACT_VERSION title = "Test sensor" description = "Reads whatever it is told to." class Params(ConnectorNode.Params): secret_token: str | None = None def __init__(self, readings: list[Any] | None = None, **kwargs: Any) -> None: super().__init__(**kwargs) self._readings = list(readings or []) self.polls = 0 async def poll(self) -> dict[str, Any] | None: self.polls += 1 if not self._readings: return None value = self._readings.pop(0) if isinstance(value, Exception): raise value return {"reading": value} def a_sensor(readings: list[Any], **params: Any) -> Sensor: node = Sensor( readings=readings, provides=[MessageSpec(name="reading", dtype=DType.FLOAT)], params={"poll_interval": 0.01, **params}, ) node.assign_flow("demo", "sensor") return node def run_briefly(node: ConnectorNode, seconds: float = 0.12) -> None: """Start the poll loop, let it tick a few times, stop it.""" async def cycle() -> None: await node.start() await asyncio.sleep(seconds) await node.stop() asyncio.run(cycle()) def test_polling_publishes_what_it_reads(): node = a_sensor([21.5]) pipeline = Pipeline(nodes=[node]) run_briefly(node) assert pipeline.state["demo.reading"] == 21.5 def test_an_unchanged_reading_is_not_republished(): node = a_sensor([21.5, 21.5, 21.5]) consumer_ran: list[float] = [] def consume(reading, params): consumer_ran.append(reading) return None consumer = Node( f=consume, requires=[MessageSpec(name="reading", dtype=DType.FLOAT)], name="consumer", ) consumer.assign_flow("demo", "consumer") Pipeline(nodes=[node, consumer]) run_briefly(node) # Polled repeatedly, but the value never changed, so downstream ran once. assert node.polls > 1 assert consumer_ran == [21.5] def test_a_failing_poll_reports_down_and_keeps_going(): health: list[tuple[str, str | None]] = [] node = a_sensor([RuntimeError("device unplugged"), 21.5]) node._on_health = lambda _node, status, detail: health.append((status, detail)) Pipeline(nodes=[node]) run_briefly(node) assert ("down", "RuntimeError: device unplugged") in health # It recovered rather than giving up. assert health[-1][0] == "ok" class Actuator(ConnectorNode): """A connector that commands something instead of reading it.""" contract = CONTRACT_VERSION title = "Test actuator" def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self.commands: list[dict[str, Any]] = [] def write(self, **ports: Any) -> None: self.commands.append(ports) return None def test_an_incoming_message_reaches_a_connector_that_writes(): node = Actuator(requires=[MessageSpec(name="level", dtype=DType.INT)]) node.assign_flow("demo", "actuator") assert node.execute({"demo.level": 255}) is None assert node.commands == [{"level": 255}] def test_a_read_only_connector_ignores_what_reaches_it(): node = a_sensor([]) assert node.execute({}) is None def test_a_credential_param_is_marked_for_the_editor(): schema = Sensor.Params.model_json_schema() assert schema["properties"]["poll_interval"]["default"] == 0 assert "secret_token" in schema["properties"] def test_a_connector_is_discovered_from_its_entry_point(monkeypatch): class FakeDist: name = "fluksio-connector-test" version = "0.1.0" class FakeEntry: name = "test_sensor" dist = FakeDist() def load(self): return Sensor monkeypatch.setattr("fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()]) try: assert load_plugins() == ["test_sensor"] assert NODE_TYPES["test_sensor"].plugin == "fluksio-connector-test 0.1.0" assert NODE_TYPES["test_sensor"].title == "Test sensor" finally: NODE_TYPES.pop("test_sensor", None) def test_a_connector_written_for_another_contract_is_refused(monkeypatch): class Outdated(ConnectorNode): contract = CONTRACT_VERSION + 1 class FakeEntry: name = "outdated" dist = None def load(self): return Outdated monkeypatch.setattr("fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()]) assert load_plugins() == [] assert "outdated" not in NODE_TYPES def test_a_connector_may_not_take_over_a_built_in_type(monkeypatch): class FakeEntry: name = "mqtt" dist = None def load(self): # pragma: no cover - never reached raise AssertionError("should not be loaded") monkeypatch.setattr("fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()]) assert load_plugins() == [] assert NODE_TYPES["mqtt"].plugin is None