chtypes 0.3.2__tar.gz → 0.4.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 (44) hide show
  1. {chtypes-0.3.2 → chtypes-0.4.0}/CHANGELOG.md +15 -0
  2. {chtypes-0.3.2 → chtypes-0.4.0}/PKG-INFO +2 -2
  3. {chtypes-0.3.2 → chtypes-0.4.0}/README.md +1 -1
  4. {chtypes-0.3.2 → chtypes-0.4.0}/pyproject.toml +1 -1
  5. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/__init__.py +3 -0
  6. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/_document.py +11 -0
  7. chtypes-0.4.0/src/chtypes/_error_codes.py +153 -0
  8. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/_native.py +35 -2
  9. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/registry.py +56 -0
  10. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/results.py +12 -0
  11. chtypes-0.4.0/tests/test_error_codes.py +250 -0
  12. chtypes-0.4.0/tests/test_partition_key.py +169 -0
  13. {chtypes-0.3.2 → chtypes-0.4.0}/uv.lock +1 -1
  14. {chtypes-0.3.2 → chtypes-0.4.0}/.gitignore +0 -0
  15. {chtypes-0.3.2 → chtypes-0.4.0}/LICENSE +0 -0
  16. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/__main__.py +0 -0
  17. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/_ed25519.py +0 -0
  18. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/_manifest.py +0 -0
  19. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/_rawjson.py +0 -0
  20. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/discover.py +0 -0
  21. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/errors.py +0 -0
  22. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/fetch.py +0 -0
  23. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/py.typed +0 -0
  24. {chtypes-0.3.2 → chtypes-0.4.0}/src/chtypes/transform.py +0 -0
  25. {chtypes-0.3.2 → chtypes-0.4.0}/tests/conftest.py +0 -0
  26. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_abi_revision.py +0 -0
  27. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_cli.py +0 -0
  28. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_csv_reader.py +0 -0
  29. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_discover_reconstruct.py +0 -0
  30. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_ed25519.py +0 -0
  31. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_errors.py +0 -0
  32. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_fetch.py +0 -0
  33. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_fetch_abi_revision.py +0 -0
  34. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_fetch_sh.py +0 -0
  35. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_formats_withnames.py +0 -0
  36. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_golden.py +0 -0
  37. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_lazy.py +0 -0
  38. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_parity.py +0 -0
  39. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_quoting_boundaries.py +0 -0
  40. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_rawjson.py +0 -0
  41. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_registry.py +0 -0
  42. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_results_rules.py +0 -0
  43. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_rows_export_with.py +0 -0
  44. {chtypes-0.3.2 → chtypes-0.4.0}/tests/test_transform.py +0 -0
@@ -6,6 +6,21 @@ The four bindings in this repository are released together and give one answer,
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] — 2026-09-30
10
+
11
+ 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.
12
+
13
+ ### Added
14
+
15
+ - **`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`.
16
+ - **`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).
17
+ - **`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.
18
+
19
+ ### Changed
20
+
21
+ - **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.
22
+ - **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.
23
+
9
24
  ## [0.3.2] — 2026-09-28
10
25
 
11
26
  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.4.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.4.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 (
@@ -165,6 +166,8 @@ __all__ = [
165
166
  "Computed",
166
167
  "DefaultKind",
167
168
  "DiscoveredColumn",
169
+ "ErrorCodeEntry",
170
+ "ErrorCodeTable",
168
171
  "Filter",
169
172
  "FilterOutcome",
170
173
  "FilterResult",
@@ -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:
@@ -19,6 +19,7 @@ from types import TracebackType
19
19
  from typing import Final
20
20
 
21
21
  from ._document import parse_batch_document, parse_filter_document, parse_row_document
22
+ from ._error_codes import ErrorCodeTable, _ErrorCodeCache
22
23
  from ._manifest import (
23
24
  Manifest,
24
25
  cache_registry_dir,
@@ -349,6 +350,38 @@ class Schema:
349
350
  if rc != 0:
350
351
  raise UnsupportedError(err or "TTL form not modeled by this build")
351
352
 
353
+ def set_partition_by(self, expr: str) -> None:
354
+ """Declare the table's partition key — the `PARTITION BY` clause after
355
+ the engine, e.g. "toYYYYMM(ts)" or "(toDate(ts), tenant)"
356
+ (`chs_schema_partition_by`, revision 6).
357
+
358
+ The key is built by the server's own CREATE-path call over this
359
+ schema's columns, under the handle's compile profile. A second call
360
+ REPLACES the first; "" removes the declaration, and the schema then
361
+ answers exactly as one that never declared a key.
362
+
363
+ With a key declared, `row` and `rows` answer `RowResult.partition_id`
364
+ for every row that would be stored and `BatchResult.partition_count`
365
+ for the batch, and a body that would split into more partitions than
366
+ the call's `max_partitions_per_insert_block` allows is an ordinary
367
+ `Outcome.REJECTED` with `err_code` 252 (TOO_MANY_PARTS), the server's
368
+ own — a verdict, never an exception.
369
+
370
+ The return follows `set_engine`'s SIGN rule, not `set_ttl`'s: raises
371
+ `SchemaError` when the server's own CREATE path refuses the key (e.g.
372
+ 36 BAD_ARGUMENTS for a non-deterministic key, 549
373
+ DATA_TYPE_CANNOT_BE_USED_IN_KEY, with the server's message), and
374
+ `UnsupportedError` when this build declines (-1, a guarded exception)
375
+ or the artifact predates the symbol. -2 is a key the server accepts but
376
+ this build will not evaluate; a non-deterministic key is the server's
377
+ own rejection, 36 BAD_ARGUMENTS.
378
+ """
379
+ with self._mu:
380
+ rc, err = self._library._native.schema_partition_by(self._live(), expr)
381
+ if rc == 0:
382
+ return
383
+ raise _error_for(rc, err or "partition key refused")
384
+
352
385
  # -- rows ---------------------------------------------------------------
353
386
 
354
387
  def row(
@@ -823,6 +856,7 @@ class Library:
823
856
 
824
857
  __slots__ = (
825
858
  "_closed",
859
+ "_error_codes",
826
860
  "_image_key",
827
861
  "_native",
828
862
  "abi_revision",
@@ -836,6 +870,9 @@ class Library:
836
870
  self.path = path
837
871
  self.manifest = manifest
838
872
  self._closed = False
873
+ # This library's own error-code table, once built (see error_codes).
874
+ # Per Library and never shared: the table is a property of the build.
875
+ self._error_codes = _ErrorCodeCache()
839
876
  self._native = NativeLibrary(path)
840
877
  # The library names itself; nothing is inferred from the directory or
841
878
  # the file name.
@@ -1013,6 +1050,25 @@ class Library:
1013
1050
  """
1014
1051
  return [line for line in self._native.registered_families().split("\n") if line]
1015
1052
 
1053
+ def error_codes(self) -> ErrorCodeTable:
1054
+ """THIS library's own error-code table (`chs_error_codes`, revision 6):
1055
+ every code the vendored ClickHouse names, with the name it gives it —
1056
+ the table the server's `system.errors` enumerates and the name it
1057
+ prints after "Code: N." in an exception message.
1058
+
1059
+ The table belongs to the build, not to this package: codes join and
1060
+ leave between lines, and one number can name different errors on two
1061
+ lines (903 differs between 25.8 and 26.2). Ask the library whose line
1062
+ you are answering for; there is no package-level table.
1063
+
1064
+ Built on the first call and kept for this library's life — the answer
1065
+ never changes for a loaded library. Only a table that was actually
1066
+ built is kept: a NULL answer (a guarded exception inside the library)
1067
+ raises `ChtypesError` and the next call asks again. Raises
1068
+ `UnsupportedError` when the artifact predates `chs_error_codes`.
1069
+ """
1070
+ return self._error_codes.get(self._native.error_codes)
1071
+
1016
1072
  def function_flags(self) -> str:
1017
1073
  """TSV audit of every registered function's volatility, VERBATIM: one
1018
1074
  function per line, six tab-separated fields (`name`, `deterministic`,
@@ -492,6 +492,12 @@ class RowResult:
492
492
  # parse error, reported as DECLINE). 0/"" otherwise.
493
493
  verdict_code: int = 0
494
494
  verdict_err: str = ""
495
+ # Revision 6: the partition this row lands in, as the loaded build's own
496
+ # MergeTreePartition::getID spells it — present only when the schema
497
+ # declared a partition key (`Schema.set_partition_by`) and the row would
498
+ # be stored; None otherwise. Grouping an accepted batch's `spans` by it
499
+ # gives per-partition bodies, each directly INSERT-able.
500
+ partition_id: str | None = None
495
501
 
496
502
  @property
497
503
  def accepted(self) -> bool:
@@ -596,6 +602,12 @@ class BatchResult:
596
602
  # these being nonzero.
597
603
  rows_passed: int = 0
598
604
  rows_cut: int = 0
605
+ # Revision 6: the distinct partitions this batch's stored rows span —
606
+ # present only when the schema declared a partition key
607
+ # (`Schema.set_partition_by`); None otherwise. A batch over the call's
608
+ # max_partitions_per_insert_block is an ordinary REJECTED with err_code
609
+ # 252 (TOO_MANY_PARTS), the server's own.
610
+ partition_count: int | None = None
599
611
 
600
612
  @property
601
613
  def lossy_transforms(self) -> tuple[Transform, ...]:
@@ -0,0 +1,250 @@
1
+ """Revision 6's error-code table.
2
+
3
+ The first half needs no artifact: it parses documents in the shape
4
+ `chs_error_codes` returns through the SAME parser `Library.error_codes()` uses,
5
+ and drives the success-only cache with a stand-in for the native call. What it
6
+ pins is the binding's own contract — unknown is absent, `all()` is ascending,
7
+ names match exactly, a NULL answer is never remembered — and none of it is a
8
+ ClickHouse fact.
9
+
10
+ The second half asks a loaded revision-6 library and skips LOUDLY, by name,
11
+ without one.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+
22
+ import chtypes
23
+ from chtypes._error_codes import _ErrorCodeCache, parse_error_codes_document
24
+
25
+ # Out of order on purpose, with unknown keys at both levels, an entry with no
26
+ # name and a negative code — none of which may reach a lookup.
27
+ FAKE = b"""{
28
+ "error_codes": [
29
+ {"code": 252, "name": "TOO_MANY_PARTS", "since": "whatever"},
30
+ {"code": 0, "name": "OK"},
31
+ {"code": 1, "name": "UNSUPPORTED_METHOD"},
32
+ {"code": 7, "name": ""},
33
+ {"code": -2, "name": "NOT_A_CLICKHOUSE_CODE"},
34
+ {"code": 47, "name": "UNKNOWN_IDENTIFIER"}
35
+ ],
36
+ "generator": "ignored"
37
+ }"""
38
+
39
+ KNOWN = {0: "OK", 1: "UNSUPPORTED_METHOD", 47: "UNKNOWN_IDENTIFIER", 252: "TOO_MANY_PARTS"}
40
+
41
+
42
+ def test_lookups_answer_the_document_and_nothing_else() -> None:
43
+ table = parse_error_codes_document(FAKE)
44
+ for code, name in KNOWN.items():
45
+ assert table.name(code) == name
46
+ assert table.code(name) == code
47
+ # Absent, never synthesized: an unknown code, the unnamed entry, a
48
+ # negative code even though the document carried one, the ABI sentinels.
49
+ for code in (2, 7, 999_999, -1, -2, -3):
50
+ assert table.name(code) is None, code
51
+ # Exact and case-sensitive; no trimming, no folding.
52
+ for name in (
53
+ "too_many_parts",
54
+ "Too_Many_Parts",
55
+ " TOO_MANY_PARTS",
56
+ "TOO_MANY_PARTS ",
57
+ "",
58
+ "NOT_A_CLICKHOUSE_CODE",
59
+ "NO_SUCH_ERROR",
60
+ ):
61
+ assert table.code(name) is None, name
62
+
63
+
64
+ def test_all_and_iteration_are_ascending() -> None:
65
+ table = parse_error_codes_document(FAKE)
66
+ want = tuple(chtypes.ErrorCodeEntry(code=c, name=n) for c, n in sorted(KNOWN.items()))
67
+ assert table.all() == want
68
+ assert tuple(table) == want
69
+ assert len(table) == len(want)
70
+
71
+
72
+ def test_the_first_entry_wins_on_a_repeat() -> None:
73
+ table = parse_error_codes_document(
74
+ b'{"error_codes":[{"code":5,"name":"A_NAME"},{"code":5,"name":"B_NAME"},'
75
+ b'{"code":6,"name":"A_NAME"}]}'
76
+ )
77
+ assert table.name(5) == "A_NAME"
78
+ assert table.name(6) is None
79
+ assert len(table.all()) == 1
80
+
81
+
82
+ @pytest.mark.parametrize(
83
+ "doc",
84
+ [b"{}", b'{"error_codes":null}', b'{"error_codes":[]}', b'{"something_else":[1,2,3]}'],
85
+ )
86
+ def test_absent_keys_are_an_empty_table(doc: bytes) -> None:
87
+ table = parse_error_codes_document(doc)
88
+ assert table.all() == ()
89
+ assert table.name(0) is None
90
+
91
+
92
+ @pytest.mark.parametrize(
93
+ "doc",
94
+ # The same list every binding's bad-document test runs: a truncated
95
+ # document, a top-level value that is not an object, error_codes that is not
96
+ # an array, an entry that is not an object, and a field of the wrong type.
97
+ [
98
+ b'{"error_codes":[',
99
+ b"[]",
100
+ b"null",
101
+ b"42",
102
+ b'"x"',
103
+ b'{"error_codes":{}}',
104
+ b'{"error_codes":[null]}',
105
+ b'{"error_codes":[[252,"X"]]}',
106
+ b'{"error_codes":[{"code":"252","name":"X"}]}',
107
+ b'{"error_codes":[{"code":252.5,"name":"X"}]}',
108
+ b'{"error_codes":[{"code":1,"name":5}]}',
109
+ ],
110
+ )
111
+ def test_a_bad_document_is_the_general_error(doc: bytes) -> None:
112
+ with pytest.raises(chtypes.ChtypesError) as info:
113
+ parse_error_codes_document(doc)
114
+ assert not isinstance(info.value, (chtypes.SchemaError, chtypes.UnsupportedError))
115
+
116
+
117
+ def test_the_cache_keeps_a_built_table_and_nothing_else() -> None:
118
+ cache = _ErrorCodeCache()
119
+ calls: list[str] = []
120
+
121
+ def null() -> bytes | None:
122
+ calls.append("null")
123
+ return None
124
+
125
+ def missing() -> bytes | None:
126
+ calls.append("missing")
127
+ raise chtypes.UnsupportedError("this artifact predates chs_error_codes (rebuild it)")
128
+
129
+ def built() -> bytes | None:
130
+ calls.append("built")
131
+ return FAKE
132
+
133
+ # A NULL answer: the general error, not a decline, and not remembered.
134
+ with pytest.raises(chtypes.ChtypesError) as info:
135
+ cache.get(null)
136
+ assert not isinstance(info.value, chtypes.UnsupportedError)
137
+ # A missing symbol: the decline type, not remembered either.
138
+ with pytest.raises(chtypes.UnsupportedError):
139
+ cache.get(missing)
140
+ first = cache.get(built)
141
+ assert first.name(252) == "TOO_MANY_PARTS"
142
+
143
+ def never() -> bytes | None:
144
+ raise AssertionError("the cache asked the library again after a table was built")
145
+
146
+ assert cache.get(never) is first
147
+ assert calls == ["null", "missing", "built"]
148
+
149
+
150
+ def test_no_package_level_table() -> None:
151
+ """The table hangs off a Library; the package carries none."""
152
+ assert not hasattr(chtypes, "ERROR_CODES")
153
+ assert not hasattr(chtypes, "error_codes")
154
+
155
+
156
+ # ------------------------------------------------------- through the ABI fixture
157
+
158
+
159
+ def test_error_codes_through_the_abi_fixture(isolated_search_path: Path) -> None:
160
+ """`chs_error_codes` through the REAL ctypes path, on the at-revision stub
161
+ the ABI fixture builds from THIS header — the one library that exists
162
+ before any revision-6 artifact does, and the one every pull request's
163
+ abi-fixtures job loads. The stub answers by return type, not with a table,
164
+ so this pins the wiring rather than an answer: the symbol resolves (never
165
+ the decline type, never a refusal), and whatever it answers takes the rule
166
+ — a NULL is the general error and is not kept, a document is kept.
167
+
168
+ Nothing here closes the library: reopening after a close is measured to
169
+ segfault (docs/reference/bindings.md §Teardown)."""
170
+ env = os.environ.get("CHTYPES_ABI_FIXTURES")
171
+ if not env:
172
+ pytest.skip(
173
+ "no ABI revision fixture: $CHTYPES_ABI_FIXTURES is unset (build one with "
174
+ "abi-revision/gen.py build --out DIR --header include/chtypes.h)"
175
+ )
176
+ root = Path(env)
177
+ doc = json.loads((root / "fixture.json").read_text())
178
+ library = chtypes.Registry(root / "at-revision").for_version(doc["clickhouse_minor"])
179
+ try:
180
+ first = library.error_codes()
181
+ except (chtypes.UnsupportedError, chtypes.SchemaError) as exc:
182
+ pytest.fail(
183
+ f"error_codes() on a stub built from this header raised {exc!r}; the symbol is "
184
+ f"declared there, so neither a decline nor a refusal is a possible answer"
185
+ )
186
+ except chtypes.ChtypesError as exc:
187
+ assert "returned no document" in str(exc), exc
188
+ # Not kept: the next call asks the library again, and gets NULL again.
189
+ with pytest.raises(chtypes.ChtypesError, match="returned no document"):
190
+ library.error_codes()
191
+ else:
192
+ assert library.error_codes() is first, "a built table was not kept"
193
+
194
+
195
+ # ---------------------------------------------------------------- with artifacts
196
+
197
+
198
+ def _rev6_libraries(registry: chtypes.Registry) -> list[chtypes.Library]:
199
+ libs: list[chtypes.Library] = []
200
+ for version in registry.versions():
201
+ try:
202
+ lib = registry.for_version(version)
203
+ except chtypes.ChtypesError:
204
+ continue
205
+ if lib.abi_revision >= 6:
206
+ libs.append(lib)
207
+ if not libs:
208
+ pytest.skip(
209
+ f"{registry.directory} holds no ABI revision-6 artifact: every case here needs one — "
210
+ f"fetch one with `scripts/fetch.sh` (docs/guides/fetch.md)"
211
+ )
212
+ return libs
213
+
214
+
215
+ def test_error_codes_from_the_loaded_library(registry: chtypes.Registry) -> None:
216
+ for lib in _rev6_libraries(registry):
217
+ table = lib.error_codes()
218
+ entries = table.all()
219
+ assert entries, lib.minor
220
+ codes = [e.code for e in entries]
221
+ assert codes == sorted(set(codes)), f"{lib.minor}: all() is not strictly ascending"
222
+ for e in entries:
223
+ assert table.name(e.code) == e.name
224
+ assert table.code(e.name) == e.code
225
+ assert table.name(252) == "TOO_MANY_PARTS", lib.minor
226
+ assert table.name(-1) is None and table.name(-2) is None
227
+ assert lib.error_codes() is table, f"{lib.minor}: the table was not kept"
228
+
229
+
230
+ # The header's own example of one number naming different errors on different
231
+ # lines, per the producer's measurement of every served line. None is ABSENT (on
232
+ # 25.10), never a synthesized name. A line not named here (26.10 and later) has
233
+ # no expectation and is not checked.
234
+ ERROR_CODE_903: dict[str, str | None] = {
235
+ "25.3": "LICENSE_EXPIRED",
236
+ "25.8": "LICENSE_EXPIRED",
237
+ "25.10": None,
238
+ **{f"26.{m}": "DISTRIBUTED_CACHE_REGISTRY_SHUTDOWN" for m in range(2, 10)},
239
+ }
240
+
241
+
242
+ def test_code_903_differs_across_lines(registry: chtypes.Registry) -> None:
243
+ checked = 0
244
+ for lib in _rev6_libraries(registry):
245
+ if lib.minor not in ERROR_CODE_903:
246
+ continue
247
+ assert lib.error_codes().name(903) == ERROR_CODE_903[lib.minor], lib.minor
248
+ checked += 1
249
+ if not checked:
250
+ pytest.skip("no loaded revision-6 line has a documented expectation for code 903")
@@ -0,0 +1,169 @@
1
+ """Revision 6's partition key: the two result fields, the setter's sign rule,
2
+ and — with a revision-6 artifact — the key end to end.
3
+
4
+ The unit half parses documents through the SAME parsers every `row`/`rows`
5
+ call uses, and drives `Schema.set_partition_by` through a stand-in native
6
+ layer, so the rc -> exception mapping that ships is the one tested. The
7
+ artifact half skips LOUDLY, by name, without a revision-6 registry.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ import weakref
14
+
15
+ import pytest
16
+
17
+ import chtypes
18
+ from chtypes._document import parse_batch_document, parse_row_document
19
+
20
+
21
+ def test_partition_fields_parse() -> None:
22
+ batch = parse_batch_document(
23
+ b'{"outcome":"accepted","rows_read":3,"partition_count":2,"rows":['
24
+ b'{"outcome":"accepted","cols":[],"partition_id":"202601"},'
25
+ b'{"outcome":"accepted","cols":[],"partition_id":"202602"},'
26
+ b'{"outcome":"rejected","code":27,"err":"x","cols":[]}]}'
27
+ )
28
+ assert batch.partition_count == 2
29
+ assert [r.partition_id for r in batch.rows] == ["202601", "202602", None]
30
+
31
+
32
+ def test_partition_fields_absent_are_none() -> None:
33
+ batch = parse_batch_document(
34
+ b'{"outcome":"accepted","rows":[{"outcome":"accepted","cols":[]}]}'
35
+ )
36
+ assert batch.partition_count is None
37
+ assert batch.rows[0].partition_id is None
38
+ row = parse_row_document(b'{"outcome":"accepted","cols":[],"partition_id":"all"}')
39
+ assert row.partition_id == "all"
40
+ # A present zero is a real answer (a key, and no stored row), not absence.
41
+ assert parse_batch_document(b'{"outcome":"accepted","partition_count":0}').partition_count == 0
42
+
43
+
44
+ def test_too_many_parts_is_an_ordinary_rejection() -> None:
45
+ batch = parse_batch_document(
46
+ b'{"outcome":"rejected","code":252,"err":"Too many partitions for single INSERT block",'
47
+ b'"rows_read":2,"rows":[{"outcome":"accepted","cols":[],"partition_id":"1"},'
48
+ b'{"outcome":"accepted","cols":[],"partition_id":"2"}]}'
49
+ )
50
+ assert batch.outcome is chtypes.Outcome.REJECTED
51
+ assert batch.err_code == 252
52
+ assert len(batch.rows) == 2
53
+
54
+
55
+ class _StubNative:
56
+ """Just enough of NativeLibrary for Schema.set_partition_by to reach."""
57
+
58
+ def __init__(self, rc: int, err: str) -> None:
59
+ self.rc, self.err = rc, err
60
+ self.calls: list[str] = []
61
+
62
+ def schema_partition_by(self, handle: int, expr: str) -> tuple[int, str]:
63
+ self.calls.append(expr)
64
+ return self.rc, self.err
65
+
66
+ def schema_free(self, handle: int) -> None:
67
+ pass
68
+
69
+
70
+ class _StubLibrary:
71
+ def __init__(self, native: _StubNative) -> None:
72
+ self._native = native
73
+
74
+
75
+ def _schema_over(native: _StubNative) -> chtypes.Schema:
76
+ """A Schema whose native layer is the stub: the real method, a fake C side."""
77
+ schema = object.__new__(chtypes.Schema)
78
+ schema._library = _StubLibrary(native)
79
+ schema._handle = 1
80
+ schema._mu = threading.Lock()
81
+ schema._filters = weakref.WeakSet()
82
+ schema._blocks = weakref.WeakSet()
83
+ return schema
84
+
85
+
86
+ def test_set_partition_by_follows_the_engine_sign_rule() -> None:
87
+ ok = _StubNative(0, "")
88
+ _schema_over(ok).set_partition_by("toYYYYMM(ts)")
89
+ assert ok.calls == ["toYYYYMM(ts)"]
90
+
91
+ # Positive codes are the server's own refusal: 36 BAD_ARGUMENTS is what a
92
+ # non-deterministic key gets on every served line, 549 a key over a type
93
+ # the line will not key on.
94
+ for rc, expr in ((36, "rand()"), (549, "m")):
95
+ with pytest.raises(chtypes.SchemaError) as refused:
96
+ _schema_over(_StubNative(rc, "the server's own message")).set_partition_by(expr)
97
+ assert refused.value.code == rc
98
+ assert "the server's own message" in str(refused.value)
99
+
100
+ # Negative codes are declines: -2 a key the server accepts but this build
101
+ # will not evaluate, -1 a guarded exception.
102
+ for rc in (-1, -2):
103
+ with pytest.raises(chtypes.UnsupportedError) as declined:
104
+ _schema_over(_StubNative(rc, "why")).set_partition_by("k")
105
+ assert not isinstance(declined.value, chtypes.SchemaError)
106
+
107
+
108
+ def test_set_partition_by_empty_string_is_passed_through() -> None:
109
+ native = _StubNative(0, "")
110
+ _schema_over(native).set_partition_by("")
111
+ assert native.calls == [""]
112
+
113
+
114
+ # ---------------------------------------------------------------- with artifacts
115
+
116
+
117
+ def _rev6_libraries(registry: chtypes.Registry) -> list[chtypes.Library]:
118
+ libs: list[chtypes.Library] = []
119
+ for version in registry.versions():
120
+ try:
121
+ lib = registry.for_version(version)
122
+ except chtypes.ChtypesError:
123
+ continue
124
+ if lib.abi_revision >= 6:
125
+ libs.append(lib)
126
+ if not libs:
127
+ pytest.skip(
128
+ f"{registry.directory} holds no ABI revision-6 artifact: every case here needs one — "
129
+ f"fetch one with `scripts/fetch.sh` (docs/guides/fetch.md)"
130
+ )
131
+ return libs
132
+
133
+
134
+ BODY = (
135
+ b'{"ts":"2026-01-15 10:00:00","tenant":"a"}\n'
136
+ b'{"ts":"2026-01-20 10:00:00","tenant":"b"}\n'
137
+ b'{"ts":"2026-02-01 10:00:00","tenant":"a"}\n'
138
+ )
139
+
140
+
141
+ def test_partition_key_end_to_end(registry: chtypes.Registry) -> None:
142
+ for lib in _rev6_libraries(registry):
143
+ with lib.compile_ddl("ts DateTime, tenant String") as schema:
144
+ plain = schema.rows(chtypes.Format.JSON_EACH_ROW, BODY)
145
+ assert plain.partition_count is None and plain.rows[0].partition_id is None
146
+
147
+ schema.set_partition_by("toYYYYMM(ts)")
148
+ keyed = schema.rows(chtypes.Format.JSON_EACH_ROW, BODY)
149
+ assert keyed.outcome is chtypes.Outcome.ACCEPTED, lib.minor
150
+ assert keyed.partition_count == 2, lib.minor
151
+ p0, p1, p2 = (r.partition_id for r in keyed.rows)
152
+ assert p0 and p0 == p1 and p0 != p2, (lib.minor, p0, p1, p2)
153
+
154
+ over = schema.rows(
155
+ chtypes.Format.JSON_EACH_ROW, BODY, {"max_partitions_per_insert_block": "1"}
156
+ )
157
+ assert over.outcome is chtypes.Outcome.REJECTED, lib.minor
158
+ assert over.err_code == 252, lib.minor
159
+ assert len(over.rows) == 3, lib.minor
160
+
161
+ schema.set_partition_by("")
162
+ cleared = schema.rows(chtypes.Format.JSON_EACH_ROW, BODY)
163
+ assert cleared.partition_count is None and cleared.rows[0].partition_id is None
164
+
165
+ # A non-deterministic key is the server's own rejection — 36
166
+ # BAD_ARGUMENTS on every served line — not a decline.
167
+ with pytest.raises(chtypes.SchemaError) as refused:
168
+ schema.set_partition_by("rand()")
169
+ assert refused.value.code == 36, lib.minor
@@ -4,7 +4,7 @@ requires-python = ">=3.11"
4
4
 
5
5
  [[package]]
6
6
  name = "chtypes"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  source = { editable = "." }
9
9
 
10
10
  [package.dev-dependencies]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes