mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:31:58 +00:00
277982ead3
- 蓝图:bp_* 操作和逐卡白名单、蓝图检查、留痕、按蓝图判权限;五个场景模板加自由形(骨架、关节、应用骨架、定时任务),实例化与骨架比对;BlueprintApplier / TemplateApplier(只收模板声明过的关节)与自查;look 新类别口径卡;蓝图对话和填关节两个流式接口,不同组的关节并行填、出错的组串行重试;模板列表 - 试运行与发布单:四种起点(对话、模板、问数据的一轮、已发布版本);试运行是探索运行,草稿应用按这次运行解析、印章写试运行,流程类派给自己、不触发升级;发布单九步、预检只读、每步留痕、停下后接着做、核对卡片指纹和应用管理权、启动时整理中断的单;白话层覆盖全部问题编号 - 记录表与文件:平台托管只追加的记录表、按高水位固定数据、连接上的临时视图做行级可见、写入走动作(records_append)、表版本在发布时切换、草稿不影响线上、记录列表遮敏感列;文件按哈希存、两步上传、提交时绑定运行、下载按读权限、清理、file_text、按配方导入一期(table_import_period) - 个人应用:存成个人应用(问数据的约束)、刷新(遮罩、只读、每日上限)、只有拥有者看得见、提交审核与共享副本、开关下发到认人接口;页面编辑的候选;隐藏组件不下发;三条新检查
971 lines
44 KiB
Python
971 lines
44 KiB
Python
from __future__ import annotations
|
||
|
||
import ast
|
||
import importlib
|
||
import json
|
||
import math
|
||
import operator
|
||
import re
|
||
from collections.abc import Iterator
|
||
from contextlib import contextmanager
|
||
from contextvars import ContextVar
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Callable
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 变量插值: {{ 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)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 模板函数:{{ file_text(input.附件) }} / {{ file_text(input.附件, 8000) }}
|
||
# --------------------------------------------------------------------------
|
||
|
||
#: 模板里能调的函数:名字 → 「模块:函数」,用到时才导入(模板引擎不依赖文件存储)。参数是路径(input.附件)、数或
|
||
#: 带引号的文字;函数要知道自己在哪次运行、哪个节点(发事件、核对绑定的文件),从 TEMPLATE_SCOPE 取
|
||
TEMPLATE_FUNCTIONS: dict[str, str] = {"file_text": "app.files.text:file_text"}
|
||
#: 模板函数的准备:节点配置里用到这个函数时,执行前先异步做的事(模板渲染是同步的,读库只能放在这里)。
|
||
#: 名字 → 「模块:函数」,函数收 TemplateScope
|
||
TEMPLATE_PREPARERS: dict[str, str] = {"file_text": "app.files.text:prepare"}
|
||
_CALL = re.compile(r"^([A-Za-z_]\w*)\s*\((.*)\)$", re.S)
|
||
_ARG_NUMBER = re.compile(r"^[+-]?\d+(?:\.\d+)?$")
|
||
|
||
|
||
@dataclass
|
||
class TemplateScope:
|
||
"""一次节点执行里渲染模板的运行上下文:node 是 NodeContext(运行、节点、发事件),memo 给模板函数记这一次执行里
|
||
已经做过的事(同一个附件同一个上限只抽一次、只发一条事件)。"""
|
||
|
||
node: Any
|
||
memo: dict[str, Any] = field(default_factory=dict)
|
||
|
||
|
||
TEMPLATE_SCOPE: ContextVar[TemplateScope | None] = ContextVar("agentlab_template_scope", default=None)
|
||
|
||
|
||
async def open_template_scope(node: Any) -> TemplateScope:
|
||
"""一次节点执行的模板上下文:节点配置(连同图级默认)里用到哪个模板函数,就先做它的准备(TEMPLATE_PREPARERS,
|
||
例如读这次运行绑定的文件)。每次执行现做:回复待办时新绑上的文件,恢复之后的这一次执行就看得到。"""
|
||
scope = TemplateScope(node)
|
||
text = json.dumps([getattr(node, "config", None), getattr(getattr(getattr(node, "run", None), "spec", None),
|
||
"defaults", None)], ensure_ascii=False, default=str)
|
||
for name, target in TEMPLATE_PREPARERS.items():
|
||
if f"{name}(" in text:
|
||
module, _, attr = target.partition(":")
|
||
await getattr(importlib.import_module(module), attr)(scope)
|
||
return scope
|
||
|
||
|
||
@contextmanager
|
||
def template_scope(scope: TemplateScope) -> Iterator[TemplateScope]:
|
||
"""节点执行期间设好模板的运行上下文(engine/compiler.py 包在每次执行外面)。不在节点里渲染时没有它,模板函数
|
||
照实说「运行时才读取」。"""
|
||
token = TEMPLATE_SCOPE.set(scope)
|
||
try:
|
||
yield scope
|
||
finally:
|
||
TEMPLATE_SCOPE.reset(token)
|
||
|
||
|
||
def template_call(expr: str) -> tuple[str, list[str]] | None:
|
||
"""`file_text(input.附件, 8000)` → ("file_text", ["input.附件", "8000"]);不是登记过的模板函数为 None。
|
||
变量检查(engine/variables.py)按它认出函数里引用的路径。"""
|
||
m = _CALL.match(expr.strip())
|
||
if not m or m.group(1) not in TEMPLATE_FUNCTIONS:
|
||
return None
|
||
args = [a.strip() for a in _split_args(m.group(2))]
|
||
return m.group(1), [a for a in args if a]
|
||
|
||
|
||
def _split_args(text: str) -> list[str]:
|
||
"""按顶层逗号切参数:引号、括号、方括号里的逗号不算。"""
|
||
out: list[str] = []
|
||
depth, quote, start = 0, "", 0
|
||
for i, ch in enumerate(text):
|
||
if quote:
|
||
if ch == quote:
|
||
quote = ""
|
||
elif ch in "'\"":
|
||
quote = ch
|
||
elif ch in "([":
|
||
depth += 1
|
||
elif ch in ")]":
|
||
depth -= 1
|
||
elif ch == "," and depth == 0:
|
||
out.append(text[start:i])
|
||
start = i + 1
|
||
out.append(text[start:])
|
||
return out
|
||
|
||
|
||
def _arg_value(arg: str, ctx: dict[str, Any]) -> Any:
|
||
if _ARG_NUMBER.match(arg):
|
||
return float(arg) if "." in arg else int(arg)
|
||
if len(arg) >= 2 and arg[0] == arg[-1] and arg[0] in "'\"":
|
||
return arg[1:-1]
|
||
return resolve_path(ctx, arg)
|
||
|
||
|
||
def _function(name: str) -> Callable[..., Any]:
|
||
module, _, attr = TEMPLATE_FUNCTIONS[name].partition(":")
|
||
return getattr(importlib.import_module(module), attr)
|
||
|
||
|
||
def render_template(text: str, ctx: dict[str, Any]) -> str:
|
||
"""把字符串里的 {{ ... }} 全部替换掉。支持 `路径 | 过滤器` 语法,以及登记过的模板函数(TEMPLATE_FUNCTIONS)。"""
|
||
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 ""
|
||
call = template_call(expr.split("|")[0]) if "(" in expr else None
|
||
parts = [p.strip() for p in expr.split("|")]
|
||
if call is not None:
|
||
name, args = call
|
||
value = _function(name)(*(_arg_value(a, ctx) for a in args))
|
||
else:
|
||
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)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 截断的结果:按行号取单格照常,当成整组用时记一笔
|
||
#
|
||
# 查询撞了行数或字节上限时,数据层只交回前面一截,标 truncated: true(data/engine.py 的 run_query)。
|
||
# 这一截的前 N 行就是完整结果的前 N 行,所以 cell(x, 0, 'gmv')、rows[0] 取到的那一格是真实的;
|
||
# 可 len(rows) 只是取回的行数,sum / max / sorted / 成员判断 / 倒数第几行,说的都是这一截而不是
|
||
# 完整结果。口径卡拿这种数出具,等于把「前 1000 行的人次」当成「入园人次」。
|
||
#
|
||
# 不按正则猜表达式(写法千变万化,get(x, 'rows')、x['rows']、x.rows 都是同一件事),而是在求值时跟踪:
|
||
# 从截断结果里取 rows 的那一刻包一层 TruncatedRows,它照常当列表用,只在被整组用到时往 TruncatedUse
|
||
# 里记一笔。数据源工具交回的是 JSON 文本,表达式里只有 cell() 读得了它;经数据整形解析成对象之后
|
||
# 才有 .rows 可取,所以这里认的是「带 truncated: true 和 rows 列表的对象」这种形状。
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
class TruncatedUse:
|
||
"""一次求值里,哪些截断的结果被当成了整组:sources 是 {键: {path, rows, artifact?}}。
|
||
|
||
- path:取到这组行的取值链(vars.visits;agent 的数组字段是字段本身 vars.kpi.amounts)
|
||
- rows:取回了几行——完整结果至少比这多
|
||
- artifact:查询快照的工件 id,结果里带着的才有
|
||
同一份结果按工件 id(没有就按 path)只记一次:len(x.rows) + x.row_count 只用了一份结果。
|
||
|
||
edge(path) 给调用方认定的截断整组:口径卡用它标出 agent 经 cite_fields 交来、引用的行段到了截断结果
|
||
末行的数组字段。返回 {rows, artifact?} 或 None。求值器只对取到列表的取值链问它。
|
||
"""
|
||
|
||
__slots__ = ("sources", "edge")
|
||
|
||
def __init__(self, edge: Any = None) -> None:
|
||
self.sources: dict[str, dict[str, Any]] = {}
|
||
self.edge = edge
|
||
|
||
def record(self, path: str, rows: int, artifact: Any = None) -> None:
|
||
key = artifact if isinstance(artifact, str) and artifact else path
|
||
self.sources.setdefault(key, {"path": path, "rows": rows,
|
||
**({"artifact": artifact} if isinstance(artifact, str) and artifact else {})})
|
||
|
||
|
||
class TruncatedRows:
|
||
"""截断结果里的一组行。只活在一次求值里:eval_expression 交出结果前一律换回普通 list。
|
||
|
||
整组用到的写法都要经过这里的某个特殊方法:len()、迭代(sum / min / max / sorted / any / all)、
|
||
in、比较、拼接、转成文本、倒数第几行、取回的行之后的行号。按行号取取回范围之内的一行不记;
|
||
真假判断也不记——截断的结果至少有一行,「有数据」这件事是真的。
|
||
|
||
不继承 list:list 有一批 C 实现按 PyList_Check 直接读底层数组(拼接、比较……),子类得把每个反射方法都
|
||
补上才不漏记,漏一处就是把截断的数当成完整的出具。独立的类只认这里实现的几样,没实现的写法直接报类型
|
||
错误,不会悄悄在截断的数据上算出一个数。属性一律带下划线,表达式碰不到(私有属性在静态检查里就拒了)。
|
||
"""
|
||
|
||
__slots__ = ("_rows", "_use", "_path", "_count", "_artifact")
|
||
__hash__ = None # type: ignore[assignment]
|
||
|
||
def __init__(self, rows: list[Any], use: TruncatedUse, path: str, count: int, artifact: Any = None) -> None:
|
||
self._rows, self._use, self._path, self._count, self._artifact = rows, use, path, count, artifact
|
||
|
||
def _whole(self) -> list[Any]:
|
||
self._use.record(self._path, self._count, self._artifact)
|
||
return self._rows
|
||
|
||
def __len__(self) -> int:
|
||
return len(self._whole())
|
||
|
||
def __iter__(self) -> Any:
|
||
return iter(self._whole())
|
||
|
||
def __reversed__(self) -> Any:
|
||
return reversed(self._whole())
|
||
|
||
def __contains__(self, item: Any) -> bool:
|
||
return item in self._whole()
|
||
|
||
def __getitem__(self, index: Any) -> Any:
|
||
# 倒数第几行:完整结果的最后一行不是取回的最后一行;取回范围之外的行号:完整结果里有这一行,
|
||
# 只是没取回来(照旧抛 IndexError,求值器按取不到处理,原因记在这里)
|
||
if isinstance(index, int) and not isinstance(index, bool) and not 0 <= index < len(self._rows):
|
||
self._whole()
|
||
return self._rows[index]
|
||
|
||
def __bool__(self) -> bool:
|
||
return bool(self._rows)
|
||
|
||
def __eq__(self, other: Any) -> bool:
|
||
return self._whole() == _plain_rows(other)
|
||
|
||
def __ne__(self, other: Any) -> bool:
|
||
return self._whole() != _plain_rows(other)
|
||
|
||
def __lt__(self, other: Any) -> bool:
|
||
return self._whole() < _plain_rows(other)
|
||
|
||
def __le__(self, other: Any) -> bool:
|
||
return self._whole() <= _plain_rows(other)
|
||
|
||
def __gt__(self, other: Any) -> bool:
|
||
return self._whole() > _plain_rows(other)
|
||
|
||
def __ge__(self, other: Any) -> bool:
|
||
return self._whole() >= _plain_rows(other)
|
||
|
||
def __add__(self, other: Any) -> Any:
|
||
return self._whole() + _plain_rows(other)
|
||
|
||
def __radd__(self, other: Any) -> Any:
|
||
return _plain_rows(other) + self._whole()
|
||
|
||
def __mul__(self, times: Any) -> Any:
|
||
return self._whole() * times
|
||
|
||
__rmul__ = __mul__
|
||
|
||
def __repr__(self) -> str:
|
||
return repr(self._whole())
|
||
|
||
|
||
def _plain_rows(value: Any) -> Any:
|
||
return value._whole() if isinstance(value, TruncatedRows) else value
|
||
|
||
|
||
def _truncated_table(value: Any) -> bool:
|
||
"""查询结果的形状、而且被截断了:数据源工具交回的 JSON 经数据整形解析成对象之后就是这样。"""
|
||
return isinstance(value, dict) and value.get("truncated") is True and isinstance(value.get("rows"), list)
|
||
|
||
|
||
def _settle(value: Any) -> Any:
|
||
"""交出结果前把 TruncatedRows 换回普通 list(整组交出去也是整组用到)。没有包装的原样返回、不复制。"""
|
||
if isinstance(value, TruncatedRows):
|
||
return list(value._whole())
|
||
if type(value) is list:
|
||
items = [_settle(v) for v in value]
|
||
return items if any(a is not b for a, b in zip(items, value)) else value
|
||
if type(value) is dict:
|
||
pairs = {k: _settle(v) for k, v in value.items()}
|
||
return pairs if any(pairs[k] is not v for k, v in value.items()) else value
|
||
return value
|
||
|
||
|
||
_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": "~",
|
||
}
|
||
|
||
|
||
#: arithmetic=False 时不能调用的函数:它们本身就是在算数(应用的可见性表达式只做比较、布尔运算、取值和 in,P7)
|
||
_ARITH_FUNCS = frozenset({"abs", "min", "max", "sum", "round", "int", "float"})
|
||
_ARITH_SYMBOLS = {
|
||
"Add": "+", "Sub": "-", "Mult": "*", "Div": "/", "FloorDiv": "//", "Mod": "%", "Pow": "**",
|
||
"USub": "-", "UAdd": "+",
|
||
}
|
||
|
||
|
||
class ExpressionArithmetic(ExpressionError):
|
||
"""arithmetic=False 时写了算术。应用检查据此给 app.expr_arith,和别的不合法(app.expr_invalid)分开。"""
|
||
|
||
|
||
def _no_arithmetic(what: str) -> ExpressionArithmetic:
|
||
return ExpressionArithmetic(f"这里的表达式只能做比较、布尔运算、取值和 in,不能做算术({what})。要用算出来的数,"
|
||
"请在工作流的口径卡里算好,再按指标取值")
|
||
|
||
|
||
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, roots: frozenset[str] = EXPRESSION_ROOTS,
|
||
arithmetic: bool = True) -> list[str]:
|
||
"""不求值,只看结构:违规直接抛 ExpressionError,返回不认识的名字(留给提示用)。
|
||
|
||
运行时求值会短路(and/or、三元只走一边),没走到的那一半写错了也发现不了;
|
||
画布校验和 Copilot 自查要的是"整条表达式都能跑",所以得把整棵树走一遍。
|
||
|
||
roots:认得的根名字(应用的可见性表达式用 apps.spec.APP_ROOTS)。arithmetic 为假时算术一律抛
|
||
ExpressionArithmetic:负数字面量(-1)不算算术,-x 算。
|
||
"""
|
||
unknown: list[str] = []
|
||
if not arithmetic:
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.BinOp) and type(node.op) in _BIN_OPS:
|
||
raise _no_arithmetic(_ARITH_SYMBOLS.get(type(node.op).__name__, ""))
|
||
if (isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd))
|
||
and not isinstance(node.operand, ast.Constant)):
|
||
raise _no_arithmetic(_ARITH_SYMBOLS[type(node.op).__name__])
|
||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in _ARITH_FUNCS:
|
||
raise _no_arithmetic(f"{node.func.id}()")
|
||
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 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, *, roots: frozenset[str] | None = None,
|
||
arithmetic: bool = True) -> tuple[ast.Expression, list[str], list[str]]:
|
||
"""解析 + 静态检查:(语法树, 被剥掉的 {{ }}, 不认识的名字)。结构违规抛 ExpressionError。
|
||
|
||
运行时求值、画布校验、Copilot 自查都走这一个入口——三处各写一套规则的话,
|
||
迟早出现"校验说能跑、跑起来报错"。
|
||
|
||
应用的可见性表达式也走这里(应用平台 A2):roots=APP_ROOTS、arithmetic=False,算术抛 ExpressionArithmetic。
|
||
不给这两个参数时和以前一模一样(图里的根名字,允许算术)。
|
||
"""
|
||
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, EXPRESSION_ROOTS if roots is None else roots, arithmetic)
|
||
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], *, track: TruncatedUse | None = None,
|
||
roots: frozenset[str] | None = None, arithmetic: bool = True) -> Any:
|
||
"""求值一个受限的 Python 表达式。
|
||
|
||
只放行字面量、比较、布尔/算术运算、下标、属性访问和一小撮白名单函数。
|
||
没有 import、没有属性魔法、没有函数定义,所以用户在画布上写条件是安全的。
|
||
|
||
track:跟踪截断的结果有没有被当成整组用,记进 track.sources(口径卡用,见 TruncatedUse)。
|
||
不给就不跟踪:分支条件、数据整形、复算的行为和以前完全一样。
|
||
|
||
roots / arithmetic 同 parse_expression。给了 roots 时只从 ctx 里取这些根名字:别的键哪怕在 ctx 里也取到 None,
|
||
调用方往上下文里多放了什么,表达式都够不着(应用的可见性表达式,P4)。
|
||
"""
|
||
expr = (expr or "").strip()
|
||
if not expr:
|
||
return None
|
||
tree, _, _ = parse_expression(expr, roots=roots, arithmetic=arithmetic)
|
||
|
||
def _tracked(base_src: Any, full_src: Any, base: Any, key: Any, value: Any) -> Any:
|
||
"""取到的值要不要包一层:截断结果的 rows 包成 TruncatedRows,row_count 直接记一笔
|
||
(它就是取回的行数,读它就是在用整组);调用方经 edge 认定的截断整组也包起来。
|
||
base_src / full_src 是取值链的源码(截断结果本身 / 取到的这个值),要用时才拼。"""
|
||
if track is None or isinstance(value, TruncatedRows):
|
||
return value
|
||
if isinstance(key, str) and key in ("rows", "row_count") and _truncated_table(base):
|
||
rows, artifact = base["rows"], base.get("artifact")
|
||
if key == "row_count":
|
||
track.record(base_src(), len(rows), artifact)
|
||
return value
|
||
return TruncatedRows(rows, track, base_src(), len(rows), artifact)
|
||
if isinstance(value, list) and track.edge is not None:
|
||
path = full_src()
|
||
info = track.edge(path)
|
||
if info:
|
||
return TruncatedRows(value, track, path, int(info.get("rows") or len(value)), info.get("artifact"))
|
||
return value
|
||
|
||
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 and (roots is None or node.id in roots):
|
||
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 _tracked(lambda: ast.unparse(node.value), lambda: ast.unparse(node), base, node.attr,
|
||
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:
|
||
value = base[key]
|
||
except (KeyError, IndexError, TypeError):
|
||
return None
|
||
return _tracked(lambda: ast.unparse(node.value), lambda: ast.unparse(node), base, key, value)
|
||
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}
|
||
result = _SAFE_FUNCS[node.func.id](*args, **kwargs)
|
||
if node.func.id == "get" and len(args) >= 2 and isinstance(args[1], str):
|
||
# get(x, 'rows') 和 x.rows 是同一件事,按 x['rows'] 的写法交给 edge
|
||
source = node.args[0]
|
||
result = _tracked(lambda: ast.unparse(source), lambda: f"{ast.unparse(source)}[{args[1]!r}]",
|
||
args[0], args[1], result)
|
||
return result
|
||
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__))
|
||
|
||
result = _eval(tree)
|
||
return _settle(result) if track is not None else result
|
||
|
||
|
||
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
|