techtree 0.1.0__py3-none-any.whl
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.
- techtree/__init__.py +35 -0
- techtree/__main__.py +14 -0
- techtree/canonical.py +239 -0
- techtree/catalog/__init__.py +25 -0
- techtree/catalog/repository.py +400 -0
- techtree/catalog/service.py +419 -0
- techtree/cli/__init__.py +1 -0
- techtree/cli/app.py +416 -0
- techtree/cli/commands/__init__.py +1 -0
- techtree/cli/commands/climb.py +1223 -0
- techtree/cli/commands/doctor.py +147 -0
- techtree/cli/commands/engine.py +207 -0
- techtree/cli/commands/proof.py +556 -0
- techtree/cli/commands/publish.py +447 -0
- techtree/cli/commands/release.py +303 -0
- techtree/cli/commands/run.py +1067 -0
- techtree/cli/commands/setup.py +181 -0
- techtree/cli/commands/skill.py +221 -0
- techtree/cli/commands/uplift.py +698 -0
- techtree/cli/commands/withdraw.py +212 -0
- techtree/cli/confirm.py +47 -0
- techtree/cli/context.py +96 -0
- techtree/cli/invoke.py +220 -0
- techtree/cli/output.py +280 -0
- techtree/constants.py +138 -0
- techtree/crypto.py +128 -0
- techtree/doctor/__init__.py +1 -0
- techtree/doctor/checks.py +675 -0
- techtree/doctor/execution_checks.py +435 -0
- techtree/doctor/service.py +326 -0
- techtree/drafts/__init__.py +32 -0
- techtree/drafts/source.py +146 -0
- techtree/drafts/store.py +992 -0
- techtree/engines/__init__.py +1 -0
- techtree/engines/bundle.py +251 -0
- techtree/engines/installer.py +679 -0
- techtree/engines/registry.py +235 -0
- techtree/engines/runner.py +170 -0
- techtree/errors.py +262 -0
- techtree/fs.py +234 -0
- techtree/harness.py +108 -0
- techtree/identity/__init__.py +41 -0
- techtree/identity/models.py +113 -0
- techtree/identity/service.py +199 -0
- techtree/identity/store.py +263 -0
- techtree/ids.py +85 -0
- techtree/manifests/__init__.py +39 -0
- techtree/manifests/builder.py +433 -0
- techtree/manifests/compare.py +376 -0
- techtree/models/__init__.py +282 -0
- techtree/models/base.py +201 -0
- techtree/models/campaign.py +484 -0
- techtree/models/catalog.py +227 -0
- techtree/models/cli.py +151 -0
- techtree/models/climb.py +254 -0
- techtree/models/data_policy.py +130 -0
- techtree/models/engine.py +156 -0
- techtree/models/episode_receipt.py +130 -0
- techtree/models/evaluation_backend.py +113 -0
- techtree/models/experiment.py +154 -0
- techtree/models/run.py +214 -0
- techtree/models/skill.py +156 -0
- techtree/models/uplift_report.py +158 -0
- techtree/models/validation.py +299 -0
- techtree/paths.py +116 -0
- techtree/presentation/__init__.py +31 -0
- techtree/presentation/build.py +1242 -0
- techtree/presentation/compact.py +246 -0
- techtree/presentation/evidence.py +169 -0
- techtree/presentation/models.py +358 -0
- techtree/presentation/rich.py +312 -0
- techtree/presentation/sanitize.py +156 -0
- techtree/publication/__init__.py +44 -0
- techtree/publication/address.py +180 -0
- techtree/publication/coordinates.py +26 -0
- techtree/publication/journal.py +212 -0
- techtree/publication/keccak.py +183 -0
- techtree/publication/models.py +209 -0
- techtree/publication/offer.py +35 -0
- techtree/publication/service.py +618 -0
- techtree/publication/transport.py +296 -0
- techtree/publication/verify.py +242 -0
- techtree/publication/withdraw.py +156 -0
- techtree/py.typed +0 -0
- techtree/receipts/__init__.py +52 -0
- techtree/receipts/bundle.py +578 -0
- techtree/receipts/compare.py +1065 -0
- techtree/receipts/episode.py +672 -0
- techtree/receipts/execution.py +630 -0
- techtree/receipts/observed.py +474 -0
- techtree/receipts/set.py +336 -0
- techtree/receipts/uplift.py +655 -0
- techtree/receipts/verify.py +1055 -0
- techtree/release/__init__.py +9 -0
- techtree/release/bootstrap.py +509 -0
- techtree/release/checks.py +376 -0
- techtree/release/document.py +125 -0
- techtree/release/generate.py +221 -0
- techtree/release/models.py +293 -0
- techtree/release/provenance.py +109 -0
- techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
- techtree/resources/catalog/catalog.json +32 -0
- techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
- techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
- techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
- techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
- techtree/resources/engines/default/engine.json +20 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
- techtree/resources/engines/default/pyproject.toml +23 -0
- techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
- techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
- techtree/resources/engines/default/tools/normalize_validation.py +222 -0
- techtree/resources/engines/default/uv.lock +1758 -0
- techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
- techtree/resources/release/build-provenance.json +4 -0
- techtree/resources/release/release-core.json +24 -0
- techtree/runs/__init__.py +31 -0
- techtree/runs/artifacts.py +750 -0
- techtree/runs/child_registry.py +228 -0
- techtree/runs/events.py +478 -0
- techtree/runs/executor.py +140 -0
- techtree/runs/fake.py +741 -0
- techtree/runs/launcher.py +253 -0
- techtree/runs/machine.py +489 -0
- techtree/runs/real.py +789 -0
- techtree/runs/service.py +616 -0
- techtree/runs/store.py +555 -0
- techtree/runs/validation.py +259 -0
- techtree/runs/variants.py +684 -0
- techtree/settings.py +143 -0
- techtree/skills/__init__.py +14 -0
- techtree/skills/archive.py +282 -0
- techtree/skills/policy.py +62 -0
- techtree/skills/scanner.py +394 -0
- techtree/skills/service.py +752 -0
- techtree/skills/starter.py +434 -0
- techtree/tasksets/__init__.py +1 -0
- techtree/tasksets/membership.py +269 -0
- techtree/tasksets/provider.py +207 -0
- techtree/tasksets/resolver.py +311 -0
- techtree/tasksets/service.py +484 -0
- techtree/tasksets/verifiers_cli.py +538 -0
- techtree/uplift/__init__.py +20 -0
- techtree/uplift/context.py +544 -0
- techtree/uplift/derive.py +203 -0
- techtree/uplift/public_tasks.py +151 -0
- techtree/uplift/service.py +719 -0
- techtree/uplift/source.py +160 -0
- techtree/verifiers/__init__.py +31 -0
- techtree/verifiers/budget.py +219 -0
- techtree/verifiers/child.py +633 -0
- techtree/verifiers/compiler.py +432 -0
- techtree/verifiers/config.py +365 -0
- techtree/verifiers/credentials.py +321 -0
- techtree/verifiers/image.py +126 -0
- techtree/verifiers/models.py +527 -0
- techtree/verifiers/outputs.py +368 -0
- techtree/verifiers/progress.py +192 -0
- techtree/verifiers/supervisor.py +341 -0
- techtree/verifiers/verify.py +782 -0
- techtree/version.py +39 -0
- techtree/worker/__init__.py +18 -0
- techtree/worker/execute.py +487 -0
- techtree/worker/main.py +57 -0
- techtree-0.1.0.dist-info/METADATA +344 -0
- techtree-0.1.0.dist-info/RECORD +174 -0
- techtree-0.1.0.dist-info/WHEEL +4 -0
- techtree-0.1.0.dist-info/entry_points.txt +3 -0
- techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Reading the embedded catalog. Spec section 14.2, decisions 0003 A7.
|
|
2
|
+
|
|
3
|
+
Everything in a Techtree catalog is addressed by what it contains. The index
|
|
4
|
+
maps a public reference, and every object digest that reference depends on, to
|
|
5
|
+
a file inside the package; nothing else in the system is allowed to name one of
|
|
6
|
+
those files directly. That indirection is what this module exists to enforce.
|
|
7
|
+
|
|
8
|
+
Three rules do the work.
|
|
9
|
+
|
|
10
|
+
*Recompute, never trust.* Every object is parsed into its protocol model and
|
|
11
|
+
re-digested from the model's canonical form. A file whose contents drifted from
|
|
12
|
+
the digest it is filed under is a :class:`~techtree.errors.VerificationError`,
|
|
13
|
+
not a warning, and not something a later stage discovers.
|
|
14
|
+
|
|
15
|
+
*Stay inside the root.* Index paths are relative, normalized, and non-escaping
|
|
16
|
+
— :class:`~techtree.models.catalog.CatalogIndex` rejects the spellings a
|
|
17
|
+
document can be checked for — and this module additionally refuses a resolved
|
|
18
|
+
path that leaves the catalog directory through a symlink, which no document
|
|
19
|
+
inspection could see.
|
|
20
|
+
|
|
21
|
+
*The kind is part of the address.* Asking for a Campaign at a digest the index
|
|
22
|
+
files as a DataPolicy fails on the index entry, before any bytes are parsed. A
|
|
23
|
+
caller therefore cannot be handed the wrong kind of object by a catalog that
|
|
24
|
+
merely points somewhere plausible.
|
|
25
|
+
|
|
26
|
+
Embedded objects are unsigned in WP0–WP5. A future remote catalog can wrap
|
|
27
|
+
these same objects in a signed envelope without changing anything here: the
|
|
28
|
+
graph is resolved from digests, and a signature would be checked before the
|
|
29
|
+
digests are believed, not instead of it.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
from importlib import resources
|
|
36
|
+
from importlib.resources.abc import Traversable
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Final, Self
|
|
39
|
+
|
|
40
|
+
from pydantic import BaseModel
|
|
41
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
42
|
+
|
|
43
|
+
from techtree.canonical import canonical_json_bytes, digest_object, sha256_digest_bytes
|
|
44
|
+
from techtree.errors import NotFoundError, ValidationError, VerificationError
|
|
45
|
+
from techtree.fs import realpath_within
|
|
46
|
+
from techtree.models.base import Digest, JsonValue
|
|
47
|
+
from techtree.models.campaign import CampaignSpec
|
|
48
|
+
from techtree.models.catalog import (
|
|
49
|
+
CatalogClimbEntry,
|
|
50
|
+
CatalogIndex,
|
|
51
|
+
CatalogObjectLocation,
|
|
52
|
+
)
|
|
53
|
+
from techtree.models.climb import ClimbManifest
|
|
54
|
+
from techtree.models.data_policy import DataPolicy
|
|
55
|
+
from techtree.models.validation import TasksetValidationReceipt, ValidationEvidence
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"CATALOG_INDEX_FILENAME",
|
|
59
|
+
"EmbeddedCatalogRepository",
|
|
60
|
+
"climb_reference",
|
|
61
|
+
"packaged_catalog_root",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
#: The one file in a catalog directory that is not content-addressed. It is the
|
|
65
|
+
#: entry point, so it is named rather than hashed.
|
|
66
|
+
CATALOG_INDEX_FILENAME: Final = "catalog.json"
|
|
67
|
+
|
|
68
|
+
#: What the index calls each kind of object, keyed by the model the caller asks
|
|
69
|
+
#: for. Keeping the mapping here rather than at each call site is what makes
|
|
70
|
+
#: "the kind is part of the address" a single rule instead of four.
|
|
71
|
+
_KIND_FOR_MODEL: Final[dict[type[BaseModel], str]] = {
|
|
72
|
+
CampaignSpec: "campaign",
|
|
73
|
+
DataPolicy: "data_policy",
|
|
74
|
+
TasksetValidationReceipt: "taskset_validation",
|
|
75
|
+
ValidationEvidence: "validation_evidence",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#: How a public reference is spelled: ``<slug>@<version>``.
|
|
79
|
+
_REFERENCE_SEPARATOR: Final = "@"
|
|
80
|
+
|
|
81
|
+
#: The identifier prefix a Climb's public ID carries. Used to decide whether a
|
|
82
|
+
#: reference that resolved to nothing is worth looking for by identity.
|
|
83
|
+
_CLIMB_ID_PREFIX: Final = "climb_"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def packaged_catalog_root() -> Traversable:
|
|
87
|
+
"""Return the catalog directory shipped inside the installed package."""
|
|
88
|
+
return resources.files("techtree") / "resources" / "catalog"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def climb_reference(climb: ClimbManifest) -> str:
|
|
92
|
+
"""Return the public reference a Climb manifest spells out."""
|
|
93
|
+
return f"{climb.metadata.slug}{_REFERENCE_SEPARATOR}{climb.metadata.version}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class EmbeddedCatalogRepository:
|
|
97
|
+
"""Loads and verifies the objects one catalog directory contains."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, resource_root: Traversable) -> None:
|
|
100
|
+
self._root = resource_root
|
|
101
|
+
self._index: CatalogIndex | None = None
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def packaged(cls) -> Self:
|
|
105
|
+
"""Return a repository over the catalog this build ships."""
|
|
106
|
+
return cls(packaged_catalog_root())
|
|
107
|
+
|
|
108
|
+
# -- The index ---------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def index(self) -> CatalogIndex:
|
|
111
|
+
"""Return the validated catalog index, reading it at most once.
|
|
112
|
+
|
|
113
|
+
Reading the index is also when the catalog is checked for the failures
|
|
114
|
+
a document cannot state about itself: a path that names no file, or a
|
|
115
|
+
path that leaves the catalog root. Both are defects in a generated
|
|
116
|
+
artifact, so they are reported once, at the door, rather than by
|
|
117
|
+
whichever command happened to need that object first.
|
|
118
|
+
"""
|
|
119
|
+
if self._index is not None:
|
|
120
|
+
return self._index
|
|
121
|
+
|
|
122
|
+
raw = self._read_bytes(
|
|
123
|
+
CATALOG_INDEX_FILENAME, self._resolve(CATALOG_INDEX_FILENAME)
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
index = CatalogIndex.model_validate_json(raw)
|
|
127
|
+
except PydanticValidationError as error:
|
|
128
|
+
raise ValidationError(
|
|
129
|
+
f"the catalog index is not a valid catalog: {_first_problem(error)}",
|
|
130
|
+
code="catalog_index_invalid",
|
|
131
|
+
details={"path": CATALOG_INDEX_FILENAME},
|
|
132
|
+
) from error
|
|
133
|
+
|
|
134
|
+
for entry in index.climbs:
|
|
135
|
+
self._resolve(entry.path)
|
|
136
|
+
for location in index.objects.values():
|
|
137
|
+
self._resolve(location.path)
|
|
138
|
+
|
|
139
|
+
self._index = index
|
|
140
|
+
return index
|
|
141
|
+
|
|
142
|
+
def catalog_metadata(self) -> dict[str, JsonValue]:
|
|
143
|
+
"""Return what the validated index says about itself."""
|
|
144
|
+
index = self.index()
|
|
145
|
+
return {
|
|
146
|
+
"schema_version": index.schema_version,
|
|
147
|
+
"climb_count": len(index.climbs),
|
|
148
|
+
"object_count": len(index.objects),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def list_climb_references(self) -> list[str]:
|
|
152
|
+
"""Return the public references this catalog ships, in index order."""
|
|
153
|
+
return [entry.reference for entry in self.index().climbs]
|
|
154
|
+
|
|
155
|
+
# -- Climbs ------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def climb_entry(self, reference: str) -> CatalogClimbEntry:
|
|
158
|
+
"""Resolve a slug, a ``slug@version``, or an exact public ID.
|
|
159
|
+
|
|
160
|
+
A bare slug resolves to the highest version the catalog ships, because
|
|
161
|
+
a reader who did not name a version is asking for the current one. A
|
|
162
|
+
public identifier costs a scan: identity lives inside the manifest, and
|
|
163
|
+
the index deliberately addresses objects by digest rather than by name.
|
|
164
|
+
"""
|
|
165
|
+
index = self.index()
|
|
166
|
+
|
|
167
|
+
for entry in index.climbs:
|
|
168
|
+
if entry.reference == reference:
|
|
169
|
+
return entry
|
|
170
|
+
|
|
171
|
+
by_slug = [
|
|
172
|
+
entry
|
|
173
|
+
for entry in index.climbs
|
|
174
|
+
if _reference_slug(entry.reference) == reference
|
|
175
|
+
]
|
|
176
|
+
if by_slug:
|
|
177
|
+
return max(by_slug, key=lambda entry: _reference_version(entry.reference))
|
|
178
|
+
|
|
179
|
+
if reference.startswith(_CLIMB_ID_PREFIX):
|
|
180
|
+
for entry in index.climbs:
|
|
181
|
+
if self._load_climb_entry(entry).metadata.id == reference:
|
|
182
|
+
return entry
|
|
183
|
+
|
|
184
|
+
raise NotFoundError(
|
|
185
|
+
f"this build ships no Climb called {reference!r}",
|
|
186
|
+
code="climb_not_found",
|
|
187
|
+
details={
|
|
188
|
+
"reference": reference,
|
|
189
|
+
"available": [entry.reference for entry in index.climbs],
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def load_climb(self, reference: str) -> ClimbManifest:
|
|
194
|
+
"""Load and verify one public Climb manifest."""
|
|
195
|
+
return self._load_climb_entry(self.climb_entry(reference))
|
|
196
|
+
|
|
197
|
+
# -- Content-addressed objects ----------------------------------------
|
|
198
|
+
|
|
199
|
+
def load_object(self, digest: Digest) -> JsonValue:
|
|
200
|
+
"""Load the JSON document filed under one digest, verifying the digest.
|
|
201
|
+
|
|
202
|
+
The verification is over the canonical form of what was parsed, which
|
|
203
|
+
is the same form the typed loaders digest. A document that is only
|
|
204
|
+
differently indented therefore verifies, and a document whose content
|
|
205
|
+
changed does not.
|
|
206
|
+
"""
|
|
207
|
+
location = self._object_location(digest)
|
|
208
|
+
raw = self._read_bytes(location.path, self._resolve(location.path))
|
|
209
|
+
document = _parse_json(raw, location.path)
|
|
210
|
+
|
|
211
|
+
recomputed = sha256_digest_bytes(canonical_json_bytes(document))
|
|
212
|
+
if recomputed != digest:
|
|
213
|
+
raise self._digest_mismatch(digest, recomputed, location.path)
|
|
214
|
+
return document
|
|
215
|
+
|
|
216
|
+
def load_campaign(self, digest: Digest) -> CampaignSpec:
|
|
217
|
+
"""Load and verify one CampaignSpec."""
|
|
218
|
+
return self._load_model(digest, CampaignSpec)
|
|
219
|
+
|
|
220
|
+
def load_data_policy(self, digest: Digest) -> DataPolicy:
|
|
221
|
+
"""Load and verify one DataPolicy."""
|
|
222
|
+
return self._load_model(digest, DataPolicy)
|
|
223
|
+
|
|
224
|
+
def load_validation_receipt(self, digest: Digest) -> TasksetValidationReceipt:
|
|
225
|
+
"""Load and verify one publisher validation receipt."""
|
|
226
|
+
return self._load_model(digest, TasksetValidationReceipt)
|
|
227
|
+
|
|
228
|
+
def load_validation_evidence(self, digest: Digest) -> ValidationEvidence:
|
|
229
|
+
"""Load and verify one normalized validation evidence document."""
|
|
230
|
+
return self._load_model(digest, ValidationEvidence)
|
|
231
|
+
|
|
232
|
+
# -- Internals ---------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _load_climb_entry(self, entry: CatalogClimbEntry) -> ClimbManifest:
|
|
235
|
+
"""Load the manifest one index entry points at and check it agrees."""
|
|
236
|
+
climb = self._parse_model(
|
|
237
|
+
self._read_bytes(entry.path, self._resolve(entry.path)),
|
|
238
|
+
ClimbManifest,
|
|
239
|
+
entry.path,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
recomputed = digest_object(climb)
|
|
243
|
+
if recomputed != entry.digest:
|
|
244
|
+
raise self._digest_mismatch(entry.digest, recomputed, entry.path)
|
|
245
|
+
|
|
246
|
+
stated = climb_reference(climb)
|
|
247
|
+
if stated != entry.reference:
|
|
248
|
+
raise ValidationError(
|
|
249
|
+
f"the catalog lists {entry.reference!r} but the manifest at that "
|
|
250
|
+
f"path calls itself {stated!r}",
|
|
251
|
+
code="catalog_reference_mismatch",
|
|
252
|
+
details={"indexed": entry.reference, "manifest": stated},
|
|
253
|
+
)
|
|
254
|
+
return climb
|
|
255
|
+
|
|
256
|
+
def _load_model[ModelT: BaseModel](
|
|
257
|
+
self, digest: Digest, model: type[ModelT]
|
|
258
|
+
) -> ModelT:
|
|
259
|
+
"""Load, type-check, and digest-verify one content-addressed object."""
|
|
260
|
+
location = self._object_location(digest)
|
|
261
|
+
expected_kind = _KIND_FOR_MODEL[model]
|
|
262
|
+
if location.kind != expected_kind:
|
|
263
|
+
raise ValidationError(
|
|
264
|
+
f"the catalog files {digest} as a {location.kind} object, and a "
|
|
265
|
+
f"{expected_kind} object was asked for",
|
|
266
|
+
code="catalog_kind_mismatch",
|
|
267
|
+
details={
|
|
268
|
+
"digest": digest,
|
|
269
|
+
"indexed_kind": location.kind,
|
|
270
|
+
"requested_kind": expected_kind,
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
raw = self._read_bytes(location.path, self._resolve(location.path))
|
|
275
|
+
loaded = self._parse_model(raw, model, location.path)
|
|
276
|
+
|
|
277
|
+
recomputed = digest_object(loaded)
|
|
278
|
+
if recomputed != digest:
|
|
279
|
+
raise self._digest_mismatch(digest, recomputed, location.path)
|
|
280
|
+
return loaded
|
|
281
|
+
|
|
282
|
+
def _object_location(self, digest: Digest) -> CatalogObjectLocation:
|
|
283
|
+
location = self.index().objects.get(digest)
|
|
284
|
+
if location is None:
|
|
285
|
+
raise NotFoundError(
|
|
286
|
+
f"the catalog has no object filed under {digest}",
|
|
287
|
+
code="catalog_object_not_found",
|
|
288
|
+
details={"digest": digest},
|
|
289
|
+
)
|
|
290
|
+
return location
|
|
291
|
+
|
|
292
|
+
def _resolve(self, relative: str) -> Traversable:
|
|
293
|
+
"""Return the file one index path names, refusing anything outside.
|
|
294
|
+
|
|
295
|
+
The path spelling was already checked when the index was validated.
|
|
296
|
+
What is checked here is the resolved location: a link inside the
|
|
297
|
+
catalog that points out of it is a valid-looking path to a file the
|
|
298
|
+
package does not ship.
|
|
299
|
+
"""
|
|
300
|
+
child = self._root
|
|
301
|
+
for segment in relative.split("/"):
|
|
302
|
+
if segment in ("", ".", ".."):
|
|
303
|
+
raise ValidationError(
|
|
304
|
+
f"catalog path {relative!r} is not a normalized relative path",
|
|
305
|
+
code="catalog_path_traversal",
|
|
306
|
+
details={"path": relative},
|
|
307
|
+
)
|
|
308
|
+
child = child / segment
|
|
309
|
+
|
|
310
|
+
if (
|
|
311
|
+
isinstance(child, Path)
|
|
312
|
+
and isinstance(self._root, Path)
|
|
313
|
+
and not realpath_within(child, self._root)
|
|
314
|
+
):
|
|
315
|
+
raise ValidationError(
|
|
316
|
+
f"catalog path {relative!r} resolves outside the catalog",
|
|
317
|
+
code="catalog_path_traversal",
|
|
318
|
+
details={"path": relative},
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
if not child.is_file():
|
|
322
|
+
raise NotFoundError(
|
|
323
|
+
f"the catalog lists {relative!r}, and this build does not ship it",
|
|
324
|
+
code="catalog_file_missing",
|
|
325
|
+
details={"path": relative},
|
|
326
|
+
)
|
|
327
|
+
return child
|
|
328
|
+
|
|
329
|
+
def _read_bytes(self, relative: str, source: Traversable) -> bytes:
|
|
330
|
+
try:
|
|
331
|
+
return source.read_bytes()
|
|
332
|
+
except FileNotFoundError as error:
|
|
333
|
+
raise NotFoundError(
|
|
334
|
+
f"the catalog lists {relative!r}, and this build does not ship it",
|
|
335
|
+
code="catalog_file_missing",
|
|
336
|
+
details={"path": relative},
|
|
337
|
+
) from error
|
|
338
|
+
except OSError as error:
|
|
339
|
+
raise ValidationError(
|
|
340
|
+
f"the catalog file {relative!r} could not be read: "
|
|
341
|
+
f"{error.strerror or error}",
|
|
342
|
+
code="catalog_file_unreadable",
|
|
343
|
+
details={"path": relative},
|
|
344
|
+
) from error
|
|
345
|
+
|
|
346
|
+
def _parse_model[ModelT: BaseModel](
|
|
347
|
+
self, raw: bytes, model: type[ModelT], relative: str
|
|
348
|
+
) -> ModelT:
|
|
349
|
+
try:
|
|
350
|
+
return model.model_validate_json(raw)
|
|
351
|
+
except PydanticValidationError as error:
|
|
352
|
+
raise ValidationError(
|
|
353
|
+
f"the catalog file {relative!r} is not a valid "
|
|
354
|
+
f"{model.__name__}: {_first_problem(error)}",
|
|
355
|
+
code="catalog_object_invalid",
|
|
356
|
+
details={"path": relative, "expected": model.__name__},
|
|
357
|
+
) from error
|
|
358
|
+
|
|
359
|
+
def _digest_mismatch(
|
|
360
|
+
self, expected: Digest, recomputed: Digest, relative: str
|
|
361
|
+
) -> VerificationError:
|
|
362
|
+
return VerificationError(
|
|
363
|
+
f"the catalog file {relative!r} does not match the digest it is "
|
|
364
|
+
"filed under",
|
|
365
|
+
code="catalog_digest_mismatch",
|
|
366
|
+
details={
|
|
367
|
+
"path": relative,
|
|
368
|
+
"expected_digest": expected,
|
|
369
|
+
"computed_digest": recomputed,
|
|
370
|
+
},
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _parse_json(raw: bytes, relative: str) -> JsonValue:
|
|
375
|
+
"""Decode one catalog file as JSON, or say which file was not JSON."""
|
|
376
|
+
try:
|
|
377
|
+
document: JsonValue = json.loads(raw.decode("utf-8"))
|
|
378
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
379
|
+
raise ValidationError(
|
|
380
|
+
f"the catalog file {relative!r} is not valid JSON",
|
|
381
|
+
code="catalog_object_invalid",
|
|
382
|
+
details={"path": relative},
|
|
383
|
+
) from error
|
|
384
|
+
return document
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _reference_slug(reference: str) -> str:
|
|
388
|
+
return reference.partition(_REFERENCE_SEPARATOR)[0]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _reference_version(reference: str) -> int:
|
|
392
|
+
_, _, version = reference.partition(_REFERENCE_SEPARATOR)
|
|
393
|
+
return int(version) if version.isdigit() else 0
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _first_problem(error: PydanticValidationError) -> str:
|
|
397
|
+
"""Return the first validation problem in words a reader can act on."""
|
|
398
|
+
first = error.errors()[0]
|
|
399
|
+
location = ".".join(str(part) for part in first["loc"])
|
|
400
|
+
return f"{location}: {first['msg']}" if location else str(first["msg"])
|