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,556 @@
1
+ """``techtree proof verify``. Spec sections 7.12 and 7.21.
2
+
3
+ One command, and it answers one question about a directory of files: does this
4
+ proof still hold together? It reads, hashes, and checks signatures, and it
5
+ writes nothing, contacts nothing, and needs no Techtree state of its own — a
6
+ person handed a proof bundle on a memory stick can check it on a machine that
7
+ has never run a Climb.
8
+
9
+ The human rendering keeps five things apart, because collapsing them is how
10
+ "the signature verifies" turns into "the result is proven":
11
+
12
+ ```text
13
+ cryptographic integrity the files still match what was signed
14
+ scientific validity the documents describe one controlled comparison
15
+ participant attestation whose key vouched for them, and what that means
16
+ independent reproduction nobody has done it
17
+ public publication what this proof's own record says, and
18
+ ```
19
+
20
+ A failed verification is a typed failure with exit code 11 and the failed
21
+ checks in the envelope, not a printed warning, because a caller that scripts
22
+ this is deciding whether to believe a number.
23
+
24
+ Two audiences read the same result and need opposite things from it. A machine
25
+ gets every check under its own stable identifier, and those identifiers are
26
+ named for the failure each check reports, because that is the vocabulary a
27
+ caller branches on. A person reading a proof that holds together does not need
28
+ three hundred rows each headed by the name of something that did not happen; a
29
+ check that passed is worth counting, and what a reader wants counted is the
30
+ kind of thing it confirmed. So the human rendering groups the checks under
31
+ headings it derives here, at the moment of printing, and prints the full list
32
+ only when it is asked for. A check that failed is the other way round entirely:
33
+ it keeps its exact identifier and its exact code, and gains the heading and the
34
+ subject that say where in the proof the trouble is.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from collections.abc import Callable, Sequence
40
+ from pathlib import Path
41
+ from typing import Annotated, Final, Literal
42
+
43
+ import typer
44
+ from rich.console import Console
45
+ from rich.table import Table
46
+ from rich.text import Text
47
+
48
+ from techtree.cli.commands.publish import build_publication_service
49
+ from techtree.cli.context import CliContext, cli_context
50
+ from techtree.cli.invoke import CommandResult, invoke_command
51
+ from techtree.cli.output import DataRenderer
52
+ from techtree.errors import NotFoundError, ValidationError, VerificationError
53
+ from techtree.identity.models import VerificationMessage, VerificationResult
54
+ from techtree.ids import validate_id
55
+ from techtree.models.base import JsonValue, NonEmptyString, ProtocolModel
56
+ from techtree.models.cli import CliMessage, MessageLevel, NextAction
57
+ from techtree.publication.offer import publish_action
58
+ from techtree.receipts.bundle import (
59
+ BUNDLE_MANIFEST_FILENAME,
60
+ PROOF_BUNDLE_INVALID,
61
+ proof_bundle_dir,
62
+ )
63
+ from techtree.receipts.verify import LocalProofVerifier
64
+
65
+ __all__ = [
66
+ "PROOF_TARGET_NOT_FOUND",
67
+ "VERIFY_COMMAND",
68
+ "ProofVerificationPayload",
69
+ "resolve_proof_target",
70
+ "verify_proof_command",
71
+ ]
72
+
73
+ VERIFY_COMMAND: Final = "proof verify"
74
+
75
+ #: Stable error code for "there is nothing at that name to verify". Distinct
76
+ #: from a bundle that exists and does not hold together, which is the section
77
+ #: 15 ``proof_bundle_invalid``.
78
+ PROOF_TARGET_NOT_FOUND: Final = "proof_target_not_found"
79
+
80
+
81
+ class ProofVerificationPayload(ProtocolModel):
82
+ """What was verified, and every check that was run on it."""
83
+
84
+ target: NonEmptyString
85
+ kind: Literal["bundle", "report"]
86
+ verified: bool
87
+ summary: list[VerificationMessage]
88
+ checks: list[VerificationMessage]
89
+
90
+
91
+ def verify_proof_command(
92
+ ctx: typer.Context,
93
+ target: Annotated[
94
+ str,
95
+ typer.Argument(
96
+ metavar="TARGET",
97
+ help=(
98
+ "A run identifier, a proof bundle directory, or a signed "
99
+ "uplift-report file."
100
+ ),
101
+ ),
102
+ ],
103
+ every_check: Annotated[
104
+ bool,
105
+ typer.Option(
106
+ "--checks",
107
+ help=(
108
+ "List every check that ran and what it confirmed, instead of "
109
+ "the counts."
110
+ ),
111
+ ),
112
+ ] = False,
113
+ ) -> None:
114
+ """Check a local proof, offline, from the bytes it stored."""
115
+ context = cli_context(ctx)
116
+
117
+ def action() -> CommandResult[ProofVerificationPayload]:
118
+ path, kind = resolve_proof_target(target, runs_dir=context.paths.runs_dir)
119
+ verifier = LocalProofVerifier()
120
+ result = (
121
+ verifier.verify_bundle(path)
122
+ if kind == "bundle"
123
+ else verifier.verify_report(path)
124
+ )
125
+ payload = ProofVerificationPayload(
126
+ target=target,
127
+ kind=kind,
128
+ verified=result.verified,
129
+ summary=verifier.explain(result),
130
+ checks=list(result.messages),
131
+ )
132
+ return CommandResult(
133
+ data=payload,
134
+ messages=_messages(payload),
135
+ warnings=_warnings(result),
136
+ next_actions=[
137
+ *_publication_offer(context, path, kind, verified=result.verified),
138
+ _read_logs(target),
139
+ ],
140
+ error=None if result.verified else _failure(payload, result),
141
+ )
142
+
143
+ invoke_command(
144
+ context,
145
+ VERIFY_COMMAND,
146
+ action,
147
+ render_data=_renderer(every_check=every_check),
148
+ )
149
+
150
+
151
+ def resolve_proof_target(
152
+ target: str, *, runs_dir: Path
153
+ ) -> tuple[Path, Literal["bundle", "report"]]:
154
+ """Turn what a caller typed into a directory or a file to verify.
155
+
156
+ Three spellings are accepted (spec section 7.21) and each one is decided by
157
+ what is actually there rather than by how it looks: a directory is a
158
+ bundle, a file is a signed report, and anything else is read as a run
159
+ identifier and looked up in this machine's runs.
160
+ """
161
+ candidate = Path(target).expanduser()
162
+ if candidate.is_dir():
163
+ return candidate, "bundle"
164
+ if candidate.is_file():
165
+ if candidate.name == BUNDLE_MANIFEST_FILENAME:
166
+ return candidate.parent, "bundle"
167
+ return candidate, "report"
168
+
169
+ missing = NotFoundError(
170
+ f"there is no proof to verify for {target}: no such run, directory or file",
171
+ code=PROOF_TARGET_NOT_FOUND,
172
+ details={"target": target},
173
+ )
174
+ try:
175
+ run_id = validate_id(target, "run")
176
+ except ValidationError as error:
177
+ raise missing from error
178
+
179
+ directory = proof_bundle_dir(runs_dir / run_id)
180
+ if directory.is_dir():
181
+ return directory, "bundle"
182
+ raise missing
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Saying what happened
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ def _failure(
191
+ payload: ProofVerificationPayload, result: VerificationResult
192
+ ) -> VerificationError:
193
+ """Return the typed failure a broken proof reports."""
194
+ return VerificationError(
195
+ f"this local proof does not verify: {result.failures[0].detail}",
196
+ code=PROOF_BUNDLE_INVALID,
197
+ details={
198
+ "target": payload.target,
199
+ "failed_checks": _identifiers(result),
200
+ "codes": _codes(result),
201
+ },
202
+ )
203
+
204
+
205
+ def _identifiers(result: VerificationResult) -> list[JsonValue]:
206
+ """Return the failed checks in the shape a typed error's details carry."""
207
+ return [message.id for message in result.failures]
208
+
209
+
210
+ def _codes(result: VerificationResult) -> list[JsonValue]:
211
+ """Return the distinct section 15 codes a failed verification reports under."""
212
+ return [code for code in sorted({message.code for message in result.failures})]
213
+
214
+
215
+ def _messages(payload: ProofVerificationPayload) -> list[CliMessage]:
216
+ if not payload.verified:
217
+ return []
218
+ return [
219
+ CliMessage(
220
+ level=MessageLevel.INFO,
221
+ code="proof_verified",
222
+ text=(
223
+ f"This proof verifies: {len(payload.checks)} checks, all from "
224
+ "the stored bytes, with nothing fetched."
225
+ ),
226
+ )
227
+ ]
228
+
229
+
230
+ def _warnings(result: VerificationResult) -> list[CliMessage]:
231
+ return [
232
+ CliMessage(
233
+ level=MessageLevel.WARNING,
234
+ code=message.code,
235
+ text=message.detail,
236
+ )
237
+ for message in result.warnings
238
+ ]
239
+
240
+
241
+ def _publication_offer(
242
+ context: CliContext,
243
+ path: Path,
244
+ kind: Literal["bundle", "report"],
245
+ *,
246
+ verified: bool,
247
+ ) -> list[NextAction]:
248
+ """Return the offer to publish, for the one case it belongs to.
249
+
250
+ Three things have to be true at once. The proof has to have verified here,
251
+ just now — a check that failed is never followed by an invitation to send
252
+ the thing that failed it. It has to be a bundle belonging to a run on this
253
+ machine, because publishing takes a run and a directory somebody was handed
254
+ on a memory stick is not one. And the report has to say it may be published,
255
+ because offering a command that would refuse is worse than offering nothing.
256
+ """
257
+ if not verified or kind != "bundle":
258
+ return []
259
+ run_id = _run_of(path, context.paths.runs_dir)
260
+ if run_id is None:
261
+ return []
262
+ if not build_publication_service(context).publication_eligible(run_id):
263
+ return []
264
+ return [publish_action(run_id)]
265
+
266
+
267
+ def _run_of(bundle: Path, runs_dir: Path) -> str | None:
268
+ """Return the run a proof directory belongs to, if it belongs to one.
269
+
270
+ Read off the directory rather than off what the caller typed, so a bundle
271
+ named by its path and the same bundle named by its run identifier are
272
+ offered the same thing.
273
+ """
274
+ run_dir = bundle.parent
275
+ if run_dir.parent.resolve() != runs_dir.resolve():
276
+ return None
277
+ try:
278
+ return validate_id(run_dir.name, "run")
279
+ except ValidationError:
280
+ return None
281
+
282
+
283
+ def _read_logs(target: str) -> NextAction:
284
+ return NextAction(
285
+ id="proof_checks",
286
+ label="See every check, including the ones that passed",
287
+ reason="Machine output lists each check with its own stable code.",
288
+ cli=["techtree", "proof", "verify", target, "--json"],
289
+ hermes_tool=None,
290
+ hermes_args=None,
291
+ requires_user_confirmation=False,
292
+ )
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Turning identifiers into headings, at the moment of printing
297
+ # ---------------------------------------------------------------------------
298
+
299
+ type _Selector = Callable[[str], bool]
300
+
301
+ #: The tail every envelope check carries. These are the only checks whose own
302
+ #: sentence does not say what it was about — thirty-six receipts report the
303
+ #: same sentence — so the thing checked is read back off the identifier's head.
304
+ _SIGNATURE_ASPECTS: Final = (
305
+ ".payload_digest",
306
+ ".signature",
307
+ ".signature_key",
308
+ ".signature_present",
309
+ )
310
+
311
+ _ARTIFACT_PREFIX: Final = "artifact."
312
+
313
+
314
+ def _about_a_missing_file(identifier: str) -> bool:
315
+ return (
316
+ identifier.endswith(".present")
317
+ or identifier.startswith("document.")
318
+ or identifier == "bundle.public_key"
319
+ )
320
+
321
+
322
+ def _about_a_stored_digest(identifier: str) -> bool:
323
+ return (
324
+ identifier.startswith(_ARTIFACT_PREFIX)
325
+ or identifier == "bundle.root_report_digest"
326
+ )
327
+
328
+
329
+ def _about_linkage(identifier: str) -> bool:
330
+ return identifier.startswith(("linkage.", "receipt_set.", "execution_record."))
331
+
332
+
333
+ def _about_a_signature(identifier: str) -> bool:
334
+ return identifier.endswith(_SIGNATURE_ASPECTS)
335
+
336
+
337
+ #: The headings a person reads a verification under, in the order the checks
338
+ #: were run. Each one says what its checks confirmed rather than what they
339
+ #: would have reported had they failed. The last heading takes whatever the
340
+ #: others left, so the counts always add up to everything that ran.
341
+ _HEADINGS: Final[tuple[tuple[str, _Selector], ...]] = (
342
+ ("Files and key present", _about_a_missing_file),
343
+ ("Stored file digests", _about_a_stored_digest),
344
+ ("Linkage and control", _about_linkage),
345
+ ("Signatures", _about_a_signature),
346
+ (
347
+ "Aggregate recomputation",
348
+ lambda identifier: identifier.startswith("aggregate."),
349
+ ),
350
+ ("Publication", lambda identifier: identifier.startswith("publication.")),
351
+ ("Proof grade conditions", lambda identifier: identifier.startswith("p1.")),
352
+ ("Other checks", lambda _identifier: True),
353
+ )
354
+
355
+
356
+ def _grouped(
357
+ checks: Sequence[VerificationMessage],
358
+ ) -> list[tuple[str, list[VerificationMessage]]]:
359
+ """Return the checks under their headings, in reading order."""
360
+ collected: dict[str, list[VerificationMessage]] = {
361
+ heading: [] for heading, _ in _HEADINGS
362
+ }
363
+ for message in checks:
364
+ for heading, belongs_here in _HEADINGS:
365
+ if belongs_here(message.id):
366
+ collected[heading].append(message)
367
+ break
368
+ return [
369
+ (heading, collected[heading]) for heading, _ in _HEADINGS if collected[heading]
370
+ ]
371
+
372
+
373
+ def _heading_of(identifier: str) -> str:
374
+ """Return the heading one check is counted under."""
375
+ for heading, belongs_here in _HEADINGS:
376
+ if belongs_here(identifier):
377
+ return heading
378
+ raise AssertionError("the last heading takes every identifier")
379
+
380
+
381
+ def _subject_of(message: VerificationMessage) -> str:
382
+ """Return what one check was about, or nothing when its own words say so."""
383
+ for aspect in _SIGNATURE_ASPECTS:
384
+ if message.id.endswith(aspect):
385
+ return message.id[: -len(aspect)]
386
+ if message.id.startswith(_ARTIFACT_PREFIX):
387
+ return message.id[len(_ARTIFACT_PREFIX) :]
388
+ return ""
389
+
390
+
391
+ def _named_beside(message: VerificationMessage) -> str:
392
+ """Return the subject to print beside a check, or nothing if it repeats.
393
+
394
+ Most checks open their own sentence with the thing they were about. The
395
+ envelope checks do not, because thirty-six receipts report the identical
396
+ sentence, and those are the ones worth naming.
397
+ """
398
+ subject = _subject_of(message)
399
+ return "" if message.detail.startswith(subject) else subject
400
+
401
+
402
+ def _tally(checks: Sequence[VerificationMessage]) -> tuple[str, str]:
403
+ """Return how one heading came out, and anything about it worth reading."""
404
+ passed = sum(1 for message in checks if message.status == "passed")
405
+ failed = sum(1 for message in checks if message.status == "failed")
406
+ weaker = len(checks) - passed - failed
407
+ if len(checks) == 1:
408
+ return checks[0].status, ""
409
+ notes = []
410
+ if failed:
411
+ notes.append(f"{failed} failed")
412
+ if weaker:
413
+ notes.append("1 warning" if weaker == 1 else f"{weaker} warnings")
414
+ return f"{passed}/{len(checks)}", ", ".join(notes)
415
+
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # Printing it
419
+ # ---------------------------------------------------------------------------
420
+
421
+
422
+ def _renderer(*, every_check: bool) -> DataRenderer:
423
+ """Return the human rendering, with or without the full list of checks."""
424
+
425
+ def render(data: object, console: Console) -> None:
426
+ _render(data, console, every_check=every_check)
427
+
428
+ return render
429
+
430
+
431
+ def _render(data: object, console: Console, *, every_check: bool) -> None:
432
+ if not isinstance(data, ProofVerificationPayload):
433
+ return
434
+
435
+ console.print(f"Proof: {data.target}")
436
+ console.print()
437
+ _render_summary(data.summary, console)
438
+
439
+ headings = _grouped(data.checks)
440
+ _render_counts(headings, len(data.checks), console)
441
+ if every_check:
442
+ _render_every_check(headings, console)
443
+ else:
444
+ console.print()
445
+ console.print("Add --checks to see every one of them and what it confirmed.")
446
+
447
+ failures = [message for message in data.checks if message.status == "failed"]
448
+ if failures:
449
+ _render_failures(failures, len(data.checks), console)
450
+
451
+
452
+ #: The colours Doctor already gives a check outcome, so a reader who has read
453
+ #: one of the two surfaces can read the other without learning a second
454
+ #: vocabulary. A warning is not a failure and must not look like one.
455
+ _STATUS_STYLE: Final[dict[str, str]] = {
456
+ "passed": "green",
457
+ "warning": "yellow",
458
+ "failed": "red",
459
+ }
460
+
461
+
462
+ def _render_summary(summary: Sequence[VerificationMessage], console: Console) -> None:
463
+ """Print each headline verdict beside what it is a verdict about.
464
+
465
+ This is not the labelled-facts table it resembles. The left column holds
466
+ the outcome rather than the name of a value, and an outcome is the thing a
467
+ reader is looking for, not the word that says which value follows. So it
468
+ keeps its own renderer and carries the same colours Doctor gives its own
469
+ checks, because a reader who has seen one of them should be able to read
470
+ the other at a glance.
471
+ """
472
+ table = Table(box=None, show_header=False, pad_edge=False, padding=(0, 2))
473
+ table.add_column("status", no_wrap=True)
474
+ table.add_column("detail", overflow="fold")
475
+ for message in summary:
476
+ table.add_row(
477
+ Text(message.status.upper(), style=_STATUS_STYLE[message.status]),
478
+ message.detail,
479
+ )
480
+ console.print(table)
481
+
482
+
483
+ def _render_counts(
484
+ headings: Sequence[tuple[str, list[VerificationMessage]]],
485
+ total: int,
486
+ console: Console,
487
+ ) -> None:
488
+ console.print()
489
+ console.print(f"What was checked, {total} checks in all")
490
+ rows = [(heading, *_tally(checks)) for heading, checks in headings]
491
+ table = Table(box=None, show_header=False, pad_edge=True, padding=(0, 2))
492
+ table.add_column("heading", overflow="fold")
493
+ table.add_column("outcome", justify="right", no_wrap=True)
494
+ # The third column exists only when something is in it, so a proof that
495
+ # holds together prints no column of blanks beside its counts.
496
+ troubled = any(note for _, _, note in rows)
497
+ if troubled:
498
+ table.add_column("note", no_wrap=True)
499
+ for heading, outcome, note in rows:
500
+ cells = (heading, outcome, note) if troubled else (heading, outcome)
501
+ table.add_row(*cells)
502
+ console.print(table)
503
+
504
+
505
+ def _render_every_check(
506
+ headings: Sequence[tuple[str, list[VerificationMessage]]], console: Console
507
+ ) -> None:
508
+ for heading, checks in headings:
509
+ console.print()
510
+ console.print(heading)
511
+ rows = [
512
+ (message.status.upper(), _named_beside(message), message.detail)
513
+ for message in checks
514
+ ]
515
+ table = Table(box=None, show_header=False, pad_edge=True, padding=(0, 2))
516
+ table.add_column("status", no_wrap=True)
517
+ # Most checks say what they were about in their own words. The ones
518
+ # that do not are named beside them rather than left to the reader.
519
+ named = any(subject for _, subject, _ in rows)
520
+ if named:
521
+ table.add_column("subject", overflow="fold")
522
+ table.add_column("confirmed", overflow="fold")
523
+ for status, subject, detail in rows:
524
+ cells = (status, subject, detail) if named else (status, detail)
525
+ table.add_row(*cells)
526
+ console.print(table)
527
+
528
+
529
+ def _render_failures(
530
+ failures: Sequence[VerificationMessage], total: int, console: Console
531
+ ) -> None:
532
+ """Print every failure whole: where it is, what went wrong, and its code.
533
+
534
+ Nothing here is grouped or shortened. A reader whose proof does not hold
535
+ together is the one reader who needs all of it, and the identifier and the
536
+ code are exactly the two strings they will quote to somebody else.
537
+ """
538
+ console.print()
539
+ console.print(f"What failed, {len(failures)} of {total} checks")
540
+ table = Table(box=None, show_header=False, pad_edge=False, padding=(0, 1))
541
+ table.add_column("index", justify="right", no_wrap=True)
542
+ table.add_column("failure", overflow="fold")
543
+ for position, message in enumerate(failures, start=1):
544
+ subject = _subject_of(message)
545
+ where = _heading_of(message.id)
546
+ table.add_row(
547
+ f"{position}.",
548
+ "\n".join(
549
+ [
550
+ f"{where} — {subject}" if subject else where,
551
+ message.detail,
552
+ f"check {message.id}, reported as {message.code}",
553
+ ]
554
+ ),
555
+ )
556
+ console.print(table)