bfplot 1.0.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.
- bfplot/__init__.py +38 -0
- bfplot/adapters/__init__.py +1 -0
- bfplot/adapters/matplotlib.py +543 -0
- bfplot/api.py +196 -0
- bfplot/artifact.py +302 -0
- bfplot/bfir.py +263 -0
- bfplot/bfir.schema.json +110 -0
- bfplot/preview.py +553 -0
- bfplot/py.typed +0 -0
- bfplot/result.py +231 -0
- bfplot/temporal.py +212 -0
- bfplot-1.0.0.dist-info/METADATA +220 -0
- bfplot-1.0.0.dist-info/RECORD +15 -0
- bfplot-1.0.0.dist-info/WHEEL +4 -0
- bfplot-1.0.0.dist-info/licenses/LICENSE +21 -0
bfplot/api.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sysconfig
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Callable, Iterable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
from matplotlib.figure import Figure
|
|
13
|
+
|
|
14
|
+
from .adapters.matplotlib import translate_figures
|
|
15
|
+
from .artifact import (
|
|
16
|
+
ResultError,
|
|
17
|
+
encode_result,
|
|
18
|
+
prepare_destination,
|
|
19
|
+
read_hash,
|
|
20
|
+
verify_result,
|
|
21
|
+
write_result,
|
|
22
|
+
)
|
|
23
|
+
from .bfir import BfirDocument
|
|
24
|
+
from .result import Collection, Result, figures_from, result
|
|
25
|
+
from .temporal import Animation
|
|
26
|
+
|
|
27
|
+
Frame = TypeVar("Frame")
|
|
28
|
+
Source = str | os.PathLike[str] | BfirDocument | Figure | Iterable[Figure] | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def translate(
|
|
32
|
+
figure: Figure | Iterable[Figure] | None = None,
|
|
33
|
+
*,
|
|
34
|
+
name: str | Iterable[str | None] | None = None,
|
|
35
|
+
) -> BfirDocument:
|
|
36
|
+
"""Translate Matplotlib figures into validated, storage-independent BFIR."""
|
|
37
|
+
collection = Collection()
|
|
38
|
+
collection.add(figure, name=name)
|
|
39
|
+
return collection._finish()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compile(
|
|
43
|
+
document: BfirDocument,
|
|
44
|
+
path: str | Path,
|
|
45
|
+
*,
|
|
46
|
+
preview: bool = True,
|
|
47
|
+
) -> Path:
|
|
48
|
+
"""Compile validated BFIR into one content-addressed BFPlot result at exactly ``path``."""
|
|
49
|
+
if not isinstance(document, BfirDocument):
|
|
50
|
+
raise TypeError("document must be a BfirDocument")
|
|
51
|
+
output = Path(path)
|
|
52
|
+
prepare_destination(output)
|
|
53
|
+
return write_result(output, document, preview=preview)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def animation(
|
|
57
|
+
path: str | Path,
|
|
58
|
+
figure: Figure | Iterable[Figure] | None = None,
|
|
59
|
+
*,
|
|
60
|
+
fps: float = 30,
|
|
61
|
+
loop: bool = True,
|
|
62
|
+
preview: bool = True,
|
|
63
|
+
) -> Animation:
|
|
64
|
+
"""Record mutable Matplotlib figures into one temporal BFPlot result."""
|
|
65
|
+
output = Path(path)
|
|
66
|
+
prepare_destination(output)
|
|
67
|
+
|
|
68
|
+
return Animation(
|
|
69
|
+
figure,
|
|
70
|
+
fps=fps,
|
|
71
|
+
loop=loop,
|
|
72
|
+
translator=lambda source: translate_figures(figures_from(source))[0],
|
|
73
|
+
finisher=lambda document: write_result(output, document, preview=preview),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_animation(
|
|
78
|
+
path: str | Path,
|
|
79
|
+
figure: Figure | Iterable[Figure],
|
|
80
|
+
frames: Iterable[Frame],
|
|
81
|
+
update: Callable[[Frame], Any],
|
|
82
|
+
*,
|
|
83
|
+
fps: float = 30,
|
|
84
|
+
loop: bool = True,
|
|
85
|
+
preview: bool = True,
|
|
86
|
+
) -> Path:
|
|
87
|
+
"""Apply ``update(frame)`` and capture each resulting Matplotlib state."""
|
|
88
|
+
recording = animation(path, figure, fps=fps, loop=loop, preview=preview)
|
|
89
|
+
try:
|
|
90
|
+
for frame in frames:
|
|
91
|
+
update(frame)
|
|
92
|
+
recording.capture()
|
|
93
|
+
written = recording.close()
|
|
94
|
+
except BaseException:
|
|
95
|
+
recording.abort()
|
|
96
|
+
raise
|
|
97
|
+
assert written is not None
|
|
98
|
+
return written
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def savefig(
|
|
102
|
+
path: str | Path,
|
|
103
|
+
figure: Figure | Iterable[Figure] | None = None,
|
|
104
|
+
*,
|
|
105
|
+
name: str | Iterable[str | None] | None = None,
|
|
106
|
+
preview: bool = True,
|
|
107
|
+
) -> Path:
|
|
108
|
+
"""Save Matplotlib figures as one BFPlot result at exactly ``path``.
|
|
109
|
+
|
|
110
|
+
The result is a self-contained directory whose numerical objects are ignored by Git.
|
|
111
|
+
An existing BFPlot result at ``path`` is replaced. ``bfplot.hash(path)`` returns its
|
|
112
|
+
hash. With ``preview=True`` the manifest ends with a rough text drawing of the figures.
|
|
113
|
+
"""
|
|
114
|
+
output = Result(path, preview=preview, _stacklevel=2)
|
|
115
|
+
output.add(figure, name=name)
|
|
116
|
+
return output.close()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def hash(source: Source = None, *, verify: bool = False) -> str:
|
|
120
|
+
"""The hash of a result, as ``"sha256:<64 hex digits>"``.
|
|
121
|
+
|
|
122
|
+
``source`` is a result path, a ``BfirDocument``, or Matplotlib figures (default: all
|
|
123
|
+
open figures), which are hashed without writing anything. Two results have the same
|
|
124
|
+
hash exactly when their figures, animations and every array are identical. For a
|
|
125
|
+
path, ``verify=True`` first checks that no object of the result is missing or changed.
|
|
126
|
+
"""
|
|
127
|
+
if isinstance(source, (str, os.PathLike)):
|
|
128
|
+
return verify_result(source) if verify else read_hash(source)
|
|
129
|
+
document = source if isinstance(source, BfirDocument) else translate(source)
|
|
130
|
+
return encode_result(document, compress=False)[0]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def same(first: Source, second: Source) -> bool:
|
|
134
|
+
"""Whether two results (paths, BFIR or figures, as for ``hash``) are identical."""
|
|
135
|
+
return hash(first) == hash(second)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def verify(path: str | os.PathLike[str]) -> str:
|
|
139
|
+
"""Check every object of the result at ``path`` against its digest and return the hash.
|
|
140
|
+
|
|
141
|
+
Raises ``bfplot.ResultError`` if the result is incomplete or has been modified.
|
|
142
|
+
"""
|
|
143
|
+
return verify_result(path)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def show(
|
|
147
|
+
figure: Figure | Iterable[Figure] | None = None,
|
|
148
|
+
*,
|
|
149
|
+
path: str | Path | None = None,
|
|
150
|
+
open_browser: bool = True,
|
|
151
|
+
) -> Path:
|
|
152
|
+
"""Save a result and open it with ``bfplot view`` (a temporary result by default)."""
|
|
153
|
+
if path is None:
|
|
154
|
+
path = Path(tempfile.mkdtemp(prefix="bfplot-show-")) / "figure.bfplot"
|
|
155
|
+
output = savefig(path, figure)
|
|
156
|
+
if open_browser:
|
|
157
|
+
executable = _find_executable()
|
|
158
|
+
if executable is None:
|
|
159
|
+
raise RuntimeError(
|
|
160
|
+
"the bfplot app is not installed (see the installation docs); use open_browser=False"
|
|
161
|
+
)
|
|
162
|
+
subprocess.Popen([executable, "view", str(output)], start_new_session=True)
|
|
163
|
+
return output
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _find_executable() -> str | None:
|
|
167
|
+
"""The ``bfplot`` app, preferring the one installed in this Python environment.
|
|
168
|
+
|
|
169
|
+
A source install (``pip install ./crates/bfplot-cli``) puts it next to this interpreter's
|
|
170
|
+
scripts, which may not be on ``PATH`` when the environment is not activated.
|
|
171
|
+
"""
|
|
172
|
+
name = "bfplot.exe" if os.name == "nt" else "bfplot"
|
|
173
|
+
for scheme in dict.fromkeys(
|
|
174
|
+
[sysconfig.get_default_scheme(), sysconfig.get_preferred_scheme("user")]
|
|
175
|
+
):
|
|
176
|
+
candidate = Path(sysconfig.get_path("scripts", scheme)) / name
|
|
177
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
178
|
+
return str(candidate)
|
|
179
|
+
return shutil.which("bfplot")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = [
|
|
183
|
+
"Animation",
|
|
184
|
+
"Result",
|
|
185
|
+
"ResultError",
|
|
186
|
+
"animation",
|
|
187
|
+
"compile",
|
|
188
|
+
"hash",
|
|
189
|
+
"result",
|
|
190
|
+
"same",
|
|
191
|
+
"save_animation",
|
|
192
|
+
"savefig",
|
|
193
|
+
"show",
|
|
194
|
+
"translate",
|
|
195
|
+
"verify",
|
|
196
|
+
]
|
bfplot/artifact.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Write and read BFPlot result directories::
|
|
2
|
+
|
|
3
|
+
<path>/manifest.toml
|
|
4
|
+
<path>/.gitignore # "/objects/": commit the manifest, not the data
|
|
5
|
+
<path>/objects/sha256/<2 hex>/<digest>
|
|
6
|
+
|
|
7
|
+
Every object is named by the SHA-256 of its content: the document by its exact JSON
|
|
8
|
+
bytes, an array by its raw little-endian bytes (stored zstd-compressed). The document
|
|
9
|
+
names every array by digest, so its digest, recorded as ``hash`` in the manifest, is
|
|
10
|
+
the hash of the whole result.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import uuid
|
|
22
|
+
import warnings
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import zstandard as zstd
|
|
28
|
+
|
|
29
|
+
from .bfir import BfirDocument
|
|
30
|
+
from .preview import render
|
|
31
|
+
|
|
32
|
+
MANIFEST_PREFIX = b'format = "bfplot"'
|
|
33
|
+
# The format version this writer produces; see docs/format.md.
|
|
34
|
+
FORMAT_VERSION = 3
|
|
35
|
+
|
|
36
|
+
_HASH = re.compile(r"sha256:[0-9a-f]{64}")
|
|
37
|
+
_DTYPE = re.compile(r"[biuf]([1-9][0-9]*)")
|
|
38
|
+
CODECS = ("zstd", "none")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def canonical_json(value: Any) -> bytes:
|
|
42
|
+
"""The canonical JSON encoding that the document digest is computed over."""
|
|
43
|
+
return json.dumps(
|
|
44
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False
|
|
45
|
+
).encode("utf-8")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def manifest_bytes(digest: str, preview: list[str] | None = None) -> bytes:
|
|
49
|
+
lines = ['format = "bfplot"', f"version = {FORMAT_VERSION}", f'hash = "{digest}"']
|
|
50
|
+
if preview:
|
|
51
|
+
# Comments, so readers ignore the drawing and it is not part of the hash.
|
|
52
|
+
lines += ["", "# Preview (approximate; regenerated on every save)", "#"]
|
|
53
|
+
lines += [f"# {line}".rstrip() for line in preview]
|
|
54
|
+
return ("\n".join(lines) + "\n").encode("utf-8")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Destinations
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _starts_with(path: Path, prefix: bytes) -> bool:
|
|
61
|
+
try:
|
|
62
|
+
with path.open("rb") as handle:
|
|
63
|
+
return handle.read(len(prefix)) == prefix
|
|
64
|
+
except OSError:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_result(path: Path) -> bool:
|
|
69
|
+
"""Whether ``path`` is a BFPlot result (or an empty directory) that may be replaced."""
|
|
70
|
+
if path.is_dir() and not path.is_symlink():
|
|
71
|
+
manifest = path / "manifest.toml"
|
|
72
|
+
return _starts_with(manifest, MANIFEST_PREFIX) or not any(path.iterdir())
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _check_destination(path: Path) -> None:
|
|
77
|
+
if os.path.lexists(path) and not is_result(path):
|
|
78
|
+
raise FileExistsError(f"{path} exists and is not a BFPlot result; refusing to replace it")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def prepare_destination(path: Path, *, stacklevel: int = 2) -> None:
|
|
82
|
+
"""Check an output path before any expensive work is done."""
|
|
83
|
+
if not path.name.endswith(".bfplot"):
|
|
84
|
+
warnings.warn(
|
|
85
|
+
f"BFPlot output {str(path)!r} does not end in '.bfplot'; writing it exactly as given",
|
|
86
|
+
stacklevel=stacklevel + 1,
|
|
87
|
+
)
|
|
88
|
+
_check_destination(path)
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
if not os.access(path.parent, os.W_OK | os.X_OK):
|
|
91
|
+
raise PermissionError(f"cannot write BFPlot output into {path.parent}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Writing
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _encode(array: np.ndarray) -> tuple[bytes, dict[str, Any]]:
|
|
98
|
+
"""Little-endian, C-ordered array bytes and their descriptor."""
|
|
99
|
+
array = np.ascontiguousarray(array, dtype=array.dtype.newbyteorder("<"))
|
|
100
|
+
descriptor = {"dtype": array.dtype.str[1:], "shape": list(array.shape), "codec": "zstd"}
|
|
101
|
+
return array.tobytes(), descriptor
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def encode_result(bfir: BfirDocument, *, compress: bool = True) -> tuple[str, dict[str, bytes]]:
|
|
105
|
+
"""The hash of ``bfir`` and its objects by hex digest (arrays compressed if ``compress``)."""
|
|
106
|
+
bfir.validate()
|
|
107
|
+
objects: dict[str, bytes] = {}
|
|
108
|
+
compressor = zstd.ZstdCompressor(level=3)
|
|
109
|
+
|
|
110
|
+
arrays = {}
|
|
111
|
+
for buffer_id, buffer in bfir.buffers.items():
|
|
112
|
+
raw, descriptor = _encode(buffer.array)
|
|
113
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
114
|
+
if digest not in objects:
|
|
115
|
+
objects[digest] = compressor.compress(raw) if compress else raw
|
|
116
|
+
arrays[buffer_id] = {"object": f"sha256:{digest}", **descriptor}
|
|
117
|
+
|
|
118
|
+
def resolve(value: Any) -> Any:
|
|
119
|
+
if isinstance(value, dict):
|
|
120
|
+
if "$buffer" in value:
|
|
121
|
+
return arrays[value["$buffer"]]
|
|
122
|
+
return {key: resolve(item) for key, item in value.items()}
|
|
123
|
+
if isinstance(value, list):
|
|
124
|
+
return [resolve(item) for item in value]
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
document = canonical_json(resolve(bfir.document))
|
|
128
|
+
digest = hashlib.sha256(document).hexdigest()
|
|
129
|
+
objects[digest] = document
|
|
130
|
+
return f"sha256:{digest}", objects
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def write_result(path: Path, bfir: BfirDocument, *, preview: bool = True) -> Path:
|
|
134
|
+
"""Write ``bfir`` as one result at exactly ``path``, replacing any previous result.
|
|
135
|
+
|
|
136
|
+
With ``preview``, the manifest ends with a text drawing of the figures in comments.
|
|
137
|
+
"""
|
|
138
|
+
digest, objects = encode_result(bfir)
|
|
139
|
+
drawing = render(bfir) if preview else None
|
|
140
|
+
manifest = manifest_bytes(digest, drawing)
|
|
141
|
+
|
|
142
|
+
_check_destination(path)
|
|
143
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
staging = path.with_name(f".{path.name}.{uuid.uuid4().hex[:8]}.partial")
|
|
145
|
+
try:
|
|
146
|
+
_write_directory(staging, manifest, objects)
|
|
147
|
+
_install(staging, path)
|
|
148
|
+
finally:
|
|
149
|
+
_remove(staging)
|
|
150
|
+
return path
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _write(path: Path, data: bytes) -> None:
|
|
154
|
+
with path.open("xb") as handle:
|
|
155
|
+
handle.write(data)
|
|
156
|
+
os.fsync(handle.fileno())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _write_directory(path: Path, manifest: bytes, objects: dict[str, bytes]) -> None:
|
|
160
|
+
path.mkdir()
|
|
161
|
+
for digest, data in objects.items():
|
|
162
|
+
target = path / "objects" / "sha256" / digest[:2] / digest
|
|
163
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
_write(target, data)
|
|
165
|
+
_write(path / "manifest.toml", manifest)
|
|
166
|
+
_write(path / ".gitignore", b"/objects/\n")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _install(staging: Path, path: Path) -> None:
|
|
170
|
+
"""Move ``staging`` to ``path``; a previous result is moved aside, then deleted."""
|
|
171
|
+
if not os.path.lexists(path):
|
|
172
|
+
os.replace(staging, path)
|
|
173
|
+
return
|
|
174
|
+
previous = path.with_name(f".{path.name}.{uuid.uuid4().hex[:8]}.old")
|
|
175
|
+
os.rename(path, previous)
|
|
176
|
+
try:
|
|
177
|
+
os.replace(staging, path)
|
|
178
|
+
except BaseException:
|
|
179
|
+
os.rename(previous, path)
|
|
180
|
+
raise
|
|
181
|
+
_remove(previous)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _remove(path: Path) -> None:
|
|
185
|
+
if path.is_dir() and not path.is_symlink():
|
|
186
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
187
|
+
elif os.path.lexists(path):
|
|
188
|
+
path.unlink()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# Reading
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class ResultError(ValueError):
|
|
195
|
+
"""Raised when a path is not a valid, intact BFPlot result."""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _result_dir(path: str | os.PathLike[str]) -> Path:
|
|
199
|
+
path = Path(path)
|
|
200
|
+
return path.parent if path.name == "manifest.toml" else path
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def read_hash(path: str | os.PathLike[str]) -> str:
|
|
204
|
+
"""The ``hash`` recorded in a result's manifest, without checking any objects."""
|
|
205
|
+
manifest = _result_dir(path) / "manifest.toml"
|
|
206
|
+
try:
|
|
207
|
+
text = manifest.read_text(encoding="utf-8")
|
|
208
|
+
except OSError as error:
|
|
209
|
+
raise ResultError(f"cannot read {manifest}: {error.strerror or error}") from error
|
|
210
|
+
if not text.startswith(MANIFEST_PREFIX.decode()):
|
|
211
|
+
raise ResultError(f"{manifest} is not a BFPlot manifest")
|
|
212
|
+
version = re.search(r"^version = (\d+)$", text, re.MULTILINE)
|
|
213
|
+
if version is None:
|
|
214
|
+
raise ResultError(f"{manifest} has no format version")
|
|
215
|
+
if int(version.group(1)) != FORMAT_VERSION:
|
|
216
|
+
raise ResultError(
|
|
217
|
+
f"{manifest} uses BFPlot format version {version.group(1)}; "
|
|
218
|
+
f"this bfplot reads version {FORMAT_VERSION}"
|
|
219
|
+
)
|
|
220
|
+
found = re.search(r'^hash = "([^"]*)"$', text, re.MULTILINE)
|
|
221
|
+
if found is None or not _HASH.fullmatch(found.group(1)):
|
|
222
|
+
raise ResultError(f"{manifest} has no valid hash")
|
|
223
|
+
return found.group(1)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _read_object(directory: Path, digest: str) -> bytes:
|
|
227
|
+
hex_digest = digest.removeprefix("sha256:")
|
|
228
|
+
try:
|
|
229
|
+
return (directory / "objects" / "sha256" / hex_digest[:2] / hex_digest).read_bytes()
|
|
230
|
+
except FileNotFoundError:
|
|
231
|
+
raise ResultError(f"object not found: {digest}") from None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _array_refs(value: Any, found: list[dict[str, Any]]) -> None:
|
|
235
|
+
if isinstance(value, dict):
|
|
236
|
+
if "object" in value:
|
|
237
|
+
found.append(value)
|
|
238
|
+
return
|
|
239
|
+
for item in value.values():
|
|
240
|
+
_array_refs(item, found)
|
|
241
|
+
elif isinstance(value, list):
|
|
242
|
+
for item in value:
|
|
243
|
+
_array_refs(item, found)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _check_array(directory: Path, reference: dict[str, Any]) -> int:
|
|
247
|
+
digest = reference.get("object")
|
|
248
|
+
dtype, shape, codec = reference.get("dtype"), reference.get("shape"), reference.get("codec")
|
|
249
|
+
if set(reference) != {"object", "dtype", "shape", "codec"} or not (
|
|
250
|
+
isinstance(digest, str) and _HASH.fullmatch(digest)
|
|
251
|
+
):
|
|
252
|
+
raise ResultError(f"invalid array reference: {reference!r}")
|
|
253
|
+
size = _DTYPE.fullmatch(dtype) if isinstance(dtype, str) else None
|
|
254
|
+
dimensions_ok = isinstance(shape, list) and all(
|
|
255
|
+
isinstance(item, int) and not isinstance(item, bool) and item >= 0 for item in shape
|
|
256
|
+
)
|
|
257
|
+
if size is None or codec not in CODECS or not dimensions_ok:
|
|
258
|
+
raise ResultError(f"invalid array reference: {reference!r}")
|
|
259
|
+
expected = math.prod(shape) * int(size.group(1))
|
|
260
|
+
stored = _read_object(directory, digest)
|
|
261
|
+
if codec == "zstd":
|
|
262
|
+
try:
|
|
263
|
+
declared = zstd.frame_content_size(stored)
|
|
264
|
+
if declared not in (-1, expected):
|
|
265
|
+
raise ResultError(f"object {digest} holds {declared} bytes, expected {expected}")
|
|
266
|
+
raw = zstd.ZstdDecompressor().decompress(stored, max_output_size=expected)
|
|
267
|
+
except zstd.ZstdError as error:
|
|
268
|
+
raise ResultError(f"object {digest} cannot be decompressed: {error}") from None
|
|
269
|
+
else:
|
|
270
|
+
raw = stored
|
|
271
|
+
if len(raw) != expected:
|
|
272
|
+
raise ResultError(f"object {digest} holds {len(raw)} bytes, expected {expected}")
|
|
273
|
+
if f"sha256:{hashlib.sha256(raw).hexdigest()}" != digest:
|
|
274
|
+
raise ResultError(f"object {digest} does not match its digest")
|
|
275
|
+
return len(stored)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def verify_result(path: str | os.PathLike[str]) -> str:
|
|
279
|
+
"""Check the document and every array of a result against their digests.
|
|
280
|
+
|
|
281
|
+
Returns the result's hash. Raises ``ResultError`` if anything is missing or changed.
|
|
282
|
+
"""
|
|
283
|
+
directory = _result_dir(path)
|
|
284
|
+
digest = read_hash(directory)
|
|
285
|
+
document = _read_object(directory, digest)
|
|
286
|
+
if f"sha256:{hashlib.sha256(document).hexdigest()}" != digest:
|
|
287
|
+
raise ResultError(f"document {digest} does not match its digest")
|
|
288
|
+
try:
|
|
289
|
+
content = json.loads(document)
|
|
290
|
+
except ValueError as error:
|
|
291
|
+
raise ResultError(f"document {digest} is not valid JSON: {error}") from None
|
|
292
|
+
if not isinstance(content, dict) or not isinstance(content.get("figures"), list):
|
|
293
|
+
raise ResultError(f"document {digest} has no figure list")
|
|
294
|
+
references: list[dict[str, Any]] = []
|
|
295
|
+
_array_refs(content, references)
|
|
296
|
+
checked: set[str] = set()
|
|
297
|
+
for reference in references:
|
|
298
|
+
key = canonical_json(reference).decode()
|
|
299
|
+
if key not in checked:
|
|
300
|
+
_check_array(directory, reference)
|
|
301
|
+
checked.add(key)
|
|
302
|
+
return digest
|