chtypes 2.0.0.dev5__tar.gz → 2.0.0.dev6__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-2.0.0.dev5 → chtypes-2.0.0.dev6}/CHANGELOG.md +12 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/PKG-INFO +1 -1
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/pyproject.toml +1 -1
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_channel.py +28 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_constants.py +7 -1
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/registry.py +83 -22
- chtypes-2.0.0.dev6/tests/abi2/test_registry_flight.py +189 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_conformance.py +90 -26
- chtypes-2.0.0.dev6/tests/ocifetch/test_prodv2_smoke.py +63 -0
- chtypes-2.0.0.dev6/tests/ocifetch/test_prodv2channel.py +70 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/uv.lock +1 -1
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/.gitignore +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/LICENSE +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/README.md +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/__init__.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/__main__.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_abi2/__init__.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_abi2/_decls.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_abi2/_errmap.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_abi2/_errors.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_abi2/_loader.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_abi2/_vocab.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_decode.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ed25519.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_guard.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_input.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/__init__.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_dsse.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_ensure.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_errors.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_faults.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_goldens.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_http.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_layout.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_lock.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_oci.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_referrers.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_retired.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_unpack.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_setup.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/errors.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/library.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/py.typed +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/results.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/__init__.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/_stub_tree.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/conftest.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_conformance.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_dev_fingerprint.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_goldens_runner_stub.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_loader.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_public_library.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_public_open.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_public_registry.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_public_server.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_public_setup_cases.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_reader_rules.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/api/__init__.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/api/test_decode.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/api/test_input.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/api/test_vocab.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/cli/__init__.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/cli/conftest.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/cli/test_devchannel.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/cli/test_main.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/conftest.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/goldens_v1/__init__.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/goldens_v1/data/stub-goldens.json +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/goldens_v1/test_runner.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/__init__.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/_bundle_support.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/_registry_support.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/_sign_support.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/conftest.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_cache_roots.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_cache_strict.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_devchannel.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_dsse.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_ensure.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_goldens.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_http.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_layout.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_lock.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_oci.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_retired.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_seam_gate.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_unpack.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/test_abi_identity.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/test_cache_roots_api.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/test_devchannel_registry.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/test_ed25519.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/test_examples_tour.py +0 -0
- {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/test_fetch_options.py +0 -0
|
@@ -33,6 +33,17 @@ The four bindings in this repository are released together and give one answer,
|
|
|
33
33
|
- **Enforcement gate lifted** for `26.7` on `darwin-arm64`.
|
|
34
34
|
- **Enforcement gate lifted** for `26.8` on `darwin-arm64`.
|
|
35
35
|
- **Enforcement gate lifted** for `26.9` on `darwin-arm64`.
|
|
36
|
+
- **Enforcement gate restored** for `26.3` on `linux-amd64`.
|
|
37
|
+
- **Enforcement gate restored** for `26.3` on `linux-arm64`.
|
|
38
|
+
- **Enforcement gate restored** for `26.7` on `linux-amd64`.
|
|
39
|
+
- **Enforcement gate restored** for `26.7` on `linux-arm64`.
|
|
40
|
+
- **Enforcement gate restored** for `26.8` on `linux-amd64`.
|
|
41
|
+
- **Enforcement gate restored** for `26.8` on `linux-arm64`.
|
|
42
|
+
- **Enforcement gate restored** for `26.9` on `linux-amd64`.
|
|
43
|
+
- **Enforcement gate restored** for `26.9` on `linux-arm64`.
|
|
44
|
+
- **Enforcement gate restored** for `26.7` on `darwin-arm64`.
|
|
45
|
+
- **Enforcement gate restored** for `26.8` on `darwin-arm64`.
|
|
46
|
+
- **Enforcement gate restored** for `26.9` on `darwin-arm64`.
|
|
36
47
|
|
|
37
48
|
### Removed
|
|
38
49
|
|
|
@@ -42,6 +53,7 @@ The four bindings in this repository are released together and give one answer,
|
|
|
42
53
|
|
|
43
54
|
- A 2.0.0-dev SDK whose own-fingerprint alias has expired, with a cached build of that fingerprint for the request, now says so in the `CHTYPES_ARTIFACT_UNPUBLISHED` message of `fetch` (#581): `; a cached build for this fingerprint (build <id>) is still usable by open; upgrade the SDK to get newer builds` is appended. The code, the exit status (4) and the cache are unchanged, and all four bindings give the same message (`fp-ahead-cached-own-fp`).
|
|
44
55
|
- A 2.0.0-dev SDK whose fingerprint no published build carries yet now fails closed with a precise message (#578). When every base answers its own alias 404 and the tag names a build signed for another fingerprint, `fetch` is `CHTYPES_ARTIFACT_UNPUBLISHED` (exit status 4): `no published build for this SDK's fingerprint <own fp>; newest published on this channel is <fp> (build <id>)`. It used to install that build, or report it already installed, and the lookup at open then answered `CHTYPES_ARTIFACT_MISSING` with advice to run `fetch`: a loop with no pointer to the cause. Nothing is downloaded or installed and the cache is unchanged. An alias that answers, a tag at the SDK's own fingerprint and the v1 contract are unchanged. All four bindings run the same fetch conformance cases, `fp-ahead-*`, and the rule is `docs/guides/fetch-v1.md` section 3.
|
|
56
|
+
- Opening a line no longer waits for another line's fetch: the registry never holds its lock across network I/O, and concurrent opens of one request share one fetch (#491).
|
|
45
57
|
|
|
46
58
|
## [1.1.0] — 2026-10-06
|
|
47
59
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: chtypes
|
|
3
|
-
Version: 2.0.0.
|
|
3
|
+
Version: 2.0.0.dev6
|
|
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
|
|
@@ -79,6 +79,7 @@ __all__ = [
|
|
|
79
79
|
"visible",
|
|
80
80
|
"use_dev_channel_for_tests",
|
|
81
81
|
"use_fetch_v1_for_tests",
|
|
82
|
+
"use_prod_v2_for_tests",
|
|
82
83
|
]
|
|
83
84
|
|
|
84
85
|
# The dev channel's registry and key (spec/abi-v2/docs.md, rule r6).
|
|
@@ -165,6 +166,25 @@ FETCH_V1_CHANNEL: Final = Channel(
|
|
|
165
166
|
pinnable=True,
|
|
166
167
|
)
|
|
167
168
|
|
|
169
|
+
# The production generation-2 channel a 2.0.0 SDK speaks after the lock: the v1
|
|
170
|
+
# contract with v2 values, from the generated constants (docs/guides/fetch-v1.md,
|
|
171
|
+
# "Generation 2 after the lock"). It trusts the release key, honors every
|
|
172
|
+
# override and pin, and has no alias step. NOT the default: only
|
|
173
|
+
# use_prod_v2_for_tests selects it, until the lock change makes it active()'s
|
|
174
|
+
# fallback.
|
|
175
|
+
PROD_V2_CHANNEL: Final = Channel(
|
|
176
|
+
name=C.PROD_V2_NAME,
|
|
177
|
+
abi=C.PROD_V2_ABI_GENERATION,
|
|
178
|
+
record_schema=C.PROD_V2_RECORD_SCHEMA,
|
|
179
|
+
root_leaf=C.PROD_V2_CACHE_DIR,
|
|
180
|
+
subroot=C.PROD_V2_CACHE_DIR,
|
|
181
|
+
system_dirs=C.PROD_V2_SYSTEM_DIRS,
|
|
182
|
+
bases=C.PROD_V2_BASES,
|
|
183
|
+
keys=C.RELEASE_KEYS,
|
|
184
|
+
overridable=True,
|
|
185
|
+
pinnable=True,
|
|
186
|
+
)
|
|
187
|
+
|
|
168
188
|
_lock = threading.Lock()
|
|
169
189
|
_current: Channel | None = None
|
|
170
190
|
|
|
@@ -224,6 +244,14 @@ def use_fetch_v1_for_tests() -> Callable[[], None]:
|
|
|
224
244
|
return _use(FETCH_V1_CHANNEL)
|
|
225
245
|
|
|
226
246
|
|
|
247
|
+
def use_prod_v2_for_tests() -> Callable[[], None]:
|
|
248
|
+
"""Make this TEST run speak the production generation-2 channel
|
|
249
|
+
(`PROD_V2_CHANNEL`): the v1 contract with abi 2, schema-2 records and the
|
|
250
|
+
`v2` subroot. Returns the restore function; raises outside a test run."""
|
|
251
|
+
_test_only("use_prod_v2_for_tests")
|
|
252
|
+
return _use(PROD_V2_CHANNEL)
|
|
253
|
+
|
|
254
|
+
|
|
227
255
|
def allow_overrides_for_tests() -> Callable[[], None]:
|
|
228
256
|
"""Make this TEST run's dev channel honor the base, trust and unsigned
|
|
229
257
|
overrides, so a test can reach a fixture registry signed with the test key;
|
|
@@ -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:
|
|
3
|
+
# Source sha256: 82afa8d6cba96c10b5945c6f7c1c8360a504cc8ac6231e74017cc6c20b908df6
|
|
4
4
|
"""The v1 fetch constants, generated: see docs/guides/fetch-v1.md."""
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
@@ -101,6 +101,12 @@ RETIRED_MESSAGE_MAX_CODE_POINTS: Final[int] = 256
|
|
|
101
101
|
|
|
102
102
|
CACHE_ROOT_TEMPLATE: Final[str] = "${XDG_CACHE_HOME:-$HOME/.cache}/chtypes/v1"
|
|
103
103
|
SYSTEM_CACHE_DIRS: Final[tuple[str, ...]] = ("/usr/local/share/chtypes/v1", "/opt/chtypes/v1")
|
|
104
|
+
PROD_V2_NAME: Final[str] = "v2"
|
|
105
|
+
PROD_V2_ABI_GENERATION: Final[int] = 2
|
|
106
|
+
PROD_V2_RECORD_SCHEMA: Final[int] = 2
|
|
107
|
+
PROD_V2_CACHE_DIR: Final[str] = "v2"
|
|
108
|
+
PROD_V2_BASES: Final[tuple[str, ...]] = ("https://registry.wavehouse.dev/chtypes/v2",)
|
|
109
|
+
PROD_V2_SYSTEM_DIRS: Final[tuple[str, ...]] = ("/usr/local/share/chtypes/v2", "/opt/chtypes/v2")
|
|
104
110
|
CACHE_UNPACKED_DIR: Final[str] = "unpacked/sha256"
|
|
105
111
|
CACHE_VERIFIED_RECORD: Final[str] = "verified.json"
|
|
106
112
|
CACHE_ANNOTATION_PREFIX: Final[str] = "dev.wavehouse.chtypes."
|
|
@@ -15,13 +15,19 @@ The sequence of `for_version(request)`:
|
|
|
15
15
|
Construction opens nothing. The binding orders and matches no versions itself:
|
|
16
16
|
which installed build a floating request means, and the spelling rules, are the
|
|
17
17
|
fetch layer's.
|
|
18
|
+
|
|
19
|
+
Opens of different requests never wait for each other's fetch (public issue
|
|
20
|
+
#491): the registry's lock guards its memo and the attempts in progress, and is
|
|
21
|
+
never held across steps 2 to 5. Concurrent opens of one request share one
|
|
22
|
+
attempt, and so one fetch, and each gets that attempt's `Library` or its error;
|
|
23
|
+
a failed attempt is never remembered, so the next open starts a new one.
|
|
18
24
|
"""
|
|
19
25
|
|
|
20
26
|
from __future__ import annotations
|
|
21
27
|
|
|
22
28
|
import os
|
|
23
29
|
import threading
|
|
24
|
-
from collections.abc import Sequence
|
|
30
|
+
from collections.abc import Callable, Sequence
|
|
25
31
|
from dataclasses import dataclass
|
|
26
32
|
from pathlib import Path
|
|
27
33
|
|
|
@@ -168,6 +174,19 @@ def _wrap(exc: FetchError) -> errors.ArtifactError:
|
|
|
168
174
|
return wrapped
|
|
169
175
|
|
|
170
176
|
|
|
177
|
+
class _Flight:
|
|
178
|
+
"""One attempt to open one request: the installed lookup, the fetch when
|
|
179
|
+
there is one, and the load. Every open of that request made while it is in
|
|
180
|
+
progress waits on it and gets its answer."""
|
|
181
|
+
|
|
182
|
+
__slots__ = ("done", "error", "library")
|
|
183
|
+
|
|
184
|
+
def __init__(self) -> None:
|
|
185
|
+
self.done = threading.Event()
|
|
186
|
+
self.library: Library | None = None
|
|
187
|
+
self.error: BaseException | None = None
|
|
188
|
+
|
|
189
|
+
|
|
171
190
|
class Registry:
|
|
172
191
|
"""Opens versions. Safe across threads. `autofetch` defaults to
|
|
173
192
|
`CHTYPES_AUTOFETCH`, and is off when that is unset.
|
|
@@ -175,6 +194,9 @@ class Registry:
|
|
|
175
194
|
Construction opens nothing: only `for_version` and `preload` open an
|
|
176
195
|
artifact. `preload` opens each listed request through the loader at
|
|
177
196
|
construction, in list order, and never fetches, even with autofetch on.
|
|
197
|
+
|
|
198
|
+
An open never waits for another request's fetch, and concurrent opens of
|
|
199
|
+
one request share one fetch (public issue #491).
|
|
178
200
|
"""
|
|
179
201
|
|
|
180
202
|
def __init__(
|
|
@@ -189,8 +211,14 @@ class Registry:
|
|
|
189
211
|
env = os.environ.get(_fetch_constants.ENV_AUTOFETCH_NAME, "")
|
|
190
212
|
autofetch = env.strip().lower() in _TRUTHY
|
|
191
213
|
self._autofetch = autofetch
|
|
192
|
-
|
|
214
|
+
# Guards the memo and the attempts in progress, never a lookup, a fetch
|
|
215
|
+
# or a load.
|
|
216
|
+
self._lock = threading.Lock()
|
|
193
217
|
self._memo: dict[str, Library] = {}
|
|
218
|
+
self._flights: dict[str, _Flight] = {}
|
|
219
|
+
# A test hook, None outside tests: called each time an open starts
|
|
220
|
+
# waiting on an attempt, its own or another open's.
|
|
221
|
+
self._on_wait: Callable[[str], None] | None = None
|
|
194
222
|
if isinstance(preload, str):
|
|
195
223
|
raise TypeError("preload must be a sequence of requests, not a single string")
|
|
196
224
|
for request in preload:
|
|
@@ -221,30 +249,63 @@ class Registry:
|
|
|
221
249
|
return tuple(seen)
|
|
222
250
|
|
|
223
251
|
def _open(self, request: str, *, allow_fetch: bool) -> Library:
|
|
252
|
+
with self._lock:
|
|
253
|
+
hit = self._memo.get(request)
|
|
254
|
+
if hit is not None:
|
|
255
|
+
return hit
|
|
256
|
+
# A refused version spelling is the caller's own misuse, refused before
|
|
257
|
+
# anything is attempted, and unlocks nothing.
|
|
258
|
+
try:
|
|
259
|
+
fetch_request = Request(request)
|
|
260
|
+
except ValueError as exc:
|
|
261
|
+
raise errors.misuse(str(exc)) from None
|
|
262
|
+
# So is a pinning request on the dev channel (rule r6).
|
|
263
|
+
_refuse_pinning(self._fetch._to_options())
|
|
224
264
|
with self._lock:
|
|
225
265
|
hit = self._memo.get(request)
|
|
226
266
|
if hit is not None:
|
|
227
267
|
return hit
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
268
|
+
flight = self._flights.get(request)
|
|
269
|
+
lead = flight is None
|
|
270
|
+
if flight is None:
|
|
271
|
+
flight = self._flights[request] = _Flight()
|
|
272
|
+
on_wait = self._on_wait
|
|
273
|
+
if on_wait is not None:
|
|
274
|
+
on_wait(request)
|
|
275
|
+
if lead:
|
|
276
|
+
self._attempt(flight, request, fetch_request, allow_fetch)
|
|
277
|
+
else:
|
|
278
|
+
flight.done.wait()
|
|
279
|
+
if flight.error is not None:
|
|
280
|
+
raise flight.error
|
|
281
|
+
assert flight.library is not None
|
|
282
|
+
return flight.library
|
|
283
|
+
|
|
284
|
+
def _attempt(
|
|
285
|
+
self, flight: _Flight, request: str, fetch_request: Request, allow_fetch: bool
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Run an attempt on the leading open's thread, with no lock held, then
|
|
288
|
+
release every open waiting on it. An attempt that tried a load and
|
|
289
|
+
failed unlocks the setup record while no image has completed load step
|
|
290
|
+
7, whatever failed: the resolve, the fetch, the signature or any load
|
|
291
|
+
step (bindings-v1.md section 6, rule 4). It is never remembered."""
|
|
292
|
+
began = _setup.generation()
|
|
293
|
+
library: Library | None = None
|
|
294
|
+
error: BaseException | None = None
|
|
295
|
+
try:
|
|
296
|
+
library = self._resolve_and_open(request, fetch_request, allow_fetch)
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
_setup.open_failed(began)
|
|
299
|
+
error = exc
|
|
300
|
+
except BaseException as exc:
|
|
301
|
+
error = exc
|
|
302
|
+
with self._lock:
|
|
303
|
+
del self._flights[request]
|
|
304
|
+
if error is None:
|
|
305
|
+
assert library is not None
|
|
306
|
+
self._memo[request] = library
|
|
307
|
+
flight.library, flight.error = library, error
|
|
308
|
+
flight.done.set()
|
|
248
309
|
|
|
249
310
|
def _resolve_and_open(self, request: str, fetch_request: Request, allow_fetch: bool) -> Library:
|
|
250
311
|
try:
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""An open never waits for another request's fetch, and concurrent opens of one
|
|
2
|
+
request share one fetch (public issue #491).
|
|
3
|
+
|
|
4
|
+
Each test serves the stub, signed with the fetch fixtures' TEST key, from the
|
|
5
|
+
fetch fixture server (scripts/fetch-v1/server.py) over HTTP, and holds a fetch
|
|
6
|
+
in flight with the server's test-only gate: the request is logged and parked
|
|
7
|
+
until the test opens the gate. Every wait is on an event (the server's parked
|
|
8
|
+
count, the registry's wait hook, a future), never on a clock; a bound only
|
|
9
|
+
turns a hang into a failure. The verdicts come from what the server logged and
|
|
10
|
+
what each open returned. Python has no cancellation of an open, so there is no
|
|
11
|
+
cancellation case here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import urllib.request
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
24
|
+
from concurrent.futures import TimeoutError as FutureTimeout
|
|
25
|
+
from contextlib import contextmanager
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import pytest
|
|
30
|
+
|
|
31
|
+
from chtypes import ArtifactError, FetchOptions, Registry
|
|
32
|
+
|
|
33
|
+
from ._stub_tree import build_stub_tree
|
|
34
|
+
|
|
35
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
36
|
+
SERVER = REPO_ROOT / "scripts" / "fetch-v1" / "server.py"
|
|
37
|
+
CASES = ("flight-install", "flight-held", "flight-one-fetch", "flight-fails")
|
|
38
|
+
BOUND = 30.0 # turns a hang into a failure; a working registry answers at once
|
|
39
|
+
N = 8
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Flight:
|
|
44
|
+
origin: str
|
|
45
|
+
cache: Path
|
|
46
|
+
trusted: object
|
|
47
|
+
layer_path: str
|
|
48
|
+
|
|
49
|
+
def registry(self, case_id: str) -> Registry:
|
|
50
|
+
"""Autofetch on, the one base `case_id`'s repository on the server."""
|
|
51
|
+
return Registry(
|
|
52
|
+
fetch=FetchOptions(
|
|
53
|
+
bases=(f"{self.origin}/s-{case_id}/chtypes/v1",),
|
|
54
|
+
cache_dir=self.cache,
|
|
55
|
+
system_dirs=(),
|
|
56
|
+
trusted_keys=(self.trusted,), # type: ignore[arg-type]
|
|
57
|
+
),
|
|
58
|
+
autofetch=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def control(self, path: str) -> object:
|
|
62
|
+
with urllib.request.urlopen(self.origin + path, timeout=BOUND + 5) as r:
|
|
63
|
+
return json.loads(r.read())
|
|
64
|
+
|
|
65
|
+
@contextmanager
|
|
66
|
+
def gate(self, case_id: str) -> Iterator[None]:
|
|
67
|
+
"""Hold `case_id`'s requests at the server; the gate opens on exit,
|
|
68
|
+
whatever happened, so no thread is left parked."""
|
|
69
|
+
self.control(f"/_gate/close/s-{case_id}")
|
|
70
|
+
try:
|
|
71
|
+
yield
|
|
72
|
+
finally:
|
|
73
|
+
self.open_gate(case_id)
|
|
74
|
+
|
|
75
|
+
def open_gate(self, case_id: str) -> None:
|
|
76
|
+
self.control(f"/_gate/open/s-{case_id}")
|
|
77
|
+
|
|
78
|
+
def wait_parked(self, case_id: str, n: int) -> int:
|
|
79
|
+
"""Once n of `case_id`'s requests are parked at its gate: how many are."""
|
|
80
|
+
doc = self.control(f"/_gate/parked/s-{case_id}?n={n}")
|
|
81
|
+
assert isinstance(doc, dict)
|
|
82
|
+
return doc["parked"]
|
|
83
|
+
|
|
84
|
+
def requests(self, case_id: str) -> Counter[str]:
|
|
85
|
+
"""`case_id`'s logged requests, "METHOD path" relative to its segment."""
|
|
86
|
+
log = self.control(f"/_log/s-{case_id}")
|
|
87
|
+
assert isinstance(log, list)
|
|
88
|
+
prefix = f"/v2/s-{case_id}"
|
|
89
|
+
return Counter(f"{e['method']} {e['path'].removeprefix(prefix)}" for e in log)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.fixture
|
|
93
|
+
def flight(stub_copy, tmp_path) -> Iterator[Flight]:
|
|
94
|
+
path, predicate = stub_copy()
|
|
95
|
+
fixtures = tmp_path / "fixtures"
|
|
96
|
+
tree = build_stub_tree(fixtures / "trees" / "stub", path, predicate, tag="26.8")
|
|
97
|
+
cases = [{"id": case_id, "tree": "stub"} for case_id in CASES]
|
|
98
|
+
(fixtures / "cases.json").write_text(json.dumps({"schema": 1, "cases": cases}))
|
|
99
|
+
repo = fixtures / "trees" / "stub" / "v2" / "chtypes" / "v1"
|
|
100
|
+
(platform,) = json.loads((repo / "manifests" / "26.8").read_text())["manifests"]
|
|
101
|
+
manifest = json.loads((repo / "manifests" / platform["digest"]).read_text())
|
|
102
|
+
(layer,) = manifest["layers"]
|
|
103
|
+
proc = subprocess.Popen(
|
|
104
|
+
[sys.executable, str(SERVER), "--fixtures", str(fixtures), "--port", "0"],
|
|
105
|
+
stdout=subprocess.PIPE,
|
|
106
|
+
text=True,
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
line = proc.stdout.readline() if proc.stdout else ""
|
|
110
|
+
parts = line.split()
|
|
111
|
+
assert len(parts) == 3 and parts[0] == "LISTENING", f"server.py printed {line!r}"
|
|
112
|
+
yield Flight(
|
|
113
|
+
origin=f"http://127.0.0.1:{parts[1]}",
|
|
114
|
+
cache=tmp_path / "cache",
|
|
115
|
+
trusted=tree.trusted,
|
|
116
|
+
layer_path=f"/chtypes/v1/blobs/{layer['digest']}",
|
|
117
|
+
)
|
|
118
|
+
finally:
|
|
119
|
+
proc.kill()
|
|
120
|
+
proc.wait()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_an_installed_line_never_waits_for_another_lines_fetch(flight: Flight) -> None:
|
|
124
|
+
"""The issue's regression: with 26.3's fetch held at the gate, an open of
|
|
125
|
+
26.8, which the cache answers, returns while 26.3's request is parked."""
|
|
126
|
+
flight.registry("flight-install").for_version("26.8") # install 26.8
|
|
127
|
+
registry = flight.registry("flight-held")
|
|
128
|
+
with ThreadPoolExecutor(2) as pool, flight.gate("flight-held"):
|
|
129
|
+
held = pool.submit(registry.for_version, "26.3")
|
|
130
|
+
flight.wait_parked("flight-held", 1)
|
|
131
|
+
installed = pool.submit(registry.for_version, "26.8")
|
|
132
|
+
try:
|
|
133
|
+
library = installed.result(timeout=BOUND)
|
|
134
|
+
except FutureTimeout:
|
|
135
|
+
pytest.fail(f"for_version('26.8'), installed, waited {BOUND} s for 26.3's fetch")
|
|
136
|
+
assert library.version == "26.8.15.10"
|
|
137
|
+
assert not held.done(), f"for_version('26.3') returned while its fetch was held: {held}"
|
|
138
|
+
assert flight.wait_parked("flight-held", 1) == 1
|
|
139
|
+
flight.open_gate("flight-held")
|
|
140
|
+
assert isinstance(held.exception(timeout=BOUND), ArtifactError)
|
|
141
|
+
made = [r for r in flight.requests("flight-held") if "/manifests/26.8" in r]
|
|
142
|
+
assert made == [], f"the open of installed 26.8 made requests: {made}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_concurrent_opens_of_one_request_share_one_fetch(flight: Flight) -> None:
|
|
146
|
+
"""N opens of one uninstalled request, all waiting while its fetch is
|
|
147
|
+
held, make one fetch and all get its Library."""
|
|
148
|
+
registry = flight.registry("flight-one-fetch")
|
|
149
|
+
waiting = threading.Semaphore(0)
|
|
150
|
+
registry._on_wait = lambda _request: waiting.release()
|
|
151
|
+
with ThreadPoolExecutor(N) as pool, flight.gate("flight-one-fetch"):
|
|
152
|
+
futures = [pool.submit(registry.for_version, "26.8") for _ in range(N)]
|
|
153
|
+
for i in range(N):
|
|
154
|
+
assert waiting.acquire(timeout=BOUND), f"open {i + 1} of {N} never waited"
|
|
155
|
+
parked = flight.wait_parked("flight-one-fetch", 1)
|
|
156
|
+
assert parked == 1, f"{parked} requests parked with {N} opens waiting"
|
|
157
|
+
flight.open_gate("flight-one-fetch")
|
|
158
|
+
libraries = [f.result(timeout=BOUND) for f in futures]
|
|
159
|
+
assert all(lib is libraries[0] for lib in libraries), "one request, one Library"
|
|
160
|
+
requests = flight.requests("flight-one-fetch")
|
|
161
|
+
assert requests["GET /chtypes/v1/manifests/26.8"] == 1, requests
|
|
162
|
+
assert requests["GET " + flight.layer_path] == 1, requests
|
|
163
|
+
assert set(requests.values()) == {1}, f"a path requested twice: {requests}"
|
|
164
|
+
assert registry.for_version("26.8") is libraries[0]
|
|
165
|
+
assert registry.libraries() == (libraries[0],)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_a_failed_fetch_reaches_every_waiter_and_is_not_remembered(flight: Flight) -> None:
|
|
169
|
+
"""N opens of a request nothing serves share one failing fetch and all
|
|
170
|
+
raise its error; the next open makes a new request."""
|
|
171
|
+
registry = flight.registry("flight-fails")
|
|
172
|
+
waiting = threading.Semaphore(0)
|
|
173
|
+
registry._on_wait = lambda _request: waiting.release()
|
|
174
|
+
with ThreadPoolExecutor(N) as pool, flight.gate("flight-fails"):
|
|
175
|
+
futures = [pool.submit(registry.for_version, "26.3") for _ in range(N)]
|
|
176
|
+
for i in range(N):
|
|
177
|
+
assert waiting.acquire(timeout=BOUND), f"open {i + 1} of {N} never waited"
|
|
178
|
+
flight.wait_parked("flight-fails", 1)
|
|
179
|
+
flight.open_gate("flight-fails")
|
|
180
|
+
raised = [f.exception(timeout=BOUND) for f in futures]
|
|
181
|
+
first = raised[0]
|
|
182
|
+
assert isinstance(first, ArtifactError), raised
|
|
183
|
+
assert all(exc is first for exc in raised), f"one attempt, one error: {raised}"
|
|
184
|
+
tag = "GET /chtypes/v1/manifests/26.3"
|
|
185
|
+
assert flight.requests("flight-fails")[tag] == 1
|
|
186
|
+
with pytest.raises(type(first)) as again:
|
|
187
|
+
registry.for_version("26.3")
|
|
188
|
+
assert str(again.value) == str(first)
|
|
189
|
+
assert flight.requests("flight-fails")[tag] == 2, "a failure is never remembered"
|
|
@@ -53,6 +53,37 @@ from chtypes._ocifetch._unpack import unpack_tar_zst
|
|
|
53
53
|
|
|
54
54
|
FIXTURES_ENV = "CHTYPES_V1_CONFORMANCE"
|
|
55
55
|
REPORT_ENV = "CHTYPES_V1_REPORT"
|
|
56
|
+
# The same cases regenerated for generation 2 (tests/fixtures/fetch-v2: ABI 2
|
|
57
|
+
# predicates, schema-2 records), run under the production generation-2 channel
|
|
58
|
+
# (docs/guides/fetch-v1.md, "Generation 2 after the lock").
|
|
59
|
+
V2_FIXTURES_ENV = "CHTYPES_V2_CONFORMANCE"
|
|
60
|
+
V2_REPORT_ENV = "CHTYPES_V2_REPORT"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _Corpus:
|
|
64
|
+
"""One conformance run: which corpus, which report, which channel."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, name: str, fixtures_env: str, report_env: str, channel: str, subroot: str):
|
|
67
|
+
self.name = name
|
|
68
|
+
self.fixtures_env = fixtures_env
|
|
69
|
+
self.report_env = report_env
|
|
70
|
+
self.channel = channel
|
|
71
|
+
self.subroot = subroot # where an explicit cache directory's layout lives
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
CORPORA = {
|
|
75
|
+
"v1": _Corpus("v1", FIXTURES_ENV, REPORT_ENV, "v1", ""),
|
|
76
|
+
"prod-v2": _Corpus(
|
|
77
|
+
"prod-v2", V2_FIXTURES_ENV, V2_REPORT_ENV, C.PROD_V2_NAME, C.PROD_V2_CACHE_DIR
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.fixture(scope="module", params=list(CORPORA), ids=list(CORPORA))
|
|
83
|
+
def corpus(request) -> _Corpus:
|
|
84
|
+
return CORPORA[request.param]
|
|
85
|
+
|
|
86
|
+
|
|
56
87
|
BINDING = "python"
|
|
57
88
|
|
|
58
89
|
|
|
@@ -70,22 +101,22 @@ def _toolchain() -> str:
|
|
|
70
101
|
)
|
|
71
102
|
|
|
72
103
|
|
|
73
|
-
def _fixtures_root() -> Path:
|
|
74
|
-
raw = os.environ.get(
|
|
104
|
+
def _fixtures_root(env: str) -> Path:
|
|
105
|
+
raw = os.environ.get(env)
|
|
75
106
|
if not raw:
|
|
76
107
|
pytest.skip(
|
|
77
|
-
f"{
|
|
108
|
+
f"{env} is not set. Set it to an absolute tests/fixtures/fetch-v1 (or fetch-v2) path "
|
|
78
109
|
f'to run this suite (docs/guides/fetch-v1.md "Conformance").'
|
|
79
110
|
)
|
|
80
111
|
root = Path(raw).resolve()
|
|
81
112
|
if not (root / "cases.json").is_file():
|
|
82
|
-
pytest.skip(f"{
|
|
113
|
+
pytest.skip(f"{env}={root} has no cases.json yet — nothing to run")
|
|
83
114
|
return root
|
|
84
115
|
|
|
85
116
|
|
|
86
117
|
@pytest.fixture(scope="module")
|
|
87
|
-
def fixtures_root() -> Path:
|
|
88
|
-
return _fixtures_root()
|
|
118
|
+
def fixtures_root(corpus: _Corpus) -> Path:
|
|
119
|
+
return _fixtures_root(corpus.fixtures_env)
|
|
89
120
|
|
|
90
121
|
|
|
91
122
|
@pytest.fixture(scope="module")
|
|
@@ -427,6 +458,7 @@ def _run_case(
|
|
|
427
458
|
http_port: int | None,
|
|
428
459
|
http_port2: int | None,
|
|
429
460
|
request_log: _RequestLog,
|
|
461
|
+
subroot: str = "",
|
|
430
462
|
) -> tuple[str, str]:
|
|
431
463
|
"""Drives one (case, transport) pair through `ensure()`. Returns
|
|
432
464
|
(verdict, detail) for the report (schema: "pass"/"fail")."""
|
|
@@ -449,18 +481,21 @@ def _run_case(
|
|
|
449
481
|
)
|
|
450
482
|
|
|
451
483
|
cache_dir = tmp_path / "cache"
|
|
484
|
+
# An explicit cache directory is used through the contract's subroot, so
|
|
485
|
+
# that is where a seeded layout lives (none for the v1 contract).
|
|
486
|
+
layout_dir = cache_dir / subroot if subroot else cache_dir
|
|
452
487
|
if setup.get("cache") and setup["cache"] != "empty":
|
|
453
488
|
seed = fixtures_root / "layouts" / setup["cache"]
|
|
454
489
|
if seed.is_dir():
|
|
455
|
-
shutil.copytree(seed,
|
|
456
|
-
installed_doc_path =
|
|
490
|
+
shutil.copytree(seed, layout_dir)
|
|
491
|
+
installed_doc_path = layout_dir / "installed.json"
|
|
457
492
|
if installed_doc_path.is_file():
|
|
458
493
|
installed = json.loads(installed_doc_path.read_text())
|
|
459
494
|
for digest in installed.get("installed", []):
|
|
460
495
|
_preinstall_from_layout(
|
|
461
|
-
layout_dir=
|
|
496
|
+
layout_dir=layout_dir,
|
|
462
497
|
manifest_digest=digest,
|
|
463
|
-
cache_root=
|
|
498
|
+
cache_root=layout_dir,
|
|
464
499
|
trusted_keys=trusted_keys,
|
|
465
500
|
)
|
|
466
501
|
|
|
@@ -487,11 +522,11 @@ def _run_case(
|
|
|
487
522
|
hook_factory = _BEFORE_INDEX_RENAME_HOOKS.get(hook_name)
|
|
488
523
|
if hook_factory is None:
|
|
489
524
|
return "fail", f"unknown before_index_rename_hook {hook_name!r} — not implemented"
|
|
490
|
-
before_index_rename = hook_factory(
|
|
525
|
+
before_index_rename = hook_factory(layout_dir)
|
|
491
526
|
|
|
492
527
|
# records_intact: each named install's directory, as the pre-install left
|
|
493
528
|
# it, compared after the call.
|
|
494
|
-
intact_before = {d: _snapshot_install(
|
|
529
|
+
intact_before = {d: _snapshot_install(layout_dir, d) for d in expect["records_intact"]}
|
|
495
530
|
for d, snap in intact_before.items():
|
|
496
531
|
if not snap:
|
|
497
532
|
return "fail", f"records_intact: {d} is not installed before the call"
|
|
@@ -619,7 +654,7 @@ def _run_case(
|
|
|
619
654
|
)
|
|
620
655
|
|
|
621
656
|
for d, snap in intact_before.items():
|
|
622
|
-
after = _snapshot_install(
|
|
657
|
+
after = _snapshot_install(layout_dir, d)
|
|
623
658
|
if after != snap:
|
|
624
659
|
mismatches.append(
|
|
625
660
|
f"records_intact: the install of {d} changed "
|
|
@@ -658,7 +693,8 @@ def _run_case(
|
|
|
658
693
|
return "pass", ""
|
|
659
694
|
|
|
660
695
|
|
|
661
|
-
def
|
|
696
|
+
def test_conformance(
|
|
697
|
+
corpus: _Corpus,
|
|
662
698
|
fixtures_root: Path,
|
|
663
699
|
cases_doc: dict,
|
|
664
700
|
cases_sha256: str,
|
|
@@ -666,16 +702,39 @@ def test_conformance_v1(
|
|
|
666
702
|
tmp_path_factory,
|
|
667
703
|
request_log: _RequestLog,
|
|
668
704
|
) -> None:
|
|
669
|
-
#
|
|
670
|
-
# are
|
|
671
|
-
# rules (r5, r6) are test_devchannel.py.
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
705
|
+
# The v1 corpus runs under the v1 fetch contract (conftest.py): its cases
|
|
706
|
+
# are that contract's specification. The ABI v2 dev channel narrows it, and
|
|
707
|
+
# its own rules (r5, r6) are test_devchannel.py. The prod-v2 corpus runs
|
|
708
|
+
# under the production generation-2 channel.
|
|
709
|
+
restore = _channel.use_prod_v2_for_tests() if corpus.name == "prod-v2" else None
|
|
710
|
+
try:
|
|
711
|
+
_run_corpus(
|
|
712
|
+
corpus,
|
|
713
|
+
fixtures_root,
|
|
714
|
+
cases_doc,
|
|
715
|
+
cases_sha256,
|
|
716
|
+
http_server,
|
|
717
|
+
tmp_path_factory,
|
|
718
|
+
request_log,
|
|
719
|
+
)
|
|
720
|
+
finally:
|
|
721
|
+
if restore is not None:
|
|
722
|
+
restore()
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _run_corpus(
|
|
726
|
+
corpus: _Corpus,
|
|
727
|
+
fixtures_root: Path,
|
|
728
|
+
cases_doc: dict,
|
|
729
|
+
cases_sha256: str,
|
|
730
|
+
http_server,
|
|
731
|
+
tmp_path_factory,
|
|
732
|
+
request_log: _RequestLog,
|
|
733
|
+
) -> None:
|
|
734
|
+
print(f"fetch contract: {_channel.channel_name()} (corpus {corpus.name})")
|
|
735
|
+
assert _channel.channel_name() == corpus.channel, (
|
|
736
|
+
f"the {corpus.name} conformance cases must run under the {corpus.channel!r} contract, "
|
|
737
|
+
f"not {_channel.channel_name()!r}"
|
|
679
738
|
)
|
|
680
739
|
results = []
|
|
681
740
|
for case in cases_doc["cases"]:
|
|
@@ -692,6 +751,7 @@ def test_conformance_v1(
|
|
|
692
751
|
http_port=http_port,
|
|
693
752
|
http_port2=http_port2,
|
|
694
753
|
request_log=request_log,
|
|
754
|
+
subroot=corpus.subroot,
|
|
695
755
|
)
|
|
696
756
|
except pytest.skip.Exception:
|
|
697
757
|
continue
|
|
@@ -699,7 +759,7 @@ def test_conformance_v1(
|
|
|
699
759
|
{"id": case["id"], "transport": transport, "verdict": verdict, "detail": detail}
|
|
700
760
|
)
|
|
701
761
|
|
|
702
|
-
report_path = os.environ.get(
|
|
762
|
+
report_path = os.environ.get(corpus.report_env)
|
|
703
763
|
if report_path:
|
|
704
764
|
report = {
|
|
705
765
|
"schema": 1,
|
|
@@ -711,9 +771,13 @@ def test_conformance_v1(
|
|
|
711
771
|
with open(report_path, "w", encoding="utf-8") as f:
|
|
712
772
|
json.dump(report, f, indent=2, sort_keys=True)
|
|
713
773
|
|
|
774
|
+
n_cases = len(cases_doc["cases"])
|
|
775
|
+
print(f"{corpus.name} conformance: {len(results)} result(s) over {n_cases} case(s)")
|
|
714
776
|
failures = [r for r in results if r["verdict"] == "fail"]
|
|
715
777
|
if failures:
|
|
716
778
|
detail = "\n".join(f" {r['id']} ({r['transport']}): {r['detail']}" for r in failures)
|
|
717
|
-
pytest.fail(
|
|
779
|
+
pytest.fail(
|
|
780
|
+
f"{len(failures)}/{len(results)} {corpus.name} conformance cases failed:\n{detail}"
|
|
781
|
+
)
|
|
718
782
|
if not results:
|
|
719
783
|
pytest.fail("0 cases ran — the fixtures tree is present but produced no results")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""A live smoke of the production generation-2 channel against a real registry,
|
|
2
|
+
opt-in: `CHTYPES_PRODV2_SMOKE_BASE` names the repository (the staging dev
|
|
3
|
+
repository, whose builds are abi 2 and signed with the staging key) and
|
|
4
|
+
`CHTYPES_PRODV2_SMOKE_KEY` the key it is signed with. It skips loudly when either
|
|
5
|
+
is unset, and the sandboxed CI runs never set them. It proves the channel honors
|
|
6
|
+
a base and a trust override, writes a schema-2 record signed by that key, and that
|
|
7
|
+
a lock then a frozen fetch pin one manifest.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from chtypes._ocifetch import _channel
|
|
20
|
+
from chtypes._ocifetch._dsse import TrustedKey
|
|
21
|
+
from chtypes._ocifetch._ensure import Options, Request, ensure
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_prod_v2_live_smoke(tmp_path: Path) -> None:
|
|
25
|
+
base = os.environ.get("CHTYPES_PRODV2_SMOKE_BASE")
|
|
26
|
+
key = os.environ.get("CHTYPES_PRODV2_SMOKE_KEY")
|
|
27
|
+
if not base or not key:
|
|
28
|
+
pytest.skip("CHTYPES_PRODV2_SMOKE_BASE and CHTYPES_PRODV2_SMOKE_KEY are not set")
|
|
29
|
+
spelling = os.environ.get("CHTYPES_PRODV2_SMOKE_SPELLING", "26.9")
|
|
30
|
+
restore = _channel.use_prod_v2_for_tests()
|
|
31
|
+
try:
|
|
32
|
+
lock = tmp_path / "chtypes.lock"
|
|
33
|
+
raw_key = bytes.fromhex(key)
|
|
34
|
+
keyid = hashlib.sha256(raw_key).hexdigest()[:16] # the constants' sha256-first16hex
|
|
35
|
+
trust = (TrustedKey(keyid=keyid, public_key=raw_key),)
|
|
36
|
+
|
|
37
|
+
def options(cache: str, **kw: bool) -> Options:
|
|
38
|
+
return Options(
|
|
39
|
+
bases=(base,),
|
|
40
|
+
trusted_keys=trust,
|
|
41
|
+
cache_dir=tmp_path / cache,
|
|
42
|
+
system_dirs=(),
|
|
43
|
+
lock_path=lock,
|
|
44
|
+
**kw,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
first = ensure(Request(spelling), options("cache1", lock_write=True))
|
|
48
|
+
record = json.loads((Path(first.dir) / "verified.json").read_text())
|
|
49
|
+
manifest = first.digests["manifest"]
|
|
50
|
+
print(f"fetched {spelling}: {first.version} build {first.build} manifest {manifest}")
|
|
51
|
+
print(
|
|
52
|
+
f"record: schema {record['schema']}, signed_by {record['signed_by']}, "
|
|
53
|
+
f"predicate abi {record['predicate']['abi']}, under {Path(first.dir).parents[2].name}"
|
|
54
|
+
)
|
|
55
|
+
assert record["schema"] == 2 and record["predicate"]["abi"] == 2
|
|
56
|
+
assert record["signed_by"]
|
|
57
|
+
assert Path(first.dir).parents[2].name == "v2"
|
|
58
|
+
|
|
59
|
+
again = ensure(Request(spelling), options("cache2", frozen=True))
|
|
60
|
+
print(f"frozen: manifest {again.digests['manifest']}")
|
|
61
|
+
assert again.digests["manifest"] == first.digests["manifest"]
|
|
62
|
+
finally:
|
|
63
|
+
restore()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The production generation-2 channel (`chtypes._ocifetch._channel.PROD_V2_CHANNEL`;
|
|
2
|
+
docs/guides/fetch-v1.md, "Generation 2 after the lock"), built and tested but not
|
|
3
|
+
the default. The fetch conformance cases run under it in `test_conformance.py`;
|
|
4
|
+
these are the pins those cases do not reach. Nothing here reaches the network.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from chtypes._ocifetch import _channel
|
|
15
|
+
from chtypes._ocifetch import _constants as C
|
|
16
|
+
from chtypes._ocifetch._ensure import Options, search_roots
|
|
17
|
+
from chtypes._ocifetch._layout import resolve_cache_root
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture(autouse=True)
|
|
21
|
+
def prod_v2(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
|
22
|
+
restore = _channel.use_prod_v2_for_tests()
|
|
23
|
+
for name in (
|
|
24
|
+
C.ENV_BASES_NAME,
|
|
25
|
+
C.ENV_TRUSTED_KEYS_NAME,
|
|
26
|
+
C.ENV_ALLOW_UNSIGNED_NAME,
|
|
27
|
+
C.ENV_CACHE_NAME,
|
|
28
|
+
):
|
|
29
|
+
monkeypatch.delenv(name, raising=False)
|
|
30
|
+
try:
|
|
31
|
+
yield
|
|
32
|
+
finally:
|
|
33
|
+
restore()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_the_production_v2_channel_carries_the_v2_values() -> None:
|
|
37
|
+
c = _channel.active()
|
|
38
|
+
assert (c.name, c.abi, c.record_schema, c.root_leaf, c.subroot) == ("v2", 2, 2, "v2", "v2")
|
|
39
|
+
assert c.overridable and c.pinnable and c.own_fingerprint == ""
|
|
40
|
+
assert c.system_dirs == ("/usr/local/share/chtypes/v2", "/opt/chtypes/v2")
|
|
41
|
+
options = Options()
|
|
42
|
+
assert options.resolved_bases() == ("https://registry.wavehouse.dev/chtypes/v2",)
|
|
43
|
+
trust = options.resolved_trusted_keys()
|
|
44
|
+
assert [k.keyid for k in trust] == ["deb275922dbff76e"]
|
|
45
|
+
assert not {k.public_key.hex() for k in trust} & {_channel.DEV_KEY_HEX}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_the_production_v2_channel_honors_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
49
|
+
monkeypatch.setenv(C.ENV_BASES_NAME, "https://mirror.example/chtypes/v2")
|
|
50
|
+
assert Options().resolved_bases() == ("https://mirror.example/chtypes/v2",)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_the_production_v2_channel_cache_roots(
|
|
54
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
55
|
+
) -> None:
|
|
56
|
+
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
|
|
57
|
+
assert resolve_cache_root() == tmp_path / "xdg" / "chtypes" / "v2"
|
|
58
|
+
assert resolve_cache_root(tmp_path / "opt") == tmp_path / "opt" / "v2"
|
|
59
|
+
roots = search_roots(tmp_path / "opt")
|
|
60
|
+
assert roots[1:] == (Path("/usr/local/share/chtypes/v2"), Path("/opt/chtypes/v2"))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_the_production_v2_channel_is_not_the_default() -> None:
|
|
64
|
+
# What an ordinary process speaks, with no seam selected.
|
|
65
|
+
restore = _channel.use_dev_channel_for_tests()
|
|
66
|
+
try:
|
|
67
|
+
assert _channel.channel_name() == "v2-dev"
|
|
68
|
+
finally:
|
|
69
|
+
restore()
|
|
70
|
+
assert _channel.channel_name() == "v2"
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|