claims-cli 0.6.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 (51) hide show
  1. claims/__init__.py +132 -0
  2. claims/__main__.py +5 -0
  3. claims/adapters/__init__.py +6 -0
  4. claims/adapters/array.py +212 -0
  5. claims/adapters/base.py +75 -0
  6. claims/adapters/cells.py +94 -0
  7. claims/adapters/columnar.py +188 -0
  8. claims/adapters/prose.py +261 -0
  9. claims/adapters/reads.py +47 -0
  10. claims/adapters/rows.py +116 -0
  11. claims/adapters/sheet.py +149 -0
  12. claims/adapters/sqlite.py +73 -0
  13. claims/adapters/table.py +135 -0
  14. claims/adapters/tree.py +163 -0
  15. claims/checks.py +511 -0
  16. claims/cli.py +1103 -0
  17. claims/core/__init__.py +9 -0
  18. claims/core/artifacts.py +121 -0
  19. claims/core/checks.py +132 -0
  20. claims/core/claims.py +100 -0
  21. claims/core/evidence.py +708 -0
  22. claims/core/manifest.py +349 -0
  23. claims/core/numbers.py +146 -0
  24. claims/core/outcomes.py +410 -0
  25. claims/core/report.py +503 -0
  26. claims/corpus.py +344 -0
  27. claims/exceptions.py +106 -0
  28. claims/export/__init__.py +23 -0
  29. claims/export/rocrate.py +787 -0
  30. claims/export/sarif.py +401 -0
  31. claims/manifest.py +59 -0
  32. claims/models.py +222 -0
  33. claims/numbers.py +453 -0
  34. claims/origins.py +303 -0
  35. claims/policy.py +369 -0
  36. claims/provenance.py +42 -0
  37. claims/record.py +1099 -0
  38. claims/regression.py +171 -0
  39. claims/renderers/__init__.py +10 -0
  40. claims/renderers/sarif.py +294 -0
  41. claims/reruns.py +111 -0
  42. claims/resolve.py +116 -0
  43. claims/stale.py +333 -0
  44. claims/starter.py +432 -0
  45. claims/toolchain.py +120 -0
  46. claims/verify.py +1371 -0
  47. claims/why.py +398 -0
  48. claims_cli-0.6.0.dist-info/METADATA +139 -0
  49. claims_cli-0.6.0.dist-info/RECORD +51 -0
  50. claims_cli-0.6.0.dist-info/WHEEL +4 -0
  51. claims_cli-0.6.0.dist-info/entry_points.txt +2 -0
claims/cli.py ADDED
@@ -0,0 +1,1103 @@
1
+ """The commands that check what a manifest declares, and how each prints what it found.
2
+
3
+ claims manifest init write a starter repro.yaml for an existing project
4
+ claims verify check every evidence assertion in repro.yaml
5
+ claims stale say which claims read a file changed since its recorded re-run
6
+ claims why say what the record establishes for one claim, and what it does not
7
+ claims numbers count every number the manuscript prints, and which a claim binds
8
+ claims origins compare each artifact copied from another repository with its origin
9
+ claims export write the check records in another format
10
+
11
+ Each is also the `repro` command of the same name: `repro.cli` adds these functions to its
12
+ parser with `add_manifest` through `add_origins`, and `main` here adds them to the parser of
13
+ `claims`. Each handler reads the name the user typed off the parsed arguments, so a message that
14
+ says what to run next names a command that user has. Nothing here runs a declared command,
15
+ writes the record of re-runs or drives another tool, and an import-linter contract holds this
16
+ package to importing nothing of `repro`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import collections
23
+ import enum
24
+ import json
25
+ import os
26
+ import shlex
27
+ from pathlib import Path
28
+
29
+ from provenance_core import atomic_write
30
+ from provenance_core.chain import ChainError
31
+
32
+ from claims import __version__
33
+ from claims.checks import LOCAL as LOCAL_CHECKS
34
+ from claims.checks import RECORD as CHECKS
35
+ from claims.checks import SET_ASIDE
36
+ from claims.checks import record as record_checks
37
+ from claims.checks import set_aside as set_aside_checks
38
+ from claims.exceptions import ReproError
39
+ from claims.export import EXPORTS
40
+ from claims.export.rocrate import METADATA_FILE
41
+ from claims.manifest import DEFAULT_NAME, find, load
42
+ from claims.models import (
43
+ AssertionAccount,
44
+ Availability,
45
+ ChainNode,
46
+ CheckedAssertion,
47
+ ClaimAccount,
48
+ Freshness,
49
+ NumberForm,
50
+ NumbersReport,
51
+ Ordering,
52
+ Outcome,
53
+ Reason,
54
+ StaleCause,
55
+ StaleFinding,
56
+ StalenessReport,
57
+ Standing,
58
+ Validity,
59
+ VerificationReport,
60
+ Warning_,
61
+ )
62
+ from claims.numbers import numbers as run_numbers
63
+ from claims.origins import OriginCheck, OriginsReport, OriginState, fetch_url
64
+ from claims.origins import origins as run_origins
65
+ from claims.policy import PROFILES, Assessment, Policy
66
+ from claims.record import check_records
67
+ from claims.renderers import to_sarif
68
+ from claims.reruns import RECORD
69
+ from claims.stale import stale as run_stale
70
+ from claims.starter import CONVENTIONAL, MAX_DISCOVERED, project_root
71
+ from claims.starter import write as write_starter
72
+ from claims.verify import verify as run_verify
73
+ from claims.why import why as run_why
74
+
75
+ #: The two commands these functions answer to. `repro` is the one they were written for, and
76
+ #: every message is as it was under it. Under `claims` a message names `claims` for a command
77
+ #: this module holds, and says who provides the one it does not.
78
+ REPRO = "repro"
79
+ CLAIMS = "claims"
80
+
81
+
82
+ def _program(args: argparse.Namespace) -> str:
83
+ """The command the user typed, which the parser recorded. `repro` where none did."""
84
+ return getattr(args, "program", REPRO)
85
+
86
+
87
+ def _reproduce(program: str) -> str:
88
+ """The command that runs the declared commands again, as a reader of `program` needs it
89
+ named: it is not one of `claims`'s, so there it comes with the distribution that has it."""
90
+ if program == REPRO:
91
+ return "`repro reproduce`"
92
+ return "`repro reproduce` (from the `reproducible-science` distribution)"
93
+
94
+
95
+ SYMBOL = {
96
+ Outcome.VERIFIED: "ok ",
97
+ Outcome.MISMATCH: "MISS",
98
+ Outcome.NOT_FOUND: "GONE",
99
+ Outcome.UNCHECKED: "-- ",
100
+ Outcome.ERROR: "ERR ",
101
+ Outcome.NOT_OFFERED: "none",
102
+ }
103
+
104
+
105
+ def cmd_manifest_init(args: argparse.Namespace) -> int:
106
+ starter = write_starter(project_root(Path.cwd()), args.files, Path.cwd(), _program(args))
107
+ print(f"wrote {starter.path}")
108
+
109
+ count = f"{len(starter.artifacts)} artifact{'' if len(starter.artifacts) == 1 else 's'}"
110
+ if starter.discovered:
111
+ places = ", ".join(f"{name}/" for name in CONVENTIONAL)
112
+ print(f" pinned {count}, found by looking under {places} and for one manuscript")
113
+ else:
114
+ print(f" pinned {count}, as named")
115
+ width = max((len(artifact.id) for artifact in starter.artifacts), default=0)
116
+ for artifact in starter.artifacts:
117
+ print(f" {artifact.id:<{width}} {artifact.path.as_posix()}")
118
+ if starter.capped:
119
+ print(
120
+ f" more than {MAX_DISCOVERED} files were found; the first {MAX_DISCOVERED} are pinned"
121
+ )
122
+ for note in starter.notes:
123
+ print(f" passed over: {note}")
124
+ if starter.discovered and starter.artifacts:
125
+ # Discovery reads names and suffixes, and knows nothing of what the manuscript reports.
126
+ print(" That list is a guess. Remove an entry under `artifacts`, or add one with its")
127
+ print(f" `shasum -a 256`; or delete {DEFAULT_NAME} and name the files:")
128
+ print(f" {_program(args)} manifest init <file> ...")
129
+ elif starter.discovered:
130
+ print(f" Nothing was found there. Delete {DEFAULT_NAME} and name the files to pin:")
131
+ print(f" {_program(args)} manifest init <file> ...")
132
+
133
+ if starter.written:
134
+ print(f" example claims, to edit: {', '.join(starter.written)}")
135
+ if starter.commented:
136
+ print(f" example claims left as comments: {', '.join(starter.commented)}")
137
+ if not starter.written:
138
+ print(
139
+ f" No claim is declared, and `{_program(args)} verify` fails a manifest that "
140
+ "checks nothing"
141
+ )
142
+ print(" (`report.empty`). Declare one claim and it will report on it and on the pins.")
143
+ print(f"\nnext: {_program(args)} verify")
144
+ return 0
145
+
146
+
147
+ def cmd_verify(args: argparse.Namespace) -> int:
148
+ path = Path(args.manifest) if args.manifest else find()
149
+ if path is None:
150
+ print(f"no {DEFAULT_NAME} here or above.\n")
151
+ print("write one that pins this project's result files, with example claims to edit:\n")
152
+ print(f" {_program(args)} manifest init")
153
+ return 2
154
+
155
+ manifest = load(path)
156
+ report = run_verify(manifest)
157
+ policy = PROFILES[args.policy]
158
+ assessment = policy.assess(report)
159
+ # The record sits beside the check and never decides it: where it cannot be written, one
160
+ # line on standard error says so and why, and the report and the exit code are the
161
+ # verification's. Standard error, so `--format json` still prints one document. Outside a
162
+ # git repository there is no local record and nothing is said.
163
+ if args.set_aside_record:
164
+ try:
165
+ moved = set_aside_checks(manifest, local=args.local_record, tracked=args.record)
166
+ except (ReproError, ChainError) as e:
167
+ os.write(2, f"record not set aside: {e}\n".encode())
168
+ except OSError as e:
169
+ os.write(2, f"record not set aside: {e.filename}: {e.strerror}\n".encode())
170
+ else:
171
+ for note in moved:
172
+ os.write(
173
+ 2,
174
+ f"record set aside: the {note.kind} record is kept as {note.kept_as}, and "
175
+ "a new one starts by saying so\n".encode(),
176
+ )
177
+ try:
178
+ record_checks(
179
+ manifest,
180
+ report,
181
+ local=args.local_record,
182
+ tracked=args.record,
183
+ program=_program(args),
184
+ )
185
+ except (ReproError, ChainError) as e:
186
+ os.write(2, f"record not written: {e}\n".encode())
187
+ except OSError as e:
188
+ os.write(2, f"record not written: {e.filename}: {e.strerror}\n".encode())
189
+
190
+ if args.format == "sarif":
191
+ print(json.dumps(to_sarif(report, assessment, version=__version__), indent=2))
192
+ return 0 if assessment.passed else 1
193
+ if args.format == "json":
194
+ print(
195
+ json.dumps(
196
+ {
197
+ "report": report.model_dump(mode="json"),
198
+ "assessment": assessment.model_dump(mode="json"),
199
+ },
200
+ indent=2,
201
+ )
202
+ )
203
+ return 0 if assessment.passed else 1
204
+
205
+ _verify_text(path, report, policy, assessment)
206
+ return 0 if assessment.passed else 1
207
+
208
+
209
+ def _verify_text(
210
+ path: Path, report: VerificationReport, policy: Policy, assessment: Assessment
211
+ ) -> None:
212
+ print(f"{path}\n")
213
+ for artifact in report.artifacts:
214
+ if artifact.validity is Validity.BROKEN_PIN:
215
+ print(
216
+ f" BROKEN PIN {artifact.artifact_id}: pinned "
217
+ f"{(artifact.expected or '')[:12]}, found {(artifact.actual or '')[:12]}"
218
+ )
219
+ elif artifact.validity is Validity.ARTIFACT_ABSENT:
220
+ print(f" ABSENT {artifact.artifact_id}: nothing at the declared path")
221
+ elif artifact.validity is Validity.UNPINNED_ARTIFACT:
222
+ print(f" unpinned {artifact.artifact_id}")
223
+ if any(a.validity is not Validity.AUTHORITATIVE for a in report.artifacts):
224
+ print()
225
+
226
+ for claim in report.claims:
227
+ if claim.availability is Availability.NOT_OFFERED:
228
+ print(
229
+ f" {SYMBOL[Outcome.NOT_OFFERED]} {claim.claim_id:<12} "
230
+ f"{'-':<8} no evidence offered"
231
+ )
232
+ continue
233
+ if claim.ordering is Ordering.VIOLATED:
234
+ print(f" ORDER {claim.claim_id:<12} {claim.ordering_detail[:52]}")
235
+ elif claim.ordering is Ordering.UNCHECKED:
236
+ print(
237
+ f" order? {claim.claim_id:<12} "
238
+ f"{claim.ordering_reason.value}: {claim.ordering_detail[:38]}"
239
+ )
240
+ for d in claim.decisions:
241
+ mark = "" if d.is_authoritative else f" <{d.validity.value}>"
242
+ note = f" [{','.join(w.value for w in d.warnings)}]" if d.warnings else ""
243
+ # Said outside the detail, which is cut short and would lose it.
244
+ tie = f" [exact tie: {d.tie.lower} or {d.tie.upper}]" if d.tie else ""
245
+ print(
246
+ f" {SYMBOL[d.outcome]} {d.claim_id:<12} {d.kind:<8} "
247
+ f"{d.detail[:52]}{tie}{note}{mark}"
248
+ )
249
+
250
+ counts = report.counts
251
+ print(f"\n {', '.join(f'{v} {k}' for k, v in sorted(counts.items()))}")
252
+ print(
253
+ f" policy {policy.name}: {'passed' if assessment.passed else 'FAILED'}"
254
+ f" ({len(assessment.errors)} errors, {len(assessment.warnings)} warnings)"
255
+ )
256
+ for v in assessment.errors[:10]:
257
+ print(f" error {v.rule:<26} {v.subject}: {v.detail[:44]}")
258
+
259
+
260
+ def _said(state: enum.StrEnum) -> str:
261
+ return state.value.replace("_", " ")
262
+
263
+
264
+ def _named(artifact_id: str, paths: dict[str, str]) -> str:
265
+ path = paths.get(artifact_id, "")
266
+ return f"{artifact_id} ({path})" if path and path != artifact_id else artifact_id
267
+
268
+
269
+ def _finding_said(found: StaleFinding, paths: dict[str, str]) -> str:
270
+ """One difference from a recorded re-run, as a clause."""
271
+
272
+ def digests(verb: str) -> str:
273
+ return (
274
+ f"the re-run of {found.rerun_at} {verb} {(found.observed or 'nothing')[:12]}, "
275
+ f"on disk {(found.on_disk or 'nothing')[:12]}"
276
+ )
277
+
278
+ cause = _said(found.cause)
279
+ # Appended, and only where the re-run's entry says where it ran.
280
+ where = f"; that re-run was {found.witness.said}" if found.witness else ""
281
+ if found.cause is StaleCause.INPUT_CHANGED:
282
+ return f"{cause}: {_named(found.artifact, paths)}: {digests('read')}{where}"
283
+ if found.cause is StaleCause.OUTPUT_CHANGED:
284
+ return f"{cause}: {_named(found.artifact, paths)}: {digests('wrote')}{where}"
285
+ if found.cause is StaleCause.COMMAND_CHANGED:
286
+ return f"{cause} since the re-run of {found.rerun_at}{where}"
287
+ if found.cause is StaleCause.CYCLE:
288
+ return f"{cause}: {found.regeneration} is upstream of itself"
289
+ if found.cause is StaleCause.LATER_RERUN_DID_NOT_REPRODUCE:
290
+ wrote = f"wrote {found.observed[:12]}" if found.observed else "wrote no output"
291
+ return (
292
+ f"{cause}: the re-run of {found.rerun_at} read the same inputs and {wrote}, "
293
+ f"on disk {(found.on_disk or 'nothing')[:12]}{where}"
294
+ )
295
+ return f"{cause}: {_named(found.artifact, paths)}"
296
+
297
+
298
+ def _stale_lines(report: StalenessReport) -> list[str]:
299
+ """One line per difference from a recorded re-run, then one per record that takes its state
300
+ from upstream, then the claims that read them."""
301
+ paths = {artifact.id: artifact.path for artifact in report.artifacts}
302
+ outputs = {node.id: node.artifact for node in report.regenerations}
303
+
304
+ lines = []
305
+ for found in report.findings:
306
+ said = _finding_said(found, paths)
307
+ label = (
308
+ "UNDETERMINED"
309
+ if found.cause
310
+ in (
311
+ StaleCause.INPUT_MISSING,
312
+ StaleCause.OUTPUT_MISSING,
313
+ StaleCause.CYCLE,
314
+ StaleCause.LATER_RERUN_DID_NOT_REPRODUCE,
315
+ )
316
+ else "STALE"
317
+ )
318
+ lines.append(f" {label:<13} {outputs[found.regeneration]}: {said}")
319
+ for node in report.regenerations:
320
+ label = _said(node.state).upper()
321
+ if node.upstream:
322
+ steps = f"{node.distance} step{'' if node.distance == 1 else 's'} up"
323
+ lines.append(
324
+ f" {label:<13} {node.artifact}: upstream {_said(node.state)}: "
325
+ f"{node.upstream}, {steps}"
326
+ )
327
+ elif node.state is Freshness.NEVER_RERUN:
328
+ lines.append(
329
+ f" {label:<13} {node.artifact}: no re-run of {node.id} that reproduced is recorded"
330
+ )
331
+ for note in report.record_disagrees_with_pin:
332
+ lines.append(
333
+ f" {'note':<13} {note.regeneration}: record disagrees with pin: "
334
+ f"{_named(note.artifact, paths)}: the record names {note.recorded[:12]}, "
335
+ f"the manifest pins {note.pinned[:12]}"
336
+ )
337
+ if lines:
338
+ lines.append("")
339
+
340
+ states = {artifact.id: artifact.state for artifact in report.artifacts}
341
+ reported = (Freshness.STALE, Freshness.NEVER_RERUN, Freshness.UNDETERMINED)
342
+ width = max((len(claim.id) for claim in report.claims), default=0)
343
+ claimed = [
344
+ f" {_said(claim.state):<13} {claim.id:<{width}} reads "
345
+ + ", ".join(
346
+ dict.fromkeys(
347
+ name
348
+ for assertion in claim.assertions
349
+ for name in assertion.artifacts
350
+ if states.get(name) is claim.state
351
+ )
352
+ )
353
+ for claim in report.claims
354
+ if claim.state in reported
355
+ ]
356
+ return [*lines, *claimed, *([""] if claimed else [])]
357
+
358
+
359
+ def _stale_summary(report: StalenessReport) -> list[str]:
360
+ """The three counts, each with what it is a count of. A line saying nothing is stale is
361
+ never printed without how many assertions could have been."""
362
+ assertions = [assertion for claim in report.claims for assertion in claim.assertions]
363
+ outside = (Freshness.NO_PRODUCER, Freshness.PARTLY_OUTSIDE)
364
+ covered = collections.Counter(a.state for a in assertions if a.state not in outside)
365
+ produced = {node.artifact for node in report.regenerations}
366
+ uncovered = [a for a in assertions if a.state in outside]
367
+ files = {name for a in uncovered for name in a.artifacts if name not in produced}
368
+ claims = collections.Counter(claim.state for claim in report.claims)
369
+ return [
370
+ f" {covered[Freshness.STALE]} stale, {covered[Freshness.NEVER_RERUN]} never rerun, "
371
+ f"{covered[Freshness.UNDETERMINED]} undetermined, {covered[Freshness.CURRENT]} current "
372
+ f"of {sum(covered.values())} assertions that read a file with a recorded producer",
373
+ f" {len(uncovered)} assertions read {len(files)} files no command is recorded for: "
374
+ "nothing upstream to compare",
375
+ f" {len(report.claims)} claims: {claims[Freshness.STALE]} stale, "
376
+ f"{claims[Freshness.NEVER_RERUN]} never rerun, {claims[Freshness.CURRENT]} current, "
377
+ f"{claims[Freshness.PARTLY_OUTSIDE]} partly outside the record, "
378
+ f"{claims[Freshness.NO_PRODUCER]} outside the record, "
379
+ f"{claims[Freshness.UNDETERMINED]} undetermined",
380
+ ]
381
+
382
+
383
+ def cmd_stale(args: argparse.Namespace) -> int:
384
+ path = Path(args.manifest) if args.manifest else find()
385
+ if path is None:
386
+ print(f"no {DEFAULT_NAME} here or above.\n")
387
+ print("write one that pins this project's result files, with example claims to edit:\n")
388
+ print(f" {_program(args)} manifest init")
389
+ return 2
390
+ manifest = load(path)
391
+ if not manifest.regenerations:
392
+ print(f"{path}\n")
393
+ print(" no command is declared, so no file has a recorded producer to compare it with.")
394
+ print(" A record under `regenerations` names a command, the files it reads and the")
395
+ print(" file it writes.")
396
+ return 2
397
+
398
+ report = run_stale(manifest)
399
+ policy = PROFILES[args.policy]
400
+ assessment = policy.assess_staleness(report)
401
+
402
+ if args.format == "json":
403
+ print(
404
+ json.dumps(
405
+ {
406
+ "staleness": report.model_dump(mode="json"),
407
+ "assessment": assessment.model_dump(mode="json"),
408
+ },
409
+ indent=2,
410
+ )
411
+ )
412
+ return 0 if assessment.passed else 1
413
+
414
+ _stale_text(path, report, policy, assessment, program=_program(args))
415
+ return 0 if assessment.passed else 1
416
+
417
+
418
+ def _stale_text(
419
+ path: Path,
420
+ report: StalenessReport,
421
+ policy: Policy,
422
+ assessment: Assessment,
423
+ program: str = REPRO,
424
+ ) -> None:
425
+ print(f"{path}\n")
426
+ if not report.reruns:
427
+ print(f" {RECORD.as_posix()} holds no re-run; {_reproduce(program)} records one.\n")
428
+ print("\n".join([*_stale_lines(report), *_stale_summary(report)]))
429
+ print(
430
+ f" policy {policy.name}: {'passed' if assessment.passed else 'FAILED'}"
431
+ f" ({len(assessment.errors)} errors, {len(assessment.warnings)} warnings)"
432
+ )
433
+
434
+
435
+ def _steps(count: int | None) -> str:
436
+ return f"{count} step{'' if count == 1 else 's'} up"
437
+
438
+
439
+ def _assertion_lines(assertion: AssertionAccount, paths: dict[str, str]) -> list[str]:
440
+ """The pin of each file the assertion reads, the value at each address, and the outcome."""
441
+ lines = []
442
+ # A file read at two addresses is pinned once.
443
+ for file in {file.artifact: file for file in assertion.reads}.values():
444
+ name = _named(file.artifact, paths)
445
+ if file.validity is None:
446
+ lines.append(f"{name}: the manifest declares no such artifact")
447
+ elif file.validity is Validity.ARTIFACT_ABSENT:
448
+ lines.append(f"{name}: nothing at the declared path")
449
+ elif file.validity is Validity.UNPINNED_ARTIFACT:
450
+ lines.append(f"{name}: no digest is pinned, on disk {(file.on_disk or '')[:12]}")
451
+ elif file.validity is Validity.BROKEN_PIN:
452
+ lines.append(
453
+ f"{name}: does not match its pinned digest: pinned "
454
+ f"{(file.pinned or '')[:12]}, on disk {(file.on_disk or '')[:12]}"
455
+ )
456
+ else:
457
+ lines.append(f"{name}: matches its pinned digest {(file.pinned or '')[:12]}")
458
+ for file in assertion.reads:
459
+ if file.address:
460
+ side = f"{file.side}: " if file.side else ""
461
+ held = f"holds {file.stored}" if file.stored is not None else "no value was read"
462
+ if file.conversion is not None and file.converted is not None:
463
+ held += f", {file.converted} {file.conversion.said}"
464
+ lines.append(f"{side}{file.artifact} at {file.address}: {held}")
465
+ if assertion.statistic is not None and assertion.computed is not None:
466
+ lines.append(
467
+ f"the {assertion.statistic.value} of the {len(assertion.reads)} values: "
468
+ f"{assertion.computed}"
469
+ )
470
+ if assertion.tie is not None:
471
+ lines.append(
472
+ f"an exact tie between {assertion.tie.lower} and {assertion.tie.upper}: "
473
+ "rounding half up prints one and half to even may print the other"
474
+ )
475
+ outcome = _said(assertion.outcome)
476
+ if (
477
+ assertion.outcome not in (Outcome.VERIFIED, Outcome.MISMATCH)
478
+ or assertion.reason is Reason.PLACES_MISMATCH
479
+ ):
480
+ outcome += f" ({_said(assertion.reason)})"
481
+ if assertion.kind == "quote":
482
+ passage = assertion.printed or ""
483
+ shown = passage if len(passage) <= 60 else f"{passage[:60]}..."
484
+ within = assertion.reads[0].artifact if assertion.reads else "the artifact"
485
+ lines.append(f'the passage "{shown}" in {within}: {outcome}')
486
+ elif assertion.printed is not None:
487
+ declared = (
488
+ ""
489
+ if assertion.places is None
490
+ else f", declared to {assertion.places} "
491
+ f"decimal place{'' if assertion.places == 1 else 's'}"
492
+ )
493
+ lines.append(f"the manuscript prints {assertion.printed}{declared}: {outcome}")
494
+ else:
495
+ lines.append(f"the two sides compared: {outcome}")
496
+ # One line a warning, named as `repro verify` names it in brackets. An unchecked page says
497
+ # why, since that is in the detail and the detail of a verified assertion is not printed.
498
+ for warning in assertion.warnings:
499
+ why_not = assertion.detail if warning is Warning_.PAGE_UNCHECKED else ""
500
+ lines.append(f"warning: {warning.value}" + (f": {why_not}" if why_not else ""))
501
+ return [*lines, *_history_lines(assertion)]
502
+
503
+
504
+ def _checked_said(state: CheckedAssertion, before: CheckedAssertion | None) -> str:
505
+ """What one check found an assertion declaring, the bytes it read and the outcome, and
506
+ what differs from the check before it."""
507
+ if state.kind == "quote":
508
+ passage = state.printed or ""
509
+ said = f'the passage "{passage if len(passage) <= 60 else f"{passage[:60]}..."}"'
510
+ elif state.printed is not None:
511
+ said = f"reported {state.printed}" + (f" at {state.address}" if state.address else "")
512
+ else:
513
+ said = state.address
514
+ files = ", ".join(
515
+ f"{name} {digest[:12] if digest else 'absent'}" for name, digest in state.digests.items()
516
+ )
517
+ line = f"{said}, {files}: {_said(state.outcome)}"
518
+ if before is None:
519
+ return line
520
+ changed = [
521
+ label
522
+ for label, then, now in (
523
+ ("claim text", before.claim_digest, state.claim_digest),
524
+ ("passage" if state.kind == "quote" else "reported", before.printed, state.printed),
525
+ ("address", before.address, state.address),
526
+ ("conversion", before.conversion, state.conversion),
527
+ ("file", before.digests, state.digests),
528
+ )
529
+ if then != now
530
+ ]
531
+ return (
532
+ f"{line} (after {_said(before.outcome)}: "
533
+ f"{', '.join(changed) or 'nothing declared'} changed)"
534
+ )
535
+
536
+
537
+ def _history_lines(assertion: AssertionAccount) -> list[str]:
538
+ """The record of checks for one assertion, where it holds more than how the assertion
539
+ stands now: each earlier state as `checked`, and the state now as `since` the check that
540
+ first found it, or as `now` where no recorded check has."""
541
+ states = assertion.history
542
+ renamed = [
543
+ f"a claim named {last.claim_id} was last checked {last.checked_at} at this address: "
544
+ f"{_said(last.outcome)}, and is no longer declared"
545
+ for last in assertion.formerly
546
+ ]
547
+ if len(states) < 2 and assertion.now is None and all(s.verifiable for s in states):
548
+ return renamed
549
+ current = len(states) - 1 if assertion.now is None else len(states)
550
+ lines = [
551
+ f"{'since' if index == current else 'checked'} {state.checked_at}: "
552
+ f"{_checked_said(state, states[index - 1] if index else None)}"
553
+ + ("" if state.verifiable else " [unverifiable]")
554
+ for index, state in enumerate(states)
555
+ ]
556
+ if assertion.now is not None:
557
+ lines.append(
558
+ f"now: {_checked_said(assertion.now, states[-1])}; no check of this is recorded"
559
+ )
560
+ return [*lines, *renamed]
561
+
562
+
563
+ def _record_lines(account: ClaimAccount) -> list[str]:
564
+ """What is to be said of the records of checks themselves: that none is kept here, or
565
+ that one does not verify or the two disagree. Nothing where both are as they should be."""
566
+ lines = []
567
+ if account.no_local_record:
568
+ lines.append(f"no local record is kept here: {account.no_local_record}")
569
+ for note in account.set_aside:
570
+ vouched = f"its last entry that verified hashes to {note.head[:12]}"
571
+ lines.append(
572
+ f"a {note.kind} record of {note.entries} entr{'y' if note.entries == 1 else 'ies'} "
573
+ f"was set aside on {note.at} as {note.kept_as}: reported {note.status}"
574
+ + (f" ({'; '.join(note.problems)})" if note.problems else "")
575
+ + f"; {vouched if note.head.strip('0') else 'none of its entries verified'}"
576
+ )
577
+ for record in account.records:
578
+ said = []
579
+ if record.unverifiable_from is not None or record.status in ("truncated", "corrupt"):
580
+ said.append(f"{record.status.replace('_', ' ')}: {'; '.join(record.problems)}")
581
+ if record.unverifiable_from is not None:
582
+ said.append(f"unverifiable from entry {record.unverifiable_from}")
583
+ if record.diverges_at is not None:
584
+ said.append(f"diverges from the local record at entry {record.diverges_at}")
585
+ if said:
586
+ count = f"{record.entries} entr{'y' if record.entries == 1 else 'ies'}"
587
+ lines.append(f"{record.kind} {record.path}: {count}, {'; '.join(said)}")
588
+ return lines
589
+
590
+
591
+ def _chain_lines(node: ChainNode, account: ClaimAccount, paths: dict[str, str]) -> list[str]:
592
+ """One file the claim rests on: what writes it, the re-run it was compared with, and each
593
+ difference from that re-run."""
594
+ record = node.regeneration
595
+ if record is None:
596
+ if node.validity is Validity.ARTIFACT_ABSENT:
597
+ return ["no command recorded", "nothing at the declared path"]
598
+ return ["no command recorded"]
599
+ lines = [f"written by {record.id}: {shlex.join(node.command)}"]
600
+ reads = [
601
+ _named(edge.target, paths)
602
+ for edge in account.edges
603
+ if edge.kind == "consumes" and edge.source == record.id
604
+ ]
605
+ if reads:
606
+ lines.append(f"which reads {', '.join(reads)}")
607
+ if StaleCause.CYCLE in record.causes:
608
+ lines.append(f"{record.id} is on a cycle of commands, so it is compared with no re-run")
609
+ elif record.rerun_at is None:
610
+ lines.append(f"no re-run of {record.id} that reproduced is recorded")
611
+ else:
612
+ wrote = next(
613
+ edge
614
+ for edge in account.edges
615
+ if edge.kind == "produced_by" and edge.target == record.id
616
+ )
617
+ lines.append(
618
+ f"re-run of {record.rerun_at} reproduced"
619
+ + (f" {record.witness.said}" if record.witness else "")
620
+ + (
621
+ ""
622
+ if wrote.observed != wrote.on_disk
623
+ else ", and its output is what is now on disk outside the fields "
624
+ f"{', '.join(node.volatile)}"
625
+ if node.volatile
626
+ else ", and its output is the bytes now on disk"
627
+ )
628
+ )
629
+ lines += [
630
+ _finding_said(found, paths) for found in account.findings if found.regeneration == record.id
631
+ ]
632
+ if record.upstream:
633
+ lines.append(
634
+ f"upstream {_said(record.state)}: {record.upstream}, {_steps(record.distance)}"
635
+ )
636
+ return lines
637
+
638
+
639
+ def _why_lines(account: ClaimAccount) -> list[str]:
640
+ """The claim, what was computed from the record just now, what the record does not cover,
641
+ and the counts."""
642
+ paths = {node.artifact: node.path for node in account.chain}
643
+ lines = [account.claim_id, *(f" {line}" for line in account.text.splitlines())]
644
+ if account.where:
645
+ lines.append(f" {account.where}")
646
+
647
+ lines += ["", "established"]
648
+ total = len(account.assertions)
649
+ for number, assertion in enumerate(account.assertions, start=1):
650
+ lines.append(f" assertion {number} of {total}: {assertion.kind} {assertion.name}".rstrip())
651
+ lines += [f" {line}" for line in _assertion_lines(assertion, paths)]
652
+ if account.chain:
653
+ lines.append(" chain")
654
+ for node in account.chain:
655
+ where = f", {_steps(node.steps)}" if node.steps else ""
656
+ lines.append(f" {_named(node.artifact, paths)}: {_said(node.state)}{where}")
657
+ lines += [f" {line}" for line in _chain_lines(node, account, paths)]
658
+
659
+ if recorded := _record_lines(account):
660
+ lines.append(" record of checks")
661
+ lines += [f" {line}" for line in recorded]
662
+
663
+ lines += ["", "not established"]
664
+ lines += [
665
+ f" {line.subject}: {line.said}" if line.subject else f" {line.said}"
666
+ for line in account.not_established
667
+ ]
668
+
669
+ outcomes = account.outcomes
670
+ covered = {
671
+ state: n for state, n in account.states.items() if state is not Freshness.NO_PRODUCER
672
+ }
673
+ count = sum(covered.values())
674
+ summary = (
675
+ f" {total} assertion{'' if total == 1 else 's'}: "
676
+ + (", ".join(f"{n} {_said(outcome)}" for outcome, n in outcomes.items()) or "none")
677
+ + f"; {count} of {total} read{'s' if count == 1 else ''} a file with a recorded producer"
678
+ + "".join(f", {n} {_said(state)}" for state, n in covered.items())
679
+ )
680
+ return [*lines, "", summary]
681
+
682
+
683
+ def cmd_why(args: argparse.Namespace) -> int:
684
+ # `repro why repro.yaml` names a manifest, as the first argument of the sibling commands
685
+ # does, and lists its claims.
686
+ if args.manifest is None and args.claim and Path(args.claim).suffix in (".yaml", ".yml"):
687
+ args.manifest, args.claim = args.claim, None
688
+ path = Path(args.manifest) if args.manifest else find()
689
+ if path is None:
690
+ print(f"no {DEFAULT_NAME} here or above.\n")
691
+ print("write one that pins this project's result files, with example claims to edit:\n")
692
+ print(f" {_program(args)} manifest init")
693
+ return 2
694
+ manifest = load(path)
695
+ if args.claim is None:
696
+ ids = [claim.id for claim in manifest.claims]
697
+ print(json.dumps(ids, indent=2) if args.format == "json" else "\n".join(ids))
698
+ return 0
699
+
700
+ account = run_why(manifest, args.claim)
701
+ if args.format == "json":
702
+ print(json.dumps(account.model_dump(mode="json"), indent=2))
703
+ return 0
704
+ print(f"{path}\n")
705
+ print("\n".join(_why_lines(account)))
706
+ return 0
707
+
708
+
709
+ def _count_said(counts: collections.Counter[Standing]) -> str:
710
+ """The four counts, which add up to the number before them."""
711
+ return (
712
+ f"{counts[Standing.BOUND]} bound, {counts[Standing.SET_ASIDE_BY_RULE]} set aside by "
713
+ f"rule, {counts[Standing.SET_ASIDE_WITH_A_REASON]} set aside with a reason, "
714
+ f"{counts[Standing.UNBOUND]} unbound"
715
+ )
716
+
717
+
718
+ def _numbers_lines(report: NumbersReport) -> list[str]:
719
+ """Per text the numbers nothing accounts for, then what was set aside and the counts."""
720
+ lines = []
721
+ for text in report.texts:
722
+ lines.append(f" {_named(text.artifact, {text.artifact: text.path})}")
723
+ unbound = [number for number in text.numbers if number.standing is Standing.UNBOUND]
724
+ width = max((len(number.text) for number in unbound), default=0)
725
+ for number in unbound:
726
+ where = f"line {number.line}" if number.line else f"offset {number.offset}"
727
+ note = " (reported elsewhere)" if number.reported_elsewhere else ""
728
+ lines.append(f" {where:<10} {number.text:<{width}} {number.context}{note}")
729
+ counts = collections.Counter(number.standing for number in text.numbers)
730
+ lines += [f" {len(text.numbers)} numbers: {_count_said(counts)}", ""]
731
+
732
+ if report.rules:
733
+ rules = ", ".join(f"{n} {_said(rule)}" for rule, n in report.rules.items())
734
+ lines.append(f" set aside by rule: {rules}")
735
+ for entry in report.set_aside:
736
+ passage = entry.text if len(entry.text) <= 60 else f"{entry.text[:60]}..."
737
+ lines.append(
738
+ f' set aside with a reason: {entry.numbers} in {entry.artifact} "{passage}": '
739
+ f"{entry.reason}"
740
+ )
741
+ counts = report.counts
742
+ total, texts = len(report.numbers), len(report.texts)
743
+ lines.append(
744
+ f" {total} numbers in {texts} text{'' if texts == 1 else 's'}: {_count_said(counts)}"
745
+ )
746
+ unbound = [number for number in report.numbers if number.standing is Standing.UNBOUND]
747
+ words = sum(number.form is NumberForm.CARDINAL_WORD for number in unbound)
748
+ lines.append(f" {len(unbound)} unbound: {len(unbound) - words} numerals, {words} number words")
749
+ lines.append(
750
+ f" {sum(number.reported_elsewhere for number in unbound)} of the {len(unbound)} "
751
+ "unbound print a value an assertion reports, outside the passage it reports it for; "
752
+ f"{sum(number.reported_elsewhere_one_digit for number in unbound)} more match on one "
753
+ "digit and are not marked"
754
+ )
755
+ return lines
756
+
757
+
758
+ def cmd_numbers(args: argparse.Namespace) -> int:
759
+ path = Path(args.manifest) if args.manifest else find()
760
+ if path is None:
761
+ print(f"no {DEFAULT_NAME} here or above.\n")
762
+ print("write one that pins this project's result files, with example claims to edit:\n")
763
+ print(f" {_program(args)} manifest init")
764
+ return 2
765
+ report = run_numbers(load(path), only=tuple(args.text or ()))
766
+ if args.format == "json":
767
+ print(json.dumps(report.model_dump(mode="json"), indent=2))
768
+ return 0
769
+ print(f"{path}\n")
770
+ print("\n".join(_numbers_lines(report)))
771
+ return 0
772
+
773
+
774
+ def _origin_lines(check: OriginCheck) -> list[str]:
775
+ """One artifact's origin: the state and why, both digests where they differ, and the file
776
+ on disk where it is not the pinned bytes."""
777
+ origin = check.origin
778
+ lines = [
779
+ f" {_said(check.state).upper() if check.state is not OriginState.SAME else 'same':<14} "
780
+ f"{check.artifact_id}: {origin.repository} {origin.commit[:12]}:{origin.path}",
781
+ f" {'':<14} {check.reason}"
782
+ + (f" (read from {check.read_from})" if check.read_from else ""),
783
+ ]
784
+ if check.state is OriginState.DIFFERS:
785
+ lines.append(f" {'':<14} pinned {check.pinned}")
786
+ lines.append(f" {'':<14} at origin {check.at_origin}")
787
+ if check.disk_disagrees:
788
+ found = f"on disk {check.on_disk}" if check.on_disk else "nothing is at the declared path"
789
+ lines.append(
790
+ f" {'':<14} note: {check.path} is not the pinned bytes ({found}); "
791
+ "`verify` reports that, and this compares the pin with the origin"
792
+ )
793
+ return lines
794
+
795
+
796
+ def _origins_exit(report: OriginsReport) -> int:
797
+ """0 where every declared origin holds the pinned bytes, 1 where one does not or lacks
798
+ the path, 2 where something could not be checked and nothing failed."""
799
+ states = {check.state for check in report.origins}
800
+ if states & {OriginState.DIFFERS, OriginState.ABSENT}:
801
+ return 1
802
+ if not states or states != {OriginState.SAME}:
803
+ return 2
804
+ return 0
805
+
806
+
807
+ def cmd_origins(args: argparse.Namespace) -> int:
808
+ path = Path(args.manifest) if args.manifest else find()
809
+ if path is None:
810
+ print(f"no {DEFAULT_NAME} here or above.\n")
811
+ print("write one that pins this project's result files, with example claims to edit:\n")
812
+ print(f" {_program(args)} manifest init")
813
+ return 2
814
+ clones: dict[str, Path] = {}
815
+ for given in args.repo or ():
816
+ repository, sep, clone = given.partition("=")
817
+ if not sep or not repository or not clone:
818
+ print(f"--repo {given}: expected REPOSITORY=PATH, a repository as an origin names it")
819
+ return 2
820
+ clones[repository] = Path(clone).expanduser().resolve()
821
+
822
+ report = run_origins(load(path), clones, fetch_url if args.fetch else None)
823
+ code = _origins_exit(report)
824
+ if args.format == "json":
825
+ print(json.dumps({"origins": report.model_dump(mode="json")}, indent=2))
826
+ return code
827
+
828
+ print(f"{path}\n")
829
+ if not report.origins:
830
+ print(" no artifact declares an origin, so nothing was compared.")
831
+ print(" `origin: {repository, commit, path}` on an artifact names the file it was")
832
+ print(" copied from.")
833
+ return code
834
+ for check in report.origins:
835
+ print("\n".join(_origin_lines(check)))
836
+ counts = report.counts
837
+ print(
838
+ f"\n {len(report.origins)} origins: "
839
+ + ", ".join(f"{counts.get(state.value, 0)} {_said(state)}" for state in OriginState)
840
+ )
841
+ if report.without_origin:
842
+ print(f" {len(report.without_origin)} artifacts declare no origin")
843
+ return code
844
+
845
+
846
+ def cmd_export(args: argparse.Namespace) -> int:
847
+ path = Path(args.manifest) if args.manifest else find()
848
+ if path is None:
849
+ print(f"no {DEFAULT_NAME} here or above.\n")
850
+ print("write one that pins this project's result files, with example claims to edit:\n")
851
+ print(f" {_program(args)} manifest init")
852
+ return 2
853
+ if args.include_matches and args.target != "sarif":
854
+ print(f"--include-matches is an option of `{_program(args)} export sarif`.")
855
+ return 2
856
+ options: dict[str, bool | str] = {"include_matches": True} if args.include_matches else {}
857
+ if args.target == "sarif":
858
+ # The log's help text names the command to run next, as the user's command names it.
859
+ options["program"] = _program(args)
860
+ written = EXPORTS[args.target](check_records(load(path)), **options)
861
+ if args.output:
862
+ target = Path(args.output)
863
+ if args.target == "ro-crate" and (target.is_dir() or target.suffix != ".json"):
864
+ # A crate is a directory holding a file of one fixed name.
865
+ target.mkdir(parents=True, exist_ok=True)
866
+ target = target / METADATA_FILE
867
+ atomic_write(target, written)
868
+ else:
869
+ print(written, end="")
870
+ return 0
871
+
872
+
873
+ def add_manifest(sub, program: str = REPRO) -> None:
874
+ """`manifest init`: write a starter manifest. Added to `repro`'s parser and to `claims`'s, each under its own name."""
875
+ p_manifest = sub.add_parser("manifest", help=f"write a starter {DEFAULT_NAME}")
876
+ p_manifest.set_defaults(func=lambda args: p_manifest.print_help() or 1)
877
+ p_manifest_init = p_manifest.add_subparsers(dest="action").add_parser(
878
+ "init",
879
+ help=f"write a starter {DEFAULT_NAME} for an existing project",
880
+ description=(
881
+ f"Write {DEFAULT_NAME} at the top of the project this is run in: its git root, or "
882
+ "the working directory outside a repository. Each file is pinned by sha256, and "
883
+ "example claims are written for a number and a quotation found in the pinned files, "
884
+ "so the manifest verifies as written. An example with nothing to point at is "
885
+ "written as a comment. An existing manifest is never overwritten."
886
+ ),
887
+ )
888
+ p_manifest_init.add_argument(
889
+ "files",
890
+ nargs="*",
891
+ help=(
892
+ "files to pin, inside the project (default: data files the adapters read under "
893
+ f"{', '.join(f'{name}/' for name in CONVENTIONAL)}, at most {MAX_DISCOVERED}, and "
894
+ "a manuscript when exactly one of paper, manuscript or main .tex/.md/.txt exists)"
895
+ ),
896
+ )
897
+ p_manifest_init.set_defaults(func=cmd_manifest_init, program=program)
898
+
899
+
900
+ def add_verify(sub, program: str = REPRO) -> None:
901
+ """`verify`: check every assertion. Added to `repro`'s parser and to `claims`'s, each under its own name."""
902
+ p_verify = sub.add_parser("verify", help="check every evidence assertion in repro.yaml")
903
+ p_verify.add_argument("manifest", nargs="?", help=f"path to {DEFAULT_NAME}")
904
+ p_verify.add_argument("--policy", choices=sorted(PROFILES), default="publication")
905
+ p_verify.add_argument("--format", choices=("text", "json", "sarif"), default="text")
906
+ p_verify.add_argument(
907
+ "--record",
908
+ action="store_true",
909
+ help=(
910
+ f"also bring the tracked record {CHECKS.as_posix()} up to the local one, which "
911
+ f"every check appends to at {LOCAL_CHECKS.as_posix()} in the git directory"
912
+ ),
913
+ )
914
+ p_verify.add_argument(
915
+ SET_ASIDE,
916
+ action="store_true",
917
+ help=(
918
+ "where the record of checks does not verify, keep it under a name that says when "
919
+ "it was set aside and start a new one whose first entry says so; with --record, "
920
+ "the tracked record as well. Refused where every record verifies"
921
+ ),
922
+ )
923
+ p_verify.set_defaults(func=cmd_verify, program=program)
924
+
925
+
926
+ def add_stale(sub, program: str = REPRO) -> None:
927
+ """`stale`: compare the files with the record of re-runs. Added to `repro`'s parser and to `claims`'s, each under its own name."""
928
+ p_stale = sub.add_parser(
929
+ "stale",
930
+ help="say which claims read a file changed since its recorded re-run",
931
+ description=(
932
+ f"Compare each command declared under `regenerations` in {DEFAULT_NAME} with the "
933
+ f"re-runs {_reproduce(program)} recorded in {RECORD.as_posix()}, and say for "
934
+ "every claim "
935
+ "whether the files it reads are still the ones a re-run that reproduced was made "
936
+ "over. Executes nothing and writes nothing. `current` means consistent with a "
937
+ "recorded re-run, and a file the manifest does not declare as an input is not "
938
+ "compared."
939
+ ),
940
+ )
941
+ p_stale.add_argument("manifest", nargs="?", help=f"path to {DEFAULT_NAME}")
942
+ p_stale.add_argument("--policy", choices=sorted(PROFILES), default="publication")
943
+ p_stale.add_argument("--format", choices=("text", "json"), default="text")
944
+ p_stale.set_defaults(func=cmd_stale, program=program)
945
+
946
+
947
+ def add_why(sub, program: str = REPRO) -> None:
948
+ """`why`: one claim's account. Added to `repro`'s parser and to `claims`'s, each under its own name."""
949
+ p_why = sub.add_parser(
950
+ "why",
951
+ help="say what the record establishes for one claim, and what it does not",
952
+ description=(
953
+ f"For one claim in {DEFAULT_NAME}: each assertion as `{program} verify` evaluates it now, "
954
+ f"each file the claim reads and each file upstream of one as `{program} stale` finds it, "
955
+ "and then what the record does not cover. Executes nothing and writes nothing. Exits "
956
+ f"0 whatever it finds; `{program} verify` and `{program} stale` are the checks that fail. With "
957
+ "no claim, lists the claim ids."
958
+ ),
959
+ )
960
+ p_why.add_argument("claim", nargs="?", help="a claim id (default: list the ids)")
961
+ p_why.add_argument("manifest", nargs="?", help=f"path to {DEFAULT_NAME}")
962
+ p_why.add_argument("--format", choices=("text", "json"), default="text")
963
+ p_why.set_defaults(func=cmd_why, program=program)
964
+
965
+
966
+ def add_numbers(sub, program: str = REPRO) -> None:
967
+ """`numbers`: count the numbers a text prints. Added to `repro`'s parser and to `claims`'s, each under its own name."""
968
+ p_numbers = sub.add_parser(
969
+ "numbers",
970
+ help="count every number the manuscript prints, bound to a claim or not",
971
+ description=(
972
+ f"Read the text the claims in {DEFAULT_NAME} read their sentences from and count "
973
+ "every number in it: bound where an assertion accounts for that occurrence, set "
974
+ "aside by a named rule (a year, a table's number), set aside with a reason where "
975
+ "the manifest lists its passage under `set_aside`, and otherwise unbound. Executes "
976
+ "nothing and writes nothing. Exits 0 whatever it counts."
977
+ ),
978
+ )
979
+ p_numbers.add_argument("manifest", nargs="?", help=f"path to {DEFAULT_NAME}")
980
+ p_numbers.add_argument(
981
+ "--text",
982
+ action="append",
983
+ metavar="ID",
984
+ help=(
985
+ "read this text artifact; repeatable (default: each artifact a `quote` beside "
986
+ "another assertion, or a prose address, reads)"
987
+ ),
988
+ )
989
+ p_numbers.add_argument("--format", choices=("text", "json"), default="text")
990
+ p_numbers.set_defaults(func=cmd_numbers, program=program)
991
+
992
+
993
+ def add_export(sub, program: str = REPRO) -> None:
994
+ """`export`: write the check records in another format. Added to `repro`'s parser and to `claims`'s, each under its own name."""
995
+ p_export = sub.add_parser(
996
+ "export",
997
+ help="write one record per checked assertion, for another program to read",
998
+ description=(
999
+ f"Check every assertion in {DEFAULT_NAME} as `{program} verify` does and write what was "
1000
+ "found in another format. `record` is JSON Lines, one object per assertion: the "
1001
+ "file read and its digests, the address, the value stored there, the value the "
1002
+ "manuscript reports, the comparison and the outcome. Experimental: each record "
1003
+ "names its version. Executes nothing, writes only what it is asked for, and exits "
1004
+ f"0 whatever the outcomes; `{program} verify` is the check that fails. `sarif` is a "
1005
+ "SARIF 2.1.0 log for GitHub code scanning, built from the records: a result for "
1006
+ "each mismatch, not-found and error, and a notification for each assertion that "
1007
+ "was not checked. `ro-crate` is the same records as an RO-Crate 1.3 "
1008
+ f"{METADATA_FILE}, and `-o DIR` writes it into DIR."
1009
+ ),
1010
+ )
1011
+ p_export.add_argument(
1012
+ "--include-matches",
1013
+ action="store_true",
1014
+ help=(
1015
+ "sarif only: also write a `pass` result for each assertion that matched. Not for "
1016
+ "an upload to GitHub, which shows each as a warning alert"
1017
+ ),
1018
+ )
1019
+ p_export.add_argument("target", choices=sorted(EXPORTS))
1020
+ p_export.add_argument("manifest", nargs="?", help=f"path to {DEFAULT_NAME}")
1021
+ p_export.add_argument("--output", "-o", help="write to this file (default: standard output)")
1022
+ p_export.set_defaults(func=cmd_export, program=program)
1023
+
1024
+
1025
+ def add_origins(sub, program: str = REPRO) -> None:
1026
+ """`origins`: compare copied artifacts with their origins. Added to `repro`'s parser and to `claims`'s, each under its own name."""
1027
+ p_origins = sub.add_parser(
1028
+ "origins",
1029
+ help="compare each artifact copied from another repository with its origin",
1030
+ description=(
1031
+ f"For each artifact in {DEFAULT_NAME} that declares `origin: {{repository, commit, "
1032
+ "path}`, read the bytes of that path at that commit and compare their sha256 with "
1033
+ "the artifact's pinned digest. A clone is read with `git cat-file`, so committed "
1034
+ "bytes are compared and never a working tree. Without a clone, a public GitHub "
1035
+ "repository is fetched only with --fetch; otherwise nothing touches the network "
1036
+ "and the origin is `not checked`. States: same, differs, absent (the commit has no "
1037
+ "such path), unknown commit (the clone has no such commit), not checked. Exits 0 "
1038
+ "where every origin is the same, 1 where one differs or is absent, 2 where one "
1039
+ f"could not be checked and none failed. `{program} verify` never reads an origin."
1040
+ ),
1041
+ )
1042
+ p_origins.add_argument("manifest", nargs="?", help=f"path to {DEFAULT_NAME}")
1043
+ p_origins.add_argument(
1044
+ "--repo",
1045
+ action="append",
1046
+ metavar="REPOSITORY=PATH",
1047
+ help=(
1048
+ "read this repository, as an origin names it, from the clone at PATH; repeatable. "
1049
+ "An origin whose repository is a local path is read from there"
1050
+ ),
1051
+ )
1052
+ p_origins.add_argument(
1053
+ "--fetch",
1054
+ action="store_true",
1055
+ help=(
1056
+ "for a GitHub repository no clone is given for, fetch the file from "
1057
+ "raw.githubusercontent.com at the commit (default: no network)"
1058
+ ),
1059
+ )
1060
+ p_origins.add_argument("--format", choices=("text", "json"), default="text")
1061
+ p_origins.set_defaults(func=cmd_origins, program=program)
1062
+
1063
+
1064
+ def main(argv: list[str] | None = None, local_record: bool = True) -> int:
1065
+ """The `claims` command: the commands above, and nothing that runs one of yours."""
1066
+ parser = argparse.ArgumentParser(
1067
+ prog=CLAIMS,
1068
+ description=(
1069
+ "Check what a manuscript states against the files it names: numbers, table cells "
1070
+ "and quotations declared in a manifest, each against a pinned file."
1071
+ ),
1072
+ epilog=(
1073
+ "`claims verify` and `repro verify` are the same command, and so is each of the "
1074
+ "others here. `repro`, from the `reproducible-science` distribution, adds the "
1075
+ "commands that run things: `repro reproduce`, `repro check`, `repro audit` and "
1076
+ "`repro ci`."
1077
+ ),
1078
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1079
+ )
1080
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
1081
+ sub = parser.add_subparsers(dest="command")
1082
+ add_manifest(sub, CLAIMS)
1083
+ add_verify(sub, CLAIMS)
1084
+ add_stale(sub, CLAIMS)
1085
+ add_why(sub, CLAIMS)
1086
+ add_numbers(sub, CLAIMS)
1087
+ add_export(sub, CLAIMS)
1088
+ add_origins(sub, CLAIMS)
1089
+
1090
+ args = parser.parse_args(argv)
1091
+ args.local_record = local_record
1092
+ if not hasattr(args, "func") or args.func is None:
1093
+ parser.print_help()
1094
+ return 1
1095
+ try:
1096
+ return args.func(args)
1097
+ except ReproError as e:
1098
+ print(str(e))
1099
+ return 2
1100
+
1101
+
1102
+ if __name__ == "__main__":
1103
+ raise SystemExit(main())