Check the four integration nodes under strict mypy

mqtt, http, influx and delay carried the prototype's annotations and were
excluded wholesale. Annotating them leaves nothing to exclude: what the two
untyped dependencies need is an implicit-reexport allowance for
influxdb_client and the import-site ignore croniter already gets elsewhere.

InfluxDbNode.inject now takes the durable flag its supertype passes, and the
cron loop reads its stop event once instead of dereferencing a field that
stop_cron() may have cleared.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 16:46:15 +02:00
co-authored by Claude Fable 5
parent 143395358a
commit 32b35255d0
5 changed files with 49 additions and 37 deletions
+11 -9
View File
@@ -106,7 +106,7 @@ class DelayNode(Node):
mapping: dict[str, str] = {} mapping: dict[str, str] = {}
cron: str | None = None cron: str | None = None
def __init__(self, params: dict[str, Any] | None = None, **kwargs): def __init__(self, params: dict[str, Any] | None = None, **kwargs: Any) -> None:
cfg = self.Params.model_validate(params or {}) cfg = self.Params.model_validate(params or {})
self.delay = cfg.delay self.delay = cfg.delay
self.interval = cfg.interval self.interval = cfg.interval
@@ -114,8 +114,8 @@ class DelayNode(Node):
self.cron_expr = cfg.cron self.cron_expr = cfg.cron
self.ts = 0.0 self.ts = 0.0
self.last_input: dict[str, Any] = {} self.last_input: dict[str, Any] = {}
self._cron_task = None self._cron_task: asyncio.Task[None] | None = None
self._stop_cron = None self._stop_cron: asyncio.Event | None = None
super().__init__(f=self._f, params=params, **kwargs) super().__init__(f=self._f, params=params, **kwargs)
@@ -128,7 +128,7 @@ class DelayNode(Node):
for i, o in zip(self.input_ports, self.output_ports, strict=False) for i, o in zip(self.input_ports, self.output_ports, strict=False)
] ]
def _f(self, params, **kwargs): def _f(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None:
"""Forward messages with optional delay, rate-limiting, and alarm.""" """Forward messages with optional delay, rate-limiting, and alarm."""
logger.info("[%s] Received %s", self.name, kwargs) logger.info("[%s] Received %s", self.name, kwargs)
@@ -220,12 +220,12 @@ class DelayNode(Node):
self._stop_cron = None self._stop_cron = None
logger.info("Stopped cron for node '%s'", self.name) logger.info("Stopped cron for node '%s'", self.name)
async def _cron_loop(self): async def _cron_loop(self) -> None:
"""Background loop that sleeps until the next cron tick and triggers.""" """Background loop that sleeps until the next cron tick and triggers."""
from datetime import datetime from datetime import datetime
try: try:
from croniter import croniter from croniter import croniter # type: ignore[import-untyped]
except ImportError: except ImportError:
logger.error( logger.error(
"croniter package is required for cron scheduling. " "croniter package is required for cron scheduling. "
@@ -243,7 +243,9 @@ class DelayNode(Node):
cron = croniter(self.cron_expr, datetime.now()) cron = croniter(self.cron_expr, datetime.now())
while not (self._stop_cron and self._stop_cron.is_set()): # Walrus so the body can use the event without re-reading a field
# stop_cron() may have cleared in the meantime.
while (stop := self._stop_cron) is not None and not stop.is_set():
try: try:
# Compute seconds until next tick # Compute seconds until next tick
next_dt = cron.get_next(datetime) next_dt = cron.get_next(datetime)
@@ -259,7 +261,7 @@ class DelayNode(Node):
# Sleep until next tick (wake up on stop signal) # Sleep until next tick (wake up on stop signal)
try: try:
await asyncio.wait_for(self._stop_cron.wait(), timeout=wait_seconds) await asyncio.wait_for(stop.wait(), timeout=wait_seconds)
# If we get here, stop was requested # If we get here, stop was requested
break break
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -283,7 +285,7 @@ class DelayNode(Node):
) )
raise raise
async def _trigger_cron(self): async def _trigger_cron(self) -> None:
"""Emit data into the pipeline on a cron tick.""" """Emit data into the pipeline on a cron tick."""
if self._pipeline is None: if self._pipeline is None:
logger.warning( logger.warning(
+5 -2
View File
@@ -204,7 +204,7 @@ class HttpNode(Node):
) )
@staticmethod @staticmethod
def _noop_trigger(params: dict, **kwargs) -> dict | None: def _noop_trigger(params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None:
""" """
No-op function for trigger mode nodes. No-op function for trigger mode nodes.
@@ -213,7 +213,9 @@ class HttpNode(Node):
""" """
return None return None
def _sender_handler(self, params: dict, **kwargs) -> dict | None: def _sender_handler(
self, params: dict[str, Any], **kwargs: Any
) -> dict[str, Any] | None:
""" """
Send HTTP request with pipeline data (sender mode). Send HTTP request with pipeline data (sender mode).
@@ -329,6 +331,7 @@ class HttpNode(Node):
try: try:
# Parse request data # Parse request data
data: dict[str, Any]
if self.method == "GET": if self.method == "GET":
data = dict(request.query_params) data = dict(request.query_params)
else: # POST else: # POST
+10 -8
View File
@@ -9,7 +9,7 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.flow.messages import MessageSpec from app.flow.messages import MessageSpec
from app.flow.nodes.base import Node from app.flow.nodes.base import Node, NodeResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -200,7 +200,7 @@ class InfluxDbNode(Node):
name=name, name=name,
) )
def _handler(self, params: dict, **kwargs) -> dict | None: def _handler(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None:
""" """
Handle incoming data - write to InfluxDB and optionally query. Handle incoming data - write to InfluxDB and optionally query.
@@ -224,7 +224,7 @@ class InfluxDbNode(Node):
return None return None
def _write_points(self, data: dict) -> None: def _write_points(self, data: dict[str, Any]) -> None:
""" """
Write data points to InfluxDB using configuration from ``writes``. Write data points to InfluxDB using configuration from ``writes``.
@@ -327,7 +327,7 @@ class InfluxDbNode(Node):
logger.error("InfluxDB write error in node '%s': %s", self.name, e) logger.error("InfluxDB write error in node '%s': %s", self.name, e)
raise raise
def _query_data(self) -> dict: def _query_data(self) -> dict[str, Any]:
""" """
Query data from InfluxDB based on provides configuration. Query data from InfluxDB based on provides configuration.
@@ -399,7 +399,7 @@ class InfluxDbNode(Node):
self, self,
measurement: str, measurement: str,
field: str, field: str,
tags: dict, tags: dict[str, Any],
time_range: str, time_range: str,
aggregation: str, aggregation: str,
) -> str: ) -> str:
@@ -452,7 +452,7 @@ class InfluxDbNode(Node):
return "\n".join(query_parts) return "\n".join(query_parts)
def _extract_query_result(self, tables, spec: MessageSpec) -> Any: def _extract_query_result(self, tables: Any, spec: MessageSpec) -> Any:
""" """
Extract a single value from query result tables. Extract a single value from query result tables.
@@ -473,7 +473,9 @@ class InfluxDbNode(Node):
return None return None
def inject(self, outputs: dict | None = None) -> dict | None: def inject(
self, outputs: dict[str, Any] | None = None, durable: bool | None = None
) -> NodeResult:
""" """
Inject queried data into the pipeline. Inject queried data into the pipeline.
@@ -495,4 +497,4 @@ class InfluxDbNode(Node):
return None return None
outputs = self._query_data() outputs = self._query_data()
return self._pipeline.trigger(self, self._to_messages(outputs)) return self._pipeline.trigger(self, self._to_messages(outputs), durable=durable)
+14 -8
View File
@@ -190,11 +190,11 @@ class MqttNode(Node):
self.keepalive = cfg.keepalive self.keepalive = cfg.keepalive
# Runtime state # Runtime state
self._subscription_task: asyncio.Task | None = None self._subscription_task: asyncio.Task[None] | None = None
self._mqtt_client = None self._mqtt_client = None
self._stop_event: asyncio.Event | None = None self._stop_event: asyncio.Event | None = None
self._publish_queue: asyncio.Queue[dict] | None = None self._publish_queue: asyncio.Queue[dict[str, Any]] | None = None
self._publisher_task: asyncio.Task | None = None self._publisher_task: asyncio.Task[None] | None = None
self._loop: asyncio.AbstractEventLoop | None = None self._loop: asyncio.AbstractEventLoop | None = None
# Set default name based on mode and topics # Set default name based on mode and topics
@@ -228,7 +228,9 @@ class MqttNode(Node):
) )
@staticmethod @staticmethod
def _noop_subscriber(params: dict, **kwargs) -> dict | None: def _noop_subscriber(
params: dict[str, Any], **kwargs: Any
) -> dict[str, Any] | None:
""" """
No-op function for subscriber mode nodes. No-op function for subscriber mode nodes.
@@ -245,7 +247,9 @@ class MqttNode(Node):
""" """
return None return None
def _publisher_handler(self, params: dict, **kwargs) -> dict | None: def _publisher_handler(
self, params: dict[str, Any], **kwargs: Any
) -> dict[str, Any] | None:
""" """
Publish pipeline data to MQTT topic (publisher mode). Publish pipeline data to MQTT topic (publisher mode).
@@ -277,7 +281,9 @@ class MqttNode(Node):
) )
return None return None
def _enqueue(self, queue: asyncio.Queue[dict], data: dict) -> None: def _enqueue(
self, queue: asyncio.Queue[dict[str, Any]], data: dict[str, Any]
) -> None:
"""Queue a payload, dropping the oldest when the broker cannot keep up.""" """Queue a payload, dropping the oldest when the broker cannot keep up."""
if queue.full(): if queue.full():
try: try:
@@ -317,7 +323,7 @@ class MqttNode(Node):
self.report_health("down", str(exc)) self.report_health("down", str(exc))
raise raise
async def _publish_once(self, data: dict) -> None: async def _publish_once(self, data: dict[str, Any]) -> None:
"""Connect, publish, disconnect — the unstarted node's path.""" """Connect, publish, disconnect — the unstarted node's path."""
import aiomqtt import aiomqtt
@@ -331,7 +337,7 @@ class MqttNode(Node):
) as client: ) as client:
await self._publish_with(client, data) await self._publish_with(client, data)
async def _publish_with(self, client: Any, data: dict) -> None: async def _publish_with(self, client: Any, data: dict[str, Any]) -> None:
""" """
Publish messages to their mapped MQTT topics. Publish messages to their mapped MQTT topics.
+9 -10
View File
@@ -46,18 +46,17 @@ build-backend = "hatchling.build"
[tool.mypy] [tool.mypy]
strict = true strict = true
exclude = ["venv", ".venv", "alembic"] exclude = ["venv", ".venv", "alembic"]
# influxdb_client's Point builder carries no annotations; calling it is not a
# reason to stop checking the caller (app/flow/nodes/influx.py).
untyped_calls_exclude = ["influxdb_client"]
# The integration nodes still carry the prototype's annotations, and croniter and # Every module is checked strictly. influxdb_client is typed but re-exports its
# influxdb_client ship no stubs. Shrinking one module at a time as each integration # names implicitly, which strict mode refuses to follow. (croniter, the other
# is revisited; the rest of the package, and everything around it, is checked strictly. # untyped dependency, is ignored at its two import sites.)
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = [ module = ["influxdb_client"]
"app.flow.nodes.delay", implicit_reexport = true
"app.flow.nodes.http",
"app.flow.nodes.influx",
"app.flow.nodes.mqtt",
]
ignore_errors = true
[tool.ruff] [tool.ruff]
target-version = "py310" target-version = "py310"