Skip to content

Commit e739616

Browse files
authored
Merge pull request #158 from buckaroo-data/fix/csv-import-review-followups
fix(csv-import): review follow-ups — kwarg collision, reserved-col totality, recon-after-delete
2 parents 5d29d98 + 0fd3f51 commit e739616

4 files changed

Lines changed: 315 additions & 25 deletions

File tree

src/tallyman_xorq/io.py

Lines changed: 111 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ class ProjectDataNotFound(FileNotFoundError):
1818
pass
1919

2020

21-
def project_path(rel_path: str, project: str | None = None) -> Path:
22-
"""Resolve `rel_path` against the project's data dir. Raises if absent."""
21+
def project_path(rel_path: str, project: str | None = None, must_exist: bool = True) -> Path:
22+
"""Resolve `rel_path` against the project's data dir. Raises if absent.
23+
24+
Pass ``must_exist=False`` for digest-pinned reconstruction: it serves the frozen
25+
``.cas`` clone (``recon_cas_path``) and must not require the live source to still
26+
be present. The traversal guard still applies regardless.
27+
"""
2328
proj = resolve_project(project)
2429
base = data_dir(proj)
2530
candidate = (base / rel_path).resolve()
@@ -28,7 +33,7 @@ def project_path(rel_path: str, project: str | None = None) -> Path:
2833
candidate.relative_to(base.resolve())
2934
except ValueError as exc:
3035
raise ProjectDataNotFound(f"{rel_path!r} resolves outside {base} (got {candidate})") from exc
31-
if not candidate.is_file():
36+
if must_exist and not candidate.is_file():
3237
raise ProjectDataNotFound(f"{candidate} not found")
3338
return candidate
3439

@@ -52,7 +57,6 @@ def read_project_file(rel_path: str, project: str | None = None):
5257
from tallyman_xorq import source_identity as si
5358

5459
proj = resolve_project(project)
55-
path = project_path(rel_path, proj)
5660
if si.mode() == "cas":
5761
recorded = _reconstructing_source_digest(proj, rel_path)
5862
if recorded is not None:
@@ -64,8 +68,14 @@ def read_project_file(rel_path: str, project: str | None = None):
6468
# source collector is independent of tracked_expr_from_alias's _RECONSTRUCTING-gated
6569
# parent capture, so both fire) so a child build reconstructing this entry
6670
# records the frozen digest and gc_cas keeps the clone alive.
71+
#
72+
# must_exist=False: recon_cas_path serves the frozen clone without the live
73+
# source, so a moved/deleted source must not defeat the pinned read. This
74+
# block MUST run before the existence-requiring project_path below.
75+
recon_path = project_path(rel_path, proj, must_exist=False)
6776
si.note_source(rel_path, recorded)
68-
return deferred_read_parquet(str(si.recon_cas_path(proj, path, recorded)))
77+
return deferred_read_parquet(str(si.recon_cas_path(proj, recon_path, recorded)))
78+
path = project_path(rel_path, proj)
6979
if si.mode() != "off":
7080
digest = si.digest_for(proj, path)
7181
si.note_source(rel_path, digest)
@@ -155,6 +165,13 @@ def _polars_overrides(schema):
155165
_REST = "&rest" # wildcard closure: the remaining tail columns (ADR D3)
156166
_BEGIN = "&begin" # wildcard closure: the leading head columns (specced, not yet implemented)
157167

168+
# scan_csv options tallyman_read_csv owns and must not accept as pass-through
169+
# **kwargs: every internal scan_csv call hardcodes infer_schema_length (the
170+
# escalation ladder) and schema_overrides (derived from schema=), so forwarding
171+
# either collides ("multiple values for keyword argument") or silently bypasses
172+
# the schema system. Rejected up front with a message steering to schema=.
173+
_RESERVED_SCAN_KWARGS = ("infer_schema_length", "schema_overrides")
174+
158175

159176
def _spec_sig(spec) -> str:
160177
"""A stable string for the cache key, covering every schema-spec shape.
@@ -189,19 +206,39 @@ def _spec_pairs(spec):
189206
return list(spec.items())
190207

191208

192-
def _normalize_schema(spec, header: list[str]) -> dict:
209+
def _normalize_schema(spec, header: list[str], *, reserved: tuple[str, ...] = ()) -> dict:
193210
"""Resolve a schema spec against the CSV *header* into a polars parse plan.
194211
212+
*header* is the CSV's full header. *reserved* names tallyman-managed columns
213+
(currently ``original_row_order``) that the caller must not spec: they are
214+
excluded from the columns the spec has to cover, but kept in the diagnostics
215+
so an error message matches the file the user is looking at rather than a
216+
silently reserved-stripped subset. Threading ``reserved`` in — rather than
217+
pre-stripping the header at the call site — keeps the totality check, the
218+
diagnostics, and positional binding all reading one consistent header.
219+
195220
Returns ``{overrides, rename, out_names, all_pinned}``:
196221
- ``overrides`` — ``{header_name: polars_dtype}`` for pinned columns, keyed by the
197222
name polars reads (positional renames are applied *after* the read).
198223
- ``rename`` — ``{header_name: output_name}`` where they differ (positional only).
199224
- ``out_names`` — output column names in column order (the final ``select``).
200225
- ``all_pinned`` — True if no column is inferred (then ``infer_schema_length=0``).
201226
202-
Raises the actionable ValueError on a by-name miss, a non-total spec, or an
203-
over-long positional spec — these are the #143 error-contract surfaces.
227+
Raises the actionable ValueError on a by-name miss, a non-total spec, an
228+
over-long positional spec, a by-name spec that names a reserved column, or a
229+
positional spec whose reserved column is not the trailing column — these are
230+
the #143 error-contract surfaces.
204231
"""
232+
reserved = tuple(r for r in reserved if r in header)
233+
speccable = [h for h in header if h not in reserved]
234+
reserved_hint = ""
235+
if reserved:
236+
names = ", ".join(repr(r) for r in reserved)
237+
reserved_hint = (
238+
f" (the file also has tallyman's reserved column {names}, which tallyman "
239+
"manages automatically — leave it out of the schema.)"
240+
)
241+
205242
overrides: dict = {}
206243
rename: dict = {}
207244
inferred = False
@@ -219,20 +256,31 @@ def _normalize_schema(spec, header: list[str]) -> dict:
219256
cells = cells[:-1]
220257
if any(n in (_REST, _BEGIN) for n, _ in cells):
221258
raise ValueError("tallyman_read_csv: '&rest' must be the last cell and '&begin' the first.")
222-
if len(cells) > len(header):
259+
# Positional cells bind by physical column position. Excluding a reserved column
260+
# that is not the trailing column would silently shift every later cell onto the
261+
# wrong data column, so require the reserved column(s) to be a trailing suffix
262+
# (header[:len(speccable)] == speccable); otherwise steer to a by-name schema.
263+
if reserved and header[: len(speccable)] != speccable:
264+
raise ValueError(
265+
f"tallyman_read_csv: a positional schema cannot bind {list(header)} — its reserved "
266+
f"column {list(reserved)} is not the last column, and positional cells bind by physical "
267+
"column position, so excluding it would silently rebind later cells onto the wrong data "
268+
"column. Use a by-name schema (a dict or ibis schema keyed on the header names) instead."
269+
)
270+
if len(cells) > len(speccable):
223271
raise ValueError(
224272
f"tallyman_read_csv: positional schema has {len(cells)} columns but the CSV header has "
225-
f"{len(header)} {list(header)}."
273+
f"{len(speccable)} {list(speccable)}.{reserved_hint}"
226274
)
227-
if rest_dtype is None and len(cells) != len(header):
228-
uncovered = list(header[len(cells):])
275+
if rest_dtype is None and len(cells) != len(speccable):
276+
uncovered = list(speccable[len(cells):])
229277
raise ValueError(
230278
f"tallyman_read_csv: schema is not total — it leaves column(s) {uncovered} unspecified. "
231279
'Name every column, or close the spec with ("&rest", "infer").'
232280
)
233281
out_names = []
234282
for i, (out_name, dt) in enumerate(cells):
235-
orig = header[i]
283+
orig = speccable[i]
236284
out_names.append(out_name)
237285
if out_name != orig:
238286
rename[orig] = out_name
@@ -241,7 +289,7 @@ def _normalize_schema(spec, header: list[str]) -> dict:
241289
inferred = True
242290
else:
243291
overrides[orig] = pl_dt
244-
for orig in header[len(cells):]: # the &rest tail keeps its header name
292+
for orig in speccable[len(cells):]: # the &rest tail keeps its header name
245293
out_names.append(orig)
246294
pl_dt = _resolve_spec_dtype(rest_dtype)
247295
if pl_dt is None:
@@ -260,14 +308,20 @@ def _normalize_schema(spec, header: list[str]) -> dict:
260308
rest_dtype = dt
261309
continue
262310
named[str(name)] = dt
263-
missing = [n for n in named if n not in header]
311+
named_reserved = [n for n in named if n in reserved]
312+
if named_reserved:
313+
raise ValueError(
314+
f"tallyman_read_csv: schema names tallyman's reserved column {sorted(named_reserved)}, which "
315+
"tallyman manages automatically — remove it from the schema (it is added and ordered for you)."
316+
)
317+
missing = [n for n in named if n not in speccable]
264318
if missing:
265319
raise ValueError(
266-
f"tallyman_read_csv: schema column(s) {sorted(missing)} are not in the CSV header {list(header)}. "
320+
f"tallyman_read_csv: schema column(s) {sorted(missing)} are not in the CSV header {list(speccable)}. "
267321
"Bind by name only when the names match the header; to rename by position pass a tuple-of-tuples "
268-
'schema, e.g. schema=(("Date", "date"), ("&rest", "infer")).'
322+
'schema, e.g. schema=(("Date", "date"), ("&rest", "infer")).' + reserved_hint
269323
)
270-
uncovered = [h for h in header if h not in named]
324+
uncovered = [h for h in speccable if h not in named]
271325
if rest_dtype is None and uncovered:
272326
raise ValueError(
273327
f"tallyman_read_csv: schema is not total — it leaves column(s) {uncovered} unspecified. "
@@ -285,7 +339,7 @@ def _normalize_schema(spec, header: list[str]) -> dict:
285339
inferred = True
286340
else:
287341
overrides[name] = pl_dt
288-
return {"overrides": overrides, "rename": {}, "out_names": list(header), "all_pinned": not inferred}
342+
return {"overrides": overrides, "rename": {}, "out_names": list(speccable), "all_pinned": not inferred}
289343

290344

291345
_ESCALATED_INFER = 10_000 # the middle rung of the #143 infer ladder; whole-file (None) is the terminal
@@ -317,12 +371,18 @@ def _polars_to_ibis(dt) -> str:
317371
return "string" # safe fallback — an unmapped column still reads as string
318372

319373

320-
def _suggest_schema_dsl(src: Path, scan_kwargs: dict) -> str:
321-
"""Whole-file-infer *src* and render it as the paste-ready tuple-of-tuples DSL."""
374+
def _suggest_schema_dsl(src: Path, scan_kwargs: dict, reserved: tuple[str, ...] = ()) -> str:
375+
"""Whole-file-infer *src* and render it as the paste-ready tuple-of-tuples DSL.
376+
377+
*reserved* columns (tallyman-managed, e.g. ``original_row_order``) are dropped:
378+
they must not appear in the suggestion because the caller cannot spec them —
379+
a suggestion carrying one is un-pasteable (the totality check excludes the
380+
reserved column, so pasting it back over-counts the columns and raises).
381+
"""
322382
import polars as pl
323383

324384
inferred = pl.scan_csv(str(src), infer_schema_length=None, **scan_kwargs).collect_schema()
325-
pairs = tuple((name, _polars_to_ibis(dt)) for name, dt in inferred.items())
385+
pairs = tuple((name, _polars_to_ibis(dt)) for name, dt in inferred.items() if name not in reserved)
326386
return repr(pairs)
327387

328388

@@ -380,13 +440,28 @@ def _materialize_ordered(src: Path, schema, scan_kwargs: dict, tmp_path: Path) -
380440
# check; infer_schema_length=0 reads just the header line, no type sampling.
381441
header = list(pl.scan_csv(str(src), infer_schema_length=0, **scan_kwargs).collect_schema().names())
382442
has_row_order = "original_row_order" in header
443+
# original_row_order is tallyman's reserved index — _ordered re-appends it, and
444+
# _validate_existing_row_order (below) vets a pre-existing one. It is not the caller's
445+
# to spec, so it is threaded as `reserved` through every header reader (the schema
446+
# plan, the diagnostics, and the suggested-schema hint) rather than stripped ad hoc.
447+
reserved = ("original_row_order",) if has_row_order else ()
383448
if has_row_order:
384449
_validate_existing_row_order(src, scan_kwargs) # reuse it, or raise — never with_row_index over it
385450

386451
plan = None
387452
explicit_only = False
388453
if schema is not None:
389-
plan = _normalize_schema(schema, header)
454+
plan = _normalize_schema(schema, header, reserved=reserved)
455+
# A spec may not PRODUCE the reserved name either: renaming a data column onto
456+
# 'original_row_order' collides with the index _ordered re-appends (a raw polars
457+
# DuplicateError at sink). Reject the reserved output name up front with the same
458+
# guidance as the pre-existing-column clash.
459+
if "original_row_order" in plan["out_names"]:
460+
raise ValueError(
461+
"tallyman_read_csv: 'original_row_order' is reserved for tallyman's canonical "
462+
"row index and cannot be a schema output column name. Rename that column to "
463+
"something other than 'original_row_order' in the schema."
464+
)
390465
explicit_only = plan["all_pinned"]
391466

392467
def _ordered(infer_len):
@@ -417,7 +492,7 @@ def _ordered(infer_len):
417492
last_exc = exc
418493
raise ValueError(
419494
f"tallyman_read_csv: the schema does not parse {src.name}: {last_exc}. "
420-
f"Suggested schema (whole-file inference): schema={_suggest_schema_dsl(src, scan_kwargs)}"
495+
f"Suggested schema (whole-file inference): schema={_suggest_schema_dsl(src, scan_kwargs, reserved)}"
421496
)
422497

423498

@@ -533,10 +608,21 @@ def tallyman_read_csv(path: str, schema=None, **kwargs):
533608
**kwargs: Forwarded to ``polars.scan_csv`` — reader options such as
534609
``separator``, ``skip_rows``, ``null_values``, ``quote_char``,
535610
``has_header``, ``encoding``. They participate in the intermediate's
536-
cache key, so changing one re-ingests.
611+
cache key, so changing one re-ingests. ``infer_schema_length`` and
612+
``schema_overrides`` are managed internally (see ``_RESERVED_SCAN_KWARGS``)
613+
and rejected — they would collide with the values every internal
614+
``scan_csv`` call already sets.
537615
"""
538616
import xorq.api as xo
539617

618+
reserved = [k for k in _RESERVED_SCAN_KWARGS if k in kwargs]
619+
if reserved:
620+
raise ValueError(
621+
f"tallyman_read_csv: {reserved} is managed internally, not a pass-through "
622+
"polars.scan_csv reader option. Type inference escalates automatically "
623+
"(100 -> 10k -> whole-file); to pin column types pass schema= (an ibis schema, "
624+
"plain dict, or tuple-of-tuples), never schema_overrides."
625+
)
540626
parquet_path = _ordered_csv_parquet(path, schema, kwargs)
541627
return xo.deferred_read_parquet(str(parquet_path)).order_by("original_row_order")
542628

tests/test_result_cache.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,36 @@ def test_cas_cold_read_after_inplace_edit_serves_built_bytes(project, monkeypatc
10201020
assert sorted(df["x"].tolist()) == [0, 1, 2]
10211021

10221022

1023+
def test_cas_cold_read_after_source_deleted_serves_built_bytes(project, monkeypatch):
1024+
"""#115 / #148-review: a cold read serves the frozen bytes even when the live
1025+
source has been DELETED (or moved), not just edited in place.
1026+
1027+
recon_cas_path serves the frozen data/.cas/<digest> clone without touching the
1028+
live file, but read_project_file called project_path first, whose is_file() guard
1029+
raised ProjectDataNotFound before the reconstruction block could run — so a
1030+
deleted source defeated pinned reconstruction entirely.
1031+
"""
1032+
from tallyman_core import data_dir
1033+
from tallyman_xorq import build_and_persist
1034+
from tallyman_xorq import source_identity as si
1035+
from tallyman_xorq.result_cache import cached_result_expr
1036+
1037+
monkeypatch.setenv("TALLYMAN_PROJECT", project)
1038+
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas")
1039+
assert si.mode() == "cas"
1040+
1041+
src = data_dir(project) / "src.parquet"
1042+
_write_ints(src, [0, 1, 2])
1043+
h = build_and_persist(project, _src_read_code(project, "src.parquet")).content_hash
1044+
1045+
src.unlink() # source gone entirely — the frozen .cas clone is the only copy left
1046+
cached_result_expr.cache_clear() # force a genuinely COLD read (re-import the recipe)
1047+
1048+
df = cached_result_expr(project, h).execute()
1049+
# Only the frozen clone holds exactly {0,1,2}; assert on values, not just the count.
1050+
assert sorted(df["x"].tolist()) == [0, 1, 2]
1051+
1052+
10231053
def test_cas_expensive_self_heal_after_edit_serves_built_bytes(project, monkeypatch, caplog):
10241054
"""#115: an expensive entry self-healing an evicted snapshot recomputes from the
10251055
FROZEN source, not the edited live file.

0 commit comments

Comments
 (0)