chtypes 2.0.0.dev1__py3-none-any.whl → 2.0.0.dev3__py3-none-any.whl

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.
chtypes/__init__.py CHANGED
@@ -53,13 +53,14 @@ from ._abi2._vocab import (
53
53
  DocFlags,
54
54
  FilterOutcome,
55
55
  Format,
56
+ MergeReason,
56
57
  Outcome,
57
58
  Reason,
58
59
  Source,
59
60
  Status,
60
61
  Verdict,
61
62
  )
62
- from ._input import BytesIn, Settings
63
+ from ._input import BytesIn, ServerProfile, Settings
63
64
  from ._setup import setup
64
65
  from .errors import (
65
66
  CODE_ARTIFACT_CORRUPT,
@@ -92,9 +93,10 @@ from .errors import (
92
93
  UnsupportedError,
93
94
  UsageError,
94
95
  )
95
- from .library import Block, Filter, Library, Schema, open_unverified
96
- from .registry import FetchOptions, Registry, Resolved, TrustedKey
96
+ from .library import Block, Filter, Library, Schema, Server, open_unverified
97
+ from .registry import FetchOptions, Registry, Resolved, TrustedKey, cache_root, search_dirs
97
98
  from .results import (
99
+ AtMergeEntry,
98
100
  BatchResult,
99
101
  BuildInfo,
100
102
  Capabilities,
@@ -111,6 +113,8 @@ from .results import (
111
113
  Header,
112
114
  RowResult,
113
115
  SchemaDescription,
116
+ SchemaReplicated,
117
+ SchemaServer,
114
118
  Span,
115
119
  Transform,
116
120
  Value,
@@ -136,6 +140,7 @@ __all__ = [
136
140
  "ArtifactPinnedError",
137
141
  "ArtifactUnpublishedError",
138
142
  "ArtifactUntrustedError",
143
+ "AtMergeEntry",
139
144
  "BatchResult",
140
145
  "Block",
141
146
  "BuildInfo",
@@ -164,6 +169,7 @@ __all__ = [
164
169
  "Header",
165
170
  "InternalError",
166
171
  "Library",
172
+ "MergeReason",
167
173
  "Outcome",
168
174
  "Reason",
169
175
  "Registry",
@@ -172,6 +178,10 @@ __all__ = [
172
178
  "Schema",
173
179
  "SchemaDescription",
174
180
  "SchemaError",
181
+ "SchemaReplicated",
182
+ "SchemaServer",
183
+ "Server",
184
+ "ServerProfile",
175
185
  "Settings",
176
186
  "Source",
177
187
  "SourceForbiddenError",
@@ -186,7 +196,9 @@ __all__ = [
186
196
  "UsageError",
187
197
  "Value",
188
198
  "Verdict",
199
+ "cache_root",
189
200
  "open_unverified",
201
+ "search_dirs",
190
202
  "setup",
191
203
  ]
192
204
 
chtypes/__main__.py CHANGED
@@ -5,7 +5,7 @@
5
5
  [--frozen] [--offline] [--update] [--strict]
6
6
  chtypes verify [--cache <dir>] [--strict] re-verify every installed build
7
7
  chtypes list [--cache <dir>] [--offline] [--strict]
8
- chtypes where [--cache <dir>] [--strict] the cache root
8
+ chtypes where [--cache <dir>] [--strict] [--all] the cache root; --all: every search dir
9
9
 
10
10
  A spelling is `26.8`, `26.8.15` or `26.8.15.10`. Exit statuses come from the
11
11
  `errors` table of spec/fetch-v1/constants.json (generated into the fetch layer
@@ -137,7 +137,10 @@ def build_parser() -> argparse.ArgumentParser:
137
137
  fetch.add_argument(
138
138
  "--offline",
139
139
  action="store_true",
140
- help="never touch a source: installed and verified, or CHTYPES_ARTIFACT_MISSING",
140
+ help=(
141
+ "never touch a source: installed and verified, or CHTYPES_ARTIFACT_MISSING "
142
+ "(or set CHTYPES_OFFLINE=1)"
143
+ ),
141
144
  )
142
145
 
143
146
  verify = sub.add_parser(
@@ -154,11 +157,22 @@ def build_parser() -> argparse.ArgumentParser:
154
157
  )
155
158
  cache_option(listing)
156
159
  listing.add_argument(
157
- "--offline", action="store_true", help="list only what is installed; no network"
160
+ "--offline",
161
+ action="store_true",
162
+ help="list only what is installed; no network (or set CHTYPES_OFFLINE=1)",
158
163
  )
159
164
 
160
- where = sub.add_parser("where", help="the cache root", description="Print the cache root.")
165
+ where = sub.add_parser(
166
+ "where",
167
+ help="the cache root",
168
+ description="Print the cache root; with --all, every directory searched.",
169
+ )
161
170
  cache_option(where)
171
+ where.add_argument(
172
+ "--all",
173
+ action="store_true",
174
+ help="every directory searched, in order, the cache root first (one per line)",
175
+ )
162
176
  return parser
163
177
 
164
178
 
@@ -294,7 +308,7 @@ def _cmd_list(args: argparse.Namespace) -> int:
294
308
  sys.stdout.write(f"installed {r.version} {r.platform} {r.dir}\n")
295
309
  sys.stdout.flush()
296
310
  _say_notes(options)
297
- if not args.offline:
311
+ if not options.resolved_offline():
298
312
  for tag in _published_tags(options):
299
313
  sys.stdout.write(f"published {tag} support unknown\n")
300
314
  sys.stdout.flush()
@@ -306,6 +320,10 @@ def _cmd_where(args: argparse.Namespace) -> int:
306
320
  if options.resolved_strict():
307
321
  # Strict mode checks the root before naming it.
308
322
  probe_roots(search_roots(options.cache_dir, options.system_dirs), strict=True)
323
+ if args.all:
324
+ for directory in search_roots(options.cache_dir, options.system_dirs):
325
+ sys.stdout.write(f"{directory}\n")
326
+ return EXIT_OK
309
327
  sys.stdout.write(f"{resolve_cache_root(args.cache)}\n")
310
328
  return EXIT_OK
311
329
 
chtypes/_abi2/_decls.py CHANGED
@@ -1,4 +1,4 @@
1
- # GENERATED by scripts/abi-v1/gen.py --major 2 from spec/abi-v2/abi.json (CHS_ABI_FINGERPRINT sha256:d87e91be2bbe75b985c38224268c2f7dc5d8236dab0a4b4d61357c6dfdc1a232) — DO NOT EDIT # noqa: E501
1
+ # GENERATED by scripts/abi-v1/gen.py --major 2 from spec/abi-v2/abi.json (CHS_ABI_FINGERPRINT sha256:4129dccc3f4e698dedb0e99cae413f5240085f633c5a1244e2c55bf7a7bb6c3a) — DO NOT EDIT # noqa: E501
2
2
  """The Python binding's generated ABI v2 layer: ctypes signatures, the handle
3
3
  classes, the two-phase resolve functions and invoke_by_name(), the test-only
4
4
  dispatcher the conformance suite drives from tests/fixtures/abi-v2/cases.json.
@@ -19,7 +19,7 @@ from dataclasses import dataclass, field
19
19
  from . import _errmap
20
20
 
21
21
  CHS_ABI_VERSION = 2
22
- CHS_ABI_FINGERPRINT = "sha256:d87e91be2bbe75b985c38224268c2f7dc5d8236dab0a4b4d61357c6dfdc1a232"
22
+ CHS_ABI_FINGERPRINT = "sha256:4129dccc3f4e698dedb0e99cae413f5240085f633c5a1244e2c55bf7a7bb6c3a"
23
23
 
24
24
  # The description's stability: "unstable" while this generation is being
25
25
  # designed, so CHS_ABI_FINGERPRINT moves with every change, and "locked" after
chtypes/_abi2/_errmap.py CHANGED
@@ -1,4 +1,4 @@
1
- # GENERATED by scripts/abi-v1/gen.py --major 2 from spec/abi-v2/abi.json (CHS_ABI_FINGERPRINT sha256:d87e91be2bbe75b985c38224268c2f7dc5d8236dab0a4b4d61357c6dfdc1a232) — DO NOT EDIT # noqa: E501
1
+ # GENERATED by scripts/abi-v1/gen.py --major 2 from spec/abi-v2/abi.json (CHS_ABI_FINGERPRINT sha256:4129dccc3f4e698dedb0e99cae413f5240085f633c5a1244e2c55bf7a7bb6c3a) — DO NOT EDIT # noqa: E501
2
2
  """spec/abi-v2/sdk.json's error-mapping tables, as references to the
3
3
  public classes (python/src/chtypes/errors.py, re-exported by
4
4
  python/src/chtypes/_abi2/_errors.py). Only the MAPPING is generated: the
chtypes/_abi2/_vocab.py CHANGED
@@ -1,10 +1,10 @@
1
- # GENERATED by scripts/abi-v1/gen.py --major 2 from spec/abi-v2/abi.json (CHS_ABI_FINGERPRINT sha256:d87e91be2bbe75b985c38224268c2f7dc5d8236dab0a4b4d61357c6dfdc1a232) — DO NOT EDIT # noqa: E501
1
+ # GENERATED by scripts/abi-v1/gen.py --major 2 from spec/abi-v2/abi.json (CHS_ABI_FINGERPRINT sha256:4129dccc3f4e698dedb0e99cae413f5240085f633c5a1244e2c55bf7a7bb6c3a) — DO NOT EDIT # noqa: E501
2
2
  """The Python binding's generated vocabularies, from spec/abi-v2/abi.json: the C
3
3
  enums (`Format`, `Status`), the document vocabularies (`Outcome`, `FilterOutcome`,
4
- `Verdict`, `DefaultKind`, `Reason`, `Source`, `DiscoverQueryParam`) with the facts the
5
- description attaches to each value (`Format.ch_name`, `Reason` lossy, `Source`
6
- is_stored, `Verdict.answered`), and the `DocFlags` groups. No other file in this
7
- package keeps a copy of any of it.
4
+ `Verdict`, `DefaultKind`, `MergeReason`, `Reason`, `Source`, `DiscoverQueryParam`) with the
5
+ facts the description attaches to each value (`Format.ch_name`, `Reason` lossy,
6
+ `Source` is_stored, `Verdict.answered`), and the `DocFlags` groups. No other file in
7
+ this package keeps a copy of any of it.
8
8
 
9
9
  Rule r3 (spec/abi-v2/docs.md): every vocabulary has an unknown(n) member, and a
10
10
  value the description does not list reads as it, carrying the raw value, for that
@@ -225,6 +225,30 @@ class DefaultKind(StrEnum):
225
225
  return cls(text)
226
226
 
227
227
 
228
+ class MergeReason(StrEnum):
229
+ """What a merge would do to one row of the part an INSERT writes, as a batch's
230
+ `at_merge` reports it."""
231
+
232
+ TTL_DELETE = "ttl_delete"
233
+ TTL_COLUMN_RESET = "ttl_column_reset"
234
+
235
+ @classmethod
236
+ def _missing_(cls, value: object) -> MergeReason | None:
237
+ return _unknown_str(cls, value)
238
+
239
+ @property
240
+ def known(self) -> bool:
241
+ """Whether the description lists this value: False for exactly the unknown(n)
242
+ member an unlisted value reads as (rule r3)."""
243
+ return type(self)._value2member_map_.get(self._value_) is self
244
+
245
+ @classmethod
246
+ def of(cls, text: str) -> MergeReason:
247
+ """Map a document's spelling. One the description does not list is kept as
248
+ this vocabulary's unknown(n) member, carrying it (rule r3)."""
249
+ return cls(text)
250
+
251
+
228
252
  class DiscoverQueryParam(StrEnum):
229
253
  """A query parameter of the SQL `discover_query` returns, which the caller binds
230
254
  when it runs the query."""
@@ -383,6 +407,7 @@ DESCRIBED_VOCABULARIES: Final[dict[str, type]] = {
383
407
  "chs_status": Status,
384
408
  "chs_format": Format,
385
409
  "transform_reason": Reason,
410
+ "merge_reason": MergeReason,
386
411
  "value_src": Source,
387
412
  "row_outcome": Outcome,
388
413
  "batch_outcome": Outcome,
chtypes/_decode.py CHANGED
@@ -28,9 +28,10 @@ import json
28
28
  from collections.abc import Callable, Mapping
29
29
  from typing import Any
30
30
 
31
- from ._abi2._vocab import DefaultKind, FilterOutcome, Outcome, Reason, Source, Verdict
31
+ from ._abi2._vocab import DefaultKind, FilterOutcome, MergeReason, Outcome, Reason, Source, Verdict
32
32
  from .errors import ArtifactCorruptError, internal
33
33
  from .results import (
34
+ AtMergeEntry,
34
35
  BatchResult,
35
36
  BuildInfo,
36
37
  Capabilities,
@@ -47,6 +48,8 @@ from .results import (
47
48
  Header,
48
49
  RowResult,
49
50
  SchemaDescription,
51
+ SchemaReplicated,
52
+ SchemaServer,
50
53
  Span,
51
54
  Transform,
52
55
  Value,
@@ -323,6 +326,29 @@ def _engine_cell(obj: dict[str, Any], where: str) -> EngineCell:
323
326
  )
324
327
 
325
328
 
329
+ def _int_list(obj: Mapping[str, Any], key: str, where: str) -> tuple[int, ...] | None:
330
+ """A list of non-negative integers: None when absent."""
331
+ if obj.get(key) is None:
332
+ return None
333
+ out = []
334
+ for item in _list(obj, key, where):
335
+ if isinstance(item, bool) or not isinstance(item, int) or item < 0:
336
+ raise _wrong(where, key, "an array of non-negative integers", item)
337
+ out.append(item)
338
+ return tuple(out)
339
+
340
+
341
+ def _at_merge(obj: dict[str, Any], where: str) -> AtMergeEntry:
342
+ # An unlisted reason is its unknown(n): kept, never a failure (rule r3).
343
+ return AtMergeEntry(
344
+ row=_int(obj, "row", where),
345
+ reason=MergeReason.of(_text(obj, "reason", where)),
346
+ column=_bytes_or_none(obj, "column", where),
347
+ stored=_bytes_or_none(obj, "stored", where),
348
+ input_rows=_int_list(obj, "input_rows", where),
349
+ )
350
+
351
+
326
352
  def _engine_rows(obj: Mapping[str, Any], where: str) -> tuple[tuple[EngineCell, ...], ...]:
327
353
  rows = []
328
354
  for i, row in enumerate(_list(obj, "engine_rows", where)):
@@ -409,6 +435,8 @@ def decode_batch(raw: bytes, export: bytes | None) -> BatchResult:
409
435
  framing=None
410
436
  if framing is None
411
437
  else _framing(_obj(framing, where, "framing"), f"{where}.framing"),
438
+ unsupported_settings=_name_list(obj, "unsupported_settings", where),
439
+ at_merge=_objects(obj, "at_merge", where, _at_merge),
412
440
  )
413
441
 
414
442
 
@@ -449,10 +477,55 @@ def _column(obj: dict[str, Any], where: str) -> Column:
449
477
  )
450
478
 
451
479
 
480
+ def _string_map(obj: Mapping[str, Any], key: str, where: str) -> dict[str, str] | None:
481
+ """An object of plain JSON strings (a server's settings or macros, the
482
+ caller's own values given back): None when absent, a dict, even an empty
483
+ one, when present, so absent and `{}` stay apart."""
484
+ value = obj.get(key)
485
+ if value is None:
486
+ return None
487
+ inner = _obj(value, where, key)
488
+ out: dict[str, str] = {}
489
+ for name, item in inner.items():
490
+ if not isinstance(item, str):
491
+ raise _wrong(f"{where}.{key}", name, "a string", item)
492
+ out[name] = item
493
+ return out
494
+
495
+
496
+ def _server(obj: dict[str, Any], where: str) -> SchemaServer:
497
+ return SchemaServer(
498
+ timezone=_text(obj, "timezone", where),
499
+ settings=_string_map(obj, "settings", where) or {},
500
+ macros=_string_map(obj, "macros", where),
501
+ )
502
+
503
+
504
+ def _replicated(obj: dict[str, Any], where: str) -> SchemaReplicated:
505
+ return SchemaReplicated(
506
+ zookeeper_path=_bytes(obj, "zookeeper_path", where),
507
+ replica_name=_bytes(obj, "replica_name", where),
508
+ )
509
+
510
+
452
511
  def decode_schema_description(raw: bytes) -> SchemaDescription:
453
512
  where = "schema_description"
454
513
  obj = _obj(strict_loads(raw, where), where)
455
- return SchemaDescription(columns=_objects(obj, "columns", where, _column))
514
+ # Both are absent on a schema compiled without a server, so that document
515
+ # decodes exactly as it did before servers existed.
516
+ server = obj.get("server")
517
+ replicated = obj.get("replicated")
518
+ return SchemaDescription(
519
+ columns=_objects(obj, "columns", where, _column),
520
+ server=None
521
+ if server is None
522
+ else _server(_obj(server, where, "server"), f"{where}.server"),
523
+ replicated=(
524
+ None
525
+ if replicated is None
526
+ else _replicated(_obj(replicated, where, "replicated"), f"{where}.replicated")
527
+ ),
528
+ )
456
529
 
457
530
 
458
531
  def _discovered(obj: dict[str, Any], where: str) -> DiscoveredColumn:
chtypes/_input.py CHANGED
@@ -12,10 +12,11 @@ from __future__ import annotations
12
12
  import base64
13
13
  import json
14
14
  from collections.abc import Mapping, Sequence
15
+ from dataclasses import dataclass
15
16
 
16
17
  from .errors import misuse
17
18
 
18
- __all__ = ["BytesIn", "Settings"]
19
+ __all__ = ["BytesIn", "ServerProfile", "Settings"]
19
20
 
20
21
  BytesIn = bytes | str
21
22
  Settings = Mapping[str, str]
@@ -107,3 +108,43 @@ def columns_json(columns: Sequence[BytesIn] | None) -> bytes | None:
107
108
  except UnicodeDecodeError:
108
109
  names.append({"name_b64": base64.b64encode(raw).decode("ascii")})
109
110
  return _dump(names)
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class ServerProfile:
115
+ """One ClickHouse server, as the library's `input:server_profile` describes
116
+ it. Every field is optional and passed through as given: the library
117
+ validates the zone with DateLUT, each setting with the server's own SET
118
+ check, and the macros with ClickHouse's own reader. The binding validates
119
+ nothing in it.
120
+
121
+ `timezone` is the server's zone, the one its `timezone()` returns; None or
122
+ "" leaves it undescribed (omitted) and the image zone applies. `settings` is
123
+ what the server's profile applies to every query, layered under a schema's
124
+ and a call's own; None omits it, and `{}` is sent as `{}`. `macros` is the
125
+ server's `<macros>`: None omits it, and the server's macros are UNKNOWN (a
126
+ schema whose engine reads one is declined); a mapping, even an empty one, is
127
+ sent (`{}` when empty) and is the server's COMPLETE set."""
128
+
129
+ timezone: str | None = None
130
+ settings: Settings | None = None
131
+ macros: Settings | None = None
132
+
133
+
134
+ def server_profile_json(profile: ServerProfile) -> bytes:
135
+ """The profile document: an empty or absent zone and an absent map are
136
+ omitted; a present map is sent as given, `{}` when empty. Absent `macros` is
137
+ not empty `macros`."""
138
+ if not isinstance(profile, ServerProfile):
139
+ raise TypeError(f"profile must be a ServerProfile, not {type(profile).__name__}")
140
+ doc: dict[str, object] = {}
141
+ if profile.timezone is not None:
142
+ if not isinstance(profile.timezone, str):
143
+ raise TypeError(f"timezone must be str, not {type(profile.timezone).__name__}")
144
+ if profile.timezone != "":
145
+ doc["timezone"] = profile.timezone
146
+ if profile.settings is not None:
147
+ doc["settings"] = _string_map(profile.settings, "settings")
148
+ if profile.macros is not None:
149
+ doc["macros"] = _string_map(profile.macros, "macros")
150
+ return _dump(doc)
@@ -18,6 +18,19 @@ five ways:
18
18
  used through its subroot `<cache>/v2-dev`, never as a whole layout;
19
19
  - a signed predicate must say abi 2.
20
20
 
21
+ It also resolves one tag the v1 contract never asks for: a version request
22
+ fetches `<tag>--fp-<its own fingerprint>` first, the newest dev build of the ABI
23
+ this binding speaks, and falls back to `<tag>` only when no base has that alias
24
+ (docs/guides/fetch-v1.md §3, "The dev channel's alias step"), so a dev build of a
25
+ newer fingerprint never strands this one. Its cache lookups (`resolve_installed`,
26
+ and `ensure`'s offline answer and its monotonic rule) see only the records whose
27
+ signed `abi_fingerprint` is its own: a build of another fingerprint in a shared
28
+ cache is never returned and never kept over this one's own, and it is never
29
+ removed or rewritten either. It is automatic, with no override; the trust checks
30
+ are unchanged, and a listing never shows an alias. The fingerprint
31
+ is the generated `CHS_ABI_FINGERPRINT` of the ABI layer (`chtypes._abi2._decls`),
32
+ never a hand-written copy.
33
+
21
34
  The v1 contract itself stays in this package, unchanged, because the fetch-v1
22
35
  conformance cases (tests/fixtures/fetch-v1) are its specification and the dev
23
36
  channel shares every other rule with it. Only a test run reaches it:
@@ -29,16 +42,19 @@ override a user can reach.
29
42
  from __future__ import annotations
30
43
 
31
44
  import os
45
+ import re
32
46
  import sys
33
47
  import threading
34
48
  from collections.abc import Callable, Sequence
35
49
  from dataclasses import dataclass, replace
36
50
  from typing import Final, Protocol
37
51
 
52
+ from chtypes._abi2._decls import CHS_ABI_FINGERPRINT
38
53
  from chtypes._ocifetch import _constants as C
39
54
  from chtypes._ocifetch._dsse import TrustedKey
40
55
 
41
56
  __all__ = [
57
+ "ALIAS_SEPARATOR",
42
58
  "DEV_ABI_GENERATION",
43
59
  "DEV_CACHE_DIR",
44
60
  "DEV_CHANNEL_BASE",
@@ -50,12 +66,15 @@ __all__ = [
50
66
  "PinningRefusedError",
51
67
  "abi",
52
68
  "active",
69
+ "alias_tag",
53
70
  "allow_overrides_for_tests",
54
71
  "channel_name",
55
72
  "enforce",
56
73
  "ignored_settings_warned",
57
74
  "pinning_requested",
58
75
  "trusted_keys",
76
+ "use_own_fingerprint_for_tests",
77
+ "visible",
59
78
  "use_dev_channel_for_tests",
60
79
  "use_fetch_v1_for_tests",
61
80
  ]
@@ -69,12 +88,20 @@ DEV_KEY_ID: Final = "824345f9bcf8e5bf"
69
88
  DEV_KEY_HEX: Final = "5cd30c53c65a1ebc2d85836a41deb06661bb0ae7b658adb9eb116ec2db8e9b1c"
70
89
  # The dev channel's cache directory name: the default root's last element and
71
90
  # an explicit cache's subroot (rule r5).
91
+ # The environment twin of the offline option (public issue #528): CHTYPES_OFFLINE=1
92
+ # reads the cache only and makes no request. It names no source, so rule r6 holds.
93
+ # Not a generated constant: the generated set is the v1 fetch contract's.
94
+ ENV_OFFLINE_NAME: Final = "CHTYPES_OFFLINE"
72
95
  DEV_CACHE_DIR: Final = "v2-dev"
73
96
  # The verified.json schema the dev channel writes, and the only one it reads (rule r5).
74
97
  DEV_RECORD_SCHEMA: Final = 2
75
98
  # The abi a dev predicate must carry.
76
99
  DEV_ABI_GENERATION: Final = 2
77
100
 
101
+ # Joins a tag and a fingerprint into the dev channel's alias tag:
102
+ # <tag>--fp-<64 lowercase hex> (docs/guides/fetch-v1.md §3).
103
+ ALIAS_SEPARATOR: Final = "--fp-"
104
+
78
105
  # What every lock, frozen or update request gets from a 2.0.0-dev SDK, before
79
106
  # any network call (rule r6).
80
107
  PINNING_REFUSED: Final = (
@@ -98,6 +125,11 @@ class Channel:
98
125
  keys: tuple[dict[str, str], ...] # {"keyid", "ed25519_hex"}: the default trust
99
126
  overridable: bool # the base, trust and unsigned overrides are honored
100
127
  pinnable: bool # lock, frozen and update are honored
128
+ # The fingerprint, 64 lowercase hex, this contract's SDK speaks: a version
129
+ # request resolves its alias tag before the tag itself, and the cache
130
+ # lookups see only records signed with it. "" does neither (the v1 contract
131
+ # and every production channel).
132
+ own_fingerprint: str = ""
101
133
 
102
134
 
103
135
  # What a 2.0.0-dev SDK speaks, and what every process speaks unless a test
@@ -113,6 +145,7 @@ DEV_CHANNEL: Final = Channel(
113
145
  keys=({"keyid": DEV_KEY_ID, "ed25519_hex": DEV_KEY_HEX},),
114
146
  overridable=False,
115
147
  pinnable=False,
148
+ own_fingerprint=CHS_ABI_FINGERPRINT.removeprefix("sha256:"),
116
149
  )
117
150
 
118
151
  # The v1 contract the fetch-v1 conformance cases specify, from the generated
@@ -198,6 +231,49 @@ def allow_overrides_for_tests() -> Callable[[], None]:
198
231
  return _use(replace(DEV_CHANNEL, overridable=True))
199
232
 
200
233
 
234
+ _FINGERPRINT_HEX = re.compile(r"[0-9a-f]{64}")
235
+
236
+
237
+ def use_own_fingerprint_for_tests(fingerprint: str) -> Callable[[], None]:
238
+ """Make this TEST run's active contract speak `fingerprint` (64 lowercase
239
+ hex) as the dev channel speaks its own: a version request resolves that
240
+ fingerprint's alias tag first, and the cache lookups see only records
241
+ signed with it. The fetch-v1 conformance cases that carry
242
+ `request.own_fingerprint` run under it, against fixtures that name a
243
+ fixture fingerprint. Everything else stays the active contract's. Returns
244
+ the restore function; raises outside a test run or on a fingerprint that is
245
+ not 64 lowercase hex."""
246
+ _test_only("use_own_fingerprint_for_tests")
247
+ if not _FINGERPRINT_HEX.fullmatch(fingerprint):
248
+ raise ValueError(f"use_own_fingerprint_for_tests: {fingerprint!r} is not 64 lowercase hex")
249
+ return _use(replace(active(), own_fingerprint=fingerprint))
250
+
251
+
252
+ def alias_tag(tag: str, channel: Channel | None = None) -> str | None:
253
+ """The alias a version request for `tag` resolves first under a contract
254
+ with an own fingerprint, or None when it resolves `tag` alone: under the
255
+ v1 contract and every production channel, and for a request that is not a
256
+ version spelling (an arbitrary tag has no alias)."""
257
+ channel = channel or active()
258
+ if not channel.own_fingerprint or not re.fullmatch(C.SPELLING_REGEX, tag):
259
+ return None
260
+ return f"{tag}{ALIAS_SEPARATOR}{channel.own_fingerprint}"
261
+
262
+
263
+ def visible(predicate: object, channel: Channel | None = None) -> bool:
264
+ """Whether a cache record (or a pre-seeded entry) whose SIGNED predicate is
265
+ `predicate` may answer a request under the active contract: under one with
266
+ an own fingerprint (the dev channel), only when the predicate's
267
+ `abi_fingerprint` is that fingerprint; under every other, always. A record
268
+ it cannot see is never returned and never kept by the monotonic rule, and
269
+ nothing removes or rewrites it: another SDK of another fingerprint owns it."""
270
+ channel = channel or active()
271
+ if not channel.own_fingerprint:
272
+ return True
273
+ fingerprint = predicate.get("abi_fingerprint") if isinstance(predicate, dict) else None
274
+ return fingerprint == f"sha256:{channel.own_fingerprint}"
275
+
276
+
201
277
  def use_dev_channel_for_tests() -> Callable[[], None]:
202
278
  """Make this TEST run speak the dev channel exactly as a user's process does
203
279
  (undoing either function above, for a test of the dev channel itself).
@@ -137,6 +137,8 @@ class Options:
137
137
  token: str | None = None
138
138
  trusted_keys: tuple[TrustedKey, ...] | None = None
139
139
  allow_unsigned: bool = False
140
+ # Offline mode is on when this is set OR CHTYPES_OFFLINE=1; neither turns
141
+ # the other off (public issue #528).
140
142
  offline: bool = False
141
143
  frozen: bool = False
142
144
  lock_path: str | os.PathLike[str] | None = None
@@ -185,6 +187,9 @@ class Options:
185
187
  def resolved_platform(self) -> str:
186
188
  return self.platform or detect_host_platform()
187
189
 
190
+ def resolved_offline(self) -> bool:
191
+ return self.offline or os.environ.get(_channel.ENV_OFFLINE_NAME) == "1"
192
+
188
193
  def resolved_strict(self) -> bool:
189
194
  if self.strict_cache is not None:
190
195
  return self.strict_cache
@@ -350,6 +355,10 @@ def _newer_installed(
350
355
  (`26.3.4.1`) keeps only a newer BUILD of that exact version. A request
351
356
  that is not a version spelling (an arbitrary tag) names no range, so
352
357
  nothing is kept for it.
358
+
359
+ Under the dev channel only an install of its own fingerprint counts
360
+ (`_channel.visible`): a newer build of another fingerprint, installed by
361
+ another dev SDK, is never kept over this one's own.
353
362
  """
354
363
  if not re.match(C.SPELLING_REGEX, request_spelling):
355
364
  return None
@@ -363,6 +372,8 @@ def _newer_installed(
363
372
  for dir_, record in list_verified_records(roots):
364
373
  if record.platform != platform_key:
365
374
  continue
375
+ if not _channel.visible(record.predicate):
376
+ continue # another fingerprint's build (_channel.visible): never kept over this one's
366
377
  if not version_within_request(record.version, request_spelling):
367
378
  continue
368
379
  version_key = _version_key(record.version)
@@ -688,7 +699,11 @@ def _ensure_floating(
688
699
  scratch_root = _scratch_root(cache_root_path)
689
700
 
690
701
  index_doc, index_bytes, index_digest = fetch_manifest_by_tag(
691
- bases, request.spelling, policy=policy, retry=retry
702
+ bases,
703
+ request.spelling,
704
+ policy=policy,
705
+ retry=retry,
706
+ alias=_channel.alias_tag(request.spelling),
692
707
  )
693
708
  platform_desc = resolve_platform_manifest(index_doc, platform_key)
694
709
  manifest_hex = parse_digest(platform_desc.digest)
@@ -1012,7 +1027,7 @@ def _ensure(
1012
1027
  request: Request, platform_key: str, options: Options, lock: Lock | None, roots: Sequence[Path]
1013
1028
  ) -> Resolved:
1014
1029
  try:
1015
- if options.offline:
1030
+ if options.resolved_offline():
1016
1031
  resolved = resolve_installed(request, platform_key, options)
1017
1032
  if resolved is None:
1018
1033
  raise ArtifactMissingError(
@@ -1045,6 +1060,7 @@ def _verify_and_install_from_local_blobs(
1045
1060
  manifest_digest: str,
1046
1061
  cache_write_root: Path,
1047
1062
  trusted_keys: tuple[TrustedKey, ...],
1063
+ visible_only: bool = False,
1048
1064
  ) -> VerifiedRecord | None:
1049
1065
  """docs/guides/fetch-v1.md §1/§6: "A pre-seeded layout has entries in
1050
1066
  `index.json` with no corresponding `unpacked/` directory yet; the first
@@ -1098,6 +1114,9 @@ def _verify_and_install_from_local_blobs(
1098
1114
  return None
1099
1115
 
1100
1116
  predicate = verified.statement.predicate
1117
+ if visible_only and not _channel.visible(predicate):
1118
+ # A lookup never installs another fingerprint's pre-seeded build.
1119
+ return None
1101
1120
  layer_hex = parse_digest(layer_desc.digest)
1102
1121
  if layer_hex not in verified.statement.subject_sha256:
1103
1122
  return None
@@ -1175,6 +1194,7 @@ def _verify_preseeded_entries(roots: Sequence[Path], trusted_keys: tuple[Trusted
1175
1194
  manifest_digest=digest,
1176
1195
  cache_write_root=cache_write_root,
1177
1196
  trusted_keys=trusted_keys,
1197
+ visible_only=True,
1178
1198
  )
1179
1199
  if record is not None:
1180
1200
  already_verified.add(digest)
@@ -1219,7 +1239,10 @@ def resolve_installed(request: Request, platform: str, options: Options) -> Reso
1219
1239
  candidates = [
1220
1240
  (dir_path, record)
1221
1241
  for dir_path, record in list_verified_records(roots)
1222
- if record.platform == platform and version_within_request(record.version, request.spelling)
1242
+ if record.platform == platform
1243
+ and version_within_request(record.version, request.spelling)
1244
+ # Under the dev channel, only its own fingerprint's builds (_channel.visible).
1245
+ and _channel.visible(record.predicate)
1223
1246
  ]
1224
1247
  if not candidates:
1225
1248
  return None
@@ -88,6 +88,12 @@ class NotFoundHttpError(TransportError):
88
88
  """A 404 (or, for a `file://` base, a missing path)."""
89
89
 
90
90
 
91
+ class AliasNotFoundError(TransportError):
92
+ """`mode="alias"`: every configured base answered the alias tag 404 (or,
93
+ for a `file://` base, has no such path). The one alias outcome the caller
94
+ answers with the tag itself (docs/guides/fetch-v1.md §3)."""
95
+
96
+
91
97
  class UnauthorizedHttpError(TransportError):
92
98
  """A 401 that is not (or is no longer) resolvable by the anonymous token flow."""
93
99
 
@@ -506,6 +512,12 @@ def fetch_from_bases(
506
512
  where it is retried within the shared budget before giving up — a
507
513
  listed digest that 404s is a host fault, never "unpublished", so the
508
514
  final exception for this mode is always `UnreachableHttpError`.
515
+ - `alias`: the dev channel's alias tag (docs/guides/fetch-v1.md §3). A 404
516
+ moves to the next base, as for a tag, but only a 404 on EVERY base is
517
+ "not found" (`AliasNotFoundError`, which the caller answers with the tag
518
+ itself). Any other failure on any base is raised as that failure, as the
519
+ tag would get it, so a transient error can never route a request to the
520
+ tag, which may name a build of another fingerprint.
509
521
 
510
522
  `decided-here` (the spec does not state it): when bases disagree — some
511
523
  404, some unreachable — the outcome follows the LAST base tried, since
@@ -516,6 +528,7 @@ def fetch_from_bases(
516
528
  raise UnreachableHttpError("no base URLs configured")
517
529
  last_index = len(bases) - 1
518
530
  last_error: TransportError | None = None
531
+ alias_failure: TransportError | None = None # mode="alias": a base's non-404 failure
519
532
  for i, base in enumerate(bases):
520
533
  is_last = i == last_index
521
534
  scheme = urlsplit(base).scheme
@@ -538,8 +551,13 @@ def fetch_from_bases(
538
551
  continue
539
552
  except UnreachableHttpError as e:
540
553
  last_error = e
554
+ alias_failure = e
541
555
  continue
542
556
  assert last_error is not None
557
+ if mode == "alias":
558
+ if alias_failure is None:
559
+ raise AliasNotFoundError(f"{path}: not found on any configured base")
560
+ last_error = alias_failure
543
561
  if mode == "tag" and isinstance(last_error, NotFoundHttpError):
544
562
  raise last_error
545
563
  if isinstance(last_error, (UnauthorizedHttpError, ForbiddenHttpError)):
@@ -49,20 +49,30 @@ def _expand_template(template: str) -> str:
49
49
  return string.Template(out).safe_substitute(mapping)
50
50
 
51
51
 
52
+ def _absolute(p: str | os.PathLike[str]) -> Path:
53
+ """A relative cache directory is the process cwd's at this call, resolved here
54
+ once so every path derived from it is absolute (fetch-v1.md section 9, public
55
+ issue #541). Lexical, like Go's `filepath.Abs`: no symlink is followed."""
56
+ return Path(os.path.abspath(p))
57
+
58
+
52
59
  def resolve_cache_root(cache_dir: str | os.PathLike[str] | None = None) -> Path:
53
60
  """`CHTYPES_CACHE` (or an explicit override) names the cache directory
54
61
  ITSELF, not a parent `chtypes/` to append to (constants: `cache.root_template`).
55
62
 
56
63
  The active fetch contract decides the rest (_channel.py): under the dev
57
- channel an explicit cache is used through its subroot `<cache>/v2-dev`,
58
- never as a whole layout, which a 1.x binding uses (rule r5, a MUST), and
59
- the default root is `${XDG_CACHE_HOME:-~/.cache}/chtypes/v2-dev`."""
64
+ channel an explicit cache, from the environment or the option, is used
65
+ through its subroot `<cache>/v2-dev`, never as a whole layout, which a 1.x
66
+ binding uses (rule r5, a MUST), and the default root is
67
+ `${XDG_CACHE_HOME:-~/.cache}/chtypes/v2-dev`. The result is absolute (#541)."""
60
68
  channel = _channel.active()
61
- explicit = cache_dir if cache_dir is not None else (os.environ.get(C.ENV_CACHE_NAME) or None)
62
- if explicit is not None:
63
- root = Path(explicit)
69
+ explicit = os.fspath(cache_dir) if cache_dir is not None else ""
70
+ if not explicit:
71
+ explicit = os.environ.get(C.ENV_CACHE_NAME) or ""
72
+ if explicit:
73
+ root = _absolute(explicit)
64
74
  return root / channel.subroot if channel.subroot else root
65
- return Path(_expand_template(C.CACHE_ROOT_TEMPLATE)).parent / channel.root_leaf
75
+ return _absolute(Path(_expand_template(C.CACHE_ROOT_TEMPLATE)).parent / channel.root_leaf)
66
76
 
67
77
 
68
78
  def cache_root(cache_dir: str | os.PathLike[str] | None = None) -> Path:
chtypes/_ocifetch/_oci.py CHANGED
@@ -16,7 +16,13 @@ from chtypes._ocifetch._errors import (
16
16
  ArtifactUnpublishedError,
17
17
  SourceIncompatibleError,
18
18
  )
19
- from chtypes._ocifetch._http import FetchPolicy, RetryPolicy, fetch_from_bases, get_json
19
+ from chtypes._ocifetch._http import (
20
+ AliasNotFoundError,
21
+ FetchPolicy,
22
+ RetryPolicy,
23
+ fetch_from_bases,
24
+ get_json,
25
+ )
20
26
 
21
27
  __all__ = [
22
28
  "Descriptor",
@@ -242,9 +248,31 @@ def fetch_manifest_by_tag(
242
248
  policy: FetchPolicy,
243
249
  retry: RetryPolicy,
244
250
  max_bytes: int = C.MANIFEST_MAX_BYTES,
251
+ alias: str | None = None,
245
252
  ) -> tuple[dict, bytes, str]:
246
- """GET manifests/<tag>; returns (doc, raw_bytes, resolved_digest)."""
253
+ """GET manifests/<tag>; returns (doc, raw_bytes, resolved_digest).
254
+
255
+ With `alias` (the dev channel's `<tag>--fp-<fingerprint>`, from
256
+ `_channel.alias_tag`), GET manifests/<alias> first, and the tag only when
257
+ every base answered the alias 404 (docs/guides/fetch-v1.md §3); any other
258
+ failure of the alias request is raised as it is. What is returned is then
259
+ trusted exactly as the tag's own answer would be."""
247
260
  accept = (C.MEDIA_TYPE_INDEX, C.MEDIA_TYPE_MANIFEST)
261
+ if alias is not None:
262
+ try:
263
+ doc, resp = get_json(
264
+ bases,
265
+ f"/manifests/{alias}",
266
+ mode="alias",
267
+ accept=accept,
268
+ max_bytes=max_bytes,
269
+ policy=policy,
270
+ retry=retry,
271
+ )
272
+ except AliasNotFoundError:
273
+ pass
274
+ else:
275
+ return doc, resp.body, f"sha256:{hashlib.sha256(resp.body).hexdigest()}"
248
276
  doc, resp = get_json(
249
277
  bases,
250
278
  f"/manifests/{tag}",
chtypes/library.py CHANGED
@@ -23,9 +23,11 @@ from ._abi2._vocab import EXPORT_NONE, DocFlags, Format
23
23
  from ._guard import CloseGuard
24
24
  from ._input import (
25
25
  BytesIn,
26
+ ServerProfile,
26
27
  Settings,
27
28
  body_bytes,
28
29
  columns_json,
30
+ server_profile_json,
29
31
  settings_json,
30
32
  string_map_json,
31
33
  to_bytes,
@@ -40,7 +42,7 @@ from .results import (
40
42
  SchemaDescription,
41
43
  )
42
44
 
43
- __all__ = ["Block", "Filter", "Library", "Schema", "open_unverified"]
45
+ __all__ = ["Block", "Filter", "Library", "Schema", "Server", "open_unverified"]
44
46
 
45
47
  # One image per file, process-wide: keyed on the realpath's device and inode, so
46
48
  # two registries, two spellings or a hardlink of one artifact share one image
@@ -159,6 +161,19 @@ class _Handle:
159
161
  self.close()
160
162
 
161
163
 
164
+ class Server(_Handle):
165
+ """One ClickHouse server (`chs_server`), made by `Library.new_server` and
166
+ passed to `Library.compile_table(server=)`. Immutable once made, so safe to
167
+ share across threads: any number of threads may compile tables on one server
168
+ at once. `close` releases the caller's reference, after the compiles already
169
+ using the server have returned; a schema compiled on the server holds its own
170
+ counted reference inside the library, so a server and its schemas close in
171
+ any order."""
172
+
173
+ __slots__ = ()
174
+ _what = "Server"
175
+
176
+
162
177
  class Block(_Handle):
163
178
  """A body parsed once, for evaluating many filters over it. It holds a counted
164
179
  reference to its schema inside the library."""
@@ -388,18 +403,45 @@ class Library:
388
403
  """Live handle counts per kind in this image (a diagnostic)."""
389
404
  return _decode.decode_live_handles(self._api.live_handles())
390
405
 
406
+ def new_server(self, profile: ServerProfile) -> Server:
407
+ """Describe one ClickHouse server from a profile (`chs_server_create`). The
408
+ library validates the whole profile here, once: a zone DateLUT cannot
409
+ load, or a setting the server's SET check refuses, is the library's own
410
+ refusal (`SchemaError`); a malformed macro set is a `UsageError`; a build
411
+ that will not describe the profile declines it (`UnsupportedError`). The
412
+ binding checks none of it first. No server option is defined yet, so the
413
+ options document is empty."""
414
+ document = server_profile_json(profile)
415
+ return Server(self, self._api.server_create(document, None))
416
+
391
417
  def compile_table(
392
418
  self,
393
419
  create_table: BytesIn,
394
420
  *,
395
421
  settings: Settings | None = None,
396
422
  session_timezone: str | None = None,
423
+ server: Server | None = None,
397
424
  ) -> Schema:
398
425
  """Compile exactly one `CREATE TABLE` statement. `settings` is the profile;
399
- a compiled type always takes the image zone, never the profile's."""
426
+ a compiled type always takes the image zone, never the profile's.
427
+
428
+ `server` (from `new_server`) compiles the table on that server: the
429
+ schema's home zone is the server's timezone, the server's settings layer
430
+ under the schema's own, and a Replicated engine's ZooKeeper path and
431
+ replica name expand the server's macros. A closed server is a
432
+ `UsageError`, raised before any call; a server from another library is
433
+ that library's own refusal, also a `UsageError`. `None` is no server: the
434
+ schema is on the image's own server."""
400
435
  statement = to_bytes(create_table, "create_table")
401
436
  settings_bytes = settings_json(settings, session_timezone)
402
- # No server (NULL) and no options (none, which is `{}`): the schema is on
403
- # the image's own server, exactly as before the server profile existed.
404
- handle = self._api.schema_create(None, statement, settings_bytes, None)
437
+ if server is not None and not isinstance(server, Server):
438
+ raise TypeError(f"server must be a Server, not {type(server).__name__}")
439
+ # No schema option is defined yet: none, which the library reads as `{}`.
440
+ # A closed server is refused here, before the call, because its freed
441
+ # handle would cross as NULL and the library would compile on the
442
+ # image's server instead of refusing.
443
+ with _hold(*(() if server is None else (server._guard,))):
444
+ handle = self._api.schema_create(
445
+ None if server is None else server._handle, statement, settings_bytes, None
446
+ )
405
447
  return Schema(self, handle)
chtypes/registry.py CHANGED
@@ -32,10 +32,11 @@ from ._ocifetch import _constants as _fetch_constants
32
32
  from ._ocifetch._dsse import TrustedKey
33
33
  from ._ocifetch._ensure import detect_host_platform, missing_notes, with_notes
34
34
  from ._ocifetch._errors import FetchError
35
+ from ._ocifetch._layout import resolve_cache_root, search_roots
35
36
  from ._ocifetch._oci import version_within_request
36
37
  from .library import Library, open_image
37
38
 
38
- __all__ = ["FetchOptions", "Registry", "Resolved", "TrustedKey"]
39
+ __all__ = ["FetchOptions", "Registry", "Resolved", "TrustedKey", "cache_root", "search_dirs"]
39
40
 
40
41
  _TRUTHY = ("1", "true", "yes", "on")
41
42
 
@@ -80,6 +81,8 @@ class FetchOptions:
80
81
  token: str | None = None
81
82
  trusted_keys: Sequence[TrustedKey] | None = None
82
83
  allow_unsigned: bool | None = None
84
+ # Offline reads the cache only and makes no request. It is on when this is
85
+ # set OR CHTYPES_OFFLINE=1; neither turns the other off (public issue #528).
83
86
  offline: bool = False
84
87
  frozen: bool = False
85
88
  lock_path: str | os.PathLike[str] | None = None
@@ -120,6 +123,27 @@ class FetchOptions:
120
123
  )
121
124
 
122
125
 
126
+ def cache_root(options: FetchOptions | None = None) -> Path:
127
+ """The cache root a fetch, list or `chtypes where` with `options` would use:
128
+ `options.cache_dir`, else `CHTYPES_CACHE` (each used through its `v2-dev`
129
+ subroot, spec/abi-v2/docs.md rule r5), else
130
+ `${XDG_CACHE_HOME:-~/.cache}/chtypes/v2-dev`.
131
+
132
+ It is the fetch layer's own resolution, `search_dirs(options)[0]`. It
133
+ creates nothing and reads no cache."""
134
+ return resolve_cache_root((options or FetchOptions()).cache_dir)
135
+
136
+
137
+ def search_dirs(options: FetchOptions | None = None) -> tuple[Path, ...]:
138
+ """Every directory a lookup reads for installed builds, in the order it reads
139
+ them: the cache root first, then each read-only system directory
140
+ (`options.system_dirs`; `None` is the built-in list, an empty sequence none).
141
+ The order is the fetch layer's own, and on a tie the earlier directory wins.
142
+ It creates nothing and touches no file."""
143
+ o = options or FetchOptions()
144
+ return search_roots(o.cache_dir, o.system_dirs)
145
+
146
+
123
147
  def _wrap(exc: FetchError) -> errors.ArtifactError:
124
148
  """The fetch layer's error as the public class of its code, one family with
125
149
  the loader's. The original stays as `__cause__`."""
chtypes/results.py CHANGED
@@ -11,11 +11,13 @@ vendored reader does not know a fact, the document says `null` and the field is
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ from collections.abc import Mapping
14
15
  from dataclasses import dataclass
15
16
 
16
- from ._abi2._vocab import DefaultKind, FilterOutcome, Outcome, Verdict
17
+ from ._abi2._vocab import DefaultKind, FilterOutcome, MergeReason, Outcome, Verdict
17
18
 
18
19
  __all__ = [
20
+ "AtMergeEntry",
19
21
  "BatchResult",
20
22
  "BuildInfo",
21
23
  "Capabilities",
@@ -32,6 +34,8 @@ __all__ = [
32
34
  "Header",
33
35
  "RowResult",
34
36
  "SchemaDescription",
37
+ "SchemaReplicated",
38
+ "SchemaServer",
35
39
  "Span",
36
40
  "Transform",
37
41
  "Value",
@@ -105,6 +109,31 @@ class EngineCell:
105
109
  value: bytes | None = None
106
110
 
107
111
 
112
+ @dataclass(frozen=True, slots=True)
113
+ class AtMergeEntry:
114
+ """One thing an `OPTIMIZE TABLE ... FINAL` of the part the INSERT writes would do
115
+ to one of its rows, at the call's clock instant: an entry of the batch's
116
+ `at_merge` list (ABI v2). `engine_rows` stays exactly what the INSERT writer
117
+ produces, so the two never contradict each other.
118
+
119
+ `row` indexes `engine_rows`: the part's row, not the input body's. `reason` is
120
+ a `MergeReason`; one the description does not list is its unknown(n) member
121
+ (rule r3). `column` is the column a `ttl_column_reset` resets (from `column`
122
+ or `column_b64`), None when the entry names none. `stored` is the column's
123
+ value after the reset (from `stored` or `stored_b64`), None when the document
124
+ carries none, which it omits when the column's DEFAULT reads the clock or a
125
+ generator and the value is decided at the merge. `input_rows` is the input
126
+ rows, each by its index in the body, that formed the part's row, None when
127
+ the document carries none.
128
+ """
129
+
130
+ row: int
131
+ reason: MergeReason
132
+ column: bytes | None = None
133
+ stored: bytes | None = None
134
+ input_rows: tuple[int, ...] | None = None
135
+
136
+
108
137
  @dataclass(frozen=True, slots=True)
109
138
  class RowResult:
110
139
  """The `row` document. `columns` is every `cols` entry in document order;
@@ -157,6 +186,12 @@ class BatchResult:
157
186
  can be fooled too. A caller that needs every record accounted for declines
158
187
  a body with any skipped row or any `unconsumed` range
159
188
  (docs/guides/batches.md).
189
+
190
+ `unsupported_settings` is the call's own settings the library declined, from
191
+ the batch's top-level list (ABI v2). `at_merge` is what an `OPTIMIZE TABLE ...
192
+ FINAL` of the part would do to its rows, at least: a later background merge
193
+ can remove more as more rows expire, and a row both removed and reset lists
194
+ only `ttl_delete`. Each is empty when the document carries none.
160
195
  """
161
196
 
162
197
  outcome: Outcome
@@ -175,6 +210,8 @@ class BatchResult:
175
210
  partition_count: int | None
176
211
  unconsumed: tuple[Span, ...]
177
212
  framing: Framing | None
213
+ unsupported_settings: tuple[bytes, ...] = ()
214
+ at_merge: tuple[AtMergeEntry, ...] = ()
178
215
 
179
216
 
180
217
  @dataclass(frozen=True, slots=True)
@@ -210,9 +247,38 @@ class Column:
210
247
  default_expr: bytes
211
248
 
212
249
 
250
+ @dataclass(frozen=True, slots=True)
251
+ class SchemaServer:
252
+ """The server a schema was compiled on, as the library holds it. Its strings
253
+ are the caller's own profile values given back, so they are plain text, not
254
+ data-derived. `timezone` is the zone the schema's types bind: the profile's,
255
+ or else the image zone. `settings` is the profile's settings (`{}` when it
256
+ gave none). `macros` is None exactly when the profile carried no macros
257
+ (unknown), and a mapping, even an empty one, when it did (the complete set)."""
258
+
259
+ timezone: str
260
+ settings: Mapping[str, str]
261
+ macros: Mapping[str, str] | None
262
+
263
+
264
+ @dataclass(frozen=True, slots=True)
265
+ class SchemaReplicated:
266
+ """What ClickHouse's own TableZnodeInfo resolved for a Replicated engine on a
267
+ server, fully expanded. Both expand DDL bytes, so both are `bytes`."""
268
+
269
+ zookeeper_path: bytes
270
+ replica_name: bytes
271
+
272
+
213
273
  @dataclass(frozen=True, slots=True)
214
274
  class SchemaDescription:
275
+ """A schema's columns, in declared order, and the server it was compiled on.
276
+ `server` is None exactly when it was compiled without one; `replicated` is
277
+ None when the document carries none."""
278
+
215
279
  columns: tuple[Column, ...]
280
+ server: SchemaServer | None = None
281
+ replicated: SchemaReplicated | None = None
216
282
 
217
283
 
218
284
  @dataclass(frozen=True, slots=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: chtypes
3
- Version: 2.0.0.dev1
3
+ Version: 2.0.0.dev3
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
@@ -19,7 +19,9 @@ Description-Content-Type: text/markdown
19
19
 
20
20
  # chtypes — Python SDK
21
21
 
22
- > **2.0.0-dev: UNSTABLE, staging only, not for production.** This is the ABI v2 development binding (public issue #511). It speaks ABI v2's unstable description, pins its dev fingerprint and refuses a library with any other ("update your dev SDK"). It fetches only from the staging dev channel (`https://registry-staging.wavehouse.dev/chtypes/v2-dev`) and trusts only the staging key; `CHTYPES_ARTIFACTS_URL`, `CHTYPES_TRUSTED_KEYS` and `CHTYPES_ALLOW_UNSIGNED`, and the `bases`, `trusted_keys` and `allow_unsigned` fetch options, are ignored, each with one warning; `--lock`, `--frozen` and `--update`, and the `frozen`, `lock_path`, `lock_write` and `update` options, are refused, because a dev build is replaceable and a superseded one expires after 14 days. Its cache is `${XDG_CACHE_HOME:-~/.cache}/chtypes/v2-dev`, or `<CHTYPES_CACHE>/v2-dev` under an explicit cache, which no 1.x SDK reads. Every vocabulary keeps a value it does not list as its `unknown(n)` member (`Outcome("x")`, whose `known` is `False`) instead of failing the document. The rules are r1 to r6 of [`docs/reference/abi-v2.md`](../docs/reference/abi-v2.md). For production, use the 1.x package: `pip install "chtypes<2"`.
22
+ > **2.0.0-dev: UNSTABLE, staging only, not for production.** This is the ABI v2 development binding (public issue #511). It speaks ABI v2's unstable description, pins its dev fingerprint and refuses a library with any other ("update your dev SDK"). It fetches only from the staging dev channel (`https://registry-staging.wavehouse.dev/chtypes/v2-dev`) and trusts only the staging key; `CHTYPES_ARTIFACTS_URL`, `CHTYPES_TRUSTED_KEYS` and `CHTYPES_ALLOW_UNSIGNED`, and the `bases`, `trusted_keys` and `allow_unsigned` fetch options, are ignored, each with one warning; `--lock`, `--frozen` and `--update`, and the `frozen`, `lock_path`, `lock_write` and `update` options, are refused, because a dev build is replaceable and a superseded one expires after 14 days. Its cache is `${XDG_CACHE_HOME:-~/.cache}/chtypes/v2-dev`, or `<CHTYPES_CACHE>/v2-dev` under an explicit cache, which no 1.x SDK reads. A dev SDK prefers the newest build of its own fingerprint: it resolves `<tag>--fp-<its fingerprint>` first and the tag only when no such build exists, and its cache lookups ignore another dev SDK's builds in a shared cache, so a newer dev fingerprint never strands it (§3 of [`docs/guides/fetch-v1.md`](../docs/guides/fetch-v1.md)). Every vocabulary keeps a value it does not list as its `unknown(n)` member (`Outcome("x")`, whose `known` is `False`) instead of failing the document. The rules are r1 to r6 of [`docs/reference/abi-v2.md`](../docs/reference/abi-v2.md). For production, use the 1.x package: `pip install "chtypes<2"`.
23
+ >
24
+ > An explicit cache directory gets the `v2-dev` subroot whether it comes from `CHTYPES_CACHE` or from `FetchOptions(cache_dir=...)`: the layout is under `<cache_dir>/v2-dev`, never in the directory itself, so code that inspects or pre-populates a cache looks there; `chtypes.cache_root` and `chtypes.search_dirs` return the resolved paths. `CHTYPES_OFFLINE=1` is the environment twin of `FetchOptions(offline=True)` and `--offline`: the cache only, no request.
23
25
 
24
26
  **If this row were inserted into this table on this ClickHouse version, what would happen?** chtypes answers with ClickHouse's own code: the real C++ type machinery, vendored per release into a native library behind the frozen `chs_*` C ABI and reached here through stdlib `ctypes`. Nothing semantic is reimplemented, so _"what does ClickHouse do with `256` into a `UInt8`?"_ is answered by ClickHouse rather than by a model of it. One peer binding among `{go, python, ts, rust}` — no language is privileged, and all four give one answer.
25
27
 
@@ -41,18 +43,18 @@ uv add "chtypes>=2.0.0.dev0" # or: pip install "chtypes>=2.0.0.dev0"
41
43
  import chtypes
42
44
  from chtypes import Format, Registry
43
45
 
44
- chtypes.setup(timezone="UTC") # once, before the first open (optional)
46
+ chtypes.setup(timezone="UTC") # once, before the first open (optional)
45
47
  registry = Registry(autofetch=True)
46
- library = registry.for_version("26.8") # a release line or an exact version
48
+ library = registry.for_version("26.8") # a release line or an exact version
47
49
 
48
50
  ddl = b"CREATE TABLE t (x UInt8, ts DateTime DEFAULT now()) ENGINE = Memory"
49
51
  with library.compile_table(ddl) as schema:
50
52
  batch = schema.rows(Format.JSON_EACH_ROW, b'{"x":256}\n')
51
53
 
52
54
  row = batch.rows[0]
53
- print(batch.outcome) # accepted
54
- print(row.values[0].text) # b'0' what would actually be stored
55
- print(row.transformed[0].reason) # overflow_wrap which is the product
55
+ print(batch.outcome) # accepted
56
+ print(row.values[0].text) # b'0' what would actually be stored
57
+ print(row.transformed[0].reason) # overflow_wrap which is the product
56
58
  ```
57
59
 
58
60
  The row is **accepted** and `256` is silently stored as `0`. That report — `transformed` — is why chtypes exists. Names, SQL, messages and renderings come back as `bytes`, never decoded for you: a column name that is not valid UTF-8 round-trips exactly.
@@ -0,0 +1,37 @@
1
+ chtypes/__init__.py,sha256=zKL5Y9XuiwZb-Ewfgvhr9IFxdpqpBS7Ub2ca68ZOxJc,5993
2
+ chtypes/__main__.py,sha256=XYGNTpTLXI1AUp7srvuNVv2SZAcfwLaXpoXRVInyhF4,14030
3
+ chtypes/_decode.py,sha256=5f-OamDm8Qx17FAWOdHO7ng7r16ZRtYy6QKn1LlnVrc,22207
4
+ chtypes/_ed25519.py,sha256=uwaAKWvgoc936e1ZfgLrtR47W87WWCL541kFaqBXrB0,4141
5
+ chtypes/_guard.py,sha256=zTtwprCS0XvGWY7GDozo-IP6GwjMAWl-o-AjShdCSVI,1985
6
+ chtypes/_input.py,sha256=i4OUmgmHxx618g0DVFYy8LmHhggfBT03NyMxH3ptZBI,6020
7
+ chtypes/_setup.py,sha256=n84T0hFmXu_fdnXQIFhk3dMVL0_-yhfdRZsqCXTLzzg,6114
8
+ chtypes/errors.py,sha256=9qv94rxbe7hm7lSVFG33Qri82FwDtCY-EnJiwnMfkp8,9501
9
+ chtypes/library.py,sha256=G6DJFiRuv8--JARxfiSNEqeFLkafaMs_n9nG-IwoC74,17878
10
+ chtypes/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ chtypes/registry.py,sha256=HTrzS_3HjOkH4rU90AVMNfFLsLoaut0jgNlt_BJ28QY,13727
12
+ chtypes/results.py,sha256=6uvfkt8dLd-F9aXyxdzWU5_kDVRVirhwB3fcH5NhJDA,11037
13
+ chtypes/_abi2/__init__.py,sha256=d3i7_iUDMFT1jg49vRQz1SMH6oseYZHUR0oPt6aSTiY,1042
14
+ chtypes/_abi2/_decls.py,sha256=ZFJUVXYIlyuKVRJyVh8KVxkT4GZjsY-CeCcohK4sWMQ,38454
15
+ chtypes/_abi2/_errmap.py,sha256=uQZ_rMaUU-tk1rSJ5wq_EjOkjesGeF-BKDJOs00KK3I,2886
16
+ chtypes/_abi2/_errors.py,sha256=wRE9M0oYtJz_9izQqpEBh5571M_1QzGWBoJ3pJTykWw,761
17
+ chtypes/_abi2/_loader.py,sha256=VMWrbWMp9Uq3K-7a219lfqAmSmBlD2hcB6WDJgG8XG0,12514
18
+ chtypes/_abi2/_vocab.py,sha256=kH58IzFHpK93ihNBo8UKMtJM6-umxbCaCZlFcqCk7Ro,15543
19
+ chtypes/_ocifetch/__init__.py,sha256=enfPOKkqP4kqAKqckBGWXN1OQsiYo9ngDP-wzG0zn2I,1484
20
+ chtypes/_ocifetch/_channel.py,sha256=yqnb35GGxdviipspR3B10t6ZgI8RJMFvW7AnB7zYYfs,15981
21
+ chtypes/_ocifetch/_constants.py,sha256=UdOGIShDXaDP13NGbuftKZRE4u7VK8JfZmoeChw4GMU,5467
22
+ chtypes/_ocifetch/_dsse.py,sha256=0RaFJaG1jhXr1uPfuHxm7yAn6viNzYhfCRUf-sW5SvQ,9333
23
+ chtypes/_ocifetch/_ensure.py,sha256=gbUSpWvdXP9f6dlFWPk67Cl_bPJ02ehTRLkDrSVNnHI,61565
24
+ chtypes/_ocifetch/_errors.py,sha256=u0eIdrr3g00ugy8ZZoAXYIgUuZgEs6eMue2QkcWXC6w,6595
25
+ chtypes/_ocifetch/_faults.py,sha256=HpoB9tJW7aLRiJdCFnspzkwhpFATZ6f6hGi49Mbfnhs,7609
26
+ chtypes/_ocifetch/_goldens.py,sha256=8MicgeJSRd4WZkaEsdujRnOa7twQvbT-wfSMPZaD-ms,4191
27
+ chtypes/_ocifetch/_http.py,sha256=DGUW6ym9mrxOgn-9FcdqzuvVGcG9WJGlYyGHnkUvGRA,23817
28
+ chtypes/_ocifetch/_layout.py,sha256=ezEoVrsSMhvDhQ4U04hsGdu1-_ISZQ4V6zg4gGIChTQ,21300
29
+ chtypes/_ocifetch/_lock.py,sha256=14_uex9qXdOht-5FSrSvFvd99-gB7DmI3bDuxXs4YU8,5530
30
+ chtypes/_ocifetch/_oci.py,sha256=YJi8y2iKIRh1_YI8lodWZXRxQfb_KrD5BhM-TQA1SCo,13595
31
+ chtypes/_ocifetch/_referrers.py,sha256=J2b4gN6vryz_IzYHxrd2Tp-tQO9Cz766rZtKgCVcKqA,6008
32
+ chtypes/_ocifetch/_unpack.py,sha256=Cn4-up63KL63L8pnkS5zwJ6VbbHPSigpCnmOdcHJ4RI,5609
33
+ chtypes-2.0.0.dev3.dist-info/METADATA,sha256=pCbL5T3EMtLWBTmEaHMGlNSjnUUQOJwYEWF5k7ycUk0,10966
34
+ chtypes-2.0.0.dev3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
35
+ chtypes-2.0.0.dev3.dist-info/entry_points.txt,sha256=TdNcluWBR0VOt5mVFROsp-NiVPAdR3EHo0UsHlMC0r0,50
36
+ chtypes-2.0.0.dev3.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
37
+ chtypes-2.0.0.dev3.dist-info/RECORD,,
@@ -1,37 +0,0 @@
1
- chtypes/__init__.py,sha256=UkNZYe0u3tqvvKDIsp4D0fPHTMLeuKtp60hs_TOkn_k,5715
2
- chtypes/__main__.py,sha256=m75yDXRqTSyqNZJ-p3TTl_5knMkaKyurjxyd8gZ1OLw,13474
3
- chtypes/_decode.py,sha256=TQX9LyzqvVus5gHRUoFI9x9ELYOaf33hcan2jZtC6ko,19507
4
- chtypes/_ed25519.py,sha256=uwaAKWvgoc936e1ZfgLrtR47W87WWCL541kFaqBXrB0,4141
5
- chtypes/_guard.py,sha256=zTtwprCS0XvGWY7GDozo-IP6GwjMAWl-o-AjShdCSVI,1985
6
- chtypes/_input.py,sha256=GonZVBB7gOPXEoDBDjEExiZXMIW7gbTIFAopvcxqnTs,4039
7
- chtypes/_setup.py,sha256=n84T0hFmXu_fdnXQIFhk3dMVL0_-yhfdRZsqCXTLzzg,6114
8
- chtypes/errors.py,sha256=9qv94rxbe7hm7lSVFG33Qri82FwDtCY-EnJiwnMfkp8,9501
9
- chtypes/library.py,sha256=U_XWbvJLxeXJmSDJV5OpwiRAKhlwTk_pFTlHxKFtab8,15600
10
- chtypes/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- chtypes/registry.py,sha256=Wra-DzLYrWPHsyPlsTPOmPvuc1as5jPEfA7gxVYHPs4,12444
12
- chtypes/results.py,sha256=JXHgnd06jXGbNGfHToBO0tRxUPy1Q3urOsH4EXax3-0,8062
13
- chtypes/_abi2/__init__.py,sha256=d3i7_iUDMFT1jg49vRQz1SMH6oseYZHUR0oPt6aSTiY,1042
14
- chtypes/_abi2/_decls.py,sha256=c2xuqG0rFgSthOO1FHEGzPhr4ldKQzoOdRxyXaEslq8,38454
15
- chtypes/_abi2/_errmap.py,sha256=L6WmPSXqqEoNchX1jrfWsbogthm5crwF-8tQBly330E,2886
16
- chtypes/_abi2/_errors.py,sha256=wRE9M0oYtJz_9izQqpEBh5571M_1QzGWBoJ3pJTykWw,761
17
- chtypes/_abi2/_loader.py,sha256=VMWrbWMp9Uq3K-7a219lfqAmSmBlD2hcB6WDJgG8XG0,12514
18
- chtypes/_abi2/_vocab.py,sha256=6UyJwWDnp_R59YMDF7ZWuLchVU7Sq8asmQChH21QEDw,14663
19
- chtypes/_ocifetch/__init__.py,sha256=enfPOKkqP4kqAKqckBGWXN1OQsiYo9ngDP-wzG0zn2I,1484
20
- chtypes/_ocifetch/_channel.py,sha256=aumM_S8X7_H2GXhaDZwoSTcg-m9_k4QSu5XTqSWZ6EQ,11836
21
- chtypes/_ocifetch/_constants.py,sha256=UdOGIShDXaDP13NGbuftKZRE4u7VK8JfZmoeChw4GMU,5467
22
- chtypes/_ocifetch/_dsse.py,sha256=0RaFJaG1jhXr1uPfuHxm7yAn6viNzYhfCRUf-sW5SvQ,9333
23
- chtypes/_ocifetch/_ensure.py,sha256=lVD_Ysg-IRNjJ_EuIHRz3fnfxy_j5Ib7M_ouLifZCfw,60521
24
- chtypes/_ocifetch/_errors.py,sha256=u0eIdrr3g00ugy8ZZoAXYIgUuZgEs6eMue2QkcWXC6w,6595
25
- chtypes/_ocifetch/_faults.py,sha256=HpoB9tJW7aLRiJdCFnspzkwhpFATZ6f6hGi49Mbfnhs,7609
26
- chtypes/_ocifetch/_goldens.py,sha256=8MicgeJSRd4WZkaEsdujRnOa7twQvbT-wfSMPZaD-ms,4191
27
- chtypes/_ocifetch/_http.py,sha256=vz3i0p5I8ch8SONpoEab0Vb5uX9MvPOHjfcaGU03bgo,22807
28
- chtypes/_ocifetch/_layout.py,sha256=SwaBgxwvYh5zGyHTZrP4-icAzhqenQSSUMIMKDlEn2A,20842
29
- chtypes/_ocifetch/_lock.py,sha256=14_uex9qXdOht-5FSrSvFvd99-gB7DmI3bDuxXs4YU8,5530
30
- chtypes/_ocifetch/_oci.py,sha256=lmrlykbyQev55uFDn8UBwXyMLthd3-pQq3xIyfXCUXY,12704
31
- chtypes/_ocifetch/_referrers.py,sha256=J2b4gN6vryz_IzYHxrd2Tp-tQO9Cz766rZtKgCVcKqA,6008
32
- chtypes/_ocifetch/_unpack.py,sha256=Cn4-up63KL63L8pnkS5zwJ6VbbHPSigpCnmOdcHJ4RI,5609
33
- chtypes-2.0.0.dev1.dist-info/METADATA,sha256=tGh0moOPb4CzqmGW2bt922V8NSKPje4Idmms1Lp5Nn4,10213
34
- chtypes-2.0.0.dev1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
35
- chtypes-2.0.0.dev1.dist-info/entry_points.txt,sha256=TdNcluWBR0VOt5mVFROsp-NiVPAdR3EHo0UsHlMC0r0,50
36
- chtypes-2.0.0.dev1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
37
- chtypes-2.0.0.dev1.dist-info/RECORD,,