Files
AgentLab/backend/app/engine/expressions.py
T
ThearchyHelios 03c18c4d78 界面文案整改:口语改书面、术语统一、不再露出实现细节;修掉几处说错的提示;新增文案检查防回潮
一、文案(前端约 1600 处,后端传到界面的约 880 处)
- 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」;
  总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别
- 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法
- 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」);
  报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供;
  发布弹窗里结论句的选项不再是 off / require_citation
- 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份
- 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例

二、说错的提示(都有测试证明)
- 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀
- 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页
- 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填
- 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因
- 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头
- 导出的证据 CSV 里两列同名,第三列改为「片段编号」

三、前后端对齐
- 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对
- 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上

四、检查
- 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证
- 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、
  界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
2026-09-30 08:05:15 +08:00

660 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import ast
import json
import math
import operator
import re
from typing import Any
# --------------------------------------------------------------------------
# 变量插值: {{ input.query }} / {{ nodes.n1.text }} / {{ vars.items | json }}
# --------------------------------------------------------------------------
_TEMPLATE_RE = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
def resolve_path(ctx: dict[str, Any], path: str) -> Any:
"""按点号路径取值,支持 a.b[0].c。取不到返回 None 而不是抛错 —— 编排里
半成品状态很常见,让它渲染成空字符串比中断整个 run 有用。"""
cur: Any = ctx
for raw in path.split("."):
raw = raw.strip()
if not raw:
continue
# 拆出 name[0][1] 形式的下标
m = re.match(r"^([^\[\]]*)((?:\[[^\[\]]+\])*)$", raw)
if not m:
return None
name, idx_part = m.group(1), m.group(2)
if name:
if isinstance(cur, dict):
cur = cur.get(name)
else:
cur = getattr(cur, name, None)
if cur is None:
return None
for idx in re.findall(r"\[([^\[\]]+)\]", idx_part):
idx = idx.strip().strip("'\"")
try:
if isinstance(cur, (list, tuple, str)):
cur = cur[int(idx)]
elif isinstance(cur, dict):
cur = cur.get(idx)
else:
return None
except (ValueError, IndexError, KeyError, TypeError):
return None
return cur
_FILTERS = {
"json": lambda v: json.dumps(v, ensure_ascii=False, indent=2, default=str),
"compact": lambda v: json.dumps(v, ensure_ascii=False, separators=(",", ":"), default=str),
"upper": lambda v: str(v).upper(),
"lower": lambda v: str(v).lower(),
"trim": lambda v: str(v).strip(),
"length": lambda v: len(v) if hasattr(v, "__len__") else 0,
"first": lambda v: v[0] if isinstance(v, (list, tuple)) and v else None,
"last": lambda v: v[-1] if isinstance(v, (list, tuple)) and v else None,
}
def _stringify(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, (dict, list, tuple)):
return json.dumps(value, ensure_ascii=False, default=str)
return str(value)
def render_template(text: str, ctx: dict[str, Any]) -> str:
"""把字符串里的 {{ ... }} 全部替换掉。支持 `路径 | 过滤器` 语法。"""
if not text or "{{" not in text:
return text or ""
def _sub(match: re.Match[str]) -> str:
expr = match.group(1).strip()
if not expr:
return ""
parts = [p.strip() for p in expr.split("|")]
value = resolve_path(ctx, parts[0])
for filt in parts[1:]:
fn = _FILTERS.get(filt)
if fn:
try:
value = fn(value)
except Exception: # noqa: BLE001 - 过滤器不该让 run 挂掉
value = None
return _stringify(value)
return _TEMPLATE_RE.sub(_sub, text)
def render_deep(value: Any, ctx: dict[str, Any]) -> Any:
"""递归渲染嵌套结构里的所有模板字符串。"""
if isinstance(value, str):
return render_template(value, ctx)
if isinstance(value, dict):
return {k: render_deep(v, ctx) for k, v in value.items()}
if isinstance(value, list):
return [render_deep(v, ctx) for v in value]
return value
# --------------------------------------------------------------------------
# 安全表达式求值(分支条件用)
# --------------------------------------------------------------------------
class ExpressionError(ValueError):
pass
# 幂运算的上界。`9**9**9**9` 只有 11 个 AST 节点,轻松过掉节点数护栏,
# 却会让 CPython 无限期地算一个天文数字的大整数 —— 而求值是在事件循环里
# 同步执行的(分支条件、transform、口径卡、每个节点的 skip_if 都走这里),
# 既没有线程池隔离也没有超时,asyncio 的 cancel 打断不了同步 CPU。
# 一条画布上的分支条件就能把整个后端冻死。
_MAX_POW_EXPONENT = 1024
_MAX_POW_BASE = 1e15
def _safe_pow(base: Any, exponent: Any) -> Any:
"""带上界的幂运算:超界直接报错,而不是让进程算到天荒地老。"""
try:
if abs(exponent) > _MAX_POW_EXPONENT:
raise ExpressionError(f"指数超过上限 {_MAX_POW_EXPONENT}")
if abs(base) > _MAX_POW_BASE and exponent > 1:
raise ExpressionError(f"底数超过上限 {_MAX_POW_BASE:g}")
except TypeError as e: # 非数值类型交给 operator.pow 自己报
raise ExpressionError(f"幂运算的操作数必须是数值:{e}") from e
return operator.pow(base, exponent)
_BIN_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: _safe_pow,
}
_CMP_OPS = {
ast.Eq: operator.eq,
ast.NotEq: operator.ne,
ast.Lt: operator.lt,
ast.LtE: operator.le,
ast.Gt: operator.gt,
ast.GtE: operator.ge,
ast.In: lambda a, b: a in b,
ast.NotIn: lambda a, b: a not in b,
ast.Is: operator.is_,
ast.IsNot: operator.is_not,
}
_UNARY_OPS = {ast.Not: operator.not_, ast.USub: operator.neg, ast.UAdd: operator.pos}
# --------------------------------------------------------------------------
# 查询结果里的一格:cell(x, row, column)
#
# 口径卡直接从 tool 节点的查询结果取数时,以前只能写 vars.q.rows[0][2]:列的顺序一改,
# 取到的就是另一列,而且报告点开只追得到这条路径。cell() 按列名取,来历能精确到
# 那次查询的那一格。报告里的 [[v:Q1.r0.amount]] 用的也是这里的规则,两边取到的永远是同一个值。
# --------------------------------------------------------------------------
#: 数据源把 DECIMAL 一律落成 str(Decimal)(data/engine.py 的 _jsonable):"45678.50";小数位为 0 的
#: ——MySQL 对整数列 SUM()(包括 SUM(CASE WHEN … THEN 1 ELSE 0 END) 这种计数)、Postgres 的
#: SUM(bigint)、NUMERIC(10,0)——是没有小数点的 "45678";很小的写成 "1E-7"。这些都按数算。
#: 带前导零的 "00123" 是编号,照文本。快照里没有列类型:文本列里的值恰好全是不带前导零的数字时,
#: 同样会被当成数,要文本就写 str(cell(...))
_NUMERIC_TEXT = re.compile(r"^\s*-?(?:0|[1-9]\d*)(?:\.\d+)?(?:E[+-]?\d+)?\s*$")
#: 字段声明成数时,前导零、正号、小写 e 也按数读:写 schema 的人说了它是数
_LOOSE_NUMERIC_TEXT = re.compile(r"^\s*[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\s*$")
_INTEGER_TEXT = re.compile(r"^\s*[+-]?\d+\s*$")
class CellError(ExpressionError):
"""取不到那一格。消息是人话:第几行、有几行、有哪些列。口径卡、分支条件照接住
表达式错误的路子接住它。"""
def numeric_text(raw: Any, *, loose: bool = False) -> int | float | None:
"""数字文本 → 数;不是数字文本(或者根本不是文本)返回 None。
没有小数点、没有指数的给整数:不经 float,大整数不丢位,45678 也不会变成 45678.0。
loose=True 连前导零、正号也认,只在字段声明成数时用。
"""
if not isinstance(raw, str) or not (_LOOSE_NUMERIC_TEXT if loose else _NUMERIC_TEXT).match(raw):
return None
try:
return int(raw) if _INTEGER_TEXT.match(raw) else float(raw)
except ValueError: # 超过 Python 整数位数上限(4300 位)的:照文本,不让它在渲染、核对里炸开
return None
def cell_value(raw: Any, kind: str | None = None) -> Any:
"""快照里的一格 → 参与运算、比对的值:DECIMAL 落成的数字文本换成数,其余原样。
kind 是查询时从驱动的原始值记下的列类型(query_snapshot 的 column_types):text 列原样是文本,
VARCHAR 的 "2026" 不会变成 2026。不给(老快照没记、口径卡表达式里的 cell())就按值猜。
"""
if kind == "text":
return raw
number = numeric_text(raw)
return raw if number is None else number
def column_kind(table: Any, column: Any) -> str | None:
"""查询时记下的这一列的类型:number / text / date / datetime / time / boolean。老快照没记,返回 None。"""
types = table.get("column_types") if isinstance(table, dict) else None
kind = types.get(column) if isinstance(types, dict) and isinstance(column, str) else None
return kind if isinstance(kind, str) else None
def same_value(model: Any, truth: Any) -> bool:
"""模型报的值和快照里的一格是不是一回事:数按数比(数字文本也算数,45678.0 和 "45678" 一致),
文本压成一行比。cite_fields 的核对和口径卡的来历用同一把尺子。"""
if model is None or truth is None:
return model is None and truth is None
m, t = cell_value(model), cell_value(truth)
if isinstance(m, bool) or isinstance(t, bool):
return m == t
if isinstance(m, (int, float)) and isinstance(t, (int, float)):
return math.isclose(float(m), float(t), rel_tol=1e-9, abs_tol=1e-9)
return re.sub(r"\s+", " ", str(m)).strip() == re.sub(r"\s+", " ", str(t)).strip()
def _table_of(table: Any) -> dict[str, Any]:
data = table
if isinstance(data, str):
try:
data = json.loads(data)
except ValueError:
# 查库失败时工具交回的是一句话(「查询失败:…」):原话照搬,比「不是 JSON」有用
raise CellError(f"需要查询结果,实际得到的是「{data.strip()[:80]}」") from None
if not isinstance(data, dict) or not isinstance(data.get("rows"), list):
raise CellError("第一个参数需要是查询结果:查询工具返回的 JSON 文本,或 {columns, rows} 结构")
return data
def locate_cell(table: Any, row: Any, column: Any) -> tuple[Any, str]:
"""(那一格的原值, 列名)。列号换成列名:按列名和按列号引用同一格,得到同一个 eid。"""
data = _table_of(table)
rows = data["rows"]
if isinstance(row, bool) or not isinstance(row, int):
raise CellError(f"行号需为从 0 开始的整数,当前为「{row}」")
if not rows:
raise CellError("查询结果为空(0 行)")
if not 0 <= row < len(rows):
raise CellError(f"请求第 {row} 行,但查询结果只有 {len(rows)} 行(行号从 0 开始)")
record = rows[row]
columns = [str(c) for c in data.get("columns") or []]
if isinstance(record, dict):
columns = columns or [str(k) for k in record]
if isinstance(column, int) and not isinstance(column, bool):
if not 0 <= column < len(columns):
raise CellError(f"请求第 {column} 列,但查询结果只有 {len(columns)} 列(列号从 0 开始)")
column = columns[column]
elif not isinstance(column, str):
raise CellError(f"列需填写列名或从 0 开始的列号,当前为「{column}」")
if isinstance(record, dict):
if column not in record:
raise CellError(f"没有列「{column}」,可用的列:{'、'.join(columns[:12])}")
return record[column], column
if column not in columns:
raise CellError(f"没有列「{column}」,可用的列:{'、'.join(columns[:12])}")
index = columns.index(column)
if not isinstance(record, (list, tuple)) or index >= len(record):
raise CellError(f"第 {row} 行中没有「{column}」对应的单元格")
return record[index], column
def table_cell(table: Any, row: Any, column: Any) -> Any:
"""表达式里的 cell(x, row, column):x 是查询工具交回的 JSON 文本或 {columns, rows}。
不看列类型、照旧按值猜:口径卡是在算数,数字写成文本的列(CAST 成文本、VARCHAR 存的金额)多半
就是要拿来算的。要文本写 str(cell(...))。
"""
try:
raw, _ = locate_cell(table, row, column)
except CellError as e:
raise CellError(f"cell() {e}") from None
return cell_value(raw)
_SAFE_FUNCS: dict[str, Any] = {
"len": len,
"str": str,
"int": int,
"float": float,
"bool": bool,
"abs": abs,
"min": min,
"max": max,
"sum": sum,
"round": round,
"sorted": sorted,
"any": any,
"all": all,
"lower": lambda s: str(s).lower(),
"upper": lambda s: str(s).upper(),
"contains": lambda hay, needle: needle in hay if hay is not None else False,
"startswith": lambda s, p: str(s).startswith(p),
"endswith": lambda s, p: str(s).endswith(p),
"matches": lambda s, p: bool(re.search(p, str(s))),
"get": lambda obj, key, default=None: (obj or {}).get(key, default),
"cell": table_cell,
}
_MAX_NODES = 200
#: 表达式能引用的根名字,就是 state.template_context 的那几个键(有测试钉住两边一致)。
#: 不在这里、也不是白名单函数或 true/false/null 的名字,运行时一律取到 None——
#: `gate == "ok"` 这种漏写了 vars. 的条件永远不成立,而且不报错
EXPRESSION_ROOTS = frozenset(
{"input", "nodes", "vars", "output", "loops", "usage", "messages", "last_message"}
)
_LITERAL_NAMES = frozenset({"true", "True", "false", "False", "null", "None"})
_ALLOWED_NODES: tuple[type[ast.AST], ...] = (
ast.Expression, ast.Constant, ast.Name, ast.Load, ast.Attribute, ast.Subscript,
ast.BinOp, ast.UnaryOp, ast.BoolOp, ast.And, ast.Or, ast.Compare, ast.IfExp,
ast.Call, ast.keyword, ast.List, ast.Tuple, ast.Dict,
*_BIN_OPS, *_UNARY_OPS, *_CMP_OPS,
)
_NODE_NAMES = {
"ListComp": "列表推导式", "SetComp": "集合推导式", "DictComp": "字典推导式",
"GeneratorExp": "生成器表达式", "Lambda": "lambda", "JoinedStr": "f-string",
"NamedExpr": ":=", "Slice": "切片 a[i:j]", "Starred": "* 展开", "Await": "await",
"Set": "集合写法",
}
#: 不支持的运算符给人看的写法:报错里写符号,不写 Python 的类名(BitOr)
_OP_SYMBOLS = {
"BitOr": "|", "BitAnd": "&", "BitXor": "^", "LShift": "<<", "RShift": ">>", "MatMult": "@",
"Invert": "~",
}
def _unsupported(name: str) -> str:
"""「表达式中不支持列表推导式」「表达式中不支持 lambda」:英文写法前面空一格。"""
label = _NODE_NAMES.get(name, name)
return f"表达式中不支持{' ' if label[:1].isascii() else ''}{label}"
class _Unbrace(ast.NodeTransformer):
"""`{{ vars.x }}` 在 Python 里是"装着一个集合的集合"。表达式不允许集合,所以
它在表达式里只可能是一种笔误:把模板写法带了进来——模型和人都这么写(开发库
里四次运行就死在"表达式里不允许出现 Set")。意思毫无歧义,按 vars.x 理解。
在语法树上剥,不在文本上剥:字符串字面量里的花括号原样保留。
"""
def __init__(self) -> None:
self.found: list[str] = []
def visit_Set(self, node: ast.Set) -> ast.AST:
self.generic_visit(node)
inner = node.elts[0] if len(node.elts) == 1 else None
if isinstance(inner, ast.Set) and len(inner.elts) == 1:
self.found.append(ast.unparse(inner.elts[0]))
return inner.elts[0]
return node
def _check_static(tree: ast.Expression) -> list[str]:
"""不求值,只看结构:违规直接抛 ExpressionError,返回不认识的名字(留给提示用)。
运行时求值会短路(and/or、三元只走一边),没走到的那一半写错了也发现不了;
画布校验和 Copilot 自查要的是"整条表达式都能跑",所以得把整棵树走一遍。
"""
unknown: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.BinOp) and type(node.op) not in _BIN_OPS:
if isinstance(node.op, ast.BitOr):
raise ExpressionError(
"表达式中不支持 | 过滤器(那是模板的写法):取长度请写 len(x),转换大小写请写 upper(x) / lower(x)"
)
op_name = type(node.op).__name__
raise ExpressionError(f"不支持运算符 {_OP_SYMBOLS.get(op_name, op_name)}")
if not isinstance(node, _ALLOWED_NODES):
if isinstance(node, ast.Set):
raise ExpressionError("表达式中不支持集合写法 {…}:如需判断是否属于其中之一,请使用列表,例如 x in ['a', 'b']")
raise ExpressionError(_unsupported(type(node).__name__))
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name) or node.func.id not in _SAFE_FUNCS:
called = node.func.id if isinstance(node.func, ast.Name) else ast.unparse(node.func)
raise ExpressionError(f"不支持函数 {called}(),可用的函数:{'、'.join(_SAFE_FUNCS)}")
elif isinstance(node, ast.Attribute) and node.attr.startswith("_"):
raise ExpressionError("不允许访问私有属性")
elif (isinstance(node, ast.Constant) and isinstance(node.value, str)
and _TEMPLATE_RE.search(node.value)):
raise ExpressionError(
f"字符串「{node.value}」中的 {{{{ }}}} 不会被渲染:表达式不经过模板渲染,请直接写路径,例如 vars.x"
)
elif (isinstance(node, ast.Name) and node.id not in EXPRESSION_ROOTS
and node.id not in _SAFE_FUNCS and node.id not in _LITERAL_NAMES):
unknown.append(node.id)
return list(dict.fromkeys(unknown))
def parse_expression(expr: str) -> tuple[ast.Expression, list[str], list[str]]:
"""解析 + 静态检查:(语法树, 被剥掉的 {{ }}, 不认识的名字)。结构违规抛 ExpressionError。
运行时求值、画布校验、Copilot 自查都走这一个入口——三处各写一套规则的话,
迟早出现"校验说能跑、跑起来报错"。
"""
text = (expr or "").strip()
try:
tree = ast.parse(text, mode="eval")
except SyntaxError as e:
hint = "(判断相等请写 ==)" if re.search(r"(?<![=!<>])=(?!=)", text) else ""
raise ExpressionError(f"表达式语法错误:{e.msg}{hint}") from e
unbrace = _Unbrace()
tree = ast.fix_missing_locations(unbrace.visit(tree))
if sum(1 for _ in ast.walk(tree)) > _MAX_NODES:
raise ExpressionError("表达式过于复杂")
unknown = _check_static(tree)
return tree, unbrace.found, unknown
# --------------------------------------------------------------------------
# 引用与代入:口径卡要说清「这个数是拿哪几个输入算出来的」
# --------------------------------------------------------------------------
def _chain_root(node: ast.AST) -> str | None:
"""node 是不是一条从根名字出发的取值链(vars.kpi.gmv、nodes.q.rows[0])。是就返回根。"""
cur = node
while isinstance(cur, (ast.Attribute, ast.Subscript)):
cur = cur.value
if isinstance(cur, ast.Name) and cur.id in EXPRESSION_ROOTS:
return cur.id
return None
def _cell_call(node: ast.AST) -> bool:
"""cell(<取值链>, row, column):第一个参数是从根名字出发的取值链,整个调用算一个输入。"""
return (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "cell"
and len(node.args) == 3 and not node.keywords and _chain_root(node.args[0]) is not None)
def cell_parts(path: str) -> tuple[str, str, str] | None:
"""leaf_refs 给出的 cell(...) 路径拆回三段源码:(取值链, 行, 列)。不是 cell 调用返回 None。"""
try:
body = ast.parse(path, mode="eval").body
except SyntaxError:
return None
if not _cell_call(body):
return None
return tuple(ast.unparse(a) for a in body.args) # type: ignore[return-value]
def _walk_refs(node: ast.AST, found: list[ast.AST]) -> None:
if _cell_call(node):
# 取哪一格由行、列决定,它们里面的引用是独立的输入;取值链本身只是这一格的来处
found.append(node)
for arg in node.args[1:]:
_walk_refs(arg, found)
return
if isinstance(node, (ast.Attribute, ast.Subscript, ast.Name)) and _chain_root(node):
found.append(node)
# 链本身不再往里拆(vars.kpi 是 vars.kpi.gmv 的一部分),但下标里的表达式是
# 独立的输入:vars.rows[vars.i] 取哪一格由 vars.i 决定
cur = node
while isinstance(cur, (ast.Attribute, ast.Subscript)):
if isinstance(cur, ast.Subscript):
_walk_refs(cur.slice, found)
cur = cur.value
return
for child in ast.iter_child_nodes(node):
_walk_refs(child, found)
def leaf_refs(tree: ast.AST) -> list[str]:
"""表达式引用了哪些值:每条从根名字出发的最长取值链,按出现顺序去重。
parse_expression 只告诉你「有没有不认识的名字」;口径卡要记来源,得知道
round((vars.kpi.gmv - vars.kpi.gmv_prev) / …) 里用到的是 vars.kpi.gmv 和
vars.kpi.gmv_prev 这两个值,而不是 vars、kpi 这些零件。
cell(nodes.fetch, 0, 'gmv') 整个调用算一个值:它的来历是那次查询的那一格。
路径用 ast.unparse 的写法,substitute 按同样的写法认。
"""
found: list[ast.AST] = []
_walk_refs(tree, found)
return list(dict.fromkeys(ast.unparse(n) for n in found))
_SCALARS = (int, float, str, bool, type(None))
#: 字符串长过这个数就不代进去:上游模型写的一大段文本抄进代入式(和 metric_set 工件),
#: 没人看得懂,只是把工件撑大
_INLINE_STR_MAX = 80
def _inlinable(value: Any) -> bool:
"""这个值能不能以字面量的样子写进代入式。
容器不行;含 {{ 或 }} 的字符串不行——parse_expression 会把整条代入式当成
「表达式里写了模板」拒掉,复算就成了假的「对不上」;太长的字符串也不代。
"""
if not isinstance(value, _SCALARS):
return False
if isinstance(value, str):
return len(value) <= _INLINE_STR_MAX and "{{" not in value and "}}" not in value
return True
def _literal(value: Any) -> ast.expr:
"""负数写成一元负号:Constant(-3) 放在幂运算左边会被 unparse 成 -3 ** 2,
读起来和算起来都是 -(3 ** 2)。一元负号节点 unparse 时会自己加括号。"""
if isinstance(value, (int, float)) and not isinstance(value, bool) and value < 0:
return ast.UnaryOp(op=ast.USub(), operand=ast.Constant(-value))
return ast.Constant(value)
class _Substitute(ast.NodeTransformer):
def __init__(self, values: dict[str, Any]) -> None:
self.values = values
def _replace(self, node: ast.AST) -> ast.AST:
if _chain_root(node):
path = ast.unparse(node)
if path in self.values and _inlinable(self.values[path]):
return ast.copy_location(_literal(self.values[path]), node)
if path in self.values:
return node # 容器、含 {{ }} 的或太长的字符串留原路径,复算时照样取得到
return self.generic_visit(node)
visit_Attribute = visit_Subscript = visit_Name = _replace
def visit_Call(self, node: ast.Call) -> ast.AST:
if _cell_call(node):
path = ast.unparse(node)
if path in self.values and _inlinable(self.values[path]):
return ast.copy_location(_literal(self.values[path]), node)
return self.generic_visit(node)
def substitute(tree: ast.AST, values: dict[str, Any]) -> str:
"""把引用换成它当时的值,得到代入式:round((45678.5 - 42010.0) / 42010.0 * 100, 1)。
只代标量(含 {{ }} 或长过 80 字的字符串除外)。代入式本身还是一条合法表达式——拿同一个上下文再求一遍,结果必须和
原式一样,这是口径卡复算(recompute_ok)的依据。不改动传进来的语法树。
"""
import copy
replaced = _Substitute(values).visit(copy.deepcopy(tree))
return ast.unparse(ast.fix_missing_locations(replaced))
def eval_expression(expr: str, ctx: dict[str, Any]) -> Any:
"""求值一个受限的 Python 表达式。
只放行字面量、比较、布尔/算术运算、下标、属性访问和一小撮白名单函数。
没有 import、没有属性魔法、没有函数定义,所以用户在画布上写条件是安全的。
"""
expr = (expr or "").strip()
if not expr:
return None
tree, _, _ = parse_expression(expr)
def _eval(node: ast.AST) -> Any:
if isinstance(node, ast.Expression):
return _eval(node.body)
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.Name):
if node.id in ctx:
return ctx[node.id]
if node.id in _SAFE_FUNCS:
return _SAFE_FUNCS[node.id]
if node.id in ("true", "True"):
return True
if node.id in ("false", "False"):
return False
if node.id in ("null", "None"):
return None
return None
if isinstance(node, ast.Attribute):
base = _eval(node.value)
if isinstance(base, dict):
return base.get(node.attr)
if node.attr.startswith("_"):
raise ExpressionError("不允许访问私有属性")
return getattr(base, node.attr, None)
if isinstance(node, ast.Subscript):
base = _eval(node.value)
key = _eval(node.slice)
try:
return base[key]
except (KeyError, IndexError, TypeError):
return None
if isinstance(node, ast.BinOp):
op = _BIN_OPS.get(type(node.op))
if not op:
raise ExpressionError("不支持的运算符")
return op(_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp):
op = _UNARY_OPS.get(type(node.op))
if not op:
raise ExpressionError("不支持的一元运算符")
return op(_eval(node.operand))
if isinstance(node, ast.BoolOp):
# Python 的 and/or 返回的是操作数本身而不是布尔值,并且短路求值。
# `vars.x or '默认值'` 是模板里最常见的写法,返回 True 就全错了。
is_and = isinstance(node.op, ast.And)
result: Any = None
for i, operand in enumerate(node.values):
result = _eval(operand)
truthy = bool(result)
if i < len(node.values) - 1 and (truthy is not is_and):
return result # and 遇假、or 遇真:短路返回该操作数
return result
if isinstance(node, ast.Compare):
left = _eval(node.left)
for op_node, comparator in zip(node.ops, node.comparators):
op = _CMP_OPS.get(type(op_node))
if not op:
raise ExpressionError("不支持的比较运算符")
right = _eval(comparator)
try:
if not op(left, right):
return False
except TypeError:
return False
left = right
return True
if isinstance(node, ast.IfExp):
return _eval(node.body) if _eval(node.test) else _eval(node.orelse)
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name) or node.func.id not in _SAFE_FUNCS:
raise ExpressionError("只能调用允许的函数")
args = [_eval(a) for a in node.args]
kwargs = {kw.arg: _eval(kw.value) for kw in node.keywords if kw.arg}
return _SAFE_FUNCS[node.func.id](*args, **kwargs)
if isinstance(node, (ast.List, ast.Tuple)):
return [_eval(e) for e in node.elts]
if isinstance(node, ast.Dict):
return {
_eval(k): _eval(v)
for k, v in zip(node.keys, node.values)
if k is not None
}
raise ExpressionError(_unsupported(type(node).__name__))
return _eval(tree)
def eval_condition(expr: str, ctx: dict[str, Any]) -> bool:
"""求值成布尔。条件写错时返回 False 而不是炸掉整个 run。"""
try:
return bool(eval_expression(expr, ctx))
except ExpressionError:
raise
except Exception: # noqa: BLE001
return False