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/__init__.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""One contract for verifying declared evidence assertions against pinned artifacts.
|
|
2
|
+
|
|
3
|
+
A quotation in a manuscript and a number in a results file are the same kind of object: a
|
|
4
|
+
typed value asserted to be extractable from a pinned artifact. This package verifies that
|
|
5
|
+
assertion -- and nothing beyond it. Whether the evidence *supports* the sentence citing it is
|
|
6
|
+
a question about entailment, addressed elsewhere with model-based methods.
|
|
7
|
+
|
|
8
|
+
from claims import load, verify
|
|
9
|
+
from claims.policy import PUBLICATION
|
|
10
|
+
|
|
11
|
+
report = verify(load("repro.yaml")) # facts
|
|
12
|
+
verdict = PUBLICATION.assess(report) # judgment
|
|
13
|
+
|
|
14
|
+
Facts and judgment are separate calls because a missing optional citation is acceptable in a
|
|
15
|
+
draft and not in a submission, and one project's standard should not be everyone's.
|
|
16
|
+
|
|
17
|
+
Nothing here prints, exits, or reads global state.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from importlib.metadata import PackageNotFoundError
|
|
21
|
+
from importlib.metadata import version as _version
|
|
22
|
+
|
|
23
|
+
from claims.exceptions import (
|
|
24
|
+
ArtifactMissingError,
|
|
25
|
+
ArtifactUnreadableError,
|
|
26
|
+
BackendUnavailableError,
|
|
27
|
+
DigestMismatchError,
|
|
28
|
+
ManifestError,
|
|
29
|
+
ReproError,
|
|
30
|
+
UnknownEvidenceKindError,
|
|
31
|
+
)
|
|
32
|
+
from claims.manifest import DEFAULT_NAME, find, load
|
|
33
|
+
from claims.models import (
|
|
34
|
+
SCHEMA_VERSION,
|
|
35
|
+
ArtifactRef,
|
|
36
|
+
ArtifactState,
|
|
37
|
+
Availability,
|
|
38
|
+
Claim,
|
|
39
|
+
ClaimAssessment,
|
|
40
|
+
ComparisonMode,
|
|
41
|
+
ComparisonStatus,
|
|
42
|
+
Decision,
|
|
43
|
+
Digest,
|
|
44
|
+
Evidence,
|
|
45
|
+
ExecutionStatus,
|
|
46
|
+
ExtractionStatus,
|
|
47
|
+
Manifest,
|
|
48
|
+
MetricEvidence,
|
|
49
|
+
Outcome,
|
|
50
|
+
QuoteEvidence,
|
|
51
|
+
Reason,
|
|
52
|
+
TableCellEvidence,
|
|
53
|
+
Validity,
|
|
54
|
+
VerificationReport,
|
|
55
|
+
Warning_,
|
|
56
|
+
)
|
|
57
|
+
from claims.policy import EXPLORATORY, PROFILES, PUBLICATION, STRICT, Assessment, Policy, Severity
|
|
58
|
+
from claims.renderers import to_sarif
|
|
59
|
+
from claims.resolve import Resolution, read_table, resolve, resolve_pointer, sniff_delimiter
|
|
60
|
+
from claims.toolchain import ALONE
|
|
61
|
+
from claims.verify import (
|
|
62
|
+
DEFAULT_BACKENDS,
|
|
63
|
+
Backend,
|
|
64
|
+
MetricBackend,
|
|
65
|
+
QuoteBackend,
|
|
66
|
+
TableBackend,
|
|
67
|
+
ValueBackend,
|
|
68
|
+
verify,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# This package's own version, `claims-cli`'s, whatever else is installed: what `claims
|
|
72
|
+
# --version` prints and what a SARIF log names as its driver's version.
|
|
73
|
+
try:
|
|
74
|
+
__version__ = _version(ALONE)
|
|
75
|
+
except PackageNotFoundError: # a source tree with nothing installed
|
|
76
|
+
__version__ = "0+unknown"
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
"DEFAULT_BACKENDS",
|
|
80
|
+
"DEFAULT_NAME",
|
|
81
|
+
"EXPLORATORY",
|
|
82
|
+
"PROFILES",
|
|
83
|
+
"PUBLICATION",
|
|
84
|
+
"SCHEMA_VERSION",
|
|
85
|
+
"STRICT",
|
|
86
|
+
"ArtifactMissingError",
|
|
87
|
+
"ArtifactRef",
|
|
88
|
+
"ArtifactState",
|
|
89
|
+
"ArtifactUnreadableError",
|
|
90
|
+
"Assessment",
|
|
91
|
+
"Availability",
|
|
92
|
+
"Backend",
|
|
93
|
+
"BackendUnavailableError",
|
|
94
|
+
"Claim",
|
|
95
|
+
"ClaimAssessment",
|
|
96
|
+
"ComparisonMode",
|
|
97
|
+
"ComparisonStatus",
|
|
98
|
+
"Decision",
|
|
99
|
+
"Digest",
|
|
100
|
+
"DigestMismatchError",
|
|
101
|
+
"Evidence",
|
|
102
|
+
"ExecutionStatus",
|
|
103
|
+
"ExtractionStatus",
|
|
104
|
+
"Manifest",
|
|
105
|
+
"ManifestError",
|
|
106
|
+
"MetricBackend",
|
|
107
|
+
"MetricEvidence",
|
|
108
|
+
"Outcome",
|
|
109
|
+
"Policy",
|
|
110
|
+
"QuoteBackend",
|
|
111
|
+
"QuoteEvidence",
|
|
112
|
+
"Reason",
|
|
113
|
+
"ReproError",
|
|
114
|
+
"Resolution",
|
|
115
|
+
"Severity",
|
|
116
|
+
"TableBackend",
|
|
117
|
+
"TableCellEvidence",
|
|
118
|
+
"UnknownEvidenceKindError",
|
|
119
|
+
"Validity",
|
|
120
|
+
"ValueBackend",
|
|
121
|
+
"VerificationReport",
|
|
122
|
+
"Warning_",
|
|
123
|
+
"__version__",
|
|
124
|
+
"find",
|
|
125
|
+
"load",
|
|
126
|
+
"read_table",
|
|
127
|
+
"resolve",
|
|
128
|
+
"resolve_pointer",
|
|
129
|
+
"sniff_delimiter",
|
|
130
|
+
"to_sarif",
|
|
131
|
+
"verify",
|
|
132
|
+
]
|
claims/__main__.py
ADDED
claims/adapters/array.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Arrays, addressed by array name and index: NumPy `.npy` and `.npz`, HDF5, and NetCDF.
|
|
2
|
+
|
|
3
|
+
An HDF5 or NetCDF file holds many arrays under paths, as an `.npz` holds them under names, so
|
|
4
|
+
the locator is the same: `array` is the dataset's path and `index` the element. Each reader is
|
|
5
|
+
an optional extra (`reproducible-science[arrays]`, `[hdf5]`, `[netcdf]`), and a missing one is
|
|
6
|
+
`unchecked` with the extra named.
|
|
7
|
+
|
|
8
|
+
NetCDF is read through `netCDF4` rather than as the HDF5 file a NetCDF-4 file also is. A
|
|
9
|
+
NetCDF variable is often stored packed -- integers with a `scale_factor` and an `add_offset`
|
|
10
|
+
-- and its fill value marks an element that holds nothing. Read as plain HDF5, the packed
|
|
11
|
+
integer would be compared with the manuscript and the fill value would be a number. The value
|
|
12
|
+
of a NetCDF variable is the one a NetCDF reader reports, unpacked, and a filled element is
|
|
13
|
+
absent.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import pathlib
|
|
19
|
+
|
|
20
|
+
from claims.adapters.base import Found, Resolution, _no, _ok
|
|
21
|
+
from claims.exceptions import ArtifactUnreadableError, BackendUnavailableError
|
|
22
|
+
from claims.models import (
|
|
23
|
+
ArrayLocator,
|
|
24
|
+
)
|
|
25
|
+
from claims.toolchain import DISTRIBUTION
|
|
26
|
+
|
|
27
|
+
try: # optional: `pip install "reproducible-science[arrays]"`
|
|
28
|
+
import numpy
|
|
29
|
+
except ImportError: # pragma: no cover - exercised by the extras matrix, not the suite
|
|
30
|
+
numpy = None
|
|
31
|
+
|
|
32
|
+
try: # optional: `pip install "reproducible-science[hdf5]"`
|
|
33
|
+
import h5py
|
|
34
|
+
except ImportError: # pragma: no cover
|
|
35
|
+
h5py = None
|
|
36
|
+
|
|
37
|
+
try: # optional: `pip install "reproducible-science[netcdf]"`
|
|
38
|
+
import netCDF4
|
|
39
|
+
except ImportError: # pragma: no cover
|
|
40
|
+
netCDF4 = None
|
|
41
|
+
|
|
42
|
+
# ----------------------------------------------------------------------------------- arrays
|
|
43
|
+
|
|
44
|
+
_NUMPY_SUFFIXES = {".npy", ".npz"}
|
|
45
|
+
_HDF5_SUFFIXES = {".h5", ".hdf5"}
|
|
46
|
+
_NETCDF_SUFFIXES = {".nc"}
|
|
47
|
+
_ARRAY_SUFFIXES = _NUMPY_SUFFIXES | _HDF5_SUFFIXES | _NETCDF_SUFFIXES
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_array(locator: ArrayLocator, path: pathlib.Path) -> Found:
|
|
51
|
+
suffix = path.suffix.lower()
|
|
52
|
+
if suffix not in _ARRAY_SUFFIXES:
|
|
53
|
+
return _no(
|
|
54
|
+
Resolution.FORMAT_UNSUPPORTED,
|
|
55
|
+
f"an array locator addresses .npy, .npz, HDF5 or NetCDF; {path.name} is {path.suffix}",
|
|
56
|
+
)
|
|
57
|
+
if suffix in _HDF5_SUFFIXES:
|
|
58
|
+
return _resolve_hdf5(locator, path)
|
|
59
|
+
if suffix in _NETCDF_SUFFIXES:
|
|
60
|
+
return _resolve_netcdf(locator, path)
|
|
61
|
+
if numpy is None:
|
|
62
|
+
raise BackendUnavailableError(
|
|
63
|
+
"array", f'numpy is not installed -- pip install "{DISTRIBUTION}[arrays]"'
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
loaded = numpy.load(path, allow_pickle=False)
|
|
68
|
+
except (OSError, ValueError) as e:
|
|
69
|
+
raise ArtifactUnreadableError(path, str(e)) from e
|
|
70
|
+
|
|
71
|
+
if suffix == ".npz":
|
|
72
|
+
if locator.array is None:
|
|
73
|
+
return _no(Resolution.SELECTOR_INVALID, f"{path.name} holds several arrays; name one")
|
|
74
|
+
if locator.array not in loaded.files:
|
|
75
|
+
return _no(
|
|
76
|
+
Resolution.SELECTOR_INVALID,
|
|
77
|
+
f"{path.name} has no array {locator.array!r}; arrays are "
|
|
78
|
+
f"{', '.join(loaded.files[:8])}",
|
|
79
|
+
)
|
|
80
|
+
array = loaded[locator.array]
|
|
81
|
+
else:
|
|
82
|
+
array = loaded
|
|
83
|
+
|
|
84
|
+
if failure := _index_faults(locator.index, tuple(array.shape)):
|
|
85
|
+
return failure
|
|
86
|
+
return _element(array[locator.index], locator.array or path.stem, locator.index)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _index_faults(index: tuple[int, ...], shape: tuple[int, ...]) -> Found | None:
|
|
90
|
+
"""What is wrong with an index for an array of this shape, or None."""
|
|
91
|
+
if len(index) != len(shape):
|
|
92
|
+
return _no(
|
|
93
|
+
Resolution.SELECTOR_INVALID,
|
|
94
|
+
f"array has {len(shape)} dimensions; index gives {len(index)}",
|
|
95
|
+
)
|
|
96
|
+
if any(i < 0 for i in index):
|
|
97
|
+
# A negative index resolves from the end in Python, so `-1` silently addressed the
|
|
98
|
+
# last element and `-99` raised out of the adapter as a backend defect. Neither is an
|
|
99
|
+
# address; the same condition on the upper side is a clean `absent`.
|
|
100
|
+
return _no(
|
|
101
|
+
Resolution.SELECTOR_INVALID,
|
|
102
|
+
f"index {index} is negative; an address is not relative to the end",
|
|
103
|
+
)
|
|
104
|
+
if any(i >= n for i, n in zip(index, shape, strict=True)):
|
|
105
|
+
return _no(Resolution.ABSENT, f"index {index} is outside shape {shape}")
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _element(value, name: str, index: tuple[int, ...]) -> Found:
|
|
110
|
+
"""One indexed element as a resolution, refusing anything that is not one scalar."""
|
|
111
|
+
if value.ndim:
|
|
112
|
+
return _no(Resolution.NOT_SCALAR, f"index resolves to a {value.ndim}-d slice")
|
|
113
|
+
if value.dtype.kind == "V":
|
|
114
|
+
# `ndim` does not settle this. The rank guard forces a full index, so every element is
|
|
115
|
+
# 0-d -- including one of a structured or subarray dtype, which indexes to a
|
|
116
|
+
# `numpy.void` holding several fields. That stringified as `(0.91, 0.02)` and was
|
|
117
|
+
# returned as one resolved value, against the invariant this adapter exists to hold.
|
|
118
|
+
held = ", ".join(value.dtype.names) if value.dtype.names else f"{value.itemsize} bytes"
|
|
119
|
+
return _no(Resolution.NOT_SCALAR, f"index resolves to a record holding {held}")
|
|
120
|
+
if value.dtype.kind in "SO":
|
|
121
|
+
# `str(numpy.bytes_(b"0.91"))` is `b'0.91'`, which is not the text the file holds. HDF5
|
|
122
|
+
# stores strings as bytes, fixed-length or variable, and these reach here as either.
|
|
123
|
+
item = value.item()
|
|
124
|
+
text = item.decode("utf-8", errors="replace") if isinstance(item, bytes) else str(item)
|
|
125
|
+
return _ok(text, type(item).__name__, f"{name}{list(index)}")
|
|
126
|
+
return _ok(str(value), str(value.dtype), f"{name}{list(index)}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------------------------- hdf5
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _members(group) -> str:
|
|
133
|
+
return ", ".join(list(group.keys())[:8]) or "nothing"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _resolve_hdf5(locator: ArrayLocator, path: pathlib.Path) -> Found:
|
|
137
|
+
if h5py is None or numpy is None:
|
|
138
|
+
raise BackendUnavailableError(
|
|
139
|
+
"array", f'h5py is not installed -- pip install "{DISTRIBUTION}[hdf5]"'
|
|
140
|
+
)
|
|
141
|
+
if locator.array is None:
|
|
142
|
+
return _no(Resolution.SELECTOR_INVALID, f"{path.name} holds datasets by path; name one")
|
|
143
|
+
try:
|
|
144
|
+
handle = h5py.File(path, "r")
|
|
145
|
+
except OSError as e:
|
|
146
|
+
raise ArtifactUnreadableError(path, f"not a readable HDF5 file: {e}") from e
|
|
147
|
+
with handle:
|
|
148
|
+
# `get` rather than indexing: a dangling soft or external link is a name that leads
|
|
149
|
+
# nowhere, which for a locator is the same fact as a name that is not there.
|
|
150
|
+
node = handle.get(locator.array)
|
|
151
|
+
if node is None:
|
|
152
|
+
return _no(
|
|
153
|
+
Resolution.SELECTOR_INVALID,
|
|
154
|
+
f"{path.name} has no dataset {locator.array!r}; the root holds {_members(handle)}",
|
|
155
|
+
)
|
|
156
|
+
if not isinstance(node, h5py.Dataset):
|
|
157
|
+
return _no(
|
|
158
|
+
Resolution.SELECTOR_INVALID,
|
|
159
|
+
f"{locator.array!r} in {path.name} is a group, holding {_members(node)}",
|
|
160
|
+
)
|
|
161
|
+
if node.shape is None:
|
|
162
|
+
return _no(Resolution.ABSENT, f"{locator.array!r} in {path.name} holds no data")
|
|
163
|
+
if failure := _index_faults(locator.index, tuple(node.shape)):
|
|
164
|
+
return failure
|
|
165
|
+
try:
|
|
166
|
+
# Reads the one element, not the dataset: a results file can be gigabytes.
|
|
167
|
+
value = node[locator.index]
|
|
168
|
+
except OSError as e:
|
|
169
|
+
# A compression filter this build of HDF5 lacks fails here, not on open.
|
|
170
|
+
raise ArtifactUnreadableError(path, f"{locator.array!r} cannot be read: {e}") from e
|
|
171
|
+
return _element(numpy.asarray(value), locator.array, locator.index)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ----------------------------------------------------------------------------------- netcdf
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolve_netcdf(locator: ArrayLocator, path: pathlib.Path) -> Found:
|
|
178
|
+
if netCDF4 is None or numpy is None:
|
|
179
|
+
raise BackendUnavailableError(
|
|
180
|
+
"array", f'netCDF4 is not installed -- pip install "{DISTRIBUTION}[netcdf]"'
|
|
181
|
+
)
|
|
182
|
+
if locator.array is None:
|
|
183
|
+
return _no(Resolution.SELECTOR_INVALID, f"{path.name} holds variables by path; name one")
|
|
184
|
+
try:
|
|
185
|
+
dataset = netCDF4.Dataset(path, "r")
|
|
186
|
+
except OSError as e:
|
|
187
|
+
raise ArtifactUnreadableError(path, f"not a readable NetCDF file: {e}") from e
|
|
188
|
+
try:
|
|
189
|
+
try:
|
|
190
|
+
node = dataset[locator.array]
|
|
191
|
+
except IndexError:
|
|
192
|
+
return _no(
|
|
193
|
+
Resolution.SELECTOR_INVALID,
|
|
194
|
+
f"{path.name} has no variable {locator.array!r}; the root holds "
|
|
195
|
+
f"{', '.join([*dataset.variables, *dataset.groups][:8]) or 'nothing'}",
|
|
196
|
+
)
|
|
197
|
+
if not isinstance(node, netCDF4.Variable):
|
|
198
|
+
return _no(
|
|
199
|
+
Resolution.SELECTOR_INVALID,
|
|
200
|
+
f"{locator.array!r} in {path.name} is a group, not a variable",
|
|
201
|
+
)
|
|
202
|
+
if failure := _index_faults(locator.index, tuple(node.shape)):
|
|
203
|
+
return failure
|
|
204
|
+
value = node[locator.index]
|
|
205
|
+
finally:
|
|
206
|
+
dataset.close()
|
|
207
|
+
if numpy.ma.is_masked(value):
|
|
208
|
+
return _no(
|
|
209
|
+
Resolution.ABSENT,
|
|
210
|
+
f"{locator.array}{list(locator.index)} in {path.name} holds the fill value",
|
|
211
|
+
)
|
|
212
|
+
return _element(numpy.asarray(numpy.ma.getdata(value)), locator.array, locator.index)
|
claims/adapters/base.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The contract every format adapter returns.
|
|
2
|
+
|
|
3
|
+
Split from `claims.resolve` so an adapter can import it without importing the dispatcher
|
|
4
|
+
that imports the adapter. Original module docstring follows.
|
|
5
|
+
|
|
6
|
+
Resolving a locator to exactly one stored value.
|
|
7
|
+
|
|
8
|
+
One contract over several formats, rather than one pointer syntax pretending every file is a
|
|
9
|
+
tree. Each adapter translates a locator into the addressing its format already has -- a JSON
|
|
10
|
+
Pointer into JSON, a column and key predicate into a table, a dataset path and index into an
|
|
11
|
+
array -- and every adapter enforces the same invariant:
|
|
12
|
+
|
|
13
|
+
0 matches -> ABSENT
|
|
14
|
+
1 scalar -> RESOLVED
|
|
15
|
+
2 or more -> AMBIGUOUS
|
|
16
|
+
a container -> NOT_SCALAR
|
|
17
|
+
|
|
18
|
+
No adapter takes the first match, and no adapter falls back to searching a file for the
|
|
19
|
+
printed number. A search would find the number wherever it appears and call that
|
|
20
|
+
verification, which is the failure the whole package exists to prevent. A format with no
|
|
21
|
+
adapter reports `FORMAT_UNSUPPORTED` and stops.
|
|
22
|
+
|
|
23
|
+
**Values come back as text.** The original representation is preserved rather than parsed to
|
|
24
|
+
a binary float, because converting `0.1` to a float and back reintroduces exactly the
|
|
25
|
+
third-significant-figure disagreements the comparison is meant to measure.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import enum
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel, ConfigDict
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Resolution(enum.StrEnum):
|
|
36
|
+
"""What a locator found."""
|
|
37
|
+
|
|
38
|
+
RESOLVED = "resolved"
|
|
39
|
+
ABSENT = "absent"
|
|
40
|
+
AMBIGUOUS = "ambiguous"
|
|
41
|
+
NOT_SCALAR = "not_scalar"
|
|
42
|
+
COLUMN_ABSENT = "column_absent"
|
|
43
|
+
"""The artifact has no such column or field. The value could be there and is not."""
|
|
44
|
+
SELECTOR_INVALID = "selector_invalid"
|
|
45
|
+
"""The locator names a column, field or array the artifact does not have. The manifest is
|
|
46
|
+
wrong, which is a different fact from the artifact lacking the value."""
|
|
47
|
+
PASSAGE_AMBIGUOUS = "passage_ambiguous"
|
|
48
|
+
"""One pair of anchors selected two different values in one document. A table reports an
|
|
49
|
+
ambiguous row for the same reason, in the vocabulary a table has."""
|
|
50
|
+
NUMBER_AS_WORD = "number_as_word"
|
|
51
|
+
"""The locator selected an English cardinal written out. The value is there and is not a
|
|
52
|
+
decimal, and converting it is a semantic decision the manifest has to ask for."""
|
|
53
|
+
FORMAT_UNSUPPORTED = "format_unsupported"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ExtractedValue(BaseModel):
|
|
57
|
+
"""One stored value, in the representation the artifact used."""
|
|
58
|
+
|
|
59
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
60
|
+
|
|
61
|
+
raw: str
|
|
62
|
+
native_type: str
|
|
63
|
+
trace: tuple[str, ...] = ()
|
|
64
|
+
"""How the value was reached, for a reader reconstructing the address by hand."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
Found = tuple[Resolution, ExtractedValue | None, str]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ok(raw: str, native: str, *trace: str) -> Found:
|
|
71
|
+
return Resolution.RESOLVED, ExtractedValue(raw=raw, native_type=native, trace=tuple(trace)), ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _no(resolution: Resolution, detail: str) -> Found:
|
|
75
|
+
return resolution, None, detail
|
claims/adapters/cells.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""A typed cell as the text a delimited cell would hold.
|
|
2
|
+
|
|
3
|
+
A delimited file stores text, so the table adapter compares text and returns text. A workbook,
|
|
4
|
+
a Parquet file or a Stata file stores a typed value, and the row predicate and the comparison
|
|
5
|
+
both need it as text in one convention, or the same table exported two ways would resolve
|
|
6
|
+
differently. The convention is the shortest text that reads back as the stored value:
|
|
7
|
+
|
|
8
|
+
float the shortest decimal that round-trips, `0.1` and never `0.1000000000000000055`
|
|
9
|
+
integral `2` rather than `2.0`, so `where: {seed: 2}` selects a row SPSS stored as 2.0
|
|
10
|
+
boolean `true` / `false`, which is what `predicate_text` writes for a boolean predicate
|
|
11
|
+
missing None, which a value cell reports as absent and a key cell never matches
|
|
12
|
+
|
|
13
|
+
Nothing here rounds. A stored 0.6478999999999999 comes back as that, and the comparison
|
|
14
|
+
applies the manuscript's printed precision exactly as it does to a cell of a CSV.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime
|
|
20
|
+
import decimal
|
|
21
|
+
import math
|
|
22
|
+
import struct
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Container(BaseModel):
|
|
28
|
+
"""A cell holding several values -- a list, a record, a nested frame.
|
|
29
|
+
|
|
30
|
+
Not text. Stringifying one produced `[0.91, 0.02]` in the array adapter and compared it as
|
|
31
|
+
though it were a number, so a container is carried as itself and reported as not a scalar.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
35
|
+
|
|
36
|
+
holds: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
Cell = str | Container | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def shortest_single(value: float) -> str:
|
|
43
|
+
"""The shortest decimal that reads back as the same 32-bit float.
|
|
44
|
+
|
|
45
|
+
Stata stores `float` columns in four bytes, and every reader widens them to a double:
|
|
46
|
+
0.1 comes back as 0.10000000149011612, which is the double nearest the single, not the
|
|
47
|
+
number the analyst wrote. A manuscript printing 0.100000 would then be contradicted by
|
|
48
|
+
digits the file never held.
|
|
49
|
+
"""
|
|
50
|
+
packed = struct.pack("<f", value)
|
|
51
|
+
for digits in range(1, 10):
|
|
52
|
+
text = f"{value:.{digits}g}"
|
|
53
|
+
if struct.pack("<f", float(text)) == packed:
|
|
54
|
+
return _integral(text)
|
|
55
|
+
return _integral(repr(value)) # pragma: no cover - nine digits always round-trip a single
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _integral(text: str) -> str:
|
|
59
|
+
return text[:-2] if text.endswith(".0") else text
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render(value: object) -> Cell:
|
|
63
|
+
"""One stored value, as text in the convention above.
|
|
64
|
+
|
|
65
|
+
Numpy scalars arrive from some readers, and are recognized by duck typing rather than by
|
|
66
|
+
importing numpy, which none of the table readers' extras otherwise need here.
|
|
67
|
+
"""
|
|
68
|
+
if value is None:
|
|
69
|
+
return None
|
|
70
|
+
if isinstance(value, Container):
|
|
71
|
+
return value
|
|
72
|
+
item = getattr(value, "item", None)
|
|
73
|
+
if callable(item) and not isinstance(value, (str, bytes, float, int)):
|
|
74
|
+
kind = getattr(getattr(value, "dtype", None), "kind", "")
|
|
75
|
+
if kind == "f":
|
|
76
|
+
# `item()` widens a float32 to a double, which is the defect `shortest_single`
|
|
77
|
+
# exists to undo. Numpy's own `str` is already the shortest for the value's width.
|
|
78
|
+
return _integral(str(value))
|
|
79
|
+
value = item()
|
|
80
|
+
if isinstance(value, bool):
|
|
81
|
+
return "true" if value else "false"
|
|
82
|
+
if isinstance(value, float):
|
|
83
|
+
return "nan" if math.isnan(value) else _integral(repr(value))
|
|
84
|
+
if isinstance(value, int | decimal.Decimal):
|
|
85
|
+
return str(value)
|
|
86
|
+
if isinstance(value, bytes):
|
|
87
|
+
return value.decode("utf-8", errors="replace").strip()
|
|
88
|
+
if isinstance(value, datetime.datetime | datetime.date | datetime.time):
|
|
89
|
+
return value.isoformat()
|
|
90
|
+
if isinstance(value, str):
|
|
91
|
+
return value.strip()
|
|
92
|
+
if isinstance(value, list | tuple | dict | set):
|
|
93
|
+
return Container(holds=f"a {type(value).__name__} of {len(value)}")
|
|
94
|
+
return Container(holds=f"a {type(value).__name__}")
|