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/__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
@@ -0,0 +1,5 @@
1
+ """`python -m claims`, the same as the `claims` command."""
2
+
3
+ from claims.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,6 @@
1
+ """One module per artifact format.
2
+
3
+ Each adapter translates a locator into the addressing its format already has, and every
4
+ one returns the same `Found` contract. Adding a format is a new module and a registry
5
+ entry, not an edit to a switchboard.
6
+ """
@@ -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)
@@ -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
@@ -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__}")