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>
107 lines
3.5 KiB
Python
107 lines
3.5 KiB
Python
"""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
|