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
@@ -0,0 +1,9 @@
1
+ """The evidence contract, one module per concept.
2
+
3
+ Split out of a single 1,000-line `models.py` that held five unrelated groups: the artifacts a
4
+ manifest declares, the evidence a claim offers, the verdict vocabulary, the manifest itself,
5
+ and what a run reports. Each is now its own module.
6
+
7
+ Nothing is re-exported here. `claims.models` is the public path and names every type; importing
8
+ `claims.core.outcomes` directly is for code inside this package that wants one group.
9
+ """
@@ -0,0 +1,121 @@
1
+ """Content addresses, and the files a manifest declares."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import pathlib
7
+ import re
8
+ from typing import Annotated, Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_serializer
11
+
12
+ SCHEMA_VERSION = "repro/1"
13
+
14
+ _SHA256_HEX = r"^[a-f0-9]{64}$"
15
+
16
+ _COMMIT_HEX = re.compile(r"[a-f0-9]{40}")
17
+
18
+
19
+ # --------------------------------------------------------------------------------- artifacts
20
+
21
+
22
+ class Digest(BaseModel):
23
+ """A content address."""
24
+
25
+ model_config = ConfigDict(frozen=True, extra="forbid")
26
+
27
+ algorithm: Literal["sha256"] = "sha256"
28
+ value: Annotated[str, Field(pattern=_SHA256_HEX)]
29
+ """Lowercase hex. The pattern is enforced because a truncated or uppercase digest compares
30
+ unequal to a correct one and reads, in a report, as a tampered file."""
31
+
32
+ def __str__(self) -> str:
33
+ return f"{self.algorithm}:{self.value}"
34
+
35
+ @classmethod
36
+ def of_file(cls, path: pathlib.Path) -> Digest:
37
+ h = hashlib.sha256()
38
+ with path.open("rb") as fh:
39
+ for block in iter(lambda: fh.read(1 << 20), b""):
40
+ h.update(block)
41
+ return cls(value=h.hexdigest())
42
+
43
+ @classmethod
44
+ def of_text(cls, text: str) -> Digest:
45
+ return cls(value=hashlib.sha256(text.encode("utf-8")).hexdigest())
46
+
47
+
48
+ class Origin(BaseModel):
49
+ """The place an artifact's bytes were copied from: one file, at one commit, of one
50
+ repository.
51
+
52
+ An origin names immutable bytes or it names nothing. A branch moves and a short id can
53
+ come to name two commits, so `commit` is the full 40-character id and nothing else. The
54
+ origin is a statement about where the bytes came from; `claims origins` is what checks it,
55
+ and `claims verify` never reads it.
56
+ """
57
+
58
+ model_config = ConfigDict(frozen=True, extra="forbid")
59
+
60
+ repository: str = Field(min_length=1)
61
+ """A URL (https or ssh) or a local path. A relative path is read from the manifest's
62
+ directory."""
63
+
64
+ commit: str
65
+ """The full commit id, 40 lowercase hex characters."""
66
+
67
+ path: str
68
+ """POSIX, relative to the root of that repository."""
69
+
70
+ @field_validator("commit")
71
+ @classmethod
72
+ def _names_a_commit(cls, value: str) -> str:
73
+ if not _COMMIT_HEX.fullmatch(value):
74
+ raise ValueError(
75
+ f"{value!r} is not a full commit id: an origin names one commit by its 40 "
76
+ "lowercase hex characters, and a branch, a tag or a short id can come to name "
77
+ "other bytes"
78
+ )
79
+ return value
80
+
81
+ @field_validator("path")
82
+ @classmethod
83
+ def _inside_the_repository(cls, value: str) -> str:
84
+ parts = pathlib.PurePosixPath(value).parts
85
+ if not value or "\\" in value or value.startswith("/") or ".." in parts:
86
+ raise ValueError(
87
+ f"{value!r} is not a path inside a repository: it is written with `/`, "
88
+ "relative to the repository's root, and has no `..`"
89
+ )
90
+ return value
91
+
92
+
93
+ class ArtifactRef(BaseModel):
94
+ """A file a manifest names, whether or not it has been pinned.
95
+
96
+ Separate from `PinnedArtifact` because "identified by content" and "may carry no digest"
97
+ cannot both be true of one type. An unpinned reference is a legitimate thing to write down
98
+ and an illegitimate thing to verify against, and the type says which it is.
99
+ """
100
+
101
+ model_config = ConfigDict(frozen=True, extra="forbid")
102
+
103
+ id: str
104
+ path: pathlib.Path
105
+ digest: Digest | None = None
106
+ media_type: str = "application/octet-stream"
107
+ origin: Origin | None = None
108
+ """Where the bytes were copied from, where the manifest says."""
109
+
110
+ @model_serializer(mode="wrap")
111
+ def _omit_no_origin(self, handler):
112
+ """Leave the field out where there is none, so a manifest that declares no origin
113
+ dumps, and so digests, as it did before the field existed."""
114
+ dumped = handler(self)
115
+ if self.origin is None:
116
+ dumped.pop("origin", None)
117
+ return dumped
118
+
119
+ @property
120
+ def is_pinned(self) -> bool:
121
+ return self.digest is not None
claims/core/checks.py ADDED
@@ -0,0 +1,132 @@
1
+ """What an assertion said each time it was checked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+ from claims.core.outcomes import Outcome, Reason
10
+
11
+ SCHEMA_VERSION = "repro/1"
12
+
13
+
14
+ class CheckedAssertion(BaseModel):
15
+ """One assertion as `repro verify` found it: what it declared, the bytes it was checked
16
+ against, and the outcome.
17
+
18
+ An assertion is the same assertion across edits where `claim_id`, `kind`, `name` and
19
+ `position` are the same. A claim given another id, an assertion given another name, and
20
+ two assertions of one kind and name that trade places each start a new history.
21
+ """
22
+
23
+ model_config = ConfigDict(frozen=True, extra="forbid")
24
+
25
+ claim_id: str
26
+ kind: str
27
+ name: str = ""
28
+ position: int = 0
29
+ """Which of the claim's assertions of this kind and name it is, from 0. A `quote` has no
30
+ name, so two quotations in one claim are told apart by this alone."""
31
+
32
+ claim_digest: str
33
+ """`Claim.digest`: the claim's id, text and `where` as they were."""
34
+ printed: str | None = None
35
+ """The `reported` value, or the passage a quotation asserts."""
36
+ address: str = ""
37
+ """Where the assertion looked. Both sides of a `correspondence`, each with its name."""
38
+ conversion: str = ""
39
+ """The conversion declared between the stored value and the printed one, where there is
40
+ one. Each side's, with its name, for a `correspondence`."""
41
+ digests: dict[str, str | None] = {}
42
+ """Per artifact the assertion read, the digest of the bytes read. None where nothing was
43
+ at the declared path."""
44
+ outcome: Outcome
45
+ reason: Reason
46
+
47
+ checked_at: str = ""
48
+ """When the check that first found the assertion like this appended it. Read from the
49
+ entry that holds it and not stored on the assertion."""
50
+ verifiable: bool = True
51
+ """False where the record this was read from does not verify at or before the entry
52
+ holding it. The entry is shown as it reads; the chain no longer vouches for it."""
53
+
54
+ @property
55
+ def identity(self) -> tuple[str, str, str, int]:
56
+ return self.claim_id, self.kind, self.name, self.position
57
+
58
+ def same_as(self, other: CheckedAssertion) -> bool:
59
+ """Whether two checks found the assertion saying the same thing with the same outcome,
60
+ whenever they ran."""
61
+ unset = {"checked_at": "", "verifiable": True}
62
+ return self.model_copy(update=unset) == other.model_copy(update=unset)
63
+
64
+
65
+ class RecordSetAside(BaseModel):
66
+ """A record of checks that was moved aside, as the entry that follows it says.
67
+
68
+ Written by `repro verify --set-aside-record` and by nothing else, as the first entry of
69
+ the chain that replaces a local record, so the break stays on the record.
70
+ """
71
+
72
+ model_config = ConfigDict(frozen=True, extra="forbid")
73
+
74
+ kind: Literal["local", "tracked"]
75
+ kept_as: str
76
+ """The name the record now has, beside where it was. Its head file is kept the same way."""
77
+ entries: int
78
+ """How many entries it held."""
79
+ head: str
80
+ """The SHA-256 of its last entry the chain still vouched for, as stored. The digest that
81
+ addresses nothing where it vouched for none."""
82
+ status: str
83
+ """What its chain was reported as: the `results` ledger's word."""
84
+ problems: tuple[str, ...] = ()
85
+ """Each fault found in it, as `repro why` listed them."""
86
+ at: str = ""
87
+ """When it was set aside. Read from the entry that says so and not stored in it."""
88
+
89
+
90
+ class CheckRecord(BaseModel):
91
+ """One entry of a record of checks: the assertions one `repro verify` found different from
92
+ the last time each was recorded.
93
+
94
+ The last three fields are the chain's, under the names the `results` ledger gives them:
95
+ the entry's position from 0, when it was appended, and the SHA-256 of the entry before it
96
+ as stored, or of nothing for the first.
97
+ """
98
+
99
+ model_config = ConfigDict(frozen=True, extra="forbid")
100
+
101
+ schema_version: str = SCHEMA_VERSION
102
+ assertions: tuple[CheckedAssertion, ...]
103
+ set_aside: RecordSetAside | None = None
104
+ """On an entry `repro verify --set-aside-record` wrote, and on no other, where the key is
105
+ absent: the record that was moved aside before this entry."""
106
+ seq: int
107
+ timestamp: str
108
+ prev_hash: str
109
+
110
+
111
+ class RecordState(BaseModel):
112
+ """One record of checks as `repro why` found it: where it is and whether its chain holds."""
113
+
114
+ model_config = ConfigDict(frozen=True, extra="forbid")
115
+
116
+ kind: Literal["local", "tracked"]
117
+ """`local` is the one in the repository's git directory, written by every `repro verify`
118
+ and never committed. `tracked` is `.repro/checks.jsonl` in the working tree, written
119
+ where `repro verify --record` asks."""
120
+ path: str
121
+ entries: int
122
+ status: str
123
+ """The `results` ledger's word for a chain: `intact`, `edited`, `reordered`, `corrupt`,
124
+ `truncated`, `extended` or `no_anchor`."""
125
+ problems: tuple[str, ...] = ()
126
+ """Each fault found, naming its entry by its line in the file, from 1."""
127
+ unverifiable_from: int | None = None
128
+ """The first entry the chain does not vouch for. None where every entry present follows
129
+ from the one before it."""
130
+ diverges_at: int | None = None
131
+ """Of the tracked record: the first entry that is not the local record's entry at that
132
+ position. None where one record is the other's beginning."""
claims/core/claims.py ADDED
@@ -0,0 +1,100 @@
1
+ """A statement a manuscript makes, and whether it was registered before the run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+
7
+ from pydantic import BaseModel, ConfigDict, model_validator
8
+
9
+ from claims.core.artifacts import Digest
10
+ from claims.core.evidence import Evidence
11
+
12
+ SCHEMA_VERSION = "repro/1"
13
+
14
+ _SHA256_HEX = r"^[a-f0-9]{64}$"
15
+
16
+
17
+ # ------------------------------------------------------------------------------------ claims
18
+
19
+
20
+ class Availability(enum.StrEnum):
21
+ """Whether a claim offers anything to check. A property of the claim, not of a check."""
22
+
23
+ OFFERED = "offered"
24
+ NOT_OFFERED = "not_offered"
25
+
26
+
27
+ class Registration(enum.StrEnum):
28
+ """Whether a plan could have fixed this claim's outcome in advance.
29
+
30
+ Three states, because two conflate different things. A descriptive measurement that was
31
+ never registered and an exhaustive count that nothing could have registered look identical
32
+ under a boolean and are not the same claim.
33
+ """
34
+
35
+ CONFIRMATORY = "confirmatory"
36
+ """Reports a preregistered outcome. The ordering check applies."""
37
+ EXPLORATORY = "exploratory"
38
+ """An outcome that a plan could have fixed and did not. The default: a claim that says
39
+ nothing about registration is exploratory, never inapplicable."""
40
+ NOT_APPLICABLE = "not_applicable"
41
+ """No outcome was selected, so registration has nothing to bind -- an exhaustive
42
+ deterministic measurement reports whatever it finds. Must carry a reason: this state is
43
+ asserted, never assumed, or it is a way for any claim to opt out of being graded."""
44
+
45
+
46
+ class Claim(BaseModel):
47
+ """A statement made in a manuscript, and the evidence offered for it."""
48
+
49
+ model_config = ConfigDict(frozen=True, extra="forbid")
50
+
51
+ id: str
52
+ text: str
53
+ where: str | None = None
54
+ registration: Registration = Registration.EXPLORATORY
55
+ registration_note: str = ""
56
+ """Why registration does not apply. Required when `registration` is `not_applicable`, and
57
+ the same convention a preregistration uses for an inapplicable heading: N/A with a reason,
58
+ never a blank."""
59
+
60
+ evidence: tuple[Evidence, ...] = ()
61
+
62
+ @model_validator(mode="before")
63
+ @classmethod
64
+ def _accept_confirmatory_flag(cls, data):
65
+ """`confirmatory: true|false` still parses, so manifests written against the boolean
66
+ keep working. `false` becomes `exploratory` and not `not_applicable`: a boolean never
67
+ carried the distinction, so reading one in cannot invent it."""
68
+ if isinstance(data, dict) and "confirmatory" in data:
69
+ data = dict(data)
70
+ flag = data.pop("confirmatory")
71
+ data.setdefault(
72
+ "registration", Registration.CONFIRMATORY if flag else Registration.EXPLORATORY
73
+ )
74
+ return data
75
+
76
+ @model_validator(mode="after")
77
+ def _not_applicable_needs_a_reason(self):
78
+ if self.registration is Registration.NOT_APPLICABLE and not self.registration_note:
79
+ raise ValueError(
80
+ f"claim {self.id!r}: registration 'not_applicable' needs a registration_note "
81
+ f"saying why no plan could have fixed this outcome"
82
+ )
83
+ return self
84
+
85
+ @property
86
+ def confirmatory(self) -> bool:
87
+ return self.registration is Registration.CONFIRMATORY
88
+
89
+ @property
90
+ def availability(self) -> Availability:
91
+ return Availability.OFFERED if self.evidence else Availability.NOT_OFFERED
92
+
93
+ @property
94
+ def digest(self) -> Digest:
95
+ """Content address of the claim as evaluated.
96
+
97
+ A decision names this so it cannot silently remain attached to text that has since
98
+ been edited.
99
+ """
100
+ return Digest.of_text(f"{self.id}\x00{self.text}\x00{self.where or ''}")