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.
Files changed (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,434 @@
1
+ """Obtaining the release's starter Skill. Spec section 10.5, decisions 0008.
2
+
3
+ The guided first run needs a Skill before it can prepare anything, and the
4
+ release names exactly which one: ``starter_skill_digest``. This module is how
5
+ those bytes reach a machine — read from a file a person names, or fetched from
6
+ a source a caller names — and how they are proved to be the right ones before
7
+ anything is done with them.
8
+
9
+ Four rules decide everything here.
10
+
11
+ *The release names the Skill; the caller only names where to look.* A source
12
+ is a hint about where bytes might be found. What makes them the starter Skill
13
+ is that they hash to the digest this build's ReleaseCore pins, and that check
14
+ is made against the release document, never against anything a caller passed
15
+ in. So a wrong URL, a stale download, or a helpful mirror serving something
16
+ else all end the same way: refused.
17
+
18
+ *A release that published its Skill needs nobody to name a source.* Spec
19
+ sections 4.1 and 10.5 make the starter Skill a public object at an exact URL,
20
+ and the release carries that URL as ``starter_skill_object_url``. So a caller
21
+ who names nothing gets the published object, and the identity check over what
22
+ arrives is the same one a hand-typed URL goes through — the release's address
23
+ buys the bytes no trust at all.
24
+
25
+ *An address that names its own bytes is checked against them first.* The
26
+ published object URL is a content address: it ends in the digest of the file it
27
+ serves. What arrives is hashed and compared to that before it is treated as a
28
+ Skill at all, which is the cheapest possible refusal of a wrong or tampered
29
+ response, and it happens before any scanning.
30
+
31
+ *The digest is the Skill's root digest, not a file hash.* Decisions document
32
+ 0008 fixes this: the starter Skill is evaluated through the kernel, so it is
33
+ pinned by its ``SkillArtifact.root_digest`` — the ordered content-tree digest
34
+ the scanner computes. Materializing therefore *scans*, exactly as preparing a
35
+ participant's own Skill does, and compares the tree digest. A Skill that the
36
+ ordinary scanner or the ordinary policy refuses is refused here too. There is
37
+ no privileged path for a Skill because a release named it.
38
+
39
+ *The cache is Techtree's own.* Materialized Skills live under the Techtree
40
+ home, in a directory named by the digest they were verified against — never in
41
+ package resources, which are the build's and are read-only. A cache entry is
42
+ re-scanned and re-verified before it is reused, so a cached directory that
43
+ somebody edited is not a shortcut past the check.
44
+
45
+ What this module does not do is prepare a draft. It returns a path, and that
46
+ path goes through ``climb prepare`` like any other: scanner, policy, draft,
47
+ confirmation. Decisions 0008 and spec section 10.5 both put the starter Skill
48
+ inside the ordinary path rather than beside it.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import urllib.error
54
+ import urllib.request
55
+ from collections.abc import Callable, Iterator
56
+ from contextlib import contextmanager
57
+ from dataclasses import dataclass
58
+ from ipaddress import ip_address
59
+ from pathlib import Path
60
+ from typing import Final, Literal
61
+ from urllib.parse import urlsplit
62
+
63
+ from filelock import FileLock, Timeout
64
+
65
+ from techtree.canonical import sha256_digest_bytes
66
+ from techtree.constants import MAX_SKILL_TOTAL_BYTES
67
+ from techtree.errors import (
68
+ NotFoundError,
69
+ PrerequisiteError,
70
+ UsageError,
71
+ ValidationError,
72
+ VerificationError,
73
+ )
74
+ from techtree.fs import ensure_private_directory, remove_tree
75
+ from techtree.manifests.builder import skill_content_digest
76
+ from techtree.models.base import Digest
77
+ from techtree.models.skill import SKILL_ENTRY_FILE, SkillFile
78
+ from techtree.paths import TechtreePaths
79
+ from techtree.release.models import ReleaseCore, object_url_digest
80
+ from techtree.skills.policy import default_instruction_skill_policy
81
+ from techtree.skills.scanner import SkillScanResult, resolve_skill_root, scan_skill
82
+
83
+ __all__ = [
84
+ "STARTER_SKILL_DIGEST_MISMATCH",
85
+ "STARTER_SKILL_SOURCE_REFUSED",
86
+ "STARTER_SKILL_UNAVAILABLE",
87
+ "Downloader",
88
+ "MaterializedStarterSkill",
89
+ "StarterSkillService",
90
+ ]
91
+
92
+ #: Nothing here holds the pinned Skill and nowhere was named to get it from.
93
+ STARTER_SKILL_UNAVAILABLE: Final = "starter_skill_unavailable"
94
+
95
+ #: A source that will not be read at all — an unusable URL, an unreachable
96
+ #: one, or a file that is not there.
97
+ STARTER_SKILL_SOURCE_REFUSED: Final = "starter_skill_source_refused"
98
+
99
+ #: Bytes arrived and are not the Skill this release pins.
100
+ STARTER_SKILL_DIGEST_MISMATCH: Final = "starter_skill_digest_mismatch"
101
+
102
+ #: One materialization at a time per Techtree home, for the same reason an
103
+ #: engine install takes a lock: two of them would be writing one directory.
104
+ _CACHE_LOCK_FILENAME: Final = ".skills.lock"
105
+ _CACHE_LOCK_TIMEOUT_SECONDS: Final = 120.0
106
+
107
+ #: Where a download is staged before it has been proved. Named so that a
108
+ #: leftover from an interrupted materialization is recognizable.
109
+ _STAGING_PREFIX: Final = ".materializing-"
110
+
111
+ #: How long a fetch may take before it is a hang rather than a slow network.
112
+ DOWNLOAD_TIMEOUT_SECONDS: Final = 60.0
113
+
114
+ type Downloader = Callable[[str], bytes]
115
+ """Returns the bytes one URL serves, or raises a typed refusal."""
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class MaterializedStarterSkill:
120
+ """Where the pinned starter Skill is, and what proved it is that Skill."""
121
+
122
+ root: Path
123
+ entrypoint: Path
124
+ root_digest: Digest
125
+ file_count: int
126
+ total_bytes: int
127
+ origin: Literal["cache", "local_file", "download", "release"]
128
+
129
+
130
+ class StarterSkillService:
131
+ """Materializes the release's starter Skill into the Techtree home."""
132
+
133
+ def __init__(
134
+ self,
135
+ paths: TechtreePaths,
136
+ *,
137
+ download: Downloader | None = None,
138
+ ) -> None:
139
+ self._paths = paths
140
+ self._download = download or _download_document
141
+
142
+ def materialize(
143
+ self,
144
+ *,
145
+ release: ReleaseCore,
146
+ local_file: Path | None = None,
147
+ url: str | None = None,
148
+ ) -> MaterializedStarterSkill:
149
+ """Return the pinned starter Skill, obtaining it if this home lacks it."""
150
+ pinned = release.starter_skill_digest
151
+ if local_file is not None and url is not None:
152
+ raise UsageError(
153
+ "name one source for the starter Skill, not two",
154
+ code=STARTER_SKILL_SOURCE_REFUSED,
155
+ )
156
+
157
+ ensure_private_directory(self._paths.cache_dir)
158
+ ensure_private_directory(self._paths.skills_cache_dir())
159
+ destination = self._paths.skill_cache_dir(pinned)
160
+
161
+ with self._cache_lock():
162
+ origin: Literal["local_file", "download", "release"]
163
+ if local_file is not None:
164
+ origin, source = "local_file", str(local_file)
165
+ elif url is not None:
166
+ origin, source = "download", url
167
+ else:
168
+ cached = _verified_cache_entry(destination, pinned)
169
+ if cached is not None:
170
+ return _describe(cached, origin="cache")
171
+ origin, source = "release", release.starter_skill_object_url
172
+
173
+ staging = destination.parent / f"{_STAGING_PREFIX}{destination.name}"
174
+ remove_tree(staging)
175
+ try:
176
+ if local_file is not None:
177
+ _stage_local(local_file, staging)
178
+ else:
179
+ _require_fetchable(source)
180
+ _stage_document(_fetched(self._download, source), staging)
181
+ _verify_scan(staging, pinned, source=source)
182
+ remove_tree(destination)
183
+ staging.replace(destination)
184
+ except BaseException:
185
+ remove_tree(staging)
186
+ raise
187
+
188
+ # Re-scanned where it now lives, so what is described is what a
189
+ # caller will hand to ``climb prepare`` and not what was staged.
190
+ published = _verified_cache_entry(destination, pinned)
191
+
192
+ if published is None: # pragma: no cover - defended, never expected
193
+ raise VerificationError(
194
+ "the starter Skill did not survive being written to the cache",
195
+ code=STARTER_SKILL_DIGEST_MISMATCH,
196
+ details={"expected": pinned, "cache": str(destination)},
197
+ )
198
+ return _describe(published, origin=origin)
199
+
200
+ @contextmanager
201
+ def _cache_lock(self) -> Iterator[None]:
202
+ """Serialize materialization within one Techtree home."""
203
+ lock = FileLock(
204
+ str(self._paths.skills_cache_dir() / _CACHE_LOCK_FILENAME),
205
+ timeout=_CACHE_LOCK_TIMEOUT_SECONDS,
206
+ )
207
+ try:
208
+ lock.acquire()
209
+ except Timeout as error:
210
+ raise PrerequisiteError(
211
+ "another Techtree process is materializing a Skill",
212
+ code=STARTER_SKILL_UNAVAILABLE,
213
+ details={"path": str(self._paths.skills_cache_dir())},
214
+ retryable=True,
215
+ ) from error
216
+ try:
217
+ yield
218
+ finally:
219
+ lock.release()
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # The steps
224
+ # ---------------------------------------------------------------------------
225
+
226
+
227
+ def _fetched(download: Downloader, url: str) -> bytes:
228
+ """Return the bytes one address serves, checked against that address.
229
+
230
+ A content address promises which bytes it returns, and the release's
231
+ starter Skill address is one. Checking the response against the address it
232
+ came from costs one hash and refuses a wrong or tampered object before any
233
+ of it is treated as a Skill. An address that promises nothing — a mirror a
234
+ person typed — is checked by the tree digest alone, further down.
235
+ """
236
+ data = download(url)
237
+ promised = _promised_digest(url)
238
+ if promised is None:
239
+ return data
240
+
241
+ served = sha256_digest_bytes(data)
242
+ if served != promised:
243
+ raise VerificationError(
244
+ f"{url} promises the bytes it serves are {promised}, and it served "
245
+ f"{served}",
246
+ code=STARTER_SKILL_DIGEST_MISMATCH,
247
+ details={"url": url, "expected": promised, "computed": served},
248
+ )
249
+ return data
250
+
251
+
252
+ def _promised_digest(url: str) -> Digest | None:
253
+ """Return the digest an address is keyed by, when it is keyed by one."""
254
+ try:
255
+ return object_url_digest(url)
256
+ except ValueError:
257
+ return None
258
+
259
+
260
+ def _stage_local(source: Path, staging: Path) -> None:
261
+ """Copy a Skill a person named on this machine into the staging tree."""
262
+ try:
263
+ root = resolve_skill_root(source)
264
+ except (NotFoundError, ValidationError) as error:
265
+ raise NotFoundError(
266
+ f"the starter Skill could not be read from {source}: {error.message}",
267
+ code=STARTER_SKILL_SOURCE_REFUSED,
268
+ details={"path": str(source)},
269
+ ) from error
270
+
271
+ # Scanned before it is copied, so nothing the policy refuses is ever
272
+ # written into the Techtree home.
273
+ scan = _scan(root, source=str(source))
274
+ ensure_private_directory(staging)
275
+ for item in scan.files:
276
+ target = staging / item.relative_path
277
+ ensure_private_directory(target.parent)
278
+ target.write_bytes(item.source_path.read_bytes())
279
+ target.chmod(0o600)
280
+
281
+
282
+ def _stage_document(data: bytes, staging: Path) -> None:
283
+ """Write fetched bytes as the Skill's one entry file.
284
+
285
+ A fetched starter Skill is a single ``SKILL.md`` document: that is what
286
+ the v0.1 starter Skill is, and no release coordinate names an archive
287
+ format a tree could arrive in. A multi-file starter Skill would need one,
288
+ chosen in a decision rather than guessed at here.
289
+ """
290
+ ensure_private_directory(staging)
291
+ entrypoint = staging / SKILL_ENTRY_FILE
292
+ entrypoint.write_bytes(data)
293
+ entrypoint.chmod(0o600)
294
+
295
+
296
+ def _verify_scan(root: Path, expected: Digest, *, source: str) -> None:
297
+ """Scan a staged Skill and refuse it unless it is the one that was pinned."""
298
+ computed = _root_digest(_scan(root, source=source))
299
+ if computed != expected:
300
+ raise VerificationError(
301
+ "what was obtained is not the starter Skill this release pins",
302
+ code=STARTER_SKILL_DIGEST_MISMATCH,
303
+ details={"expected": expected, "computed": computed, "source": source},
304
+ )
305
+
306
+
307
+ def _verified_cache_entry(root: Path, expected: Digest) -> SkillScanResult | None:
308
+ """Return a cached Skill that still is what its directory name claims."""
309
+ if not (root / SKILL_ENTRY_FILE).is_file():
310
+ return None
311
+ try:
312
+ scan = scan_skill(root, default_instruction_skill_policy())
313
+ except (NotFoundError, ValidationError):
314
+ return None
315
+ return scan if _root_digest(scan) == expected else None
316
+
317
+
318
+ def _scan(root: Path, *, source: str) -> SkillScanResult:
319
+ """Run the ordinary scanner and policy over a Skill, and report refusals."""
320
+ try:
321
+ return scan_skill(root, default_instruction_skill_policy())
322
+ except ValidationError as error:
323
+ raise ValidationError(
324
+ f"the starter Skill obtained from {source} is not a Skill Techtree "
325
+ f"would accept from anyone: {error.message}",
326
+ code=STARTER_SKILL_SOURCE_REFUSED,
327
+ details={"source": source},
328
+ ) from error
329
+
330
+
331
+ def _root_digest(scan: SkillScanResult) -> Digest:
332
+ """Return the content-tree digest decisions 0008 pins a starter Skill by."""
333
+ return skill_content_digest(
334
+ [
335
+ SkillFile(
336
+ path=item.relative_path.as_posix(),
337
+ media_type=item.media_type,
338
+ size=item.size,
339
+ digest=item.digest,
340
+ )
341
+ for item in scan.files
342
+ ]
343
+ )
344
+
345
+
346
+ def _describe(
347
+ scan: SkillScanResult,
348
+ *,
349
+ origin: Literal["cache", "local_file", "download", "release"],
350
+ ) -> MaterializedStarterSkill:
351
+ """Say where the verified Skill is and how it got there."""
352
+ return MaterializedStarterSkill(
353
+ root=scan.root,
354
+ entrypoint=scan.root / SKILL_ENTRY_FILE,
355
+ root_digest=_root_digest(scan),
356
+ file_count=len(scan.files),
357
+ total_bytes=sum(item.size for item in scan.files),
358
+ origin=origin,
359
+ )
360
+
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # Fetching
364
+ # ---------------------------------------------------------------------------
365
+
366
+
367
+ def _download_document(url: str) -> bytes:
368
+ """Fetch one document over HTTP, bounded.
369
+
370
+ Transport is not what makes these bytes trustworthy — the release's digest
371
+ is, and it is checked afterwards on whatever arrives. This is only the
372
+ transport, and it is reached only after the service has decided the URL is
373
+ one Techtree fetches from at all.
374
+ """
375
+ request = urllib.request.Request(
376
+ url, method="GET", headers={"Accept": "text/markdown, text/plain, */*"}
377
+ )
378
+ try:
379
+ with urllib.request.urlopen(
380
+ request, timeout=DOWNLOAD_TIMEOUT_SECONDS
381
+ ) as response:
382
+ data: bytes = response.read(MAX_SKILL_TOTAL_BYTES + 1)
383
+ except (urllib.error.URLError, OSError, ValueError) as error:
384
+ raise PrerequisiteError(
385
+ f"the starter Skill could not be fetched from {url}: "
386
+ f"{getattr(error, 'reason', None) or error}",
387
+ code=STARTER_SKILL_SOURCE_REFUSED,
388
+ details={"url": url},
389
+ retryable=True,
390
+ ) from error
391
+
392
+ if len(data) > MAX_SKILL_TOTAL_BYTES:
393
+ raise ValidationError(
394
+ f"{url} served more than the {MAX_SKILL_TOTAL_BYTES} bytes a Skill "
395
+ "may hold, so it is not the starter Skill",
396
+ code=STARTER_SKILL_SOURCE_REFUSED,
397
+ details={"url": url, "maximum_total_bytes": str(MAX_SKILL_TOTAL_BYTES)},
398
+ )
399
+ return data
400
+
401
+
402
+ def _require_fetchable(url: str) -> None:
403
+ """Refuse a URL that is not an ordinary web address before anything opens it.
404
+
405
+ An ``https`` URL, or a plain ``http`` one only when it addresses this
406
+ machine, which is what a local fixture server is. Every other scheme is
407
+ refused rather than handed to a fetcher, because ``file:`` and friends
408
+ would turn a URL into a local read. The check is here rather than inside
409
+ the fetcher so that it holds for every fetcher, including a substituted
410
+ one.
411
+ """
412
+ parts = urlsplit(url)
413
+ if parts.scheme == "https" and parts.hostname:
414
+ return
415
+ if parts.scheme == "http" and _is_loopback(parts.hostname):
416
+ return
417
+ raise UsageError(
418
+ "a starter Skill is fetched over https, or over http from this "
419
+ f"machine; {url!r} is neither",
420
+ code=STARTER_SKILL_SOURCE_REFUSED,
421
+ details={"url": url},
422
+ )
423
+
424
+
425
+ def _is_loopback(hostname: str | None) -> bool:
426
+ """Return whether a host names this machine and nothing else."""
427
+ if hostname is None:
428
+ return False
429
+ if hostname == "localhost":
430
+ return True
431
+ try:
432
+ return ip_address(hostname).is_loopback
433
+ except ValueError:
434
+ return False
@@ -0,0 +1 @@
1
+ """The taskset subsystem: membership identity, locking, and validation."""