clankloop 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
clankloop/core/env.py ADDED
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env python3
2
+ """Environment and templating — key-path navigation, values, and ClankTemplate rendering.
3
+
4
+ The environment is a tree of :class:`Object` nodes holding :class:`Value` leaves
5
+ and :class:`Locator` aliases. Values are accessed via dotted key paths (e.g.
6
+ ``pipeline.globals.workdir``).
7
+
8
+ :class:`ClankTemplate` allows recursive rendering of templates with values from
9
+ the environment.
10
+ """
11
+
12
+
13
+ import logging
14
+ from dataclasses import dataclass
15
+ from typing import Callable, Iterable, Iterator, Protocol, runtime_checkable
16
+ from string import Template
17
+
18
+ from clankloop.core.errors import (
19
+ ImmutableValueError,
20
+ UnassignedValueError,
21
+ UnknownKeyError,
22
+ )
23
+
24
+ logger = logging.getLogger("clankloop")
25
+
26
+ __all__ = [
27
+ "Object",
28
+ "Value",
29
+ "EnvPath",
30
+ "ClankTemplate",
31
+ "data_dependencies",
32
+ ]
33
+
34
+
35
+ RESULT_T = str | int
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class EnvPath:
40
+ """A first-class environment path, stored as segments.
41
+
42
+ The dotted-string form (e.g. ``pipeline.globals.workdir``) is the
43
+ serialization, produced by :meth:`__str__` and consumed by :meth:`parse`.
44
+ The env module stores and navigates paths structurally via segments held
45
+ privately; callers interact through semantic accessors (:meth:`head`,
46
+ :meth:`tail`, :attr:`leaf`) rather than indexing.
47
+
48
+ ``Object.evaluate`` / ``set_value`` / ``locate`` take ``EnvPath``;
49
+ callers parse dotted strings at the boundary via :meth:`parse`, or
50
+ construct from segments via :meth:`of`.
51
+ """
52
+
53
+ _segments: tuple[str, ...]
54
+
55
+ @classmethod
56
+ def parse(cls, dotted: str) -> "EnvPath":
57
+ return cls(tuple(dotted.split("."))) if dotted else cls(())
58
+
59
+ @classmethod
60
+ def of(cls, *segments: str) -> "EnvPath":
61
+ """Construct a path from its segments."""
62
+ return cls(segments)
63
+
64
+ def __str__(self) -> str:
65
+ return ".".join(self._segments)
66
+
67
+ @property
68
+ def leaf(self) -> str:
69
+ return self._segments[-1]
70
+
71
+ @property
72
+ def head(self) -> str:
73
+ return self._segments[0]
74
+
75
+ def tail(self) -> "EnvPath | None":
76
+ """Return the path minus its first segment, or ``None`` if there is no
77
+ segment beyond the head."""
78
+ if len(self._segments) < 2:
79
+ return None
80
+ return EnvPath(self._segments[1:])
81
+
82
+
83
+ class ClankTemplate(Template):
84
+ idpattern = r"(?a:[_a-z][_a-z0-9\.]*)"
85
+
86
+ def render(self, root: "Object") -> str:
87
+ """Substitute every identifier by evaluating it against *root*."""
88
+ variables: dict[str, str] = {}
89
+ for parameter in self.get_identifiers():
90
+ variables[parameter] = str(root.evaluate(EnvPath.parse(parameter)))
91
+ return self.substitute(variables)
92
+
93
+
94
+ #: What a :class:`Value` stores: an evaluated literal or an (immutable)
95
+ #: template re-rendered on read. Extends :data:`RESULT_T` with the template
96
+ #: case; runtime writes (``set_value``) take only literals.
97
+ VALUE_T = RESULT_T | ClankTemplate
98
+
99
+
100
+ @runtime_checkable
101
+ class Locator(Protocol):
102
+ """Protocol for objects that can locate values by key path."""
103
+
104
+ def locate(self, key_path: EnvPath, root: "Object") -> "Object": ...
105
+
106
+
107
+ @dataclass
108
+ class Value:
109
+ """A leaf in the environment holding a literal or a render template.
110
+
111
+ The stored ``value`` is either a literal (``str`` or ``int``) returned
112
+ verbatim, or a :class:`ClankTemplate` re-rendered against the env on each
113
+ read. Which it is is determined by type, not a flag.
114
+
115
+ A mutable value (``immutable=False``) cannot hold a template: rendering
116
+ re-interprets the stored string as a template at read time, so a mutable
117
+ template value would let a runtime write (e.g. a task's stdout captured
118
+ into a global) inject template identifiers whose transitive read set is
119
+ not statically knowable. Restricting templates to immutable values keeps
120
+ the transitive read set a compile-time property.
121
+ """
122
+
123
+ value: VALUE_T
124
+ immutable: bool = True
125
+ assigned: bool = False
126
+
127
+ def __post_init__(self) -> None:
128
+ if isinstance(self.value, ClankTemplate) and not self.immutable:
129
+ raise ImmutableValueError(
130
+ "A mutable Value cannot hold a render template "
131
+ "(immutable=False with a ClankTemplate value is not allowed)"
132
+ )
133
+
134
+ def evaluate(self, context: "Object") -> RESULT_T:
135
+ if not self.assigned:
136
+ raise UnassignedValueError("Accessed unassigned literal value")
137
+
138
+ if isinstance(self.value, ClankTemplate):
139
+ return self.value.render(context)
140
+
141
+ return self.value
142
+
143
+ def set_value(self, new_value: RESULT_T) -> None:
144
+ if self.immutable and self.assigned:
145
+ raise ImmutableValueError("Cannot modify assigned immutable literal value")
146
+
147
+ self.assigned = True
148
+ self.value = new_value
149
+
150
+ def template_identifiers(self) -> frozenset[str]:
151
+ """Identifiers embedded in this value's template, or empty if literal."""
152
+ if isinstance(self.value, ClankTemplate):
153
+ return frozenset(self.value.get_identifiers())
154
+ return frozenset()
155
+
156
+
157
+ @dataclass
158
+ class Alias(Locator):
159
+ """Represents an alias to another object in the environment."""
160
+
161
+ alias_fn: Callable[["Object"], Locator]
162
+
163
+ def locate(self, key_path: EnvPath, root: "Object") -> "Object":
164
+ return self.alias_fn(root).locate(key_path, root)
165
+
166
+
167
+ class Object(Locator):
168
+ """Represents a node in the environment tree, holding values and locators."""
169
+
170
+ def __init__(self, entries: dict[str, Value | Locator]) -> None:
171
+ self.entries = entries
172
+
173
+
174
+ def evaluate(self, key_path: EnvPath) -> RESULT_T:
175
+ """Evaluate the value at the given key path, resolving any aliases and
176
+ rendering templates as needed."""
177
+
178
+ value = self.locate(key_path, self)
179
+ logger.debug(
180
+ "Evaluating key_path",
181
+ extra={"key_path": str(key_path), "value": value},
182
+ )
183
+ leaf = value.entries[key_path.leaf]
184
+ assert isinstance(leaf, Value), f"Leaf at {key_path!s} is not a Value"
185
+ return leaf.evaluate(self)
186
+
187
+ def locate(self, key_path: EnvPath, root: "Object") -> "Object":
188
+ """Locate the object corresponding to the given key path, resolving any aliases."""
189
+ item_key = key_path.head
190
+ tail = key_path.tail()
191
+
192
+ logger.debug("Locating key", extra={"item_key": item_key, "tail": tail})
193
+ if item_key not in self.entries:
194
+ logger.debug("Key %s not in %s", item_key, root)
195
+ raise UnknownKeyError(f"Object has no attribute {item_key!r}")
196
+
197
+ if tail is None:
198
+ logger.debug(
199
+ "Returning value for key",
200
+ extra={"item_key": item_key, "value": self.entries[item_key]},
201
+ )
202
+ return self
203
+
204
+ logger.debug(
205
+ "Navigating to nested key",
206
+ extra={"tail": tail, "item_key": item_key},
207
+ )
208
+ next_locator = self.entries[item_key]
209
+ assert isinstance(
210
+ next_locator, Locator
211
+ ), f"Intermediate at {item_key!r} is not a Locator"
212
+ return next_locator.locate(tail, root)
213
+
214
+ def set_value(self, key_path: EnvPath, value: RESULT_T) -> None:
215
+ """Set the value at the given key path, resolving any aliases and
216
+ ensuring immutability rules are respected."""
217
+
218
+ logger.debug(
219
+ "Setting value for key_path",
220
+ extra={"key_path": str(key_path), "value": value},
221
+ )
222
+ value_obj = self.locate(key_path, self)
223
+ leaf = value_obj.entries[key_path.leaf]
224
+ assert isinstance(leaf, Value), f"Leaf at {key_path!s} is not a Value"
225
+ leaf.set_value(value)
226
+
227
+ def __repr__(self) -> str:
228
+ def build_paths(
229
+ current: Object, prefix: str = ""
230
+ ) -> list[tuple[str, Value | Locator | str]]:
231
+ paths = []
232
+ if len(current.entries) == 0:
233
+ return [(prefix, "Empty")]
234
+
235
+ for key, val in current.entries.items():
236
+ path = f"{prefix}.{key}" if prefix else key
237
+ if isinstance(val, Object):
238
+ paths.extend(build_paths(val, path))
239
+ else:
240
+ paths.append((path, val))
241
+ return paths
242
+
243
+ paths = build_paths(self)
244
+ paths.sort(key=lambda x: x[0])
245
+ return "\n".join(f"{p[0]}: {p[1]}" for p in paths)
246
+
247
+
248
+ def locator_values(root: Locator, prefix: str = "") -> Iterator[tuple[str, Value]]:
249
+ """Yield ``(dotted_path, Value)`` for each Value leaf declared under *root*.
250
+
251
+ A container walk over the env's declared values, emitting root-relative
252
+ dotted paths. Aliases are pointers, not containers -- they declare no
253
+ values of their own -- so the walk never recurses through one (which would
254
+ otherwise duplicate the target's leaves under the alias's path, or loop).
255
+ """
256
+ if not isinstance(root, Object):
257
+ return
258
+ for key, val in root.entries.items():
259
+ path = f"{prefix}.{key}" if prefix else key
260
+ if isinstance(val, Object):
261
+ yield from locator_values(val, path)
262
+ elif isinstance(val, Value):
263
+ yield (path, val)
264
+
265
+
266
+ def data_dependencies(root: Locator, roots: Iterable[str]) -> frozenset[str]:
267
+ """Close a set of env paths through render templates.
268
+
269
+ Each path plus the identifiers embedded in any render value reached,
270
+ recursively. Recursion bottoms out because only immutable values can
271
+ hold a template, so a reached value's template contents are statically
272
+ known. Paths that resolve to no declared value are included as leaves
273
+ (they are read, even if reading would fail); assignment is not checked
274
+ here.
275
+ """
276
+ leaves = dict(locator_values(root))
277
+ seen: set[str] = set()
278
+ stack: list[str] = list(roots)
279
+ while stack:
280
+ path_str = stack.pop()
281
+ if path_str in seen:
282
+ continue
283
+ seen.add(path_str)
284
+ leaf = leaves.get(path_str)
285
+ if leaf is not None:
286
+ stack.extend(leaf.template_identifiers())
287
+ return frozenset(seen)
288
+
289
+
290
+ def assigned_paths(root: Locator) -> frozenset[str]:
291
+ """Dotted paths to every assigned Value leaf declared under *root*."""
292
+ return frozenset(path for path, v in locator_values(root) if v.assigned)
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """Exception hierarchy for clankloop.
3
+
4
+ One root — :class:`ClankloopError` — so callers can catch any clankloop-raised
5
+ exception with a single ``except``. The three subtrees mirror the package's
6
+ concerns: :class:`EnvError` (environment/templating), :class:`GraphError`
7
+ (control-flow analysis and runtime), and :class:`ParameterError` (caller-side
8
+ parameter binding).
9
+
10
+ All exceptions live here so the error surface is greppable in one place.
11
+ """
12
+
13
+
14
+
15
+ class ClankloopError(Exception):
16
+ """Root of every exception clankloop raises."""
17
+
18
+
19
+ class EnvError(ClankloopError):
20
+ """Base for environment/templating errors."""
21
+
22
+
23
+ class UnassignedValueError(EnvError):
24
+ """Raised when trying to evaluate an unassigned value."""
25
+
26
+
27
+ class ImmutableValueError(EnvError):
28
+ """Raised when trying to modify an already-assigned immutable value."""
29
+
30
+
31
+ class UnknownKeyError(EnvError):
32
+ """Raised when a key path references a key that does not exist."""
33
+
34
+
35
+ class GraphError(ClankloopError):
36
+ """Base for execution-graph errors (static analysis and runtime)."""
37
+
38
+
39
+ class UnexpectedTerminationError(GraphError):
40
+ """Raised when a task exits with a non-zero code and no failure branch matched."""
41
+
42
+
43
+ class UnknownTaskError(GraphError):
44
+ """A branch targets a task that is not in the graph.
45
+
46
+ Raised at compile time by the graph validator (a configuration error) and at
47
+ runtime by execute() as a safety net against dangling branch targets.
48
+ """
49
+
50
+
51
+ class InfiniteLoopError(GraphError):
52
+ """A reachable execution path never terminates.
53
+
54
+ Raised when the state-space walk revisits a (task, retry-budget-vector)
55
+ state already on its DFS stack. Because retry budgets only decrement,
56
+ such a revisit means the path repeats forever without exhausting a budget.
57
+ """
58
+
59
+
60
+ class UnassignedDataDependencyError(GraphError):
61
+ """A task reads an env path that no reachable producer assigns.
62
+
63
+ Raised when the state-space walk reaches a task whose read set includes a
64
+ path not in the assigned set on the current path. Assignment grows
65
+ monotonically along a path as producers (SetActions on matched branches)
66
+ fire; the check is therefore path-sensitive.
67
+ """
68
+
69
+
70
+ class ParameterError(ClankloopError):
71
+ """Base for parameter-binding errors."""
72
+
73
+
74
+ class MissingParameterError(ParameterError):
75
+ """Raised when a required parameter is not supplied for a pipeline run."""
76
+
77
+
78
+ class UnknownParameterError(ParameterError):
79
+ """Raised when a supplied parameter name is not declared in the pipeline."""
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env python3
2
+ """Execution graph engine — tasks, conditions, actions, and graph execution.
3
+
4
+ The execution graph (:class:`ExecutionGraph`) is the core runtime structure. It
5
+ holds a set of :class:`~clankloop.core.types.Execution` tasks, their branching
6
+ logic (conditions and actions), and a shared environment
7
+ (:class:`~clankloop.core.env.Object`).
8
+
9
+ This module is the public surface of the ``clankloop.core`` package: it
10
+ re-exports the domain types (:mod:`clankloop.core.types`), the error classes
11
+ (:mod:`clankloop.core.errors`), the bash execution backend
12
+ (:mod:`clankloop.core.bash`), and the LTS analyser
13
+ (:mod:`clankloop.core.lts`) so callers can import everything from
14
+ ``clankloop.core.graph``.
15
+ """
16
+
17
+
18
+ import logging
19
+ from typing import Mapping, Sequence
20
+
21
+ from clankloop.core.env import ClankTemplate # noqa: F401
22
+ from clankloop.core.env import Object
23
+ from clankloop.core.errors import ( # noqa: F401
24
+ InfiniteLoopError,
25
+ UnexpectedTerminationError,
26
+ UnassignedDataDependencyError,
27
+ UnknownTaskError,
28
+ )
29
+ # BashExecution is the one Execution backend shipped in core.
30
+ from clankloop.core.bash import BashExecution # noqa: F401
31
+ # LTS analyser — the public entry points the facade re-exports.
32
+ from clankloop.core.lts import (
33
+ LabeledTransitionSystem,
34
+ analyze,
35
+ data_dependencies as _data_dependencies,
36
+ validate_data_dependencies as _validate_data_dependencies,
37
+ )
38
+ # Domain types — the public surface re-exported from the types module.
39
+ from clankloop.core.types import (
40
+ Action,
41
+ AlwaysCondition,
42
+ Condition,
43
+ Execution,
44
+ ExecutionContext,
45
+ ExecutionResult,
46
+ IOChannels,
47
+ NextAction,
48
+ OnFailureCondition,
49
+ OnRetryCondition,
50
+ OnSuccessCondition,
51
+ SetAction,
52
+ )
53
+
54
+ logger = logging.getLogger("clankloop")
55
+
56
+ __all__ = [
57
+ "ExecutionGraph",
58
+ "BashExecution",
59
+ "Execution",
60
+ "Condition",
61
+ "Action",
62
+ "OnSuccessCondition",
63
+ "OnFailureCondition",
64
+ "AlwaysCondition",
65
+ "OnRetryCondition",
66
+ "SetAction",
67
+ "NextAction",
68
+ "ExecutionResult",
69
+ "ExecutionContext",
70
+ "IOChannels",
71
+ "UnexpectedTerminationError",
72
+ "UnknownTaskError",
73
+ "InfiniteLoopError",
74
+ "UnassignedDataDependencyError",
75
+ "analyze",
76
+ "LabeledTransitionSystem",
77
+ "ClankTemplate",
78
+ ]
79
+
80
+
81
+ class ExecutionGraph:
82
+ """A directed execution graph of tasks with conditional branching.
83
+
84
+ The graph holds named executions, their action branches (condition → actions),
85
+ and a shared environment. Execution starts at the entry task and walks
86
+ forward, evaluating conditions to pick the next task, set variables, or abort.
87
+
88
+ Attributes:
89
+ name: The pipeline name.
90
+ execs: Mapping of task name to :class:`Execution`.
91
+ exec_actions: Mapping of task name to branches (condition, actions).
92
+ env: The shared environment object.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ name: str,
98
+ execs: dict[str, "Execution"],
99
+ exec_actions: dict[str, Sequence[tuple[Condition, Sequence[Action]]]],
100
+ env: Object,
101
+ ):
102
+ self.name = name
103
+ self.execs = execs
104
+ self.exec_actions = exec_actions
105
+ self.env = env
106
+
107
+ def captures_stdio(self, task: str) -> bool:
108
+ """Whether any branch of *task* captures stdout/stderr into a SetAction.
109
+
110
+ Such tasks must run captured even under ``--interactive``: making them
111
+ interactive would starve the capture their SetActions depend on.
112
+ """
113
+ return any(
114
+ isinstance(action, SetAction) and (action.stdout or action.stderr)
115
+ for _cond, actions in self.exec_actions.get(task, [])
116
+ for action in actions
117
+ )
118
+
119
+ def validate(
120
+ self,
121
+ entry_task: str,
122
+ *,
123
+ parameter_paths: frozenset[str] = frozenset(),
124
+ check_data_dependencies: bool = False,
125
+ ) -> None:
126
+ """Statically verify the graph is well-formed before execution.
127
+
128
+ Builds the labeled transition system (raising ``UnknownTaskError`` for
129
+ a dangling branch target, ``InfiniteLoopError`` for an unbounded cycle
130
+ detected during construction) and, when ``check_data_dependencies`` is
131
+ set, runs the path-sensitive data-dependency check
132
+ (``UnassignedDataDependencyError``). Graph-only: no subprocess, no
133
+ env rendering.
134
+
135
+ The data-dependency check requires a loopfile format whose env layout
136
+ makes read sets statically determinable; formats that cannot support
137
+ it (e.g. v1) leave it off and rely on the runtime
138
+ ``UnassignedValueError`` safety net.
139
+
140
+ Args:
141
+ entry_task: The task the walk starts from.
142
+ parameter_paths: Env paths of declared parameters, which count as
143
+ assigned from the walk's start.
144
+ check_data_dependencies: Run the data-dependency phase.
145
+ """
146
+ lts = analyze(self, entry_task, parameter_paths=parameter_paths)
147
+ if check_data_dependencies:
148
+ _validate_data_dependencies(lts, _data_dependencies(self))
149
+
150
+ def execute(
151
+ self,
152
+ exec_name: str,
153
+ *,
154
+ environ: Mapping[str, str],
155
+ io: IOChannels | None = None,
156
+ interactive: bool = False,
157
+ ) -> None:
158
+ """Walk the graph from *exec_name* against a pre-bound environment.
159
+
160
+ Parameters must be bound before this is called — via
161
+ :meth:`~clankloop.runner.Pipeline.bind`, which translates,
162
+ validates, and writes caller-supplied values into the env. The graph
163
+ executes a bound env; it never sees bare parameter names.
164
+
165
+ Args:
166
+ exec_name: The name of the entry task.
167
+ environ: Base process environment for subprocess execution;
168
+ pipeline-declared values override it. Supplied by the caller
169
+ (the CLI reads ``os.environ`` once at the boundary).
170
+ io: CLI-owned streams the core tees subprocess output through.
171
+ Defaults to discard-forward + capture (today's behaviour).
172
+ interactive: When set, tasks whose branches capture no stdio
173
+ inherit the tty (stdin/stdout/stderr from *io*) so interactive
174
+ commands work; tasks that capture stdio always run captured.
175
+
176
+ Raises:
177
+ UnexpectedTerminationError: If a task fails and no failure branch
178
+ matches.
179
+ """
180
+ if io is None:
181
+ io = IOChannels()
182
+ current_execution: Execution | None = self.execs[exec_name]
183
+
184
+ while current_execution is not None:
185
+ current_task_name = current_execution.name
186
+ logger.info(
187
+ "Executing task",
188
+ extra={"pipeline": self.name, "task": current_task_name},
189
+ )
190
+
191
+ task_io = self._select_task_io(current_task_name, io, interactive)
192
+ context = ExecutionContext(env=self.env, environ=environ, io=task_io)
193
+ out = current_execution.execute(context)
194
+
195
+ logger.debug(
196
+ "Execution result",
197
+ extra={
198
+ "pipeline": self.name,
199
+ "task": current_task_name,
200
+ "stdout": out.stdout,
201
+ "stderr": out.stderr,
202
+ "returncode": out.returncode,
203
+ },
204
+ )
205
+
206
+ matched, next_exec_name = self._resolve_branch(current_task_name, out)
207
+
208
+ if out.returncode != 0 and not matched:
209
+ raise UnexpectedTerminationError(
210
+ f"Task {current_task_name!r} in pipeline {self.name!r} "
211
+ f"terminated unexpectedly with exit code {out.returncode}."
212
+ f"Stdout: {out.stdout}\nStderr: {out.stderr}"
213
+ )
214
+
215
+ if next_exec_name is None:
216
+ current_execution = None
217
+ continue
218
+
219
+ logger.debug(
220
+ "Next task determined",
221
+ extra={"pipeline": self.name, "next_task": next_exec_name},
222
+ )
223
+ if next_exec_name not in self.execs:
224
+ raise UnknownTaskError(
225
+ f"Task {current_task_name!r} in pipeline {self.name!r} "
226
+ f"branches to unknown task {next_exec_name!r}"
227
+ )
228
+ current_execution = self.execs[next_exec_name]
229
+ logger.debug(
230
+ "Execution loop iteration complete",
231
+ extra={"pipeline": self.name},
232
+ )
233
+
234
+ def _select_task_io(
235
+ self, task: str, io: IOChannels, interactive: bool
236
+ ) -> IOChannels:
237
+ """Pick the IO channels for *task* under the current interactivity mode.
238
+
239
+ A task inherits the tty only under ``--interactive`` AND when it
240
+ captures no stdio into a SetAction (capturing tasks must run
241
+ captured). stdin presence is the signal :class:`BashExecution` reads.
242
+ """
243
+ if interactive and not self.captures_stdio(task):
244
+ return io
245
+ return IOChannels(stdin=None, stdout=io.stdout, stderr=io.stderr)
246
+
247
+ def _resolve_branch(
248
+ self, task: str, result: ExecutionResult
249
+ ) -> tuple[bool, str | None]:
250
+ """Resolve the branch outcome for *task* given *result*.
251
+
252
+ Mirrors the LTS walker's ``_resolve_outcome``: the first matching
253
+ condition wins; its actions run in order, and the first NextAction
254
+ redirects. Returns ``(matched, next_task)`` — ``matched`` is True if
255
+ any condition matched, ``next_task`` is the redirect target or None.
256
+ """
257
+ for condition, actions in self.exec_actions.get(task, []):
258
+ if not condition.matches(result):
259
+ continue
260
+ logger.debug(
261
+ "Condition matched",
262
+ extra={
263
+ "pipeline": self.name,
264
+ "condition": condition.__class__.__name__,
265
+ },
266
+ )
267
+ for action in actions:
268
+ next_name = action.next(self.env, result)
269
+ if next_name is not None:
270
+ return (True, next_name)
271
+ return (True, None)
272
+ return (False, None)