Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""Exec: run a command and hand back what it said.
|
||||
|
||||
The command runs inside the backend container, not on the host. That matters
|
||||
when porting: a flow that read the host's journal or poked a host script needs
|
||||
either a mount or a small listener on the host side, not this node.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shlex
|
||||
import subprocess
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from fluksio.flow.messages import MessageSpec
|
||||
from fluksio.flow.nodes.base import Node
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExecNode(Node):
|
||||
"""Run a command, returning its output, error text and exit code."""
|
||||
|
||||
# Running a command twice is running it twice.
|
||||
idempotent = False
|
||||
|
||||
class Params(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
command: str = Field(description="The command to run.")
|
||||
append_payload: bool = Field(
|
||||
default=False,
|
||||
description="Add the incoming value to the command as one argument.",
|
||||
)
|
||||
timeout: float = Field(
|
||||
default=30.0, gt=0, description="Give up after this many seconds."
|
||||
)
|
||||
fail_on_error: bool = Field(
|
||||
default=False,
|
||||
description="Treat a non-zero exit as a node failure rather than output.",
|
||||
)
|
||||
|
||||
__slots__ = ("cfg",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
requires: MessageSpec | Iterable[MessageSpec] = (),
|
||||
provides: MessageSpec | Iterable[MessageSpec] = (),
|
||||
params: dict[str, Any] | None = None,
|
||||
name: str | None = None,
|
||||
):
|
||||
self.cfg = self.Params.model_validate(params or {})
|
||||
super().__init__(
|
||||
f=self._run,
|
||||
requires=requires,
|
||||
provides=provides,
|
||||
params=params,
|
||||
name=name or "exec",
|
||||
)
|
||||
|
||||
def _run(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None:
|
||||
argv = shlex.split(self.cfg.command)
|
||||
if not argv:
|
||||
raise ValueError("exec node has no command")
|
||||
if self.cfg.append_payload and kwargs:
|
||||
argv.append(str(next(iter(kwargs.values()))))
|
||||
|
||||
try:
|
||||
# No shell: the command is a list, so a value carrying a semicolon
|
||||
# is an argument rather than a second command.
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.cfg.timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise TimeoutError(
|
||||
f"'{argv[0]}' did not finish within {self.cfg.timeout}s"
|
||||
) from exc
|
||||
except FileNotFoundError as exc:
|
||||
raise FileNotFoundError(
|
||||
f"'{argv[0]}' is not available in this container"
|
||||
) from exc
|
||||
|
||||
if self.cfg.fail_on_error and completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"'{argv[0]}' exited {completed.returncode}: "
|
||||
f"{completed.stderr.strip()[:200]}"
|
||||
)
|
||||
|
||||
available = {
|
||||
"stdout": completed.stdout,
|
||||
"stderr": completed.stderr,
|
||||
"code": completed.returncode,
|
||||
}
|
||||
# Ports named after one of those get it; anything else gets stdout,
|
||||
# which is what a single-output exec node is almost always after.
|
||||
return {
|
||||
spec.port: available.get(spec.port, completed.stdout)
|
||||
for spec in self.output_ports
|
||||
} or None
|
||||
Reference in New Issue
Block a user