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] = {}
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 {})
self.delay = cfg.delay
self.interval = cfg.interval
@@ -114,8 +114,8 @@ class DelayNode(Node):
self.cron_expr = cfg.cron
self.ts = 0.0
self.last_input: dict[str, Any] = {}
self._cron_task = None
self._stop_cron = None
self._cron_task: asyncio.Task[None] | None = None
self._stop_cron: asyncio.Event | None = None
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)
]
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."""
logger.info("[%s] Received %s", self.name, kwargs)
@@ -220,12 +220,12 @@ class DelayNode(Node):
self._stop_cron = None
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."""
from datetime import datetime
try:
from croniter import croniter
from croniter import croniter # type: ignore[import-untyped]
except ImportError:
logger.error(
"croniter package is required for cron scheduling. "
@@ -243,7 +243,9 @@ class DelayNode(Node):
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:
# Compute seconds until next tick
next_dt = cron.get_next(datetime)
@@ -259,7 +261,7 @@ class DelayNode(Node):
# Sleep until next tick (wake up on stop signal)
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
break
except asyncio.TimeoutError:
@@ -283,7 +285,7 @@ class DelayNode(Node):
)
raise
async def _trigger_cron(self):
async def _trigger_cron(self) -> None:
"""Emit data into the pipeline on a cron tick."""
if self._pipeline is None:
logger.warning(
+5 -2
View File
@@ -204,7 +204,7 @@ class HttpNode(Node):
)
@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.
@@ -213,7 +213,9 @@ class HttpNode(Node):
"""
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).
@@ -329,6 +331,7 @@ class HttpNode(Node):
try:
# Parse request data
data: dict[str, Any]
if self.method == "GET":
data = dict(request.query_params)
else: # POST
+10 -8
View File
@@ -9,7 +9,7 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
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__)
@@ -200,7 +200,7 @@ class InfluxDbNode(Node):
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.
@@ -224,7 +224,7 @@ class InfluxDbNode(Node):
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``.
@@ -327,7 +327,7 @@ class InfluxDbNode(Node):
logger.error("InfluxDB write error in node '%s': %s", self.name, e)
raise
def _query_data(self) -> dict:
def _query_data(self) -> dict[str, Any]:
"""
Query data from InfluxDB based on provides configuration.
@@ -399,7 +399,7 @@ class InfluxDbNode(Node):
self,
measurement: str,
field: str,
tags: dict,
tags: dict[str, Any],
time_range: str,
aggregation: str,
) -> str:
@@ -452,7 +452,7 @@ class InfluxDbNode(Node):
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.
@@ -473,7 +473,9 @@ class InfluxDbNode(Node):
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.
@@ -495,4 +497,4 @@ class InfluxDbNode(Node):
return None
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
# Runtime state
self._subscription_task: asyncio.Task | None = None
self._subscription_task: asyncio.Task[None] | None = None
self._mqtt_client = None
self._stop_event: asyncio.Event | None = None
self._publish_queue: asyncio.Queue[dict] | None = None
self._publisher_task: asyncio.Task | None = None
self._publish_queue: asyncio.Queue[dict[str, Any]] | None = None
self._publisher_task: asyncio.Task[None] | None = None
self._loop: asyncio.AbstractEventLoop | None = None
# Set default name based on mode and topics
@@ -228,7 +228,9 @@ class MqttNode(Node):
)
@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.
@@ -245,7 +247,9 @@ class MqttNode(Node):
"""
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).
@@ -277,7 +281,9 @@ class MqttNode(Node):
)
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."""
if queue.full():
try:
@@ -317,7 +323,7 @@ class MqttNode(Node):
self.report_health("down", str(exc))
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."""
import aiomqtt
@@ -331,7 +337,7 @@ class MqttNode(Node):
) as client:
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.
+9 -10
View File
@@ -46,18 +46,17 @@ build-backend = "hatchling.build"
[tool.mypy]
strict = true
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
# influxdb_client ship no stubs. Shrinking one module at a time as each integration
# is revisited; the rest of the package, and everything around it, is checked strictly.
# Every module is checked strictly. influxdb_client is typed but re-exports its
# names implicitly, which strict mode refuses to follow. (croniter, the other
# untyped dependency, is ignored at its two import sites.)
[[tool.mypy.overrides]]
module = [
"app.flow.nodes.delay",
"app.flow.nodes.http",
"app.flow.nodes.influx",
"app.flow.nodes.mqtt",
]
ignore_errors = true
module = ["influxdb_client"]
implicit_reexport = true
[tool.ruff]
target-version = "py310"