chtypes 0.3.0__tar.gz → 0.3.1__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 (40) hide show
  1. {chtypes-0.3.0 → chtypes-0.3.1}/CHANGELOG.md +10 -0
  2. {chtypes-0.3.0 → chtypes-0.3.1}/PKG-INFO +1 -1
  3. {chtypes-0.3.0 → chtypes-0.3.1}/pyproject.toml +1 -1
  4. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/fetch.py +131 -54
  5. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/registry.py +4 -5
  6. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/results.py +2 -2
  7. chtypes-0.3.1/tests/test_csv_reader.py +445 -0
  8. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_errors.py +1 -1
  9. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_fetch.py +154 -0
  10. chtypes-0.3.1/tests/test_formats_withnames.py +458 -0
  11. chtypes-0.3.1/tests/test_golden.py +262 -0
  12. chtypes-0.3.1/tests/test_quoting_boundaries.py +235 -0
  13. {chtypes-0.3.0 → chtypes-0.3.1}/uv.lock +1 -1
  14. chtypes-0.3.0/tests/test_golden.py +0 -153
  15. {chtypes-0.3.0 → chtypes-0.3.1}/.gitignore +0 -0
  16. {chtypes-0.3.0 → chtypes-0.3.1}/LICENSE +0 -0
  17. {chtypes-0.3.0 → chtypes-0.3.1}/README.md +0 -0
  18. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/__init__.py +0 -0
  19. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/__main__.py +0 -0
  20. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/_document.py +0 -0
  21. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/_ed25519.py +0 -0
  22. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/_manifest.py +0 -0
  23. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/_native.py +0 -0
  24. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/_rawjson.py +0 -0
  25. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/discover.py +0 -0
  26. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/errors.py +0 -0
  27. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/py.typed +0 -0
  28. {chtypes-0.3.0 → chtypes-0.3.1}/src/chtypes/transform.py +0 -0
  29. {chtypes-0.3.0 → chtypes-0.3.1}/tests/conftest.py +0 -0
  30. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_abi_revision.py +0 -0
  31. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_cli.py +0 -0
  32. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_discover_reconstruct.py +0 -0
  33. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_ed25519.py +0 -0
  34. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_lazy.py +0 -0
  35. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_parity.py +0 -0
  36. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_rawjson.py +0 -0
  37. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_registry.py +0 -0
  38. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_results_rules.py +0 -0
  39. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_rows_export_with.py +0 -0
  40. {chtypes-0.3.0 → chtypes-0.3.1}/tests/test_transform.py +0 -0
@@ -4,6 +4,16 @@ All notable changes to the python binding. The format is [Keep a Changelog](http
4
4
 
5
5
  The four bindings in this repository are released together and give one answer, so an entry here has a counterpart in the other three.
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.3.1] — 2026-09-26
10
+
11
+ Speaks ABI revision 5, unchanged from 0.3.0: every artifact 0.3.0 loads, this release loads.
12
+
13
+ ### Changed
14
+
15
+ - **The publish-window retry (`docs/guides/fetch.md` §3a) now also covers a release-level file whose hash disagrees with its `SHA256SUMS` row** — `sdk-goldens.json`, checked after the requested artifact has already installed, since it is best-effort and never blocks that install — and the retry budget widened from 3 attempts ~10s apart to 5 attempts with delays doubling from 4s (4/8/16/32s, ~60s of sleep, ~70s of wall time with network latency). The artifacts host's edge cache can hold a stale pairing of `SHA256SUMS`, its signature, `index.json` or `sdk-goldens.json` for up to its measured 60-second `Cache-Control: max-age`, wider than the original ~10s budget covered. Every retry now re-reads the WHOLE consistent set fresh — never one freshly re-fetched object checked against another attempt's stale one. A release-level file mismatch that never heals still leaves the golden tests skipping loudly, exactly as before, and never fails the fetch that asked for the artifact (issue #223).
16
+
7
17
  ## [0.3.0] — 2026-09-22
8
18
 
9
19
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: chtypes
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chtypes"
3
- version = "0.3.0"
3
+ version = "0.3.1"
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"
@@ -40,7 +40,7 @@ import warnings
40
40
  from collections.abc import Callable, Iterable, Sequence
41
41
  from dataclasses import dataclass
42
42
  from pathlib import Path, PurePosixPath
43
- from typing import Any, Final
43
+ from typing import Any, Final, TypeVar
44
44
 
45
45
  from ._ed25519 import verify as _ed25519_verify
46
46
  from ._manifest import (
@@ -96,9 +96,13 @@ ENV_AUTOFETCH: Final = "CHTYPES_AUTOFETCH"
96
96
  ENV_REGISTRY: Final = "CHTYPES_REGISTRY"
97
97
 
98
98
  #: How many times `Fetcher.release` reads an ``http(s)`` release before giving
99
- #: up, and the wait between reads: three attempts span about ten seconds, which
100
- #: comfortably outlasts a one-object publish window.
101
- RELEASE_LOAD_ATTEMPTS: Final = 3
99
+ #: up, and the BASE delay before the first retry — each later retry doubles it,
100
+ #: so the default 5 attempts sleep 4+8+16+32 = 60s (~70s wall with network
101
+ #: time). That is chosen to outlast the artifacts host's edge cache (observed
102
+ #: ``Cache-Control: max-age=60`` on the mutable release objects, so a stale
103
+ #: pairing of any of them can persist up to 60s), while a genuine few-second
104
+ #: mid-publish window still clears on the second attempt.
105
+ RELEASE_LOAD_ATTEMPTS: Final = 5
102
106
  RELEASE_RETRY_DELAY: Final = 4
103
107
 
104
108
  #: The served golden set: a release-level file, and a row in the signed
@@ -128,6 +132,8 @@ DEFAULT_LOCK_FILE: Final = "chtypes.lock"
128
132
  _USER_AGENT = "chtypes-python (+https://github.com/wave-rf/chtypes)"
129
133
  _HTTP_ATTEMPTS = 3
130
134
  _HTTP_TIMEOUT = 60.0
135
+
136
+ _T = TypeVar("_T")
131
137
  _CHUNK = 1 << 20
132
138
 
133
139
  log = logging.getLogger("chtypes.fetch")
@@ -752,45 +758,86 @@ class Fetcher:
752
758
 
753
759
  # ------------------------------------------------------------ the release
754
760
 
755
- def release(self) -> Release:
756
- """Steps 0–1, retried through a publish window.
761
+ def _retry_publish_window(self, load: Callable[[], _T]) -> _T:
762
+ """Runs ``load`` through the publish-window retry: on every attempt but
763
+ the last, an `ArtifactUntrustedError` or `ArtifactCorruptError` is
764
+ logged and slept off before trying again; any other exception, or the
765
+ last attempt's, propagates immediately.
757
766
 
758
767
  A publish into the rolling release is three objects — ``SHA256SUMS``,
759
- ``SHA256SUMS.sig``, ``index.json`` — and object storage cannot swap them
760
- atomically. They go up in that order, so an old index read against new
761
- sums still cross-checks; the unsafe window is between the sums and the
762
- signature that covers them, one small object wide and seconds long.
763
-
764
- The two symptoms of reading inside it — a signature under no trusted key
765
- (`ArtifactUntrustedError`) and an index that disagrees with the sums
766
- (`ArtifactCorruptError`) — are retried. Nothing else is, and neither are
767
- these once the attempts run out: the same exception surfaces, with the
768
- same exit code, as it did before. A tarball whose hash is wrong is never
769
- retried; that is the release lying about a byte, not a half-finished
770
- upload.
768
+ ``SHA256SUMS.sig``, ``index.json`` — plus, when ``load`` also reads it,
769
+ the served golden set — and object storage cannot swap them atomically.
770
+ The artifacts host's edge cache widens that window from "between two
771
+ uploads" to "as long as any one object can still be served stale from
772
+ cache" (observed ``Cache-Control: max-age=60`` on all four).
773
+
774
+ Three symptoms of reading inside it are retried: a signature under no
775
+ trusted key, an index that disagrees with the sums, and a release-level
776
+ file whose hash disagrees with its own SHA256SUMS row (or that the sums
777
+ list but the source does not yet serve). Each retry calls ``load``
778
+ again from scratch — the whole consistent set is re-read together,
779
+ never one freshly re-fetched object checked against another attempt's
780
+ stale one. Nothing else is retried, and neither are these three once
781
+ the attempts run out: the same exception surfaces, with the same exit
782
+ code, as it did before. A tarball whose hash is wrong is never retried;
783
+ that is the release lying about a byte, not a half-finished upload.
771
784
 
772
785
  Only an ``http(s)`` source can be mid-publish, so a ``file://`` or
773
- directory source is read exactly once. Read once per `Fetcher`."""
774
- if self._release is not None:
775
- return self._release
786
+ directory source runs ``load`` exactly once."""
776
787
  attempts = RELEASE_LOAD_ATTEMPTS if self.source.kind == "http" else 1
788
+ delay = RELEASE_RETRY_DELAY
777
789
  for attempt in range(1, attempts + 1):
778
790
  try:
779
- self._release = self._load_release()
780
- return self._release
791
+ return load()
781
792
  except (ArtifactUntrustedError, ArtifactCorruptError) as exc:
782
793
  if attempt >= attempts:
783
794
  raise
784
795
  self._say(
785
796
  f"{exc} (attempt {attempt}/{attempts}) — this is what a release being "
786
- f"published looks like from outside; retrying in {RELEASE_RETRY_DELAY}s"
797
+ f"published looks like from outside; retrying in {delay}s"
787
798
  )
788
- time.sleep(RELEASE_RETRY_DELAY)
799
+ time.sleep(delay)
800
+ delay *= 2
789
801
  raise AssertionError("unreachable") # pragma: no cover
790
802
 
791
- def _load_release(self) -> Release:
792
- """One read of the release's three small files. Never memoizes: the
793
- caller does that, and only on success."""
803
+ def release(self) -> Release:
804
+ """Steps 0–1, retried through a publish window (`_retry_publish_window`).
805
+ Read once per `Fetcher`: a second call returns the memoized result."""
806
+ if self._release is not None:
807
+ return self._release
808
+ self._release, _, _ = self._retry_publish_window(lambda: self._load_release())
809
+ return self._release
810
+
811
+ def _read_goldens_or_raise(self, want: str) -> bytes:
812
+ """One read of ``sdk-goldens.json``, verified against ``want`` (its row
813
+ in an already-verified SHA256SUMS). Raises `ArtifactCorruptError` for
814
+ either half of the new window symptom — a hash mismatch, or the sums
815
+ listing it while the source does not (yet) serve it — and
816
+ `SourceUnreachableError` for a plain I/O fault."""
817
+ src = str(self.source)
818
+ try:
819
+ blob = self.source.read(GOLDENS_ASSET)
820
+ except OSError as exc:
821
+ raise SourceUnreachableError(
822
+ f"chtypes: could not read {GOLDENS_ASSET} from {src}: {exc}"
823
+ ) from exc
824
+ if blob is None:
825
+ raise ArtifactCorruptError(
826
+ f"chtypes: SHA256SUMS lists {GOLDENS_ASSET} but {src} does not serve it "
827
+ f"— the release disagrees with itself; not installing it"
828
+ )
829
+ got = hashlib.sha256(blob).hexdigest()
830
+ if got != want:
831
+ raise ArtifactCorruptError(
832
+ f"chtypes: {GOLDENS_ASSET} hashes to {got} but the signed SHA256SUMS "
833
+ f"says {want} — not installing it"
834
+ )
835
+ return blob
836
+
837
+ def _load_release(self, *, want_goldens: bool = False) -> tuple[Release, bytes | None, bool]:
838
+ """One read of the release's three small files, plus — when
839
+ ``want_goldens`` — the served golden set, as part of the SAME read.
840
+ Never memoizes: the caller does that, and only on success."""
794
841
  if self.offline:
795
842
  raise SourceUnreachableError(
796
843
  f"chtypes: offline — not reading the release at {self.source}"
@@ -826,11 +873,12 @@ class Fetcher:
826
873
  if index_raw is None:
827
874
  raise SourceUnreachableError(f"chtypes: no index.json at {src} — not a chtypes release")
828
875
  entries, tag, license_, license_url = _parse_index(index_raw, src)
876
+ sums = _parse_sums(sums_raw)
829
877
  release = Release(
830
878
  source=src,
831
879
  tag=tag or self.tag,
832
880
  entries=tuple(entries),
833
- sums=_parse_sums(sums_raw),
881
+ sums=sums,
834
882
  signed_by=signed_by,
835
883
  license=license_,
836
884
  license_url=license_url,
@@ -845,12 +893,25 @@ class Fetcher:
845
893
  f"chtypes: index.json says {entry.file} is {entry.sha256} but SHA256SUMS "
846
894
  f"says {listed} — the release disagrees with itself; not installing it"
847
895
  )
896
+ # The served golden set, read as part of the SAME set as the three
897
+ # objects above: it is a row in SHA256SUMS exactly like a tarball, so a
898
+ # stale pairing of it against the sums (or against nothing, if the sums
899
+ # row landed before the file itself is visible) is the same publish
900
+ # window, not a separate failure mode. A release that never lists it
901
+ # simply predates the served set — not a window symptom, so not raised.
902
+ goldens_blob: bytes | None = None
903
+ goldens_listed = False
904
+ if want_goldens:
905
+ want = sums.get(GOLDENS_ASSET)
906
+ goldens_listed = want is not None
907
+ if want is not None:
908
+ goldens_blob = self._read_goldens_or_raise(want)
848
909
  if license_:
849
910
  self._say(
850
911
  f"artifacts are licensed under {license_}{' ' + license_url if license_url else ''}"
851
912
  f" — LICENSE and NOTICE ship beside them"
852
913
  )
853
- return release
914
+ return release, goldens_blob, goldens_listed
854
915
 
855
916
  # --------------------------------------------------------------- ensure
856
917
 
@@ -883,37 +944,53 @@ class Fetcher:
883
944
  the same chain and lands beside the artifacts, where every binding's
884
945
  golden test reads it offline.
885
946
 
947
+ The first look reuses `release`'s already-verified sums — cheap, and
948
+ right on the overwhelmingly common case that nothing is mid-publish.
949
+ Only a disagreement (a hash mismatch, or the sums listing it while the
950
+ source does not yet serve it) is retried, and a retry re-reads the
951
+ WHOLE consistent set fresh — SHA256SUMS, its signature, index.json and
952
+ the golden set together — never the golden set alone against the first
953
+ look's by-then possibly-stale sums.
954
+
886
955
  Never raises: a release with no such row simply predates the served set,
887
- and a set that cannot be written leaves the golden tests skipping
888
- loudly, which is their job when there is nothing to read. What it will
889
- not do is install bytes the signed ``SHA256SUMS`` does not describe.
956
+ a mismatch that never heals leaves the golden tests skipping loudly —
957
+ which is their job when there is nothing trustworthy to read — and a
958
+ set that cannot be written does the same. What it will not do is
959
+ install bytes the signed ``SHA256SUMS`` does not describe.
890
960
  """
891
- release = self.release()
892
- want = release.sums.get(GOLDENS_ASSET)
893
- if want is None:
961
+ attempts = RELEASE_LOAD_ATTEMPTS if self.source.kind == "http" else 1
962
+ delay = RELEASE_RETRY_DELAY
963
+ sums = self.release().sums
964
+ listed = False
965
+ blob: bytes | None = None
966
+ for attempt in range(1, attempts + 1):
967
+ try:
968
+ if attempt == 1:
969
+ want = sums.get(GOLDENS_ASSET)
970
+ listed = want is not None
971
+ blob = self._read_goldens_or_raise(want) if want is not None else None
972
+ else:
973
+ # A retry: the earlier `sums` may itself be stale by now, so
974
+ # this re-reads everything, not just the golden set.
975
+ _, blob, listed = self._load_release(want_goldens=True)
976
+ break
977
+ except (ArtifactUntrustedError, ArtifactCorruptError, SourceUnreachableError) as exc:
978
+ if attempt >= attempts:
979
+ self._say(f"{exc} — the golden tests will skip")
980
+ return None
981
+ self._say(
982
+ f"{exc} (attempt {attempt}/{attempts}) — this is what a release being "
983
+ f"published looks like from outside; retrying in {delay}s"
984
+ )
985
+ time.sleep(delay)
986
+ delay *= 2
987
+ if not listed:
894
988
  self._say(
895
989
  f"this release does not publish {GOLDENS_ASSET} "
896
990
  f"(the SDKs' golden tests will skip until it does)"
897
991
  )
898
992
  return None
899
- try:
900
- blob = self.source.read(GOLDENS_ASSET)
901
- except OSError as exc:
902
- self._say(f"could not read {GOLDENS_ASSET}: {exc} — the golden tests will skip")
903
- return None
904
- if blob is None:
905
- self._say(
906
- f"SHA256SUMS lists {GOLDENS_ASSET} but {self.source} does not serve it "
907
- f"— NOT installing it"
908
- )
909
- return None
910
- got = hashlib.sha256(blob).hexdigest()
911
- if got != want:
912
- self._say(
913
- f"NOT installing {GOLDENS_ASSET}: it hashes to {got} but the signed "
914
- f"SHA256SUMS says {want}"
915
- )
916
- return None
993
+ assert blob is not None # listed and no exception implies bytes were read
917
994
  try:
918
995
  self.dest.mkdir(parents=True, exist_ok=True)
919
996
  path = self.dest / GOLDENS_ASSET
@@ -166,10 +166,9 @@ def encode_columns(columns: Sequence[str] | None) -> str | None:
166
166
 
167
167
  `None` and an EMPTY sequence are the SAME input and both encode to
168
168
  `None`, never to `"[]"`: `INSERT INTO t () FORMAT X` is code 62
169
- `SYNTAX_ERROR` on every served line (measured 2026-09-15, core's
170
- explicit-column-list measurements), so an implementation that renders an
171
- empty list into parentheses sends a statement no ClickHouse has ever
172
- accepted.
169
+ `SYNTAX_ERROR` on every served line (measured 2026-09-15 against explicit
170
+ column lists), so an implementation that renders an empty list into
171
+ parentheses sends a statement no ClickHouse has ever accepted.
173
172
 
174
173
  No local validation of names — an unknown column, an `ALIAS` column and a
175
174
  repeated name are all refused by the SERVER, with its own codes (16, 16,
@@ -1020,7 +1019,7 @@ class Library:
1020
1019
  `deterministic_in_query`, `server_constant`, `stateful`,
1021
1020
  `resolver_error_code`) — ClickHouse's own answers off this build's own
1022
1021
  registry, the input to the statelessness gate
1023
- (`lib/tools/gen_function_flags.py`).
1022
+ (the build's function-flags generator).
1024
1023
 
1025
1024
  Part of the three-question introspection surface every SDK exposes
1026
1025
  (docs/reference/bindings.md §Introspection). Raises `UnsupportedError` when the
@@ -235,7 +235,7 @@ class Outcome(StrEnum):
235
235
 
236
236
  ACCEPTED = "accepted"
237
237
  REJECTED = "rejected"
238
- # The insert returns rc=0 and every later SELECT fails with code 691. This
238
+ # The insert returns rc=0 and every later SELECT fails (usually 691). This
239
239
  # is an ACCEPTED insert, and reporting it as a rejection is wrong.
240
240
  ACCEPTED_POISONED = "accepted_poisoned"
241
241
  # This build declines to answer. Never scored as agreement.
@@ -500,7 +500,7 @@ class RowResult:
500
500
 
501
501
  @property
502
502
  def poisoned(self) -> bool:
503
- """Whether ClickHouse stored a value it cannot read back (code 691).
503
+ """Whether ClickHouse stored a value it cannot read back (usually 691).
504
504
 
505
505
  A poisoned row IS an accepted insert; every later SELECT on it fails.
506
506
  """