chtypes 0.2.2__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. chtypes-0.3.1/CHANGELOG.md +129 -0
  2. {chtypes-0.2.2 → chtypes-0.3.1}/PKG-INFO +4 -2
  3. {chtypes-0.2.2 → chtypes-0.3.1}/README.md +3 -1
  4. {chtypes-0.2.2 → chtypes-0.3.1}/pyproject.toml +1 -1
  5. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/__init__.py +2 -2
  6. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/_document.py +27 -3
  7. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/_manifest.py +38 -8
  8. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/_native.py +108 -8
  9. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/discover.py +12 -22
  10. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/fetch.py +131 -54
  11. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/registry.py +305 -25
  12. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/results.py +78 -2
  13. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/transform.py +14 -2
  14. {chtypes-0.2.2 → chtypes-0.3.1}/tests/conftest.py +8 -2
  15. chtypes-0.3.1/tests/test_csv_reader.py +445 -0
  16. chtypes-0.3.1/tests/test_discover_reconstruct.py +91 -0
  17. {chtypes-0.2.2 → chtypes-0.3.1}/tests/test_errors.py +1 -1
  18. {chtypes-0.2.2 → chtypes-0.3.1}/tests/test_fetch.py +161 -4
  19. chtypes-0.3.1/tests/test_formats_withnames.py +458 -0
  20. chtypes-0.3.1/tests/test_golden.py +262 -0
  21. chtypes-0.3.1/tests/test_lazy.py +251 -0
  22. chtypes-0.3.1/tests/test_quoting_boundaries.py +235 -0
  23. {chtypes-0.2.2 → chtypes-0.3.1}/tests/test_registry.py +67 -19
  24. chtypes-0.3.1/tests/test_results_rules.py +150 -0
  25. chtypes-0.3.1/tests/test_rows_export_with.py +133 -0
  26. chtypes-0.3.1/tests/test_transform.py +231 -0
  27. chtypes-0.3.1/uv.lock +108 -0
  28. chtypes-0.2.2/CHANGELOG.md +0 -74
  29. chtypes-0.2.2/tests/test_golden.py +0 -146
  30. chtypes-0.2.2/tests/test_transform.py +0 -82
  31. chtypes-0.2.2/uv.lock +0 -108
  32. {chtypes-0.2.2 → chtypes-0.3.1}/.gitignore +0 -0
  33. {chtypes-0.2.2 → chtypes-0.3.1}/LICENSE +0 -0
  34. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/__main__.py +0 -0
  35. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/_ed25519.py +0 -0
  36. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/_rawjson.py +0 -0
  37. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/errors.py +0 -0
  38. {chtypes-0.2.2 → chtypes-0.3.1}/src/chtypes/py.typed +0 -0
  39. {chtypes-0.2.2 → chtypes-0.3.1}/tests/test_abi_revision.py +0 -0
  40. {chtypes-0.2.2 → chtypes-0.3.1}/tests/test_cli.py +0 -0
  41. {chtypes-0.2.2 → chtypes-0.3.1}/tests/test_ed25519.py +0 -0
  42. {chtypes-0.2.2 → chtypes-0.3.1}/tests/test_parity.py +0 -0
  43. {chtypes-0.2.2 → chtypes-0.3.1}/tests/test_rawjson.py +0 -0
@@ -0,0 +1,129 @@
1
+ # Changelog — `chtypes`
2
+
3
+ All notable changes to the python binding. The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
+
5
+ The four bindings in this repository are released together and give one answer, so an entry here has a counterpart in the other three.
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.3.1] — 2026-09-26
10
+
11
+ Speaks ABI revision 5, unchanged from 0.3.0: every artifact 0.3.0 loads, this release loads.
12
+
13
+ ### Changed
14
+
15
+ - **The publish-window retry (`docs/guides/fetch.md` §3a) now also covers a release-level file whose hash disagrees with its `SHA256SUMS` row** — `sdk-goldens.json`, checked after the requested artifact has already installed, since it is best-effort and never blocks that install — and the retry budget widened from 3 attempts ~10s apart to 5 attempts with delays doubling from 4s (4/8/16/32s, ~60s of sleep, ~70s of wall time with network latency). The artifacts host's edge cache can hold a stale pairing of `SHA256SUMS`, its signature, `index.json` or `sdk-goldens.json` for up to its measured 60-second `Cache-Control: max-age`, wider than the original ~10s budget covered. Every retry now re-reads the WHOLE consistent set fresh — never one freshly re-fetched object checked against another attempt's stale one. A release-level file mismatch that never heals still leaves the golden tests skipping loudly, exactly as before, and never fails the fetch that asked for the artifact (issue #223).
16
+
17
+ ## [0.3.0] — 2026-09-22
18
+
19
+ ### Added
20
+
21
+ - **Identifier and literal quoting now come from ClickHouse itself: `QuoteIdentifier`, `QuoteIdentifierIfNeeded` and `QuoteLiteral` on a loaded library** (issue #52; each binding spells the three in its own idiom — the parity contract's `library.quote-*` rows carry the four spellings). Each is a passthrough over `chs_quote_identifier`, `chs_quote_identifier_if_needed` or `chs_quote_literal` — the vendored `backQuote`, `backQuoteIfNeed` and `quoteString` that the server's own formatter runs — and nothing else. **`QuoteLiteral` is new surface**: a string value as a ClickHouse string literal, quotes and escapes included, which is exactly what a caller splicing a value into DDL text needed (the value-injection recipe in `docs/guides/transformations.md` told the caller to do it themselves, because no binding exported it). Inputs are **counted**, not NUL-terminated, so a value carrying a NUL byte is quoted correctly; the answer is escaped and therefore NUL-free. They hang off a loaded library rather than off the package because the answers are the LOADED BUILD's own, and `QuoteIdentifierIfNeeded`'s differ between ClickHouse lines — `all`, `distinct`, `table` and `null` are back-quoted on every line this release was tested against, `select` from 26.3, `from` and `values` from 26.5, and `where` stays bare throughout. That is the server's own list of names it treats as problematic unquoted, not a keyword class: do not read it as one. The three symbols joined ABI revision 5 without a revision bump, so an artifact built from a revision-5 header that predates them still reports revision 5 and does not export them; all four bindings degrade to their own "this artifact predates the symbol" decline rather than failing to load. **The per-line answers are not pinned by any test in this repository yet** — they need a revision-5 artifact, none of which is fetchable, and are deferred (#119).
22
+ - **This binding now speaks ABI revision 5.** `Schema.row`, `Schema.rows` and `Schema.parse_block` gain a keyword-only `columns` argument (a sequence of column names) — the INSERT column list: the data supplies exactly those columns, in list order for the positional formats, and the server computes the rest with the listed values in scope for their DEFAULT expressions. `None` or an empty sequence is the no-list behavior of every earlier revision and is marshaled to NULL, never to `"[]"` (an empty column list renders as `INSERT INTO t () FORMAT X`, which is code 62 `SYNTAX_ERROR` on every served ClickHouse line — measured 2026-09-15). A listed `EPHEMERAL` column's value is read and is in scope for the DEFAULTs that reference it, and is still never stored and never exported. This binding does not validate column names locally: an unknown column, an `ALIAS` column, and a repeated name are all refused by the server, with its own codes (16, 16 and 15 respectively), surfaced exactly as they come back.
23
+ - This release **requires revision-5 artifacts**. A revision-4 artifact (or any artifact reporting a `chs_abi_revision()` other than 5 or 0) is refused at load, naming both the binding's revision and the artifact's.
24
+ - **`Source.EPHEMERAL_INPUT` / `Source.MATERIALIZED_INPUT`** name `columns_json`'s two new `Value.source` provenances (revision 5, issue #53): a listed `EPHEMERAL` column's read value (`"ephemeral_input"`) and a listed `MATERIALIZED` column's supplied value under `insert_allow_materialized_columns=1` (`"materialized_input"` — stored, replacing the expression). `Source` is a plain class of `Final` strings, not an `Enum`, the same shape as `Reason` — for the same reason: an unrecognized `src` from a newer artifact must pass through, not raise. **The two get opposite treatment in `RowResult.values`**: `"ephemeral_input"` is never stored, so it is excluded from `values` exactly as `"skipped"` already is — reported only through the per-column result document, never through `values`. `"materialized_input"` IS stored, so it stays IN `values`. The artifact-backed behavior tests (a listed EPHEMERAL/MATERIALIZED column actually producing these values end to end) need a revision-5 artifact, none of which is fetchable yet, and are deferred; the `values` membership rule itself was decidable without one, since "never stored" is definitional rather than something an artifact reveals.
25
+ - **`Source.SKIPPED`** names the `Value.source` provenance the `"skipped"` string has always carried (`MATERIALIZED`/`ALIAS`/`EPHEMERAL`, never read from an input row). The exclusion it names from `RowResult.values` predates revision 5 and was already correct — `_row_result` has special-cased it from the start — it simply had no named constant, only the bare string literal `"skipped"` at the one call site that checks it. Added to the `Source` class alongside `EPHEMERAL_INPUT` / `MATERIALIZED_INPUT`, in the same idiom, so the parity manifest's `results.skipped-excluded-from-values` rule can be keyed on a real symbol instead of four bindings agreeing on a bare literal by coincidence (issue #90). No behavior change: `_row_result` compares against the same string either way.
26
+ - **`Source.DEFAULT_SUBSTITUTED`** names the `Value.source` provenance the `"default_substituted"` string has always carried: a VOLATILE DEFAULT this library resolved locally, which the caller MUST echo as an explicit column on any later INSERT. `_row_result` has populated `RowResult.substituted` for exactly this src value from the start; like `Source.SKIPPED` before it, it simply had no named constant, only the bare string literal `"default_substituted"` at the one call site that checks it. Added to the `Source` class in the same idiom, so the parity manifest's `results.default-substituted-populates-substituted` rule can be keyed on a real symbol instead of four bindings agreeing on a bare literal by coincidence (issue #122). No behavior change: `_row_result` compares against the same string either way.
27
+ - **`Schema.rows` gains `row_filter=`** (revision 5, second half — issue #54, `docs/guides/filters.md` §Exporting only the rows a filter admits): attach a compiled `Filter` to the export channel and get ONE `chs_rows` parse that answers both a per-row filter verdict (`RowResult.verdict`, the same `Verdict` `Filter.rows` already answers) and, for `Verdict.TRUE` rows, the export bytes. `Verdict.ERROR` (the predicate threw) and `Verdict.DECLINE` (declined — including a row whose own `outcome` was not `Outcome.ACCEPTED`) are NEVER exported and NEVER collapsed into `Verdict.FALSE`: collapsing either inverts fail-closed into fail-open, the leak class this vocabulary exists to prevent. `BatchResult.rows_passed`/`rows_cut` join the result. A filter compiled over a different `Schema` of the same loaded library rejects the whole call, loudly (code 1002); one from a different loaded library raises `ChtypesError` before any C call. The lifetime mechanism is the one `compile_filter` already established — reused, not duplicated. The artifact-backed behavior tests need a revision-5 artifact, none of which is fetchable yet, and are deferred; the decode rule and the cross-library refusal were both decidable without one, and are unit-tested against hand-built documents and fake handles.
28
+ - **`CSVWithNames` = 10 and `TSVWithNames` = 11 join the `chs_format` set** (issue #55; ClickHouse's own format names, which each binding spells in its own idiom). **Both numbers are frozen from the day they ship**, like every other `chs_format` value: they are what crosses the C boundary, and they are never renumbered. Each is CSV / TSV whose first row is a header naming the columns, so the data is addressed by name rather than by position. With an INSERT column list as well, the list decides the block and the header decides the layout: a listed column the header omits takes its `DEFAULT` under `input_format_defaults_for_omitted_fields=1` and the reader's zero under `0`, an unlisted column the header names is an unknown field, and an unlisted column takes its `DEFAULT` whatever that setting says. **Header-name matching differs by ClickHouse line: exact through 26.4, case-insensitive from 26.5**, exactly as those servers do, so the same header can bind differently on either side of that boundary. As an export format both are the existing loud decline. They joined the set inside ABI revision 5 without a revision bump, so an artifact built from a revision-5 header that predates them still reports revision 5 and does not know them: **probe the artifact before declaring either format** (`docs/reference/bindings.md` §Values a binding must accept and reject). The round trip through a real artifact needs a revision-5 artifact that supports them, none of which is fetchable yet, and is deferred (#119).
29
+
30
+ ### Changed
31
+
32
+ - **BREAKING: `QuoteIdentifier` ALWAYS quotes**, and it is a call into the loaded library rather than a function of this binding (issue #52). It used to quote only where it decided quoting was needed, from a hand-written copy of ClickHouse's rule living in the binding; that copy is deleted, not kept as a fallback, because two implementations of one rule is how it came to disagree with the server in the first place. **This changes the bytes a caller gets back** — a name that used to come back bare now comes back quoted — so it can move a cached key, a golden file or a stored test expectation even where the SQL means the same thing. **The one-line migration for a caller who genuinely wanted the old shape is `QuoteIdentifierIfNeeded`**, which is that behavior under a name that says so; read the next two entries before assuming it is byte-for-byte what you had.
33
+
34
+ - **`QuoteIdentifierIfNeeded` returns `` `all` ``, `` `distinct` ``, `` `table` `` and `` `null` `` back-quoted**, where the hand-written copy returned all four bare. The server back-quotes them on every ClickHouse line this release was tested against, so the copy was simply wrong, and a bare `null` is exactly the shape that parses as something else. This is a behavior change **on top of** the breaking one above: a caller who migrates from `QuoteIdentifier` to `QuoteIdentifierIfNeeded` to keep the old shape still gets different bytes for these four names. Which further names it quotes is the loaded build's own rule and moves between lines (`select` from 26.3, `from` and `values` from 26.5, while `where` stays bare), so an answer cached across versions was never safe and is now visibly so.
35
+
36
+ - **`QuoteIdentifier` emits the BACKSLASH spelling for an embedded back-quote** — `` `a\`b` `` — where the deleted copy doubled it (``` `a``b` ```). Both spellings parse, on every line, and both round-trip to the same column name; only one is what the server itself prints. That is how two hand-written quoters can each look correct and still disagree, and it is the same failure that produced the `null` divergence above. A caller comparing quoted text against a stored string, rather than passing it straight into DDL, must expect the new spelling.
37
+
38
+ - **BREAKING: reconstructing DDL from discovered columns is a method on a loaded library.** It was a free function; it is not one any more, because the single thing it spells — the column NAME — is now spelled by the library's own `QuoteIdentifier` instead of by a rule in the binding. Resolve the artifact first (the server's own version string already decides which one) and call reconstruction on it; the arguments, the return and every refusal are unchanged, and the declaration list it produces differs only in how the names are spelled. This was not in the issue's own scope — it is the unavoidable consequence of deleting the hand-written rule, since nothing else in the binding could spell a name afterwards.
39
+
40
+ - **BREAKING: loading is lazy by default, and `preload` is the one eager path.** Constructing a `Registry` already read manifests and `dlopen`ed nothing — but `libraries()` did not: it opened every discovered line, roughly 120 MB of artifact each, to answer a question about what the registry knows. **`libraries()` now lists what is OPEN and opens nothing**, which is what Go, TypeScript and Rust have always answered, and which also fixes `__iter__`, whose every use inherited those loads. The rule, now true of every surface here: nothing in this library opens an artifact except a request for a specific version, or an explicit `preload` — not `versions()`, not `libraries()`, not `in`, not `len()`, not `repr()` (#50).
41
+
42
+ **What a caller who relied on `libraries()` loading must do:** ask for the lines. `for line in registry.versions(): registry.for_version(line)` opens them, or `Registry(dir, preload=["25.8", "26.7"])` opens a named set in order before the constructor returns. `preload` is deliberately a list rather than "everything in the directory", because a registry directory is whatever a fetch left behind. An entry no directory on the search path holds is `ArtifactMissingError` at construction — the same §7 error the first `for_version` would have raised, raised earlier — and it never fetches, even with `autofetch=True`.
43
+
44
+ - **BREAKING: two new construction errors, both decidable from manifests alone.** A search path on which no directory holds a readable `<minor>/manifest.json` is now `RegistryError` at construction, naming every directory looked in; this was the one binding where `Registry()` could succeed against a completely empty machine and report the miss three calls later. And a directory the CALLER NAMED that does not exist is now an error too, as it already was in Go and TypeScript, rather than being skipped in silence. Both are suppressed with `autofetch=True`, where the directory is the destination the first fetch creates, and `for_version` still re-scans, so a line installed after construction keeps working (#50).
45
+
46
+ - **`verify_hashes` is unchanged, and the timing is now stated: a library's checksum is computed immediately before that library is `dlopen`ed, and at no other time** — at construction for the `preload`ed lines, at first use for the rest, never for a line nobody asks for. It is a policy on the registry, not a property of the preload list: a lazily-opened line is hashed too (#50).
47
+
48
+ - **BREAKING: `library_bytes` is now checked on EVERY load, not only under `verify_hashes`** (issue #82, split out of #50 so the behavior change was not hidden inside a larger one). The size check — the file's actual length against the manifest's `library_bytes` — used to run only when verification was on, bundled inside `verify_library`'s hash comparison; Go and TypeScript agreed with Python on that, and Rust did not, checking bytes unconditionally on every load. It is nearly free (one `stat`, never a re-hash of the library's contents) and it catches the commonest shape of a broken artifact directory — a truncated or partially-written library file — so all four bindings now check it unconditionally, and Python stops being three of the four that didn't. The new standalone `check_library_bytes` carries this half; `verify_library` calls it too, so its own behavior is unchanged for a caller who invokes it directly.
49
+
50
+ **What changes for a caller who never asked for verification:** an artifact directory whose manifest carries a `library_bytes` that does not match the file on disk now fails the load (`for_version`, and `preload`, which routes through it) with `chtypes: <path> is <n> bytes, manifest says <m>`, refused before `dlopen` is ever attempted. Before this change the same directory reached `dlopen` regardless — usually failing there too, with a less specific error, though a stale-rather-than-corrupt manifest could pass through unnoticed. A manifest with no `library_bytes` at all (`0`, its default for one that predates the field) is unaffected either way: this was never, and is still not, a reason a load fails for a directory with no size to check against.
51
+
52
+ **Timing, read together with `verify_hashes`'s rule above:** the size check runs at the exact same point the hash check does — immediately before `dlopen`, at construction for `preload`ed lines and at first use for the rest — because `_load` is the one place both checks live, whether or not verification is on.
53
+
54
+ - **CSV and TSV rejection messages are now ClickHouse's own text** (with revision-5 artifacts). The error **codes are unchanged**, and so is every verdict and every stored value; only the human-readable message moved, because CSV and TSV rows are now read by ClickHouse's own readers rather than hand-written splitters. A CSV row the reader rejects now carries an **empty `cols` list**, where the previous path could report a partial one. **Match on the error code, never on the message text** — the message is ClickHouse's to change between releases.
55
+
56
+ - **An empty CSV field under `input_format_defaults_for_omitted_fields=0` is now reported as an input, and its coercion as a transform** (with revision-5 artifacts). Where the previous reader reported no transform, the detector now reports one, because the field's reference value is the empty string rather than absent. The reason follows the column's type — measured as `enum_coerce` for an `Enum`, `fixedstring_pad` for a `FixedString`, and `uuid_mangle` for a `UUID` (the zero UUID). **Stored values are unchanged** — this changes what is reported, not what is stored. A caller that reads "no transform" as "the value was not changed" should expect these rows to report one. Under the default setting (`1`) an empty field still takes the column's `DEFAULT`, as before. **TSV is not affected:** its reader takes an empty field through the typed parse whether the setting is on or off, exactly as the previous splitter did.
57
+
58
+ - **An unlisted column now takes its `DEFAULT` regardless of `input_format_defaults_for_omitted_fields` when a column list is present** (with revision-5 artifacts). With an INSERT column list, a column the list does not name is computed from its `DEFAULT` expression under `input_format_defaults_for_omitted_fields=0` as well as under the default `1`. This is a fix in the artifact that rides revision 5, and it applies to **every format**, not only `CSVWithNames` and `TSVWithNames`.
59
+
60
+ - **The rolling artifacts channel now serves ABI revision 5.** A 0.2.x consumer that fetches from the default channel will download an artifact its bindings **refuse at load** — correct behavior that looks exactly like a broken install. ⚠️ **Pinning an exact ClickHouse version does not avoid this**: a relink republishes the same `clickhouse_version`, and an exact request resolves to the highest-ranked matching row, which is the revision-5 one. The only way to stay on revision 4 is `--url` / `--tag` pointed at a source that still serves revision-4 artifacts, if one is retained. **Upgrading to 0.3.0 is the supported path.**
61
+
62
+ ## [0.2.2] — 2026-09-17
63
+
64
+ ### Changed
65
+
66
+ - **`Registry(..., verify_hashes=True)` now refuses an artifact whose manifest carries no `library_sha256`, instead of loading it silently.** This is a behavior change in a security posture, not a silent fix: previously, asking for verification against a manifest with the field absent returned as if the bytes had been checked, when they never were — the caller believed the load was verified and it was not. `verify_library` now raises `RegistryError` naming the path and saying the manifest carries no `library_sha256`, the same shape its hash-mismatch path has always raised. `verify_hashes` off is unaffected: the field stays optional for a caller who did not ask. Go, TypeScript and Rust already refused this case; this was the one gap issue #13's item A2 left (#48).
67
+
68
+ ### Notes
69
+
70
+ - Speaks **ABI revision 4**, unchanged since 0.1.0, so no artifact needs relinking. The golden set this release was tested against is the one core serves, generated on the ClickHouse lines 24.8, 25.3, 25.8, 25.10, 26.2, 26.3, 26.4, 26.5, 26.6, 26.7 and 26.8.
71
+ - Seven facts about the compiled library's behavior that a consumer previously had to discover by experiment are now written down: `CHECK`-constraint batch rejection, binding filter parameters as `String`, single-line compact JSON array loss under `allow_errors`, the JSONCompactEachRow export field separator, the value-injection pattern and its compile-time wrap trap, `Columns` as canonical and in declaration order, and the frozen-signatures promise reworded ahead of the next ABI revision (#56). That promise is now stated once, in `docs/support.md`, and linked from everywhere else rather than restated in nine places (#69).
72
+
73
+ ## [0.2.1] — 2026-09-15
74
+
75
+ Released in step with the Rust crate, which gains `Registry::open` and `Error::LibraryRead`; this binding's public API is unchanged.
76
+
77
+ ### Notes
78
+
79
+ - Speaks **ABI revision 4**, unchanged since 0.1.0, so no artifact needs relinking. The golden set this release was tested against is the one core serves, generated on the ClickHouse lines 24.8, 25.3, 25.8, 25.10, 26.2, 26.3, 26.4, 26.5, 26.6, 26.7 and 26.8.
80
+ - **The ABI-revision refusal is now run, not assumed.** This binding's suite points a registry at a generated artifact answering the wrong revision and asserts the load is refused naming both numbers, beside a matching-revision control that must load; CI fails the build if either case did not run (#36).
81
+
82
+ ## [0.2.0] — 2026-09-15
83
+
84
+ ### Changed — BREAKING
85
+
86
+ - **The TypeScript filter verdicts are the wire characters.** `Verdict.True` / `False` / `Error` / `Decline` are now `'t'` / `'f'` / `'e'` / `'d'`, which Go, Python and Rust have always rendered. TypeScript alone spelled them `'true'` / `'false'` / `'error'` / `'decline'`, so two SDKs could not share a log line, a fixture or a test. The four states and the fail-closed rule are unchanged: `'e'` and `'d'` are NOT answers, and a caller enforcing visibility must hide the row or fail the request on both — collapsing either into `false` inverts fail-closed into fail-open, the measured leak class. Only the rendered characters moved. **The parity suites now ENFORCE the shared value in all four bindings**; until this release two of the four merely declared it.
87
+
88
+ - **TypeScript `FetchError` now extends `ArtifactError` (and so `RegistryError`), where it previously extended `ChtypesError` directly.** A `try`/`catch` chain that tests `RegistryError` before `FetchError` now takes the `RegistryError` arm for the five fetch verdicts, which it did not before. Ordering a catch chain most-specific-first is unaffected.
89
+
90
+ ### Added
91
+
92
+ - **Load-time checksum verification in Go and Rust**, matching what Python and TypeScript have always offered: re-hash the library against `manifest.json`'s `library_sha256` **before `dlopen`**, and refuse the load on a mismatch. Off by default in all four. Whether an artifact was re-hashed before being mapped used to depend on which binding a consumer picked, which is a security posture and not an API spelling. A manifest carrying no `library_sha256` is refused by Go, TypeScript and Rust — verification asked for and not possible is not verification — while Python's returns silently, which is what remains of issue #13's item A2.
93
+ - **One catchable artifact error in TypeScript.** `ArtifactError` is a new exported base: all six artifact conditions now extend it, where `ArtifactMissingError` previously sat under `RegistryError` and the other five under `FetchError`, so a caller had to catch two unrelated types. Every existing `code` value and message is unchanged, and `ArtifactMissingError instanceof RegistryError` still holds.
94
+ - **Eight constants that three bindings exported and the fourth did not**, so a caller no longer retypes a literal the other SDKs hand them: `Registry.SearchPath()` (go), `parse_signature_file` and `FETCH_COMMAND` (python), `RELEASE_KEY_ID` and `DEFAULT_LOCK_FILE` (ts), `fetch::LOCK_SCHEMA` (rust), and `CHTYPES_REGISTRY` / `CHTYPES_AUTOFETCH` as `EnvRegistry` / `EnvAutoFetch` (go) and `ENV_REGISTRY` / `ENV_AUTOFETCH` (ts). Each new name replaces the inline literal in its own binding's code path, so there is one definition rather than two.
95
+
96
+ ### Notes
97
+
98
+ - Speaks **ABI revision 4**, unchanged since 0.1.0, so no artifact needs relinking. The golden set this release was tested against is the one core serves, generated on the ClickHouse lines 24.8, 25.3, 25.8, 25.10, 26.2, 26.3, 26.4, 26.5, 26.6, 26.7 and 26.8.
99
+ - `Library` still has no scope-based release in Python or TypeScript, while `Registry`, `Schema`, `Filter` and `Block` do. That asymmetry is deliberate — a `with library:` or `using library` closes at the end of a block, which is the mid-lifecycle teardown measured to segfault on the next open — and it is now recorded in the parity manifest so no future audit reopens it as a gap.
100
+
101
+ ## [0.1.2] — 2026-09-11
102
+
103
+ ### Changed — BREAKING
104
+
105
+ - **The transform reason for a materialized DEFAULT is now `default_materialized`** — previously the same word spelled with an `s` — and the exported constant naming it is now `Reason.DEFAULT_MATERIALIZED`. ClickHouse's own keyword is `MATERIALIZED`; its parser rejects the `s` spelling outright with a syntax error, so the reason naming that concept now matches the system it describes. Code comparing against the old string or the old constant name must be updated.
106
+ - This is an SDK-only change and **the ABI is untouched: it remains revision 4, and no artifact needs relinking.** The reason is derived in the binding, not received from the artifact — the library emits `default_substituted`, which each binding translates. That wire value is unchanged.
107
+
108
+ ### Changed
109
+
110
+ - **The per-language README is now an orientation page, not a manual.** It carries install, a quickstart that runs, the three outcomes and the error model, and then points at `docs/` for the rest — roughly 450 lines of reference prose moved to `docs/guides/` and `docs/reference/` rather than being repeated four times and drifting four ways.
111
+ - **The published packages no longer cite a repository you cannot open.** 148 source comments — in every binding and in `include/chtypes.h`, all of which ship inside the crate, the sdist and the module — attributed the contract they implement to a private repository's specification. They now cite **the C ABI contract**, with `include/chtypes.h` as the public authority for it. Alongside those, 14 dead paths into that repository, the examples' instruction to build an artifact there, and two descriptions of the signing infrastructure are gone. What remains is every place where the two-repository split is itself the subject — describing it in prose is honest and stays legal; naming something a reader cannot open is not, and `scripts/lint-public.sh` is what enforces the difference. (An earlier draft of this entry enumerated three files. That count was never checked and was wrong by eighteen, which is the same mistake the entry above is about: the rule is checkable, a file list is not.)
112
+ - `scripts/lint-public.sh` is new and blocking in CI, because the sweep this replaces reported itself complete while 148 of its targets survived in a different shape. It refuses both the private repository's name and that exact prose pointer, and its `--selftest` proves each rule fires rather than assuming it.
113
+
114
+ ## [0.1.1] — 2026-09-11
115
+
116
+ - **The golden set is served, not tracked.** `goldens/cases.json` no longer exists in this repository. Core publishes `sdk-goldens.json` in the rolling release as a row in the signed `SHA256SUMS`; `scripts/fetch.sh` installs it at `<registry>/sdk-goldens.json` and the golden test reads it offline from there (`CHTYPES_GOLDENS` overrides). A case runs only against the exact ClickHouse version `generated.exact` names for its line and skips loudly otherwise, so the set shrinks as lines are added rather than recording an answer twice.
117
+ - **Artifact identity gained a wrapper build.** Assets are `chtypes-<version>-<os>-<arch>-b<N>.tar.gz`, where `<N>` is the core commit's UNIX timestamp; an absent suffix means build 0 and stays valid. Line resolution ranks by `(version, build)`, so a rebuild of a line supersedes the older build.
118
+ - Fetching now retries the publish window, so a fetch that races a release no longer fails on sums naming a file the host has not finished serving.
119
+ - README install instructions name the published package rather than a path dependency.
120
+ - Two errors in the published README, both found by running the snippets rather than reading them: Python's `substituted` is on `RowResult`, not `BatchResult`, and TypeScript's `using schema = …` is a syntax error on Node 22, this package's own `engines` floor (`schema.close()` is portable).
121
+ - `docs/support.md` is new: which language versions, platforms and ClickHouse lines are supported, generated from the manifests and the release's own index.
122
+
123
+ ## [0.1.0] — 2026-09-10
124
+
125
+ First release, published to PyPI from the tag `python/v0.1.0`.
126
+
127
+ - Speaks **ABI revision 4** of the frozen `chs_*` C ABI (`include/chtypes.h`, 28 functions). The binding `dlopen`s a per-version artifact and reimplements no ClickHouse semantics; `Transformed` is the one derived result.
128
+ - Verified against the public golden set (`goldens/cases.json`, schema 1, 31 cases), whose expectations were generated on ClickHouse 24.8, 25.3, 25.8, 25.10, 26.5, 26.6 and 26.7 — every case answered identically on all seven lines, and any case that did not was refused by the generator.
129
+ - Pre-1.0: the artifact name `libchtypes`, the `chs_` prefix and the `enum chs_format` numbers are frozen. Function signatures freeze at this tag.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: chtypes
3
- Version: 0.2.2
3
+ Version: 0.3.1
4
4
  Summary: Python binding for the chtypes artifacts: ClickHouse's own type system, per version, behind the frozen chs_* C ABI
5
5
  Project-URL: Homepage, https://github.com/wave-rf/chtypes
6
6
  Project-URL: Repository, https://github.com/wave-rf/chtypes
@@ -76,7 +76,7 @@ A bad **row** is a verdict, not an exception: `outcome` becomes `Outcome.REJECTE
76
76
  | [Filters](https://github.com/wave-rf/chtypes/blob/main/docs/guides/filters.md) · [Multi-version](https://github.com/wave-rf/chtypes/blob/main/docs/guides/multi-version.md) | boolean expressions over rows; several ClickHouse versions in one process |
77
77
  | [Support matrix](https://github.com/wave-rf/chtypes/blob/main/docs/support.md) · [Limitations](https://github.com/wave-rf/chtypes/blob/main/docs/limitations.md) | what works where; what chtypes declines to answer |
78
78
 
79
- ## Three things specific to this binding
79
+ ## Four things specific to this binding
80
80
 
81
81
  **A settings value must never be a `float`.** `encode_settings` stringifies an `int` exactly and raises `TypeError` on a `float`: a 19-digit `chtypes_now_epoch_nanos` does not survive an IEEE double, and as a JSON number the setting would be silently ignored.
82
82
 
@@ -84,6 +84,8 @@ A bad **row** is a verdict, not an exception: `outcome` becomes `Outcome.REJECTE
84
84
 
85
85
  **`ctypes` releases the GIL for the whole duration of a foreign call**, so the GIL is not the exclusion. The package uses a writer-preferring readers-writer lock per loaded image plus one plain lock per `Schema`; `set_default_settings` and `close` take it exclusively, as the ABI requires. Measured under contention: 27,770 batch reads across 8 threads against 566 concurrent settings swaps, every answer byte-identical to the uncontended one.
86
86
 
87
+ **The INSERT column list (ABI revision 5) is a keyword-only `columns` on the same calls** — `schema.row(fmt, raw, columns=["id", "e"])`, and the same argument on `Schema.rows` and `Schema.parse_block`. `None` or an empty sequence is the no-list behavior of every earlier revision; a list makes the data supply exactly those columns, with a listed `EPHEMERAL` value read and in scope for the DEFAULTs that reference it but never stored. This release requires a revision-5 artifact — a revision-4 one is refused at load, naming both revisions.
88
+
87
89
  ## Tests
88
90
 
89
91
  `uv run pytest -q`. Tests that need an artifact **skip loudly by name** without a registry on the search path, and a suite that ran nothing fails. The fetch suite runs offline against the miniature releases in `tests/fixtures/fetch/` through `file://` sources and the test key.
@@ -58,7 +58,7 @@ A bad **row** is a verdict, not an exception: `outcome` becomes `Outcome.REJECTE
58
58
  | [Filters](https://github.com/wave-rf/chtypes/blob/main/docs/guides/filters.md) · [Multi-version](https://github.com/wave-rf/chtypes/blob/main/docs/guides/multi-version.md) | boolean expressions over rows; several ClickHouse versions in one process |
59
59
  | [Support matrix](https://github.com/wave-rf/chtypes/blob/main/docs/support.md) · [Limitations](https://github.com/wave-rf/chtypes/blob/main/docs/limitations.md) | what works where; what chtypes declines to answer |
60
60
 
61
- ## Three things specific to this binding
61
+ ## Four things specific to this binding
62
62
 
63
63
  **A settings value must never be a `float`.** `encode_settings` stringifies an `int` exactly and raises `TypeError` on a `float`: a 19-digit `chtypes_now_epoch_nanos` does not survive an IEEE double, and as a JSON number the setting would be silently ignored.
64
64
 
@@ -66,6 +66,8 @@ A bad **row** is a verdict, not an exception: `outcome` becomes `Outcome.REJECTE
66
66
 
67
67
  **`ctypes` releases the GIL for the whole duration of a foreign call**, so the GIL is not the exclusion. The package uses a writer-preferring readers-writer lock per loaded image plus one plain lock per `Schema`; `set_default_settings` and `close` take it exclusively, as the ABI requires. Measured under contention: 27,770 batch reads across 8 threads against 566 concurrent settings swaps, every answer byte-identical to the uncontended one.
68
68
 
69
+ **The INSERT column list (ABI revision 5) is a keyword-only `columns` on the same calls** — `schema.row(fmt, raw, columns=["id", "e"])`, and the same argument on `Schema.rows` and `Schema.parse_block`. `None` or an empty sequence is the no-list behavior of every earlier revision; a list makes the data supply exactly those columns, with a listed `EPHEMERAL` value read and in scope for the DEFAULTs that reference it but never stored. This release requires a revision-5 artifact — a revision-4 one is refused at load, naming both revisions.
70
+
69
71
  ## Tests
70
72
 
71
73
  `uv run pytest -q`. Tests that need an artifact **skip loudly by name** without a registry on the search path, and a suite that ran nothing fails. The fetch suite runs offline against the miniature releases in `tests/fixtures/fetch/` through `file://` sources and the test key.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chtypes"
3
- version = "0.2.2"
3
+ version = "0.3.1"
4
4
  description = "Python binding for the chtypes artifacts: ClickHouse's own type system, per version, behind the frozen chs_* C ABI"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -54,7 +54,6 @@ from .discover import (
54
54
  parse_changed_settings_result,
55
55
  parse_columns_result,
56
56
  parse_version_result,
57
- reconstruct_ddl,
58
57
  )
59
58
  from .errors import (
60
59
  CODE_ARTIFACT_CORRUPT,
@@ -120,6 +119,7 @@ from .results import (
120
119
  Outcome,
121
120
  Reason,
122
121
  RowResult,
122
+ Source,
123
123
  Span,
124
124
  Substitution,
125
125
  Transform,
@@ -181,6 +181,7 @@ __all__ = [
181
181
  "Schema",
182
182
  "SchemaError",
183
183
  "ServerProfile",
184
+ "Source",
184
185
  "SourceUnreachableError",
185
186
  "Span",
186
187
  "Substitution",
@@ -199,7 +200,6 @@ __all__ = [
199
200
  "parse_version_result",
200
201
  "quote_bare_denormals",
201
202
  "read_manifest",
202
- "reconstruct_ddl",
203
203
  "registry_search_path",
204
204
  "verify_library",
205
205
  ]
@@ -20,6 +20,7 @@ from .results import (
20
20
  FilterRowError,
21
21
  Outcome,
22
22
  RowResult,
23
+ Source,
23
24
  Span,
24
25
  Substitution,
25
26
  Transform,
@@ -153,8 +154,13 @@ def _row_result(doc: RawObject) -> RowResult:
153
154
  for entry in cols if isinstance(cols, list) else ():
154
155
  col = _col_doc(entry)
155
156
  # MATERIALIZED / ALIAS / EPHEMERAL are never read from an input row and
156
- # are not part of the stored row a subscriber would SELECT.
157
- if col.src == "skipped":
157
+ # are not part of the stored row a subscriber would SELECT. A listed
158
+ # EPHEMERAL column's ephemeral_input value IS read but is never
159
+ # stored either, so it is excluded here too — exactly as Source.SKIPPED
160
+ # is — never sitting where a caller reads the stored row (issue #53).
161
+ # materialized_input stays IN: it IS stored, replacing the column's
162
+ # expression.
163
+ if col.src in (Source.SKIPPED, Source.EPHEMERAL_INPUT):
158
164
  continue
159
165
  values.append(
160
166
  Value(
@@ -164,7 +170,7 @@ def _row_result(doc: RawObject) -> RowResult:
164
170
  source=col.src,
165
171
  )
166
172
  )
167
- if col.src == "default_substituted":
173
+ if col.src == Source.DEFAULT_SUBSTITUTED:
168
174
  substituted.append(Substitution(column=col.name, expr=col.input, text=col.stored))
169
175
  transformed.extend(classify(col))
170
176
 
@@ -180,6 +186,16 @@ def _row_result(doc: RawObject) -> RowResult:
180
186
  )
181
187
  )
182
188
 
189
+ # `verdict` (RowsExportWith's Python spelling: `rows(..., row_filter=)`)
190
+ # is present exactly when a filter was attached to the call that
191
+ # produced this document — absent from every Row/Rows document and from
192
+ # `rows()` with no `row_filter`. `Verdict.of` degrades an unrecognized
193
+ # character to DECLINE, never to an invented answer.
194
+ verdict_char = _text(doc, "verdict")
195
+ verdict = Verdict.of(verdict_char) if verdict_char else None
196
+ verdict_code = _count(doc, "verdict_code")
197
+ verdict_err = _text(doc, "verdict_err")
198
+
183
199
  return RowResult(
184
200
  outcome=outcome,
185
201
  err_code=_count(doc, "code"),
@@ -190,6 +206,9 @@ def _row_result(doc: RawObject) -> RowResult:
190
206
  unsupported_settings=unsupported_settings,
191
207
  substituted=tuple(substituted),
192
208
  computed=tuple(computed),
209
+ verdict=verdict,
210
+ verdict_code=verdict_code,
211
+ verdict_err=verdict_err,
193
212
  )
194
213
 
195
214
 
@@ -222,6 +241,9 @@ def parse_batch_document(raw: bytes, payload: bytes | None = None) -> BatchResul
222
241
  unsupported_settings=row.unsupported_settings,
223
242
  substituted=row.substituted,
224
243
  computed=row.computed,
244
+ verdict=row.verdict,
245
+ verdict_code=row.verdict_code,
246
+ verdict_err=row.verdict_err,
225
247
  )
226
248
  )
227
249
  transformed.extend(indexed)
@@ -273,6 +295,8 @@ def parse_batch_document(raw: bytes, payload: bytes | None = None) -> BatchResul
273
295
  payload=payload,
274
296
  spans=spans,
275
297
  export_declined=_text(doc, "export_declined"),
298
+ rows_passed=_count(doc, "rows_passed"),
299
+ rows_cut=_count(doc, "rows_cut"),
276
300
  )
277
301
 
278
302
 
@@ -85,26 +85,56 @@ def read_manifest(version_dir: str | os.PathLike[str]) -> Manifest | None:
85
85
  return None
86
86
 
87
87
 
88
+ def check_library_bytes(
89
+ version_dir: str | os.PathLike[str], manifest: Manifest | None = None
90
+ ) -> None:
91
+ """Compare the shared library's on-disk size to the manifest's ``library_bytes``.
92
+
93
+ Unconditional on the load path, unlike ``verify_library``'s hash check
94
+ below (issue #82): nearly free (one ``stat``, never a re-hash of the
95
+ library's contents), and it catches the commonest shape of a broken
96
+ artifact directory -- a truncated or partially-written library file.
97
+
98
+ A manifest with no ``library_bytes`` (0, its default for one that
99
+ predates the field) is not asked, so this is a no-op for one: a
100
+ directory with no size to compare against must never become a new
101
+ reason a load fails.
102
+ """
103
+ directory = Path(version_dir)
104
+ if manifest is None:
105
+ manifest = read_manifest(directory)
106
+ if manifest is None:
107
+ raise RegistryError(f"chtypes: no usable manifest.json in {directory}")
108
+ if not manifest.library_bytes:
109
+ return
110
+ path = directory / manifest.library
111
+ try:
112
+ size = path.stat().st_size
113
+ except OSError as exc:
114
+ raise RegistryError(f"chtypes: {path}: {exc}") from exc
115
+ if size != manifest.library_bytes:
116
+ raise RegistryError(
117
+ f"chtypes: {path} is {size} bytes, manifest says {manifest.library_bytes}"
118
+ )
119
+
120
+
88
121
  def verify_library(version_dir: str | os.PathLike[str]) -> None:
89
122
  """Re-hash the shared library and compare it against the manifest.
90
123
 
91
124
  The artifact carries its own checksum, so this is neither optional nor
92
125
  expensive for anything that arrived over a network: a move that reported
93
126
  success and truncated a 232 MB library looks identical to one that worked.
127
+ The size check ahead of the hash, ``check_library_bytes``, ALSO runs
128
+ unconditionally on the load path outside of verification (issue #82);
129
+ calling it here too keeps this function's own behavior unchanged for a
130
+ caller who invokes it directly.
94
131
  """
95
132
  directory = Path(version_dir)
96
133
  manifest = read_manifest(directory)
97
134
  if manifest is None:
98
135
  raise RegistryError(f"chtypes: no usable manifest.json in {directory}")
136
+ check_library_bytes(directory, manifest)
99
137
  path = directory / manifest.library
100
- try:
101
- size = path.stat().st_size
102
- except OSError as exc:
103
- raise RegistryError(f"chtypes: {path}: {exc}") from exc
104
- if manifest.library_bytes and size != manifest.library_bytes:
105
- raise RegistryError(
106
- f"chtypes: {path} is {size} bytes, manifest says {manifest.library_bytes}"
107
- )
108
138
  if not manifest.library_sha256:
109
139
  raise RegistryError(
110
140
  f"chtypes: {path} cannot be verified: manifest carries no library_sha256"
@@ -60,6 +60,22 @@ _SIGNATURES: Final[dict[str, tuple[object, list[object]]]] = {
60
60
  "chs_free": (None, [ctypes.c_void_p]),
61
61
  "chs_validate_type": (ctypes.c_int, [ctypes.c_char_p, _c_owned_p, _c_int_p, _c_owned_p]),
62
62
  "chs_reference_type": (ctypes.c_void_p, [ctypes.c_char_p]),
63
+ # Revision 5, additive: the quoting trio, straight off the vendored
64
+ # backQuote / backQuoteIfNeed / quoteString. The input is COUNTED — a
65
+ # literal may carry a NUL byte, so the length is the contract and
66
+ # `strlen` is not — and the answer arrives in a `char **` out-param.
67
+ "chs_quote_identifier": (
68
+ ctypes.c_int,
69
+ [ctypes.c_char_p, ctypes.c_size_t, _c_owned_p, _c_owned_p],
70
+ ),
71
+ "chs_quote_identifier_if_needed": (
72
+ ctypes.c_int,
73
+ [ctypes.c_char_p, ctypes.c_size_t, _c_owned_p, _c_owned_p],
74
+ ),
75
+ "chs_quote_literal": (
76
+ ctypes.c_int,
77
+ [ctypes.c_char_p, ctypes.c_size_t, _c_owned_p, _c_owned_p],
78
+ ),
63
79
  "chs_registered_families": (ctypes.c_void_p, []),
64
80
  "chs_function_flags": (ctypes.c_void_p, []),
65
81
  # settings_json + mode compile a column list under a DECLARED settings
@@ -84,14 +100,30 @@ _SIGNATURES: Final[dict[str, tuple[object, list[object]]]] = {
84
100
  "chs_schema_column_default_kind": (ctypes.c_char_p, [ctypes.c_void_p, ctypes.c_int]),
85
101
  "chs_schema_column_default_expr": (ctypes.c_char_p, [ctypes.c_void_p, ctypes.c_int]),
86
102
  "chs_schema_column_default_is_literal": (ctypes.c_int, [ctypes.c_void_p, ctypes.c_int]),
103
+ # Revision 5: chs_row gains a trailing columns_json (the INSERT column
104
+ # list). The ABI-revision gate below guarantees this 6-argument
105
+ # declaration describes the loaded artifact before any call is made
106
+ # through it: a rev-4 artifact (5-argument shape) is refused at load.
87
107
  "chs_row": (
88
108
  ctypes.c_void_p,
89
- [ctypes.c_void_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_size_t, ctypes.c_char_p],
109
+ [
110
+ ctypes.c_void_p,
111
+ ctypes.c_int,
112
+ ctypes.c_char_p,
113
+ ctypes.c_size_t,
114
+ ctypes.c_char_p,
115
+ ctypes.c_char_p,
116
+ ],
90
117
  ),
91
118
  # Revision 3: chs_rows carries export_format / doc_flags / out_bytes
92
- # (the C ABI contract §Rows). The ABI-revision gate below is what guarantees
93
- # this 8-argument declaration describes the loaded artifact before any
94
- # call is made through it.
119
+ # (the C ABI contract §Rows). Revision 5 appends columns_json after
120
+ # out_bytes and then, in the same open window, the attached row filter
121
+ # LAST. This binding never attaches one and passes NULL — today's
122
+ # behavior byte for byte — but the parameter is DECLARED, because calling
123
+ # a ten-parameter symbol through a nine-parameter declaration leaves the
124
+ # callee reading the filter slot from whatever happened to occupy it. The
125
+ # ABI-revision gate below is what guarantees this 10-argument declaration
126
+ # describes the loaded artifact before any call is made through it.
95
127
  "chs_rows": (
96
128
  ctypes.c_void_p,
97
129
  [
@@ -103,6 +135,8 @@ _SIGNATURES: Final[dict[str, tuple[object, list[object]]]] = {
103
135
  ctypes.c_int,
104
136
  ctypes.c_uint,
105
137
  ctypes.POINTER(_ChsBytes),
138
+ ctypes.c_char_p,
139
+ ctypes.c_void_p,
106
140
  ],
107
141
  ),
108
142
  # Revision 3: the filter trio (the C ABI contract §Filters). Optional symbols —
@@ -125,6 +159,7 @@ _SIGNATURES: Final[dict[str, tuple[object, list[object]]]] = {
125
159
  ),
126
160
  # Revision 4: the block twin (the C ABI contract §Blocks) — parse a body once,
127
161
  # evaluate K filters against the block. Optional, same degradation rule.
162
+ # Revision 5 appends columns_json, LAST, after out_err.
128
163
  "chs_block_parse": (
129
164
  ctypes.c_void_p,
130
165
  [
@@ -135,6 +170,7 @@ _SIGNATURES: Final[dict[str, tuple[object, list[object]]]] = {
135
170
  ctypes.c_char_p,
136
171
  _c_int_p,
137
172
  _c_owned_p,
173
+ ctypes.c_char_p,
138
174
  ],
139
175
  ),
140
176
  "chs_block_free": (None, [ctypes.c_void_p]),
@@ -151,7 +187,9 @@ _SIGNATURES: Final[dict[str, tuple[object, list[object]]]] = {
151
187
  # 4 = the filter phase-2 cycle, 2026-08-31: chs_filter_compile gained
152
188
  # params_json ({name:Type} query parameters) and the block twin joined
153
189
  # (chs_block_parse / chs_block_free / chs_filter_eval).
154
- ABI_REVISION: Final = 4
190
+ # 5 = the explicit INSERT column list, 2026-09-15: chs_row, chs_rows and
191
+ # chs_block_parse each gained a trailing columns_json.
192
+ ABI_REVISION: Final = 5
155
193
 
156
194
  _MANDATORY: Final = (
157
195
  "chs_clickhouse_version",
@@ -434,6 +472,31 @@ class NativeLibrary:
434
472
  raw = self._take(fn(type_expr.encode()))
435
473
  return (raw or b"").decode("utf-8", "surrogateescape")
436
474
 
475
+ # -- quoting -----------------------------------------------------------
476
+
477
+ def quote(self, symbol: str, text: bytes) -> tuple[str, int, str]:
478
+ """(quoted, code, err) from one of the three `chs_quote_*` symbols.
479
+
480
+ The input is COUNTED, so `text` is bytes and its length is passed
481
+ explicitly: a string literal may legally carry a NUL byte and
482
+ `strlen` would truncate it. The answer is always NUL-free — every
483
+ byte the server escapes comes back escaped — so reading it as a C
484
+ string is exact, not a best effort.
485
+ """
486
+ fn = self._need(symbol, f"this artifact predates {symbol} (rebuild it)")
487
+ quoted = ctypes.c_void_p()
488
+ err = ctypes.c_void_p()
489
+ with self._lock.read():
490
+ rc = int(fn(text, len(text), ctypes.byref(quoted), ctypes.byref(err)))
491
+ # Both out-params are taken on EVERY path, success or failure:
492
+ # the library owns whatever it wrote, and an answer left behind
493
+ # on an error return is a leak nothing else can reach.
494
+ raw = self._take(quoted.value)
495
+ message = self._take_err(err)
496
+ if rc != 0:
497
+ return "", rc, message
498
+ return (raw or b"").decode("utf-8", "surrogateescape"), 0, ""
499
+
437
500
  def registered_families(self) -> str:
438
501
  """The newline-separated family list, verbatim."""
439
502
  fn = self._need(
@@ -538,7 +601,18 @@ class NativeLibrary:
538
601
 
539
602
  # -- rows --------------------------------------------------------------
540
603
 
541
- def row(self, handle: int, fmt: int, raw: bytes, settings_json: str) -> bytes:
604
+ def row(
605
+ self,
606
+ handle: int,
607
+ fmt: int,
608
+ raw: bytes,
609
+ settings_json: str,
610
+ columns_json: str | None = None,
611
+ ) -> bytes:
612
+ """`columns_json` (revision 5) is the INSERT column list, already
613
+ JSON-encoded, or None for "no list" — passed through to the C side as
614
+ NULL, never as the literal string `"[]"` (the caller-facing rule is
615
+ `Schema.row`'s; this layer only forwards what it is given)."""
542
616
  fn = self._fn.get("chs_row")
543
617
  if fn is None:
544
618
  raise UnsupportedError("this artifact predates chs_row (rebuild it)")
@@ -546,7 +620,14 @@ class NativeLibrary:
546
620
  # NUL bytes are legal inside a RowBinary body, so the length is
547
621
  # passed explicitly and `strlen` is never involved.
548
622
  out = self._take(
549
- fn(ctypes.c_void_p(handle), fmt, raw, len(raw), settings_json.encode())
623
+ fn(
624
+ ctypes.c_void_p(handle),
625
+ fmt,
626
+ raw,
627
+ len(raw),
628
+ settings_json.encode(),
629
+ columns_json.encode() if columns_json is not None else None,
630
+ )
550
631
  )
551
632
  if out is None:
552
633
  raise UnsupportedError("this artifact predates chs_row (rebuild it)")
@@ -560,11 +641,20 @@ class NativeLibrary:
560
641
  settings_json: str,
561
642
  export_format: int,
562
643
  doc_flags: int,
644
+ columns_json: str | None = None,
645
+ filter_handle: int | None = None,
563
646
  ) -> tuple[bytes, bytes | None]:
564
647
  """(document, payload). `export_format` is -1 (CHS_EXPORT_NONE — no
565
648
  export, payload None) or an `enum chs_format` value; `doc_flags` the
566
649
  CHS_DOC_* bitmask. Both ride through UNVALIDATED — an unknown value is
567
650
  the library's loud refusal to make, never this binding's guess.
651
+ `columns_json` (revision 5) is the INSERT column list, already
652
+ JSON-encoded, or None for "no list" — forwarded as NULL, never `"[]"`.
653
+
654
+ `filter_handle` (revision 5, second half) is the attached row filter
655
+ — None (the default) is "no filter", today's behavior byte for byte.
656
+ `Schema.rows`'s `row_filter=` is the only caller that ever passes one;
657
+ every other call site forwards None exactly as before.
568
658
 
569
659
  Ownership: the export buffer is COPIED into a Python `bytes` and the
570
660
  C side's `data` freed with THIS library's `chs_free` before returning
@@ -583,6 +673,8 @@ class NativeLibrary:
583
673
  export_format,
584
674
  doc_flags,
585
675
  ctypes.byref(out_bytes) if out_bytes is not None else None,
676
+ columns_json.encode() if columns_json is not None else None,
677
+ ctypes.c_void_p(filter_handle) if filter_handle is not None else None,
586
678
  )
587
679
  payload: bytes | None = None
588
680
  if out_bytes is not None and out_bytes.data:
@@ -641,11 +733,18 @@ class NativeLibrary:
641
733
  # -- blocks --------------------------------------------------------------
642
734
 
643
735
  def block_parse(
644
- self, handle: int, fmt: int, body: bytes, settings_json: str
736
+ self,
737
+ handle: int,
738
+ fmt: int,
739
+ body: bytes,
740
+ settings_json: str,
741
+ columns_json: str | None = None,
645
742
  ) -> tuple[int | None, int, str]:
646
743
  """(block handle, code, err). code/err meaningful only on None — a
647
744
  call-level failure (unknown setting 115, framing, a binary decode
648
745
  fault) yields no block and no partial answers (the C ABI contract §Blocks).
746
+ `columns_json` (revision 5) is the INSERT column list, already
747
+ JSON-encoded, or None for "no list" — forwarded as NULL, never `"[]"`.
649
748
  """
650
749
  fn = self._need("chs_block_parse", "this artifact predates chs_block_parse (rebuild it)")
651
750
  code = ctypes.c_int(0)
@@ -659,6 +758,7 @@ class NativeLibrary:
659
758
  settings_json.encode(),
660
759
  ctypes.byref(code),
661
760
  ctypes.byref(err),
761
+ columns_json.encode() if columns_json is not None else None,
662
762
  )
663
763
  if not bhandle:
664
764
  return None, int(code.value), self._take_err(err)