Repository navigation
fix(csv): a zone on offset-less CSV text is attached to the wall-clock time (#231) - #244
Conversation
Seven failing tests in test_tallyman_read_csv_contract.py, each importing a
one-column CSV under a zoned timestamp schema and reading the snapshot back
with pyarrow:
- 09:30 under America/New_York is stored as 09:30-05:00 in January and
09:30-04:00 in July (today 04:30-05:00 and 05:30-04:00).
- the same with timestamp('America/New_York', 9) keeps every ns digit
(today the digits survive but the instant is shifted by five hours).
- 2024-03-10 02:30, which New York skips, raises a ValueError naming the
column, the value and its row (today it is stored as 21:30 the day before).
- 2024-11-03 01:30, which New York passes twice, raises the same way
(today it is stored as 21:30 the day before).
- a column mixing text with and without a UTC offset raises an error that
says so and names a row of each kind, whether the offset comes second,
first, or 50,000 rows in (today a generic "the schema does not parse"
error that suggests reading the column as a string).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…231) polars' CSV reader, given pl.Datetime(unit, time_zone=tz), parses text with no UTC offset as UTC and converts it into the zone. A zoned column is now read as text and parsed with str.to_datetime(time_unit, time_zone=tz), which attaches the zone to offset-less text and converts text that has an offset, as ADR-005 D9(a) says. The declared unit is kept (#145). It raises instead of shifting a value when offset-less text names a time the zone skips or repeats at a DST change, and when a column mixes text with and without an offset: the streaming engine infers one format per column from its first non-null value and parses every later batch with it. polars 1.40.1's streaming parse writes a batch as nulls, without an error, when that first value matches no format (fixed upstream in pola-rs/polars#28986). The first non-null value of each zoned column is parsed eagerly before the read, which raises in exactly that case. After a failed read, _explain_zoned_failure reads the zoned columns again as text and names the column, the row and the value: a mixed column, a skipped or repeated wall-clock time, or text that is not a timestamp. The ladder now also catches InvalidOperationError, which is how str.to_datetime reports text that does not match the column's format. Guard tests, which pass before and after: offset text converts, UTC reads offset-less text as UTC, a naive timestamp stays naive, a zoned column renamed by position beside inferred columns, and text that is not a timestamp raises as the first value or a later one. ADR-005 D9 gets an implementation note: (a) and (b) were both wrong about polars 1.40.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ty strings (#231) Found in review of #244. Reading a zoned column as text broke two CSVs that imported before it: - a header named `row` collides with the row index the zoned-column checks add, and the import fails with a raw polars DuplicateError; - with `missing_utf8_is_empty_string=True`, an empty zoned cell reaches the parse as "" instead of null, and the import fails with "'' is not a timestamp". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed import (#231) Found in review of #244. - `_zoned_text` adds its row index after selecting the one column it reads, so a column of the file's own named `row` no longer collides with it (a raw polars DuplicateError before). - `_zoned_text_value` turns an empty cell of a zoned column into null before the parse and the checks. `missing_utf8_is_empty_string` is meant for string columns, and a zoned column is read as a string now, so without this an empty cell became "" and failed as "not a timestamp". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review summary
A zoned column is one whose schema type carries a time zone, such as Fixed in this PR
Tests in 4d38c3e: Deferred to #257
Not a bug: the review also checked that text which is not a timestamp still raises past the first value with 1 and 8 threads, that mixed text formats in one column (date only, no seconds, 🤖 Generated with Claude Code |
…implemented Brings in #242, #244, #245 and #241. Resolves docs/installing.md: keeps this branch's environment table (with TALLYMAN_COMPANION_URL now read from the data dir's server.lock) and its troubleshooting list, with #241's two bullets in place of the old port-7860 one, and without the stray tags this branch had already removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stacked on #189: the base is
feat/adr-007-009-cache-redesign, notmain. Fixes #231.Terms: a zoned column is one whose schema type is a timestamp with a time zone, such as
timestamp('America/New_York'). Offset-less text is a timestamp with no UTC offset at its end (2024-01-02 09:30:00); text with an offset ends in+00:00,-0500,Zand so on. A source's snapshot is the parquet file the import writes (ADR-011 D1), so since ADR-011 the CSV reader's output is baked into the source version.Problem
_polars_dtype(io.py:128-129) passes a zoned type to polars' CSV reader aspl.Datetime(unit, time_zone=tz). The reader parses offset-less text as UTC and converts it into the zone, so09:30under New York was stored as04:30-05:00in January and05:30-04:00in July. ADR-005 D9(a) says the zone is attached to the wall-clock time. Nothing failed: every row was off by four or five hours.Fix
A zoned column is read as text and parsed after the scan with
str.to_datetime(time_unit, time_zone=tz, ambiguous="raise")(io._parse_zoned). That call does what D9(a) says: text without an offset gets the zone attached, and text with an offset is an instant and is converted into the zone. The declared unit is kept, sotimestamp('America/New_York', 9)keeps every ns digit (#145). A naive timestamp and every other type still go through polars' reader as before.Decisions the issue left open:
2024-03-10 02:30never happens in New York, and2024-11-03 01:30happens twice. polars raises on both (ambiguous="raise", and a skipped time raises by default), so no value is shifted to get past it.timestamptzinput), but raising is the conservative choice and costs no extra code: polars' streaming engine infers one format per column from its first non-null value in file order and parses every later batch with it (thestrptime-infernode, perf: Streamingstrptimewithformat=Nonepola-rs/polars#27056), so the other kind fails wherever it first appears.test_a_zoned_column_mixing_offset_and_offsetless_text_raises[offset-late]puts the offset 50,000 rows in, past the first batch, and I checked 36 layouts (4 splits × 3 batch sizes × 1, 2 and 8 threads) by hand: all raise._POLARS_TMP_0) and suggest options the importer cannot pass (strict=False,non_existent='null'). After a failed read,_explain_zoned_failurereads the zoned columns again as text and reports the first mixed row, the first skipped or repeated time, or the first value that is not a timestamp. For example:column 'ts' is a timestamp with the zone 'America/New_York', where text with no UTC offset is a wall-clock time, and '2024-03-10 02:30:00' (row 3) does not exist in America/New_York: the clocks skip it at a daylight-saving change, so no instant matches it. tallyman does not pick an instant for it. Correct the value, or write it with its UTC offset.Anything it cannot explain falls through to the existing "the schema does not parse" error with its suggested schema.A polars 1.40.1 bug this has to work around
The streaming
strptime-infernode in polars 1.40.1 (installed) writes a batch as nulls, with no error, when the first non-null value it sees matches no datetime format. A zoned column whose first value washelloimported as[null, null], dropping the good value after it too; polars' reader, which the code used before, raised. Upstream fixed it in pola-rs/polars#28986 (apolars_ensure!when strict). The value the node infers from is the column's first non-null value in file order, so_unreadable_first_zoned_valueparses that one value eagerly before the read and raises when it is not a timestamp.test_text_that_is_not_a_timestamp_raises_in_a_zoned_column[first-value]fails without that check.Deviation from the suggested fix
The issue suggests reading the column naive and then calling
.dt.replace_time_zone(tz). polars' reader under a naiveDatetimeoverride accepts offset text, converts it to UTC and drops the offset, so after a naive read nothing says which rows had one:2024-07-02T09:30:00-04:00comes back as naive13:30, and attaching New York would store13:30-04:00. Reading the text and parsing it withtime_zone=tzkeeps "text with an offset still converts" true.Found while here
timestamp(9),timestamp('UTC', 9)and (with this fix)timestamp('America/New_York', 9)end to end throughupdate_and_depend: all keep.123456789. The red run shows the same for the zoned column before the fix: the digits survive and only the instant is shifted. ADR-005 D9 gets an implementation note saying (a) and (b) were both wrong about the installed polars.timestampover offset text converts it to UTC and drops the offset, silently. This is the same class of bug for naive columns, and this PR does not change it.Execution sites: none added in #118's sense (
.execute()/to_pyarrow_batches()). New polars.collect()calls: onehead(1)per zoned column before the read, and the queries in_explain_zoned_failure, which run only after a read has failed.Tests
All in
tests/test_tallyman_read_csv_contract.py. Each imports a CSV throughupdate_and_dependand reads the snapshot back with pyarrow.Failing before the fix:
test_a_zone_attaches_to_offsetless_text:09:30is09:30-05:00in January and09:30-04:00in July, and an empty field stays null.test_a_zoned_nanosecond_column_keeps_its_digits:timestamp('America/New_York', 9)keeps the type and every digit of09:30:00.123456789.test_a_wall_clock_time_the_zone_skips_raisesandtest_a_wall_clock_time_the_zone_repeats_raises.test_a_zoned_column_mixing_offset_and_offsetless_text_raises, three layouts: offset second, offset first, and offset at row 50,001.Guards, which pass before and after and are in the fix commit:
test_offset_text_converts_into_the_zone:+00:00in January becomes04:30-05:00,Zin July becomes05:30-04:00.test_utc_reads_offsetless_text_as_utc, with a leading null.test_a_naive_timestamp_stays_naive.test_a_zoned_column_renamed_by_position_beside_inferred_columns: the parse is keyed on the header name and runs before a positional rename, in inference mode.test_text_that_is_not_a_timestamp_raises_in_a_zoned_column, as the first value and as a later one. With the eager first-value check removed the first case fails (the import succeeds with nulls); withInvalidOperationErrordropped from the ladder'sexcept, the later case and the three mixed cases fail with a raw polars error.How it was built
9243c9badds the seven failing tests. CI run 36017271086: ruff passed, and the fast suite had 7 failed and 954 passed. The seven failures were exactly the new tests: the two value tests on the shifted instant, the two DST tests onDID NOT RAISE, and the three mixed cases on the message (the old error blamed the schema, named the clone's hashed file name and suggested reading the column as a string).71a93c2adds the fix, the guards and the ADR note. CI run 36019495448: ruff, the fast suite (967 passed, and vitest 15 passed) and the integration suite (7 passed) all pass.Checked locally before each push: the seven tests fail on the unfixed
io.pyfor the reasons above and the guards pass on it; with the fix the fast suite had 961 passed (test_fouc::test_unknown_api_path_404sanswers 503 until the SPA is built in the worktree, and passed afterpnpm build), the integration suite 7 passed, anduvx ruff checkpassed. The repo has no.pre-commit-config.yaml; the new code isruff formatclean, and I did not reformat the pre-existing lines inio.pyand the test file thatruff formatwould change. A 2,000,000-row import took about the same time with a naive, a UTC and a New York column (1.0 s, 0.6 s, 0.6 s, one run each).Not in this PR
timestampover offset text (above).Datetimetotimestamp, so for a zoned column it suggests dropping the zone.e7e909b3….csv), not the path the user imported.🤖 Generated with Claude Code