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/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