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.
- claims/__init__.py +132 -0
- claims/__main__.py +5 -0
- claims/adapters/__init__.py +6 -0
- claims/adapters/array.py +212 -0
- claims/adapters/base.py +75 -0
- claims/adapters/cells.py +94 -0
- claims/adapters/columnar.py +188 -0
- claims/adapters/prose.py +261 -0
- claims/adapters/reads.py +47 -0
- claims/adapters/rows.py +116 -0
- claims/adapters/sheet.py +149 -0
- claims/adapters/sqlite.py +73 -0
- claims/adapters/table.py +135 -0
- claims/adapters/tree.py +163 -0
- claims/checks.py +511 -0
- claims/cli.py +1103 -0
- claims/core/__init__.py +9 -0
- claims/core/artifacts.py +121 -0
- claims/core/checks.py +132 -0
- claims/core/claims.py +100 -0
- claims/core/evidence.py +708 -0
- claims/core/manifest.py +349 -0
- claims/core/numbers.py +146 -0
- claims/core/outcomes.py +410 -0
- claims/core/report.py +503 -0
- claims/corpus.py +344 -0
- claims/exceptions.py +106 -0
- claims/export/__init__.py +23 -0
- claims/export/rocrate.py +787 -0
- claims/export/sarif.py +401 -0
- claims/manifest.py +59 -0
- claims/models.py +222 -0
- claims/numbers.py +453 -0
- claims/origins.py +303 -0
- claims/policy.py +369 -0
- claims/provenance.py +42 -0
- claims/record.py +1099 -0
- claims/regression.py +171 -0
- claims/renderers/__init__.py +10 -0
- claims/renderers/sarif.py +294 -0
- claims/reruns.py +111 -0
- claims/resolve.py +116 -0
- claims/stale.py +333 -0
- claims/starter.py +432 -0
- claims/toolchain.py +120 -0
- claims/verify.py +1371 -0
- claims/why.py +398 -0
- claims_cli-0.6.0.dist-info/METADATA +139 -0
- claims_cli-0.6.0.dist-info/RECORD +51 -0
- claims_cli-0.6.0.dist-info/WHEEL +4 -0
- claims_cli-0.6.0.dist-info/entry_points.txt +2 -0
claims/core/__init__.py
ADDED
|
@@ -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
|
+
"""
|
claims/core/artifacts.py
ADDED
|
@@ -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 ''}")
|