From a0fd03f12f09d91e716f99766b10c2095945e3c9 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Thu, 21 May 2026 18:22:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor(autoscript):=20ASEngine=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E8=87=B3=20Lua=20=E6=B2=99=E7=AE=B1=E5=BC=95=E6=93=8E?= =?UTF-8?q?=EF=BC=8C=E5=BC=BA=E5=8C=96=E7=B1=BB=E5=9E=8B=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E4=B8=8E=E5=BC=82=E5=B8=B8=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- requirement.txt | Bin 1504 -> 1490 bytes src/autoscript/ASEngine.py | 852 ++++++++++++---------------------- src/autoscript/ASObject.py | 269 ----------- src/autoscript/ASObserver.py | 78 ---- src/autoscript/ASOperator.py | 191 -------- src/autoscript/ASTokenizer.py | 584 ----------------------- src/autoscript/__init__.py | 37 +- 7 files changed, 302 insertions(+), 1709 deletions(-) delete mode 100644 src/autoscript/ASObject.py delete mode 100644 src/autoscript/ASObserver.py delete mode 100644 src/autoscript/ASOperator.py delete mode 100644 src/autoscript/ASTokenizer.py diff --git a/requirement.txt b/requirement.txt index 199a2464f9e5bf43e875a9ca02ef773d290e4a06..b85c98d87b0d8fdbee7985539adb91ceeaf2801b 100644 GIT binary patch delta 34 ocmaFBeTkdt|G$lDVvK@045bVO42cZ3Kxo9E$6&G9oN*-!0KW?fBLDyZ delta 52 zcmcb_{eWBT|Gz|r9EK8xbcP~^M1}%}3oG22 F0RV2g3rzq3 diff --git a/src/autoscript/ASEngine.py b/src/autoscript/ASEngine.py index 1781f13..129e803 100644 --- a/src/autoscript/ASEngine.py +++ b/src/autoscript/ASEngine.py @@ -7,640 +7,368 @@ This software is provided "as is", without any warranty of any kind. You may use, modify, and distribute this file under the terms of the MIT License. See the LICENSE file for details. """ -import re from datetime import ( - datetime, - timedelta, date, - time + datetime, ) -from .ASObject import ( - ASObject, - _META_VARS, - _inferType -) -from .ASOperator import ASOperator -from .ASTokenizer import ( - ASTokenizer, - NodeVisitor, - Script, - IfNode, - SetNode, - OpNode, - PassNode, - UnrecogNode -) +from lupa import LuaRuntime as _LuaRuntime -__all__ = ["execute", "addTargetVar", "splitTopLevel"] +__all__ = ["execute", "addTargetVar", "resetEngine"] # Engine state -# User-registered target variables (bound to target_data paths) -_TARGET_VARS = {} -# Free-form script variables (not bound to target_data) -_SCRIPT_VARS = {} -# Name -> ASObject lookup map built from _META_VARS, _TARGET_VARS, and display names -_FIELD_MAP = {} +_TARGET_VARS: dict[str, dict] = {} +_lua = None + +# Built-in meta variable definitions (name / type / display-name) +META_VARS = { + "CURRENT_DATE": {"name": "CURRENT_DATE", "type": "Date", "display": "当前日期"}, + "CURRENT_TIME": {"name": "CURRENT_TIME", "type": "Time", "display": "当前时间"}, +} -def _errPos( - line: int, - message: str -) -> str: - """ - Format an error message with a script line number. - - Args: - line (int): The script line number where the error occurred. - message (str): The error description. - - Returns: - str: A formatted error string like "AutoScript syntax error(line X): message". - """ - return f"AutoScript 语法错误(第{line}行): {message}" - - -# Pre-compiled regex patterns for value resolution -_RE_TIME = re.compile(r"^TIME\((\d{1,2}):(\d{2})\)$", re.IGNORECASE) -_RE_DATE = re.compile(r"^DATE\((\d{4})-(\d{2})-(\d{2})\)$", re.IGNORECASE) - - -def splitTopLevel( - text: str, - delimiter: str -) -> list: - """ - Split a condition expression by a delimiter (.AND. / .OR.), respecting parentheses. - - Only splits at the top nesting level; delimiters inside parentheses are ignored. - - Args: - text (str): The condition expression to split. - delimiter (str): The delimiter string, e.g. ".OR." or ".AND.". - - Returns: - list: A list of sub-expression strings (stripped of leading/trailing whitespace). - """ - - parts = [] - depth = 0 - buf = "" - i = 0 - text_upper = text.upper() - delim_upper = delimiter.upper() - dlen = len(delim_upper) - while i < len(text): - if text[i] == "(": - depth += 1 - buf += text[i] - elif text[i] == ")": - depth -= 1 - buf += text[i] - elif depth == 0 and text_upper[i:i + dlen] == delim_upper: - parts.append(buf) - buf = "" - i += dlen - continue - else: - buf += text[i] - i += 1 - if buf.strip(): - parts.append(buf) - return parts - - -def _buildFieldMap(): - """ - Rebuild the _FIELD_MAP lookup from _META_VARS and _TARGET_VARS. - - Each variable is registered under both its canonical name (uppercased) - and its display_name (if present), so that scripts can refer to either. - """ - - _FIELD_MAP.clear() - for ch_name, obj in _META_VARS.items(): - _FIELD_MAP[obj.name.upper()] = obj - _FIELD_MAP[ch_name.upper().strip()] = obj - for obj in _TARGET_VARS.values(): - _FIELD_MAP[obj.name.upper()] = obj - if obj.display_name: - _FIELD_MAP[obj.display_name.upper().strip()] = obj - - -def _resolveFieldObj( - field_name: str +def _getLua( ): """ - Resolve a field name to its ASObject by looking up _FIELD_MAP then _SCRIPT_VARS. - - Unlike getting a raw value, this returns the ASObject instance itself, - preserving type information for operations and comparisons. - - Args: - field_name (str): The field name (case-insensitive). - - Returns: - ASObject or None: The resolved ASObject, or None if not found. + Return the sandboxed Lua runtime singleton. """ - upper_name = field_name.upper().strip() - obj = _FIELD_MAP.get(upper_name) - if obj: - return obj - obj = _SCRIPT_VARS.get(upper_name) - if obj: - return obj - return None + global _lua + if _lua is None: + _lua = _LuaRuntime(unpack_returned_tuples = True) + _sandbox(_lua) + _registerHelpers(_lua) + return _lua -def _resolveValue( - value_str: str, - target_data: dict +def _sandbox( + lua, +) -> None: + """ + Remove dangerous Lua globals while keeping os.date / os.time for date-time helpers. + """ + + lua.execute(""" + io = nil + require = nil + dofile = nil + loadfile = nil + load = nil + package = nil + rawget = nil + rawset = nil + rawequal = nil + getfenv = nil + setfenv = nil + debug = nil + -- selectively disable dangerous os functions, keep date / time + if os then + os.execute = nil + os.exit = nil + os.getenv = nil + os.remove = nil + os.rename = nil + os.tmpname = nil + os.setlocale = nil + end + """) + + +def _registerHelpers( + lua, +) -> None: + """ + Inject Date / Time helpers as pure Lua functions. + + Date values are os.time timestamps (seconds since epoch). + Time values are minutes since midnight (0-1439). + + This keeps Date / Time as native Lua numbers during script execution, + enabling type-safe arithmetic (+, -) and comparisons (<, <=, ==, ~=). + """ + + lua.execute(""" + function date(y, m, d) + return os.time({year = y, month = m, day = d}) + end + + function time(h, m) + return h * 60 + m + end + + function CURRENT_DATE() + local now = os.date("*t") + return os.time({year = now.year, month = now.month, day = now.day}) + end + + function CURRENT_TIME() + local now = os.date("*t") + return now.hour * 60 + now.min + end + + function date_add(date_val, n) + return date_val + n * 86400 + end + + function time_add(time_val, n) + return (time_val + n * 60) % 1440 + end + + -- push helpers: string -> native type + function _to_date(iso_str) + local y, m, d = iso_str:match("(%d+)-(%d+)-(%d+)") + return os.time({year = y, month = m, day = d}) + end + + function _to_time(hm_str) + local h, m = hm_str:match("(%d+):(%d+)") + return h * 60 + m + end + + -- pull helpers: native type -> string + function _from_date(ts) + return os.date("%Y-%m-%d", ts) + end + + function _from_time(m) + return string.format("%02d:%02d", math.floor(m / 60), m % 60) + end + """) + + +def _navigatePath( + data: dict, + key_path: list, + default = None, ): """ - Parse and resolve a value string from a script into a Python object. - - Supports the following literal forms: - - TIME(hh:mm) - - DATE(yyyy-mm-dd) - - .TRUE. / .FALSE. - - Single/double quoted strings (with escaped single quotes) - - Arithmetic expressions: operand (+|-) operand (Date ± Int, Int ± Int, etc.) - - Numeric literals (int / float) - - Field references (resolved via _resolveFieldObj) - - Args: - value_str (str): The raw value string from the script. - target_data (dict): The application data dict. - - Returns: - The resolved Python value. + Walk *key_path* into *data* and return the value at the leaf. """ - s = value_str.strip() - m = _RE_TIME.match(s) - if m: - return time(int(m.group(1)), int(m.group(2))) - m = _RE_DATE.match(s) - if m: - return date(int(m.group(1)), int(m.group(2)), int(m.group(3))) - up = s.upper() - if up == ".TRUE.": - return True - if up == ".FALSE.": - return False - if s.startswith("'") and s.endswith("'"): - return s[1:-1].replace("''", "'") - if s.startswith('"') and s.endswith('"'): - return s[1:-1] - try: - return int(s) - except ValueError: - pass - try: - return float(s) - except ValueError: - pass - arith_result = _resolveArithExpr(s, target_data) - if arith_result is not None: - return arith_result - obj = _resolveFieldObj(s) - if obj: - return obj.getValue(target_data) - return "" + d = data + for key in key_path[:-1]: + d = d.get(key, {}) + if not isinstance(d, dict): + return default + return d.get(key_path[-1], default) -def _resolveAsObject( - expr: str, - target_data: dict -) -> ASObject: +def _assignPath( + data: dict, + key_path: list, + value, +) -> None: """ - Resolve a value expression to an ASObject. - - - If the expression is a registered field name, returns its ASObject directly. - - If the expression is a literal (number, string, DATE(), TIME(), bool), - creates a temporary ASObject with the inferred type. - - This is the key function that ensures all internal operations work - with typed ASObject instances rather than raw Python values. - - Args: - expr (str): The raw expression string from the script. - target_data (dict): The application data dict. - - Returns: - ASObject: A registered or temporary ASObject representing the expression value. + Walk *key_path* into *data* and set *value* at the leaf. """ - s = expr.strip() - obj = _resolveFieldObj(s) - if obj is not None: - return obj - value = _resolveValue(s, target_data) - inferred = _inferType(value, s) - return ASObject._makeTemp(value, inferred) + d = data + for key in key_path[:-1]: + d = d.setdefault(key, {}) + d[key_path[-1]] = value -def _resolveArithExpr( - expr: str, - target_data: dict, - line: int = 0 -): +def _checkType( + var_name: str, + var_type: str, + value, +) -> None: """ - Try to evaluate expr as a two-operand arithmetic expression: left (+|-) right. + Validate that *value* matches the declared variable type. - Each operand is resolved via _resolveAsObject, reusing the full literal / - field / script-variable resolution stack. The left operand's value is - copied into a temporary ASObject and ASOperator.apply() performs the - type-safe calculation on the copy, so the original variable is never - mutated. - - Returns the computed Python value, or None if expr is not a recognised - arithmetic pattern. + Date / Time values arrive as ISO / HH:MM strings (already converted + from Lua native types during the pull phase). + Int / Float / Boolean / String check Python type identity. + Int -> Float widening is allowed. """ - s = expr.strip() - m = re.match(r'^(.+?)\s+([+-])\s+(.+)$', s) - if not m: - # Fallback for no-space expressions like RESERVE_DATE+1 - # (e.g. when extracted from IF(RESERVE_DATE.EQ.CURRENT_DATE+1)). - # Left operand must be an identifier (letter/underscore start) to - # avoid false-matching date strings like 2026-05-20. - m = re.match(r'^([A-Za-z_]\w*)([+-])(\d+|[A-Za-z_]\w*)$', s) - if not m: - return None - left_expr = m.group(1).strip() - op_symbol = m.group(2).strip() - right_expr = m.group(3).strip() - if " + " in left_expr or " - " in left_expr: - return None - if " + " in right_expr or " - " in right_expr: - return None - left_obj = _resolveAsObject(left_expr, target_data) - right_obj = _resolveAsObject(right_expr, target_data) - op = ".ADD." if op_symbol == "+" else ".SUB." - left_val = left_obj.getValue(target_data) - result_type = left_obj.var_type - if left_obj.var_type == "Int" and right_obj.var_type == "Float": - result_type = "Float" - elif left_obj.var_type == "Float" and right_obj.var_type == "Int": - result_type = "Float" - temp = ASObject._makeTemp(left_val, result_type) - ASOperator.apply(temp, right_obj, op, target_data) - return temp.getValue(target_data) - - -def _evaluateCondition( - condition_str: str, - target_data: dict, - line: int = 0 -) -> bool: - """ - Evaluate a condition expression and return a boolean result. - - Supports: - - Boolean literals: .TRUE., .FALSE. - - .AND. / .OR. operators (lowest precedence) - - Parenthesised sub-expressions - - Comparison operators: .EQ., .NEQ., .BGT., .BLT., .BGE., .BLE. - - All operands are resolved as ASObject instances (via _resolveAsObject) - and comparisons are delegated to ASOperator.compare(). - - Args: - condition_str (str): The raw condition expression from the script. - target_data (dict): The application data dict. - - Returns: - bool: The evaluation result. - - Raises: - ValueError: If the expression contains unrecognised tokens or type-mismatched comparisons. - """ - - s = condition_str.strip() - if not s: - return False - or_parts = splitTopLevel(s, ".OR.") - if len(or_parts) > 1: - return any( - _evaluateCondition(p.strip(), target_data, line) - for p in or_parts - ) - and_parts = splitTopLevel(s, ".AND.") - if len(and_parts) > 1: - return all( - _evaluateCondition(p.strip(), target_data, line) - for p in and_parts - ) - s = s.strip() - if s.startswith("(") and s.endswith(")"): - return _evaluateCondition(s[1:-1], target_data, line) - up = s.upper() - if up == ".TRUE.": - return True - if up == ".FALSE.": - return False - for op in ASOperator._COMPARE: - idx = up.find(op.upper()) - if idx < 0: - continue - left_raw = s[:idx].strip() - right_raw = s[idx + len(op):].strip() - try: - left_obj = _resolveAsObject(left_raw, target_data) - right_obj = _resolveAsObject(right_raw, target_data) - return ASOperator.compare(left_obj, right_obj, op, target_data) - except ValueError as e: - raise ValueError(_errPos(line, str(e))) - raise ValueError( - _errPos(line, f"无法识别的条件表达式 '{condition_str}'") - ) - - -def _executeSet( - line_text: str, - target_data: dict, - line: int = 0 -): - """ - Execute a SET statement to assign a value to a field or script variable. - - Parses the line as "SET field_name = value_expr", resolves the value, - and assigns it. If the target field does not exist, a new script variable - is created with an inferred type. - - Args: - line (str): The raw SET line from the script. - target_data (dict): The application data dict. - - Raises: - ValueError: If the value string contains unexpected extra tokens. - """ - - rest = line_text[3:].strip() - eq_idx = rest.find("=") - if eq_idx < 0: + if var_type == "Date": + if not isinstance(value, str): + raise ValueError( + f"Date 类型变量 '{var_name}' 只能接受日期字符串," + f"不能接受 {type(value).__name__} 类型" + ) + date.fromisoformat(value) return - field_name = rest[:eq_idx].strip() - value_str = rest[eq_idx + 1:].strip() - if not field_name: + if var_type == "Time": + if not isinstance(value, str): + raise ValueError( + f"Time 类型变量 '{var_name}' 只能接受时间字符串," + f"不能接受 {type(value).__name__} 类型" + ) + datetime.strptime(value, "%H:%M") return - resolved = _resolveValue(value_str, target_data) - stripped = value_str.strip() - if resolved == "" and stripped not in ("''", '""') and len(stripped.split()) > 1: - try: - resolved = _resolveArithExpr(stripped, target_data, line) - except ValueError as e: - raise ValueError(_errPos(line, str(e))) - if resolved is None: - raise ValueError(_errPos(line, f"SET 值中存在多余内容 '{stripped}'")) - upper_name = field_name.upper().strip() - obj = _FIELD_MAP.get(upper_name) - if not obj: - obj = _SCRIPT_VARS.get(upper_name) - if obj: - try: - obj.setValue(resolved, target_data) - except ValueError as e: - raise ValueError(_errPos(line, str(e))) + if var_type == "Int": + if isinstance(value, bool): + raise ValueError( + f"Int 类型变量 '{var_name}' 不能接受 Boolean 类型的值" + ) + if not isinstance(value, int) and not (isinstance(value, float) and value == int(value)): + raise ValueError( + f"Int 类型变量 '{var_name}' 不能接受 {type(value).__name__} 类型的值" + ) return - inferred_type = _inferType(resolved, stripped) - new_var = ASObject( - upper_name, - inferred_type, - read_only=False, - is_config=False, - default_value=resolved - ) - _SCRIPT_VARS[upper_name] = new_var - - -def _executeOperation( - line_text: str, - target_data: dict, - line: int = 0 -): - """ - Execute a field operation statement: "FIELD .ADD. N" or "FIELD .SUB. N". - - Resolves the left side as a registered ASObject and the right side - as a temporary numeric ASObject, then delegates to ASOperator.apply(). - - Args: - line (str): The raw operation line from the script (e.g. "RESERVE_DATE .ADD. 1"). - target_data (dict): The application data dict. - - Raises: - ValueError: If the field is unknown, the operand is invalid, - or the type does not support the operation. - """ - - parts = line_text.split() - if len(parts) < 3: + if var_type == "Float": + if isinstance(value, bool): + raise ValueError( + f"Float 类型变量 '{var_name}' 不能接受 Boolean 类型的值" + ) + if not isinstance(value, (int, float)): + raise ValueError( + f"Float 类型变量 '{var_name}' 不能接受 {type(value).__name__} 类型的值" + ) + return + if var_type == "Boolean": + if not isinstance(value, bool): + raise ValueError( + f"Boolean 类型变量 '{var_name}' 不能接受 {type(value).__name__} 类型的值" + ) + return + if var_type == "String": + if not isinstance(value, str): + raise ValueError( + f"String 类型变量 '{var_name}' 不能接受 {type(value).__name__} 类型的值" + ) return - if len(parts) > 3: - raise ValueError( - _errPos(line, f"操作语句中存在多余内容 '{' '.join(parts[3:])}'") - ) - field_name = parts[0].upper().strip() - op = parts[1].upper().strip() - raw_value = parts[2].strip() - target = _resolveFieldObj(field_name) - if target is None: - raise ValueError(_errPos(line, f"未知字段 '{field_name}'")) - try: - operand = _resolveAsObject(raw_value, target_data) - ASOperator.apply(target, operand, op, target_data) - except ValueError as e: - raise ValueError(_errPos(line, str(e))) def addTargetVar( name: str, var_type: str, key_path: list, - display_name: str = None -): + display_name: str = None, +) -> None: """ Register a new target variable bound to a path in the application data dict. - Once registered, the variable can be read, written, and operated on in scripts - using its canonical name or display_name. - Args: name (str): The canonical variable name (e.g. "RESERVE_DATE"). - var_type (str): The type ("Int", "Float", "Boolean", "Date", "Time", "String"). - key_path (list): The nested path into target_data, e.g. ["reserve_info", "date"]. - display_name (str): An optional Chinese alias for use in script conditions. - - Example: - >>> addTargetVar("MY_FIELD", "String", ["custom", "field"], display_name="自定义字段") + var_type (str): "Int" | "Float" | "Boolean" | "Date" | "Time" | "String". + key_path (list): Nested path into target_data, e.g. ["reserve_info", "date"]. + display_name (str): Optional Chinese alias (unused by the engine). """ upper_name = name.upper().strip() - obj = ASObject( - upper_name, - var_type, - is_config=True, - key_path=key_path, - display_name=display_name - ) - _TARGET_VARS[upper_name] = obj + _TARGET_VARS[upper_name] = { + "type": var_type, + "key_path": key_path, + } -class _EngineExecutor(NodeVisitor): +def resetEngine( +) -> None: """ - AST visitor that executes AutoScript against target_data. - Walks the AST and dispatches SET / ADD / SUB operations - via visitScript / visitIf / visitSet / visitOp / visitPass / visitUnrecog. + Reset the engine to its initial state: clear all target variables + and release the Lua runtime. + """ + global _TARGET_VARS, _lua + _TARGET_VARS = {} + _lua = None + + +def _push( + target_data: dict, +) -> None: + """ + Push target_data values into Lua globals. + Date / Time strings are converted to native Lua types (timestamp / minutes). """ - def __init__( - self, - target_data: dict - ): + lua = _getLua() + g = lua.globals() + _toDate = g["_to_date"] + _toTime = g["_to_time"] - super().__init__() - self._target_data = target_data - self._cur_line = 0 + for var_name, info in _TARGET_VARS.items(): + key_path = info["key_path"] + vt = info["type"] + raw = _navigatePath(target_data, key_path) - @property - def _line(self) -> int: - """Return current line number for _errPos calls.""" - - return self._cur_line - - - def _incLine( - self - ): - - self._cur_line += 1 - - - def visitScript( - self, - _node: Script - ): - - for child in _node.body: - child.accept(self) - - - def visitIf( - self, - _node: IfNode - ): - - self._incLine() - if not _node.closed: - raise ValueError(_errPos(self._line, "IF 与 ENDIF / END IF 不匹配")) - matched = _evaluateCondition(_node.condition, self._target_data, self._line) - if matched: - for child in _node.body: - child.accept(self) + if vt == "Date": + if raw and isinstance(raw, str): + try: + date.fromisoformat(raw.strip()) + except (ValueError, AttributeError): + raw = "2099-01-01" + else: + raw = "2099-01-01" + g[var_name] = _toDate(raw) + elif vt == "Time": + if raw and isinstance(raw, str): + try: + datetime.strptime(raw.strip(), "%H:%M") + except (ValueError, AttributeError): + raw = "00:00" + else: + raw = "00:00" + g[var_name] = _toTime(raw) else: - executed = False - for elif_node in _node.elif_branches: - self._incLine() - if _evaluateCondition(elif_node.condition, self._target_data, self._line): - for child in elif_node.body: - child.accept(self) - executed = True - break - if not executed and _node.else_body: - self._incLine() - for child in _node.else_body: - child.accept(self) + if raw is None: + raw = "" if vt == "String" else 0 if vt == "Int" else 0.0 if vt == "Float" else False + g[var_name] = raw - def visitSet( - self, - _node: SetNode - ): +def _pull( + target_data: dict, +) -> None: + """ + Pull Lua global values back into target_data. + Date / Time native types are converted back to ISO / HH:MM strings. + """ - self._incLine() - full_line = f"SET {_node.target} = {_node.value}" - _executeSet(full_line, self._target_data, self._line) + lua = _getLua() + g = lua.globals() + _fromDate = g["_from_date"] + _fromTime = g["_from_time"] - - def visitOp( - self, - _node: OpNode - ): - - self._incLine() - op_upper = _node.op_type.upper() - full_line = f"{_node.target} .{op_upper}. {_node.value}" - _executeOperation(full_line, self._target_data, self._line) - - - def visitPass( - self, - _node: PassNode - ): - - self._incLine() - - - def visitUnrecog( - self, - _node: UnrecogNode - ): - - self._incLine() - upper = _node.raw_line.upper().strip() - if upper.startswith("IF"): - paren_open = upper.find("(") - if paren_open < 0: - raise ValueError(_errPos(self._line, "IF 缺少左括号")) - depth = 1 - for ci in range(paren_open + 1, len(upper)): - if upper[ci] == "(": - depth += 1 - elif upper[ci] == ")": - depth -= 1 - if depth == 0: - remaining = upper[ci + 1:].strip() - if remaining and remaining != "THEN": - raise ValueError(_errPos(self._line, f"IF 条件后存在多余内容 '{remaining}'")) - break - if depth > 0: - raise ValueError(_errPos(self._line, "IF 缺少右括号")) - elif upper.startswith("ELSE IF"): - paren_open = upper.find("(") - if paren_open < 0: - raise ValueError(_errPos(self._line, "ELSE IF 缺少左括号")) - raise ValueError(_errPos(self._line, f"无法识别的语法 '{_node.raw_line}'")) + for var_name, info in _TARGET_VARS.items(): + try: + lua_val = g[var_name] + except (KeyError, AttributeError): + continue + vt = info["type"] + if vt == "Date": + lua_val = _fromDate(lua_val) + elif vt == "Time": + lua_val = _fromTime(lua_val) + elif vt == "Float" and isinstance(lua_val, int) and not isinstance(lua_val, bool): + lua_val = float(lua_val) + _checkType(var_name, vt, lua_val) + _assignPath(target_data, info["key_path"], lua_val) def execute( script_text: str, - target_data: dict -): + target_data: dict, +) -> None: """ - Execute an AutoScript on the given target data. + Execute an AutoScript (Lua) on the given target data. - Parses the script into an AST via ASTokenizer.parse(), - then walks the tree with a visitor to evaluate conditions - and dispatch SET / ADD / SUB operations. + The script runs in a sandboxed Lua environment with target variables + exposed as globals. The following helpers are available as Lua functions: - Args: - script_text (str): The AutoScript source code. - target_data (dict): The application data dict to read from / write to. + date(y, m, d) -> timestamp (os.time seconds) + time(h, m) -> minutes since midnight (0-1439) + CURRENT_DATE() -> today's timestamp + CURRENT_TIME() -> current minutes since midnight + date_add(ts, n) -> ts + n * 86400 + time_add(m, n) -> (m + n * 60) % 1440 + + Date and Time values are native Lua numbers during execution. + Arithmetic (+, -) and comparisons (<, <=, ==, ~=, >, >=) work + with strong type safety — no implicit string coercion. Raises: - ValueError: On syntax errors, unbalanced IF/END IF, unknown fields, etc. + ValueError: On Lua compilation/runtime errors or type mismatches. """ - _buildFieldMap() if not script_text or not script_text.strip(): return - ast = ASTokenizer.parse(script_text) - if not ast.body: - return - executor = _EngineExecutor(target_data) - ast.accept(executor) + _push(target_data) + try: + _getLua().execute(script_text) + _pull(target_data) + except Exception as e: + raise ValueError(f"AutoScript 执行错误: {e}") diff --git a/src/autoscript/ASObject.py b/src/autoscript/ASObject.py deleted file mode 100644 index 32302bb..0000000 --- a/src/autoscript/ASObject.py +++ /dev/null @@ -1,269 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Copyright (c) 2026 KenanZhu. -All rights reserved. - -This software is provided "as is", without any warranty of any kind. -You may use, modify, and distribute this file under the terms of the MIT License. -See the LICENSE file for details. -""" -import re -from datetime import ( - datetime, - date, - time -) - - -__all__ = [ - "ASObject", - "_META_VARS", - "_inferType" -] - - -# Default values for each supported type when no value is present -_TYPE_DEFAULTS = { - "Int": 0, - "Float": 0.0, - "Boolean": False, - "Date": None, - "Time": None, - "String": "" -} - -# Mapping from Python type to AutoScript type name for error messages -_PYTHON_TO_AS_TYPE = { - bool: "Boolean", - int: "Int", - float: "Float", - str: "String", - date: "Date", - time: "Time", -} - -def _asTypeName( - value -) -> str: - return _PYTHON_TO_AS_TYPE.get(type(value), "UnknowType") - - -class ASObject: - """ - Represents a variable object used throughout the AutoScript engine. - - An ASObject can be a meta variable (read-only, e.g. CURRENT_DATE), - a target variable (bound to a config data dict via key_path), - or a script variable (free-form, stored internally). - - Args: - name (str): The canonical name of the variable (case-insensitive). - var_type (str): One of "Int", "Float", "Boolean", "Date", "Time", "String". - read_only (bool): Whether the variable is read-only (default: False). - is_config (bool): Whether the variable maps to a target_data path (default: False). - key_path (list): The nested key path into target_data, e.g. ["reserve_info", "date"]. - default_value: The fallback value when no target_data value is found. - display_name (str): An alias for use in script conditions and assignments. - - Example: - >>> obj = ASObject("MY_DATE", "Date", is_config=True, - ... key_path=["reserve_info", "date"], - ... display_name="预约日期") - >>> obj.getValue({"reserve_info": {"date": "2026-05-01"}}) - datetime.date(2026, 5, 1) - """ - - _TEMP_COUNTER = 0 - - def __init__( - self, - name: str, - var_type: str, *, - read_only: bool = False, - is_config: bool = False, - key_path: list = None, - default_value=None, - display_name: str = None - ): - - self.name = name - self.var_type = var_type - self.read_only = read_only - self.is_config = is_config - self.key_path = key_path or [] - self._value = default_value - self.display_name = display_name - - @classmethod - def _makeTemp( - cls, - value, - inferred_type: str - ): - """ - Create a temporary unnamed ASObject from a literal value. - - Temporary objects are used for inline script literals (e.g. 42, - 'hello', DATE(2026-01-01)) so they can participate in typed - operations alongside registered variables. - - Args: - value: The resolved Python value. - inferred_type (str): The AutoScript type name. - - Returns: - ASObject: A temporary, non-config, read-write ASObject. - """ - - cls._TEMP_COUNTER += 1 - return cls( - f"__TMP_{cls._TEMP_COUNTER}", - inferred_type, - read_only=False, - is_config=False, - default_value=value - ) - - - def _typeEmpty( - self - ): - """ - Return the type-appropriate empty / default value. - """ - - return _TYPE_DEFAULTS.get(self.var_type, "") - - - def getValue( - self, - target_data: dict = None - ): - """ - Retrieve the current value of this variable. - - For read-only variables (CURRENT_DATE, CURRENT_TIME), returns the - live datetime. For config variables, traverses the key_path into - target_data and parses Date/Time strings. Otherwise returns the - internal _value. - - Args: - target_data (dict): The application data dict (required for config vars). - - Returns: - The resolved value, or a type-appropriate default if missing. - """ - - if self.read_only: - if self.name == "CURRENT_DATE": - return datetime.now().date() - if self.name == "CURRENT_TIME": - return datetime.now().time() - return self._value - if self.is_config and target_data is not None and self.key_path: - d = target_data - for key in self.key_path[:-1]: - d = d.get(key, {}) - if not isinstance(d, dict): - return self._typeEmpty() - raw = d.get(self.key_path[-1]) - if raw is None: - return self._typeEmpty() - if self.var_type == "Date" and isinstance(raw, str): - try: - return datetime.strptime(raw, "%Y-%m-%d").date() - except ValueError: - return self._typeEmpty() - if self.var_type == "Time" and isinstance(raw, str): - try: - return datetime.strptime(raw, "%H:%M").time() - except ValueError: - return self._typeEmpty() - return raw - return self._value - - - def setValue( - self, - value, - target_data: dict = None - ): - """ - Assign a new value to this variable, with strict type checking. - - AutoScript is strongly typed: only values whose Python type matches the - declared variable type are accepted. Int->Float widening is allowed; - all other cross-type assignments raise ValueError. - - Args: - value: The value to assign. - target_data (dict): The application data dict (required for config vars). - - Raises: - ValueError: If the variable is read-only or value type mismatches the variable type. - """ - - if self.read_only: - raise ValueError(f"不能修改只读变量 '{self.name}'") - vt = self.var_type - value_type = _asTypeName(value) - if vt != value_type and not (vt == "Float" and value_type == "Int"): - raise ValueError( - f"{vt} 类型变量 '{self.name}' 不能接受 {value_type} 类型的值" - ) - - if self.is_config: - if self.var_type == "Date" and isinstance(value, date): - value = value.strftime("%Y-%m-%d") - if self.var_type == "Time" and isinstance(value, time): - value = value.strftime("%H:%M") - if self.is_config and target_data is not None and self.key_path: - d = target_data - for key in self.key_path[:-1]: - d = d.setdefault(key, {}) - d[self.key_path[-1]] = value - else: - self._value = value - - -# Built-in read-only meta variables available to all scripts -_META_VARS = { - "CURRENT_DATE": ASObject("CURRENT_DATE", "Date", read_only=True, display_name="当前日期"), - "CURRENT_TIME": ASObject("CURRENT_TIME", "Time", read_only=True, display_name="当前时间"), -} - - -def _inferType( - value, - raw_expr: str = None -) -> str: - """ - Infer the ASObject type string from a Python value or raw expression. - - When the Python type is ambiguous (e.g. int can be Int or a component - of Date), the raw_expr is used as a hint. - - Args: - value: The resolved Python value. - raw_expr (str): The original expression string from the script (optional). - - Returns: - str: One of "Boolean", "Int", "Float", "Date", "Time", "String". - """ - - if isinstance(value, bool): - return "Boolean" - if isinstance(value, int): - return "Int" - if isinstance(value, float): - return "Float" - if isinstance(value, date): - return "Date" - if isinstance(value, time): - return "Time" - if raw_expr: - if re.match(r"^DATE\(\d{4}-\d{2}-\d{2}\)$", raw_expr, re.IGNORECASE): - return "Date" - if re.match(r"^TIME\(\d{1,2}:\d{2}\)$", raw_expr, re.IGNORECASE): - return "Time" - return "String" diff --git a/src/autoscript/ASObserver.py b/src/autoscript/ASObserver.py deleted file mode 100644 index 7fa3138..0000000 --- a/src/autoscript/ASObserver.py +++ /dev/null @@ -1,78 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Copyright (c) 2026 KenanZhu. -All rights reserved. - -This software is provided "as is", without any warranty of any kind. -You may use, modify, and distribute this file under the terms of the MIT License. -See the LICENSE file for details. -""" - - -class ParsingObserver: - """ - Base observer for AutoScript parsing events. - - Subclass and override the relevant methods to react to - tokenization / parsing events produced by ASTokenizer. - This is the core abstraction that lets pre-check and - orchestration modules subscribe to the same parsing pipeline. - """ - - def onParseStart( - self, - script_text: str - ): - """ - Called when tokenization of a new script begins. - - Args: - script_text (str): The full script text being parsed. - """ - pass - - def onTokenParsed( - self, - kind: str | None, - data, - line_num: int, - raw_line: str - ): - """ - Called after each script line has been classified as a token. - - Args: - kind (str | None): Token kind (K_IF, K_ELSE_IF, K_ELSE, - K_ENDIF, K_SET, K_ADD, K_SUB) or - None if unrecognised. - data: Token payload — condition string for IF/ELSE IF, - (target, value) tuple for SET/ADD/SUB, - None for ELSE/ENDIF/unrecognised. - line_num (int): 1-based line number. - raw_line (str): The stripped raw line text. - """ - pass - - def onParseComplete( - self, - statements: list - ): - """ - Called when flat tokenization is complete. - - Args: - statements (list[Stmt]): The list of parsed Stmt objects. - """ - pass - - def onASTReady( - self, - ast - ): - """ - Called when full AST construction is complete (after parse()). - - Args: - ast (Script): The root Script AST node. - """ - pass diff --git a/src/autoscript/ASOperator.py b/src/autoscript/ASOperator.py deleted file mode 100644 index 9d233ba..0000000 --- a/src/autoscript/ASOperator.py +++ /dev/null @@ -1,191 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Copyright (c) 2026 KenanZhu. -All rights reserved. - -This software is provided "as is", without any warranty of any kind. -You may use, modify, and distribute this file under the terms of the MIT License. -See the LICENSE file for details. -""" -from datetime import ( - datetime, - timedelta, - date, - time -) - -from .ASObject import ASObject - - -__all__ = ["ASOperator", "ARITH_TYPES", "COMPARISON_OPERATORS"] - - -class ASOperator: - """ - Centralised type-safe operations for AutoScript engine types. - - All arithmetic (ADD / SUB) and comparison operators are routed through - this class, which dispatches to the correct Python-level logic based on - the ASObject's var_type. This keeps type-specific branching in one - place instead of scattering it across the engine. - - Args: - op (str): One of ".ADD.", ".SUB.", ".EQ.", ".NEQ.", ".BGT.", - ".BLT.", ".BGE.", ".BLE.". - - Example: - >>> obj = ASObject("X", "Int", default_value=10) - >>> ASOperator.apply(obj, ASObject._makeTemp(5, "Int"), ".ADD.", None) - >>> obj.getValue() - 15 - >>> ASOperator.compare( - ... obj, - ... ASObject._makeTemp(15, "Int"), - ... ".EQ.", None - ... ) - True - """ - - _COMPARE = { - ".EQ." : lambda a, b: a == b, - ".NEQ.": lambda a, b: a != b, - ".BGT.": lambda a, b: a > b, - ".BLT.": lambda a, b: a < b, - ".BGE.": lambda a, b: a >= b, - ".BLE.": lambda a, b: a <= b, - } - _ARITH_TYPES = {"Date", "Time", "Int", "Float"} - # Comparison-compatible type groups - _COMPATIBLE_GROUPS = [ - {"String"}, - {"Boolean"}, - {"Int", "Float"}, - {"Date"}, - {"Time"}, - ] - - @classmethod - def apply( - cls, - target: ASObject, - operand: ASObject, - op: str, - target_data: dict - ): - """ - Apply ADD or SUB to a target ASObject, modifying it in place. - - Args: - target (ASObject): The variable to modify. - operand (ASObject): The operand (numeric value for Date/Time/Int/Float). - op (str): ".ADD." or ".SUB.". - target_data (dict): Application data dict (passed through to getValue/setValue). - - Raises: - ValueError: If the type does not support the operation or values are invalid. - """ - - tp = target.var_type - op_tp = operand.var_type - if tp not in cls._ARITH_TYPES: - raise ValueError(f"'{tp}' 类型字段不支持操作运算") - if op_tp not in ("Int", "Float"): - raise ValueError(f"操作数类型 '{op_tp}' 不能用于运算,需要数值类型 (Int / Float)") - if tp in ("Date", "Time") and op_tp != "Int": - raise ValueError(f"'{tp}' 类型的加减法操作数必须为 Int 类型,不允许 Float") - target_val = target.getValue(target_data) - if target_val is None: - raise ValueError(f"'{target.name}' 的值为空,无法进行运算") - op_name = "ADD" if op == ".ADD." else "SUB" if op == ".SUB." else None - if op_name is None: - raise ValueError(f"不支持的操作 '{op}'") - sign = 1 if op == ".ADD." else -1 - cls._arithBinary(target, target_val, operand, target_data, sign, op_name) - - @classmethod - def _arithBinary( - cls, - target: ASObject, - target_val, - operand: ASObject, - target_data: dict, - sign: int, - op_name: str = "" - ): - """Apply arithmetic per type.""" - - tp = target.var_type - raw_op = operand.getValue(target_data) - - if tp == "Date": - if not isinstance(target_val, date): - raise ValueError(f"'{target.name}' 的值 '{target_val}' 不是有效日期") - new_val = target_val + timedelta(days=int(raw_op)) * sign - elif tp == "Time": - if not isinstance(target_val, time): - raise ValueError(f"'{target.name}' 的值 '{target_val}' 不是有效时间") - delta = timedelta(hours=int(raw_op)) * sign - dt = datetime.combine(datetime.today(), target_val) + delta - new_val = dt.time() - elif tp == "Int": - new_val = int(target_val) + int(raw_op)*sign - elif tp == "Float": - new_val = float(target_val) + float(raw_op)*sign - else: - raise ValueError(f"'{tp}' 类型不支持 {op_name} 操作") - target.setValue(new_val, target_data) - - @classmethod - def compare( - cls, - left: ASObject, - right: ASObject, - op: str, - target_data: dict - ) -> bool: - """ - Compare two ASObjects using the given comparison operator. - - Args: - left (ASObject): Left-hand side. - right (ASObject): Right-hand side. - op (str): One of ".EQ.", ".NEQ.", ".BGT.", ".BLT.", ".BGE.", ".BLE.". - target_data (dict): Application data dict. - - Returns: - bool: The comparison result. - - Raises: - ValueError: If the types are incompatible for comparison. - """ - - cmp_func = cls._COMPARE.get(op) - if cmp_func is None: - raise ValueError(f"未知的比较操作 '{op}'") - left_tp = left.var_type - right_tp = right.var_type - if left_tp != right_tp: - same_group = any( - left_tp in g and right_tp in g - for g in cls._COMPATIBLE_GROUPS - ) - if not same_group: - raise ValueError( - f"类型不兼容: 无法将 '{left.name}' ({left_tp}) " - f"与 '{right.name}' ({right_tp}) 进行比较" - ) - left_val = left.getValue(target_data) - right_val = right.getValue(target_data) - try: - return cmp_func(left_val, right_val) - except TypeError: - raise ValueError( - f"无法比较 '{left.name}' ({left.var_type}) " - f"与 '{right.name}' ({right.var_type})" - ) - - -# Public constants -# may be used by the GUI orchestration dialog. -ARITH_TYPES = ASOperator._ARITH_TYPES -COMPARISON_OPERATORS = set(ASOperator._COMPARE.keys()) diff --git a/src/autoscript/ASTokenizer.py b/src/autoscript/ASTokenizer.py deleted file mode 100644 index d82ddc7..0000000 --- a/src/autoscript/ASTokenizer.py +++ /dev/null @@ -1,584 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Copyright (c) 2026 KenanZhu. -All rights reserved. - -This software is provided "as is", without any warranty of any kind. -You may use, modify, and distribute this file under the terms of the MIT License. -See the LICENSE file for details. -""" -import re - - -__all__ = [ - "ASTokenizer", - "Stmt", - "Script", - "IfNode", - "ElifNode", - "SetNode", - "OpNode", - "PassNode", - "UnrecogNode", - "NodeVisitor", - "LineStrategy" -] - - -# Token kind constants -K_IF = "IF" -K_ELSE_IF = "ELSE IF" -K_ELSE = "ELSE" -K_ENDIF = "ENDIF" -K_SET = "SET" -K_ADD = "ADD" -K_SUB = "SUB" -K_PASS = "PASS" - -# Op-type constants -OP_SET = "set" -OP_ADD = "add" -OP_SUB = "sub" - -# Compiled line patterns -_RE_IF = re.compile(r"^IF\((.+)\)(?:\s+THEN\s*)?$", re.IGNORECASE) -_RE_ELSE_IF = re.compile(r"^ELSE\s+IF\((.+)\)(?:\s+THEN\s*)?$", re.IGNORECASE) -_RE_ELSE = re.compile(r"^ELSE\s*$", re.IGNORECASE) -_RE_ENDIF = re.compile(r"^(ENDIF|END IF)$", re.IGNORECASE) -_RE_SET = re.compile(r"^SET\s+(\w+)\s*=\s*(.+)$", re.IGNORECASE) -_RE_ADD = re.compile(r"^(\w+)\s+\.ADD\.\s+(-?\d+(?:\.\d+)?|\w+)$", re.IGNORECASE) -_RE_SUB = re.compile(r"^(\w+)\s+\.SUB\.\s+(-?\d+(?:\.\d+)?|\w+)$", re.IGNORECASE) -_RE_PASS = re.compile(r"^\s*PASS\s*$", re.IGNORECASE) - - -class Script: - """ - Root AST node for an entire AutoScript. - Contains an ordered list of top-level statement nodes. - """ - - def __init__( - self, - body: list = None - ): - - self.body = body or [] - - def accept( - self, - visitor - ): - - return visitor.visitScript(self) - - -class IfNode: - """ - IF conditional block with optional ELSE IF / ELSE branches. - - Attributes: - condition (str): Raw condition expression. - body (list): Statements executed when condition is true. - elif_branches (list[ElifNode]): ELSE IF branches in order. - else_body (list): Statements executed for the ELSE branch. - closed (bool): Whether this IF has a matching ENDIF token. - """ - - def __init__( - self, - condition: str = "", - body: list = None, - elif_branches: list = None, - else_body: list = None, - closed: bool = True - ): - - self.condition = condition - self.body = body or [] - self.elif_branches = elif_branches or [] - self.else_body = else_body or [] - self.closed = closed - - def accept( - self, - visitor - ): - - return visitor.visitIf(self) - - -class ElifNode: - """ - ELSE IF branch within an IfNode. - """ - - def __init__( - self, - condition: str = "", - body: list = None - ): - - self.condition = condition - self.body = body or [] - - -class SetNode: - """ - SET assignment statement. - """ - - def __init__( - self, - target: str = "", - value: str = "" - ): - - self.target = target - self.value = value - - def accept( - self, - visitor - ): - - return visitor.visitSet(self) - - -class OpNode: - """ - .ADD. / .SUB. operation statement. - """ - - def __init__( - self, - op_type: str = "", - target: str = "", - value: str = "" - ): - - self.op_type = op_type - self.target = target - self.value = value - - def accept( - self, - visitor - ): - - return visitor.visitOp(self) - - -class PassNode: - """ - PASS no-op statement. - """ - - def accept( - self, - visitor - ): - - return visitor.visitPass(self) - - -class UnrecogNode: - """ - Unrecognised line preserved for downstream error reporting. - """ - - def __init__( - self, - raw_line: str = "" - ): - - self.raw_line = raw_line - - def accept( - self, - visitor - ): - - return visitor.visitUnrecog(self) - - -class NodeVisitor: - """ - Base visitor for the AutoScript AST. - - Subclass and override visit* methods to implement - custom traversal logic. Default walks tree depth-first. - """ - - def visitScript( - self, - _node: Script - ): - - for child in _node.body: - child.accept(self) - - def visitIf( - self, - _node: IfNode - ): - - for child in _node.body: - child.accept(self) - for elif_node in _node.elif_branches: - for child in elif_node.body: - child.accept(self) - for child in _node.else_body: - child.accept(self) - - def visitSet( - self, - _node: SetNode - ): - - pass - - def visitOp( - self, - _node: OpNode - ): - - pass - - def visitPass( - self, - _node: PassNode - ): - - pass - - def visitUnrecog( - self, - _node: UnrecogNode - ): - - pass - - -class LineStrategy: - """ - Encapsulates a regex pattern and its data-extraction handler. - Used by the tokenizer to classify a single line. - """ - - def __init__( - self, - pattern, - handler - ): - - self.pattern = pattern - self.handler = handler - - def match( - self, - line: str - ): - - m = self.pattern.match(line) - if m: - return self.handler(m) - return None - - -# Strategy instances — one per recognised AutoScript syntax form -_LINE_STRATEGIES = [ - LineStrategy(_RE_IF, lambda m: (K_IF, m.group(1))), - LineStrategy(_RE_ELSE_IF, lambda m: (K_ELSE_IF, m.group(1))), - LineStrategy(_RE_ELSE, lambda m: (K_ELSE, None)), - LineStrategy(_RE_ENDIF, lambda m: (K_ENDIF, None)), - LineStrategy(_RE_SET, lambda m: (K_SET, (m.group(1).strip(), m.group(2).strip()))), - LineStrategy(_RE_ADD, lambda m: (K_ADD, (m.group(1).strip(), m.group(2).strip()))), - LineStrategy(_RE_SUB, lambda m: (K_SUB, (m.group(1).strip(), m.group(2).strip()))), - LineStrategy(_RE_PASS, lambda m: (K_PASS, None)), -] - - -class Stmt: - """ - Flat statement container, backward-compatible with the original - tokenize() output and the orchestration dialog's _classifyLine. - """ - - def __init__( - self, - kind: str | None = None, - condition: str | None = None, - target: str | None = None, - value: str | None = None, - op_type: str | None = None, - raw_line: str = "" - ): - - self.kind = kind - self.condition = condition - self.target = target - self.value = value - self.op_type = op_type - self.raw_line = raw_line - - -class ASTokenizer: - """ - Tokenizer / parser for the AutoScript DSL. - - Main class-level entry points (engine-facing): - - classifyLine(line) — single-line classifier. - - tokenize(script) — flat Stmt list. - - parse(script) — structured AST (Script root). - - Observer-enabled API (used by pre-check & orchestration): - >>> obs = ScriptPrecheckObserver() - >>> stmts = ASTokenizer.tokenizeWithObservers(script, [obs]) - """ - - @classmethod - def _notifyObservers( - cls, - observers: list, - method: str, - *args - ): - - for obs in observers: - getattr(obs, method)(*args) - - @classmethod - def _matchLine( - cls, - stripped: str - ): - - for strategy in _LINE_STRATEGIES: - result = strategy.match(stripped) - if result: - return result - return (None, None) - - @classmethod - def _buildStmt( - cls, - stripped: str, - kind: str | None, - data - ) -> Stmt: - - stmt = Stmt(kind=kind, raw_line=stripped) - if kind == K_IF or kind == K_ELSE_IF: - stmt.condition = data - elif kind == K_SET: - stmt.target, stmt.value = data - stmt.op_type = OP_SET - elif kind == K_ADD: - stmt.target, stmt.value = data - stmt.op_type = OP_ADD - elif kind == K_SUB: - stmt.target, stmt.value = data - stmt.op_type = OP_SUB - return stmt - - @classmethod - def _stripComment( - cls, - line: str - ) -> str: - - in_single = False - in_double = False - i = 0 - while i < len(line): - ch = line[i] - if ch == "'" and not in_double: - if i + 1 < len(line) and line[i + 1] == "'": - i += 2 - continue - in_single = not in_single - elif ch == '"' and not in_single: - in_double = not in_double - elif ch == "/" and i + 1 < len(line) and line[i + 1] == "/" and not in_single and not in_double: - return line[:i].rstrip() - i += 1 - return line - - @classmethod - def _tokenizeImpl( - cls, - script: str - ) -> list: - - statements = [] - for raw_line in script.split("\n"): - code = cls._stripComment(raw_line.strip()) - if not code: - continue - kind, data = cls._matchLine(code) - statements.append(cls._buildStmt(code, kind, data)) - return statements - - @classmethod - def _parseTokens( - cls, - tokens: list - ) -> Script: - - body = [] - i = 0 - while i < len(tokens): - tok = tokens[i] - kind = tok.kind - - if kind == K_IF: - node, consumed = cls._parseIfBlock(tokens, i) - body.append(node) - i += consumed - elif kind in (K_ELSE_IF, K_ELSE, K_ENDIF): - i += 1 - elif kind == K_SET: - body.append(SetNode(target=tok.target, value=tok.value)) - i += 1 - elif kind in (K_ADD, K_SUB): - body.append(OpNode( - op_type=tok.op_type, - target=tok.target, - value=tok.value - )) - i += 1 - elif kind == K_PASS: - body.append(PassNode()) - i += 1 - else: - body.append(UnrecogNode(raw_line=tok.raw_line)) - i += 1 - return Script(body=body) - - @classmethod - def classifyLine( - cls, - stripped: str - ): - - kind, data = cls._matchLine(stripped) - if kind is None or kind == K_PASS: - return None - return (kind, data) - - @classmethod - def tokenize( - cls, - script: str - ) -> list: - - return cls._tokenizeImpl(script) - - @classmethod - def parse( - cls, - script: str - ) -> Script: - - return cls._parseTokens(cls._tokenizeImpl(script)) - - @classmethod - def tokenizeWithObservers( - cls, - script: str, - observers: list - ) -> list: - """ - Tokenize and notify observers for each classified line. - - Fires onParseStart, onTokenParsed, and onParseComplete - events to each observer. This is the single tokenization - pipeline shared by pre-check and orchestration modules. - """ - - cls._notifyObservers(observers, "onParseStart", script) - statements = [] - for i, raw_line in enumerate(script.split("\n"), 1): - code = cls._stripComment(raw_line.strip()) - if not code: - continue - kind, data = cls._matchLine(code) - cls._notifyObservers(observers, "onTokenParsed", kind, data, i, code) - statements.append(cls._buildStmt(code, kind, data)) - cls._notifyObservers(observers, "onParseComplete", statements) - return statements - - @classmethod - def parseWithObservers( - cls, - script: str, - observers: list - ) -> Script: - """ - Parse and notify observers throughout the pipeline. - - Calls tokenizeWithObservers (which fires per-token events), - then builds the AST and fires onASTReady. - """ - - tokens = cls.tokenizeWithObservers(script, observers) - ast = cls._parseTokens(tokens) - cls._notifyObservers(observers, "onASTReady", ast) - return ast - - @classmethod - def _parseIfBlock( - cls, - tokens: list, - start: int - ): - - first = tokens[start] - node = IfNode(condition=first.condition or "") - body = [] - elif_branches = [] - else_body = [] - current_target = body - i = start + 1 - - while i < len(tokens): - tok = tokens[i] - kind = tok.kind - if kind == K_IF: - sub_node, consumed = cls._parseIfBlock(tokens, i) - current_target.append(sub_node) - i += consumed - elif kind == K_ELSE_IF: - elif_branches.append(ElifNode(condition=tok.condition or "")) - current_target = elif_branches[-1].body - i += 1 - elif kind == K_ELSE: - else_body = [] - current_target = else_body - i += 1 - elif kind == K_ENDIF: - node.body = body - node.elif_branches = elif_branches - node.else_body = else_body - return (node, i - start + 1) - elif kind == K_SET: - current_target.append(SetNode(target=tok.target, value=tok.value)) - i += 1 - elif kind in (K_ADD, K_SUB): - current_target.append(OpNode( - op_type=tok.op_type, - target=tok.target, - value=tok.value - )) - i += 1 - elif kind == K_PASS: - current_target.append(PassNode()) - i += 1 - else: - current_target.append(UnrecogNode(raw_line=tok.raw_line)) - i += 1 - node.body = body - node.elif_branches = elif_branches - node.else_body = else_body - node.closed = False - return (node, i - start) diff --git a/src/autoscript/__init__.py b/src/autoscript/__init__.py index d1a474d..480d09e 100644 --- a/src/autoscript/__init__.py +++ b/src/autoscript/__init__.py @@ -1,43 +1,30 @@ +# -*- coding: utf-8 -*- """ - AutoScript module for the AutoLibrary project. - A lightweight scripting DSL for preprocessing user reservation data. +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. """ -from autoscript.ASTokenizer import ( - ASTokenizer, - Stmt, - ElifNode, - Script, - IfNode, - SetNode, - OpNode, -) from autoscript.ASEngine import ( execute, addTargetVar, - splitTopLevel, + resetEngine, + META_VARS, ) -from autoscript.ASObject import _META_VARS as META_VARS -from autoscript.ASObserver import ParsingObserver __all__ = [ "execute", "addTargetVar", - "splitTopLevel", + "resetEngine", "registerDefaultTargetVars", "buildMockTargetData", "META_VARS", "ALL_VARIABLES", "_TARGET_VAR_DEFS", "_MOCK_TYPE_VALUES", - "ASTokenizer", - "Stmt", - "Script", - "IfNode", - "SetNode", - "OpNode", - "ElifNode", - "ParsingObserver", ] @@ -56,8 +43,8 @@ ALL_VARIABLES = { display_name: (name, var_type) for name, var_type, _, display_name in _TARGET_VAR_DEFS } | { - obj.display_name: (obj.name, obj.var_type) - for obj in META_VARS.values() + v["display"]: (v["name"], v["type"]) + for v in META_VARS.values() } _MOCK_TYPE_VALUES = { "String": "__mock__",