Skip to content

Commit d03fd5c

Browse files
paddymulclaude
andcommitted
feat(mcp): pre-execution lint scanner and namespace pre-injection for catalog code
Add a static AST scanner that catches bad import patterns before any user code executes, and pre-inject the four canonical names (ibis, xo, from_catalog, from_project) into the module namespace so catalog scripts need zero import statements for common usage. Session analysis showed `import ibis` and `import xorq as xo` caused 3 of 5 MCP failures — both required 1-2 retries to recover from runtime errors with cryptic AttributeErrors. The scanner now returns a clear rejection before exec; the pre-injection means well-written code works without imports. Also fix the summary-stats sandbox: Exception and common exception types were missing from _SAFE_BUILTIN_NAMES, forcing `except Exception:` to fail with NameError. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 32057df commit d03fd5c

5 files changed

Lines changed: 192 additions & 21 deletions

File tree

src/tallyman_core/summary_stats.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@
5858
"isinstance",
5959
"issubclass",
6060
"type",
61+
# Exception types — needed for try/except Exception: in compute()
62+
"Exception",
63+
"ValueError",
64+
"TypeError",
65+
"KeyError",
66+
"AttributeError",
67+
"IndexError",
68+
"RuntimeError",
69+
"StopIteration",
70+
"NotImplementedError",
6171
)
6272
_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
6373

src/tallyman_mcp/server.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -203,31 +203,36 @@ def catalog_run(code: str, prompt: str = "") -> dict:
203203
"""Execute a xorq expression script and persist it as a catalog entry.
204204
205205
The provided code MUST bind a top-level variable named `expr` to a xorq/ibis
206-
expression. Imports allowed; all imports happen in a fresh module scope.
206+
expression. The four canonical names are PRE-INJECTED — no import statement
207+
needed for basic usage:
207208
208-
PREFERRED pattern:
209+
ibis # xorq.vendor.ibis — ibis._, ibis.cases, ibis.window,
210+
# ibis.literal, ibis.coalesce, ibis.desc, ibis.schema
211+
xo # xorq.api — xo.memtable, xo.deferred_read_parquet, xo.connect
212+
from_catalog # read a named catalog entry or content hash
213+
from_project # read a raw file under <project>/data/
209214
210-
from tallyman_xorq.io import from_project, from_catalog
211-
import xorq.vendor.ibis as ibis
212-
t = from_catalog("alias_name") # named catalog entry (alias or hash)
215+
MINIMAL PATTERN (no imports needed):
216+
217+
t = from_catalog("alias_name") # named entry
213218
t = from_project("file.parquet") # raw file under <project>/data/
214219
expr = t.filter(t.col > 0).group_by("region").aggregate(n=t.count())
220+
expr = t.mutate(pk=ibis._.a + "_" + ibis._.b) # ibis._ available pre-injected
221+
222+
Explicit `import xorq.api as xo` or `import xorq.vendor.ibis as ibis` still work
223+
and are fine for clarity — they just overwrite the pre-injected binding with the
224+
same module.
225+
226+
FORBIDDEN IMPORTS — the linter rejects these before any code runs:
227+
228+
import ibis # imports system ibis (no datafusion backend); use
229+
# the pre-injected ibis or `import xorq.vendor.ibis as ibis`
230+
from ibis import X # same problem — use xorq.vendor.ibis
231+
import xorq as xo # bare xorq has no API methods; use `import xorq.api as xo`
232+
import xorq # same — use `import xorq.api as xo`
215233
216-
THREE NAMESPACES — mixing these up is the #1 build failure:
217-
218-
import xorq.api as xo # backends + deferred reads:
219-
# xo.memtable, xo.deferred_read_parquet, xo.connect
220-
import xorq.vendor.ibis as ibis # the expression API: ibis._, ibis.cases,
221-
# ibis.window, ibis.literal, ibis.coalesce, ibis.desc
222-
from tallyman_xorq.io import from_project, from_catalog # reading data
223-
224-
t.mutate(pk=ibis._.a + "_" + ibis._.b) # deferred string concat via ibis._
225-
# NEVER `import xorq` then `xorq.<fn>`: bare xorq.read_parquet / xorq.memtable /
226-
# xorq._ all raise AttributeError. The api module is `xo` (xorq.api).
227-
# NEVER bare `import ibis` / `from ibis ...`: it builds but fails at save time
228-
# with a vendored-Expr class error. Always `import xorq.vendor.ibis as ibis`.
229-
# Math is a COLUMN METHOD: col.sin(), col.log(), col.sqrt() — not ibis.sin(col).
230-
# Read data only via from_project / from_catalog (no xo.read_parquet / ibis.read_parquet).
234+
Math is a COLUMN METHOD: col.sin(), col.log(), col.sqrt() — not ibis.sin(col).
235+
Read data only via from_project / from_catalog (no xo.read_parquet / ibis.read_parquet).
231236
232237
ONLY BACKEND — xorq's built-in datafusion; there is NO duckdb. Do not use
233238
`ibis.duckdb`, a duckdb connection, `.sql()`, or `con.register()`. Build

src/tallyman_xorq/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
BuildError,
33
BuildResult,
44
build_and_persist,
5+
lint_catalog_code,
56
list_entries,
67
load_entry,
78
read_prompts,
@@ -47,6 +48,7 @@
4748
"key_diff",
4849
"key_diff_polars",
4950
"key_diff_xorq",
51+
"lint_catalog_code",
5052
"list_entries",
5153
"load_entry",
5254
"project_path",

src/tallyman_xorq/build.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import ast
34
import importlib.util
45
import json
56
import re
@@ -73,6 +74,51 @@ def _user_imports_bare_ibis(code: str) -> bool:
7374
return False
7475

7576

77+
def lint_catalog_code(code: str) -> list[str]:
78+
"""AST-based pre-execution scan. Returns actionable error strings, empty = clean.
79+
80+
Catches import mistakes that cause cryptic runtime errors. Runs before any
81+
code is executed so the model gets a clear rejection on the first bad call
82+
rather than after exec blows up with an AttributeError or IbisError.
83+
"""
84+
try:
85+
tree = ast.parse(code)
86+
except SyntaxError as e:
87+
return [f"syntax error: {e}"]
88+
89+
errors: list[str] = []
90+
for node in ast.walk(tree):
91+
if isinstance(node, ast.Import):
92+
for alias in node.names:
93+
if alias.name == "ibis":
94+
errors.append(
95+
"`import ibis` imports the system ibis package which has no datafusion "
96+
"backend here and will shadow the pre-injected xorq.vendor.ibis. "
97+
"ibis is already available without any import — just use `ibis.window(...)`, "
98+
"`ibis.literal(...)`, `ibis._` etc. directly. If you need an explicit import "
99+
"for clarity, write `import xorq.vendor.ibis as ibis`."
100+
)
101+
elif alias.name == "xorq":
102+
stmt = f"`import xorq as {alias.asname}`" if alias.asname else "`import xorq`"
103+
errors.append(
104+
f"{stmt} — the bare `xorq` module has no API methods "
105+
"(connect, memtable, deferred_read_parquet live on `xorq.api`). "
106+
"Use `import xorq.api as xo`, or just use the pre-injected `xo` "
107+
"directly without any import statement."
108+
)
109+
elif isinstance(node, ast.ImportFrom):
110+
module = node.module or ""
111+
if module == "ibis" or module.startswith("ibis."):
112+
names = ", ".join(a.name for a in node.names)
113+
errors.append(
114+
f"`from {module} import {names}` imports from the system ibis package. "
115+
"Use `import xorq.vendor.ibis as ibis` instead, or just use the pre-injected "
116+
"`ibis` without any import statement."
117+
)
118+
119+
return errors
120+
121+
76122
# Read helpers the model reaches for on the wrong namespace. The project-aware
77123
# way is from_project/from_catalog; xo.deferred_read_parquet handles a raw path.
78124
_READ_FNS = frozenset(
@@ -210,6 +256,26 @@ def _ibis_import_hint(exc_msg: str, code: str = "") -> str:
210256
return "\n\nHint: " + " ".join(hints)
211257

212258

259+
def _inject_prelude(module: object) -> None:
260+
"""Pre-inject the four canonical names into a module before exec_module runs.
261+
262+
ibis, xo, from_catalog, from_project are available without any import
263+
statement. Explicit `import xorq.api as xo` / `import xorq.vendor.ibis as ibis`
264+
still work — they just overwrite the same module object.
265+
"""
266+
try:
267+
import xorq.api as _xo # noqa: PLC0415
268+
import xorq.vendor.ibis as _ibis # noqa: PLC0415
269+
from tallyman_xorq.io import from_catalog as _from_catalog # noqa: PLC0415
270+
from tallyman_xorq.io import from_project as _from_project # noqa: PLC0415
271+
except ImportError:
272+
return
273+
module.ibis = _ibis # type: ignore[attr-defined]
274+
module.xo = _xo # type: ignore[attr-defined]
275+
module.from_catalog = _from_catalog # type: ignore[attr-defined]
276+
module.from_project = _from_project # type: ignore[attr-defined]
277+
278+
213279
def _import_script(code: str) -> tuple[object, Path]:
214280
"""Write code to a temp file, import it, return (module, temp_path).
215281
@@ -221,6 +287,7 @@ def _import_script(code: str) -> tuple[object, Path]:
221287
if spec is None or spec.loader is None:
222288
raise BuildError(f"Could not load module spec from {tmp}")
223289
module = importlib.util.module_from_spec(spec)
290+
_inject_prelude(module)
224291
sys.modules[spec.name] = module
225292
try:
226293
spec.loader.exec_module(module)
@@ -241,6 +308,12 @@ def build_and_persist(
241308
The user code must bind a variable named `expr_name` (default "expr") to an
242309
ibis/xorq expression. Imports happen in a fresh module scope.
243310
"""
311+
lint_errors = lint_catalog_code(code)
312+
if lint_errors:
313+
raise BuildError(
314+
"pre-execution lint:\n" + "\n".join(f" - {e}" for e in lint_errors)
315+
)
316+
244317
from xorq.ibis_yaml.compiler import build_expr, load_expr
245318

246319
from tallyman_core.paths import compute_cache_dir

tests/test_build.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,88 @@ def test_build_no_hint_on_unrelated_error(project: str, orders_parquet: Path):
105105
# hint only caught bare `import ibis`, the Expr class mismatch, and `xorq._`.
106106
# ---------------------------------------------------------------------------
107107

108-
from tallyman_xorq.build import _ibis_import_hint # noqa: E402
108+
from tallyman_xorq.build import _ibis_import_hint, lint_catalog_code # noqa: E402
109+
110+
111+
# ---------------------------------------------------------------------------
112+
# lint_catalog_code — pre-execution static scanner
113+
# ---------------------------------------------------------------------------
114+
115+
116+
def test_lint_rejects_import_ibis():
117+
errors = lint_catalog_code("import ibis\nexpr = ibis.table('x')")
118+
assert errors
119+
assert any("xorq.vendor.ibis" in e for e in errors)
120+
121+
122+
def test_lint_rejects_from_ibis_import():
123+
errors = lint_catalog_code("from ibis import window\nexpr = None")
124+
assert errors
125+
assert any("xorq.vendor.ibis" in e for e in errors)
126+
127+
128+
def test_lint_rejects_from_ibis_submodule():
129+
errors = lint_catalog_code("from ibis.expr.types import Column\nexpr = None")
130+
assert errors
131+
132+
133+
def test_lint_rejects_bare_import_xorq():
134+
errors = lint_catalog_code("import xorq\nexpr = xorq.connect()")
135+
assert errors
136+
assert any("xorq.api" in e for e in errors)
137+
138+
139+
def test_lint_rejects_import_xorq_as_xo():
140+
errors = lint_catalog_code("import xorq as xo\nexpr = xo.connect()")
141+
assert errors
142+
assert any("xorq.api" in e for e in errors)
143+
144+
145+
def test_lint_accepts_correct_imports():
146+
code = """
147+
import xorq.api as xo
148+
import xorq.vendor.ibis as ibis
149+
from tallyman_xorq.io import from_catalog, from_project
150+
t = from_catalog("foo")
151+
expr = t.filter(t.x > 0)
152+
"""
153+
assert lint_catalog_code(code) == []
154+
155+
156+
def test_lint_accepts_no_imports():
157+
code = "t = from_catalog('foo')\nexpr = t.filter(t.x > 0)"
158+
assert lint_catalog_code(code) == []
159+
160+
161+
def test_lint_returns_syntax_error():
162+
errors = lint_catalog_code("def broken(\nexpr = None")
163+
assert errors
164+
assert any("syntax" in e for e in errors)
165+
166+
167+
def test_build_lint_fires_before_exec(project: str):
168+
# `import ibis` is caught by the linter — no code should execute.
169+
code = "import ibis\nexpr = ibis.table('x', schema={'a': 'int64'})"
170+
with pytest.raises(BuildError, match="pre-execution lint"):
171+
build_and_persist(project, code)
172+
173+
174+
def test_preinjected_names_available(project: str, orders_parquet: Path):
175+
# ibis, xo, from_project are pre-injected — no import statements needed.
176+
code = f"""
177+
t = xo.deferred_read_parquet({str(orders_parquet)!r})
178+
expr = t.group_by("region").aggregate(
179+
total=t.price.sum(),
180+
label=ibis.literal("ok"),
181+
)
182+
"""
183+
res = build_and_persist(project, code)
184+
assert res.row_count == 4
185+
186+
187+
# ---------------------------------------------------------------------------
188+
# Namespace / column-name hints
189+
# ---------------------------------------------------------------------------
109190

110191

111192
def test_hint_bare_xorq_attribute_points_to_api():

0 commit comments

Comments
 (0)