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,149 @@
1
+ """Excel workbooks, addressed by sheet, column and a row predicate.
2
+
3
+ `.xlsx` and `.xlsm` are read with `openpyxl` and `.xls` with `xlrd`, both optional
4
+ (`pip install "reproducible-science[sheets]"`). Without them a check is `unchecked` and says
5
+ which extra to install; it never fails and never passes.
6
+
7
+ A cell is read as the value a formula last computed, which is what the sheet shows, and never
8
+ as the formula. A workbook saved by a program that does not compute formulas holds no computed
9
+ value for them, and such a cell is reported as holding no value: a check that substituted the
10
+ formula text, or zero, would be comparing the manuscript against something the file does not
11
+ say.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pathlib
17
+ import warnings
18
+ import zipfile
19
+
20
+ from claims.adapters.base import Found, Resolution, _no
21
+ from claims.adapters.cells import Cell, render
22
+ from claims.adapters.rows import resolve_rows
23
+ from claims.exceptions import ArtifactUnreadableError, BackendUnavailableError
24
+ from claims.models import SheetLocator
25
+ from claims.toolchain import DISTRIBUTION
26
+
27
+ try: # optional: `pip install "reproducible-science[sheets]"`
28
+ import openpyxl
29
+ except ImportError: # pragma: no cover - exercised by the extras matrix, not the suite
30
+ openpyxl = None
31
+
32
+ try: # optional: `pip install "reproducible-science[sheets]"`
33
+ import xlrd
34
+ import xlrd.biffh
35
+ except ImportError: # pragma: no cover
36
+ xlrd = None
37
+
38
+ WORKBOOK_SUFFIXES = {".xlsx", ".xlsm", ".xls"}
39
+
40
+ _INSTALL = f'pip install "{DISTRIBUTION}[sheets]"'
41
+
42
+
43
+ def _xlsx(path: pathlib.Path, sheet: str | None) -> tuple[list[str], list[list[object]]]:
44
+ """Sheet names, and the rows of the one selected; `sheet` is resolved by `_choose`."""
45
+ if openpyxl is None:
46
+ raise BackendUnavailableError("sheet", f"openpyxl is not installed -- {_INSTALL}")
47
+ # openpyxl warns about workbook features it does not model -- a data validation extension,
48
+ # a conditional format. None is a cell's value, and the warning would land in the middle of
49
+ # a verification report.
50
+ with warnings.catch_warnings():
51
+ warnings.simplefilter("ignore", UserWarning)
52
+ try:
53
+ book = openpyxl.load_workbook(path, read_only=True, data_only=True)
54
+ except (zipfile.BadZipFile, KeyError, OSError, ValueError) as e:
55
+ raise ArtifactUnreadableError(path, f"not a readable workbook: {e}") from e
56
+ try:
57
+ names = list(book.sheetnames)
58
+ chosen = _choose(names, sheet)
59
+ if chosen is None:
60
+ return names, []
61
+ ws = book[chosen]
62
+ # A workbook written by something other than Excel can record a used range of one
63
+ # cell, and a read-only sheet trusts it: every row after the first is silently not
64
+ # returned. The citations extractor met this on publishers' supplements.
65
+ ws.reset_dimensions()
66
+ return names, [list(row) for row in ws.iter_rows(values_only=True)]
67
+ finally:
68
+ book.close()
69
+
70
+
71
+ def _xls(path: pathlib.Path, sheet: str | None) -> tuple[list[str], list[list[object]]]:
72
+ if xlrd is None:
73
+ raise BackendUnavailableError("sheet", f"xlrd is not installed -- {_INSTALL}")
74
+ try:
75
+ book = xlrd.open_workbook(str(path))
76
+ except (xlrd.biffh.XLRDError, OSError, EOFError, AssertionError) as e:
77
+ raise ArtifactUnreadableError(path, f"not a readable workbook: {e}") from e
78
+ names = book.sheet_names()
79
+ chosen = _choose(names, sheet)
80
+ if chosen is None:
81
+ return names, []
82
+ ws = book.sheet_by_name(chosen)
83
+
84
+ def value(cell) -> object:
85
+ # The old format has no integer type, and an empty cell is the empty string. A date is
86
+ # a float tagged as one; it is not a number a manuscript reports, and reading it as
87
+ # one would compare a day count against a printed value.
88
+ if cell.ctype in (xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK):
89
+ return None
90
+ if cell.ctype == xlrd.XL_CELL_DATE:
91
+ return xlrd.xldate_as_datetime(cell.value, book.datemode)
92
+ if cell.ctype == xlrd.XL_CELL_BOOLEAN:
93
+ return bool(cell.value)
94
+ if cell.ctype == xlrd.XL_CELL_ERROR:
95
+ return xlrd.error_text_from_code.get(cell.value, "#ERROR")
96
+ return cell.value
97
+
98
+ return names, [[value(c) for c in ws.row(r)] for r in range(ws.nrows)]
99
+
100
+
101
+ def _choose(names: list[str], sheet: str | None) -> str | None:
102
+ """The sheet a locator selects, or None when it selects none; `_resolve_sheet` says why."""
103
+ if sheet is None:
104
+ return names[0] if len(names) == 1 else None
105
+ return sheet if sheet in names else None
106
+
107
+
108
+ def _frame(raw: list[list[object]]) -> tuple[list[str], list[dict[str, Cell]]]:
109
+ """The first row as the header and every later row keyed by it, as delimited text is read.
110
+
111
+ A column whose header cell is empty is left out: no locator can name it, and a sheet's used
112
+ range routinely runs past its last named column. Kept as columns named "", two of them
113
+ would repeat a header name and the repeated-header rule would refuse the whole sheet.
114
+ """
115
+ if not raw:
116
+ return [], []
117
+ names = [h if isinstance(h, str) else "" for h in map(render, raw[0])]
118
+ named = [(i, n) for i, n in enumerate(names) if n]
119
+ header = [n for _, n in named]
120
+ rows: list[dict[str, Cell]] = [
121
+ {n: render(values[i]) if i < len(values) else None for i, n in named} for values in raw[1:]
122
+ ]
123
+ return header, rows
124
+
125
+
126
+ def _resolve_sheet(locator: SheetLocator, path: pathlib.Path) -> Found:
127
+ suffix = path.suffix.lower()
128
+ if suffix not in WORKBOOK_SUFFIXES:
129
+ return _no(
130
+ Resolution.FORMAT_UNSUPPORTED,
131
+ f"a sheet locator addresses an Excel workbook; {path.name} is "
132
+ f"{path.suffix or 'extensionless'}",
133
+ )
134
+ read = _xls if suffix == ".xls" else _xlsx
135
+ names, raw = read(path, locator.sheet)
136
+ if locator.sheet is None and len(names) != 1:
137
+ return _no(
138
+ Resolution.SELECTOR_INVALID,
139
+ f"{path.name} holds {len(names)} sheets; name one of {', '.join(names[:8])}",
140
+ )
141
+ if locator.sheet is not None and locator.sheet not in names:
142
+ return _no(
143
+ Resolution.SELECTOR_INVALID,
144
+ f"{path.name} has no sheet {locator.sheet!r}; sheets are {', '.join(names[:8])}",
145
+ )
146
+ header, rows = _frame(raw)
147
+ name = f"{path.name}, sheet {locator.sheet or names[0]!r}"
148
+ wanted = {k: render(v) for k, v in locator.where.items()}
149
+ return resolve_rows(header, rows, locator.column, wanted, name)
@@ -0,0 +1,73 @@
1
+ """SQLite, addressed by table, column and a key predicate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ import sqlite3
7
+
8
+ from claims.adapters.base import Found, Resolution, _no, _ok
9
+ from claims.exceptions import ArtifactUnreadableError
10
+ from claims.models import (
11
+ SqliteLocator,
12
+ )
13
+
14
+ # ---------------------------------------------------------------------------------- sqlite
15
+
16
+ _SQLITE_SUFFIXES = {".db", ".sqlite", ".sqlite3"}
17
+
18
+
19
+ def _resolve_sqlite(locator: SqliteLocator, path: pathlib.Path) -> Found:
20
+ if path.suffix.lower() not in _SQLITE_SUFFIXES:
21
+ return _no(
22
+ Resolution.FORMAT_UNSUPPORTED,
23
+ f"a sqlite locator addresses a database file; {path.name} is {path.suffix}",
24
+ )
25
+ try:
26
+ connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
27
+ except sqlite3.Error as e:
28
+ raise ArtifactUnreadableError(path, str(e)) from e
29
+ try:
30
+ tables = {
31
+ r[0]
32
+ for r in connection.execute(
33
+ "SELECT name FROM sqlite_master WHERE type IN ('table','view')"
34
+ )
35
+ }
36
+ if locator.table not in tables:
37
+ return _no(
38
+ Resolution.SELECTOR_INVALID,
39
+ f"{path.name} has no table {locator.table!r}; tables are "
40
+ f"{', '.join(sorted(tables)[:8])}",
41
+ )
42
+ # Identifiers cannot be bound as parameters, so they are checked against the schema
43
+ # and then quoted. Values are always bound.
44
+ columns = {r[1] for r in connection.execute(f'PRAGMA table_info("{locator.table}")')}
45
+ unknown = [c for c in (locator.column, *locator.where) if c not in columns]
46
+ if unknown:
47
+ return _no(
48
+ Resolution.SELECTOR_INVALID,
49
+ f"{locator.table} has no column {', '.join(repr(c) for c in unknown)}; "
50
+ f"columns are {', '.join(sorted(columns)[:8])}",
51
+ )
52
+ clause = " AND ".join(f'"{k}" IS ?' for k in locator.where)
53
+ query = f'SELECT "{locator.column}" FROM "{locator.table}"' + (
54
+ f" WHERE {clause}" if clause else ""
55
+ )
56
+ try:
57
+ found = connection.execute(query, tuple(locator.where.values())).fetchall()
58
+ except sqlite3.Error as e:
59
+ raise ArtifactUnreadableError(path, str(e)) from e
60
+ finally:
61
+ connection.close()
62
+
63
+ described = ", ".join(f"{k}={v!r}" for k, v in locator.where.items())
64
+ if not found:
65
+ return _no(Resolution.ABSENT, f"no row in {locator.table} where {described}")
66
+ if len(found) > 1:
67
+ return _no(Resolution.AMBIGUOUS, f"{len(found)} rows in {locator.table} where {described}")
68
+ value = found[0][0]
69
+ if value is None:
70
+ return _no(Resolution.ABSENT, f"{locator.column} is NULL where {described}")
71
+ if isinstance(value, (bytes, memoryview)):
72
+ return _no(Resolution.NOT_SCALAR, f"{locator.column} holds a blob")
73
+ return _ok(str(value), type(value).__name__, f"{locator.table}.{locator.column}")
@@ -0,0 +1,135 @@
1
+ """Tables, addressed by column plus a row predicate.
2
+
3
+ CSV, TSV and PSV are read here. Parquet, Feather, Stata, SPSS and R data frames are read by
4
+ `claims.adapters.columnar` into the same header and rows, and resolved by the same code, so a
5
+ table and its export to another format refuse and resolve the same manifests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import io
12
+ import pathlib
13
+
14
+ from claims.adapters.base import Found, Resolution, _no
15
+ from claims.adapters.cells import Cell, render
16
+ from claims.adapters.columnar import COLUMNAR_SUFFIXES, read_columnar
17
+ from claims.adapters.reads import once
18
+ from claims.adapters.rows import index_rows, resolve_row_at, resolve_rows
19
+ from claims.adapters.sheet import WORKBOOK_SUFFIXES
20
+ from claims.exceptions import ArtifactUnreadableError
21
+ from claims.models import (
22
+ PredicateValue,
23
+ TableLocator,
24
+ TablePositionLocator,
25
+ )
26
+
27
+ # ----------------------------------------------------------------------------------- tables
28
+
29
+ #: Delimiters implied by a suffix, checked before sniffing.
30
+ _DELIMITERS = {".csv": ",", ".tsv": "\t", ".psv": "|"}
31
+ _TABLE_SUFFIXES = set(_DELIMITERS) | {".txt", ""}
32
+
33
+
34
+ def sniff_delimiter(path: pathlib.Path, sample: str) -> str:
35
+ """The delimiter for a table, from its suffix or from the header line.
36
+
37
+ Suffix first, because a `.tsv` whose header happens to contain commas is still tab
38
+ separated and sniffing it would split every row in the wrong place.
39
+ """
40
+ if known := _DELIMITERS.get(path.suffix.lower()):
41
+ return known
42
+ header = sample.splitlines()[0] if sample.splitlines() else ""
43
+ counts = {d: header.count(d) for d in (",", "\t", ";", "|")}
44
+ best = max(counts, key=lambda delimiter: counts[delimiter])
45
+ return best if counts[best] else ","
46
+
47
+
48
+ def read_table(path: pathlib.Path, delimiter: str = "") -> tuple[list[str], list[dict]]:
49
+ """Header and rows. Raises `ArtifactUnreadableError` when the file is not a table."""
50
+ try:
51
+ text = path.read_text(encoding="utf-8-sig")
52
+ except (OSError, UnicodeDecodeError) as e:
53
+ raise ArtifactUnreadableError(path, str(e)) from e
54
+ if not text.strip():
55
+ raise ArtifactUnreadableError(path, "file is empty")
56
+ reader = csv.DictReader(io.StringIO(text), delimiter=delimiter or sniff_delimiter(path, text))
57
+ rows = list(reader)
58
+ if reader.fieldnames is None:
59
+ raise ArtifactUnreadableError(path, "no header row")
60
+ return list(reader.fieldnames), rows
61
+
62
+
63
+ def predicate_text(value: PredicateValue) -> str:
64
+ """A predicate value as the text a delimited cell would hold.
65
+
66
+ Delimited files have no types: every cell is text. Comparing as text and never coercing
67
+ keeps `"001"` distinct from `1`, which for an identifier column is the difference between
68
+ two different rows.
69
+ """
70
+ if isinstance(value, bool):
71
+ return "true" if value else "false"
72
+ return str(value)
73
+
74
+
75
+ def _delimited(path: pathlib.Path, delimiter: str) -> tuple[list[str], list[dict[str, Cell]]]:
76
+ header, rows = read_table(path, delimiter)
77
+ # Every delimited cell is text. A short row's missing fields come back as None, which here
78
+ # would read as a null; they were always the empty string, and stay so. The `None` key
79
+ # holds a long row's surplus fields, which no header names.
80
+ text = [{k: (v or "").strip() for k, v in row.items() if k is not None} for row in rows]
81
+ return header, text
82
+
83
+
84
+ def _load(path: pathlib.Path, delimiter: str, wanted: set[str]) -> tuple[tuple, Found | None]:
85
+ """A table's header and rows from whichever reader its suffix names, or why not.
86
+
87
+ `wanted` is the columns a locator names, which is all a columnar reader reads.
88
+ """
89
+ suffix = path.suffix.lower()
90
+ if suffix in COLUMNAR_SUFFIXES:
91
+ if delimiter:
92
+ return (), _no(
93
+ Resolution.SELECTOR_INVALID,
94
+ f"{path.name} is {suffix}, which has no delimiter to override",
95
+ )
96
+ return read_columnar(path, wanted), None
97
+ if suffix in WORKBOOK_SUFFIXES:
98
+ return (), _no(
99
+ Resolution.FORMAT_UNSUPPORTED,
100
+ f"{path.name} is a workbook, which holds several tables; address it with "
101
+ f"`kind: sheet`, which names the sheet",
102
+ )
103
+ if suffix not in _TABLE_SUFFIXES:
104
+ return (), _no(
105
+ Resolution.FORMAT_UNSUPPORTED,
106
+ f"a table locator addresses delimited text or a columnar file; "
107
+ f"{path.name} is {path.suffix}",
108
+ )
109
+ return once(("delimited", path, delimiter), lambda: _delimited(path, delimiter)), None
110
+
111
+
112
+ def _resolve_table(locator: TableLocator, path: pathlib.Path) -> Found:
113
+ loaded, failure = _load(path, locator.delimiter, {locator.column, *locator.where})
114
+ if failure is not None:
115
+ return failure
116
+ header, rows = loaded
117
+ # Delimited text is matched as text, as it always was; a typed table renders the predicate
118
+ # in the convention its cells were rendered in, so `seed: 2` selects a stored 2.0.
119
+ columnar = path.suffix.lower() in COLUMNAR_SUFFIXES
120
+ key = render if columnar else predicate_text
121
+ wanted = {k: key(v) for k, v in locator.where.items()}
122
+ if columnar:
123
+ # A columnar read holds only the columns one locator named, so it is not shared.
124
+ return resolve_rows(header, rows, locator.column, wanted, path.name)
125
+ columns = tuple(sorted(wanted))
126
+ index = once(("rows", path, locator.delimiter, columns), lambda: index_rows(rows, columns))
127
+ return resolve_rows(header, rows, locator.column, wanted, path.name, index)
128
+
129
+
130
+ def _resolve_table_position(locator: TablePositionLocator, path: pathlib.Path) -> Found:
131
+ loaded, failure = _load(path, locator.delimiter, {locator.column})
132
+ if failure is not None:
133
+ return failure
134
+ header, rows = loaded
135
+ return resolve_row_at(header, rows, locator.column, locator.row, path.name)
@@ -0,0 +1,163 @@
1
+ """JSON and restricted YAML, addressed by RFC 6901 pointer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import decimal
6
+ import json
7
+ import pathlib
8
+
9
+ import yaml
10
+
11
+ from claims.adapters.base import Found, Resolution, _no, _ok
12
+ from claims.adapters.reads import once
13
+ from claims.exceptions import ArtifactUnreadableError
14
+ from claims.models import (
15
+ TreeLocator,
16
+ )
17
+
18
+ # ------------------------------------------------------------------------------------ trees
19
+
20
+ _TREE_SUFFIXES = {".json": "json", ".yaml": "yaml", ".yml": "yaml"}
21
+ _MISSING = object()
22
+
23
+
24
+ class Written(str):
25
+ """A number with a fraction or an exponent, as the characters the file wrote for it.
26
+
27
+ Both loaders hand such a number over as a binary float, and the float was then printed
28
+ back: `0.910` came out as `0.91`, `1e-7` as `1e-07`, a value of twenty significant digits
29
+ as its nearest seventeen, `9007199254740993.0` as `9007199254740992.0`, and `1e400` as
30
+ `inf`. A comparison at printed precision in exact decimals was made against digits the
31
+ file never held. The text is kept in place of the float and is what `decimal.Decimal`
32
+ reads, so the value compared is the decimal the file wrote, trailing zeros included.
33
+
34
+ An integer needs none of this: both loaders read one exactly, at any length.
35
+ """
36
+
37
+ __slots__ = ()
38
+
39
+
40
+ def resolve_pointer(document: object, pointer: str) -> object:
41
+ """RFC 6901 JSON Pointer resolution. Returns `_MISSING` when the pointer does not resolve.
42
+
43
+ `~1` is a literal `/` and `~0` a literal `~`, unescaped in that order, so a key containing
44
+ a slash is addressable and a key containing a period is unremarkable.
45
+ """
46
+ if pointer == "":
47
+ return document
48
+ if not pointer.startswith("/"):
49
+ raise ValueError(f"JSON Pointer must start with '/': {pointer!r}")
50
+ node = document
51
+ for raw in pointer[1:].split("/"):
52
+ token = raw.replace("~1", "/").replace("~0", "~")
53
+ if isinstance(node, dict):
54
+ if token not in node:
55
+ return _MISSING
56
+ node = node[token]
57
+ elif isinstance(node, list):
58
+ # RFC 6901 array indices are digits with no leading zeros, so "01" addresses
59
+ # nothing and is not silently read as 1.
60
+ if not token.isdigit() or (len(token) > 1 and token[0] == "0"):
61
+ return _MISSING
62
+ index = int(token)
63
+ if index >= len(node):
64
+ return _MISSING
65
+ node = node[index]
66
+ else:
67
+ return _MISSING
68
+ return node
69
+
70
+
71
+ def _no_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict:
72
+ """`json.loads` keeps the last of a duplicated key, exactly as PyYAML does.
73
+
74
+ The YAML path has rejected this from the start; JSON did not, so one artifact could hold
75
+ two values for one quantity and resolve to whichever came last. That is the finding this
76
+ repository's own regression corpus records against someone else's paper.
77
+ """
78
+ seen: dict[str, object] = {}
79
+ for key, value in pairs:
80
+ if key in seen:
81
+ raise ValueError(f"duplicate key {key!r}")
82
+ seen[key] = value
83
+ return seen
84
+
85
+
86
+ class _StrictYaml(yaml.SafeLoader):
87
+ """SafeLoader that refuses duplicate mapping keys.
88
+
89
+ PyYAML keeps the last of a duplicated key, so a file with two `accuracy:` entries resolves
90
+ to one of them with nothing said. An artifact that cannot be read one way only is not one
91
+ a pointer can address.
92
+ """
93
+
94
+
95
+ def _no_duplicate_keys(loader, node, deep=False):
96
+ mapping = {}
97
+ for key_node, value_node in node.value:
98
+ key = loader.construct_object(key_node, deep=deep)
99
+ if key in mapping:
100
+ raise yaml.constructor.ConstructorError(
101
+ None, None, f"duplicate key {key!r}", key_node.start_mark
102
+ )
103
+ mapping[key] = loader.construct_object(value_node, deep=deep)
104
+ return mapping
105
+
106
+
107
+ _StrictYaml.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_duplicate_keys)
108
+
109
+
110
+ def _float_as_written(loader, node):
111
+ """A YAML float as its text, where the text is a decimal literal.
112
+
113
+ YAML has float forms `decimal.Decimal` does not read: `.inf`, `.nan` and base-sixty
114
+ `1:30.5`. Those stay the floats the loader makes of them, as they were.
115
+ """
116
+ text = loader.construct_scalar(node)
117
+ try:
118
+ decimal.Decimal(text)
119
+ except decimal.InvalidOperation:
120
+ return loader.construct_yaml_float(node)
121
+ return Written(text)
122
+
123
+
124
+ _StrictYaml.add_constructor("tag:yaml.org,2002:float", _float_as_written)
125
+
126
+
127
+ def _load_tree(path: pathlib.Path) -> object:
128
+ fmt = _TREE_SUFFIXES.get(path.suffix.lower())
129
+ try:
130
+ text = path.read_text(encoding="utf-8-sig")
131
+ except (OSError, UnicodeDecodeError) as e:
132
+ raise ArtifactUnreadableError(path, str(e)) from e
133
+ if fmt == "json":
134
+ try:
135
+ return json.loads(text, object_pairs_hook=_no_duplicate_json_keys, parse_float=Written)
136
+ except json.JSONDecodeError as e:
137
+ raise ArtifactUnreadableError(path, f"not valid JSON: {e}") from e
138
+ except ValueError as e: # raised by the hook below
139
+ raise ArtifactUnreadableError(path, str(e)) from e
140
+ try:
141
+ return yaml.load(text, Loader=_StrictYaml)
142
+ except yaml.YAMLError as e:
143
+ raise ArtifactUnreadableError(path, f"not valid YAML: {e}") from e
144
+
145
+
146
+ def _resolve_tree(locator: TreeLocator, path: pathlib.Path) -> Found:
147
+ if path.suffix.lower() not in _TREE_SUFFIXES:
148
+ return _no(
149
+ Resolution.FORMAT_UNSUPPORTED,
150
+ f"a tree locator addresses JSON or YAML; {path.name} is "
151
+ f"{path.suffix or 'extensionless'}",
152
+ )
153
+ node = resolve_pointer(once(("tree", path), lambda: _load_tree(path)), locator.pointer)
154
+ if node is _MISSING:
155
+ return _no(Resolution.ABSENT, f"{locator.pointer} does not resolve in {path.name}")
156
+ if isinstance(node, (dict, list)):
157
+ return _no(
158
+ Resolution.NOT_SCALAR, f"{locator.pointer} holds a {type(node).__name__}, not a value"
159
+ )
160
+ if node is None:
161
+ return _no(Resolution.ABSENT, f"{locator.pointer} holds null")
162
+ native = "float" if isinstance(node, Written) else type(node).__name__
163
+ return _ok(str(node), native, locator.pointer)