chtypes 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {chtypes-0.3.1 → chtypes-0.3.2}/CHANGELOG.md +17 -0
  2. {chtypes-0.3.1 → chtypes-0.3.2}/PKG-INFO +2 -2
  3. {chtypes-0.3.1 → chtypes-0.3.2}/README.md +1 -1
  4. {chtypes-0.3.1 → chtypes-0.3.2}/pyproject.toml +1 -1
  5. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/__main__.py +13 -1
  6. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/_manifest.py +9 -4
  7. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/fetch.py +147 -21
  8. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/registry.py +4 -4
  9. {chtypes-0.3.1 → chtypes-0.3.2}/tests/conftest.py +44 -2
  10. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_cli.py +36 -4
  11. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_fetch.py +116 -6
  12. chtypes-0.3.2/tests/test_fetch_abi_revision.py +248 -0
  13. chtypes-0.3.2/tests/test_fetch_sh.py +139 -0
  14. chtypes-0.3.2/uv.lock +108 -0
  15. chtypes-0.3.1/uv.lock +0 -108
  16. {chtypes-0.3.1 → chtypes-0.3.2}/.gitignore +0 -0
  17. {chtypes-0.3.1 → chtypes-0.3.2}/LICENSE +0 -0
  18. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/__init__.py +0 -0
  19. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/_document.py +0 -0
  20. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/_ed25519.py +0 -0
  21. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/_native.py +0 -0
  22. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/_rawjson.py +0 -0
  23. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/discover.py +0 -0
  24. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/errors.py +0 -0
  25. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/py.typed +0 -0
  26. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/results.py +0 -0
  27. {chtypes-0.3.1 → chtypes-0.3.2}/src/chtypes/transform.py +0 -0
  28. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_abi_revision.py +0 -0
  29. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_csv_reader.py +0 -0
  30. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_discover_reconstruct.py +0 -0
  31. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_ed25519.py +0 -0
  32. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_errors.py +0 -0
  33. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_formats_withnames.py +0 -0
  34. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_golden.py +0 -0
  35. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_lazy.py +0 -0
  36. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_parity.py +0 -0
  37. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_quoting_boundaries.py +0 -0
  38. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_rawjson.py +0 -0
  39. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_registry.py +0 -0
  40. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_results_rules.py +0 -0
  41. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_rows_export_with.py +0 -0
  42. {chtypes-0.3.1 → chtypes-0.3.2}/tests/test_transform.py +0 -0
@@ -6,6 +6,23 @@ The four bindings in this repository are released together and give one answer,
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.2] — 2026-09-28
10
+
11
+ Speaks ABI revision 5, unchanged from 0.3.1: every artifact 0.3.1 loads, this release loads.
12
+
13
+ ### Added
14
+
15
+ - **`ReleaseEntry.abi_revision`** (`int | None`): the row's `abi_revision`, `None` when the row declares none or declares something that is not an integer.
16
+
17
+ ### Changed
18
+
19
+ - **Installs move to a per-revision directory, so the first fetch after upgrading downloads again.** The per-user cache `default_registry_dir()` is now `${XDG_CACHE_HOME:-~/.cache}/chtypes/artifacts/abi<R>/<os>-<arch>`, `<R>` this binding's own `ABI_REVISION`, where it was `…/chtypes/artifacts/<os>-<arch>`. Two SDK versions that speak different ABI revisions on one machine no longer overwrite each other's artifacts. Nothing is migrated: the old directory is simply no longer read, and can be deleted once nothing older uses it. `CHTYPES_REGISTRY` and an explicit directory are taken exactly as before.
20
+ - **Fetch now selects only artifacts built at this binding's own ABI revision** (`docs/guides/fetch.md` §2). The release's rows are narrowed to those whose `abi_revision` is `ABI_REVISION` before the newest-version, highest-build rule applies; a row that declares no revision never matches. Nothing at that revision for the line and platform is `ArtifactUnpublishedError` (`CHTYPES_ARTIFACT_UNPUBLISHED`, exit 4), naming the revision(s) the release does serve — never a fallback to another revision's build, which the loader would refuse anyway. Across a revision cutover the grace this gives an older binding is temporary: an older-revision build stays fetchable only until that line's next publish evicts it under retention, which keeps two builds per version.
21
+ - **`fetch_lines(..., all_lines=True)` and `python -m chtypes fetch --all` no longer skip a line silently.** A line the release has for the platform only at another ABI revision, or only in rows that record none (built before revisions were recorded), is not installed, and one loud warning line (through `progress` when one is given, else on stderr) names the line, the platform, what the release does serve and this binding's revision; every other line still installs, and the exit status stays 0 when they all do.
22
+ - **`list` shows only the release's rows at this binding's ABI revision** — what a fetch could install — and, when that hides any of the platform's rows, adds one line naming them: `<N> row(s) at ABI revision(s) <S> not shown; this SDK speaks <R>`, or, for rows that record none, `<M> row(s) that record no ABI revision (built before revisions were recorded) not shown; this SDK speaks <R>`. Nothing is added when nothing was hidden.
23
+ - **On darwin-arm64, fetching line 24.8 now fails at fetch time** with `CHTYPES_ARTIFACT_UNPUBLISHED` (exit 4) instead of installing an artifact the loader then refuses: the release's only 24.8 rows for that platform record no ABI revision, because they were built before revisions were recorded, and the message says exactly that rather than that the release serves none.
24
+ - `Release.offered()` now returns only rows at this binding's ABI revision, and `Release.select()` considers only those.
25
+
9
26
  ## [0.3.1] — 2026-09-26
10
27
 
11
28
  Speaks ABI revision 5, unchanged from 0.3.0: every artifact 0.3.0 loads, this release loads.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: chtypes
3
- Version: 0.3.1
3
+ Version: 0.3.2
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
@@ -31,7 +31,7 @@ uv add chtypes # or: pip install chtypes
31
31
  python -m chtypes fetch 25.8
32
32
  ```
33
33
 
34
- The fetch lands in `~/.cache/chtypes/artifacts/<os>-<arch>/25.8/` — the per-user cache every chtypes binding reads by default — after checking an ed25519 signature over the release and the sha256 of every byte. `$CHTYPES_REGISTRY` overrides it. The ed25519 verifier is pure stdlib too.
34
+ The fetch lands in `~/.cache/chtypes/artifacts/abi<R>/<os>-<arch>/25.8/` — the per-user cache every chtypes binding reads by default, `<R>` the ABI revision this SDK speaks (fetch installs only artifacts built at it) — after checking an ed25519 signature over the release and the sha256 of every byte. `$CHTYPES_REGISTRY` overrides it. The ed25519 verifier is pure stdlib too.
35
35
 
36
36
  ## Quickstart
37
37
 
@@ -13,7 +13,7 @@ uv add chtypes # or: pip install chtypes
13
13
  python -m chtypes fetch 25.8
14
14
  ```
15
15
 
16
- The fetch lands in `~/.cache/chtypes/artifacts/<os>-<arch>/25.8/` — the per-user cache every chtypes binding reads by default — after checking an ed25519 signature over the release and the sha256 of every byte. `$CHTYPES_REGISTRY` overrides it. The ed25519 verifier is pure stdlib too.
16
+ The fetch lands in `~/.cache/chtypes/artifacts/abi<R>/<os>-<arch>/25.8/` — the per-user cache every chtypes binding reads by default, `<R>` the ABI revision this SDK speaks (fetch installs only artifacts built at it) — after checking an ed25519 signature over the release and the sha256 of every byte. `$CHTYPES_REGISTRY` overrides it. The ed25519 verifier is pure stdlib too.
17
17
 
18
18
  ## Quickstart
19
19
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chtypes"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  description = "Python binding for the chtypes artifacts: ClickHouse's own type system, per version, behind the frozen chs_* C ABI"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -196,7 +196,14 @@ def _cmd_verify(args: argparse.Namespace) -> int:
196
196
 
197
197
 
198
198
  def _cmd_list(args: argparse.Namespace) -> int:
199
- from .fetch import Fetcher, fetch_destination, host_platform, installed_lines
199
+ from .fetch import (
200
+ Fetcher,
201
+ _fetch_abi_revision,
202
+ _not_shown,
203
+ fetch_destination,
204
+ host_platform,
205
+ installed_lines,
206
+ )
200
207
 
201
208
  platform = args.platform or host_platform()
202
209
  registry = fetch_destination(args.dest, platform=platform)
@@ -221,6 +228,11 @@ def _cmd_list(args: argparse.Namespace) -> int:
221
228
  f" {entry.minor:<8} {entry.clickhouse_version:<18} b{entry.build_number:<3} "
222
229
  f"{entry.file} [{state}]\n"
223
230
  )
231
+ # Only this SDK's own ABI revision is offered (docs/guides/fetch.md §2), and
232
+ # one line names what that hid.
233
+ note = _not_shown([e for e in release.entries if e.platform == platform], _fetch_abi_revision())
234
+ if note is not None:
235
+ sys.stdout.write(f" {note}\n")
224
236
  sys.stdout.flush()
225
237
  return EXIT_OK
226
238
 
@@ -15,6 +15,7 @@ import platform as _platform
15
15
  from dataclasses import dataclass, fields
16
16
  from pathlib import Path
17
17
 
18
+ from ._native import ABI_REVISION
18
19
  from .errors import RegistryError
19
20
 
20
21
  __all__ = [
@@ -38,11 +39,15 @@ def host_platform() -> str:
38
39
 
39
40
  def cache_registry_dir(platform: str | None = None) -> str:
40
41
  """The per-user artifact cache for a platform — ``${XDG_CACHE_HOME:-~/.cache}/
41
- chtypes/artifacts/<os>-<arch>``, this host's by default. Where fetch installs,
42
- where a core-repository build lands, and slot 3 of the registry search path
43
- (docs/guides/fetch.md §1). A path, not a promise: it need not exist yet."""
42
+ chtypes/artifacts/abi<R>/<os>-<arch>``, R this binding's `ABI_REVISION`, this
43
+ host's platform by default. Where fetch installs, and slot 3 of the registry
44
+ search path (docs/guides/fetch.md §1); keyed by revision so two SDK versions
45
+ at different revisions never share it. A path, not a promise: it need not
46
+ exist yet."""
44
47
  base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
45
- return os.path.join(base, "chtypes", "artifacts", platform or host_platform())
48
+ return os.path.join(
49
+ base, "chtypes", "artifacts", f"abi{ABI_REVISION}", platform or host_platform()
50
+ )
46
51
 
47
52
 
48
53
  @dataclass(frozen=True, slots=True)
@@ -30,6 +30,7 @@ import logging
30
30
  import os
31
31
  import re
32
32
  import shutil
33
+ import sys
33
34
  import tarfile
34
35
  import tempfile
35
36
  import time
@@ -51,6 +52,7 @@ from ._manifest import (
51
52
  read_manifest,
52
53
  verify_library,
53
54
  )
55
+ from ._native import ABI_REVISION
54
56
  from .errors import (
55
57
  ArtifactCorruptError,
56
58
  ArtifactPinnedError,
@@ -145,6 +147,90 @@ def _env_flag(name: str) -> bool:
145
147
  return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
146
148
 
147
149
 
150
+ #: TEST-ONLY. When set, the ABI revision fetch selects release rows at, in place
151
+ #: of `ABI_REVISION` (docs/guides/fetch.md §2). Not part of the public contract:
152
+ #: the fetch-fixture suites set it to the revision their fixture release carries
153
+ #: — derived from that release's own ``index.json`` — so a binding whose own
154
+ #: revision has moved ahead of the fixtures still exercises the whole chain.
155
+ #: It changes WHICH rows are eligible and nothing else: the default registry
156
+ #: directory stays ``abi<ABI_REVISION>/``, and the loader still refuses an
157
+ #: artifact of another revision.
158
+ _ABI_REVISION_OVERRIDE: int | None = None
159
+
160
+
161
+ def _fetch_abi_revision() -> int:
162
+ """The one ABI revision whose rows fetch may install: this binding's own."""
163
+ return ABI_REVISION if _ABI_REVISION_OVERRIDE is None else _ABI_REVISION_OVERRIDE
164
+
165
+
166
+ def _revision_of(value: object) -> int | None:
167
+ """A row's ``abi_revision`` when it is a JSON integer, else None. The artifact
168
+ producer writes the field from the revision that introduced it onward, so a
169
+ row without one is an older revision; a value that is not an integer is
170
+ read the same way rather than failing the whole listing."""
171
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
172
+
173
+
174
+ #: Rows that carry no ``abi_revision``, named with the reason: the artifact
175
+ #: producer records the field from the revision that introduced it onward. Every
176
+ #: unpublished message, ``--all`` warning and ``list`` note says this in these
177
+ #: words rather than that the release serves "none".
178
+ _BUILT_BEFORE: Final = "(built before revisions were recorded)"
179
+ _NO_RECORDED_REVISION: Final = f"rows that record no ABI revision {_BUILT_BEFORE}"
180
+
181
+
182
+ def _served(entries: Sequence[ReleaseEntry], noun: str) -> str:
183
+ """What the release DOES have for ``noun``, for an unpublished message: the
184
+ ABI revision(s) its rows carry, or that it has none at any revision."""
185
+ if not entries:
186
+ return f"the release does not have {noun} at any ABI revision"
187
+ revs = sorted({e.abi_revision for e in entries if e.abi_revision is not None})
188
+ if not revs:
189
+ return f"the release has {noun} only in {_NO_RECORDED_REVISION}"
190
+ said = (
191
+ f"ABI revision {revs[0]}"
192
+ if len(revs) == 1
193
+ else "ABI revisions " + ", ".join(str(r) for r in revs)
194
+ )
195
+ if any(e.abi_revision is None for e in entries):
196
+ said += f" and in {_NO_RECORDED_REVISION}"
197
+ return f"the release has {noun} only at {said}"
198
+
199
+
200
+ def _skipped_lines(on_platform: Sequence[ReleaseEntry], platform: str, rev: int) -> list[str]:
201
+ """One message per line the release has for ``platform`` only at another ABI
202
+ revision, or only in rows that record none — the lines ``--all`` installs
203
+ nothing for — in numeric line order."""
204
+ by_line: dict[str, list[ReleaseEntry]] = {}
205
+ for e in on_platform:
206
+ by_line.setdefault(e.minor, []).append(e)
207
+ return [
208
+ f"ClickHouse line {line} on {platform} is not installed: "
209
+ f"{_served(rows, f'that line for {platform}')}, and this SDK speaks ABI revision {rev}"
210
+ for line, rows in sorted(by_line.items(), key=lambda kv: _minor_key(kv[0]))
211
+ if not any(e.abi_revision == rev for e in rows)
212
+ ]
213
+
214
+
215
+ def _not_shown(on_platform: Sequence[ReleaseEntry], rev: int) -> str | None:
216
+ """``list``'s one line naming the platform's rows at another ABI revision,
217
+ which it does not show (docs/guides/fetch.md §6); None when none were
218
+ hidden. Rows that carry no ``abi_revision`` are named for what they are —
219
+ built before revisions were recorded — never as a revision called "none"."""
220
+ hidden = [e for e in on_platform if e.abi_revision != rev]
221
+ if not hidden:
222
+ return None
223
+ declared = [e.abi_revision for e in hidden if e.abi_revision is not None]
224
+ undeclared = len(hidden) - len(declared)
225
+ parts = []
226
+ if declared:
227
+ revs = ", ".join(str(r) for r in sorted(set(declared)))
228
+ parts.append(f"{len(declared)} row(s) at ABI revision(s) {revs}")
229
+ if undeclared:
230
+ parts.append(f"{undeclared} row(s) that record no ABI revision " + _BUILT_BEFORE)
231
+ return f"{' and '.join(parts)} not shown; this SDK speaks {rev}"
232
+
233
+
148
234
  # ------------------------------------------------------------------ §1 paths
149
235
 
150
236
 
@@ -372,6 +458,11 @@ class ReleaseEntry:
372
458
  build: int = 0
373
459
  #: The core commit the wrapper was built from; ``""`` on an old row.
374
460
  core_commit: str = ""
461
+ #: The chs_* ABI revision the artifact was built from, or None when the row
462
+ #: declares none (or declares something that is not an integer). Fetch
463
+ #: installs only a row at this binding's own `ABI_REVISION`
464
+ #: (docs/guides/fetch.md §2): any other would be refused at load.
465
+ abi_revision: int | None = None
375
466
 
376
467
  @property
377
468
  def platform(self) -> str:
@@ -411,10 +502,14 @@ class Release:
411
502
  license_url: str = ""
412
503
 
413
504
  def offered(self, platform: str) -> list[ReleaseEntry]:
414
- """Every line published for a platform, newest patch per line, release order."""
505
+ """Every line published for a platform at this binding's ABI revision,
506
+ newest patch per line (then highest build), release order. Rows of any
507
+ other revision, and rows that record none, are not offered: fetch never
508
+ installs them (docs/guides/fetch.md §2)."""
509
+ rev = _fetch_abi_revision()
415
510
  best: dict[str, ReleaseEntry] = {}
416
511
  for e in self.entries:
417
- if e.platform != platform:
512
+ if e.platform != platform or e.abi_revision != rev:
418
513
  continue
419
514
  cur = best.get(e.minor)
420
515
  if cur is None or e.rank > cur.rank:
@@ -426,42 +521,49 @@ class Release:
426
521
 
427
522
  def select(self, spelling: str, platform: str) -> ReleaseEntry:
428
523
  """A line (``25.8``) resolves to the one patch published for it; an
429
- exact patch (``25.8.28.1-lts``) is a hard requirement (§2)."""
524
+ exact patch (``25.8.28.1-lts``) is a hard requirement (§2). Either way,
525
+ only rows at this binding's ABI revision are considered, FIRST: nothing
526
+ at that revision is `ArtifactUnpublishedError`, never another
527
+ revision's row."""
430
528
  line, exact = parse_spelling(spelling)
431
- offered = self.offered(platform)
432
- if not offered:
529
+ rev = _fetch_abi_revision()
530
+ on_platform = [e for e in self.entries if e.platform == platform]
531
+ if not on_platform:
433
532
  raise ArtifactUnpublishedError(
434
533
  f"chtypes: {self.source} publishes nothing for {platform} "
435
534
  f"(it has: {', '.join(self.platforms()) or 'nothing'})"
436
535
  )
536
+ have = ", ".join(e.clickhouse_version for e in on_platform if e.abi_revision == rev)
437
537
  if exact is not None:
438
538
  bare = exact.split("-", 1)[0]
439
539
  # A rebuild publishes the same clickhouse_version twice, so an exact
440
540
  # request can match more than one row: take the highest build, never
441
541
  # whichever the index happens to list first.
442
- hits = [
542
+ any_revision = [
443
543
  e
444
- for e in self.entries
445
- if e.platform == platform
446
- and (
447
- e.clickhouse_version == exact
448
- or ("-" not in exact and e.clickhouse_version.split("-", 1)[0] == bare)
449
- )
544
+ for e in on_platform
545
+ if e.clickhouse_version == exact
546
+ or ("-" not in exact and e.clickhouse_version.split("-", 1)[0] == bare)
450
547
  ]
548
+ hits = [e for e in any_revision if e.abi_revision == rev]
451
549
  if hits:
452
550
  return max(hits, key=lambda e: e.rank)
453
551
  raise ArtifactUnpublishedError(
454
- f"chtypes: you asked for exactly ClickHouse {exact} on {platform} and "
455
- f"{self.source} does not publish it (it has: "
456
- f"{', '.join(e.clickhouse_version for e in offered)}). "
552
+ f"chtypes: you asked for exactly ClickHouse {exact} on {platform} at ABI "
553
+ f"revision {rev} (this SDK's) and {self.source} does not publish it at that "
554
+ f"revision: {_served(any_revision, f'that patch for {platform}')}; at ABI "
555
+ f"revision {rev} the release has: {have or 'nothing'}. "
457
556
  f"Ask for the line ({line}) to take what was published."
458
557
  )
459
- for e in offered:
558
+ for e in self.offered(platform):
460
559
  if e.minor == line:
461
560
  return e
561
+ any_revision = [e for e in on_platform if e.minor == line]
462
562
  raise ArtifactUnpublishedError(
463
- f"chtypes: no artifact for ClickHouse line {line} on {platform} at {self.source} "
464
- f"(it has: {', '.join(e.clickhouse_version for e in offered)})"
563
+ f"chtypes: no artifact for ClickHouse line {line} on {platform} at ABI revision "
564
+ f"{rev} (this SDK's) at {self.source}: "
565
+ f"{_served(any_revision, f'that line for {platform}')}"
566
+ f"; at ABI revision {rev} the release has: {have or 'nothing'}"
465
567
  )
466
568
 
467
569
 
@@ -509,6 +611,7 @@ def _parse_index(raw: bytes, source: str) -> tuple[list[ReleaseEntry], str, str,
509
611
  arch=str(row["arch"]),
510
612
  build=int(row.get("build") or 0),
511
613
  core_commit=str(row.get("core_commit") or ""),
614
+ abi_revision=_revision_of(row.get("abi_revision")),
512
615
  )
513
616
  )
514
617
  except (KeyError, TypeError, ValueError) as exc:
@@ -725,6 +828,7 @@ class Fetcher:
725
828
  host = (os.environ.get(ENV_ARTIFACTS_URL) or DEFAULT_ARTIFACTS_URL).rstrip("/")
726
829
  base = url or f"{host}/{self.tag}"
727
830
  self.progress: Progress = progress or (lambda line: None)
831
+ self._progress_given = progress is not None
728
832
  self.source = _Source(base, self._say)
729
833
  self.lock = Path(lock) if lock is not None else None
730
834
  self.frozen = frozen
@@ -743,6 +847,19 @@ class Fetcher:
743
847
  log.info("%s", line)
744
848
  self.progress(line)
745
849
 
850
+ def _warn(self, message: str) -> None:
851
+ """A loud line, never silent: through ``progress`` when the caller gave
852
+ one (the CLI does), else straight to stderr."""
853
+ line = f"chtypes: WARNING: {message}"
854
+ # Not log.warning: logging's last-resort handler would print it a
855
+ # second time on stderr for a process that configured no logging.
856
+ log.info("%s", line)
857
+ if self._progress_given:
858
+ self.progress(line)
859
+ else:
860
+ sys.stderr.write(line + "\n")
861
+ sys.stderr.flush()
862
+
746
863
  def _lock_pins(self) -> dict[str, dict[str, str]]:
747
864
  if self._pins is None:
748
865
  if self.lock is None:
@@ -926,12 +1043,21 @@ class Fetcher:
926
1043
  return installed
927
1044
 
928
1045
  def ensure_all(self) -> list[Path]:
929
- """Every line the release publishes for the platform."""
930
- offered = self.release().offered(self.platform)
1046
+ """Every line the release publishes for the platform at this binding's
1047
+ ABI revision. A line it has only at another revision is not installed,
1048
+ and is named in one loud warning line; the rest go on."""
1049
+ release = self.release()
1050
+ offered = release.offered(self.platform)
1051
+ rev = _fetch_abi_revision()
1052
+ on_platform = [e for e in release.entries if e.platform == self.platform]
931
1053
  if not offered:
932
1054
  raise ArtifactUnpublishedError(
933
- f"chtypes: {self.release().source} publishes nothing for {self.platform}"
1055
+ f"chtypes: {release.source} publishes nothing for {self.platform} at ABI "
1056
+ f"revision {rev} (this SDK's): "
1057
+ f"{_served(on_platform, f'rows for {self.platform}')}"
934
1058
  )
1059
+ for message in _skipped_lines(on_platform, self.platform, rev):
1060
+ self._warn(message)
935
1061
  out = [self.install(entry) for entry in offered]
936
1062
  self.install_goldens()
937
1063
  return out
@@ -76,10 +76,10 @@ ENV_REGISTRY: Final = "CHTYPES_REGISTRY"
76
76
 
77
77
  def default_registry_dir() -> str:
78
78
  """The per-user artifact cache for this host — ``${XDG_CACHE_HOME:-~/.cache}/
79
- chtypes/artifacts/<os>-<arch>`` with ``<arch>`` spelled the artifact way
80
- (``amd64``/``arm64``). Where ``chtypes fetch`` installs, where a core-repo
81
- build lands, and slot 3 of the registry search path every SDK walks
82
- (docs/guides/fetch.md §1). A path, not a promise: it need not exist yet."""
79
+ chtypes/artifacts/abi<R>/<os>-<arch>`` with R this binding's `ABI_REVISION`
80
+ and ``<arch>`` spelled the artifact way (``amd64``/``arm64``). Where
81
+ ``chtypes fetch`` installs, and slot 3 of the registry search path every SDK
82
+ walks (docs/guides/fetch.md §1). A path, not a promise: it need not exist yet."""
83
83
  return cache_registry_dir()
84
84
 
85
85
 
@@ -6,14 +6,15 @@ server-truth suites against this same tree.
6
6
 
7
7
  The registry comes from the search path (docs/guides/fetch.md §1): `$CHTYPES_REGISTRY`,
8
8
  else the per-user artifact cache for this host (`chtypes.default_registry_dir()`:
9
- `~/.cache/chtypes/artifacts/<os>-<arch>`, where `chtypes fetch` installs and a
10
- core-repo build lands), else the system locations. Without a single line on it
9
+ `~/.cache/chtypes/artifacts/abi<R>/<os>-<arch>`, R the binding's ABI revision,
10
+ where `chtypes fetch` installs), else the system locations. Without a single line on it
11
11
  the suite skips with that message: a green run that never touched a ClickHouse
12
12
  build would be worse than no run.
13
13
  """
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import json
17
18
  import os
18
19
  from collections.abc import Callable
19
20
  from pathlib import Path
@@ -23,6 +24,47 @@ import pytest
23
24
  import chtypes
24
25
 
25
26
  FALLBACK_REGISTRY = Path(chtypes.default_registry_dir())
27
+ FETCH_FIXTURES = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "fetch"
28
+
29
+
30
+ def _fixture_abi_revision(fixtures: Path) -> int:
31
+ """The ABI revision the fixture set under ``fixtures`` DECLARES, as
32
+ ``expected.json``'s ``fixtures_abi_revision``: the revision every fixture's
33
+ rows carry except ``two-revisions/``'s, which deliberately spans two.
34
+
35
+ Read from the fixtures' own declaration, never typed into a test: a suite
36
+ that hand-set it would be testing its author's belief about the fixtures,
37
+ not the fetch. A missing or non-integer field raises."""
38
+ expected = fixtures / "expected.json"
39
+ doc = json.loads(expected.read_text())
40
+ if "fixtures_abi_revision" not in doc:
41
+ raise AssertionError(
42
+ f"{expected} declares no fixtures_abi_revision — regenerate the fixtures from the "
43
+ f"artifact producer's current set"
44
+ )
45
+ rev = doc["fixtures_abi_revision"]
46
+ if not isinstance(rev, int) or isinstance(rev, bool):
47
+ raise AssertionError(f"{expected}: fixtures_abi_revision is {rev!r}, not an integer")
48
+ return rev
49
+
50
+
51
+ @pytest.fixture
52
+ def derive_fixture_abi_revision() -> Callable[[Path], int]:
53
+ """The declared-field reader itself, for the test that pins it."""
54
+ return _fixture_abi_revision
55
+
56
+
57
+ @pytest.fixture
58
+ def at_fixture_revision(monkeypatch: pytest.MonkeyPatch) -> int:
59
+ """Fetch selects only rows at the binding's own ABI revision
60
+ (docs/guides/fetch.md §2); the fixtures carry whatever revision they were
61
+ generated at, and declare it. Point the test-only override at THAT revision —
62
+ read from expected.json's ``fixtures_abi_revision`` — for this test, and return it."""
63
+ from chtypes import fetch as fetch_module
64
+
65
+ rev = _fixture_abi_revision(FETCH_FIXTURES)
66
+ monkeypatch.setattr(fetch_module, "_ABI_REVISION_OVERRIDE", rev)
67
+ return rev
26
68
 
27
69
 
28
70
  def _candidate() -> tuple[Path, str]:
@@ -35,12 +35,26 @@ def _keys() -> str:
35
35
 
36
36
 
37
37
  @pytest.fixture
38
- def env(isolated_search_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
38
+ def env(
39
+ isolated_search_path: Path, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest
40
+ ) -> Path:
39
41
  if EXPECTED_FILE.is_file():
40
42
  monkeypatch.setenv("CHTYPES_TRUSTED_KEYS", _keys())
43
+ # Fetch at the ABI revision the fixtures carry, read off their own rows.
44
+ request.getfixturevalue("at_fixture_revision")
41
45
  return isolated_search_path
42
46
 
43
47
 
48
+ # `python -m chtypes`, exactly — runpy runs chtypes/__main__.py as __main__,
49
+ # which is all `-m` does — with fetch's test-only revision override set first
50
+ # (the child cannot inherit the parent's monkeypatch). argv[1] is the revision.
51
+ _MODULE_AT_REVISION = (
52
+ "import runpy, sys; import chtypes.fetch as f; "
53
+ "f._ABI_REVISION_OVERRIDE = int(sys.argv.pop(1)); "
54
+ "runpy.run_module('chtypes', run_name='__main__', alter_sys=True)"
55
+ )
56
+
57
+
44
58
  def test_where_prints_the_write_directory(env: Path, capsys: pytest.CaptureFixture[str]) -> None:
45
59
  assert main(["where"]) == 0
46
60
  out = capsys.readouterr().out
@@ -80,7 +94,11 @@ def test_usage_errors_exit_2(env: Path, capsys: pytest.CaptureFixture[str]) -> N
80
94
  ),
81
95
  )
82
96
  def test_fetch_exit_codes_and_streams(
83
- verdict: dict, env: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
97
+ verdict: dict,
98
+ env: Path,
99
+ tmp_path: Path,
100
+ monkeypatch: pytest.MonkeyPatch,
101
+ at_fixture_revision: int,
84
102
  ) -> None:
85
103
  """Every fixture verdict through the real entry point, in a subprocess:
86
104
  the spec's exit code, the code on stderr, and stdout empty or exactly
@@ -99,8 +117,9 @@ def test_fetch_exit_codes_and_streams(
99
117
  proc = subprocess.run(
100
118
  [
101
119
  sys.executable,
102
- "-m",
103
- "chtypes",
120
+ "-c",
121
+ _MODULE_AT_REVISION,
122
+ str(at_fixture_revision),
104
123
  "fetch",
105
124
  "25.8",
106
125
  "--platform",
@@ -174,6 +193,19 @@ def test_verify_and_list(env: Path, tmp_path: Path, capsys: pytest.CaptureFixtur
174
193
  out = capsys.readouterr().out
175
194
  assert "installed (" in out and "[installed]" in out and "signed by key" in out
176
195
  assert "[not installed]" not in out
196
+ assert "not shown" not in out # every row is at the revision fetch selects at
197
+
198
+ # One revision past the fixtures' own, list shows none of their rows and
199
+ # says so in one line naming what it hid (docs/guides/fetch.md §6).
200
+ from chtypes import fetch as fetch_module
201
+
202
+ rev = fetch_module._fetch_abi_revision()
203
+ with pytest.MonkeyPatch.context() as mp:
204
+ mp.setattr(fetch_module, "_ABI_REVISION_OVERRIDE", rev + 1)
205
+ assert main(["list", "--platform", PLATFORM, "--url", signed, "--dest", str(dest)]) == 0
206
+ out = capsys.readouterr().out
207
+ assert f" 2 row(s) at ABI revision(s) {rev} not shown; this SDK speaks {rev + 1}\n" in out
208
+ assert "[installed]" not in out
177
209
 
178
210
  # Corrupt one library: verify says so, exits 1, and names the line.
179
211
  (dest / "26.7" / "libchtypes.so").write_bytes(b"corrupt")