From 3aa4ec7cfa61a3df9792eef4efdc537291e46db2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:44:49 +0000 Subject: [PATCH 1/3] chore: outline guardrail + lifecycle plan --- .../__pycache__/__init__.cpython-312.pyc | Bin 1651 -> 1651 bytes .../__pycache__/components.cpython-312.pyc | Bin 13608 -> 13608 bytes .../__pycache__/engine.cpython-312.pyc | Bin 16863 -> 16863 bytes .../__pycache__/variables.cpython-312.pyc | Bin 12130 -> 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 fbd9601002c3339b48fac047ad368b25faf3edfb..200ccdbc280937f035d1ca7a719a41a8c06beb8f 100644 GIT binary patch delta 19 Zcmey&^O=Y1G%qg~0}$wkZ{*5k0{}O{1gHQ2 delta 19 Zcmey&^O=Y1G%qg~0}wQLY~;#h0{}T(1nK|) diff --git a/src/automation/__pycache__/components.cpython-312.pyc b/src/automation/__pycache__/components.cpython-312.pyc index 3a8789d8d616acdf9303ad8392bc317acbbb52c5..03008df85588148bafd4cb50daead70ee09efc18 100644 GIT binary patch delta 19 ZcmZ3HwIYk_G%qg~0}$wkZ{(6Q1pqlp1hN1C delta 19 ZcmZ3HwIYk_G%qg~0}wQLY~+$N1pqqb1oQv^ diff --git a/src/automation/__pycache__/engine.cpython-312.pyc b/src/automation/__pycache__/engine.cpython-312.pyc index c5802b8282726c0222a58f25aff1c5ca8d1cba40..1d74b62533c671e46711e9ecf9fac96f0a6704c3 100644 GIT binary patch delta 21 bcmccL%y_? Date: Sat, 4 Jul 2026 00:46:20 +0000 Subject: [PATCH 2/3] feat: add start/stop lifecycle guardrail and TextFileGuardrail component --- src/automation/__init__.py | 9 + .../__pycache__/__init__.cpython-312.pyc | Bin 1651 -> 1787 bytes .../__pycache__/engine.cpython-312.pyc | Bin 16863 -> 19384 bytes src/automation/engine.py | 60 ++++++ src/automation/guardrails.py | 131 +++++++++++++ tests/test_automation_review_regressions.py | 181 ++++++++++++++++++ 6 files changed, 381 insertions(+) create mode 100644 src/automation/guardrails.py diff --git a/src/automation/__init__.py b/src/automation/__init__.py index 915c1c8..703e853 100644 --- a/src/automation/__init__.py +++ b/src/automation/__init__.py @@ -31,6 +31,7 @@ from .engine import ( AutomationDefinition, AutomationEngine, + EngineNotStartedError, RunResult, RunStatus, TriggerEvent, @@ -48,6 +49,10 @@ GeneratorVariable, VariableRegistry, ) +from .guardrails import ( + DEFAULT_TEXT_EXTENSIONS, + TextFileGuardrail, +) __all__ = [ # Engine @@ -56,6 +61,7 @@ "TriggerEvent", "RunResult", "RunStatus", + "EngineNotStartedError", "default_engine", "trigger", # Components @@ -67,4 +73,7 @@ # Variables "GeneratorVariable", "VariableRegistry", + # Guardrails + "DEFAULT_TEXT_EXTENSIONS", + "TextFileGuardrail", ] diff --git a/src/automation/__pycache__/__init__.cpython-312.pyc b/src/automation/__pycache__/__init__.cpython-312.pyc index 200ccdbc280937f035d1ca7a719a41a8c06beb8f..f646cb45582af0e1f8c82c1692d65b4b0d5970c7 100644 GIT binary patch delta 383 zcmXxf%SyvQ6b9f-F3qJ)+N4dYsA~<~b}5Kr>V*;)i72xv(S(wcNn{d5m+sw^yn$~Z z;=*U}1td?Q7RkbmGerl^#|(%6%*>+oqiS!esxV|dpB>ITajva^V!kQlP#d2s=x1yc2CFt#COj92`R_*j!s;!(*5YR7{y6+ za2?XSqYcsnr1Bx*g98A3vg?4aw`IWA3}Hd3dkw&T UGlW{>2om8-v6}3?h}TH)7mZh3qyPW_ delta 253 zcmey(`Glxh!V&Zj1pvINM}f4UBnnARLQ2vzHz};W@h$VB9j@JMJDI42v45I zvO|Wihy`dz5i^Kj2NA3wf(=A)OrF82&FrTsJozfC3C}IAlH$n=SS5vS zah4?(WhN%&q!t(PgVYO5j%JH;lms#vfw)*7NPJ*sWMurvB*Vz^-G+ma={p0E_y{7t d3$ZaWePsX=-}#ssnLcp|aWgft7l{M)004%SICcO4 diff --git a/src/automation/__pycache__/engine.cpython-312.pyc b/src/automation/__pycache__/engine.cpython-312.pyc index 1d74b62533c671e46711e9ecf9fac96f0a6704c3..d83cac840e2459b2ae04330a771bb147d1072ab1 100644 GIT binary patch delta 5309 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 LyG-sS!hZh)xg0PP delta 3036 zcmZuzYiv}<6~1%#;eB}5_O912Y*-uoxPD_GF&OaLV2m*dk2E1!a4(DJ+N@dcZsx8X z>^d=NO%%0BRC1)!&>wBpHVF`^RGT!ds8FJ$jjBpbt4gVK`vd+|N-L!b3aM12sy%1! z+Srh_{LRdF&U|<7%sFT7uP(Bi-(tZ(1Oh$uuiedO~sFOXJZx4G}*I{z(BtuZt zQooL1JwY$Qb^?3zQb{uh&xgu8El*ZtvWCdb*{eN7KKE_jF5(D=;Pvt|Y!_}`b-pdq zq%sYTRyMJAn5&HKT>A)dlqSUqP7r*Z;6B~Qi6h9tmzAUJanfD<5XTo4Ly0r4#8uAX z4j$p^k~8kau&Lw@j2f3$;T2wG^&hX|PPDk!aMj#}^NM&4cVp|}>v$2iUS7*R*!r;b zV(aI1+=p#3U(fwZqt1AMZ{Wp91bLL#^B~HW@CIIjOex>UOR+6O%O+limJn~`A!Ne5 ziHET*=gqtv+sIr%Ysv4czN@mcFi<O(ndP>E-W?Pc!<<2zc428{0*T`q&4R~RZE>n+saN`xgec_ zvrYBvI$Ub{C_*P{xw$cc;wvkH+2(`nx9~ypjQ~fxT%rulS6XVF8D!uWtznNn>CX!M zzU9LZ`B{!^CVLr!Sk8g(Yg^S%6!@d|zF(@ykeP)`t=+1nz{hRX@b}hh?m2Rvg&(x_ zs5goII5I&}?nE|gr1`SiH00ZR)lU`pto;N3Bu#maU=gl&Jfwc6z!x2TJ`0(P%B1qF z3f@ZHWZY#RuBO!iXg0-JKfks(N$0@AUg z#|zzEeg~Vs1_NCOz|}o5s1*tMnr@lVrkw*a`=6vGJ9R8Qa$TBB?t$epX7Qw~B4L;_ zshlZH2yR}-`k{4mUEtq1#hgYsr+gmFXEujfkCc)Xk;))$xU6zijS7Ndt%rC4-tOsV z-u#`OAy=q}{2Z(5QVd3W+dXu^NIwOOy^#*m{m1|CH?Qu9T0UJjWK`+;K8zb#oL)qa zLWP3&dpj1l(0D7sHUe2XDyX-CKpvHM=4bz|bm%jO^l_RL!%=tdYX_6i<;sZjda@vXC|qx0j$E z!E)y2u=>er`5au@9&Oq~CWC;&M)TRxX%g`Q!ES;V;nwzs4J2wgaOtKGdzguZpPi8B zp?*gV3*~q2xbBF_w83+IdL}6h%b7KdGlk5vT~#K0><#wV8${DK$z8L& zr{;7MZ`K)8^H`3Qk+$DDMh=f4SdK{}XUnWiS_#w~-pMI(1%5i%$5Q!QgXdV}Dovz# zzxND${>2}aU5uFyOz;4Y+n+xCvPz?**&Z5({N^}aZz3+ zD_(a)&G*;4<~5>OQYI-OMZ+ZO6!I+P-6Pp|Y*v=l6=^Z{RjgOMZPU}>-PaYkw^FB= zhM|4+i>nzKEixmCy<2h+OYZTbGwGu;xyQsQWG{Q{6qV1Ff$Bll{;H8ZDfmlzsC(7A z*GkJI!}t23f+;7Ck${{gzXY7(CrHc(4#|W|q>d-kxiQ-=WmRz)s)idEkI*x4K(lEz zTE$m>LL|v96F)`0rwORQi)RSt2^I*>5qy(CejI&=dglp%;AaFf9IsI?uP8kad>ou4 z3&G3yZ(c!IQf{kNF3%TRy{;I$vmT=RbFMO1<((#YZGVfKT*pOi;V)$y&jr79tL!k7AGQAnZsoDc diff --git a/src/automation/engine.py b/src/automation/engine.py index a62a526..b973f3c 100644 --- a/src/automation/engine.py +++ b/src/automation/engine.py @@ -56,6 +56,10 @@ class RunStatus(Enum): SKIPPED = "skipped" +class EngineNotStartedError(RuntimeError): + """Raised when :meth:`AutomationEngine.trigger` is called before :meth:`start`.""" + + @dataclass class TriggerEvent: """Represents an event that can trigger an automation run.""" @@ -171,11 +175,54 @@ def __init__(self) -> None: self._automations: Dict[str, AutomationDefinition] = {} self._history: List[RunResult] = [] + self._running: bool = False # Middleware hooks: callables invoked before/after each run self._before_run: List[Callable[[RunResult, TriggerEvent], None]] = [] self._after_run: List[Callable[[RunResult], None]] = [] + # ------------------------------------------------------------------ + # Lifecycle: start / stop + # ------------------------------------------------------------------ + + def start(self) -> "AutomationEngine": + """ + Start the engine, allowing automation runs. + + Must be called before :meth:`trigger` / :meth:`trigger_type`. + Calling :meth:`start` on an already-running engine is a no-op. + Returns *self* for chaining. + """ + if not self._running: + self._running = True + logger.info("AutomationEngine started") + return self + + def stop(self) -> "AutomationEngine": + """ + Stop the engine and tear down all registered components. + + After :meth:`stop` returns, :meth:`trigger` / :meth:`trigger_type` + will raise :class:`EngineNotStartedError` until :meth:`start` is + called again. All component teardown hooks are invoked; any + exceptions they raise are logged and suppressed. + Returns *self* for chaining. + """ + if self._running: + self._running = False + for name in list(self.components.names()): + try: + self.components.deregister(name) + except Exception: + logger.exception("teardown of component %r raised an exception", name) + logger.info("AutomationEngine stopped") + return self + + @property + def is_running(self) -> bool: + """``True`` while the engine is between :meth:`start` and :meth:`stop`.""" + return self._running + # ------------------------------------------------------------------ # Component & variable shortcuts # ------------------------------------------------------------------ @@ -247,9 +294,19 @@ def trigger(self, event: TriggerEvent) -> List[RunResult]: """ Dispatch *event* to all matching, enabled automations. + Raises: + EngineNotStartedError: if the engine has not been started via + :meth:`start`. + Returns the list of :class:`RunResult` objects produced (one per matching automation). """ + if not self._running: + raise EngineNotStartedError( + "AutomationEngine must be started before triggering events. " + "Call engine.start() first." + ) + results: List[RunResult] = [] for automation in self._automations.values(): @@ -391,7 +448,10 @@ def __repr__(self) -> str: # --------------------------------------------------------------------------- #: Default engine instance – use this for simple single-engine setups. +#: Pre-started for convenience; create a fresh :class:`AutomationEngine` and +#: call :meth:`~AutomationEngine.start` explicitly for production use. default_engine = AutomationEngine() +default_engine.start() def trigger(event_type: str, payload: Any = None, **metadata: Any) -> List[RunResult]: diff --git a/src/automation/guardrails.py b/src/automation/guardrails.py new file mode 100644 index 0000000..9102ad6 --- /dev/null +++ b/src/automation/guardrails.py @@ -0,0 +1,131 @@ +""" +Built-in guardrail components for the serverless automation engine. + +Guardrails are :class:`~automation.components.Component` subclasses that act as +safety gates in a pipeline. They validate their input and return an error +output (without raising) when the input violates a policy, letting the engine +record the failure and invoke after-run hooks as normal. + +Included guardrails +------------------- +* :class:`TextFileGuardrail` – validates payloads that reference ``.txt`` (or + other significant text-based) file paths, blocking path-traversal attacks, + null bytes, and disallowed file extensions. +""" + +from __future__ import annotations + +import os +from typing import FrozenSet + +from .components import Component, ComponentInput, ComponentOutput + +# --------------------------------------------------------------------------- +# Allowed text-file extensions +# --------------------------------------------------------------------------- + +#: Extensions that are treated as significant text files and subjected to +#: guardrail checks when a payload string ends with one of these suffixes. +#: The set is intentionally conservative; extend via subclassing or by +#: passing *allowed_extensions* to :class:`TextFileGuardrail`. +DEFAULT_TEXT_EXTENSIONS: FrozenSet[str] = frozenset( + {".txt", ".text", ".log", ".csv", ".tsv", ".md", ".rst", ".ini", ".cfg"} +) + + +# --------------------------------------------------------------------------- +# TextFileGuardrail +# --------------------------------------------------------------------------- + +class TextFileGuardrail(Component): + """ + Safety guardrail for text-file path payloads. + + When this component is included as a step in an automation pipeline it + inspects the current ``payload`` (expected to be a file path string) and + rejects it if any of the following conditions are true: + + * The payload is not a string. + * The file extension is not in *allowed_extensions*. + * The path contains ``..`` (directory traversal). + * The path contains a null byte. + * The path is an absolute path (configurable via *allow_absolute*). + + On success the payload is passed through unchanged so that subsequent + pipeline steps can continue processing it. + + Args: + allowed_extensions: Set of lowercase file extensions (including the + leading dot) that are permitted. Defaults to + :data:`DEFAULT_TEXT_EXTENSIONS`. + allow_absolute: When ``False`` (default) absolute paths are rejected. + + Example:: + + engine = AutomationEngine() + engine.start() + engine.register_fn("read_file", lambda inp: open(inp.payload).read()) + engine.components.register("guard", TextFileGuardrail()) + + engine.define(AutomationDefinition( + name="safe_read", + triggers=["file.read"], + steps=["guard", "read_file"], + )) + + results = engine.trigger_type("file.read", payload="notes.txt") + """ + + name = "text_file_guardrail" + description = ( + "Validates text-file path payloads: rejects path traversal, null bytes, " + "absolute paths, and disallowed extensions." + ) + + def __init__( + self, + allowed_extensions: FrozenSet[str] = DEFAULT_TEXT_EXTENSIONS, + allow_absolute: bool = False, + ) -> None: + self._allowed_extensions = frozenset(ext.lower() for ext in allowed_extensions) + self._allow_absolute = allow_absolute + + def validate(self, input_: ComponentInput) -> tuple[bool, str]: + payload = input_.payload + + if not isinstance(payload, str): + return False, ( + f"TextFileGuardrail: payload must be a string file path, " + f"got {type(payload).__name__!r}" + ) + + if "\x00" in payload: + return False, "TextFileGuardrail: payload contains a null byte" + + if not self._allow_absolute and os.path.isabs(payload): + return False, ( + "TextFileGuardrail: absolute paths are not permitted; " + f"got {payload!r}" + ) + + # Normalise separators then check for traversal components + normalised = payload.replace("\\", "/") + parts = normalised.split("/") + if ".." in parts: + return False, ( + "TextFileGuardrail: path traversal ('..') detected in " + f"{payload!r}" + ) + + _, ext = os.path.splitext(payload) + if ext.lower() not in self._allowed_extensions: + return False, ( + f"TextFileGuardrail: file extension {ext!r} is not in the " + f"allowed set {sorted(self._allowed_extensions)}" + ) + + return True, "" + + def execute(self, input_: ComponentInput) -> ComponentOutput: + """Pass the validated payload through unchanged.""" + return self._ok(input_.payload) diff --git a/tests/test_automation_review_regressions.py b/tests/test_automation_review_regressions.py index 844291f..568c103 100644 --- a/tests/test_automation_review_regressions.py +++ b/tests/test_automation_review_regressions.py @@ -1,6 +1,10 @@ +import pytest + from automation import __doc__ as automation_doc +from automation import EngineNotStartedError from automation.components import Component, ComponentInput, ComponentRegistry, FunctionComponent from automation.engine import AutomationDefinition, AutomationEngine, RunStatus +from automation.guardrails import DEFAULT_TEXT_EXTENSIONS, TextFileGuardrail from automation.variables import GeneratorVariable @@ -54,6 +58,7 @@ def broken(input_): def test_missing_variables_fail_run_instead_of_being_silently_dropped(): engine = AutomationEngine() + engine.start() engine.register_fn("echo", lambda input_: input_.payload) engine.define( AutomationDefinition( @@ -81,3 +86,179 @@ def test_quick_start_uses_package_imports(): assert automation_doc.strip() assert "from automation import AutomationEngine" in automation_doc assert "from src.automation import" not in automation_doc + + +# --------------------------------------------------------------------------- +# Lifecycle guardrail: start / stop +# --------------------------------------------------------------------------- + +def test_trigger_before_start_raises_engine_not_started_error(): + engine = AutomationEngine() + engine.register_fn("echo", lambda inp: inp.payload) + engine.define(AutomationDefinition(name="a", triggers=["x"], steps=["echo"])) + + with pytest.raises(EngineNotStartedError): + engine.trigger_type("x") + + +def test_trigger_after_start_succeeds(): + engine = AutomationEngine() + engine.start() + engine.register_fn("echo", lambda inp: inp.payload) + engine.define(AutomationDefinition(name="a", triggers=["x"], steps=["echo"])) + + results = engine.trigger_type("x", payload="hello") + assert len(results) == 1 + assert results[0].status is RunStatus.SUCCESS + + +def test_trigger_after_stop_raises_engine_not_started_error(): + engine = AutomationEngine() + engine.start() + engine.stop() + + with pytest.raises(EngineNotStartedError): + engine.trigger_type("x") + + +def test_start_is_idempotent(): + engine = AutomationEngine() + engine.start() + engine.start() # second call must not raise + assert engine.is_running is True + + +def test_stop_calls_component_teardown(): + events = [] + + class TrackedComponent(Component): + def teardown(self): + events.append("teardown") + + def execute(self, input_: ComponentInput): + return self._ok(input_.payload) + + engine = AutomationEngine() + engine.start() + engine.components.register("tracked", TrackedComponent()) + engine.stop() + + assert "teardown" in events + assert engine.is_running is False + + +def test_restart_after_stop_works(): + engine = AutomationEngine() + engine.start() + engine.stop() + engine.start() + engine.register_fn("echo", lambda inp: inp.payload) + engine.define(AutomationDefinition(name="a", triggers=["x"], steps=["echo"])) + + results = engine.trigger_type("x", payload="hi") + assert results[0].status is RunStatus.SUCCESS + + +# --------------------------------------------------------------------------- +# TextFileGuardrail +# --------------------------------------------------------------------------- + +def test_text_file_guardrail_passes_valid_txt(): + guardrail = TextFileGuardrail() + inp = ComponentInput(name="guard", payload="notes.txt") + valid, reason = guardrail.validate(inp) + assert valid is True + assert reason == "" + + +def test_text_file_guardrail_passes_through_payload_on_execute(): + guardrail = TextFileGuardrail() + inp = ComponentInput(name="guard", payload="report.csv") + out = guardrail.execute(inp) + assert out.success is True + assert out.result == "report.csv" + + +def test_text_file_guardrail_rejects_path_traversal(): + guardrail = TextFileGuardrail() + for path in ["../secrets.txt", "a/../../etc/passwd.txt", "..\\config.txt"]: + inp = ComponentInput(name="guard", payload=path) + valid, reason = guardrail.validate(inp) + assert valid is False, f"Expected rejection for {path!r}" + assert "traversal" in reason.lower() or ".." in reason + + +def test_text_file_guardrail_rejects_disallowed_extension(): + guardrail = TextFileGuardrail() + inp = ComponentInput(name="guard", payload="script.exe") + valid, reason = guardrail.validate(inp) + assert valid is False + assert "extension" in reason.lower() + + +def test_text_file_guardrail_rejects_absolute_path(): + guardrail = TextFileGuardrail(allow_absolute=False) + inp = ComponentInput(name="guard", payload="/etc/hosts.txt") + valid, reason = guardrail.validate(inp) + assert valid is False + assert "absolute" in reason.lower() + + +def test_text_file_guardrail_allows_absolute_when_configured(): + guardrail = TextFileGuardrail(allow_absolute=True) + inp = ComponentInput(name="guard", payload="/var/log/app.log") + valid, reason = guardrail.validate(inp) + assert valid is True + + +def test_text_file_guardrail_rejects_null_byte(): + guardrail = TextFileGuardrail() + inp = ComponentInput(name="guard", payload="file\x00.txt") + valid, reason = guardrail.validate(inp) + assert valid is False + assert "null" in reason.lower() + + +def test_text_file_guardrail_rejects_non_string_payload(): + guardrail = TextFileGuardrail() + inp = ComponentInput(name="guard", payload=12345) + valid, reason = guardrail.validate(inp) + assert valid is False + assert "string" in reason.lower() + + +def test_text_file_guardrail_in_pipeline_blocks_bad_payload(): + engine = AutomationEngine() + engine.start() + engine.components.register("guard", TextFileGuardrail()) + engine.register_fn("process", lambda inp: f"processed:{inp.payload}") + engine.define(AutomationDefinition( + name="safe_process", + triggers=["file.process"], + steps=["guard", "process"], + )) + + results = engine.trigger_type("file.process", payload="../bad.txt") + assert len(results) == 1 + assert results[0].status is RunStatus.FAILED + + +def test_text_file_guardrail_in_pipeline_passes_valid_payload(): + engine = AutomationEngine() + engine.start() + engine.components.register("guard", TextFileGuardrail()) + engine.register_fn("process", lambda inp: f"processed:{inp.payload}") + engine.define(AutomationDefinition( + name="safe_process", + triggers=["file.process"], + steps=["guard", "process"], + )) + + results = engine.trigger_type("file.process", payload="data.txt") + assert len(results) == 1 + assert results[0].status is RunStatus.SUCCESS + + +def test_default_text_extensions_contains_txt(): + assert ".txt" in DEFAULT_TEXT_EXTENSIONS + From 9783bf30965f9249780ed6e80fd3d2283942a366 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:47:30 +0000 Subject: [PATCH 3/3] fix: address review feedback - error message assertion and docstring clarity --- src/automation/guardrails.py | 7 ++++--- tests/test_automation_review_regressions.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/automation/guardrails.py b/src/automation/guardrails.py index 9102ad6..7e71d96 100644 --- a/src/automation/guardrails.py +++ b/src/automation/guardrails.py @@ -8,9 +8,10 @@ Included guardrails ------------------- -* :class:`TextFileGuardrail` – validates payloads that reference ``.txt`` (or - other significant text-based) file paths, blocking path-traversal attacks, - null bytes, and disallowed file extensions. +* :class:`TextFileGuardrail` – validates payloads that reference text-based + file paths (any extension in :data:`DEFAULT_TEXT_EXTENSIONS`, including + ``.txt``, ``.log``, ``.csv``, etc.), blocking path-traversal attacks, null + bytes, absolute paths, and disallowed file extensions. """ from __future__ import annotations diff --git a/tests/test_automation_review_regressions.py b/tests/test_automation_review_regressions.py index 568c103..1fa18d3 100644 --- a/tests/test_automation_review_regressions.py +++ b/tests/test_automation_review_regressions.py @@ -97,7 +97,7 @@ def test_trigger_before_start_raises_engine_not_started_error(): engine.register_fn("echo", lambda inp: inp.payload) engine.define(AutomationDefinition(name="a", triggers=["x"], steps=["echo"])) - with pytest.raises(EngineNotStartedError): + with pytest.raises(EngineNotStartedError, match="start\\(\\)"): engine.trigger_type("x")