chtypes 0.3.2__tar.gz → 0.5.0__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 (46) hide show
  1. {chtypes-0.3.2 → chtypes-0.5.0}/CHANGELOG.md +38 -0
  2. {chtypes-0.3.2 → chtypes-0.5.0}/PKG-INFO +2 -2
  3. {chtypes-0.3.2 → chtypes-0.5.0}/README.md +1 -1
  4. {chtypes-0.3.2 → chtypes-0.5.0}/pyproject.toml +1 -1
  5. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/__init__.py +7 -0
  6. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/__main__.py +10 -12
  7. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/_document.py +11 -0
  8. chtypes-0.5.0/src/chtypes/_error_codes.py +153 -0
  9. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/_native.py +35 -2
  10. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/errors.py +16 -0
  11. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/fetch.py +474 -70
  12. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/registry.py +467 -89
  13. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/results.py +18 -0
  14. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_cli.py +48 -1
  15. chtypes-0.5.0/tests/test_error_codes.py +250 -0
  16. chtypes-0.5.0/tests/test_exact_patch_resolution.py +613 -0
  17. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_fetch.py +152 -14
  18. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_fetch_abi_revision.py +1 -1
  19. chtypes-0.5.0/tests/test_fetch_sh.py +440 -0
  20. chtypes-0.5.0/tests/test_partition_key.py +169 -0
  21. {chtypes-0.3.2 → chtypes-0.5.0}/uv.lock +1 -1
  22. chtypes-0.3.2/tests/test_fetch_sh.py +0 -139
  23. {chtypes-0.3.2 → chtypes-0.5.0}/.gitignore +0 -0
  24. {chtypes-0.3.2 → chtypes-0.5.0}/LICENSE +0 -0
  25. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/_ed25519.py +0 -0
  26. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/_manifest.py +0 -0
  27. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/_rawjson.py +0 -0
  28. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/discover.py +0 -0
  29. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/py.typed +0 -0
  30. {chtypes-0.3.2 → chtypes-0.5.0}/src/chtypes/transform.py +0 -0
  31. {chtypes-0.3.2 → chtypes-0.5.0}/tests/conftest.py +0 -0
  32. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_abi_revision.py +0 -0
  33. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_csv_reader.py +0 -0
  34. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_discover_reconstruct.py +0 -0
  35. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_ed25519.py +0 -0
  36. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_errors.py +0 -0
  37. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_formats_withnames.py +0 -0
  38. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_golden.py +0 -0
  39. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_lazy.py +0 -0
  40. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_parity.py +0 -0
  41. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_quoting_boundaries.py +0 -0
  42. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_rawjson.py +0 -0
  43. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_registry.py +0 -0
  44. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_results_rules.py +0 -0
  45. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_rows_export_with.py +0 -0
  46. {chtypes-0.3.2 → chtypes-0.5.0}/tests/test_transform.py +0 -0
@@ -6,6 +6,44 @@ The four bindings in this repository are released together and give one answer,
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] — 2026-10-01
10
+
11
+ **Upgrade together if you share a lock: 0.4.x and earlier refuse a schema-2 lock, and this release writes one.** Schema-1 locks are still read and are converted the next time the lock is written. A server's exact ClickHouse patch now loads when it is installed, or published and autofetch is on; otherwise the newest installed patch of the same line loads, flagged as not exact and warned once per pair, and no request ever crosses to another line. Several patches of one line coexist: the patch a line request selects installs flat as before, and every other patch installs under `patches/<minor>/<version>/`, where an older flat patch is moved rather than deleted. `--frozen` now installs exactly the pinned file, even while a newer one is served. Same ABI revision as 0.4.0 (6), so the artifacts 0.4.0 loads are the ones this release loads. An older Python SDK's refusal reads `ValueError: chtypes: <lock> is not a chtypes lock file (schema 1)`, before any download.
12
+
13
+ ### Added
14
+
15
+ - **`fetch --lock` now records the artifact's ABI revision** in each lock entry (`abi_revision`, optional and additive); `--frozen` checks it FIRST, before the file/sha256 pin: a lock that names a different revision raises `ArtifactPinnedError`, naming both numbers and the `python -m chtypes fetch` remedy, instead of surfacing as a bare drifted pin or `ArtifactUnpublishedError`. A lock entry with no recorded revision (written by an SDK before this change) keeps today's behavior, with one sentence appended when it ends in one of those two errors. `offline=True, frozen=True` is unaffected (docs/guides/fetch.md §5, closes #253).
16
+ - **Exact-patch resolution (SDK#284).** `Registry.resolve(v)` returns a `Resolution(library, requested, version, exact)`: a minor-line request (`"25.8"`) is always `exact=True`, pinned to the newest reachable patch for as long as the registry stays open; an exact-patch request (`"25.8.28.1-lts"`) loads that patch when it is installed or — with `autofetch` on — published, and otherwise falls back to the newest patch of the same line with `exact=False` and a `PatchFallbackWarning` (new, exported), raised once per (requested, actual) pair per process. Never crosses to another line — that failure is still `ArtifactMissingError`. `for_version` / `registry[v]` run the same resolution and hand back `.library`. A fallen-back patch is re-checked against the search path at most once every 60 seconds per (destination, requested patch), so a patch installed later — by fetch or by hand — is picked up with no restart and no directory read on every call.
17
+ - **`fetch.installed_patches(registry)`** — every installed PATCH under a registry directory (the flat line slot and every other patch under `patches/`), as `InstalledPatch(minor, version, directory, manifest, flat)`. Supersedes `fetch.installed_lines` as the fetch-level listing, which stays, unlisted, as "the one patch a line request loads."
18
+
19
+ ### Changed
20
+
21
+ - **Several patches of one ClickHouse line now coexist in the cache**, so fetching a second patch no longer overwrites the first. The patch a LINE request selects (`fetch 25.8`) still installs FLAT at `<registry>/<minor>/`, exactly as before; any OTHER exact patch installs at `<registry>/patches/<minor>/<clickhouse_version>/`. When a line fetch changes which patch occupies the flat slot, the outgoing install is DEMOTED — renamed atomically, same filesystem, into `patches/<minor>/<its version>/` — never deleted, before the incoming patch takes the flat slot. Every reader (`Registry`, `list`, `verify`, `installed_patches`) reads both locations; a released (0.4.x and earlier) reader never touches or lists `patches/`, so the two coexist safely in one cache.
22
+ - **Lock schema 2** (`LOCK_SCHEMA = 2`), keyed `<os>-<arch>/<clickhouse_version>` — the exact patch, not the line, so two patches of one line each get their own pin and nothing is silently dropped when a second is fetched. Schema 1 is still read: a `<os-arch>/<minor>` entry converts to its schema-2 key by recovering the exact version from the entry's own pinned `file` name. The first write through an existing schema-1 file rewrites it as schema 2 wholesale — schema is a property of the file, not of one entry. `--frozen` now selects entirely from the lock (never from the release's other rows) and installs exactly the pinned file, even while a newer patch — or a higher build of the same patch — is served: the old "pins X but the release offers Y" refusal is gone. `--all --frozen` installs the newest patch EACH pinned line's lock entries name; a line the release has that the lock does not pin is simply not installed, not a refusal.
23
+ - **fetch.md Decision 7 (a channel-free spelling matches that patch on any channel) now applies to `ensure`'s offline path too** — the request that lands here whenever a live server answers `SELECT version()` with no channel suffix.
24
+ - **Docs: plain `CSV`/`TSV` input also honors `input_format_csv_detect_header` / `input_format_tsv_detect_header`, exactly as a real server does**, because the body is read by ClickHouse's own vendored row readers. The behavior arrived with 0.3.0's switch to those readers; it is now documented (#299).
25
+
26
+ ### Fixed
27
+
28
+ - **`fetch --lock` without `--frozen` re-pins, as in the other three bindings.** It used to enforce an existing lock entry anyway and refuse a differing one with `CHTYPES_ARTIFACT_PINNED`. That meant the re-lock command the refusal itself names (`--lock` without `--frozen`) was refused too, and the only way out was deleting the lock. Only `--frozen` enforces a pin now (`docs/guides/fetch.md` §5).
29
+ - **An offline exact-patch request without a channel now matches an installed patch that has one** (Decision 7) — `ensure("25.8.28.1", offline=True)` used to raise `CHTYPES_SOURCE_UNREACHABLE` against an installed `25.8.28.1-lts`, because only the REQUESTED spelling had its channel stripped for comparison, never the installed manifest's own version.
30
+ - **An autofetched patch the release does not publish now falls back within its line** instead of raising `ArtifactUnpublishedError` — `Registry`'s lazy fetch used to hand the caller's own patch spelling straight to `ensure`, so it failed hard on an unpublished patch where Go, TypeScript and Rust would quietly serve the line's newest.
31
+
32
+ ## [0.4.0] — 2026-09-30
33
+
34
+ Speaks ABI revision 6 and refuses revision-5 artifacts. Revision 6 adds the error-code table and the partition key; revision-6 artifacts are published for every ClickHouse line this SDK supports, beside the revision-5 builds that 0.3.x keeps loading.
35
+
36
+ ### Added
37
+
38
+ - **`Library.error_codes()`** — THIS build's own ClickHouse error-code table (`chs_error_codes`), as an `ErrorCodeTable`: `name(code) -> str | None`, `code(name) -> int | None`, `all()` (ascending) and iteration, over `ErrorCodeEntry(code, name)`. Per `Library` and never package-level, because the table moves between lines — one number names two different errors on 25.8 and 26.2. An unknown or negative code and an unknown name are `None`, never synthesized; names match exactly. Built on the first call and kept on success only: a `NULL` answer raises `ChtypesError` and is never cached; an artifact that predates the symbol raises `UnsupportedError`.
39
+ - **`Schema.set_partition_by(expr)`** — declare the table's partition key; `""` removes it. It follows `set_engine`'s sign rule, not `set_ttl`'s: `SchemaError` for the server's own CREATE-path refusal (e.g. 36 BAD_ARGUMENTS for a non-deterministic key, 549), `UnsupportedError` for a decline (-2 is a key the server accepts but this build will not evaluate).
40
+ - **`RowResult.partition_id`** and **`BatchResult.partition_count`**, both `None` unless the schema declared a key. A batch over `max_partitions_per_insert_block` is an ordinary `Outcome.REJECTED` with `err_code` 252.
41
+
42
+ ### Changed
43
+
44
+ - **Speaks ABI revision 6, and refuses revision-5 artifacts** — and a revision-5 binding refuses revision-6 ones. Revision 6 only ADDS `chs_error_codes` and `chs_schema_partition_by`; no existing declaration changed. It is still a new number, so a fetch after upgrading downloads revision-6 artifacts into their own `abi6` cache directory, and a directory holding only revision-5 artifacts is refused at load, naming both revisions. Revision-6 artifacts are published by the artifact producer; until they are, this binding has nothing to load.
45
+ - **A TTL on a schema with a `CHECK` constraint is now evaluated.** `Schema.set_ttl` on such a schema used to answer `UnsupportedError`; against revision-6 artifacts it answers as on any other schema, accepting the TTL or giving the server's own refusal. That matches a server, whose TTL validation does not read constraints. The change is in the artifacts, so it arrives with them.
46
+
9
47
  ## [0.3.2] — 2026-09-28
10
48
 
11
49
  Speaks ABI revision 5, unchanged from 0.3.1: every artifact 0.3.1 loads, this release loads.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: chtypes
3
- Version: 0.3.2
3
+ Version: 0.5.0
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
@@ -84,7 +84,7 @@ 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.
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. The column list needs an artifact of revision 5 or later, and this binding loads only artifacts at its own `chtypes.ABI_REVISION` — any other is refused at load, naming both revisions.
88
88
 
89
89
  ## Tests
90
90
 
@@ -66,7 +66,7 @@ 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.
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. The column list needs an artifact of revision 5 or later, and this binding loads only artifacts at its own `chtypes.ABI_REVISION` — any other is refused at load, naming both revisions.
70
70
 
71
71
  ## Tests
72
72
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chtypes"
3
- version = "0.3.2"
3
+ version = "0.5.0"
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"
@@ -43,6 +43,7 @@ from __future__ import annotations
43
43
  from importlib.metadata import PackageNotFoundError as _PackageNotFound
44
44
  from importlib.metadata import version as _package_version
45
45
 
46
+ from ._error_codes import ErrorCodeEntry, ErrorCodeTable
46
47
  from ._native import ABI_REVISION
47
48
  from ._rawjson import RawNumber, quote_bare_denormals
48
49
  from .discover import (
@@ -71,6 +72,7 @@ from .errors import (
71
72
  ArtifactUnpublishedError,
72
73
  ArtifactUntrustedError,
73
74
  ChtypesError,
75
+ PatchFallbackWarning,
74
76
  RegistryError,
75
77
  SchemaError,
76
78
  SourceUnreachableError,
@@ -93,6 +95,7 @@ from .registry import (
93
95
  Library,
94
96
  Manifest,
95
97
  Registry,
98
+ Resolution,
96
99
  Schema,
97
100
  default_registry_dir,
98
101
  host_platform,
@@ -165,6 +168,8 @@ __all__ = [
165
168
  "Computed",
166
169
  "DefaultKind",
167
170
  "DiscoveredColumn",
171
+ "ErrorCodeEntry",
172
+ "ErrorCodeTable",
168
173
  "Filter",
169
174
  "FilterOutcome",
170
175
  "FilterResult",
@@ -173,10 +178,12 @@ __all__ = [
173
178
  "Library",
174
179
  "Manifest",
175
180
  "Outcome",
181
+ "PatchFallbackWarning",
176
182
  "RawNumber",
177
183
  "Reason",
178
184
  "Registry",
179
185
  "RegistryError",
186
+ "Resolution",
180
187
  "RowResult",
181
188
  "Schema",
182
189
  "SchemaError",
@@ -20,7 +20,6 @@ import warnings
20
20
  from collections.abc import Sequence
21
21
  from pathlib import Path
22
22
 
23
- from ._manifest import Manifest
24
23
  from .errors import (
25
24
  CODE_ARTIFACT_UNPUBLISHED,
26
25
  CODE_SOURCE_UNREACHABLE,
@@ -28,7 +27,7 @@ from .errors import (
28
27
  ChtypesError,
29
28
  UnsignedArtifactWarning,
30
29
  )
31
- from .fetch import DEFAULT_ARTIFACTS_URL, DEFAULT_TAG, PLATFORMS, ReleaseEntry
30
+ from .fetch import DEFAULT_ARTIFACTS_URL, DEFAULT_TAG, PLATFORMS
32
31
 
33
32
  __all__ = ["main"]
34
33
 
@@ -202,17 +201,20 @@ def _cmd_list(args: argparse.Namespace) -> int:
202
201
  _not_shown,
203
202
  fetch_destination,
204
203
  host_platform,
205
- installed_lines,
204
+ installed_patches,
206
205
  )
207
206
 
208
207
  platform = args.platform or host_platform()
209
208
  registry = fetch_destination(args.dest, platform=platform)
210
- have = installed_lines(registry)
209
+ have = installed_patches(registry)
211
210
  sys.stdout.write(f"installed ({registry}):\n")
212
211
  if not have:
213
212
  sys.stdout.write(" (nothing)\n")
214
- for minor, (directory, manifest) in have.items():
215
- sys.stdout.write(f" {minor:<8} {manifest.clickhouse_version:<18} {directory}\n")
213
+ for patch in have:
214
+ # SDK#284: the flat slot (the patch a LINE request selects) and every
215
+ # other patch under patches/<minor>/<version>/ both report.
216
+ tag = "" if patch.flat else " (patches/)"
217
+ sys.stdout.write(f" {patch.minor:<8} {patch.version:<18} {patch.directory}{tag}\n")
216
218
  sys.stdout.flush()
217
219
 
218
220
  fetcher = Fetcher(dest=registry, platform=platform, url=args.url, tag=args.tag, progress=_say)
@@ -222,8 +224,9 @@ def _cmd_list(args: argparse.Namespace) -> int:
222
224
  sys.stdout.write(f"release ({release.source}, {signed}) offers for {platform}:\n")
223
225
  if not offered:
224
226
  sys.stdout.write(" (nothing)\n")
227
+ installed_versions = {patch.version for patch in have}
225
228
  for entry in offered:
226
- state = "installed" if _installed(have, entry) else "not installed"
229
+ state = "installed" if entry.clickhouse_version in installed_versions else "not installed"
227
230
  sys.stdout.write(
228
231
  f" {entry.minor:<8} {entry.clickhouse_version:<18} b{entry.build_number:<3} "
229
232
  f"{entry.file} [{state}]\n"
@@ -237,11 +240,6 @@ def _cmd_list(args: argparse.Namespace) -> int:
237
240
  return EXIT_OK
238
241
 
239
242
 
240
- def _installed(have: dict[str, tuple[object, Manifest]], entry: ReleaseEntry) -> bool:
241
- got = have.get(entry.minor)
242
- return got is not None and got[1].clickhouse_version == entry.clickhouse_version
243
-
244
-
245
243
  def _goldens_line(registry: Path) -> None:
246
244
  """The served golden set sits beside the artifacts, so "where is my registry"
247
245
  and "is my registry sound" are both moments someone wants to know whether it
@@ -196,6 +196,10 @@ def _row_result(doc: RawObject) -> RowResult:
196
196
  verdict_code = _count(doc, "verdict_code")
197
197
  verdict_err = _text(doc, "verdict_err")
198
198
 
199
+ # `partition_id` (revision 6) is present exactly when the schema declared
200
+ # a partition key and this row would be stored; absent is None.
201
+ partition_id = doc.get("partition_id")
202
+
199
203
  return RowResult(
200
204
  outcome=outcome,
201
205
  err_code=_count(doc, "code"),
@@ -209,6 +213,7 @@ def _row_result(doc: RawObject) -> RowResult:
209
213
  verdict=verdict,
210
214
  verdict_code=verdict_code,
211
215
  verdict_err=verdict_err,
216
+ partition_id=partition_id if isinstance(partition_id, str) else None,
212
217
  )
213
218
 
214
219
 
@@ -244,6 +249,7 @@ def parse_batch_document(raw: bytes, payload: bytes | None = None) -> BatchResul
244
249
  verdict=row.verdict,
245
250
  verdict_code=row.verdict_code,
246
251
  verdict_err=row.verdict_err,
252
+ partition_id=row.partition_id,
247
253
  )
248
254
  )
249
255
  transformed.extend(indexed)
@@ -297,6 +303,11 @@ def parse_batch_document(raw: bytes, payload: bytes | None = None) -> BatchResul
297
303
  export_declined=_text(doc, "export_declined"),
298
304
  rows_passed=_count(doc, "rows_passed"),
299
305
  rows_cut=_count(doc, "rows_cut"),
306
+ # Revision 6: present exactly when the schema declared a partition
307
+ # key; absent is None, never a guessed 0.
308
+ partition_count=_count(doc, "partition_count")
309
+ if isinstance(doc.get("partition_count"), RawNumber)
310
+ else None,
300
311
  )
301
312
 
302
313
 
@@ -0,0 +1,153 @@
1
+ """The error-code table (revision 6): one loaded build's own code -> name map.
2
+
3
+ There is no table in this package, and there must never be one. The table is a
4
+ property of the BUILD: codes join and leave between ClickHouse lines, and one
5
+ number can name two different errors on two lines (903 is LICENSE_EXPIRED on
6
+ 25.3 and 25.8, absent on 25.10, and DISTRIBUTED_CACHE_REGISTRY_SHUTDOWN on 26.2
7
+ through 26.9). Every answer
8
+ therefore comes from `Library.error_codes()`, i.e. from `chs_error_codes` of
9
+ the library being asked, and `scripts/check-no-error-code-table.py` fails the
10
+ build if a literal code -> name table appears in any binding.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import threading
17
+ from collections.abc import Callable, Iterator
18
+ from dataclasses import dataclass
19
+
20
+ from .errors import ChtypesError
21
+
22
+ __all__ = ["ErrorCodeEntry", "ErrorCodeTable"]
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ErrorCodeEntry:
27
+ """One row of a build's error-code table: a ClickHouse error code and the
28
+ name THAT BUILD gives it."""
29
+
30
+ code: int
31
+ name: str
32
+
33
+
34
+ class ErrorCodeTable:
35
+ """One loaded library's own error-code table, from `chs_error_codes` —
36
+ obtained from `Library.error_codes()` and valid for that library's
37
+ ClickHouse line only. Immutable, so safe to share between threads.
38
+
39
+ Lookups answer only what the build's own table holds: an unknown code, a
40
+ negative code (the ABI's -1 and -2 sentinels included — they are not
41
+ ClickHouse codes) or an unknown name is `None`, never a synthesized
42
+ spelling. Names match exactly and case-sensitively, as the server prints
43
+ them. Iterating yields the entries in ascending code order, as `all()`
44
+ returns them.
45
+ """
46
+
47
+ __slots__ = ("_by_code", "_by_name", "_entries")
48
+
49
+ def __init__(self, entries: tuple[ErrorCodeEntry, ...]) -> None:
50
+ by_code: dict[int, str] = {}
51
+ by_name: dict[str, int] = {}
52
+ kept: list[ErrorCodeEntry] = []
53
+ for e in entries:
54
+ # Not a name, not a ClickHouse code, or a repeat: the first entry
55
+ # for a code or a name wins, and nothing else is invented.
56
+ if not e.name or e.code < 0 or e.code in by_code or e.name in by_name:
57
+ continue
58
+ by_code[e.code] = e.name
59
+ by_name[e.name] = e.code
60
+ kept.append(e)
61
+ self._entries = tuple(sorted(kept, key=lambda e: e.code))
62
+ self._by_code = by_code
63
+ self._by_name = by_name
64
+
65
+ def name(self, code: int) -> str | None:
66
+ """The name this build gives `code`, or None."""
67
+ if code < 0:
68
+ return None
69
+ return self._by_code.get(code)
70
+
71
+ def code(self, name: str) -> int | None:
72
+ """The code this build gives `name` — an exact, case-sensitive match —
73
+ or None."""
74
+ return self._by_name.get(name)
75
+
76
+ def all(self) -> tuple[ErrorCodeEntry, ...]:
77
+ """Every entry, in ascending code order."""
78
+ return self._entries
79
+
80
+ def __iter__(self) -> Iterator[ErrorCodeEntry]:
81
+ return iter(self._entries)
82
+
83
+ def __len__(self) -> int:
84
+ return len(self._entries)
85
+
86
+ def __repr__(self) -> str:
87
+ return f"<chtypes.ErrorCodeTable {len(self._entries)} codes>"
88
+
89
+
90
+ def parse_error_codes_document(raw: bytes) -> ErrorCodeTable:
91
+ """Build a table from a `chs_error_codes` document,
92
+ `{"error_codes":[{"code":N,"name":"…"}, …]}`.
93
+
94
+ Unknown keys are ignored and an absent (or null) key is its default, like
95
+ every document this ABI hands back. A value of the wrong type is a bad
96
+ document, never a guess.
97
+ """
98
+ try:
99
+ doc = json.loads(raw)
100
+ except ValueError as exc:
101
+ raise ChtypesError(f"chtypes: bad chs_error_codes document: {exc}") from exc
102
+ if not isinstance(doc, dict):
103
+ raise ChtypesError("chtypes: bad chs_error_codes document: not a JSON object")
104
+ rows = doc.get("error_codes")
105
+ if rows is None:
106
+ rows = []
107
+ if not isinstance(rows, list):
108
+ raise ChtypesError("chtypes: bad chs_error_codes document: error_codes is not an array")
109
+ entries: list[ErrorCodeEntry] = []
110
+ for row in rows:
111
+ if not isinstance(row, dict):
112
+ raise ChtypesError("chtypes: bad chs_error_codes document: an entry is not an object")
113
+ code = row.get("code")
114
+ name = row.get("name")
115
+ if code is None:
116
+ code = 0
117
+ if name is None:
118
+ name = ""
119
+ if isinstance(code, bool) or not isinstance(code, int) or not isinstance(name, str):
120
+ raise ChtypesError(f"chtypes: bad chs_error_codes document: entry {row!r}")
121
+ entries.append(ErrorCodeEntry(code=code, name=name))
122
+ return ErrorCodeTable(tuple(entries))
123
+
124
+
125
+ class _ErrorCodeCache:
126
+ """One library's table once it has been built, and only then.
127
+
128
+ A NULL answer from `chs_error_codes` is a guarded exception inside the
129
+ library — transient by definition — so it raises `ChtypesError` and is NOT
130
+ remembered: the next call asks again. A missing symbol raises the decline
131
+ type and is not remembered either. Only a table that was actually built is
132
+ kept.
133
+ """
134
+
135
+ __slots__ = ("_mu", "_table")
136
+
137
+ def __init__(self) -> None:
138
+ self._mu = threading.Lock()
139
+ self._table: ErrorCodeTable | None = None
140
+
141
+ def get(self, fetch: Callable[[], bytes | None]) -> ErrorCodeTable:
142
+ with self._mu:
143
+ if self._table is not None:
144
+ return self._table
145
+ raw = fetch()
146
+ if raw is None:
147
+ raise ChtypesError(
148
+ "chtypes: chs_error_codes returned no document (a guarded exception "
149
+ "inside the library); nothing was cached, so the next call asks again"
150
+ )
151
+ table = parse_error_codes_document(raw)
152
+ self._table = table
153
+ return table
@@ -78,6 +78,11 @@ _SIGNATURES: Final[dict[str, tuple[object, list[object]]]] = {
78
78
  ),
79
79
  "chs_registered_families": (ctypes.c_void_p, []),
80
80
  "chs_function_flags": (ctypes.c_void_p, []),
81
+ # Revision 6: the build's own error-code table, an owned JSON document.
82
+ # Optional: an artifact that predates it degrades to UnsupportedError at
83
+ # call time. NULL from a PRESENT symbol is a guarded exception, which is a
84
+ # different answer — see `error_codes`.
85
+ "chs_error_codes": (ctypes.c_void_p, []),
81
86
  # settings_json + mode compile a column list under a DECLARED settings
82
87
  # profile; NULL/"{}" settings_json and mode 0 answer identically to the
83
88
  # pre-consolidation, settings-less compile — structurally, not just by
@@ -94,6 +99,9 @@ _SIGNATURES: Final[dict[str, tuple[object, list[object]]]] = {
94
99
  [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p, _c_owned_p],
95
100
  ),
96
101
  "chs_schema_ttl": (ctypes.c_int, [ctypes.c_void_p, ctypes.c_char_p, _c_owned_p]),
102
+ # Revision 6: the partition key — chs_schema_ttl's exact C shape. The SIGN
103
+ # rule its return follows is chs_schema_engine's (see Schema.set_partition_by).
104
+ "chs_schema_partition_by": (ctypes.c_int, [ctypes.c_void_p, ctypes.c_char_p, _c_owned_p]),
97
105
  "chs_schema_column_count": (ctypes.c_int, [ctypes.c_void_p]),
98
106
  "chs_schema_column_name": (ctypes.c_char_p, [ctypes.c_void_p, ctypes.c_int]),
99
107
  "chs_schema_column_type": (ctypes.c_char_p, [ctypes.c_void_p, ctypes.c_int]),
@@ -189,7 +197,10 @@ _SIGNATURES: Final[dict[str, tuple[object, list[object]]]] = {
189
197
  # (chs_block_parse / chs_block_free / chs_filter_eval).
190
198
  # 5 = the explicit INSERT column list, 2026-09-15: chs_row, chs_rows and
191
199
  # chs_block_parse each gained a trailing columns_json.
192
- ABI_REVISION: Final = 5
200
+ # 6 = the error-code table and the partition key: chs_error_codes and
201
+ # chs_schema_partition_by joined the surface. Purely additive, and still a
202
+ # new number: a revision-6 binding refuses a revision-5 artifact.
203
+ ABI_REVISION: Final = 6
193
204
 
194
205
  _MANDATORY: Final = (
195
206
  "chs_clickhouse_version",
@@ -217,7 +228,8 @@ class _RWLock:
217
228
 
218
229
  **Readers** are every call that reaches `chs_row`, `chs_rows`,
219
230
  `chs_schema_compile`, `chs_schema_engine`, `chs_schema_ttl`,
220
- `chs_validate_type` and the column accessors. The C ABI contract
231
+ `chs_schema_partition_by`, `chs_validate_type`, `chs_error_codes` and the
232
+ column accessors. The C ABI contract
221
233
  §Thread-safety declares those safe together **on distinct handles**, so
222
234
  per-handle serialization is `Schema`'s own lock and not this one.
223
235
 
@@ -507,6 +519,16 @@ class NativeLibrary:
507
519
  raw = self._take(fn())
508
520
  return (raw or b"").decode("utf-8", "surrogateescape")
509
521
 
522
+ def error_codes(self) -> bytes | None:
523
+ """The build's own error-code table as the raw JSON document, or None
524
+ when the library could not build it (a guarded exception — transient,
525
+ so the caller must not remember it). A missing symbol raises
526
+ `UnsupportedError`, which is a different answer: the artifact predates
527
+ revision 6."""
528
+ fn = self._need("chs_error_codes", "this artifact predates chs_error_codes (rebuild it)")
529
+ with self._lock.read():
530
+ return self._take(fn())
531
+
510
532
  def function_flags(self) -> str:
511
533
  """The function-volatility TSV audit, verbatim. Requires chs_init."""
512
534
  fn = self._need(
@@ -576,6 +598,17 @@ class NativeLibrary:
576
598
  rc = int(fn(ctypes.c_void_p(handle), ttl_sql.encode(), ctypes.byref(err)))
577
599
  return rc, self._take_err(err)
578
600
 
601
+ def schema_partition_by(self, handle: int, partition_by: str) -> tuple[int, str]:
602
+ """(rc, err) from chs_schema_partition_by. The caller maps rc by the
603
+ SIGN rule chs_schema_engine uses, not chs_schema_ttl's."""
604
+ fn = self._need(
605
+ "chs_schema_partition_by", "this artifact predates chs_schema_partition_by (rebuild it)"
606
+ )
607
+ err = ctypes.c_void_p()
608
+ with self._lock.read():
609
+ rc = int(fn(ctypes.c_void_p(handle), partition_by.encode(), ctypes.byref(err)))
610
+ return rc, self._take_err(err)
611
+
579
612
  def schema_columns(self, handle: int) -> list[tuple[str, str, str, str, bool]]:
580
613
  """(name, type, default_kind, default_expr, default_is_literal) per column."""
581
614
  if not self.has_columns:
@@ -22,6 +22,7 @@ __all__ = [
22
22
  "ArtifactUnpublishedError",
23
23
  "ArtifactUntrustedError",
24
24
  "ChtypesError",
25
+ "PatchFallbackWarning",
25
26
  "RegistryError",
26
27
  "SchemaError",
27
28
  "SourceUnreachableError",
@@ -181,6 +182,21 @@ class UnsignedArtifactWarning(UserWarning):
181
182
  signature check — the one loud warning docs/guides/fetch.md §4 requires."""
182
183
 
183
184
 
185
+ class PatchFallbackWarning(UserWarning):
186
+ """Emitted once per (requested, actual) pair per process when a request for
187
+ an exact ClickHouse patch falls back to the newest installed or published
188
+ patch of the same line (SDK#284, docs/guides/fetch.md "Version selection").
189
+
190
+ Behavior can differ between patches of one line — the reason this change
191
+ exists at all — so this is a loud, once-per-pair signal, not a debug log
192
+ line. The pair is recorded BEFORE the warning is emitted, so a caller's
193
+ own `warnings.filterwarnings("error", category=PatchFallbackWarning)`
194
+ turning this into an exception does not cause a retry to warn again
195
+ (design risk R-h): the library has already loaded by the time the warning
196
+ fires, and the fallback is not undone.
197
+ """
198
+
199
+
184
200
  class SchemaError(ChtypesError):
185
201
  """ClickHouse itself REFUSED a type expression, a DDL, an engine or a TTL.
186
202