chtypes 0.2.0__tar.gz → 0.2.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.
- {chtypes-0.2.0 → chtypes-0.2.2}/CHANGELOG.md +20 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/PKG-INFO +1 -1
- {chtypes-0.2.0 → chtypes-0.2.2}/pyproject.toml +1 -1
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/_manifest.py +3 -1
- chtypes-0.2.2/tests/test_abi_revision.py +139 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/tests/test_registry.py +43 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/uv.lock +1 -1
- {chtypes-0.2.0 → chtypes-0.2.2}/.gitignore +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/LICENSE +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/README.md +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/__init__.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/__main__.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/_document.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/_ed25519.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/_native.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/_rawjson.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/discover.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/errors.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/fetch.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/py.typed +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/registry.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/results.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/src/chtypes/transform.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/tests/conftest.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/tests/test_cli.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/tests/test_ed25519.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/tests/test_errors.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/tests/test_fetch.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/tests/test_golden.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/tests/test_parity.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/tests/test_rawjson.py +0 -0
- {chtypes-0.2.0 → chtypes-0.2.2}/tests/test_transform.py +0 -0
|
@@ -4,6 +4,26 @@ 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
|
+
## [0.2.2] — 2026-09-17
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **`Registry(..., verify_hashes=True)` now refuses an artifact whose manifest carries no `library_sha256`, instead of loading it silently.** This is a behavior change in a security posture, not a silent fix: previously, asking for verification against a manifest with the field absent returned as if the bytes had been checked, when they never were — the caller believed the load was verified and it was not. `verify_library` now raises `RegistryError` naming the path and saying the manifest carries no `library_sha256`, the same shape its hash-mismatch path has always raised. `verify_hashes` off is unaffected: the field stays optional for a caller who did not ask. Go, TypeScript and Rust already refused this case; this was the one gap issue #13's item A2 left (#48).
|
|
12
|
+
|
|
13
|
+
### Notes
|
|
14
|
+
|
|
15
|
+
- Speaks **ABI revision 4**, unchanged since 0.1.0, so no artifact needs relinking. The golden set this release was tested against is the one core serves, generated on the ClickHouse lines 24.8, 25.3, 25.8, 25.10, 26.2, 26.3, 26.4, 26.5, 26.6, 26.7 and 26.8.
|
|
16
|
+
- Seven facts about the compiled library's behavior that a consumer previously had to discover by experiment are now written down: `CHECK`-constraint batch rejection, binding filter parameters as `String`, single-line compact JSON array loss under `allow_errors`, the JSONCompactEachRow export field separator, the value-injection pattern and its compile-time wrap trap, `Columns` as canonical and in declaration order, and the frozen-signatures promise reworded ahead of the next ABI revision (#56). That promise is now stated once, in `docs/support.md`, and linked from everywhere else rather than restated in nine places (#69).
|
|
17
|
+
|
|
18
|
+
## [0.2.1] — 2026-09-15
|
|
19
|
+
|
|
20
|
+
Released in step with the Rust crate, which gains `Registry::open` and `Error::LibraryRead`; this binding's public API is unchanged.
|
|
21
|
+
|
|
22
|
+
### Notes
|
|
23
|
+
|
|
24
|
+
- Speaks **ABI revision 4**, unchanged since 0.1.0, so no artifact needs relinking. The golden set this release was tested against is the one core serves, generated on the ClickHouse lines 24.8, 25.3, 25.8, 25.10, 26.2, 26.3, 26.4, 26.5, 26.6, 26.7 and 26.8.
|
|
25
|
+
- **The ABI-revision refusal is now run, not assumed.** This binding's suite points a registry at a generated artifact answering the wrong revision and asserts the load is refused naming both numbers, beside a matching-revision control that must load; CI fails the build if either case did not run (#36).
|
|
26
|
+
|
|
7
27
|
## [0.2.0] — 2026-09-15
|
|
8
28
|
|
|
9
29
|
### Changed — BREAKING
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: chtypes
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -106,7 +106,9 @@ def verify_library(version_dir: str | os.PathLike[str]) -> None:
|
|
|
106
106
|
f"chtypes: {path} is {size} bytes, manifest says {manifest.library_bytes}"
|
|
107
107
|
)
|
|
108
108
|
if not manifest.library_sha256:
|
|
109
|
-
|
|
109
|
+
raise RegistryError(
|
|
110
|
+
f"chtypes: {path} cannot be verified: manifest carries no library_sha256"
|
|
111
|
+
)
|
|
110
112
|
digest = hashlib.sha256()
|
|
111
113
|
with path.open("rb") as handle:
|
|
112
114
|
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""The ABI-revision handshake, run against a wrong-revision fixture (#36).
|
|
2
|
+
|
|
3
|
+
`docs/reference/artifact.md` step 5 is normative: an artifact whose
|
|
4
|
+
`chs_abi_revision()` answers a value that is neither 0 nor the revision this
|
|
5
|
+
binding was written against MUST be refused, **naming both numbers**
|
|
6
|
+
(`chtypes/_native.py`, `NativeLibrary.__init__`: "refusing to call through
|
|
7
|
+
mismatched declarations"). This suite is the first thing in this repository
|
|
8
|
+
that actually RUNS that rule instead of assuming it.
|
|
9
|
+
|
|
10
|
+
The fixture is a pair of stub shared libraries generated from `include/chtypes.h`
|
|
11
|
+
itself, by the served ABI-revision fixture generator (`abi-revision/gen.py`):
|
|
12
|
+
one registry whose artifact answers `abi_revision + 1` (`wrong-revision/`), one
|
|
13
|
+
answering `abi_revision`
|
|
14
|
+
exactly (`at-revision/`), both on ClickHouse minor line "0.0". **Both halves
|
|
15
|
+
matter** — without the `at-revision` control, a binding whose own
|
|
16
|
+
`ABI_REVISION` had drifted would refuse everything and pass the refusal case
|
|
17
|
+
for entirely the wrong reason.
|
|
18
|
+
|
|
19
|
+
Every expected number comes from `fixture.json` — never from
|
|
20
|
+
`chtypes.ABI_REVISION` — because a case that read the constant under test
|
|
21
|
+
could not catch that constant being wrong.
|
|
22
|
+
|
|
23
|
+
`isolated_search_path` (`tests/conftest.py`) is used to keep the per-user
|
|
24
|
+
artifact cache and any ambient `$CHTYPES_REGISTRY`/system roots out of the
|
|
25
|
+
search path: `Registry(root)` puts `root` first regardless, but this removes
|
|
26
|
+
any chance that the fixture's odd "0.0" minor line is ever resolved from
|
|
27
|
+
somewhere other than the fixture itself.
|
|
28
|
+
|
|
29
|
+
Reopening a library after `close()` in the same process is measured to
|
|
30
|
+
segfault (`docs/reference/bindings.md` §Teardown), so nothing here closes a
|
|
31
|
+
loaded library: the refusal case never gets far enough to open one, the
|
|
32
|
+
control case leaves its `Library` for process teardown (`chs_shutdown` runs
|
|
33
|
+
via `atexit`), and neither path is ever loaded a second time.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Final
|
|
43
|
+
|
|
44
|
+
import pytest
|
|
45
|
+
|
|
46
|
+
import chtypes
|
|
47
|
+
|
|
48
|
+
#: Where the fixture root lives. Built by the served abi-revision generator
|
|
49
|
+
#: (`abi-revision/gen.py build`, core repo); this suite never builds one itself.
|
|
50
|
+
ENV_ABI_FIXTURES: Final = "CHTYPES_ABI_FIXTURES"
|
|
51
|
+
|
|
52
|
+
#: The one ClickHouse minor line every fixture artifact answers to.
|
|
53
|
+
FIXTURE_MINOR: Final = "0.0"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.fixture(scope="session")
|
|
57
|
+
def abi_fixture() -> dict:
|
|
58
|
+
"""Parse and validate `fixture.json`. Missing `$CHTYPES_ABI_FIXTURES` is a
|
|
59
|
+
SKIP (no fallback build); a fixture that exists but is malformed is a
|
|
60
|
+
FAILURE naming the path — a bad fixture must never read as "nothing to
|
|
61
|
+
test here"."""
|
|
62
|
+
env = os.environ.get(ENV_ABI_FIXTURES)
|
|
63
|
+
if not env:
|
|
64
|
+
pytest.skip(
|
|
65
|
+
f"no ABI revision fixture: ${ENV_ABI_FIXTURES} is unset (build one with "
|
|
66
|
+
"abi-revision/gen.py build --out DIR --header include/chtypes.h)"
|
|
67
|
+
)
|
|
68
|
+
root = Path(env)
|
|
69
|
+
fixture_path = root / "fixture.json"
|
|
70
|
+
try:
|
|
71
|
+
raw = fixture_path.read_text()
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
pytest.fail(f"{fixture_path}: cannot read the ABI revision fixture: {exc}")
|
|
74
|
+
try:
|
|
75
|
+
doc = json.loads(raw)
|
|
76
|
+
except json.JSONDecodeError as exc:
|
|
77
|
+
pytest.fail(f"{fixture_path}: not valid JSON: {exc}")
|
|
78
|
+
abi_revision = doc.get("abi_revision")
|
|
79
|
+
valid = isinstance(abi_revision, int) and not isinstance(abi_revision, bool)
|
|
80
|
+
if not valid or abi_revision <= 0:
|
|
81
|
+
pytest.fail(
|
|
82
|
+
f"{fixture_path}: abi_revision must be a positive integer, got {abi_revision!r}"
|
|
83
|
+
)
|
|
84
|
+
mismatch_revision = doc.get("mismatch_revision")
|
|
85
|
+
if mismatch_revision != abi_revision + 1:
|
|
86
|
+
pytest.fail(
|
|
87
|
+
f"{fixture_path}: mismatch_revision is {mismatch_revision!r}, expected "
|
|
88
|
+
f"abi_revision ({abi_revision}) + 1"
|
|
89
|
+
)
|
|
90
|
+
doc["root"] = str(root)
|
|
91
|
+
return doc
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _names_number(message: str, n: int) -> bool:
|
|
95
|
+
""" "The message says N", not "the message contains the digits of N": a
|
|
96
|
+
refusal naming revision 4 must not be satisfied by the 4 in a path like
|
|
97
|
+
`.../24.8/`, and one naming 5 must not be satisfied by 15."""
|
|
98
|
+
return re.search(rf"(^|[^0-9]){n}([^0-9]|$)", message) is not None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _without_fixture_path(message: str, root: Path) -> str:
|
|
102
|
+
"""Strip every occurrence of the fixture root from a message before
|
|
103
|
+
`_names_number` checks it. The refusal message embeds the artifact's
|
|
104
|
+
PATH, and a path segment can itself hold a standalone digit (a scratchpad
|
|
105
|
+
session id such as `4d9fd784-...`), which would let the path alone
|
|
106
|
+
satisfy "the message names 4" for reasons that have nothing to do with
|
|
107
|
+
the refusal text itself. Both the given root and its resolved form are
|
|
108
|
+
stripped (macOS's `/tmp` vs `/private/tmp` differ), longest first so one
|
|
109
|
+
is never a partial match of the other.
|
|
110
|
+
"""
|
|
111
|
+
candidates = sorted({str(root), os.path.realpath(root)}, key=len, reverse=True)
|
|
112
|
+
for candidate in candidates:
|
|
113
|
+
message = message.replace(candidate, "<fixture>")
|
|
114
|
+
return message
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_wrong_revision_is_refused(
|
|
118
|
+
abi_fixture: dict,
|
|
119
|
+
isolated_search_path: Path,
|
|
120
|
+
) -> None:
|
|
121
|
+
root = Path(abi_fixture["root"], "wrong-revision")
|
|
122
|
+
registry = chtypes.Registry(root)
|
|
123
|
+
with pytest.raises(chtypes.RegistryError) as excinfo:
|
|
124
|
+
registry.for_version(FIXTURE_MINOR)
|
|
125
|
+
message = str(excinfo.value)
|
|
126
|
+
stripped = _without_fixture_path(message, root)
|
|
127
|
+
for n in (abi_fixture["mismatch_revision"], abi_fixture["abi_revision"]):
|
|
128
|
+
assert _names_number(stripped, n), f"the refusal does not name {n}: {message!r}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_matching_revision_loads(
|
|
132
|
+
abi_fixture: dict,
|
|
133
|
+
isolated_search_path: Path,
|
|
134
|
+
) -> None:
|
|
135
|
+
root = Path(abi_fixture["root"], "at-revision")
|
|
136
|
+
registry = chtypes.Registry(root)
|
|
137
|
+
library = registry.for_version(FIXTURE_MINOR)
|
|
138
|
+
assert library.abi_revision == abi_fixture["abi_revision"]
|
|
139
|
+
assert library.version == abi_fixture["clickhouse_version"]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import dataclasses
|
|
5
6
|
import hashlib
|
|
6
7
|
import json
|
|
7
8
|
import os
|
|
@@ -221,6 +222,48 @@ def test_verify_library_checks_the_bytes(tmp_path: Path) -> None:
|
|
|
221
222
|
chtypes.verify_library(tmp_path)
|
|
222
223
|
|
|
223
224
|
|
|
225
|
+
def test_verify_library_refuses_a_manifest_with_no_hash(tmp_path: Path) -> None:
|
|
226
|
+
"""Verification asked for and not possible is not verification (#48): Go,
|
|
227
|
+
TypeScript and Rust all refuse an artifact whose manifest carries no
|
|
228
|
+
`library_sha256` when checksums are asked for. `verify_library` runs only
|
|
229
|
+
when a caller asked for it (`Registry(verify_hashes=True)`), so a manifest
|
|
230
|
+
missing the field must raise here too, not return quietly.
|
|
231
|
+
"""
|
|
232
|
+
payload = b"not really a library"
|
|
233
|
+
(tmp_path / "fake.so").write_bytes(payload)
|
|
234
|
+
(tmp_path / "manifest.json").write_text(
|
|
235
|
+
json.dumps({"library": "fake.so", "library_bytes": len(payload)})
|
|
236
|
+
)
|
|
237
|
+
with pytest.raises(chtypes.RegistryError, match="library_sha256"):
|
|
238
|
+
chtypes.verify_library(tmp_path)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_registry_verify_hashes_refuses_a_manifest_with_no_hash(
|
|
242
|
+
registry: chtypes.Registry, tmp_path: Path
|
|
243
|
+
) -> None:
|
|
244
|
+
"""`Registry(..., verify_hashes=True)` must not load bytes it never
|
|
245
|
+
checked (#48): a manifest with the field simply absent is exactly the
|
|
246
|
+
shape a truncated or hand-assembled artifact directory has. With
|
|
247
|
+
`verify_hashes` off the field stays optional for a caller who did not
|
|
248
|
+
ask, and the same directory loads normally.
|
|
249
|
+
"""
|
|
250
|
+
smallest = min(registry.libraries(), key=lambda lib: lib.manifest.library_bytes or 1 << 62)
|
|
251
|
+
fields = dataclasses.asdict(smallest.manifest)
|
|
252
|
+
del fields["library_sha256"]
|
|
253
|
+
line = tmp_path / smallest.minor
|
|
254
|
+
line.mkdir()
|
|
255
|
+
(line / smallest.manifest.library).symlink_to(Path(smallest.path).resolve())
|
|
256
|
+
(line / "manifest.json").write_text(json.dumps(fields))
|
|
257
|
+
|
|
258
|
+
strict = chtypes.Registry(tmp_path, verify_hashes=True)
|
|
259
|
+
with pytest.raises(chtypes.RegistryError, match="library_sha256"):
|
|
260
|
+
strict.libraries()
|
|
261
|
+
|
|
262
|
+
lenient = chtypes.Registry(tmp_path, verify_hashes=False)
|
|
263
|
+
loaded = lenient.for_version(smallest.minor) # must NOT raise
|
|
264
|
+
assert loaded.version == smallest.version
|
|
265
|
+
|
|
266
|
+
|
|
224
267
|
def test_verify_a_real_artifact(registry: chtypes.Registry) -> None:
|
|
225
268
|
# The one integrity check that means anything, run against the real bytes: a
|
|
226
269
|
# move that reported success and truncated a 232 MB library looks identical
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|