Put the deployment-only dependencies behind a server extra

A data-science environment installing fluksio waited for lxml, aiohttp and
the rest of a connector stack it has nothing to talk to. Outbound mail,
error reporting and the MQTT and InfluxDB clients moved to
`fluksio[server]`, which the image installs; each import is guarded and
names the extra. `tenacity` had no import site at all and is gone.

23 fewer packages and the compiled ones among them — a bare `pip install
fluksio` still serves, runs every python node, and registers the mqtt and
influxdb node types, which only need the library when one is actually
built. sentry-sdk arrives anyway underneath `fastapi[standard]`; what
changed there is that nothing of ours requires it.

The dev environment keeps every extra: the suite exercises the connectors
and strict mypy checks their call sites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
This commit is contained in:
2026-08-29 14:06:50 +02:00
co-authored by Claude Opus 5
parent 743432205e
commit 22c682e505
11 changed files with 93 additions and 50 deletions
+22 -4
View File
@@ -14,6 +14,22 @@ from fluksio.flow.nodes.base import Node, NodeResult
logger = logging.getLogger(__name__)
def _influxdb() -> Any:
"""The client library, which is a `fluksio[server]` extra.
Imported per use rather than at module level, because the node type is
registered at boot and an installation with no InfluxDB behind it should
not have to carry the library to start.
"""
try:
import influxdb_client
except ImportError:
raise RuntimeError(
"the influxdb node needs the server extra: pip install 'fluksio[server]'"
) from None
return influxdb_client
class InfluxDbNode(Node):
"""
InfluxDB node for writing to and reading from InfluxDB.
@@ -290,7 +306,7 @@ class InfluxDbNode(Node):
:returns: ``{"rows": [...], **echo}``.
:rtype: dict
"""
from influxdb_client import InfluxDBClient
InfluxDBClient = _influxdb().InfluxDBClient
flux = str(request["flux"])
echo = {key: value for key, value in request.items() if key != "flux"}
@@ -338,8 +354,10 @@ class InfluxDbNode(Node):
- Dict with "value" and "tags" keys: Value written with merged tags
:type data: dict
"""
from influxdb_client import InfluxDBClient, Point, WritePrecision
from influxdb_client.client.write_api import SYNCHRONOUS
influxdb_client = _influxdb()
InfluxDBClient = influxdb_client.InfluxDBClient
Point, WritePrecision = influxdb_client.Point, influxdb_client.WritePrecision
SYNCHRONOUS = influxdb_client.client.write_api.SYNCHRONOUS
try:
with InfluxDBClient(
@@ -438,7 +456,7 @@ class InfluxDbNode(Node):
:returns: Dict of port name to queried value.
:rtype: dict
"""
from influxdb_client import InfluxDBClient
InfluxDBClient = _influxdb().InfluxDBClient
results = {}
+19 -3
View File
@@ -20,6 +20,22 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _aiomqtt() -> Any:
"""The client library, which is a `fluksio[server]` extra.
Imported per use rather than at module level, because the node type is
registered at boot and an installation that talks to no broker should not
have to carry the library to start.
"""
try:
import aiomqtt
except ImportError:
raise RuntimeError(
"the mqtt node needs the server extra: pip install 'fluksio[server]'"
) from None
return aiomqtt
def topic_matches(filter_: str, topic: str) -> bool:
"""Does an MQTT topic filter cover this topic?
@@ -382,7 +398,7 @@ class MqttNode(Node):
A dropped connection raises, and the supervisor decides when to
reconnect — the same arrangement the subscriber uses.
"""
import aiomqtt
aiomqtt = _aiomqtt()
queue = self._publish_queue
if queue is None:
@@ -408,7 +424,7 @@ class MqttNode(Node):
async def _publish_once(self, data: dict[str, Any]) -> None:
"""Connect, publish, disconnect — the unstarted node's path."""
import aiomqtt
aiomqtt = _aiomqtt()
async with aiomqtt.Client(
hostname=self.broker_host,
@@ -570,7 +586,7 @@ class MqttNode(Node):
"""
import json
import aiomqtt
aiomqtt = _aiomqtt()
if not (self._stop_event and self._stop_event.is_set()):
try:
+4 -1
View File
@@ -4,7 +4,6 @@ import logging
from collections.abc import AsyncIterator
from contextlib import AbstractAsyncContextManager, asynccontextmanager
import sentry_sdk
from fastapi import FastAPI, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
@@ -51,6 +50,10 @@ def custom_generate_unique_id(route: APIRoute) -> str:
if settings.SENTRY_DSN and settings.ENVIRONMENT != "local":
# Imported here rather than at the top: it is a `fluksio[server]` extra, so
# a pip install without one has no sentry to import — and no DSN either.
import sentry_sdk
# `enable_tracing` was removed in sentry-sdk 2.x; this is what it meant.
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), traces_sample_rate=1.0)
+5 -5
View File
@@ -485,14 +485,14 @@ def _params(definition: dict[str, Any], rest: list[str]) -> dict[str, Any]:
f"{', '.join(sorted(types)) or 'none'}){hint}"
)
params: dict[str, Any] = {}
for name, value in raw.items():
if value is True:
params[name] = True
for key, written in raw.items():
if isinstance(written, bool):
params[key] = written
continue
try:
params[name] = _coerce(value, types[name])
params[key] = _coerce(written, types[key])
except ValueError as exc:
raise SyncError(f"'{name}' takes {types[name]}: {exc}") from exc
raise SyncError(f"'{key}' takes {types[key]}: {exc}") from exc
return params
+6 -1
View File
@@ -4,7 +4,6 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import emails # type: ignore
import jwt
from jinja2 import Template
from jwt.exceptions import InvalidTokenError
@@ -37,6 +36,12 @@ def send_email(
html_content: str = "",
) -> None:
assert settings.emails_enabled, "no provided configuration for email variables"
try:
import emails # type: ignore
except ImportError:
raise RuntimeError(
"sending mail needs the server extra: pip install 'fluksio[server]'"
) from None
message = emails.Message(
subject=subject,
html=html_content,