chtypes 2.0.0.dev2__py3-none-any.whl → 2.0.0.dev4__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
@@ -48,11 +48,13 @@ from importlib.metadata import version as _package_version
48
48
 
49
49
  from ._abi2._vocab import (
50
50
  EXPORT_NONE,
51
+ DeclinedTier,
51
52
  DefaultKind,
52
53
  DiscoverQueryParam,
53
54
  DocFlags,
54
55
  FilterOutcome,
55
56
  Format,
57
+ MergeReason,
56
58
  Outcome,
57
59
  Reason,
58
60
  Source,
@@ -71,6 +73,7 @@ from .errors import (
71
73
  CODE_CACHE_UNUSABLE,
72
74
  CODE_SOURCE_FORBIDDEN,
73
75
  CODE_SOURCE_INCOMPATIBLE,
76
+ CODE_SOURCE_RETIRED,
74
77
  CODE_SOURCE_UNAUTHORIZED,
75
78
  CODE_SOURCE_UNREACHABLE,
76
79
  ArtifactCorruptError,
@@ -87,6 +90,7 @@ from .errors import (
87
90
  SchemaError,
88
91
  SourceForbiddenError,
89
92
  SourceIncompatibleError,
93
+ SourceRetiredError,
90
94
  SourceUnauthorizedError,
91
95
  SourceUnreachableError,
92
96
  UnsupportedError,
@@ -95,11 +99,13 @@ from .errors import (
95
99
  from .library import Block, Filter, Library, Schema, Server, open_unverified
96
100
  from .registry import FetchOptions, Registry, Resolved, TrustedKey, cache_root, search_dirs
97
101
  from .results import (
102
+ AtMergeEntry,
98
103
  BatchResult,
99
104
  BuildInfo,
100
105
  Capabilities,
101
106
  Column,
102
107
  Computed,
108
+ DeclinedSetting,
103
109
  DiscoveredColumn,
104
110
  Discovery,
105
111
  EngineCell,
@@ -128,6 +134,7 @@ __all__ = [
128
134
  "CODE_CACHE_UNUSABLE",
129
135
  "CODE_SOURCE_FORBIDDEN",
130
136
  "CODE_SOURCE_INCOMPATIBLE",
137
+ "CODE_SOURCE_RETIRED",
131
138
  "CODE_SOURCE_UNAUTHORIZED",
132
139
  "CODE_SOURCE_UNREACHABLE",
133
140
  "EXPORT_NONE",
@@ -138,6 +145,7 @@ __all__ = [
138
145
  "ArtifactPinnedError",
139
146
  "ArtifactUnpublishedError",
140
147
  "ArtifactUntrustedError",
148
+ "AtMergeEntry",
141
149
  "BatchResult",
142
150
  "Block",
143
151
  "BuildInfo",
@@ -148,6 +156,8 @@ __all__ = [
148
156
  "ChtypesError",
149
157
  "Column",
150
158
  "Computed",
159
+ "DeclinedSetting",
160
+ "DeclinedTier",
151
161
  "DefaultKind",
152
162
  "DiscoverQueryParam",
153
163
  "DiscoveredColumn",
@@ -166,6 +176,7 @@ __all__ = [
166
176
  "Header",
167
177
  "InternalError",
168
178
  "Library",
179
+ "MergeReason",
169
180
  "Outcome",
170
181
  "Reason",
171
182
  "Registry",
@@ -182,6 +193,7 @@ __all__ = [
182
193
  "Source",
183
194
  "SourceForbiddenError",
184
195
  "SourceIncompatibleError",
196
+ "SourceRetiredError",
185
197
  "SourceUnauthorizedError",
186
198
  "SourceUnreachableError",
187
199
  "Span",
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:28c7445ce6b5c6d0ae33177fd60eb7b3b5654a966c842b8d812aea622824dc72) — 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:28c7445ce6b5c6d0ae33177fd60eb7b3b5654a966c842b8d812aea622824dc72"
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:28c7445ce6b5c6d0ae33177fd60eb7b3b5654a966c842b8d812aea622824dc72) — 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:28c7445ce6b5c6d0ae33177fd60eb7b3b5654a966c842b8d812aea622824dc72) — 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`, `DeclinedTier`, `Reason`, `Source`,
5
+ `DiscoverQueryParam`) with the facts the description attaches to each value
6
+ (`Format.ch_name`, `Reason` lossy, `Source` is_stored, `Verdict.answered`), and the
7
+ `DocFlags` groups. No other file in 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,55 @@ 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
+
252
+ class DeclinedTier(StrEnum):
253
+ """The WHERE-settings tier of a setting a server profile sets and this build's
254
+ filters do not honor in a WHERE, as a `DeclinedSetting` reports it."""
255
+
256
+ PREDICATE = "predicate"
257
+ PREDICATE_UNFLIPPED = "predicate-unflipped"
258
+ RESULT_CONTENT = "result-content"
259
+
260
+ @classmethod
261
+ def _missing_(cls, value: object) -> DeclinedTier | None:
262
+ return _unknown_str(cls, value)
263
+
264
+ @property
265
+ def known(self) -> bool:
266
+ """Whether the description lists this value: False for exactly the unknown(n)
267
+ member an unlisted value reads as (rule r3)."""
268
+ return type(self)._value2member_map_.get(self._value_) is self
269
+
270
+ @classmethod
271
+ def of(cls, text: str) -> DeclinedTier:
272
+ """Map a document's spelling. One the description does not list is kept as
273
+ this vocabulary's unknown(n) member, carrying it (rule r3)."""
274
+ return cls(text)
275
+
276
+
228
277
  class DiscoverQueryParam(StrEnum):
229
278
  """A query parameter of the SQL `discover_query` returns, which the caller binds
230
279
  when it runs the query."""
@@ -383,6 +432,7 @@ DESCRIBED_VOCABULARIES: Final[dict[str, type]] = {
383
432
  "chs_status": Status,
384
433
  "chs_format": Format,
385
434
  "transform_reason": Reason,
435
+ "merge_reason": MergeReason,
386
436
  "value_src": Source,
387
437
  "row_outcome": Outcome,
388
438
  "batch_outcome": Outcome,
@@ -390,6 +440,7 @@ DESCRIBED_VOCABULARIES: Final[dict[str, type]] = {
390
440
  "filter_verdict": Verdict,
391
441
  "discover_query_param": DiscoverQueryParam,
392
442
  "default_kind": DefaultKind,
443
+ "declined_tier": DeclinedTier,
393
444
  }
394
445
 
395
446
 
chtypes/_decode.py CHANGED
@@ -28,14 +28,25 @@ 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 (
32
+ DeclinedTier,
33
+ DefaultKind,
34
+ FilterOutcome,
35
+ MergeReason,
36
+ Outcome,
37
+ Reason,
38
+ Source,
39
+ Verdict,
40
+ )
32
41
  from .errors import ArtifactCorruptError, internal
33
42
  from .results import (
43
+ AtMergeEntry,
34
44
  BatchResult,
35
45
  BuildInfo,
36
46
  Capabilities,
37
47
  Column,
38
48
  Computed,
49
+ DeclinedSetting,
39
50
  DiscoveredColumn,
40
51
  Discovery,
41
52
  EngineCell,
@@ -325,6 +336,29 @@ def _engine_cell(obj: dict[str, Any], where: str) -> EngineCell:
325
336
  )
326
337
 
327
338
 
339
+ def _int_list(obj: Mapping[str, Any], key: str, where: str) -> tuple[int, ...] | None:
340
+ """A list of non-negative integers: None when absent."""
341
+ if obj.get(key) is None:
342
+ return None
343
+ out = []
344
+ for item in _list(obj, key, where):
345
+ if isinstance(item, bool) or not isinstance(item, int) or item < 0:
346
+ raise _wrong(where, key, "an array of non-negative integers", item)
347
+ out.append(item)
348
+ return tuple(out)
349
+
350
+
351
+ def _at_merge(obj: dict[str, Any], where: str) -> AtMergeEntry:
352
+ # An unlisted reason is its unknown(n): kept, never a failure (rule r3).
353
+ return AtMergeEntry(
354
+ row=_int(obj, "row", where),
355
+ reason=MergeReason.of(_text(obj, "reason", where)),
356
+ column=_bytes_or_none(obj, "column", where),
357
+ stored=_bytes_or_none(obj, "stored", where),
358
+ input_rows=_int_list(obj, "input_rows", where),
359
+ )
360
+
361
+
328
362
  def _engine_rows(obj: Mapping[str, Any], where: str) -> tuple[tuple[EngineCell, ...], ...]:
329
363
  rows = []
330
364
  for i, row in enumerate(_list(obj, "engine_rows", where)):
@@ -411,6 +445,8 @@ def decode_batch(raw: bytes, export: bytes | None) -> BatchResult:
411
445
  framing=None
412
446
  if framing is None
413
447
  else _framing(_obj(framing, where, "framing"), f"{where}.framing"),
448
+ unsupported_settings=_name_list(obj, "unsupported_settings", where),
449
+ at_merge=_objects(obj, "at_merge", where, _at_merge),
414
450
  )
415
451
 
416
452
 
@@ -467,11 +503,22 @@ def _string_map(obj: Mapping[str, Any], key: str, where: str) -> dict[str, str]
467
503
  return out
468
504
 
469
505
 
506
+ def _declined_setting(obj: dict[str, Any], where: str) -> DeclinedSetting:
507
+ # An unlisted tier is its unknown(n): kept, never a failure (rule r3).
508
+ return DeclinedSetting(
509
+ name=_name(obj, "name", where),
510
+ tier=DeclinedTier.of(_text(obj, "tier", where)),
511
+ )
512
+
513
+
470
514
  def _server(obj: dict[str, Any], where: str) -> SchemaServer:
471
515
  return SchemaServer(
472
516
  timezone=_text(obj, "timezone", where),
473
517
  settings=_string_map(obj, "settings", where) or {},
474
518
  macros=_string_map(obj, "macros", where),
519
+ filter_declined_settings=_objects(
520
+ obj, "filter_declined_settings", where, _declined_setting
521
+ ),
475
522
  )
476
523
 
477
524
 
@@ -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,20 @@ 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
55
+ from chtypes._ocifetch._errors import ArtifactUnpublishedError
40
56
 
41
57
  __all__ = [
58
+ "ALIAS_SEPARATOR",
42
59
  "DEV_ABI_GENERATION",
43
60
  "DEV_CACHE_DIR",
44
61
  "DEV_CHANNEL_BASE",
@@ -50,12 +67,16 @@ __all__ = [
50
67
  "PinningRefusedError",
51
68
  "abi",
52
69
  "active",
70
+ "ahead_of_registry",
71
+ "alias_tag",
53
72
  "allow_overrides_for_tests",
54
73
  "channel_name",
55
74
  "enforce",
56
75
  "ignored_settings_warned",
57
76
  "pinning_requested",
58
77
  "trusted_keys",
78
+ "use_own_fingerprint_for_tests",
79
+ "visible",
59
80
  "use_dev_channel_for_tests",
60
81
  "use_fetch_v1_for_tests",
61
82
  ]
@@ -79,6 +100,10 @@ DEV_RECORD_SCHEMA: Final = 2
79
100
  # The abi a dev predicate must carry.
80
101
  DEV_ABI_GENERATION: Final = 2
81
102
 
103
+ # Joins a tag and a fingerprint into the dev channel's alias tag:
104
+ # <tag>--fp-<64 lowercase hex> (docs/guides/fetch-v1.md §3).
105
+ ALIAS_SEPARATOR: Final = "--fp-"
106
+
82
107
  # What every lock, frozen or update request gets from a 2.0.0-dev SDK, before
83
108
  # any network call (rule r6).
84
109
  PINNING_REFUSED: Final = (
@@ -102,6 +127,11 @@ class Channel:
102
127
  keys: tuple[dict[str, str], ...] # {"keyid", "ed25519_hex"}: the default trust
103
128
  overridable: bool # the base, trust and unsigned overrides are honored
104
129
  pinnable: bool # lock, frozen and update are honored
130
+ # The fingerprint, 64 lowercase hex, this contract's SDK speaks: a version
131
+ # request resolves its alias tag before the tag itself, and the cache
132
+ # lookups see only records signed with it. "" does neither (the v1 contract
133
+ # and every production channel).
134
+ own_fingerprint: str = ""
105
135
 
106
136
 
107
137
  # What a 2.0.0-dev SDK speaks, and what every process speaks unless a test
@@ -117,6 +147,7 @@ DEV_CHANNEL: Final = Channel(
117
147
  keys=({"keyid": DEV_KEY_ID, "ed25519_hex": DEV_KEY_HEX},),
118
148
  overridable=False,
119
149
  pinnable=False,
150
+ own_fingerprint=CHS_ABI_FINGERPRINT.removeprefix("sha256:"),
120
151
  )
121
152
 
122
153
  # The v1 contract the fetch-v1 conformance cases specify, from the generated
@@ -202,6 +233,86 @@ def allow_overrides_for_tests() -> Callable[[], None]:
202
233
  return _use(replace(DEV_CHANNEL, overridable=True))
203
234
 
204
235
 
236
+ _FINGERPRINT_HEX = re.compile(r"[0-9a-f]{64}")
237
+
238
+
239
+ def use_own_fingerprint_for_tests(fingerprint: str) -> Callable[[], None]:
240
+ """Make this TEST run's active contract speak `fingerprint` (64 lowercase
241
+ hex) as the dev channel speaks its own: a version request resolves that
242
+ fingerprint's alias tag first, and the cache lookups see only records
243
+ signed with it. The fetch-v1 conformance cases that carry
244
+ `request.own_fingerprint` run under it, against fixtures that name a
245
+ fixture fingerprint. Everything else stays the active contract's. Returns
246
+ the restore function; raises outside a test run or on a fingerprint that is
247
+ not 64 lowercase hex."""
248
+ _test_only("use_own_fingerprint_for_tests")
249
+ if not _FINGERPRINT_HEX.fullmatch(fingerprint):
250
+ raise ValueError(f"use_own_fingerprint_for_tests: {fingerprint!r} is not 64 lowercase hex")
251
+ return _use(replace(active(), own_fingerprint=fingerprint))
252
+
253
+
254
+ def alias_tag(tag: str, channel: Channel | None = None) -> str | None:
255
+ """The alias a version request for `tag` resolves first under a contract
256
+ with an own fingerprint, or None when it resolves `tag` alone: under the
257
+ v1 contract and every production channel, and for a request that is not a
258
+ version spelling (an arbitrary tag has no alias)."""
259
+ channel = channel or active()
260
+ if not channel.own_fingerprint or not re.fullmatch(C.SPELLING_REGEX, tag):
261
+ return None
262
+ return f"{tag}{ALIAS_SEPARATOR}{channel.own_fingerprint}"
263
+
264
+
265
+ def visible(predicate: object, channel: Channel | None = None) -> bool:
266
+ """Whether a cache record (or a pre-seeded entry) whose SIGNED predicate is
267
+ `predicate` may answer a request under the active contract: under one with
268
+ an own fingerprint (the dev channel), only when the predicate's
269
+ `abi_fingerprint` is that fingerprint; under every other, always. A record
270
+ it cannot see is never returned and never kept by the monotonic rule, and
271
+ nothing removes or rewrites it: another SDK of another fingerprint owns it."""
272
+ channel = channel or active()
273
+ if not channel.own_fingerprint:
274
+ return True
275
+ fingerprint = predicate.get("abi_fingerprint") if isinstance(predicate, dict) else None
276
+ return fingerprint == f"sha256:{channel.own_fingerprint}"
277
+
278
+
279
+ def ahead_of_registry(
280
+ alias_absent: bool, predicate: object, channel: Channel | None = None
281
+ ) -> None:
282
+ """The dev channel's answer for an SDK whose fingerprint no published build
283
+ carries yet (docs/guides/fetch-v1.md §3; public issue #578): when every base
284
+ answered its own alias 404 (`alias_absent`) and the build the tag names is
285
+ signed for another fingerprint (`predicate`, the SIGNED predicate, the field
286
+ `visible` keys on), raise `ArtifactUnpublishedError`, the code a request no
287
+ build answers gets, naming both fingerprints. Nothing is installed and an
288
+ install of that build is never reported, because the lookup this SDK opens
289
+ through would refuse it. Returns in every other case: the alias answered,
290
+ the tag's build is this SDK's own, or the contract has no own fingerprint."""
291
+ channel = channel or active()
292
+ if not alias_absent or visible(predicate, channel):
293
+ return
294
+ raise ArtifactUnpublishedError(f"chtypes: {_ahead_message(channel.own_fingerprint, predicate)}")
295
+
296
+
297
+ def _ahead_message(own: str, predicate: object) -> str:
298
+ """`ahead_of_registry`'s message: "no published build for this SDK's
299
+ fingerprint <own>; newest published on this channel is <fp> (build <id>)",
300
+ each fingerprint 64 lowercase hex without its `sha256:` prefix, the
301
+ parenthesis dropped when the predicate names no build, and "unnamed" for a
302
+ predicate that names no fingerprint."""
303
+ fields = predicate if isinstance(predicate, dict) else {}
304
+ theirs = fields.get("abi_fingerprint")
305
+ theirs = theirs.removeprefix("sha256:") if isinstance(theirs, str) else ""
306
+ message = (
307
+ f"no published build for this SDK's fingerprint {own}; "
308
+ f"newest published on this channel is {theirs or 'unnamed'}"
309
+ )
310
+ build = fields.get("build")
311
+ if isinstance(build, str) and build:
312
+ message += f" (build {build})"
313
+ return message
314
+
315
+
205
316
  def use_dev_channel_for_tests() -> Callable[[], None]:
206
317
  """Make this TEST run speak the dev channel exactly as a user's process does
207
318
  (undoing either function above, for a test of the dev channel itself).
@@ -1,6 +1,6 @@
1
1
  # Code generated by scripts/fetch-v1/gen-constants.py from
2
2
  # spec/fetch-v1/constants.json. DO NOT EDIT.
3
- # Source sha256: 163dbaa1e5173aa65de7d46f1eac27c02f862e96b5c8160a23fee8cfabd77a2a
3
+ # Source sha256: 4554956faeb154fc324a6120b7b35976222c6bc8ecbce7fd0147502e3073611d
4
4
  """The v1 fetch constants, generated: see docs/guides/fetch-v1.md."""
5
5
 
6
6
  from __future__ import annotations
@@ -83,6 +83,7 @@ RETRY_AFTER_STATUSES: Final[tuple[int, ...]] = (429, 503)
83
83
  RETRY_AFTER_OVER_BUDGET: Final[str] = "refuse"
84
84
  DIGEST_404_POLICY: Final[str] = "next_base_then_retry_on_last"
85
85
  TAG_404_POLICY: Final[str] = "next_base_then_unpublished"
86
+ RETIRED_STATUSES: Final[tuple[int, ...]] = (410,)
86
87
 
87
88
  CONNECT_TIMEOUT_S: Final[float] = 30.0
88
89
  IDLE_READ_TIMEOUT_S: Final[float] = 60.0
@@ -95,6 +96,8 @@ MAX_REFERRERS: Final[int] = 16
95
96
  MAX_REDIRECTS: Final[int] = 5
96
97
  MAX_UNPACKED_BYTES: Final[int] = 4294967296
97
98
  ZSTD_WINDOW_LOG_MAX: Final[int] = 27
99
+ RETIRED_BODY_MAX_BYTES: Final[int] = 65536
100
+ RETIRED_MESSAGE_MAX_CODE_POINTS: Final[int] = 256
98
101
 
99
102
  CACHE_ROOT_TEMPLATE: Final[str] = "${XDG_CACHE_HOME:-$HOME/.cache}/chtypes/v1"
100
103
  SYSTEM_CACHE_DIRS: Final[tuple[str, ...]] = ("/usr/local/share/chtypes/v1", "/opt/chtypes/v1")
@@ -122,4 +125,5 @@ ERROR_EXIT_CODES: Final[dict[str, int]] = {
122
125
  "CHTYPES_SOURCE_INCOMPATIBLE": 7,
123
126
  "CHTYPES_ARTIFACT_INCOMPATIBLE": 8,
124
127
  "CHTYPES_CACHE_UNUSABLE": 9,
128
+ "CHTYPES_SOURCE_RETIRED": 10,
125
129
  }
@@ -38,6 +38,7 @@ from chtypes._ocifetch._errors import (
38
38
  ArtifactUntrustedError,
39
39
  FetchError,
40
40
  SourceForbiddenError,
41
+ SourceRetiredError,
41
42
  SourceUnauthorizedError,
42
43
  SourceUnreachableError,
43
44
  )
@@ -49,6 +50,7 @@ from chtypes._ocifetch._http import (
49
50
  ForbiddenHttpError,
50
51
  NotFoundHttpError,
51
52
  OversizeHttpError,
53
+ RetiredHttpError,
52
54
  RetryPolicy,
53
55
  TransportError,
54
56
  UnauthorizedHttpError,
@@ -355,6 +357,10 @@ def _newer_installed(
355
357
  (`26.3.4.1`) keeps only a newer BUILD of that exact version. A request
356
358
  that is not a version spelling (an arbitrary tag) names no range, so
357
359
  nothing is kept for it.
360
+
361
+ Under the dev channel only an install of its own fingerprint counts
362
+ (`_channel.visible`): a newer build of another fingerprint, installed by
363
+ another dev SDK, is never kept over this one's own.
358
364
  """
359
365
  if not re.match(C.SPELLING_REGEX, request_spelling):
360
366
  return None
@@ -368,6 +374,8 @@ def _newer_installed(
368
374
  for dir_, record in list_verified_records(roots):
369
375
  if record.platform != platform_key:
370
376
  continue
377
+ if not _channel.visible(record.predicate):
378
+ continue # another fingerprint's build (_channel.visible): never kept over this one's
371
379
  if not version_within_request(record.version, request_spelling):
372
380
  continue
373
381
  version_key = _version_key(record.version)
@@ -434,7 +442,7 @@ def _find_verified_signature(
434
442
  bases, ref.digest, policy=policy, retry=retry
435
443
  )
436
444
  blob_desc = manifest_single_layer(referrer_doc, expected_media_type=C.MEDIA_TYPE_BUNDLE)
437
- except ArtifactCorruptError:
445
+ except (ArtifactCorruptError, RetiredHttpError):
438
446
  raise
439
447
  except (TransportError, FetchError):
440
448
  continue
@@ -452,7 +460,7 @@ def _find_verified_signature(
452
460
  with open(bundle_path, "rb") as f:
453
461
  bundle_json = json.loads(f.read())
454
462
  verified = verify_bundle(bundle_json, trusted_keys)
455
- except ArtifactCorruptError:
463
+ except (ArtifactCorruptError, RetiredHttpError):
456
464
  raise
457
465
  except (TransportError, FetchError, json.JSONDecodeError):
458
466
  continue
@@ -692,8 +700,12 @@ def _ensure_floating(
692
700
  cache_root_path = roots[0]
693
701
  scratch_root = _scratch_root(cache_root_path)
694
702
 
695
- index_doc, index_bytes, index_digest = fetch_manifest_by_tag(
696
- bases, request.spelling, policy=policy, retry=retry
703
+ index_doc, index_bytes, index_digest, alias_absent = fetch_manifest_by_tag(
704
+ bases,
705
+ request.spelling,
706
+ policy=policy,
707
+ retry=retry,
708
+ alias=_channel.alias_tag(request.spelling),
697
709
  )
698
710
  platform_desc = resolve_platform_manifest(index_doc, platform_key)
699
711
  manifest_hex = parse_digest(platform_desc.digest)
@@ -701,6 +713,10 @@ def _ensure_floating(
701
713
  existing_dir = unpacked_dir_for(cache_root_path, manifest_hex)
702
714
  existing_record = read_verified_record(existing_dir)
703
715
  if existing_record is not None and existing_record.platform == platform_key:
716
+ # An install of another fingerprint's build, reached through the tag
717
+ # because the registry has none at this SDK's own, is never reported
718
+ # installed (public issue #578): refused before the cache is touched.
719
+ _channel.ahead_of_registry(alias_absent, existing_record.predicate)
704
720
  # A NEWER build within the request, installed beside it, is still the
705
721
  # answer ("monotonic-warning", docs/guides/fetch-v1.md §9).
706
722
  newer = _newer_installed(
@@ -791,6 +807,12 @@ def _ensure_floating(
791
807
  signed_by = verified.signed_by
792
808
 
793
809
  _check_predicate_matches_request(predicate, platform_key, request.spelling)
810
+ if found is not None:
811
+ # An SDK ahead of the registry (public issue #578): the alias for its
812
+ # own fingerprint answered 404 on every base, and the tag's build is
813
+ # signed for another. Refused from the signed statement, before the
814
+ # layer is requested, so nothing is downloaded or installed.
815
+ _channel.ahead_of_registry(alias_absent, predicate)
794
816
  newer = _newer_installed(roots, platform_key, predicate, request.spelling)
795
817
  if newer is not None:
796
818
  newer_dir, newer_record, warning = newer
@@ -973,6 +995,8 @@ def _translate_transport_error(e: TransportError) -> FetchError:
973
995
  return SourceForbiddenError(f"chtypes: {e}")
974
996
  if isinstance(e, OversizeHttpError):
975
997
  return ArtifactCorruptError(f"chtypes: {e}")
998
+ if isinstance(e, RetiredHttpError):
999
+ return SourceRetiredError(f"chtypes: {e}")
976
1000
  if isinstance(e, UnreachableHttpError):
977
1001
  return SourceUnreachableError(f"chtypes: {e}", retryable=getattr(e, "retryable", True))
978
1002
  return SourceUnreachableError(f"chtypes: {e}")
@@ -1050,6 +1074,7 @@ def _verify_and_install_from_local_blobs(
1050
1074
  manifest_digest: str,
1051
1075
  cache_write_root: Path,
1052
1076
  trusted_keys: tuple[TrustedKey, ...],
1077
+ visible_only: bool = False,
1053
1078
  ) -> VerifiedRecord | None:
1054
1079
  """docs/guides/fetch-v1.md §1/§6: "A pre-seeded layout has entries in
1055
1080
  `index.json` with no corresponding `unpacked/` directory yet; the first
@@ -1103,6 +1128,9 @@ def _verify_and_install_from_local_blobs(
1103
1128
  return None
1104
1129
 
1105
1130
  predicate = verified.statement.predicate
1131
+ if visible_only and not _channel.visible(predicate):
1132
+ # A lookup never installs another fingerprint's pre-seeded build.
1133
+ return None
1106
1134
  layer_hex = parse_digest(layer_desc.digest)
1107
1135
  if layer_hex not in verified.statement.subject_sha256:
1108
1136
  return None
@@ -1180,6 +1208,7 @@ def _verify_preseeded_entries(roots: Sequence[Path], trusted_keys: tuple[Trusted
1180
1208
  manifest_digest=digest,
1181
1209
  cache_write_root=cache_write_root,
1182
1210
  trusted_keys=trusted_keys,
1211
+ visible_only=True,
1183
1212
  )
1184
1213
  if record is not None:
1185
1214
  already_verified.add(digest)
@@ -1224,7 +1253,10 @@ def resolve_installed(request: Request, platform: str, options: Options) -> Reso
1224
1253
  candidates = [
1225
1254
  (dir_path, record)
1226
1255
  for dir_path, record in list_verified_records(roots)
1227
- if record.platform == platform and version_within_request(record.version, request.spelling)
1256
+ if record.platform == platform
1257
+ and version_within_request(record.version, request.spelling)
1258
+ # Under the dev channel, only its own fingerprint's builds (_channel.visible).
1259
+ and _channel.visible(record.predicate)
1228
1260
  ]
1229
1261
  if not candidates:
1230
1262
  return None
@@ -1350,7 +1382,9 @@ def _fetch_signed_impl(repository: str, ref: str, predicate_type: str, options:
1350
1382
  doc, _raw = fetch_manifest_by_digest(bases, ref, policy=policy, retry=retry)
1351
1383
  manifest_digest = ref
1352
1384
  else:
1353
- doc, _raw, manifest_digest = fetch_manifest_by_tag(bases, ref, policy=policy, retry=retry)
1385
+ doc, _raw, manifest_digest, _alias_absent = fetch_manifest_by_tag(
1386
+ bases, ref, policy=policy, retry=retry
1387
+ )
1354
1388
 
1355
1389
  layer_desc = manifest_single_layer(doc)
1356
1390
  found = _find_verified_signature(
@@ -1448,6 +1482,8 @@ def _fetch_goldens(
1448
1482
  retry=retry,
1449
1483
  max_bytes=C.MAX_UNPACKED_BYTES,
1450
1484
  )
1485
+ except RetiredHttpError:
1486
+ raise
1451
1487
  except (TransportError, FetchError) as e:
1452
1488
  first_failure = first_failure or e
1453
1489
  continue
@@ -1562,6 +1598,8 @@ def _verify_goldens_signature(
1562
1598
  except ArtifactCorruptError as e:
1563
1599
  failure = failure or e
1564
1600
  continue
1601
+ except RetiredHttpError:
1602
+ raise
1565
1603
  except (TransportError, FetchError, json.JSONDecodeError):
1566
1604
  continue
1567
1605
  finally:
@@ -5,7 +5,7 @@ Scoped to `chtypes._ocifetch`: the stable, public exception types live in
5
5
  imports from or edits. A future switch lane decides how (or whether) these
6
6
  become public types; until then every name here is private.
7
7
 
8
- Every exception carries a `.code` string from one of the eleven codes
8
+ Every exception carries a `.code` string from one of the twelve codes
9
9
  `spec/fetch-v1/constants.json`'s `errors` map assigns an exit status to
10
10
  (the generated `ERROR_EXIT_CODES` table), so a CLI added later needs no
11
11
  second mapping — it is the same lookup v0's `__main__.py` does today.
@@ -30,6 +30,7 @@ __all__ = [
30
30
  "CODE_CACHE_UNUSABLE",
31
31
  "CODE_SOURCE_FORBIDDEN",
32
32
  "CODE_SOURCE_INCOMPATIBLE",
33
+ "CODE_SOURCE_RETIRED",
33
34
  "CODE_SOURCE_UNAUTHORIZED",
34
35
  "CODE_SOURCE_UNREACHABLE",
35
36
  "ArtifactCorruptError",
@@ -41,6 +42,7 @@ __all__ = [
41
42
  "FetchError",
42
43
  "SourceForbiddenError",
43
44
  "SourceIncompatibleError",
45
+ "SourceRetiredError",
44
46
  "SourceUnauthorizedError",
45
47
  "SourceUnreachableError",
46
48
  ]
@@ -55,6 +57,7 @@ CODE_SOURCE_UNAUTHORIZED = "CHTYPES_SOURCE_UNAUTHORIZED"
55
57
  CODE_SOURCE_FORBIDDEN = "CHTYPES_SOURCE_FORBIDDEN"
56
58
  CODE_SOURCE_INCOMPATIBLE = "CHTYPES_SOURCE_INCOMPATIBLE"
57
59
  CODE_CACHE_UNUSABLE = "CHTYPES_CACHE_UNUSABLE"
60
+ CODE_SOURCE_RETIRED = "CHTYPES_SOURCE_RETIRED"
58
61
 
59
62
  _CODES_USED_HERE = (
60
63
  CODE_ARTIFACT_MISSING,
@@ -67,6 +70,7 @@ _CODES_USED_HERE = (
67
70
  CODE_SOURCE_FORBIDDEN,
68
71
  CODE_SOURCE_INCOMPATIBLE,
69
72
  CODE_CACHE_UNUSABLE,
73
+ CODE_SOURCE_RETIRED,
70
74
  )
71
75
 
72
76
  # A drift guard, cheaper than a test: every code this module raises must be
@@ -187,3 +191,13 @@ class CacheUnusableError(FetchError):
187
191
  self.path = path
188
192
  self.reason = reason
189
193
  self.os_error = os_error
194
+
195
+
196
+ class SourceRetiredError(FetchError):
197
+ """The source answered 410 Gone: a retired repository, which is permanent,
198
+ so the request was never retried and never sent to the next base
199
+ (docs/guides/fetch-v1.md §2, "A retired repository"; public issue #571).
200
+ The message names the URL that answered and carries the registry's own
201
+ message, made safe to print."""
202
+
203
+ code = CODE_SOURCE_RETIRED
@@ -29,6 +29,7 @@ from email.utils import parsedate_to_datetime
29
29
  from urllib.parse import urljoin, urlsplit, urlunsplit
30
30
 
31
31
  from chtypes._ocifetch import _constants as C
32
+ from chtypes._ocifetch._retired import retired_text
32
33
 
33
34
  __all__ = [
34
35
  "USER_AGENT",
@@ -38,6 +39,7 @@ __all__ = [
38
39
  "HttpResponse",
39
40
  "NotFoundHttpError",
40
41
  "OversizeHttpError",
42
+ "RetiredHttpError",
41
43
  "RetryPolicy",
42
44
  "TransportError",
43
45
  "UnauthorizedHttpError",
@@ -88,6 +90,12 @@ class NotFoundHttpError(TransportError):
88
90
  """A 404 (or, for a `file://` base, a missing path)."""
89
91
 
90
92
 
93
+ class AliasNotFoundError(TransportError):
94
+ """`mode="alias"`: every configured base answered the alias tag 404 (or,
95
+ for a `file://` base, has no such path). The one alias outcome the caller
96
+ answers with the tag itself (docs/guides/fetch-v1.md §3)."""
97
+
98
+
91
99
  class UnauthorizedHttpError(TransportError):
92
100
  """A 401 that is not (or is no longer) resolvable by the anonymous token flow."""
93
101
 
@@ -114,6 +122,13 @@ class OversizeHttpError(TransportError):
114
122
  """A response body exceeded the caller's declared size cap."""
115
123
 
116
124
 
125
+ class RetiredHttpError(TransportError):
126
+ """A retired repository (`RETIRED_STATUSES`, a 410; docs/guides/fetch-v1.md
127
+ §2): permanent, so never retried and never a reason to try the next base.
128
+ Its text names the URL that answered and carries the registry's message,
129
+ made safe to print (`_retired.retired_message`)."""
130
+
131
+
117
132
  class Clock:
118
133
  """The real wall clock. Tests inject a fake with the same interface so
119
134
  retry cases run instantly and sleeps are asserted exactly."""
@@ -222,7 +237,16 @@ def _single_request(
222
237
  )
223
238
  except urllib.error.HTTPError as e:
224
239
  try:
225
- body = _read_capped(e, max_bytes) if e.code not in _REDIRECT_CODES else b""
240
+ if e.code in C.RETIRED_STATUSES:
241
+ # Only the registry's message is wanted from a retired
242
+ # repository's answer: at most RETIRED_BODY_MAX_BYTES of it,
243
+ # and a body that fails part way is no body.
244
+ try:
245
+ body = e.read(C.RETIRED_BODY_MAX_BYTES)
246
+ except (OSError, ValueError, AttributeError):
247
+ body = b""
248
+ else:
249
+ body = _read_capped(e, max_bytes) if e.code not in _REDIRECT_CODES else b""
226
250
  finally:
227
251
  e.close()
228
252
  return HttpResponse(status=e.code, headers=dict(e.headers or {}), body=body, url=url)
@@ -374,6 +398,11 @@ def _http_get_once(
374
398
  headers.pop("Authorization", None)
375
399
  continue
376
400
 
401
+ if resp.status in C.RETIRED_STATUSES:
402
+ # A retired repository: permanent, never retried, and the
403
+ # exception ends fetch_from_bases's loop over bases.
404
+ raise RetiredHttpError(retired_text(current_url, resp.status, resp.body))
405
+
377
406
  if resp.status == 401:
378
407
  if not tried_anon_flow and not policy.token:
379
408
  challenge = resp.headers.get("WWW-Authenticate") or resp.headers.get(
@@ -506,16 +535,27 @@ def fetch_from_bases(
506
535
  where it is retried within the shared budget before giving up — a
507
536
  listed digest that 404s is a host fault, never "unpublished", so the
508
537
  final exception for this mode is always `UnreachableHttpError`.
538
+ - `alias`: the dev channel's alias tag (docs/guides/fetch-v1.md §3). A 404
539
+ moves to the next base, as for a tag, but only a 404 on EVERY base is
540
+ "not found" (`AliasNotFoundError`, which the caller answers with the tag
541
+ itself). Any other failure on any base is raised as that failure, as the
542
+ tag would get it, so a transient error can never route a request to the
543
+ tag, which may name a build of another fingerprint.
509
544
 
510
545
  `decided-here` (the spec does not state it): when bases disagree — some
511
546
  404, some unreachable — the outcome follows the LAST base tried, since
512
547
  bases are an explicit preference order and the last one is the final
513
548
  word a caller configured.
549
+
550
+ In every mode a retired repository (`RetiredHttpError`, a 410) ends the
551
+ loop on the base that answered it: it is permanent, never a reason to try
552
+ the next base, and in `alias` mode never a reason to fall back to the tag.
514
553
  """
515
554
  if not bases:
516
555
  raise UnreachableHttpError("no base URLs configured")
517
556
  last_index = len(bases) - 1
518
557
  last_error: TransportError | None = None
558
+ alias_failure: TransportError | None = None # mode="alias": a base's non-404 failure
519
559
  for i, base in enumerate(bases):
520
560
  is_last = i == last_index
521
561
  scheme = urlsplit(base).scheme
@@ -538,8 +578,13 @@ def fetch_from_bases(
538
578
  continue
539
579
  except UnreachableHttpError as e:
540
580
  last_error = e
581
+ alias_failure = e
541
582
  continue
542
583
  assert last_error is not None
584
+ if mode == "alias":
585
+ if alias_failure is None:
586
+ raise AliasNotFoundError(f"{path}: not found on any configured base")
587
+ last_error = alias_failure
543
588
  if mode == "tag" and isinstance(last_error, NotFoundHttpError):
544
589
  raise last_error
545
590
  if isinstance(last_error, (UnauthorizedHttpError, ForbiddenHttpError)):
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,35 @@ def fetch_manifest_by_tag(
242
248
  policy: FetchPolicy,
243
249
  retry: RetryPolicy,
244
250
  max_bytes: int = C.MANIFEST_MAX_BYTES,
245
- ) -> tuple[dict, bytes, str]:
246
- """GET manifests/<tag>; returns (doc, raw_bytes, resolved_digest)."""
251
+ alias: str | None = None,
252
+ ) -> tuple[dict, bytes, str, bool]:
253
+ """GET manifests/<tag>; returns (doc, raw_bytes, resolved_digest, alias_absent).
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. `alias_absent` reports
260
+ that fallback: the alias was asked for and every base answered it 404, so
261
+ the registry has no build at this SDK's fingerprint, and the build the tag
262
+ names is checked against it (`_channel.ahead_of_registry`)."""
247
263
  accept = (C.MEDIA_TYPE_INDEX, C.MEDIA_TYPE_MANIFEST)
264
+ alias_absent = False
265
+ if alias is not None:
266
+ try:
267
+ doc, resp = get_json(
268
+ bases,
269
+ f"/manifests/{alias}",
270
+ mode="alias",
271
+ accept=accept,
272
+ max_bytes=max_bytes,
273
+ policy=policy,
274
+ retry=retry,
275
+ )
276
+ except AliasNotFoundError:
277
+ alias_absent = True
278
+ else:
279
+ return doc, resp.body, f"sha256:{hashlib.sha256(resp.body).hexdigest()}", False
248
280
  doc, resp = get_json(
249
281
  bases,
250
282
  f"/manifests/{tag}",
@@ -255,7 +287,7 @@ def fetch_manifest_by_tag(
255
287
  retry=retry,
256
288
  )
257
289
  digest = f"sha256:{hashlib.sha256(resp.body).hexdigest()}"
258
- return doc, resp.body, digest
290
+ return doc, resp.body, digest, alias_absent
259
291
 
260
292
 
261
293
  def fetch_blob_to_path(
@@ -0,0 +1,77 @@
1
+ """A retired repository (docs/guides/fetch-v1.md §2, "A retired repository";
2
+ public issue #571).
3
+
4
+ The registry's operator answers every route of a retired repository with 410
5
+ Gone (`RETIRED_STATUSES`) and a short error document. That is permanent: the
6
+ transport never retries it and never asks the next base, so the caller sees
7
+ `CHTYPES_SOURCE_RETIRED` with the URL that answered and the registry's own
8
+ message, made safe to print by `retired_message`, the one function every
9
+ binding has (tests/fixtures/retired-message/cases.json is the table all four
10
+ answer alike).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+
17
+ from chtypes._ocifetch import _constants as C
18
+
19
+ __all__ = ["retired_message", "retired_text"]
20
+
21
+ _ELLIPSIS = "…"
22
+
23
+
24
+ def _removed(code_point: int) -> bool:
25
+ return (
26
+ code_point <= 0x1F
27
+ or 0x7F <= code_point <= 0x9F
28
+ or code_point in (0x200E, 0x200F)
29
+ or 0x202A <= code_point <= 0x202E
30
+ or 0x2066 <= code_point <= 0x2069
31
+ )
32
+
33
+
34
+ def _not_json(name: str) -> None:
35
+ # `json.loads` accepts NaN, Infinity and -Infinity, which are not JSON;
36
+ # the other three bindings' readers refuse them.
37
+ raise ValueError(f"{name} is not JSON")
38
+
39
+
40
+ def retired_message(body: bytes) -> str | None:
41
+ """The registry's own message in a retired repository's response body,
42
+ made safe to print, or None when it sent none.
43
+
44
+ `body` is what was read of it (at most `RETIRED_BODY_MAX_BYTES`). When it is
45
+ UTF-8 JSON whose `errors[0].message` is a string, that string is the
46
+ message: every code point in U+0000-U+001F, U+007F-U+009F, U+200E, U+200F,
47
+ U+202A-U+202E and U+2066-U+2069 is removed, and the rest is cut to
48
+ `RETIRED_MESSAGE_MAX_CODE_POINTS` code points, with U+2026 appended when it
49
+ was cut. Nothing else is changed, and an empty result is no message."""
50
+ try:
51
+ text = body.decode("utf-8")
52
+ doc = json.loads(text, parse_constant=_not_json)
53
+ except (UnicodeDecodeError, ValueError, RecursionError):
54
+ return None
55
+ if not isinstance(doc, dict):
56
+ return None
57
+ errors = doc.get("errors")
58
+ if not isinstance(errors, list) or not errors or not isinstance(errors[0], dict):
59
+ return None
60
+ message = errors[0].get("message")
61
+ # A lone surrogate escape is not a Unicode string (and could not be printed).
62
+ if not isinstance(message, str) or any("\ud800" <= ch <= "\udfff" for ch in message):
63
+ return None
64
+ kept = [ch for ch in message if not _removed(ord(ch))]
65
+ if len(kept) > C.RETIRED_MESSAGE_MAX_CODE_POINTS:
66
+ kept = [*kept[: C.RETIRED_MESSAGE_MAX_CODE_POINTS], _ELLIPSIS]
67
+ return "".join(kept) or None
68
+
69
+
70
+ def retired_text(url: str, status: int, body: bytes) -> str:
71
+ """The error text for a retired repository's answer: the URL and status
72
+ that answered, and the registry's message when it sent one."""
73
+ text = f"{url} answered {status} Gone: the repository is retired"
74
+ message = retired_message(body)
75
+ if message is not None:
76
+ text += f"; the registry says: {message}"
77
+ return text
chtypes/errors.py CHANGED
@@ -31,6 +31,7 @@ __all__ = [
31
31
  "CODE_CACHE_UNUSABLE",
32
32
  "CODE_SOURCE_FORBIDDEN",
33
33
  "CODE_SOURCE_INCOMPATIBLE",
34
+ "CODE_SOURCE_RETIRED",
34
35
  "CODE_SOURCE_UNAUTHORIZED",
35
36
  "CODE_SOURCE_UNREACHABLE",
36
37
  "ArtifactCorruptError",
@@ -47,6 +48,7 @@ __all__ = [
47
48
  "SchemaError",
48
49
  "SourceForbiddenError",
49
50
  "SourceIncompatibleError",
51
+ "SourceRetiredError",
50
52
  "SourceUnauthorizedError",
51
53
  "SourceUnreachableError",
52
54
  "UnsupportedError",
@@ -66,6 +68,7 @@ CODE_SOURCE_UNAUTHORIZED = "CHTYPES_SOURCE_UNAUTHORIZED"
66
68
  CODE_SOURCE_FORBIDDEN = "CHTYPES_SOURCE_FORBIDDEN"
67
69
  CODE_SOURCE_INCOMPATIBLE = "CHTYPES_SOURCE_INCOMPATIBLE"
68
70
  CODE_CACHE_UNUSABLE = "CHTYPES_CACHE_UNUSABLE"
71
+ CODE_SOURCE_RETIRED = "CHTYPES_SOURCE_RETIRED"
69
72
 
70
73
  # `chs_status` value of CHS_INVALID_ARGUMENT. The binding's own misuse errors
71
74
  # carry it so a handler sees one shape whichever side caught the misuse. It is
@@ -258,6 +261,15 @@ class SourceIncompatibleError(ArtifactError):
258
261
  code = CODE_SOURCE_INCOMPATIBLE
259
262
 
260
263
 
264
+ class SourceRetiredError(ArtifactError):
265
+ """The source answered 410 Gone: a retired repository, which is permanent,
266
+ so the request was never retried and never sent to the next base. The
267
+ message names the URL that answered and carries the registry's own
268
+ message, made safe to print."""
269
+
270
+ code = CODE_SOURCE_RETIRED
271
+
272
+
261
273
  class CacheUnusableError(ArtifactError):
262
274
  """A cache directory or entry the fetch layer could not read or write, or
263
275
  one strict mode refuses: never "not installed", never the network's
chtypes/registry.py CHANGED
@@ -51,6 +51,7 @@ _FETCH_CLASSES: dict[str, type[errors.ArtifactError]] = {
51
51
  errors.CODE_SOURCE_FORBIDDEN: errors.SourceForbiddenError,
52
52
  errors.CODE_SOURCE_INCOMPATIBLE: errors.SourceIncompatibleError,
53
53
  errors.CODE_CACHE_UNUSABLE: errors.CacheUnusableError,
54
+ errors.CODE_SOURCE_RETIRED: errors.SourceRetiredError,
54
55
  }
55
56
 
56
57
 
chtypes/results.py CHANGED
@@ -14,14 +14,16 @@ from __future__ import annotations
14
14
  from collections.abc import Mapping
15
15
  from dataclasses import dataclass
16
16
 
17
- from ._abi2._vocab import DefaultKind, FilterOutcome, Outcome, Verdict
17
+ from ._abi2._vocab import DeclinedTier, DefaultKind, FilterOutcome, MergeReason, Outcome, Verdict
18
18
 
19
19
  __all__ = [
20
+ "AtMergeEntry",
20
21
  "BatchResult",
21
22
  "BuildInfo",
22
23
  "Capabilities",
23
24
  "Column",
24
25
  "Computed",
26
+ "DeclinedSetting",
25
27
  "DiscoveredColumn",
26
28
  "EngineCell",
27
29
  "Discovery",
@@ -108,6 +110,31 @@ class EngineCell:
108
110
  value: bytes | None = None
109
111
 
110
112
 
113
+ @dataclass(frozen=True, slots=True)
114
+ class AtMergeEntry:
115
+ """One thing an `OPTIMIZE TABLE ... FINAL` of the part the INSERT writes would do
116
+ to one of its rows, at the call's clock instant: an entry of the batch's
117
+ `at_merge` list (ABI v2). `engine_rows` stays exactly what the INSERT writer
118
+ produces, so the two never contradict each other.
119
+
120
+ `row` indexes `engine_rows`: the part's row, not the input body's. `reason` is
121
+ a `MergeReason`; one the description does not list is its unknown(n) member
122
+ (rule r3). `column` is the column a `ttl_column_reset` resets (from `column`
123
+ or `column_b64`), None when the entry names none. `stored` is the column's
124
+ value after the reset (from `stored` or `stored_b64`), None when the document
125
+ carries none, which it omits when the column's DEFAULT reads the clock or a
126
+ generator and the value is decided at the merge. `input_rows` is the input
127
+ rows, each by its index in the body, that formed the part's row, None when
128
+ the document carries none.
129
+ """
130
+
131
+ row: int
132
+ reason: MergeReason
133
+ column: bytes | None = None
134
+ stored: bytes | None = None
135
+ input_rows: tuple[int, ...] | None = None
136
+
137
+
111
138
  @dataclass(frozen=True, slots=True)
112
139
  class RowResult:
113
140
  """The `row` document. `columns` is every `cols` entry in document order;
@@ -160,6 +187,12 @@ class BatchResult:
160
187
  can be fooled too. A caller that needs every record accounted for declines
161
188
  a body with any skipped row or any `unconsumed` range
162
189
  (docs/guides/batches.md).
190
+
191
+ `unsupported_settings` is the call's own settings the library declined, from
192
+ the batch's top-level list (ABI v2). `at_merge` is what an `OPTIMIZE TABLE ...
193
+ FINAL` of the part would do to its rows, at least: a later background merge
194
+ can remove more as more rows expire, and a row both removed and reset lists
195
+ only `ttl_delete`. Each is empty when the document carries none.
163
196
  """
164
197
 
165
198
  outcome: Outcome
@@ -178,6 +211,8 @@ class BatchResult:
178
211
  partition_count: int | None
179
212
  unconsumed: tuple[Span, ...]
180
213
  framing: Framing | None
214
+ unsupported_settings: tuple[bytes, ...] = ()
215
+ at_merge: tuple[AtMergeEntry, ...] = ()
181
216
 
182
217
 
183
218
  @dataclass(frozen=True, slots=True)
@@ -189,8 +224,10 @@ class FilterRowError:
189
224
 
190
225
  @dataclass(frozen=True, slots=True)
191
226
  class FilterResult:
192
- """The `filter_result` document. `e` and `d` are never answers, and a caller
193
- enforcing visibility fails closed on both; `Verdict.answered` says which."""
227
+ """The `filter_result` document. A verdict counts only when `outcome` is `ok`;
228
+ otherwise treat every verdict as `d`, whatever the verdict string holds. Under
229
+ `ok`, `e` and `d` are never answers, and a caller enforcing visibility fails
230
+ closed on both; `Verdict.answered` says which."""
194
231
 
195
232
  outcome: FilterOutcome
196
233
  err_code: int
@@ -213,6 +250,18 @@ class Column:
213
250
  default_expr: bytes
214
251
 
215
252
 
253
+ @dataclass(frozen=True, slots=True)
254
+ class DeclinedSetting:
255
+ """One setting a server's profile sets that this build's filters do not honor
256
+ in a WHERE: an entry of the server's `filter_declined_settings` (ABI v2).
257
+ `name` is bytes (from `name` or `name_b64`). `tier` is the tier this build
258
+ line's WHERE-settings list gives it, a `DeclinedTier`; one the description
259
+ does not list is its unknown(n) member (rule r3)."""
260
+
261
+ name: bytes
262
+ tier: DeclinedTier
263
+
264
+
216
265
  @dataclass(frozen=True, slots=True)
217
266
  class SchemaServer:
218
267
  """The server a schema was compiled on, as the library holds it. Its strings
@@ -220,11 +269,17 @@ class SchemaServer:
220
269
  data-derived. `timezone` is the zone the schema's types bind: the profile's,
221
270
  or else the image zone. `settings` is the profile's settings (`{}` when it
222
271
  gave none). `macros` is None exactly when the profile carried no macros
223
- (unknown), and a mapping, even an empty one, when it did (the complete set)."""
272
+ (unknown), and a mapping, even an empty one, when it did (the complete set).
273
+ `filter_declined_settings` is the settings the profile sets that this
274
+ build's filters do not honor in a WHERE, in the profile's own order, empty
275
+ when it lists none: a schema on a server that lists any is one
276
+ `compile_filter` declines (`UnsupportedError`), and a consumer that streams
277
+ rows refuses such a tenant (spec/abi-v2/where-settings/README.md)."""
224
278
 
225
279
  timezone: str
226
280
  settings: Mapping[str, str]
227
281
  macros: Mapping[str, str] | None
282
+ filter_declined_settings: tuple[DeclinedSetting, ...] = ()
228
283
 
229
284
 
230
285
  @dataclass(frozen=True, slots=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: chtypes
3
- Version: 2.0.0.dev2
3
+ Version: 2.0.0.dev4
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,9 +19,11 @@ 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
23
  >
24
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.
25
+ >
26
+ > <!-- remove-at-v2-lock -->v2-dev builds before the one that carries this contract (its release notice names it) can answer `ok` with `t` verdicts after a refused row. On those builds, when a body or block holds more than one row, treat every verdict as `d` if `errors` is non-empty.<!-- /remove-at-v2-lock -->
25
27
 
26
28
  **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.
27
29
 
@@ -1,37 +1,38 @@
1
- chtypes/__init__.py,sha256=aIVdrUX79p18amwUcV27FItM4SA1n-LlipJHLopgHgU,5919
1
+ chtypes/__init__.py,sha256=9xXEUgqnxJmAjUbz8X3coym4UyM01HShX7Lrds751VM,6177
2
2
  chtypes/__main__.py,sha256=XYGNTpTLXI1AUp7srvuNVv2SZAcfwLaXpoXRVInyhF4,14030
3
- chtypes/_decode.py,sha256=i_nHXx6QKCbXej0GJMnfq_utRE-Oq7SiqPNETOmhFZ4,21149
3
+ chtypes/_decode.py,sha256=v5pdwphwAldVzgHg6saWsxqUFjMHqcMO-XOuDyuRVZg,22686
4
4
  chtypes/_ed25519.py,sha256=uwaAKWvgoc936e1ZfgLrtR47W87WWCL541kFaqBXrB0,4141
5
5
  chtypes/_guard.py,sha256=zTtwprCS0XvGWY7GDozo-IP6GwjMAWl-o-AjShdCSVI,1985
6
6
  chtypes/_input.py,sha256=i4OUmgmHxx618g0DVFYy8LmHhggfBT03NyMxH3ptZBI,6020
7
7
  chtypes/_setup.py,sha256=n84T0hFmXu_fdnXQIFhk3dMVL0_-yhfdRZsqCXTLzzg,6114
8
- chtypes/errors.py,sha256=9qv94rxbe7hm7lSVFG33Qri82FwDtCY-EnJiwnMfkp8,9501
8
+ chtypes/errors.py,sha256=7gDi9Ij8yptrr9E0LZyA2XkeaFFVyZoQMVQjQj6mn0s,9936
9
9
  chtypes/library.py,sha256=G6DJFiRuv8--JARxfiSNEqeFLkafaMs_n9nG-IwoC74,17878
10
10
  chtypes/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- chtypes/registry.py,sha256=HTrzS_3HjOkH4rU90AVMNfFLsLoaut0jgNlt_BJ28QY,13727
12
- chtypes/results.py,sha256=AI0QALKW-NcgM5qOUxUMesFXd4BcsujzuiaG8ejBelg,9330
11
+ chtypes/registry.py,sha256=Q3LR7wBs7Ggy_5xG_-0bZrTRgvRWJO5OJbD92ecUlt4,13786
12
+ chtypes/results.py,sha256=OnxFUNyhlNeA2jwsMVAqYjKjmqguvKtWzrCwi7F6CFM,12123
13
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
14
+ chtypes/_abi2/_decls.py,sha256=X88Ylb33Sw5iJjikVTfVzdgCfjGvKpn4F3mT0qAnD7I,38454
15
+ chtypes/_abi2/_errmap.py,sha256=vNPgHFV742Px2aTyYqJez7I3qXRq80AOUcMqDvyPGFI,2886
16
16
  chtypes/_abi2/_errors.py,sha256=wRE9M0oYtJz_9izQqpEBh5571M_1QzGWBoJ3pJTykWw,761
17
17
  chtypes/_abi2/_loader.py,sha256=VMWrbWMp9Uq3K-7a219lfqAmSmBlD2hcB6WDJgG8XG0,12514
18
- chtypes/_abi2/_vocab.py,sha256=6UyJwWDnp_R59YMDF7ZWuLchVU7Sq8asmQChH21QEDw,14663
18
+ chtypes/_abi2/_vocab.py,sha256=rzbenqXqDoWZkvpjRw0UUBEkgWoOtE462QUGyCE3cTw,16516
19
19
  chtypes/_ocifetch/__init__.py,sha256=enfPOKkqP4kqAKqckBGWXN1OQsiYo9ngDP-wzG0zn2I,1484
20
- chtypes/_ocifetch/_channel.py,sha256=p6FbWbrCBTyXmYjdP4UOkkjj02aEkn8Em9EE6xuUZ1Y,12121
21
- chtypes/_ocifetch/_constants.py,sha256=UdOGIShDXaDP13NGbuftKZRE4u7VK8JfZmoeChw4GMU,5467
20
+ chtypes/_ocifetch/_channel.py,sha256=2L-sSetXv4iF0mpsIX4-iTAhNbu9eY1IsGnb4WTptwM,17980
21
+ chtypes/_ocifetch/_constants.py,sha256=IZtAK3oxnCELZysvrzRozJkntxKSQllGjxod5No-Kco,5644
22
22
  chtypes/_ocifetch/_dsse.py,sha256=0RaFJaG1jhXr1uPfuHxm7yAn6viNzYhfCRUf-sW5SvQ,9333
23
- chtypes/_ocifetch/_ensure.py,sha256=NG_Fhg-UkafZZC5oHnG4f61W9-sWp9jE8gXOWEWJWrs,60772
24
- chtypes/_ocifetch/_errors.py,sha256=u0eIdrr3g00ugy8ZZoAXYIgUuZgEs6eMue2QkcWXC6w,6595
23
+ chtypes/_ocifetch/_ensure.py,sha256=GtuYJJXhL0TbUypx94OPr3BhnkrQoHuWjZdPYrvpLgw,62597
24
+ chtypes/_ocifetch/_errors.py,sha256=TTYpwhvZ7MyD9Ssg5js8_YB8Fl30rYBPlENyAetqEbQ,7129
25
25
  chtypes/_ocifetch/_faults.py,sha256=HpoB9tJW7aLRiJdCFnspzkwhpFATZ6f6hGi49Mbfnhs,7609
26
26
  chtypes/_ocifetch/_goldens.py,sha256=8MicgeJSRd4WZkaEsdujRnOa7twQvbT-wfSMPZaD-ms,4191
27
- chtypes/_ocifetch/_http.py,sha256=vz3i0p5I8ch8SONpoEab0Vb5uX9MvPOHjfcaGU03bgo,22807
27
+ chtypes/_ocifetch/_http.py,sha256=e1zAWu-2gvBe3FNE5bZ7jmz2OPn1bzImPfHWvA10O_4,25179
28
28
  chtypes/_ocifetch/_layout.py,sha256=ezEoVrsSMhvDhQ4U04hsGdu1-_ISZQ4V6zg4gGIChTQ,21300
29
29
  chtypes/_ocifetch/_lock.py,sha256=14_uex9qXdOht-5FSrSvFvd99-gB7DmI3bDuxXs4YU8,5530
30
- chtypes/_ocifetch/_oci.py,sha256=lmrlykbyQev55uFDn8UBwXyMLthd3-pQq3xIyfXCUXY,12704
30
+ chtypes/_ocifetch/_oci.py,sha256=UdA8QGTh1O27DBYRf5UxsevjgI52IecQlEf9IO6Y0A4,13920
31
31
  chtypes/_ocifetch/_referrers.py,sha256=J2b4gN6vryz_IzYHxrd2Tp-tQO9Cz766rZtKgCVcKqA,6008
32
+ chtypes/_ocifetch/_retired.py,sha256=MQ6nbWLJw50IZYY1VfM6AiCZ4BySK0GVkGVOtAXaq3Y,3060
32
33
  chtypes/_ocifetch/_unpack.py,sha256=Cn4-up63KL63L8pnkS5zwJ6VbbHPSigpCnmOdcHJ4RI,5609
33
- chtypes-2.0.0.dev2.dist-info/METADATA,sha256=ORRyWPTzbIQl_tI3z1ZSAWNYwcOesmInGA0xvW6Eb5o,10632
34
- chtypes-2.0.0.dev2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
35
- chtypes-2.0.0.dev2.dist-info/entry_points.txt,sha256=TdNcluWBR0VOt5mVFROsp-NiVPAdR3EHo0UsHlMC0r0,50
36
- chtypes-2.0.0.dev2.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
37
- chtypes-2.0.0.dev2.dist-info/RECORD,,
34
+ chtypes-2.0.0.dev4.dist-info/METADATA,sha256=xGAOsOpaNNPKh1cxhmBitwUrzSsCgiwBOqNltkBmWO0,11280
35
+ chtypes-2.0.0.dev4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
36
+ chtypes-2.0.0.dev4.dist-info/entry_points.txt,sha256=TdNcluWBR0VOt5mVFROsp-NiVPAdR3EHo0UsHlMC0r0,50
37
+ chtypes-2.0.0.dev4.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
38
+ chtypes-2.0.0.dev4.dist-info/RECORD,,