Skip to content

Commit 77ce9f5

Browse files
paddymulclaude
andcommitted
test(csv-import): add the dastardly CSV pathology corpus
The CSV sibling of buckaroo's dastardly-dataframe dataset: one builder per pathological CSV (embedded newlines, BOM, non-UTF-8, decimal comma, mixed types past the infer window, ragged rows, NUL bytes, ambiguous dates, int overflow, tz-pinned naive timestamps, ...), each drawn from docs/research/csv-importers/edge-cases.md with the per-importer contrast in its docstring. test_dastardly_csv.py runs the corpus through tallyman_read_csv in three layers: - TestCorpusIsDeterministic — every case parses or raises a contract ValueError, never an unhandled exception or silent crash. - TestDocumentedBehavior — characterization guards pinning tallyman's current behavior pathology by pathology. - TestKnownGaps — xfail markers for the behavior the ADR/edge-cases.md want but tallyman does not yet implement (#142 ragged-short null-fill chief among them), ready-made red tests for the holistic edge-case pass. 47 passed, 3 xfailed against current main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e739616 commit 77ce9f5

2 files changed

Lines changed: 684 additions & 0 deletions

File tree

tests/dastardly_csv_library.py

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
"""The dastardly CSV dataset.
2+
3+
The weirdest CSVs that cause trouble frequently — the pathology corpus that
4+
tallyman's ``tallyman_read_csv`` must handle deterministically (parse with a
5+
known schema, or raise a clean ``ValueError`` — never silently corrupt, never
6+
crash).
7+
8+
This is the CSV sibling of buckaroo's ``ddd_library.py`` ("the dastardly
9+
dataframe dataset"): one builder per pathology, each docstring naming the
10+
hazard, how the three reference importers (DuckDB / Polars / Pandas) behave, and
11+
the behavior tallyman wants. Cases and citations are drawn from
12+
``docs/research/csv-importers/edge-cases.md`` (the seed corpus) — case numbers
13+
below match that file.
14+
15+
Every builder returns the raw CSV **bytes** (not str), because several
16+
pathologies live below the text layer: BOM bytes, NUL bytes, non-UTF-8
17+
encodings. The accompanying tests in ``test_dastardly_csv.py`` write these
18+
bytes to a project data dir and run them through ``tallyman_read_csv``.
19+
20+
A builder returns only the dastardly *data*; any reader option needed to tame a
21+
case (``separator``, ``encoding``, ``decimal_comma``, a pinned ``schema``) is
22+
supplied by the test, never baked into the fixture — same split buckaroo keeps
23+
between ``ddd_library`` (the frames) and the widget tests (the config).
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from collections.abc import Callable
29+
30+
31+
# --------------------------------------------------------------------------- #
32+
# 1. Embedded newline inside a quoted field
33+
# --------------------------------------------------------------------------- #
34+
def csv_embedded_newline() -> bytes:
35+
"""A real ``\\n`` inside a ``"..."``-quoted field (data cell and header cell).
36+
37+
Why: the most common "row count is wrong" bug — an in-quote newline must be
38+
data, not a record terminator. DuckDB/Polars/Pandas all honor it when the
39+
quote char is set. tallyman (polars) should keep 2 data rows, not 3+.
40+
"""
41+
return b'note,n\n"line one\nline two",1\n"single",2\n'
42+
43+
44+
def csv_embedded_newline_in_header() -> bytes:
45+
"""A quoted newline inside a *header* cell — the name spans two physical lines."""
46+
return b'"first\nsecond",val\nx,1\ny,2\n'
47+
48+
49+
# --------------------------------------------------------------------------- #
50+
# 2. Byte-order mark (BOM)
51+
# --------------------------------------------------------------------------- #
52+
def csv_bom_utf8() -> bytes:
53+
"""A UTF-8 BOM (``EF BB BF``) prefixing the first header cell.
54+
55+
Why: an unstripped BOM corrupts the first column name (``id``),
56+
silently breaking by-name schema binding (#141). DuckDB/Polars/Pandas all
57+
strip a valid UTF-8 BOM; tallyman must too, so the header is ``id`` not
58+
``id``.
59+
"""
60+
return b"\xef\xbb\xbfid,name\n1,alice\n2,bob\n"
61+
62+
63+
def csv_bom_on_quoted_header() -> bytes:
64+
"""A UTF-8 BOM in front of a *quoted* first header cell."""
65+
return b'\xef\xbb\xbf"id",name\n1,alice\n2,bob\n'
66+
67+
68+
# --------------------------------------------------------------------------- #
69+
# 3. Alternate / non-UTF-8 encoding
70+
# --------------------------------------------------------------------------- #
71+
def csv_latin1() -> bytes:
72+
"""A latin-1 (ISO-8859-1) file with ``café`` / ``£`` — invalid as UTF-8.
73+
74+
Why: encoding is undecidable from content; a wrong guess is mojibake or a
75+
mid-parse failure. Default strict-UTF-8 should fail loud; reading it needs an
76+
explicit ``encoding`` knob. The ``\xe9``/``\xa3`` bytes are not valid UTF-8.
77+
"""
78+
return "name,price\ncafé,£5\nrésumé,£9\n".encode("latin-1")
79+
80+
81+
# --------------------------------------------------------------------------- #
82+
# 4. Decimal comma (European numerics)
83+
# --------------------------------------------------------------------------- #
84+
def csv_decimal_comma() -> bytes:
85+
"""Floats written ``1,5`` with ``;`` as the field separator (European style).
86+
87+
Why: with ``,`` as the decimal point you must NOT also use it as the
88+
delimiter. Read with ``separator=';'`` + ``decimal_comma=True``. Polars:
89+
``decimal_comma`` switches ``FLOAT_RE``; without it ``1,5`` stays String.
90+
"""
91+
return b"label;amount\na;1,5\nb;2,75\n"
92+
93+
94+
# --------------------------------------------------------------------------- #
95+
# 5. Thousands separator
96+
# --------------------------------------------------------------------------- #
97+
def csv_thousands_separator() -> bytes:
98+
"""Grouped integers ``1,234`` — the grouping char must be stripped only from
99+
numeric columns, never from the string column.
100+
101+
Why: Polars has **no** thousands option (the gap called out in the research),
102+
so grouped numbers stay String unless pre-cleaned. Pandas guards against
103+
stripping dots from non-numeric columns.
104+
"""
105+
return b'qty,label\n"1,234","a,b"\n"5,678","c,d"\n'
106+
107+
108+
# --------------------------------------------------------------------------- #
109+
# 6. Mixed-type column past the inference window
110+
# --------------------------------------------------------------------------- #
111+
def csv_mixed_type_late(n_clean: int = 12_000) -> bytes:
112+
"""Column ``v`` is integer for ``n_clean`` rows, then a string far past the
113+
default 100-row (and 10k) infer window.
114+
115+
Why: *the* canonical importer bug — the schema is decided from the window
116+
and the violator is unseen until parse time. tallyman's escalation ladder
117+
(ADR D6) must widen to whole-file and fall ``v`` back to ``string`` on a
118+
no-schema read; an explicit ``int64`` must raise with a suggestion.
119+
"""
120+
rows = [b"k,v"]
121+
rows += [f"{i},{i * 2}".encode() for i in range(n_clean)]
122+
rows.append(b"oops,not_a_number")
123+
rows += [f"{i},{i * 2}".encode() for i in range(n_clean + 1, n_clean + 10)]
124+
return b"\n".join(rows) + b"\n"
125+
126+
127+
# --------------------------------------------------------------------------- #
128+
# 7. All-null column
129+
# --------------------------------------------------------------------------- #
130+
def csv_all_null_column() -> bytes:
131+
"""A column whose every value is empty — no evidence for a type.
132+
133+
Why: whatever the importer guesses may conflict with a later widening.
134+
Polars resolves an all-null column to ``null``/``String``; with an explicit
135+
schema it is a non-issue.
136+
"""
137+
return b"a,b\n1,\n2,\n3,\n"
138+
139+
140+
# --------------------------------------------------------------------------- #
141+
# 8. Leading-zero identifiers (ZIP / FIPS / account numbers)
142+
# --------------------------------------------------------------------------- #
143+
def csv_leading_zeros() -> bytes:
144+
"""``01234`` ZIP-style identifiers that are NOT integers.
145+
146+
Why: promoting to int destroys the leading zero irreversibly. DuckDB keeps
147+
leading-zero values as VARCHAR by a deliberate sniffer rule; Polars (and so
148+
tallyman by default) infers ``Int64`` and loses the zero. Per ADR D2 the
149+
parse layer does **no** semantic cleaning — keeping zeros means pinning the
150+
column to ``string`` in the schema.
151+
"""
152+
return b"zip,city\n01234,boston\n02115,boston\n"
153+
154+
155+
# --------------------------------------------------------------------------- #
156+
# 9. Scientific notation and signed numbers
157+
# --------------------------------------------------------------------------- #
158+
def csv_scientific_notation() -> bytes:
159+
"""An int-looking column that holds one ``1e5`` value.
160+
161+
Why: scientific notation is float-shaped — an int-typed column that meets
162+
``1e5`` fails the cast (the mixed-type trap in disguise). Must surface as a
163+
float (inference) or a value-error (explicit int schema), never silently
164+
truncate. Also a scientific number must never be coerced to a date.
165+
"""
166+
return b"energy\n10\n20\n1e5\n30\n"
167+
168+
169+
# --------------------------------------------------------------------------- #
170+
# 10. Duplicate header names
171+
# --------------------------------------------------------------------------- #
172+
def csv_duplicate_headers() -> bytes:
173+
"""Two columns both named ``id``.
174+
175+
Why: by-name schema binding (#141) is ambiguous when names collide; a silent
176+
de-dup can bind the wrong column. Polars dedups to ``id``/``id_duplicated_0``;
177+
tallyman binds by name so the post-dedup names must be deterministic and
178+
surfaced.
179+
"""
180+
return b"id,id\n1,2\n3,4\n"
181+
182+
183+
# --------------------------------------------------------------------------- #
184+
# 11. Empty / blank header cell
185+
# --------------------------------------------------------------------------- #
186+
def csv_empty_header_cell() -> bytes:
187+
"""A header row with an empty middle cell (``a,,c``)."""
188+
return b"a,,c\n1,2,3\n4,5,6\n"
189+
190+
191+
# --------------------------------------------------------------------------- #
192+
# 12. Headerless file (all-string, header ambiguous)
193+
# --------------------------------------------------------------------------- #
194+
def csv_headerless_all_string() -> bytes:
195+
"""No header; every field is a string, so row 0 can't be type-distinguished
196+
from a header.
197+
198+
Why: an importer that assumes a header eats a real data row as names. DuckDB
199+
documents that an all-VARCHAR file is assumed to *have* a header; Polars
200+
(tallyman) assumes the same unless told ``has_header=False`` + names.
201+
"""
202+
return b"alpha,beta\ngamma,delta\nepsilon,zeta\n"
203+
204+
205+
# --------------------------------------------------------------------------- #
206+
# 13. Trailing delimiter (ghost column)
207+
# --------------------------------------------------------------------------- #
208+
def csv_trailing_delimiter() -> bytes:
209+
"""Every line ends with the delimiter (``a,b,``) → a spurious empty final
210+
column.
211+
212+
Why: the ghost column shifts width and can be mistaken for an index column.
213+
Polars emits an extra all-null/empty-named column; the decision (keep vs
214+
drop) should be deterministic and surfaced.
215+
"""
216+
return b"a,b,\n1,2,\n3,4,\n"
217+
218+
219+
# --------------------------------------------------------------------------- #
220+
# 14. Ragged SHORT row (too few fields) — tallyman #142
221+
# --------------------------------------------------------------------------- #
222+
def csv_ragged_short() -> bytes:
223+
"""A row with fewer fields than the header: ``a,b\\n1,2\\n3\\n4,5``.
224+
225+
Why: **this is tallyman #142.** The old datafusion path raised; the polars
226+
``scan_csv`` path silently null-fills, baking ``(3, null)`` into the
227+
canonical snapshot with no signal. Silent-by-direction is the trap — DuckDB
228+
raises ``MISSING COLUMNS`` by default. Empirically polars cannot be made to
229+
raise on a short row (pola-rs/polars#10585); the fail-loud detector is a
230+
``pyarrow.csv`` validation gate (edge-cases.md, 2026-06-26).
231+
"""
232+
return b"a,b\n1,2\n3\n4,5\n"
233+
234+
235+
# --------------------------------------------------------------------------- #
236+
# 15. Ragged LONG row (too many fields) — #142 sibling
237+
# --------------------------------------------------------------------------- #
238+
def csv_ragged_long() -> bytes:
239+
"""A row with more fields than the header: ``a,b\\n1,2\\n3,4,5\\n6,7``.
240+
241+
Why: the asymmetric twin of the short row — most tools are *loud* here and
242+
*silent* on short rows. Polars raises ``found more fields than defined``
243+
unless ``truncate_ragged_lines=True``; tallyman surfaces that as a
244+
``ValueError``. The asymmetry between short and long is what to eliminate.
245+
"""
246+
return b"a,b\n1,2\n3,4,5\n6,7\n"
247+
248+
249+
# --------------------------------------------------------------------------- #
250+
# 16. Quoted-empty vs unquoted-empty as null
251+
# --------------------------------------------------------------------------- #
252+
def csv_quoted_vs_bare_empty() -> bytes:
253+
"""``""`` (quoted empty) vs a bare empty field — should one be null and the
254+
other empty string?
255+
256+
Why: the null-vs-empty distinction changes downstream joins/aggregations and
257+
the tools disagree. DuckDB ``allow_quoted_nulls`` / ``force_not_null``;
258+
Polars ``missing_utf8_is_empty_string``; Pandas treats bare empty as NA.
259+
"""
260+
return b'a,b\n"",x\n,y\nz,w\n'
261+
262+
263+
# --------------------------------------------------------------------------- #
264+
# 17. Whitespace sensitivity
265+
# --------------------------------------------------------------------------- #
266+
def csv_leading_whitespace() -> bytes:
267+
"""Leading spaces after the delimiter (`` 1, 2``) that can flip inferred type.
268+
269+
Why: Polars does **not** strip surrounding whitespace before inference, so
270+
`` 1`` may not match ``INTEGER_RE`` and the column stays String. Pandas has
271+
opt-in ``skipinitialspace``. The fixture pairs a clean numeric column with a
272+
space-prefixed one so the type difference is visible.
273+
"""
274+
return b"clean,spaced\n1, 1\n2, 2\n3, 3\n"
275+
276+
277+
# --------------------------------------------------------------------------- #
278+
# 18. Windows vs Unix newlines (and mixed)
279+
# --------------------------------------------------------------------------- #
280+
def csv_mixed_newlines() -> bytes:
281+
"""One file mixing ``\\n`` and ``\\r\\n`` line terminators.
282+
283+
Why: a trailing ``\\r`` can ride along on the last field's value; mixed
284+
newlines can confuse record boundaries. Default behavior should normalize
285+
CRLF/CR and strip the trailing ``\\r`` so values don't carry it.
286+
"""
287+
return b"a,b\r\n1,2\n3,4\r\n5,6\n"
288+
289+
290+
# --------------------------------------------------------------------------- #
291+
# 19. NUL bytes and other control bytes mid-field
292+
# --------------------------------------------------------------------------- #
293+
def csv_nul_byte() -> bytes:
294+
"""A ``\\x00`` byte embedded in a data field.
295+
296+
Why: NUL bytes corrupt C string handling and signal a binary file mis-fed as
297+
CSV — a classic fuzzer crash. Pandas raises ``"NULL byte detected"``; DuckDB
298+
has dedicated null-byte tests. edge-cases.md suggests tallyman treat a NUL as
299+
a hard structural error rather than silently absorbing it into a string.
300+
"""
301+
return b"a,b\n1,2\n3,4\x005\n"
302+
303+
304+
# --------------------------------------------------------------------------- #
305+
# 20. Huge / wide files and over-long lines
306+
# --------------------------------------------------------------------------- #
307+
def csv_over_long_line(width: int = 2_000_000) -> bytes:
308+
"""A single field roughly ``width`` bytes long.
309+
310+
Why: an over-long line can blow read buffers. DuckDB bounds it with
311+
``max_line_size`` (~2 MB) and raises ``LINE SIZE OVER MAXIMUM``. The fixture
312+
is a deterministic 'x'*width cell so the boundary behavior is exercised.
313+
"""
314+
big = b"x" * width
315+
return b"a,b\n1,2\n" + big + b",3\n"
316+
317+
318+
# --------------------------------------------------------------------------- #
319+
# 21. Datetime format zoo
320+
# --------------------------------------------------------------------------- #
321+
def csv_ambiguous_date() -> bytes:
322+
"""Ambiguous ``1/6/2000`` (Jan-6 vs Jun-1) and an impossible ``32/32/2019``.
323+
324+
Why: date parsing is the richest source of silent month/day swaps. tallyman
325+
keeps dates opt-in/explicit (no auto-parse), so without a ``date`` schema
326+
these stay ``string`` — never silently swapped, never a wrong date.
327+
"""
328+
return b"d\n1/6/2000\n2/7/2001\n32/32/2019\n"
329+
330+
331+
# --------------------------------------------------------------------------- #
332+
# 22. Integer overflow / large-int fidelity
333+
# --------------------------------------------------------------------------- #
334+
def csv_integer_overflow() -> bytes:
335+
"""A value past int64 range alongside ordinary ints.
336+
337+
Why: silent wraparound or precision loss on identifiers/large counts. Polars
338+
falls back i64 → Int128 on overflow; with an explicit narrow type an
339+
out-of-range value should be a value-error, not a silent wrap.
340+
"""
341+
return b"big\n1\n2\n99999999999999999999999\n"
342+
343+
344+
# --------------------------------------------------------------------------- #
345+
# 23. Naive timestamp string with a tz-aware schema pin (#145)
346+
# --------------------------------------------------------------------------- #
347+
def csv_naive_ts_pinned_utc() -> bytes:
348+
"""Naive timestamp strings (no tz offset) with a schema that pins ``timestamp('UTC')``.
349+
350+
Why: the timestamp scale → polars time_unit mapping (#145) resolved the
351+
rounding direction but left open a factual question: when polars is given a
352+
naive string and a tz-aware ``Datetime(unit, time_zone='UTC')`` override,
353+
does it (a) *attach* UTC (reinterpret the value as UTC, no shift), (b)
354+
*convert* from local time to UTC (shift the clock), or (c) raise?
355+
356+
Verdict (polars 1.x, verified 2026-06-26): **attach** — ``2024-01-15
357+
10:30:00`` becomes ``2024-01-15T10:30:00+00:00`` with no shift. tallyman
358+
inherits this semantics: pinning ``timestamp('UTC')`` on a naive-string
359+
column is a declaration that the values *are* UTC, not a conversion request.
360+
"""
361+
return b"ts,val\n2024-01-15 10:30:00,1\n2024-06-20 08:00:00.123456,2\n"
362+
363+
364+
# --------------------------------------------------------------------------- #
365+
# Registry — every dastardly case, for the parametrized corpus sweep.
366+
# (Builders are also importable individually, buckaroo-ddd style.)
367+
# --------------------------------------------------------------------------- #
368+
ALL_CASES: list[Callable[[], bytes]] = [
369+
csv_embedded_newline,
370+
csv_embedded_newline_in_header,
371+
csv_bom_utf8,
372+
csv_bom_on_quoted_header,
373+
csv_latin1,
374+
csv_decimal_comma,
375+
csv_thousands_separator,
376+
csv_mixed_type_late,
377+
csv_all_null_column,
378+
csv_leading_zeros,
379+
csv_scientific_notation,
380+
csv_duplicate_headers,
381+
csv_empty_header_cell,
382+
csv_headerless_all_string,
383+
csv_trailing_delimiter,
384+
csv_ragged_short,
385+
csv_ragged_long,
386+
csv_quoted_vs_bare_empty,
387+
csv_leading_whitespace,
388+
csv_mixed_newlines,
389+
csv_nul_byte,
390+
csv_over_long_line,
391+
csv_ambiguous_date,
392+
csv_integer_overflow,
393+
csv_naive_ts_pinned_utc,
394+
]

0 commit comments

Comments
 (0)