From 7e0ea37221880fac0fbac061ed52d80e16528a40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:23:49 +0000 Subject: [PATCH 1/6] feat: add serverless automation engine with pluggable components and generator variables --- src/automation/__init__.py | 70 +++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1663 bytes .../__pycache__/components.cpython-312.pyc | Bin 0 -> 13176 bytes .../__pycache__/engine.cpython-312.pyc | Bin 0 -> 16563 bytes .../__pycache__/variables.cpython-312.pyc | Bin 0 -> 11592 bytes src/automation/components.py | 320 ++++++++++++++ src/automation/engine.py | 398 ++++++++++++++++++ src/automation/variables.py | 227 ++++++++++ 8 files changed, 1015 insertions(+) create mode 100644 src/automation/__init__.py create mode 100644 src/automation/__pycache__/__init__.cpython-312.pyc create mode 100644 src/automation/__pycache__/components.cpython-312.pyc create mode 100644 src/automation/__pycache__/engine.cpython-312.pyc create mode 100644 src/automation/__pycache__/variables.cpython-312.pyc create mode 100644 src/automation/components.py create mode 100644 src/automation/engine.py create mode 100644 src/automation/variables.py diff --git a/src/automation/__init__.py b/src/automation/__init__.py new file mode 100644 index 0000000..35b78ff --- /dev/null +++ b/src/automation/__init__.py @@ -0,0 +1,70 @@ +""" +Serverless Python automation with pluggable components and generator variables. + +Quick-start:: + + from src.automation import AutomationEngine, AutomationDefinition, TriggerEvent + from src.automation import Component, ComponentInput, ComponentRegistry + from src.automation import GeneratorVariable, VariableRegistry + + engine = AutomationEngine() + + # 1. Register a component + engine.register_fn("greet", lambda inp: f"Hello, {inp.payload}!") + + # 2. Define a generator variable + engine.define_variable("counter", lambda: (i for i in range(100))) + + # 3. Define an automation + engine.define(AutomationDefinition( + name="greet_on_request", + triggers=["user.request"], + steps=["greet"], + variables=["counter"], + )) + + # 4. Fire an event (serverless – no threads, no server) + results = engine.trigger_type("user.request", payload="world") + print(results[0].status) # RunStatus.SUCCESS +""" + +from .engine import ( + AutomationDefinition, + AutomationEngine, + RunResult, + RunStatus, + TriggerEvent, + default_engine, + trigger, +) +from .components import ( + Component, + ComponentInput, + ComponentOutput, + ComponentRegistry, + FunctionComponent, +) +from .variables import ( + GeneratorVariable, + VariableRegistry, +) + +__all__ = [ + # Engine + "AutomationEngine", + "AutomationDefinition", + "TriggerEvent", + "RunResult", + "RunStatus", + "default_engine", + "trigger", + # Components + "Component", + "ComponentInput", + "ComponentOutput", + "ComponentRegistry", + "FunctionComponent", + # Variables + "GeneratorVariable", + "VariableRegistry", +] diff --git a/src/automation/__pycache__/__init__.cpython-312.pyc b/src/automation/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d04d4067fe4289d13d7e7ad921ced9bb62ca6ef GIT binary patch literal 1663 zcmah}%Z}Vc6t&%N&vZ}s1PLLrC^{Qk>bNJ5RVy6{h6%9YF-=%FikwQj+T+AkcFN_6 zS_mom0X8hyv*QQ&3I3p#Va1LRp^>uUmfe29fa1mRE#LAz_xd->7h@4K4z zn>v(_u@hcD1mQ=`*IdoleIwGT;Tp8%wrJaJ(~jGrUAIenZjbiezOL@Hq5(BslkT~D zbl=^lLw86=?kLx_qXRm2$8_RObS=>Q&QH2~xb51#?3b3NnJdE236DscqA$1NEM^F2 zBBoe`AU+Gl8A_ro2=Jqbpmj`>m=PvYgqe>5!U)GA=I9*rP~Au!)BGw6*H5mc0&}rg zm?nZ}!()n4zIGa>Lome!`miE9VL`~qtnuYzvI$wJ9GRi7c^CwQpPU2x+iV_}Zq868 ze#VkaG|s0a2vfngZ?pAD8RIv*5ND{ADigU4lDm(V4gZUXEW1d;Rdn4!#Xp22Tqm>f zyTfz02=_M3ng*N@F`c0Z(?>o=Axjo$GyRlAQ9MK6!qG|aHj1(T-3QZ(#AD$ zJ=Gu#Q?e}L^)|h{G#_kiA_Lc1I4XF_NQ7%qL17ROc@; z0V5_S)rWFE`jgL6$})ph06C7 z>?;^57%4bVFb4SCz9M^`=f`W$lieb|!f0;3Hkxx(mZiBaH%a=xEOJowS52tca1?po z&)RF?*WN7VXE7yno-tUNbEy22dC@`Di?8Rn+J$q^Q=8B8oMii^f1AdB7LhyrJusyz wpUwgPXz9BC%6MOIU)=8N#)Ss(O2NfpS2zCB04_!?-T2cy+%sPE_`C4W-zkCre*gdg literal 0 HcmV?d00001 diff --git a/src/automation/__pycache__/components.cpython-312.pyc b/src/automation/__pycache__/components.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f51d95c711ae91c66e6bf6acd99e05d1f13861e8 GIT binary patch literal 13176 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 literal 0 HcmV?d00001 diff --git a/src/automation/__pycache__/engine.cpython-312.pyc b/src/automation/__pycache__/engine.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c221425d897ad8ecd3f49cea80529fb782511417 GIT binary patch literal 16563 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 literal 0 HcmV?d00001 diff --git a/src/automation/__pycache__/variables.cpython-312.pyc b/src/automation/__pycache__/variables.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eabcfb67275493e222a74f93ec07544590553c56 GIT binary patch literal 11592 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 literal 0 HcmV?d00001 diff --git a/src/automation/components.py b/src/automation/components.py new file mode 100644 index 0000000..d8139e2 --- /dev/null +++ b/src/automation/components.py @@ -0,0 +1,320 @@ +""" +Pluggable component registry for serverless automation. + +Components are self-describing units of work that can be registered by name +and composed inside automation pipelines. The registry enforces a consistent +interface while remaining fully decoupled from any specific runtime or +framework. +""" + +from __future__ import annotations + +import inspect +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Type + + +# --------------------------------------------------------------------------- +# Component contract +# --------------------------------------------------------------------------- + +@dataclass +class ComponentInput: + """Typed input envelope passed to a component.""" + name: str + payload: Any + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ComponentOutput: + """Typed output envelope returned by a component.""" + component: str + result: Any + success: bool = True + error: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + +class Component(ABC): + """ + Base class for all pluggable components. + + Subclass and implement :meth:`execute` to create a new component. + Register the component with a :class:`ComponentRegistry` using + :meth:`ComponentRegistry.register`. + + Subclasses may optionally implement: + + * :meth:`validate` – return ``(True, "")`` to accept the input or + ``(False, "")`` to reject it before execution. + * :meth:`setup` / :meth:`teardown` – hooks called once when the + component is registered / deregistered. + """ + + #: Human-readable name shown in registry listings. Defaults to the + #: class name when not overridden. + name: str = "" + + #: Short description surfaced by :meth:`ComponentRegistry.describe`. + description: str = "" + + def validate(self, input_: ComponentInput) -> tuple[bool, str]: + """ + Validate *input_* before execution. + + Returns: + A ``(valid, reason)`` tuple. ``valid`` is ``True`` when the + input is acceptable. ``reason`` is an empty string on success + or a human-readable message on failure. + """ + return True, "" + + @abstractmethod + def execute(self, input_: ComponentInput) -> ComponentOutput: + """Process *input_* and return a :class:`ComponentOutput`.""" + + def setup(self) -> None: + """Optional lifecycle hook called when the component is registered.""" + + def teardown(self) -> None: + """Optional lifecycle hook called when the component is removed.""" + + # Convenience method so subclasses can build outputs without importing the class + def _ok(self, result: Any, **meta: Any) -> ComponentOutput: + return ComponentOutput( + component=self.name or type(self).__name__, + result=result, + success=True, + metadata=meta, + ) + + def _err(self, error: str, **meta: Any) -> ComponentOutput: + return ComponentOutput( + component=self.name or type(self).__name__, + result=None, + success=False, + error=error, + metadata=meta, + ) + + +# --------------------------------------------------------------------------- +# Function-based component adapter +# --------------------------------------------------------------------------- + +class FunctionComponent(Component): + """ + Wraps a plain callable as a :class:`Component`. + + Useful for registering lambda functions or module-level functions + without creating a full subclass:: + + def double(inp): + return inp.payload * 2 + + registry.register_fn("double", double) + """ + + def __init__( + self, + fn: Callable[[ComponentInput], Any], + name: str = "", + description: str = "", + ) -> None: + self.name = name or fn.__name__ + self.description = description or (inspect.getdoc(fn) or "") + self._fn = fn + + def execute(self, input_: ComponentInput) -> ComponentOutput: + try: + result = self._fn(input_) + return self._ok(result) + except Exception as exc: + return self._err(str(exc)) + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +class ComponentRegistry: + """ + Central store of named :class:`Component` instances. + + Components are looked up by *name* (a plain string) so that pipelines + can remain decoupled from concrete implementations. + + Usage:: + + registry = ComponentRegistry() + + @registry.component("greet") + class GreetComponent(Component): + description = "Returns a greeting string." + + def execute(self, inp): + return self._ok(f"Hello, {inp.payload}!") + + output = registry.run("greet", ComponentInput("greet", "world")) + print(output.result) # Hello, world! + """ + + def __init__(self) -> None: + self._components: Dict[str, Component] = {} + + # ------------------------------------------------------------------ + # Registration + # ------------------------------------------------------------------ + + def register(self, name: str, component: Component) -> "ComponentRegistry": + """ + Register *component* under *name*. + + Calls :meth:`Component.setup` and returns *self* for chaining. + """ + component.name = component.name or name + component.setup() + self._components[name] = component + return self + + def register_class(self, name: str, cls: Type[Component], **kwargs: Any) -> "ComponentRegistry": + """Instantiate *cls* with **kwargs** and register the instance.""" + return self.register(name, cls(**kwargs)) + + def register_fn( + self, + name: str, + fn: Callable[[ComponentInput], Any], + description: str = "", + ) -> "ComponentRegistry": + """Wrap *fn* in a :class:`FunctionComponent` and register it.""" + return self.register(name, FunctionComponent(fn, name=name, description=description)) + + def component(self, name: str, description: str = "") -> Callable[[Type[Component]], Type[Component]]: + """ + Class decorator that registers a component under *name*:: + + @registry.component("my_step") + class MyStep(Component): + ... + """ + def _decorator(cls: Type[Component]) -> Type[Component]: + if description: + cls.description = description + self.register_class(name, cls) + return cls + + return _decorator + + def deregister(self, name: str) -> bool: + """ + Remove the component registered as *name*. + + Calls :meth:`Component.teardown` if found. Returns ``True`` when + a component was removed. + """ + component = self._components.pop(name, None) + if component is not None: + component.teardown() + return True + return False + + # ------------------------------------------------------------------ + # Execution + # ------------------------------------------------------------------ + + def run(self, name: str, input_: ComponentInput) -> ComponentOutput: + """ + Execute the component registered as *name*. + + Validates the input first; returns an error output if the component + is not found or validation fails. + """ + component = self._components.get(name) + if component is None: + return ComponentOutput( + component=name, + result=None, + success=False, + error=f"Component '{name}' not registered", + ) + + valid, reason = component.validate(input_) + if not valid: + return ComponentOutput( + component=name, + result=None, + success=False, + error=f"Validation failed: {reason}", + ) + + return component.execute(input_) + + def run_pipeline( + self, + steps: List[str], + initial_payload: Any, + metadata: Optional[Dict[str, Any]] = None, + ) -> List[ComponentOutput]: + """ + Run a sequential pipeline of named components. + + Each step's ``result`` is forwarded as the ``payload`` of the next + step's :class:`ComponentInput`. Execution stops on the first + failure unless *stop_on_error* is ``True``. + + Args: + steps: Ordered list of registered component names. + initial_payload: Payload for the first step. + metadata: Optional metadata forwarded to every step. + + Returns: + List of :class:`ComponentOutput` objects, one per step executed. + """ + outputs: List[ComponentOutput] = [] + payload = initial_payload + meta = metadata or {} + + for step in steps: + inp = ComponentInput(name=step, payload=payload, metadata=meta) + out = self.run(step, inp) + outputs.append(out) + if not out.success: + break + payload = out.result + + return outputs + + # ------------------------------------------------------------------ + # Introspection + # ------------------------------------------------------------------ + + def names(self) -> List[str]: + """Return registered component names.""" + return list(self._components.keys()) + + def get(self, name: str) -> Optional[Component]: + """Return the component registered as *name*, or ``None``.""" + return self._components.get(name) + + def describe(self, name: str) -> str: + """Return the description of the component registered as *name*.""" + component = self._components.get(name) + if component is None: + return f"(no component named '{name}')" + return component.description or "(no description)" + + def describe_all(self) -> Dict[str, str]: + """Return a mapping of name → description for every registered component.""" + return {name: (c.description or "") for name, c in self._components.items()} + + def __contains__(self, name: str) -> bool: + return name in self._components + + def __len__(self) -> int: + return len(self._components) + + def __repr__(self) -> str: + return f"ComponentRegistry({list(self._components.keys())!r})" diff --git a/src/automation/engine.py b/src/automation/engine.py new file mode 100644 index 0000000..cfcb163 --- /dev/null +++ b/src/automation/engine.py @@ -0,0 +1,398 @@ +""" +Serverless automation engine for Pmaster AI Operator. + +The engine is *serverless* in the sense that it is purely function-driven: +there is no persistent background thread or network listener. Automation +runs are triggered by explicit calls, making the engine safe to use inside +FaaS environments (AWS Lambda, Google Cloud Functions, etc.) as well as +in-process within any Python application. + +Architecture:: + + ┌──────────────────────────────────────────────────────┐ + │ AutomationEngine │ + │ │ + │ VariableRegistry ←── GeneratorVariable(s) │ + │ │ │ + │ ComponentRegistry ←── Component / FunctionComponent│ + │ │ │ + │ Trigger dispatcher │ + │ │ │ + │ RunResult / RunHistory │ + └──────────────────────────────────────────────────────┘ +""" + +from __future__ import annotations + +import traceback +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any, Callable, Dict, List, Optional + +from .components import ( + Component, + ComponentInput, + ComponentOutput, + ComponentRegistry, + FunctionComponent, +) +from .variables import GeneratorVariable, VariableRegistry + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + +class RunStatus(Enum): + PENDING = "pending" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + SKIPPED = "skipped" + + +@dataclass +class TriggerEvent: + """Represents an event that can trigger an automation run.""" + event_type: str + payload: Any = None + metadata: Dict[str, Any] = field(default_factory=dict) + timestamp: datetime = field(default_factory=datetime.now) + event_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12]) + + +@dataclass +class RunResult: + """Result of a single automation run.""" + run_id: str + trigger: TriggerEvent + status: RunStatus + outputs: List[ComponentOutput] = field(default_factory=list) + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + error: Optional[str] = None + + @property + def duration_ms(self) -> Optional[float]: + """Wall-clock duration in milliseconds, or ``None`` if not finished.""" + if self.started_at and self.finished_at: + delta = self.finished_at - self.started_at + return delta.total_seconds() * 1000 + return None + + def to_dict(self) -> Dict[str, Any]: + return { + "run_id": self.run_id, + "trigger": { + "event_type": self.trigger.event_type, + "event_id": self.trigger.event_id, + "timestamp": self.trigger.timestamp.isoformat(), + }, + "status": self.status.value, + "outputs": [ + { + "component": o.component, + "success": o.success, + "error": o.error, + } + for o in self.outputs + ], + "started_at": self.started_at.isoformat() if self.started_at else None, + "finished_at": self.finished_at.isoformat() if self.finished_at else None, + "duration_ms": self.duration_ms, + "error": self.error, + } + + +# --------------------------------------------------------------------------- +# Automation definition +# --------------------------------------------------------------------------- + +@dataclass +class AutomationDefinition: + """ + Declarative description of an automation. + + An automation is defined by: + + * A *name* (unique identifier). + * A list of *triggers*: event type strings that activate the automation. + * An ordered list of *steps*: component names executed in sequence. + * Optional *variables*: names from the :class:`VariableRegistry` that are + injected into each step's ``metadata`` before execution. + """ + name: str + triggers: List[str] + steps: List[str] + variables: List[str] = field(default_factory=list) + description: str = "" + enabled: bool = True + + +# --------------------------------------------------------------------------- +# Engine +# --------------------------------------------------------------------------- + +class AutomationEngine: + """ + Serverless automation engine. + + The engine binds together a :class:`ComponentRegistry` (what to run) and a + :class:`VariableRegistry` (runtime data), wires up event-based dispatch, + and keeps a lightweight run history. + + Usage:: + + engine = AutomationEngine() + + # Register a component + engine.components.register_fn("echo", lambda inp: inp.payload) + + # Define an automation + engine.define(AutomationDefinition( + name="on_hello", + triggers=["hello"], + steps=["echo"], + )) + + # Fire an event + result = engine.trigger(TriggerEvent("hello", payload="world")) + print(result[0].status) # RunStatus.SUCCESS + """ + + def __init__(self) -> None: + self.components = ComponentRegistry() + self.variables = VariableRegistry() + + self._automations: Dict[str, AutomationDefinition] = {} + self._history: List[RunResult] = [] + + # Middleware hooks: callables invoked before/after each run + self._before_run: List[Callable[[RunResult, TriggerEvent], None]] = [] + self._after_run: List[Callable[[RunResult], None]] = [] + + # ------------------------------------------------------------------ + # Component & variable shortcuts + # ------------------------------------------------------------------ + + def component(self, name: str, description: str = "") -> Callable: + """Decorator that registers a :class:`Component` subclass.""" + return self.components.component(name, description) + + def register_fn( + self, + name: str, + fn: Callable[[ComponentInput], Any], + description: str = "", + ) -> "AutomationEngine": + """Register a plain callable as a component. Returns self.""" + self.components.register_fn(name, fn, description) + return self + + def define_variable( + self, + name: str, + factory: Callable, + ) -> GeneratorVariable: + """Create a generator variable and add it to the variable registry.""" + return self.variables.define(name, factory) + + # ------------------------------------------------------------------ + # Automation registration + # ------------------------------------------------------------------ + + def define(self, automation: AutomationDefinition) -> "AutomationEngine": + """Register an automation definition. Returns self for chaining.""" + self._automations[automation.name] = automation + return self + + def undefine(self, name: str) -> bool: + """Remove an automation by name. Returns ``True`` if it existed.""" + return self._automations.pop(name, None) is not None + + def enable(self, name: str) -> None: + """Enable a previously disabled automation.""" + if name in self._automations: + self._automations[name].enabled = True + + def disable(self, name: str) -> None: + """Disable an automation without removing it.""" + if name in self._automations: + self._automations[name].enabled = False + + # ------------------------------------------------------------------ + # Middleware + # ------------------------------------------------------------------ + + def before_run(self, fn: Callable[[RunResult, TriggerEvent], None]) -> Callable: + """Register a hook called just before each automation run starts.""" + self._before_run.append(fn) + return fn + + def after_run(self, fn: Callable[[RunResult], None]) -> Callable: + """Register a hook called just after each automation run finishes.""" + self._after_run.append(fn) + return fn + + # ------------------------------------------------------------------ + # Triggering + # ------------------------------------------------------------------ + + def trigger(self, event: TriggerEvent) -> List[RunResult]: + """ + Dispatch *event* to all matching, enabled automations. + + Returns the list of :class:`RunResult` objects produced (one per + matching automation). + """ + results: List[RunResult] = [] + + for automation in self._automations.values(): + if not automation.enabled: + continue + if event.event_type not in automation.triggers: + continue + result = self._run(automation, event) + results.append(result) + + return results + + def trigger_type(self, event_type: str, payload: Any = None, **metadata: Any) -> List[RunResult]: + """Convenience wrapper: build a :class:`TriggerEvent` and dispatch it.""" + event = TriggerEvent(event_type=event_type, payload=payload, metadata=metadata) + return self.trigger(event) + + # ------------------------------------------------------------------ + # Internal execution + # ------------------------------------------------------------------ + + def _run(self, automation: AutomationDefinition, event: TriggerEvent) -> RunResult: + """Execute a single automation in response to *event*.""" + run_id = f"run-{uuid.uuid4().hex[:12]}" + result = RunResult( + run_id=run_id, + trigger=event, + status=RunStatus.PENDING, + ) + + # Before-run hooks + for hook in self._before_run: + try: + hook(result, event) + except Exception: + pass + + result.started_at = datetime.now() + result.status = RunStatus.RUNNING + + try: + # Build variable snapshot for this run + var_snapshot = self._snapshot_variables(automation.variables) + + # Merge event metadata with variable snapshot + run_meta: Dict[str, Any] = {**event.metadata, "variables": var_snapshot} + + # Execute the component pipeline + outputs = self.components.run_pipeline( + steps=automation.steps, + initial_payload=event.payload, + metadata=run_meta, + ) + result.outputs = outputs + + # Determine overall status + if outputs and not outputs[-1].success: + result.status = RunStatus.FAILED + result.error = outputs[-1].error + else: + result.status = RunStatus.SUCCESS + + except Exception as exc: + result.status = RunStatus.FAILED + result.error = f"{type(exc).__name__}: {exc}" + result.outputs.append( + ComponentOutput( + component="__engine__", + result=None, + success=False, + error=traceback.format_exc(), + ) + ) + + result.finished_at = datetime.now() + self._history.append(result) + + # After-run hooks + for hook in self._after_run: + try: + hook(result) + except Exception: + pass + + return result + + def _snapshot_variables(self, names: List[str]) -> Dict[str, Any]: + """ + Peek at the next value of each named variable. + + Values are peeked (not consumed) so the same run can be replayed + without advancing the generators. + """ + snapshot: Dict[str, Any] = {} + for name in names: + var = self.variables.get(name) + if var is not None: + snapshot[name] = var.peek() + return snapshot + + # ------------------------------------------------------------------ + # History & introspection + # ------------------------------------------------------------------ + + def history(self, limit: Optional[int] = None) -> List[RunResult]: + """Return run history, most-recent first, optionally limited.""" + runs = list(reversed(self._history)) + return runs[:limit] if limit is not None else runs + + def automations(self) -> Dict[str, AutomationDefinition]: + """Return a copy of the registered automations map.""" + return dict(self._automations) + + def stats(self) -> Dict[str, Any]: + """Return aggregate run statistics.""" + total = len(self._history) + by_status: Dict[str, int] = {} + for run in self._history: + key = run.status.value + by_status[key] = by_status.get(key, 0) + 1 + + return { + "total_runs": total, + "automations": len(self._automations), + "components": len(self.components), + "variables": len(self.variables), + "by_status": by_status, + } + + def __repr__(self) -> str: + return ( + f"AutomationEngine(" + f"automations={len(self._automations)}, " + f"components={len(self.components)}, " + f"variables={len(self.variables)})" + ) + + +# --------------------------------------------------------------------------- +# Module-level default engine +# --------------------------------------------------------------------------- + +#: Default engine instance – use this for simple single-engine setups. +default_engine = AutomationEngine() + + +def trigger(event_type: str, payload: Any = None, **metadata: Any) -> List[RunResult]: + """Trigger *event_type* on the module-level :data:`default_engine`.""" + return default_engine.trigger_type(event_type, payload=payload, **metadata) diff --git a/src/automation/variables.py b/src/automation/variables.py new file mode 100644 index 0000000..46ec835 --- /dev/null +++ b/src/automation/variables.py @@ -0,0 +1,227 @@ +""" +Generator-backed variable system for serverless automation. + +Variables support lazy evaluation via Python generators, allowing values to be +computed on demand, streamed, or composed into pipelines without eager loading. +""" + +from typing import Any, Callable, Generator, Iterable, Iterator, Optional, TypeVar + +T = TypeVar("T") + + +class GeneratorVariable: + """ + A variable whose value is produced by a Python generator. + + Values are computed lazily: each call to ``next()`` or iteration + yields the next value from the underlying generator factory. + + Examples:: + + counter = GeneratorVariable(lambda: (i for i in range(10))) + print(counter.next()) # 0 + print(counter.next()) # 1 + + seq = GeneratorVariable.from_iterable([10, 20, 30]) + for v in seq: + print(v) + """ + + def __init__(self, factory: Callable[[], Generator]) -> None: + """ + Args: + factory: Zero-argument callable that returns a fresh generator + each time the variable is reset or first accessed. + """ + self._factory = factory + self._gen: Optional[Iterator] = None + + # ------------------------------------------------------------------ + # Core interface + # ------------------------------------------------------------------ + + def _ensure_gen(self) -> Iterator: + if self._gen is None: + self._gen = self._factory() + return self._gen + + def next(self, default: Any = None) -> Any: + """Return the next value, or *default* when the generator is exhausted.""" + try: + return next(self._ensure_gen()) + except StopIteration: + return default + + def reset(self) -> "GeneratorVariable": + """Restart the generator from the beginning.""" + self._gen = self._factory() + return self + + def peek(self) -> Any: + """ + Return the next value without advancing the generator. + + Uses ``itertools.chain`` internally to re-inject the peeked value. + Returns ``None`` when exhausted. + """ + import itertools + + try: + value = next(self._ensure_gen()) + self._gen = itertools.chain([value], self._gen) # type: ignore[arg-type] + return value + except StopIteration: + return None + + def collect(self) -> list: + """Drain all remaining values into a list.""" + return list(self._ensure_gen()) + + # ------------------------------------------------------------------ + # Composition helpers + # ------------------------------------------------------------------ + + def map(self, fn: Callable[[Any], Any]) -> "GeneratorVariable": + """Return a new variable that applies *fn* to each value.""" + source_factory = self._factory + + def _mapped(): + for v in source_factory(): + yield fn(v) + + return GeneratorVariable(_mapped) + + def filter(self, predicate: Callable[[Any], bool]) -> "GeneratorVariable": + """Return a new variable that yields only values matching *predicate*.""" + source_factory = self._factory + + def _filtered(): + for v in source_factory(): + if predicate(v): + yield v + + return GeneratorVariable(_filtered) + + def take(self, n: int) -> "GeneratorVariable": + """Return a new variable limited to the first *n* values.""" + source_factory = self._factory + + def _taken(): + for i, v in enumerate(source_factory()): + if i >= n: + break + yield v + + return GeneratorVariable(_taken) + + def chain(self, other: "GeneratorVariable") -> "GeneratorVariable": + """Concatenate this variable with *other*.""" + a_factory = self._factory + b_factory = other._factory + + def _chained(): + yield from a_factory() + yield from b_factory() + + return GeneratorVariable(_chained) + + # ------------------------------------------------------------------ + # Convenience constructors + # ------------------------------------------------------------------ + + @classmethod + def from_iterable(cls, iterable: Iterable) -> "GeneratorVariable": + """Create a variable that replays a fixed iterable on each reset.""" + items = list(iterable) + return cls(lambda: iter(items)) + + @classmethod + def from_value(cls, value: Any) -> "GeneratorVariable": + """Create a variable that yields a single value once.""" + return cls(lambda: iter([value])) + + @classmethod + def constant(cls, value: Any) -> "GeneratorVariable": + """Create an infinite variable that always yields *value*.""" + + def _infinite(): + while True: + yield value + + return cls(_infinite) + + @classmethod + def counter(cls, start: int = 0, step: int = 1) -> "GeneratorVariable": + """Create an infinite counter variable.""" + + def _count(): + n = start + while True: + yield n + n += step + + return cls(_count) + + # ------------------------------------------------------------------ + # Python protocol support + # ------------------------------------------------------------------ + + def __iter__(self) -> Iterator: + return self._ensure_gen() + + def __next__(self) -> Any: + return next(self._ensure_gen()) + + def __repr__(self) -> str: + return f"GeneratorVariable(factory={self._factory!r})" + + +class VariableRegistry: + """ + Named registry of :class:`GeneratorVariable` instances. + + Allows automation components to share and look up variables by name. + """ + + def __init__(self) -> None: + self._vars: dict[str, GeneratorVariable] = {} + + def register(self, name: str, variable: GeneratorVariable) -> "VariableRegistry": + """Register *variable* under *name*. Returns self for chaining.""" + self._vars[name] = variable + return self + + def define(self, name: str, factory: Callable[[], Generator]) -> GeneratorVariable: + """Create a :class:`GeneratorVariable` from *factory* and register it.""" + var = GeneratorVariable(factory) + self._vars[name] = var + return var + + def get(self, name: str) -> Optional[GeneratorVariable]: + """Return the variable registered as *name*, or ``None``.""" + return self._vars.get(name) + + def require(self, name: str) -> GeneratorVariable: + """Return the variable registered as *name*; raise ``KeyError`` if absent.""" + if name not in self._vars: + raise KeyError(f"Variable '{name}' not found in registry") + return self._vars[name] + + def names(self) -> list[str]: + """Return the list of registered variable names.""" + return list(self._vars.keys()) + + def reset_all(self) -> None: + """Reset every registered variable to its initial state.""" + for var in self._vars.values(): + var.reset() + + def __contains__(self, name: str) -> bool: + return name in self._vars + + def __len__(self) -> int: + return len(self._vars) + + def __repr__(self) -> str: + return f"VariableRegistry({list(self._vars.keys())!r})" From 308d011c11aafb897c31dc8d4f612003dbce6d31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:25:40 +0000 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20address=20code=20review=20=E2=80=93?= =?UTF-8?q?=20idempotent=20peek,=20hook=20exception=20logging,=20fix=20doc?= =?UTF-8?q?string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__pycache__/components.cpython-312.pyc | Bin 13176 -> 13231 bytes .../__pycache__/engine.cpython-312.pyc | Bin 16563 -> 16882 bytes .../__pycache__/variables.cpython-312.pyc | Bin 11592 -> 11894 bytes src/automation/components.py | 3 +- src/automation/engine.py | 7 +++-- src/automation/variables.py | 27 ++++++++++++------ 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/automation/__pycache__/components.cpython-312.pyc b/src/automation/__pycache__/components.cpython-312.pyc index f51d95c711ae91c66e6bf6acd99e05d1f13861e8..43731a932fddcd68c756e62fdf2660f23df053f3 100644 GIT binary patch delta 236 zcmey7wmzNrG%qg~0}vc}NF=cXt%3bc%yyX1Sypq(S)RfKjs)md%3P2E& zk*biBSzMx!n^>ukl&VmikzWKIDjDp2XjP8t|7{FAK9MF3JBZEp# delta 191 zcmZ3V{v(a|G%qg~0}!Y`a>_E>$Xless5`kq<*rg`UQTLpv4U1{Nq#|meqMZPQBi)8 zmO^H+LPA1FQE6&I!sZNBL&nL^)s8S~ZC;~p&&a4Y`HjXmAm_1WJR_stW@qiWjEts} zAL^WBw4A(JcQvEUoEZg+m>As|KQVx*A~~Ro0ZjZm!2kdN diff --git a/src/automation/__pycache__/engine.cpython-312.pyc b/src/automation/__pycache__/engine.cpython-312.pyc index c221425d897ad8ecd3f49cea80529fb782511417..c868dc5470600f017490eec8132c90267f026e48 100644 GIT binary patch delta 1684 zcmah}U2GIp6rMA)vpcif-OjQU6jSFZ%x{DhwRAuwq%~}l%2XucIo+YzAnjyWhm&3RR zcH5v4UlnRuXhV1Hmm!XiI`TP4;4`iv4tnr!?vnsd;Uk{i9Hj9re?1S^@WZu1fSXt- z@d12>OG~Q(zQj9&7Jxb26>TWOm~3#O+9(9v~Xsn=x&L?**%K{OHcA$P3@ zJ*dftas^meTiL7m^Ts)8~_Bvsq6ERXuxmGV*TsHQ&PBGp#&)2n@kNx00+CQ^5p ze$HxfQnb`U$aXZ{o<}tmTRrm~V4j-6<%s8CyM-S=?(64nas(m!4 zrP(}{5zWh~#mrdFUSbq6BVJ-Ib42oLgBgofm&-9dEpB=_n{F|E#jda}#gX1_2DDam zWwQs3N;dX-9-69O3rpy``d~}ZW4m8h8^(4lvYfLUzL&h?`!DYMR;pO!En+vI=NgvU5-W6K znSLXWatT<>V(Oe0Wi7CU0xZRW{nM^uV8zb=aM;i|SsHkVtU!dW=O;-V~p-0W@;WDa;mBA$H zip3#?$6}`et|M{FPIwu2Z}9?rg8JjN@G-g&zXdnYuC41D-Xgk;0~8Vzq68uq<6UA- zIrn5I(U*K?FqM9GnZJSF+4{N<(bV@TT%+(I1&q2{is4l}*m4%&HT282hN_z+9;r0w z)m^an_Vyo3A0vbBHR!K(!A6HQm>MwNL|eCyG%Sb%YPu^sKckq|o1j2nyHQPn-c@5C zh36@JioV%iZl5MD{d*5jUugBRuKUQ-TCwJV7#J(KCkE!kz{S{IF?`yJnp#Kx0UMWj A1^@s6 delta 1346 zcmZuxTTC2P7(V~Z&g{tz6R5 z?%Gx{X$*A2gsq`l{rZ)zg_250EbMANWy=VLsEUSNwFA*W6EY6Np*jj& z%t(f;$^~xEI1QKTGTf@$C{as{Qnl3Zs2-McXi}!kC|Ao3uj(~?s?ShV#qg{CLQc+9 z7?oqVaz2;Bun%fyzKV!pa)oWb#G1_BUw%l zS(_Abg$*t%j=>7#?5wS}n6a2%e{ZXX0&;qjiBbDSaeEMiN;gnCB zoHka_kLj3#-?YXh8;}t)4|8cu>~7qV;ySg*YRyV{*yehN6)WNrd#PDzi7fL>!j9Uk zO>ZvNSWNG-SP`d^7S|+=)8wh{ZJ&);@3Dp%y6DWafVnI!rfQALNiR!tH!%?pm+@v~ z?$R-GoF5^_AnL-o_!B6$4Rg^t%(cbomu&$MOW9Q>1^h8FRH2-GXp`gJ%J&}M{F84V z_|{XuDcB?h2imvV{*!ylRY+{Qh{u1{{l{;kKzzY10&e98k8hJ_h$ruxdDPsf;N6|< zWkSCyllGT!*PQ*G`y*ssa11tb>ns!!)`MW$h%{KougCi0gF${HC@^QEQ5tL)HWDz{ zPSbl902c68M-G}ZRJc`LSSi;U^1Xl52vKwBci^wSupAQra5n#k4Au{*t$QsD diff --git a/src/automation/__pycache__/variables.cpython-312.pyc b/src/automation/__pycache__/variables.cpython-312.pyc index eabcfb67275493e222a74f93ec07544590553c56..47dac5d0c407683c9a0249c4ea74dde822bf718d 100644 GIT binary patch delta 2951 zcmai0eQaA-6@NGOi|yF4llaAcas2UY$97)68c0dkC8h0_W^0>f(9mwMvBY)W61R?> z-shAiRI4|b2vee_yVDwI1r2|UMB0Q{SAhTll@Q;IM(xyKm5NPBNJCI>DNs$Ia?VZS z&g&5GkKg?|_ndR@@0@erFF*IsC+xqetgH~=TX_3{!&d)y?Lm=rvvx74440h{2Fq{> zzhMF+!&+YuCW&fb2gFyKdzXxP^WanQL^eYw^TxpuEuA+Fj%(TxZ8%@K-Wfa=Pfch{ zDnCJj>~y)add9Ni3r!nmY%8JYwDC=|hrL`rYpVtAIss6qi)}Y;B5f>VY9TT^X9^i# zf_bJmWHWVgR|NKZ)n79pScI&&;nfM@u!()s+h7a}Su^{$DL~4Y*W7!ucS)p95TX)5 z-k8>oXQ>yvl>^z#`0lJmWrzD@*cX}Ut1Q@WaxV@YRUvS!Z)$CbuIH#>t2DuW)j4;6+dmTDRLm9QP zXPR6eO+cvT9?v;lXj;jRYKk5o(-a;^%0xPwOjRiB-_Q{9z{N9ENp7&IBtZAXn5M&28}nP;JBvF| z9R%tcn@UnwwBw`k2|cR~w-seR(@z6%*3yf7(HcD21GD<006C#hCD^JsC=3l7~ltzXva{Ic&`<_57@*kY;f&tq1^=i=l_4b`s z;gZ#|(`~rqB7j9UShH=i8iTjgvx~MEIl%sC3qZR3$5tR7_V>z0;$m+5W)f$8_6JBU zJ7xF6%q#W)`8@lrJx1gUx9k-JMDI94un?;1B1hPhRVcbpg`z*MijWcZ=PDGnRa;Qh zRnrCwyJ|YfF*aGV$IlaH3%b*d&<3DmuqZ>y4#?uSYJ!9^gA+8cTtgk}aoz$YTP1Yu zkaQpU5<4pmfW4biLnWqKUQCR}lj$ipJ1V)!)2y?$9d?e?zTxx0>JrHtkzbYMMQF!# z9^pB#v){F&>onT70^H6C=Qf`g&vxYm9?2@9F_=~jc|#(l(_PS~dr%l7TH${F&ecI? z8F7cL82Wi}tzfmf7eZ*4J`Au#Xg@R?CUJT%z*HNHyWGqqyU9x|AiMmifVKcYIqAbb z-^I(x5O_Kuw-Uylkv;1-a#5ahaT~aWei~rI0(-n}8#%|mR@W*Q_0R(-!2O2gaIqV8 zDw$_yj}Np(Jr^5_np6YjtYDzh$BLVBcv*Kmo#bWKS>NU7dW$0Tagdg^-BS>-F7bj;4xKXsRv=n5UK*OMUUk!JsM!RN6aHU?p&)fnp|2YFh%z5j%ldm& zt2kmPf{Es?qi(Gd&=X$P54a-u)6)Aef5xZ=TQJF2rAaZ@5 zvXvCo^FEdQkp0>hDCdlO*iGLNj~fh7Jm;DhctdMWg<00$x~B)md21pS*YzREwab=MNkZDLp5gvA`m)teF>1JC(e(C4X)hz%(dB-QF zjk6VSlgZEvfpM6F@Z^;P$)-9=@|ea4mwCdCO~1k^lz`W&K0IwaRq@q|xy{RF`EK;k zN?1-9*n%vvTzEEe6owECch%y0-hdt&p@)wJDF1jv19^iz+R(_;?}cr+D{mOlvPz{g}8Y`MxFiTFWJI`U2<*JIwRP41#JL+KYkq$O{n+R=$5 z)mX5}XF}yv=SjV)b;W-EN+mc=4s4u|sOR_b<4!8i42KT=vY=>8Z<(;AL}gJ@kK} zevFr2wJdK)X=#c+l#tG9o1)|$*4>h8JC!9l8x)4{>N zF{!!vB!)Pg%MMMy>Y^HMdq!Q>`Ma_9^OwdIgrBfCn}25K>4(F9)ka6zw_CObxWor?6Bge$caLR; hCsNuc=`6d`((28Wk?rpai2oWEH<2CWJpqy1`7iP8q}u=h delta 2794 zcmZuzeQaA-6~EWd&tHk{CUzXZeAq9JllbMcBP}aSL$j`B>(-`aq0>@LvyJnbIPvWE z+G(4Tt`=KSP`i=rASKXIT7*!tF|ny25Ku$~5=@9HRW!9&MJto|XF}jE1*%EaIOjPZ z&Di_nckey#oZq?ko{#tb^V}Z~x&GvIIv76o4|a}ztL`0FgUnUfEH{`ZxMqT*@>`V) zrh+YcIG)I5#6-ay9oEyZ$1>@wM58<_mNP}2GM{tajI>XgU$Mi7mRHI>_$td0RN;k} ztX;evE?e8V4{lpS)@M;Z%k0qU>^3*DoEcuU1v!Tcwl6)eNd77LM5Gi*d;6}|bY;WIG zj*GARYuWiSO`7ML1pl)5_-43b@3Ks?0j6rpQACjjRfR0&u)AD zv-@WD!G1iu{ifS9RVI9}qh1O85MR%-AwFbEu~Xd6auN!RXOfM|aNiL+eabY+N3E;k z^>NZP%u&g8O}`u4DZhf9?8N?NSqC_3Da?QLKb*piqi2^YC_FrRH1hy z)1!JKN5<+AT_4kj)MN3~xZe3Gc4F(#q;;e?KBA{p{lrLoJe$*p))fVvq+hcX?8&}W zzeWg4F_(=k2+A!HK+vR8)I&Hg$cED_T__$3Q){xk;e}pYW&08GY}v)^O3^Ia{kOdS zIqwZcyRK+UO52jReQNhjf5Z3P-*L}Z&0F90{#7hH?}`2v8$5(QMYu}#S!6>!v~r(RY3{5F+YCfY~lOhnlp%L{!eF- z`{8|8gnPl}-ogi=*S(W_An&e2$@knreh{v>Te$D?-`x(5s>MwqeBr6+;R)DZu`^&~ z`sc{UR)S81ECpVTseO3vpHwvQD7;re4EHPS#9;MwBE#oB-8>DWo+tYZhKI>``)4qq zs9?^;$MnxC9FmX1T@SLujH19+?_G4zT1BTBsM^NA2Ir~<@HDrp!giwq8<7s&K7}6x z&9@n?4*9O3g;rgQ7Q3tadcKJY*)oKCdG`92i}LxNJQF@#Yh1L@AQhxUDl2y3TrXm)`?K^T6NKS zzIVm1Xh$)2l&5egSsNWChW?&VI_l1nB_#k_-m$Pvo+VFY$*nEgG%WHNWx;1SCl0Zn zRH;XxYdBET%fAh;)^s$j5sLv*Fj6+A8oMjl5}9;17f$*hz-v;%m#u7!UW zeq2Y+{$Wvg2%BGH^8gd%6$A2rsf}X%07R)xycY0>XpBbRAXd7fJj3pH!yUZt4lcPH z7HtisK8RJaEmER1t=IR4l3C%!J`I;FN(=StX!I@OrAX)5Ew^WS*9~v@x;MP!ZC-M> zEZSO-{F8_RB5VMFA%gCS>lKK;8;x+(g^Car87|wN?TFk2f+^B2#Y~q z_g<17AovQwA%f=!o<}H1*_;q@S{l(d$i1Mb!M#?wUCmyOwDv z)=084_v|vm;f_O=Yv&#-G92c+muXmbNwT--aLNAZ#4^L7sKU$Yy-;W_vdp*~Dq6T4 znQNqZgyuWu8t>wKhwxp#&@j)#6s84UkfX=qBKzqJ6?Ep43VgZEQ z+J%u_rIs9P3iIE=nWhL@QS7NlW4w_sW~-|!7Tff^^htDo+t_2DGm`$Q+2 zybGtAX$P-2f7oj9*q+iSb_2&4!+(^uZA-dRqPe7=O?!{7q}+H=w+gx?XSV z_G+VY>1H?X4yIA#7F=zuG3XWeck4q&2h&UiDW#_cx!Vv+c&x2ieV=^MuLu=G<@A=> zM`j*5dwS6pFq&tA!=+~YoV1OpF*1LV;K8b(HRWz8AMkdLVBvlMJKNh%QzGMuTsZV= zK9L~RAp)J?Fo97$qckfg$P!SXg-mdafc{hj#ZF8TP%9UCf|m$}3C<9V5KIxQlu|WL zOSMG8)GGWGq>kUaj29&9*UH6Vc(3CNK|`aR)Yhy%wrzhQGc=ykzbMW^M`uUfkcscO S!wCP|FL&`i{t+WIEdB?=5r=jF diff --git a/src/automation/components.py b/src/automation/components.py index d8139e2..e6aedb3 100644 --- a/src/automation/components.py +++ b/src/automation/components.py @@ -263,7 +263,7 @@ def run_pipeline( Each step's ``result`` is forwarded as the ``payload`` of the next step's :class:`ComponentInput`. Execution stops on the first - failure unless *stop_on_error* is ``True``. + failure encountered. Args: steps: Ordered list of registered component names. @@ -272,6 +272,7 @@ def run_pipeline( Returns: List of :class:`ComponentOutput` objects, one per step executed. + The list may be shorter than *steps* if a failure occurred early. """ outputs: List[ComponentOutput] = [] payload = initial_payload diff --git a/src/automation/engine.py b/src/automation/engine.py index cfcb163..321afe8 100644 --- a/src/automation/engine.py +++ b/src/automation/engine.py @@ -24,6 +24,7 @@ from __future__ import annotations +import logging import traceback import uuid from dataclasses import dataclass, field @@ -40,6 +41,8 @@ ) from .variables import GeneratorVariable, VariableRegistry +logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Data structures @@ -282,7 +285,7 @@ def _run(self, automation: AutomationDefinition, event: TriggerEvent) -> RunResu try: hook(result, event) except Exception: - pass + logger.exception("before_run hook %r raised an exception", hook) result.started_at = datetime.now() result.status = RunStatus.RUNNING @@ -329,7 +332,7 @@ def _run(self, automation: AutomationDefinition, event: TriggerEvent) -> RunResu try: hook(result) except Exception: - pass + logger.exception("after_run hook %r raised an exception", hook) return result diff --git a/src/automation/variables.py b/src/automation/variables.py index 46ec835..e303f0b 100644 --- a/src/automation/variables.py +++ b/src/automation/variables.py @@ -36,6 +36,8 @@ def __init__(self, factory: Callable[[], Generator]) -> None: """ self._factory = factory self._gen: Optional[Iterator] = None + self._peeked: bool = False + self._peeked_value: Any = None # ------------------------------------------------------------------ # Core interface @@ -48,6 +50,9 @@ def _ensure_gen(self) -> Iterator: def next(self, default: Any = None) -> Any: """Return the next value, or *default* when the generator is exhausted.""" + if self._peeked: + self._peeked = False + return self._peeked_value try: return next(self._ensure_gen()) except StopIteration: @@ -56,21 +61,24 @@ def next(self, default: Any = None) -> Any: def reset(self) -> "GeneratorVariable": """Restart the generator from the beginning.""" self._gen = self._factory() + self._peeked = False + self._peeked_value = None return self def peek(self) -> Any: """ Return the next value without advancing the generator. - Uses ``itertools.chain`` internally to re-inject the peeked value. - Returns ``None`` when exhausted. + Consecutive calls to ``peek()`` return the same value until + ``next()`` or iteration consumes it. Returns ``None`` when + the generator is exhausted. """ - import itertools - + if self._peeked: + return self._peeked_value try: - value = next(self._ensure_gen()) - self._gen = itertools.chain([value], self._gen) # type: ignore[arg-type] - return value + self._peeked_value = next(self._ensure_gen()) + self._peeked = True + return self._peeked_value except StopIteration: return None @@ -168,9 +176,12 @@ def _count(): # ------------------------------------------------------------------ def __iter__(self) -> Iterator: - return self._ensure_gen() + return self def __next__(self) -> Any: + if self._peeked: + self._peeked = False + return self._peeked_value return next(self._ensure_gen()) def __repr__(self) -> str: From b306d721ebd75bf8dbb944aeee54d61155082e0e Mon Sep 17 00:00:00 2001 From: Pmaster-dev <8pinkycollie8@gmail.com> Date: Tue, 30 Jun 2026 05:11:22 -0500 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/automation/variables.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/automation/variables.py b/src/automation/variables.py index e303f0b..342421d 100644 --- a/src/automation/variables.py +++ b/src/automation/variables.py @@ -82,9 +82,15 @@ def peek(self) -> Any: except StopIteration: return None - def collect(self) -> list: - """Drain all remaining values into a list.""" - return list(self._ensure_gen()) +def collect(self) -> list: + """Drain all remaining values into a list.""" + values: list[Any] = [] + if self._peeked: + values.append(self._peeked_value) + self._peeked = False + self._peeked_value = None + values.extend(list(self._ensure_gen())) + return values # ------------------------------------------------------------------ # Composition helpers From ba1c7004408e34896c0407cba64e9685355a5aa4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:13:42 +0000 Subject: [PATCH 4/6] fix: address remaining automation review comments --- src/automation/__init__.py | 6 +++--- src/automation/components.py | 18 +++++++++++++++--- src/automation/engine.py | 4 +--- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/automation/__init__.py b/src/automation/__init__.py index 35b78ff..915c1c8 100644 --- a/src/automation/__init__.py +++ b/src/automation/__init__.py @@ -3,9 +3,9 @@ Quick-start:: - from src.automation import AutomationEngine, AutomationDefinition, TriggerEvent - from src.automation import Component, ComponentInput, ComponentRegistry - from src.automation import GeneratorVariable, VariableRegistry + from automation import AutomationEngine, AutomationDefinition, TriggerEvent + from automation import Component, ComponentInput, ComponentRegistry + from automation import GeneratorVariable, VariableRegistry engine = AutomationEngine() diff --git a/src/automation/components.py b/src/automation/components.py index e6aedb3..03712d3 100644 --- a/src/automation/components.py +++ b/src/automation/components.py @@ -10,6 +10,7 @@ from __future__ import annotations import inspect +import traceback from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional, Type @@ -131,8 +132,8 @@ def execute(self, input_: ComponentInput) -> ComponentOutput: try: result = self._fn(input_) return self._ok(result) - except Exception as exc: - return self._err(str(exc)) + except Exception: + return self._err(traceback.format_exc()) # --------------------------------------------------------------------------- @@ -174,6 +175,9 @@ def register(self, name: str, component: Component) -> "ComponentRegistry": Calls :meth:`Component.setup` and returns *self* for chaining. """ + existing = self._components.pop(name, None) + if existing is not None: + existing.teardown() component.name = component.name or name component.setup() self._components[name] = component @@ -250,7 +254,15 @@ def run(self, name: str, input_: ComponentInput) -> ComponentOutput: error=f"Validation failed: {reason}", ) - return component.execute(input_) + try: + return component.execute(input_) + except Exception: + return ComponentOutput( + component=name, + result=None, + success=False, + error=traceback.format_exc(), + ) def run_pipeline( self, diff --git a/src/automation/engine.py b/src/automation/engine.py index 321afe8..a62a526 100644 --- a/src/automation/engine.py +++ b/src/automation/engine.py @@ -345,9 +345,7 @@ def _snapshot_variables(self, names: List[str]) -> Dict[str, Any]: """ snapshot: Dict[str, Any] = {} for name in names: - var = self.variables.get(name) - if var is not None: - snapshot[name] = var.peek() + snapshot[name] = self.variables.require(name).peek() return snapshot # ------------------------------------------------------------------ From 252005e566844e51dc33e530e008094a4721f7fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:15:04 +0000 Subject: [PATCH 5/6] fix: restore generator variable collect method --- src/automation/variables.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/automation/variables.py b/src/automation/variables.py index 342421d..71eff9b 100644 --- a/src/automation/variables.py +++ b/src/automation/variables.py @@ -82,15 +82,15 @@ def peek(self) -> Any: except StopIteration: return None -def collect(self) -> list: - """Drain all remaining values into a list.""" - values: list[Any] = [] - if self._peeked: - values.append(self._peeked_value) - self._peeked = False - self._peeked_value = None - values.extend(list(self._ensure_gen())) - return values + def collect(self) -> list: + """Drain all remaining values into a list.""" + values: list[Any] = [] + if self._peeked: + values.append(self._peeked_value) + self._peeked = False + self._peeked_value = None + values.extend(list(self._ensure_gen())) + return values # ------------------------------------------------------------------ # Composition helpers From 6163e5bde2bf6f365c6b4609e6e24d2f8d928318 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:15:47 +0000 Subject: [PATCH 6/6] Apply remaining changes --- .../__pycache__/__init__.cpython-312.pyc | Bin 1663 -> 1651 bytes .../__pycache__/components.cpython-312.pyc | Bin 13231 -> 13608 bytes .../__pycache__/engine.cpython-312.pyc | Bin 16882 -> 16863 bytes .../__pycache__/variables.cpython-312.pyc | Bin 11894 -> 12130 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/src/automation/__pycache__/__init__.cpython-312.pyc b/src/automation/__pycache__/__init__.cpython-312.pyc index 4d04d4067fe4289d13d7e7ad921ced9bb62ca6ef..f698bd6c611db168b81a160e19e1f93516e6fc41 100644 GIT binary patch delta 38 tcmey*^O=YDG%qg~0}yPV=A6|uk+*?y)x^HsiT?v82QzwXp3nGz1ponl4jupi delta 53 zcmey&^Ph+JG%qg~0}zBha?0wP$lJiUZDL<8OL0-M-o)PlV0yAQqX(F=c{1Yz769fK B5_JFo diff --git a/src/automation/__pycache__/components.cpython-312.pyc b/src/automation/__pycache__/components.cpython-312.pyc index 43731a932fddcd68c756e62fdf2660f23df053f3..6c0a3f409537f45731f427d77bdb88eb599f8b91 100644 GIT binary patch delta 1600 zcmZ9MUu;uV7{JeWZ*SMz-u_u{x3yc_b!%a3w{@&tStcTXh9DV^1^MIdHVA9WV65A` zt+L7Zrv#NfnDH4kY79h4rduM=Cw(xYF(wd&I+8X>jKl|h&^R^{$X@iEJBHEo@Vnpn z{+;uE=bZal|I~oxy4h?bbX3rFOVaUJTcT;LRBKyKq?BF(4-aKouWkaM zGW)5J(ZPDGG@gfc{K|L%wqu{^JnTST`V)3%?@7e~d$YVH2oTIRSpVW+3_bQvqd*s? zi^IYU9j>dz$+Q34>41S6jzzVPgBm*H0z1Z%FRDY&2v16vBk8N`oB^YARpInkol{2*dXIW z`5X-5gk!TIrh2Ja-GuErS9abp$-_lF?F<-oRI8PF4Sk^|djgjGc#I$CPua$K?5TC@ zfQ)mvKH$LbohCEer8dU70g-m29RGF}VRKMMm&*hku5cY++YuDhUDW9|KQcU`wopke zW{6P)bxUsjbwj}<-!xSgBu8HILwKcvVtCa3bo<`-%$|pg(`p8PcqsQqs}Bi)BunSyH?s52l}YE zjCpxJU}3Fv&Ir@22Xy@Rie=xb5@4m_QB(Vq8MTV~sErIw49_xjFl@(5)jRdSQ!)Jw z8vUnw45FnVyYq4&CkM{&o|WrwO7#!#Se^4kFIw)%)rE3zp{%0d^xP?}%$J68rJ>o< z`un2bu*?&|Y?I6J9O1ZKxwx>p~ly{MThbnZ4lO$HQTSnh!Y>WG~d%0CwS( z+E$3-AGM$BwQe26>7d(vkS2J!U;1t7BnWOiT9HpbKnE&a8$ILlULB)vzYp zR@cUozEN zWGAe~8G0BD4BGjgrBV>oAp01WuBeR}w=#4xXgkq#{|4wW>~LYps=?wR>ar+qqlI+S zT6)!CJh!SHCh+G~XHRsoaCY6)eGKfTs|OekGh`UX7+7D_V+^Ml*xOgnFucLwVbI=g zC6jh6<4r20FH@W*_c&QFEQCO4Ua&}lW5LY{kvSXw*0fGvC0OP<0P7-(o<#=Ba3WH{ zsU7%HBtidyhN#ClN%g>z9mU3I3I7wZ;il+zckEs9K5;|r!Dcwl!4{Y&+DF^UKh4T} A9RL6T delta 1323 zcmYL}e@t6d6vyv*udi!q>2H2KT1xp@%Fpp*TOe)>m%(&~fgp@BR6;ufQegLW0Y(RI z!JkVu*~u~!iNOTp44TcoXrhV6MU5u>qoR=z%@XlXjZ8F4P^0IzbKaz%bME(^^WHi4 zzT0af*N1GY>FH^V#-~3%I1&pj*s@`LAs8qzj2dD}Scw_KMqm+$D5JY#rm!hy4x5v6 z<7isU5?(l!VE`62EnM`o=9S0h%zFwgeC37iq6S8;)3(paU(|UyP4Yl2(NdTLAs( zKSRH6v4sFi^*#1qC78luS9_X~LZ}MZzJ_zIuv~Oj#_wH$xrDp|sW<*-2c=#XVQ1x8 zx$~Z#gh@6f_p(WN6WT%wKSBxo0m6p)@YG;*B0fAm#t#V5K?oUmrSPi+3JDu>bZSsD zkQboA^MqbQIHR_l#tZH&$9bA)t%Rr9mSivo@us`DA-L|&y4tYNu-Liee&zU@yX0GI z$)8&?n1ef74#l3&FJjB)W~cnN+H93?8G+neR;AgAX3u4K3Ku;s5Y#t4@5r!>SF(%T z;s}x(FhQgTK+6d>V!)aLw*^G23D(I%fS1)-TS(?k zQurQ$F8n%BfZaL8juo0}bp-0d%5nZ3r~ zsa`zi?}t^q?XUIqlC)#*nx2UE9U6~KjE_af;{CjdR6c-}c{%VT9?7eNPxM6IQE+sT zz9CPHPw;&rq8m2@jj)FP{2urQUn+0J8~HO*LdJI0gCDu9`t;$o4^{i25xvzKEnceL2mA5!>enHTvE2=D z5Z~B+x?Sug9}ox&m;{nL`8;_c;2~j5sdE$`Eq_Shm_V|Er1pP;5t}?JK2tO59Ho#Q z^CQ$iANA1BV<^=gg%_}`_Qkm#kuDCF_X`vVL2xlV$Qo6KU3B z7Le-eCYDYPKt=<@2R;TBwGNIOB9fmN7&#poJ6vwaD$b9a8FyXQ_@b=w2A9jSme*zN zF3Q?nmUZaxm=MzC_kkIt@BzR0=15a6M#kjHDP}(zGd9PWFK1+2KAFu@kM$OFdTPmJ zOG`P%Rg)tutr>GRPqA!ZWL!O2)Y_e~W^=5y1ry`q$KVj_M{LB6Zqe3Y&BiBa;Fj>XUAf$9zP`QEcD;pDI0ArCVP#*v$nOqzI delta 326 zcmccL%=oF9k@qw&FBbz4?0MvrmAsKR%2=cd$eYfP$xzEw!|1{gYs|n<%RKp`lGxQR_{+Qib^9mr~6_`t`YqSnE2Lqzfu12d;ihszBivFUmf^{xx4Toh7S z5OP^atHb#QzffoW4MmmZ5(_03OHJUIAu@^Y13OUp2Mz`Si4OkBr&OgUC!4-yOr0EP z_LDJdv$y$jM#dGBKU?Ur7R?3PGg-}2j&b#5S4(Tgyv>c44UCLyCNo*PGuCbPvbJDi zTr#=Yb`#q&pu(aRldbIzvGTA=d{LRqWdDS*fAe$u8;lC&%#2(g8Ng&6JA;tYWkKZz PzOQUdi~)>Asz7}JgY9DQ diff --git a/src/automation/__pycache__/variables.cpython-312.pyc b/src/automation/__pycache__/variables.cpython-312.pyc index 47dac5d0c407683c9a0249c4ea74dde822bf718d..d03c19e13ae340f4e943360a59b84f2dc3eb8d6d 100644 GIT binary patch delta 1452 zcmZ9Mdq`VX7{KqBmvPB$sxgTsY0Oq_y~ZS+uU3uKO|3$!o!x^yq+-pTHI0oqNyJ)R z9kWb_EQ7C9M@#8mu(JL$^3R}bQ0NA3gRNB85LhW=u)#KPvvg&!vGd&HQb**wk439!HE>PKe;YSAmV^;u2C%KfeuhcY5Fy7y&$JNH zJ!InQN1)n32*b26_+LO+!!^yO$}1LZ%3Eu)a8u^BO?er0CfD|@s?+eaAZc+}l=dI! zlMKwt!*Yh9@p1WNn3X7sJelN5P8S_bB$=C=Bn`7Eo{6DoN=_u`HN5zq7(CL)rCH&w z+cR&-nk?zi8=zYkfTBM8j5-#j)#j{9ChT>DKD)$_o)%@6xG34W{t<0l*DG$TxER%rZHme_+O&p zY*Qm{Sc5Q`xo2$#2x8DS01LQiJLFOJ(W}kT$CAfo#=0n>gV2fhZM86$k?aEySmKrh zeUb;W!o~W_`U@?y0xRXZ1Wckuo6`xs4Of8m^Cdx}G0W@} z(ua3T>!gch6*<(Zw#x+-#xuA;UoP1Y__FTKyye}{z}Gmp^Mm3958-n| z^UP9&32doofNwBf@uip~XMS!{*i#vTRXkgHt(ELK?TDPbqTU20Q@sXW^?S_lh^dn2 zhS=!Pv8a4R9-+H)D!@~6nVDUMuw5x(JSd;)Vl2kkD|mi)E!@GC-D?J=1xb9rY9IWF z-&fsnP%Nt^?BvMbJ~oAGQX~9~=4xi3Q}7yd)gkx|uUE$*iFLk4SjUL(l2<0@YXldE zGBx?Pr2b5mVZYB>z~pf@IJkjN)-<0vPO+ne2;l_5L5LB|gh>MZT`@i3G{H!qgR@r& z$}!H8^g1C*SRjlM776P2A&Ev`?Oa^oby%G1pQF~lQbx0k^e+Wb+<6p^(OUwWO delta 1182 zcmY+De`r%z6vyAmueD9n#Q2)Tq-mnINne{ETkVh8AG2De#UBM7R)?L~*0;7A8hetu zX}30Pl)+%29@vD6sGv}of(AkG4-rJD8&mOD$-qCVLlDIuWmEP?**)*IitmrlJ@yQucA3nC}-ac z-4t=OSrrkAaYl?r9Z~o)`eeskV<<+b3sE zru$XIHN2pOv6}vNRl{i3R<)V>Ktt6O9=OJz}l^hIugNjJ=5zt`jz{ zr&FF5_?aGg?t0pVV-m1K3@I*h2Dx^)fo?8Yq5Q&Ln}D9R_{2vO&GUItE06&YcK%A2(BtuN{ujcfQa9|=}Dj-S}715Dd)>$1>+Ne zoT(?7Okf?G#(}{!HnXypt^{4mCN{S)IvHJzos3-qIiu(=?B!{M=7Ph826M$xc~%m` zOGkEW>8t^+Q>yH=bDszieWSGoa(Id=%WGkZc9q|-4Y4@iH%RrNCis)mq07ze&l&sF z^rYea%9KHPgF*d&92E9Jxw9Jwx_c7p7PXK2{Xk4CxtmRd^TDB|$lIVEScjNI9HH@w zYM7==756M!35JN2PvIf`p*-|zv6lVaiD5N`%lS<6+kQ<~E9+o}-d3W8FCx