Skip to content

Commit 5f28c3d

Browse files
committed
Add runtime variables and data-driven control flow
Pre-execution interpolate.py only resolved ${var} placeholders once against a static mapping; scripts had no way to mutate state during execution. VariableScope is a runtime mapping the executor exposes to flow-control commands so AC_set_var / AC_inc_var / AC_get_var, AC_if_var (with eq/ne/lt/le/gt/ge/contains/startswith/endswith), and AC_for_each can read and write the same bag the runtime interpolator consults. The executor now resolves ${var} per command call (not pre-flattened), so nested body/then/else lists keep their placeholders and re-bind each time they execute — letting AC_for_each iterate over a list while the body sees the current item.
1 parent 9777386 commit 5f28c3d

7 files changed

Lines changed: 317 additions & 7 deletions

File tree

je_auto_control/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
from je_auto_control.utils.script_vars.interpolate import (
105105
interpolate_actions, interpolate_value, load_vars_from_json,
106106
)
107+
from je_auto_control.utils.script_vars.scope import VariableScope
107108
# Watchers (headless)
108109
from je_auto_control.utils.watcher.watcher import (
109110
LogTail, MouseWatcher, PixelWatcher,
@@ -213,6 +214,7 @@ def start_autocontrol_gui(*args, **kwargs):
213214
"Scheduler", "ScheduledJob", "default_scheduler",
214215
# Script variables
215216
"interpolate_actions", "interpolate_value", "load_vars_from_json",
217+
"VariableScope",
216218
# Watchers
217219
"MouseWatcher", "PixelWatcher", "LogTail",
218220
# Window manager

je_auto_control/utils/executor/action_executor.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131
wait_for_text as ocr_wait_for_text,
3232
)
3333
from je_auto_control.utils.run_history.history_store import default_history_store
34-
from je_auto_control.utils.script_vars.interpolate import interpolate_actions
34+
from je_auto_control.utils.script_vars.interpolate import (
35+
interpolate_actions, interpolate_value,
36+
)
37+
from je_auto_control.utils.script_vars.scope import VariableScope
3538
from je_auto_control.utils.generate_report.generate_html_report import generate_html, generate_html_report
3639
from je_auto_control.utils.generate_report.generate_json_report import generate_json, generate_json_report
3740
from je_auto_control.utils.generate_report.generate_xml_report import generate_xml, generate_xml_report
@@ -157,8 +160,13 @@ class Executor:
157160
- 支援流程控制指令 (AC_loop, AC_if_image_found 等)
158161
"""
159162

163+
# Args keys that hold nested action lists; runtime interpolation must
164+
# leave them untouched so each iteration re-reads current variable state.
165+
_DEFERRED_ARG_KEYS: frozenset = frozenset({"body", "then", "else"})
166+
160167
def __init__(self):
161168
self._block_commands = BLOCK_COMMANDS
169+
self.variables = VariableScope()
162170
# 事件字典,對應字串名稱到函式
163171
self.event_dict: dict = {
164172
# Mouse 滑鼠相關
@@ -258,6 +266,27 @@ def known_commands(self) -> set:
258266
"""Return the set of all command names the executor recognises."""
259267
return set(self.event_dict.keys()) | set(self._block_commands.keys())
260268

269+
def _resolve_runtime_args(self, args: Any) -> Any:
270+
"""Interpolate ``${var}`` placeholders against the current scope.
271+
272+
Keys inside :attr:`_DEFERRED_ARG_KEYS` (``body``/``then``/``else``)
273+
are left as-is so nested action lists keep their placeholders for
274+
per-iteration evaluation.
275+
"""
276+
if not self.variables:
277+
return args
278+
if isinstance(args, dict):
279+
resolved: Dict[str, Any] = {}
280+
for key, value in args.items():
281+
if key in self._DEFERRED_ARG_KEYS:
282+
resolved[key] = value
283+
else:
284+
resolved[key] = interpolate_value(value, self.variables)
285+
return resolved
286+
if isinstance(args, list):
287+
return [interpolate_value(item, self.variables) for item in args]
288+
return args
289+
261290
def _execute_event(self, action: list) -> Any:
262291
"""
263292
執行單一事件
@@ -271,16 +300,17 @@ def _execute_event(self, action: list) -> Any:
271300
raise AutoControlActionException(
272301
f"{name} requires a dict of arguments"
273302
)
274-
return block_handler(self, args)
303+
return block_handler(self, self._resolve_runtime_args(args))
275304

276305
event = self.event_dict.get(name)
277306
if event is None:
278307
raise AutoControlActionException(f"Unknown action: {name}")
279308

280309
if len(action) == 2:
281-
if isinstance(action[1], dict):
282-
return event(**action[1])
283-
return event(*action[1])
310+
resolved = self._resolve_runtime_args(action[1])
311+
if isinstance(resolved, dict):
312+
return event(**resolved)
313+
return event(*resolved)
284314
if len(action) == 1:
285315
return event()
286316
raise AutoControlActionException(cant_execute_action_error_message + " " + str(action))
@@ -393,6 +423,12 @@ def execute_files(execute_files_list: list) -> List[Dict[str, str]]:
393423

394424
def execute_action_with_vars(action_list: list, variables: dict
395425
) -> Dict[str, str]:
396-
"""Interpolate ``${name}`` placeholders with ``variables`` and execute."""
426+
"""Interpolate ``${name}`` placeholders with ``variables`` and execute.
427+
428+
The same mapping seeds the runtime variable scope so flow-control
429+
commands (``AC_set_var``/``AC_if_var``/...) can read and mutate the
430+
same values during execution.
431+
"""
397432
resolved = interpolate_actions(action_list, variables)
433+
executor.variables.update_many(variables)
398434
return executor.execute_action(resolved)

je_auto_control/utils/executor/action_schema.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
FLOW_BODY_KEYS = {
1313
"AC_if_image_found": ("then", "else"),
1414
"AC_if_pixel": ("then", "else"),
15+
"AC_if_var": ("then", "else"),
1516
"AC_loop": ("body",),
1617
"AC_while_image": ("body",),
1718
"AC_retry": ("body",),
19+
"AC_for_each": ("body",),
1820
}
1921

2022

je_auto_control/utils/executor/flow_control.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,95 @@ def exec_continue(executor: Any, args: Mapping[str, Any]) -> None:
178178
raise LoopContinue()
179179

180180

181+
def exec_set_var(executor: Any, args: Mapping[str, Any]) -> Any:
182+
"""Store ``value`` under ``name`` in the executor's variable scope."""
183+
name = args["name"]
184+
value = args.get("value")
185+
executor.variables.set(name, value)
186+
return value
187+
188+
189+
def exec_get_var(executor: Any, args: Mapping[str, Any]) -> Any:
190+
"""Return the variable named ``name`` (or ``default`` if missing)."""
191+
return executor.variables.get_value(args["name"], args.get("default"))
192+
193+
194+
def exec_inc_var(executor: Any, args: Mapping[str, Any]) -> Any:
195+
"""Increment a numeric variable by ``by`` (default 1) and return new value."""
196+
name = args["name"]
197+
delta = args.get("by", 1)
198+
current = executor.variables.get_value(name, 0)
199+
try:
200+
new_value = current + delta
201+
except TypeError as error:
202+
raise AutoControlActionException(
203+
f"AC_inc_var: variable {name!r} is not numeric: {current!r}"
204+
) from error
205+
executor.variables.set(name, new_value)
206+
return new_value
207+
208+
209+
_COMPARATORS: Dict[str, Callable[[Any, Any], bool]] = {
210+
"eq": lambda a, b: a == b,
211+
"ne": lambda a, b: a != b,
212+
"lt": lambda a, b: a < b,
213+
"le": lambda a, b: a <= b,
214+
"gt": lambda a, b: a > b,
215+
"ge": lambda a, b: a >= b,
216+
"contains": lambda a, b: b in a,
217+
"startswith": lambda a, b: isinstance(a, str) and a.startswith(b),
218+
"endswith": lambda a, b: isinstance(a, str) and a.endswith(b),
219+
}
220+
221+
222+
def exec_if_var(executor: Any, args: Mapping[str, Any]) -> Any:
223+
"""Run ``then`` when ``variable op value`` holds, else run ``else``."""
224+
name = args["name"]
225+
op = args.get("op", "eq")
226+
comparator = _COMPARATORS.get(op)
227+
if comparator is None:
228+
raise AutoControlActionException(
229+
f"AC_if_var: unsupported op {op!r}; "
230+
f"expected one of {sorted(_COMPARATORS)}"
231+
)
232+
current = executor.variables.get_value(name)
233+
try:
234+
matched = comparator(current, args.get("value"))
235+
except TypeError as error:
236+
raise AutoControlActionException(
237+
f"AC_if_var: cannot compare {current!r} {op} {args.get('value')!r}"
238+
) from error
239+
key = "then" if matched else "else"
240+
return _run_branch(executor, args.get(key))
241+
242+
243+
def exec_for_each(executor: Any, args: Mapping[str, Any]) -> int:
244+
"""Bind each item in ``items`` to ``as`` and execute ``body``."""
245+
items = args["items"]
246+
if not isinstance(items, (list, tuple)):
247+
raise AutoControlActionException(
248+
f"AC_for_each: items must be a list, got {type(items).__name__}"
249+
)
250+
var_name = args.get("as", "item")
251+
body = args.get("body") or []
252+
iterations = 0
253+
for item in items:
254+
executor.variables.set(var_name, item)
255+
try:
256+
executor.execute_action(body, _validated=True)
257+
except LoopContinue:
258+
iterations += 1
259+
continue
260+
except LoopBreak:
261+
break
262+
iterations += 1
263+
return iterations
264+
265+
181266
BLOCK_COMMANDS: Dict[str, Callable[[Any, Mapping[str, Any]], Any]] = {
182267
"AC_if_image_found": exec_if_image_found,
183268
"AC_if_pixel": exec_if_pixel,
269+
"AC_if_var": exec_if_var,
184270
"AC_wait_image": exec_wait_image,
185271
"AC_wait_pixel": exec_wait_pixel,
186272
"AC_sleep": exec_sleep,
@@ -189,4 +275,8 @@ def exec_continue(executor: Any, args: Mapping[str, Any]) -> None:
189275
"AC_retry": exec_retry,
190276
"AC_break": exec_break,
191277
"AC_continue": exec_continue,
278+
"AC_set_var": exec_set_var,
279+
"AC_get_var": exec_get_var,
280+
"AC_inc_var": exec_inc_var,
281+
"AC_for_each": exec_for_each,
192282
}

je_auto_control/utils/script_vars/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,9 @@
22
from je_auto_control.utils.script_vars.interpolate import (
33
interpolate_actions, interpolate_value, load_vars_from_json,
44
)
5+
from je_auto_control.utils.script_vars.scope import VariableScope
56

6-
__all__ = ["interpolate_actions", "interpolate_value", "load_vars_from_json"]
7+
__all__ = [
8+
"VariableScope", "interpolate_actions", "interpolate_value",
9+
"load_vars_from_json",
10+
]
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Runtime variable scope for the action executor.
2+
3+
Pre-execution interpolation in :mod:`interpolate` replaces ``${var}``
4+
placeholders once, against a static mapping. Some scripts need to mutate
5+
state during execution — counters in loops, captured OCR/locator results,
6+
``for_each`` items. ``VariableScope`` is a thin mutable container the
7+
executor exposes to flow-control commands so those commands can read and
8+
write the same bag the runtime interpolator consults.
9+
"""
10+
from typing import Any, Dict, Iterator, Mapping, MutableMapping, Optional
11+
12+
13+
class VariableScope(MutableMapping[str, Any]):
14+
"""Mutable mapping of script variables shared across action execution."""
15+
16+
__slots__ = ("_vars",)
17+
18+
def __init__(self, initial: Optional[Mapping[str, Any]] = None) -> None:
19+
self._vars: Dict[str, Any] = dict(initial) if initial else {}
20+
21+
def __getitem__(self, key: str) -> Any:
22+
return self._vars[key]
23+
24+
def __setitem__(self, key: str, value: Any) -> None:
25+
if not isinstance(key, str) or not key:
26+
raise ValueError("variable name must be a non-empty string")
27+
self._vars[key] = value
28+
29+
def __delitem__(self, key: str) -> None:
30+
del self._vars[key]
31+
32+
def __iter__(self) -> Iterator[str]:
33+
return iter(self._vars)
34+
35+
def __len__(self) -> int:
36+
return len(self._vars)
37+
38+
def __contains__(self, key: object) -> bool:
39+
return key in self._vars
40+
41+
def set(self, name: str, value: Any) -> None:
42+
"""Assign ``name`` to ``value``."""
43+
self[name] = value
44+
45+
def get_value(self, name: str, default: Any = None) -> Any:
46+
"""Return the variable, or ``default`` when missing."""
47+
return self._vars.get(name, default)
48+
49+
def update_many(self, mapping: Mapping[str, Any]) -> None:
50+
"""Bulk-assign from a mapping."""
51+
for key, value in mapping.items():
52+
self[key] = value
53+
54+
def as_dict(self) -> Dict[str, Any]:
55+
"""Return a shallow copy as a plain dict (safe for interpolation)."""
56+
return dict(self._vars)
57+
58+
def clear(self) -> None:
59+
"""Drop every stored variable."""
60+
self._vars.clear()

0 commit comments

Comments
 (0)