sparrow-agent 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nikefd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: sparrow-agent
3
+ Version: 0.1.0
4
+ Summary: A small-but-complete agent harness: ReAct loop, tool injection, three-tier memory, restricted-expression panels. Zero heavy deps.
5
+ Author: nikefd
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/nikefd/sparrow
8
+ Project-URL: Repository, https://github.com/nikefd/sparrow
9
+ Keywords: agent,llm,react,tool-calling,deepseek,harness
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # 🐦 sparrow
22
+
23
+ **A small-but-complete agent harness.** 麻雀虽小,五脏俱全。
24
+
25
+ Bring your own tools and a system prompt; sparrow wires them into a ReAct loop
26
+ with citations, three-tier memory, and a restricted-expression engine for safe
27
+ computed panels. Stdlib-only core, zero heavy dependencies — no LangChain, no
28
+ LangGraph.
29
+
30
+ > [English](#english) · [中文](#中文)
31
+
32
+ ---
33
+
34
+ ## English
35
+
36
+ ### Why sparrow
37
+
38
+ Most agent frameworks are big. sparrow is the opposite: a single readable
39
+ package you can fully understand in an afternoon, yet it has all the organs of a
40
+ real agent:
41
+
42
+ - **ReAct tool loop** — the model decides *what* it needs; deterministic code
43
+ decides *how* to get it.
44
+ - **Tool injection** — the engine assumes nothing about your domain. You inject
45
+ plain functions as tools; finance, news, weather — all the same engine.
46
+ - **Three-tier memory** — conversations, materialized panels, and an append-only
47
+ journal, all in one SQLite file, physically isolated from your business data.
48
+ - **LLM emits declarations, not code** — even custom panel columns are a
49
+ *restricted expression* (AST allowlist: field names + numbers + arithmetic),
50
+ so the model can compose derived metrics but never run arbitrary code.
51
+ - **Citations by construction** — every tool result carries a `source`; final
52
+ answers collect them automatically.
53
+
54
+ ### Install
55
+
56
+ ```bash
57
+ pip install sparrow-agent # import name is `sparrow`
58
+ ```
59
+
60
+ The core engine is stdlib-only. Point it at any OpenAI-compatible endpoint
61
+ (DeepSeek by default) via env or `configure()`:
62
+
63
+ ```bash
64
+ export SPARROW_LLM_API_KEY=sk-...
65
+ export SPARROW_LLM_BASE_URL=https://api.deepseek.com # optional
66
+ export SPARROW_LLM_MODEL=deepseek-chat # optional
67
+ ```
68
+
69
+ ### Quickstart
70
+
71
+ ```python
72
+ from sparrow import tool, AgentConfig, Harness
73
+
74
+ @tool(description="Get current weather", source="demo-weather")
75
+ def get_weather(city: str) -> dict:
76
+ return {"city": city, "weather": "sunny, 24°C"}
77
+
78
+ config = AgentConfig(
79
+ system_prompt="You are a weather assistant. Always call a tool; never invent weather.",
80
+ tools=[get_weather],
81
+ )
82
+
83
+ for event in Harness(config).run([{"role": "user", "content": "Weather in Beijing?"}]):
84
+ print(event) # tool_call / tool_result / final / error
85
+ ```
86
+
87
+ See [`examples/weather_agent.py`](examples/weather_agent.py) for a full run.
88
+
89
+ ### Memory & panels (optional)
90
+
91
+ For dashboard-style apps, sparrow ships "panel as memory": the agent can persist
92
+ a conversation insight as a live, declarative panel.
93
+
94
+ ```python
95
+ from sparrow import Memory, AgentConfig, panel_tools
96
+
97
+ mem = Memory("ui.db", transforms={"count": lambda d: {"value": len(next(iter(d.values()), []))}})
98
+
99
+ config = AgentConfig(
100
+ system_prompt="...",
101
+ tools=[*my_query_tools, *panel_tools(mem)], # adds create/archive/list_panels
102
+ recall_provider=mem.journal_summary_for_prompt, # inject episodic recall
103
+ )
104
+ ```
105
+
106
+ Panels store *recipes* (a tool + transform/columns), not snapshots, so they
107
+ recompute from live data every time. Custom table columns use the restricted
108
+ expression engine:
109
+
110
+ ```python
111
+ {"title": "Market Value", "expr": "current_price * shares"} # safe
112
+ {"title": "x", "expr": "__import__('os')"} # rejected
113
+ ```
114
+
115
+ ### Design principles
116
+
117
+ 1. **LLM decides *what*, deterministic code decides *how*.** The model only ever
118
+ emits declarations (which tool, which transform, which column expression);
119
+ real execution is plain Python. This prevents hallucinated data and confines
120
+ the model to a read-only, validated surface.
121
+ 2. **Read/write separation.** Query tools read your business data; write tools
122
+ only touch the agent's own memory db. The LLM can shape presentation, never
123
+ the underlying truth.
124
+ 3. **Memory covers every actor.** The journal records what the user did, what
125
+ the agent did, and what the system did — so the agent's worldview is complete.
126
+
127
+ ### Status
128
+
129
+ `v0.1` — extracted from two production agents (a quant-trading assistant and an
130
+ AI-frontier tracker) and generalized. API may still move before `1.0`.
131
+
132
+ MIT licensed.
133
+
134
+ ---
135
+
136
+ ## 中文
137
+
138
+ ### 为什么是 sparrow
139
+
140
+ 大多数 agent 框架都很重。sparrow 反其道而行:一个一下午就能读透的单包,却五脏俱全:
141
+
142
+ - **ReAct 工具循环** —— LLM 决定「要什么」,确定性代码决定「怎么做」。
143
+ - **工具注入** —— 引擎对业务零假设。你把普通函数注入成工具;金融、新闻、天气,同一套引擎。
144
+ - **三层记忆** —— 对话、物化面板、append-only 流水,同一个 SQLite 文件,与业务数据物理隔离。
145
+ - **LLM 只产声明,不产代码** —— 连自定义面板列都是「受限表达式」(AST 白名单:字段名+数字+四则运算),模型能组合衍生指标,却碰不到任意代码。
146
+ - **天生带溯源** —— 每个工具结果带 `source`,最终答案自动收集成 citations。
147
+
148
+ ### 安装
149
+
150
+ ```bash
151
+ pip install sparrow-agent # import 名是 sparrow
152
+ ```
153
+
154
+ 核心引擎零三方依赖(仅 stdlib)。指向任意 OpenAI 兼容端点(默认 DeepSeek):
155
+
156
+ ```bash
157
+ export SPARROW_LLM_API_KEY=sk-...
158
+ ```
159
+
160
+ ### 快速开始
161
+
162
+ ```python
163
+ from sparrow import tool, AgentConfig, Harness
164
+
165
+ @tool(description="查天气", source="demo")
166
+ def get_weather(city: str) -> dict:
167
+ return {"city": city, "weather": "晴, 24°C"}
168
+
169
+ config = AgentConfig(
170
+ system_prompt="你是天气助手,必须调工具拿真实数据,绝不编造。",
171
+ tools=[get_weather],
172
+ )
173
+
174
+ for event in Harness(config).run([{"role": "user", "content": "北京天气?"}]):
175
+ print(event)
176
+ ```
177
+
178
+ ### 设计理念
179
+
180
+ 1. **LLM 决定「要什么」,确定性代码决定「怎么做」。** 模型永远只产声明(用哪个工具、哪种 transform、哪个列表达式),真正执行都是普通代码。防幻觉,且把模型限制在只读、已校验的边界内。
181
+ 2. **读写分级。** 查询工具读业务数据,写工具只动 agent 自己的记忆库。LLM 能塑造呈现,永远碰不到底层真相。
182
+ 3. **记忆覆盖所有 actor。** 流水记录人做的、AI 做的、系统做的——agent 的世界观才完整。
183
+
184
+ ### 状态
185
+
186
+ `v0.1` —— 从两个生产 agent(A股量化助手 + AI 前沿追踪)抽取并通用化而来。`1.0` 前 API 可能调整。
187
+
188
+ MIT 协议。
@@ -0,0 +1,168 @@
1
+ # 🐦 sparrow
2
+
3
+ **A small-but-complete agent harness.** 麻雀虽小,五脏俱全。
4
+
5
+ Bring your own tools and a system prompt; sparrow wires them into a ReAct loop
6
+ with citations, three-tier memory, and a restricted-expression engine for safe
7
+ computed panels. Stdlib-only core, zero heavy dependencies — no LangChain, no
8
+ LangGraph.
9
+
10
+ > [English](#english) · [中文](#中文)
11
+
12
+ ---
13
+
14
+ ## English
15
+
16
+ ### Why sparrow
17
+
18
+ Most agent frameworks are big. sparrow is the opposite: a single readable
19
+ package you can fully understand in an afternoon, yet it has all the organs of a
20
+ real agent:
21
+
22
+ - **ReAct tool loop** — the model decides *what* it needs; deterministic code
23
+ decides *how* to get it.
24
+ - **Tool injection** — the engine assumes nothing about your domain. You inject
25
+ plain functions as tools; finance, news, weather — all the same engine.
26
+ - **Three-tier memory** — conversations, materialized panels, and an append-only
27
+ journal, all in one SQLite file, physically isolated from your business data.
28
+ - **LLM emits declarations, not code** — even custom panel columns are a
29
+ *restricted expression* (AST allowlist: field names + numbers + arithmetic),
30
+ so the model can compose derived metrics but never run arbitrary code.
31
+ - **Citations by construction** — every tool result carries a `source`; final
32
+ answers collect them automatically.
33
+
34
+ ### Install
35
+
36
+ ```bash
37
+ pip install sparrow-agent # import name is `sparrow`
38
+ ```
39
+
40
+ The core engine is stdlib-only. Point it at any OpenAI-compatible endpoint
41
+ (DeepSeek by default) via env or `configure()`:
42
+
43
+ ```bash
44
+ export SPARROW_LLM_API_KEY=sk-...
45
+ export SPARROW_LLM_BASE_URL=https://api.deepseek.com # optional
46
+ export SPARROW_LLM_MODEL=deepseek-chat # optional
47
+ ```
48
+
49
+ ### Quickstart
50
+
51
+ ```python
52
+ from sparrow import tool, AgentConfig, Harness
53
+
54
+ @tool(description="Get current weather", source="demo-weather")
55
+ def get_weather(city: str) -> dict:
56
+ return {"city": city, "weather": "sunny, 24°C"}
57
+
58
+ config = AgentConfig(
59
+ system_prompt="You are a weather assistant. Always call a tool; never invent weather.",
60
+ tools=[get_weather],
61
+ )
62
+
63
+ for event in Harness(config).run([{"role": "user", "content": "Weather in Beijing?"}]):
64
+ print(event) # tool_call / tool_result / final / error
65
+ ```
66
+
67
+ See [`examples/weather_agent.py`](examples/weather_agent.py) for a full run.
68
+
69
+ ### Memory & panels (optional)
70
+
71
+ For dashboard-style apps, sparrow ships "panel as memory": the agent can persist
72
+ a conversation insight as a live, declarative panel.
73
+
74
+ ```python
75
+ from sparrow import Memory, AgentConfig, panel_tools
76
+
77
+ mem = Memory("ui.db", transforms={"count": lambda d: {"value": len(next(iter(d.values()), []))}})
78
+
79
+ config = AgentConfig(
80
+ system_prompt="...",
81
+ tools=[*my_query_tools, *panel_tools(mem)], # adds create/archive/list_panels
82
+ recall_provider=mem.journal_summary_for_prompt, # inject episodic recall
83
+ )
84
+ ```
85
+
86
+ Panels store *recipes* (a tool + transform/columns), not snapshots, so they
87
+ recompute from live data every time. Custom table columns use the restricted
88
+ expression engine:
89
+
90
+ ```python
91
+ {"title": "Market Value", "expr": "current_price * shares"} # safe
92
+ {"title": "x", "expr": "__import__('os')"} # rejected
93
+ ```
94
+
95
+ ### Design principles
96
+
97
+ 1. **LLM decides *what*, deterministic code decides *how*.** The model only ever
98
+ emits declarations (which tool, which transform, which column expression);
99
+ real execution is plain Python. This prevents hallucinated data and confines
100
+ the model to a read-only, validated surface.
101
+ 2. **Read/write separation.** Query tools read your business data; write tools
102
+ only touch the agent's own memory db. The LLM can shape presentation, never
103
+ the underlying truth.
104
+ 3. **Memory covers every actor.** The journal records what the user did, what
105
+ the agent did, and what the system did — so the agent's worldview is complete.
106
+
107
+ ### Status
108
+
109
+ `v0.1` — extracted from two production agents (a quant-trading assistant and an
110
+ AI-frontier tracker) and generalized. API may still move before `1.0`.
111
+
112
+ MIT licensed.
113
+
114
+ ---
115
+
116
+ ## 中文
117
+
118
+ ### 为什么是 sparrow
119
+
120
+ 大多数 agent 框架都很重。sparrow 反其道而行:一个一下午就能读透的单包,却五脏俱全:
121
+
122
+ - **ReAct 工具循环** —— LLM 决定「要什么」,确定性代码决定「怎么做」。
123
+ - **工具注入** —— 引擎对业务零假设。你把普通函数注入成工具;金融、新闻、天气,同一套引擎。
124
+ - **三层记忆** —— 对话、物化面板、append-only 流水,同一个 SQLite 文件,与业务数据物理隔离。
125
+ - **LLM 只产声明,不产代码** —— 连自定义面板列都是「受限表达式」(AST 白名单:字段名+数字+四则运算),模型能组合衍生指标,却碰不到任意代码。
126
+ - **天生带溯源** —— 每个工具结果带 `source`,最终答案自动收集成 citations。
127
+
128
+ ### 安装
129
+
130
+ ```bash
131
+ pip install sparrow-agent # import 名是 sparrow
132
+ ```
133
+
134
+ 核心引擎零三方依赖(仅 stdlib)。指向任意 OpenAI 兼容端点(默认 DeepSeek):
135
+
136
+ ```bash
137
+ export SPARROW_LLM_API_KEY=sk-...
138
+ ```
139
+
140
+ ### 快速开始
141
+
142
+ ```python
143
+ from sparrow import tool, AgentConfig, Harness
144
+
145
+ @tool(description="查天气", source="demo")
146
+ def get_weather(city: str) -> dict:
147
+ return {"city": city, "weather": "晴, 24°C"}
148
+
149
+ config = AgentConfig(
150
+ system_prompt="你是天气助手,必须调工具拿真实数据,绝不编造。",
151
+ tools=[get_weather],
152
+ )
153
+
154
+ for event in Harness(config).run([{"role": "user", "content": "北京天气?"}]):
155
+ print(event)
156
+ ```
157
+
158
+ ### 设计理念
159
+
160
+ 1. **LLM 决定「要什么」,确定性代码决定「怎么做」。** 模型永远只产声明(用哪个工具、哪种 transform、哪个列表达式),真正执行都是普通代码。防幻觉,且把模型限制在只读、已校验的边界内。
161
+ 2. **读写分级。** 查询工具读业务数据,写工具只动 agent 自己的记忆库。LLM 能塑造呈现,永远碰不到底层真相。
162
+ 3. **记忆覆盖所有 actor。** 流水记录人做的、AI 做的、系统做的——agent 的世界观才完整。
163
+
164
+ ### 状态
165
+
166
+ `v0.1` —— 从两个生产 agent(A股量化助手 + AI 前沿追踪)抽取并通用化而来。`1.0` 前 API 可能调整。
167
+
168
+ MIT 协议。
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "sparrow-agent"
3
+ version = "0.1.0"
4
+ description = "A small-but-complete agent harness: ReAct loop, tool injection, three-tier memory, restricted-expression panels. Zero heavy deps."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "nikefd" }]
9
+ keywords = ["agent", "llm", "react", "tool-calling", "deepseek", "harness"]
10
+ classifiers = [
11
+ "License :: OSI Approved :: MIT License",
12
+ "Programming Language :: Python :: 3",
13
+ "Intended Audience :: Developers",
14
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
15
+ ]
16
+ # Core engine is stdlib-only (urllib/sqlite/ast). No runtime dependencies.
17
+ dependencies = []
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest>=8.0"]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/nikefd/sparrow"
24
+ Repository = "https://github.com/nikefd/sparrow"
25
+
26
+ [build-system]
27
+ requires = ["setuptools>=68"]
28
+ build-backend = "setuptools.build_meta"
29
+
30
+ [tool.setuptools.packages.find]
31
+ include = ["sparrow*"]
32
+
33
+ [tool.pytest.ini_options]
34
+ pythonpath = ["."]
35
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,74 @@
1
+ """sparrow — a small-but-complete agent harness.
2
+
3
+ 麻雀虽小,五脏俱全 / Small bird, all the organs.
4
+
5
+ Bring your own tools and a system prompt; sparrow wires them into a ReAct loop
6
+ with citations, three-tier memory (conversations / panels / journal), and a
7
+ restricted-expression engine for safe computed columns.
8
+
9
+ from sparrow import tool, AgentConfig, Harness
10
+
11
+ @tool(description="Echo back", source="demo")
12
+ def echo(text: str) -> dict:
13
+ return {"text": text}
14
+
15
+ cfg = AgentConfig(system_prompt="You are a helpful assistant.", tools=[echo])
16
+ for event in Harness(cfg).run([{"role": "user", "content": "hi"}]):
17
+ print(event)
18
+ """
19
+ from .registry import AgentConfig, Tool, ToolRegistry, tool
20
+ from .harness import Harness
21
+ from .memory import Memory
22
+ from .llm import chat, configure, LLMError
23
+ from . import panel_data
24
+ from .expr import safe_eval, is_safe_expr
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ "AgentConfig", "Tool", "ToolRegistry", "tool",
30
+ "Harness", "Memory",
31
+ "chat", "configure", "LLMError",
32
+ "panel_data", "safe_eval", "is_safe_expr",
33
+ "panel_tools",
34
+ ]
35
+
36
+
37
+ def panel_tools(memory):
38
+ """Return the standard panel-management tools (create / archive / list) bound
39
+ to a :class:`~sparrow.memory.Memory`. Add them to your AgentConfig.tools to
40
+ give the agent "panel as memory" capabilities.
41
+ """
42
+ from .registry import tool as _tool
43
+
44
+ @_tool(name="create_panel", writes=True, source="ui.db: panels",
45
+ description="Persist a conversation insight as a dashboard panel. "
46
+ "Only call after the user explicitly agrees.")
47
+ def create_panel(spec: dict = None, conversation_id: str = "") -> dict:
48
+ import json as _json
49
+ if isinstance(spec, str):
50
+ try:
51
+ spec = _json.loads(spec)
52
+ except (ValueError, TypeError):
53
+ return {"error": "spec must be an object"}
54
+ return memory.create_panel(spec, conversation_id=conversation_id)
55
+
56
+ @_tool(name="archive_panel", writes=True, source="ui.db: panels",
57
+ description="Archive a panel (reversible). Requires user confirmation.")
58
+ def archive_panel(id: str = "", conversation_id: str = "") -> dict:
59
+ return memory.archive_panel(id)
60
+
61
+ @_tool(name="list_panels", source="ui.db: panels",
62
+ description="List current dashboard panels.")
63
+ def list_panels(include_archived: bool = False) -> dict:
64
+ allp = memory.list_panels(include_archived=include_archived)
65
+ custom = [p for p in allp if p.get("kind") != "builtin"]
66
+ builtin = [p for p in allp if p.get("kind") == "builtin"]
67
+ return {
68
+ "custom_panels": [{"id": p["id"], "title": p["title"], "viz": p["viz"],
69
+ "status": p["status"], "note": p.get("note", "")} for p in custom],
70
+ "builtin_panels": [{"title": p["title"], "tab": p.get("tab", "")} for p in builtin],
71
+ "summary": f"{len(custom)} custom + {len(builtin)} builtin panels",
72
+ }
73
+
74
+ return [create_panel, archive_panel, list_panels]
@@ -0,0 +1,101 @@
1
+ """Restricted expression evaluation — for computed columns in table panels.
2
+
3
+ Security boundary: only "field names + numbers + arithmetic + parentheses" are
4
+ allowed; function calls, attribute access, subscripts, comprehensions, etc. are
5
+ all forbidden. The LLM may *declare* a column's formula (e.g.
6
+ ``current_price * shares``) but can never execute arbitrary code — consistent
7
+ with sparrow's guiding principle: the LLM emits declarations, not code.
8
+
9
+ Usage:
10
+ safe_eval('current_price * shares', {'current_price': 10, 'shares': 100}) # -> 1000
11
+ safe_eval('(current_price - avg_cost) / avg_cost * 100', row) # P&L %
12
+ """
13
+ import ast
14
+ import operator
15
+
16
+ _BINOPS = {
17
+ ast.Add: operator.add, ast.Sub: operator.sub,
18
+ ast.Mult: operator.mul, ast.Div: operator.truediv,
19
+ ast.Mod: operator.mod, ast.Pow: operator.pow,
20
+ ast.FloorDiv: operator.floordiv,
21
+ }
22
+ _UNARYOPS = {ast.UAdd: operator.pos, ast.USub: operator.neg}
23
+
24
+
25
+ class ExprError(Exception):
26
+ pass
27
+
28
+
29
+ def _to_num(v):
30
+ """Coerce a field value to a number; non-numeric values (e.g. string names)
31
+ are returned as-is so text columns keep working."""
32
+ if isinstance(v, bool):
33
+ return v
34
+ if isinstance(v, (int, float)):
35
+ return v
36
+ try:
37
+ return float(v)
38
+ except (TypeError, ValueError):
39
+ return v
40
+
41
+
42
+ def _eval_node(node, row):
43
+ if isinstance(node, ast.Expression):
44
+ return _eval_node(node.body, row)
45
+ # Field name
46
+ if isinstance(node, ast.Name):
47
+ if node.id not in row:
48
+ return 0 # missing field -> 0, so the whole column doesn't crash
49
+ return _to_num(row[node.id])
50
+ # Numeric / string constant
51
+ if isinstance(node, ast.Constant):
52
+ if isinstance(node.value, (int, float, str)):
53
+ return node.value
54
+ raise ExprError(f'unsupported constant: {node.value!r}')
55
+ # Binary operation
56
+ if isinstance(node, ast.BinOp):
57
+ op = _BINOPS.get(type(node.op))
58
+ if not op:
59
+ raise ExprError(f'unsupported operator: {type(node.op).__name__}')
60
+ left, right = _eval_node(node.left, row), _eval_node(node.right, row)
61
+ if not isinstance(left, (int, float)) or not isinstance(right, (int, float)):
62
+ return 0 # text in arithmetic -> safe fallback
63
+ try:
64
+ return op(left, right)
65
+ except ZeroDivisionError:
66
+ return 0
67
+ # Unary operation
68
+ if isinstance(node, ast.UnaryOp):
69
+ op = _UNARYOPS.get(type(node.op))
70
+ if not op:
71
+ raise ExprError(f'unsupported unary op: {type(node.op).__name__}')
72
+ return op(_eval_node(node.operand, row))
73
+ raise ExprError(f'disallowed expression node: {type(node).__name__}')
74
+
75
+
76
+ def safe_eval(expr, row):
77
+ """Evaluate an expression against a single row. Returns None on failure
78
+ (the render layer shows a placeholder)."""
79
+ try:
80
+ tree = ast.parse(str(expr), mode='eval')
81
+ val = _eval_node(tree, row)
82
+ if isinstance(val, float):
83
+ return round(val, 2)
84
+ return val
85
+ except (ExprError, SyntaxError, ValueError):
86
+ return None
87
+
88
+
89
+ def is_safe_expr(expr):
90
+ """Static check: does the expression contain only whitelisted nodes?
91
+ (used when validating a panel spec at creation time)."""
92
+ try:
93
+ tree = ast.parse(str(expr), mode='eval')
94
+ except SyntaxError:
95
+ return False
96
+ allowed = (ast.Expression, ast.BinOp, ast.UnaryOp, ast.Name, ast.Constant,
97
+ ast.Load) + tuple(_BINOPS) + tuple(_UNARYOPS)
98
+ for n in ast.walk(tree):
99
+ if not isinstance(n, allowed):
100
+ return False
101
+ return True