Files
gyxx-flow/src/gyxx_flow/adapters/native.py
T

210 lines
8.1 KiB
Python

"""Resolve workflow commands from source owned by this installed project."""
from __future__ import annotations
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from types import MappingProxyType
from typing import Callable, Mapping
from gyxx_flow.adapters.integration import RuntimeIntegrationCatalog
from gyxx_flow.catalog import WorkflowEntry
from gyxx_flow.core.config import Settings
from gyxx_flow.core.context import RunContext
from gyxx_flow.core.layout import DataLayout
from gyxx_flow.workflow.model import ExecutableStep
from gyxx_flow.workflow.steps import CommandStep, StepExecution
class ModuleSourceError(ValueError):
"""Raised when a workflow cannot resolve project-owned module source safely."""
class ModuleSourceRoots:
"""Validated runtime roots for independently packaged business modules."""
def __init__(self, roots: Mapping[str, Path | str]) -> None:
resolved: dict[str, Path] = {}
for module, configured in roots.items():
if not module or not isinstance(module, str):
raise ModuleSourceError(f"invalid module id: {module!r}")
root = Path(configured).expanduser().resolve(strict=True)
if not root.is_dir():
raise ModuleSourceError(f"module source root is not a directory: {root}")
resolved[module] = root
self._roots = MappingProxyType(resolved)
def get(self, module: str) -> Path:
try:
return self._roots[module]
except KeyError as exc:
raise ModuleSourceError(f"unknown module source: {module!r}") from exc
class ModuleCommandAdapter:
"""Build a shell-free command rooted in source shipped with this project."""
def __init__(
self,
roots: ModuleSourceRoots,
*,
base_env: Mapping[str, str] | None = None,
python_executable: str | Path | None = None,
project_root: str | Path | None = None,
data_root: str | Path | None = None,
integration_catalog: RuntimeIntegrationCatalog | None = None,
) -> None:
self._roots = roots
self._base_env = dict(os.environ if base_env is None else base_env)
self._python = str(python_executable or sys.executable)
settings = Settings.from_env(project_root=project_root, env=self._base_env)
self._project_root = settings.project_root
self._data_root = (
Path(data_root).expanduser().resolve()
if data_root is not None
else settings.data_root
)
binding_file = self._project_root / "config" / "runtime-bindings.json"
self._integration_catalog = integration_catalog
if self._integration_catalog is None and binding_file.is_file():
self._integration_catalog = RuntimeIntegrationCatalog.load_default(
project_root=self._project_root,
data_root=self._data_root,
)
def build(self, entry: WorkflowEntry, *, context: RunContext) -> CommandStep:
if entry.workflow_id != context.workflow_id:
raise ModuleSourceError(
f"workflow entry {entry.workflow_id!r} does not match "
f"run context {context.workflow_id!r}"
)
if entry.trigger == "unavailable":
raise ModuleSourceError(f"workflow is unavailable: {entry.workflow_id}")
root = self._roots.get(entry.module)
executable = _resolve_entry(root, entry.entry)
suffix = executable.suffix.casefold()
if suffix in {".py", ".pyw"}:
argv = (self._python, str(executable), *entry.args)
elif suffix in {".bat", ".cmd"}:
command_line = subprocess.list2cmdline([str(executable), *entry.args])
argv = ("cmd.exe", "/d", "/s", "/c", command_line)
elif suffix == ".ps1":
argv = (
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(executable),
*entry.args,
)
else:
argv = (str(executable), *entry.args)
env = {
**self._base_env,
"GYXX_PROJECT_ROOT": str(self._project_root),
"GYXX_DATA_ROOT": str(self._data_root),
"GYXX_MODULE_ROOT": str(root),
"GYXX_MODULE_ID": entry.module,
"GYXX_PYTHON": self._python,
"GYXX_WORKFLOW_ID": context.workflow_id,
"GYXX_RUN_ID": context.run_id,
"GYXX_BUSINESS_DATE": context.business_date.isoformat(),
"GYXX_SHADOW": "true" if context.shadow else "false",
"PYTHONUNBUFFERED": "1",
}
if entry.module == "supply_chain":
supply_paths = DataLayout(self._data_root).for_module("supply_chain")
env.update(
{
"GYXX_SUPPLY_RAW_ROOT": str(supply_paths.raw_root),
"GYXX_SUPPLY_STATE_ROOT": str(supply_paths.state_root),
"GYXX_SUPPLY_EXPORT_ROOT": str(supply_paths.exports_root),
"GYXX_SUPPLY_WORK_ROOT": str(supply_paths.tmp_root),
}
)
if self._integration_catalog is not None:
env = self._integration_catalog.environment_for(
f"{entry.module}:{entry.entry}", env
)
existing_pythonpath = env.get("PYTHONPATH", "").strip()
env["PYTHONPATH"] = (
f"{root}{os.pathsep}{existing_pythonpath}" if existing_pythonpath else str(root)
)
return CommandStep(argv=tuple(argv), cwd=root, env=env)
ModuleCommandFactory = Callable[[WorkflowEntry, RunContext], ExecutableStep]
def _command_from_project(entry: WorkflowEntry, context: RunContext) -> ExecutableStep:
package_root = Path(__file__).resolve().parents[1]
runtime_root = package_root / "modules" / entry.module / "runtime"
settings = Settings.from_env()
return ModuleCommandAdapter(
ModuleSourceRoots({entry.module: runtime_root}),
project_root=settings.project_root,
data_root=settings.data_root,
).build(entry, context=context)
@dataclass(frozen=True, slots=True)
class DeferredModuleCommandStep:
"""Resolve project-owned module source only when a real execution starts."""
entry: WorkflowEntry
command_factory: ModuleCommandFactory = _command_from_project
def __post_init__(self) -> None:
if not callable(self.command_factory):
raise TypeError("command_factory must be callable")
def execute(
self,
*,
context: RunContext,
timeout_seconds: float | None,
dry_run: bool,
) -> StepExecution:
if dry_run:
return StepExecution(exit_code=0, skipped=True, reason="dry-run")
command = self.command_factory(self.entry, context)
if not callable(getattr(command, "execute", None)):
raise TypeError("command_factory must return an executable step")
return command.execute(
context=context,
timeout_seconds=timeout_seconds,
dry_run=False,
)
def _resolve_entry(root: Path, configured_entry: str) -> Path:
if not configured_entry or "\\" in configured_entry or ":" in configured_entry:
raise ModuleSourceError("module entry must use a safe relative path")
relative = PurePosixPath(configured_entry)
if relative.is_absolute() or ".." in relative.parts:
raise ModuleSourceError("module entry cannot escape its module root")
try:
candidate = root.joinpath(*relative.parts).resolve(strict=True)
except OSError as exc:
raise ModuleSourceError(
f"module entry does not exist or cannot be resolved: {configured_entry}"
) from exc
if not candidate.is_relative_to(root):
raise ModuleSourceError("module entry cannot escape its module root")
if not candidate.is_file():
raise ModuleSourceError(f"module entry is not a file: {configured_entry}")
return candidate
__all__ = [
"DeferredModuleCommandStep",
"ModuleCommandAdapter",
"ModuleCommandFactory",
"ModuleSourceError",
"ModuleSourceRoots",
]