|
| 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