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.
Files changed (93) hide show
  1. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/CHANGELOG.md +12 -0
  2. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/PKG-INFO +1 -1
  3. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/pyproject.toml +1 -1
  4. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_channel.py +28 -0
  5. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_constants.py +7 -1
  6. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/registry.py +83 -22
  7. chtypes-2.0.0.dev6/tests/abi2/test_registry_flight.py +189 -0
  8. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_conformance.py +90 -26
  9. chtypes-2.0.0.dev6/tests/ocifetch/test_prodv2_smoke.py +63 -0
  10. chtypes-2.0.0.dev6/tests/ocifetch/test_prodv2channel.py +70 -0
  11. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/uv.lock +1 -1
  12. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/.gitignore +0 -0
  13. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/LICENSE +0 -0
  14. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/README.md +0 -0
  15. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/__init__.py +0 -0
  16. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/__main__.py +0 -0
  17. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_abi2/__init__.py +0 -0
  18. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_abi2/_decls.py +0 -0
  19. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_abi2/_errmap.py +0 -0
  20. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_abi2/_errors.py +0 -0
  21. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_abi2/_loader.py +0 -0
  22. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_abi2/_vocab.py +0 -0
  23. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_decode.py +0 -0
  24. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ed25519.py +0 -0
  25. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_guard.py +0 -0
  26. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_input.py +0 -0
  27. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/__init__.py +0 -0
  28. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_dsse.py +0 -0
  29. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_ensure.py +0 -0
  30. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_errors.py +0 -0
  31. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_faults.py +0 -0
  32. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_goldens.py +0 -0
  33. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_http.py +0 -0
  34. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_layout.py +0 -0
  35. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_lock.py +0 -0
  36. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_oci.py +0 -0
  37. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_referrers.py +0 -0
  38. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_retired.py +0 -0
  39. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_ocifetch/_unpack.py +0 -0
  40. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/_setup.py +0 -0
  41. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/errors.py +0 -0
  42. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/library.py +0 -0
  43. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/py.typed +0 -0
  44. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/src/chtypes/results.py +0 -0
  45. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/__init__.py +0 -0
  46. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/_stub_tree.py +0 -0
  47. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/conftest.py +0 -0
  48. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_conformance.py +0 -0
  49. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_dev_fingerprint.py +0 -0
  50. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_goldens_runner_stub.py +0 -0
  51. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_loader.py +0 -0
  52. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_public_library.py +0 -0
  53. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_public_open.py +0 -0
  54. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_public_registry.py +0 -0
  55. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_public_server.py +0 -0
  56. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_public_setup_cases.py +0 -0
  57. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/abi2/test_reader_rules.py +0 -0
  58. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/api/__init__.py +0 -0
  59. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/api/test_decode.py +0 -0
  60. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/api/test_input.py +0 -0
  61. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/api/test_vocab.py +0 -0
  62. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/cli/__init__.py +0 -0
  63. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/cli/conftest.py +0 -0
  64. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/cli/test_devchannel.py +0 -0
  65. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/cli/test_main.py +0 -0
  66. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/conftest.py +0 -0
  67. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/goldens_v1/__init__.py +0 -0
  68. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/goldens_v1/data/stub-goldens.json +0 -0
  69. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/goldens_v1/test_runner.py +0 -0
  70. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/__init__.py +0 -0
  71. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/_bundle_support.py +0 -0
  72. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/_registry_support.py +0 -0
  73. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/_sign_support.py +0 -0
  74. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/conftest.py +0 -0
  75. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_cache_roots.py +0 -0
  76. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_cache_strict.py +0 -0
  77. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_devchannel.py +0 -0
  78. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_dsse.py +0 -0
  79. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_ensure.py +0 -0
  80. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_goldens.py +0 -0
  81. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_http.py +0 -0
  82. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_layout.py +0 -0
  83. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_lock.py +0 -0
  84. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_oci.py +0 -0
  85. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_retired.py +0 -0
  86. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_seam_gate.py +0 -0
  87. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/ocifetch/test_unpack.py +0 -0
  88. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/test_abi_identity.py +0 -0
  89. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/test_cache_roots_api.py +0 -0
  90. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/test_devchannel_registry.py +0 -0
  91. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/test_ed25519.py +0 -0
  92. {chtypes-2.0.0.dev5 → chtypes-2.0.0.dev6}/tests/test_examples_tour.py +0 -0
  93. {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.dev5
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chtypes"
3
- version = "2.0.0.dev5"
3
+ version = "2.0.0.dev6"
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"
@@ -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: 4554956faeb154fc324a6120b7b35976222c6bc8ecbce7fd0147502e3073611d
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
- self._lock = threading.RLock()
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
- # A refused version spelling is the caller's own misuse, refused
229
- # before anything is attempted, and unlocks nothing. An open that
230
- # attempted a load and failed unlocks the setup record while no
231
- # image has completed load step 7, whatever failed: the resolve,
232
- # the fetch, the signature or any load step (bindings-v1.md
233
- # section 6, rule 4).
234
- try:
235
- fetch_request = Request(request)
236
- except ValueError as exc:
237
- raise errors.misuse(str(exc)) from None
238
- # So is a pinning request on the dev channel (rule r6).
239
- _refuse_pinning(self._fetch._to_options())
240
- began = _setup.generation()
241
- try:
242
- library = self._resolve_and_open(request, fetch_request, allow_fetch)
243
- except Exception:
244
- _setup.open_failed(began)
245
- raise
246
- self._memo[request] = library
247
- return library
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(FIXTURES_ENV)
104
+ def _fixtures_root(env: str) -> Path:
105
+ raw = os.environ.get(env)
75
106
  if not raw:
76
107
  pytest.skip(
77
- f"{FIXTURES_ENV} is not set. Set it to an absolute tests/fixtures/fetch-v1 path "
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"{FIXTURES_ENV}={root} has no cases.json yet — nothing to run")
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, cache_dir)
456
- installed_doc_path = cache_dir / "installed.json"
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=cache_dir,
496
+ layout_dir=layout_dir,
462
497
  manifest_digest=digest,
463
- cache_root=cache_dir,
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(cache_dir)
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(cache_dir, d) for d in expect["records_intact"]}
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(cache_dir, d)
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 test_conformance_v1(
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
- # This directory runs under the v1 fetch contract (conftest.py): the cases
670
- # are its specification. The ABI v2 dev channel narrows it, and its own
671
- # rules (r5, r6) are test_devchannel.py.
672
- print(
673
- f"fetch contract: {_channel.channel_name()} (the fetch-v1 cases' own; the dev channel "
674
- "this binding ships is tested by tests/ocifetch/test_devchannel.py)"
675
- )
676
- assert _channel.channel_name() == "v1", (
677
- f"the fetch-v1 conformance cases must run under the v1 contract, not "
678
- f"{_channel.channel_name()!r}"
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(REPORT_ENV)
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(f"{len(failures)}/{len(results)} v1 conformance cases failed:\n{detail}")
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"
@@ -74,7 +74,7 @@ wheels = [
74
74
 
75
75
  [[package]]
76
76
  name = "chtypes"
77
- version = "2.0.0.dev5"
77
+ version = "2.0.0.dev6"
78
78
  source = { editable = "." }
79
79
  dependencies = [
80
80
  { name = "backports-zstd", marker = "python_full_version < '3.14'" },
File without changes
File without changes
File without changes