backstamp 0.0.1__tar.gz
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.
- backstamp-0.0.1/LICENSE +28 -0
- backstamp-0.0.1/PKG-INFO +89 -0
- backstamp-0.0.1/README.md +68 -0
- backstamp-0.0.1/pyproject.toml +33 -0
- backstamp-0.0.1/setup.cfg +4 -0
- backstamp-0.0.1/src/backstamp/__init__.py +11 -0
- backstamp-0.0.1/src/backstamp/metadata.py +217 -0
- backstamp-0.0.1/src/backstamp/provenance.py +303 -0
- backstamp-0.0.1/src/backstamp/save.py +60 -0
- backstamp-0.0.1/src/backstamp.egg-info/PKG-INFO +89 -0
- backstamp-0.0.1/src/backstamp.egg-info/SOURCES.txt +13 -0
- backstamp-0.0.1/src/backstamp.egg-info/dependency_links.txt +1 -0
- backstamp-0.0.1/src/backstamp.egg-info/requires.txt +5 -0
- backstamp-0.0.1/src/backstamp.egg-info/top_level.txt +1 -0
- backstamp-0.0.1/tests/test_provenance.py +419 -0
backstamp-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Joschua Geuter
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
backstamp-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: backstamp
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Record how a figure was made inside the figure file itself: commit, calling function, parameters.
|
|
5
|
+
Author: Joschua Geuter
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Repository, https://github.com/Joschua21/backstamp
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: Framework :: Matplotlib
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: matplotlib
|
|
17
|
+
Requires-Dist: pypdf>=3
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest; extra == "test"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# backstamp
|
|
23
|
+
|
|
24
|
+
A saved figure says nothing about the code that made it. Weeks later, finding the commit,
|
|
25
|
+
the function and the parameters behind a plot means searching old scripts, or making it
|
|
26
|
+
again.
|
|
27
|
+
|
|
28
|
+
backstamp writes that record into the figure file when it is saved, and reads it back.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install backstamp
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Python 3.9 or later. Installs matplotlib and pypdf.
|
|
37
|
+
|
|
38
|
+
## Save
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import backstamp
|
|
42
|
+
|
|
43
|
+
def plot_accuracy(trials, window=50):
|
|
44
|
+
fig, ax = plt.subplots()
|
|
45
|
+
...
|
|
46
|
+
backstamp.save(fig, "accuracy", fig_dir="figures/")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This writes `figures/accuracy.pdf` with a record of:
|
|
50
|
+
|
|
51
|
+
- the commit of the repository the calling code lives in, marked `-dirty` when it has
|
|
52
|
+
uncommitted changes
|
|
53
|
+
- the installed version of the calling package
|
|
54
|
+
- the function that called `save`: its module, file, line, and the functions enclosing it
|
|
55
|
+
- that function's parameters. Data objects are stored as their type and shape
|
|
56
|
+
(`<DataFrame 50x2>`, `<ndarray (3, 4) float64>`), not their values.
|
|
57
|
+
- when the figure was made
|
|
58
|
+
|
|
59
|
+
Parameters are recorded with the values they have when `save` runs, so a parameter
|
|
60
|
+
reassigned inside the function is recorded with its new value.
|
|
61
|
+
|
|
62
|
+
The record is JSON in the PDF's `Subject` field, with a one-line summary in `Title`.
|
|
63
|
+
|
|
64
|
+
## Read
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from backstamp.metadata import read_figure_metadata
|
|
68
|
+
|
|
69
|
+
read_figure_metadata("figures/accuracy.pdf")
|
|
70
|
+
# {"commit": "a1b2c3d", "function": "plot_accuracy",
|
|
71
|
+
# "params": {"trials": "<DataFrame 50x2>", "window": 50}, ...}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Returns `{}` for a figure without a record, a file that is not a readable PDF, or a missing
|
|
75
|
+
file.
|
|
76
|
+
|
|
77
|
+
## Wrapping `save`
|
|
78
|
+
|
|
79
|
+
The record names the nearest calling function outside backstamp. A function that wraps
|
|
80
|
+
`save` passes its own module, so the record names the plotting function instead of the
|
|
81
|
+
wrapper:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
backstamp.save(fig, name, fig_dir=out_dir, skip_modules=(__name__,))
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Licence
|
|
88
|
+
|
|
89
|
+
BSD-3-Clause
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# backstamp
|
|
2
|
+
|
|
3
|
+
A saved figure says nothing about the code that made it. Weeks later, finding the commit,
|
|
4
|
+
the function and the parameters behind a plot means searching old scripts, or making it
|
|
5
|
+
again.
|
|
6
|
+
|
|
7
|
+
backstamp writes that record into the figure file when it is saved, and reads it back.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pip install backstamp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Python 3.9 or later. Installs matplotlib and pypdf.
|
|
16
|
+
|
|
17
|
+
## Save
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import backstamp
|
|
21
|
+
|
|
22
|
+
def plot_accuracy(trials, window=50):
|
|
23
|
+
fig, ax = plt.subplots()
|
|
24
|
+
...
|
|
25
|
+
backstamp.save(fig, "accuracy", fig_dir="figures/")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This writes `figures/accuracy.pdf` with a record of:
|
|
29
|
+
|
|
30
|
+
- the commit of the repository the calling code lives in, marked `-dirty` when it has
|
|
31
|
+
uncommitted changes
|
|
32
|
+
- the installed version of the calling package
|
|
33
|
+
- the function that called `save`: its module, file, line, and the functions enclosing it
|
|
34
|
+
- that function's parameters. Data objects are stored as their type and shape
|
|
35
|
+
(`<DataFrame 50x2>`, `<ndarray (3, 4) float64>`), not their values.
|
|
36
|
+
- when the figure was made
|
|
37
|
+
|
|
38
|
+
Parameters are recorded with the values they have when `save` runs, so a parameter
|
|
39
|
+
reassigned inside the function is recorded with its new value.
|
|
40
|
+
|
|
41
|
+
The record is JSON in the PDF's `Subject` field, with a one-line summary in `Title`.
|
|
42
|
+
|
|
43
|
+
## Read
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from backstamp.metadata import read_figure_metadata
|
|
47
|
+
|
|
48
|
+
read_figure_metadata("figures/accuracy.pdf")
|
|
49
|
+
# {"commit": "a1b2c3d", "function": "plot_accuracy",
|
|
50
|
+
# "params": {"trials": "<DataFrame 50x2>", "window": 50}, ...}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Returns `{}` for a figure without a record, a file that is not a readable PDF, or a missing
|
|
54
|
+
file.
|
|
55
|
+
|
|
56
|
+
## Wrapping `save`
|
|
57
|
+
|
|
58
|
+
The record names the nearest calling function outside backstamp. A function that wraps
|
|
59
|
+
`save` passes its own module, so the record names the plotting function instead of the
|
|
60
|
+
wrapper:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
backstamp.save(fig, name, fig_dir=out_dir, skip_modules=(__name__,))
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Licence
|
|
67
|
+
|
|
68
|
+
BSD-3-Clause
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "backstamp"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Record how a figure was made inside the figure file itself: commit, calling function, parameters."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "BSD-3-Clause"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Joschua Geuter" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = ["matplotlib", "pypdf>=3"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
17
|
+
"Framework :: Matplotlib",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Visualization",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
test = ["pytest"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Repository = "https://github.com/Joschua21/backstamp"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.dynamic]
|
|
30
|
+
version = { attr = "backstamp.__version__" }
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Record how a figure was made inside the figure file itself.
|
|
2
|
+
|
|
3
|
+
- `save`: write a figure as PDF with its record embedded.
|
|
4
|
+
- `metadata.read_figure_metadata`: read the record back.
|
|
5
|
+
- `provenance`: the record itself -- commit, version, and the call that made the figure.
|
|
6
|
+
"""
|
|
7
|
+
from .save import save
|
|
8
|
+
|
|
9
|
+
__version__ = "0.0.1"
|
|
10
|
+
|
|
11
|
+
__all__ = ["save", "__version__"]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Provenance embedded in the saved PDF itself -- no sidecar, still one file.
|
|
2
|
+
|
|
3
|
+
Open a figure months later and recover what it shows and how it was made, from the file
|
|
4
|
+
you are already looking at. A sidecar JSON solves the same problem right up until
|
|
5
|
+
someone emails the PDF on its own.
|
|
6
|
+
|
|
7
|
+
**The constraint that shapes this** (verified against the matplotlib PDF backend): the
|
|
8
|
+
PDF info dictionary accepts only ``Title``, ``Author``, ``Subject``, ``Keywords``,
|
|
9
|
+
``Creator``, ``Producer``, ``CreationDate``, ``ModDate`` and ``Trapped``. Any other key
|
|
10
|
+
is dropped with a ``UserWarning``, and ``CreationDate`` must be a real ``datetime``. So
|
|
11
|
+
the structured record goes into ``Subject`` as JSON, with a human-readable one-liner in
|
|
12
|
+
``Title`` -- the field a PDF viewer shows without being asked.
|
|
13
|
+
|
|
14
|
+
Reading uses `pypdf`. matplotlib writes the info dictionary as an uncompressed literal
|
|
15
|
+
string, so a stdlib parser is the fallback when pypdf cannot open a file.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .provenance import provenance as _provenance
|
|
25
|
+
|
|
26
|
+
# A PDF string value has to stay a reasonable size; beyond this the record is truncated
|
|
27
|
+
# rather than risking an unwieldy info dictionary.
|
|
28
|
+
MAX_BLOB = 3000
|
|
29
|
+
|
|
30
|
+
# The keys matplotlib will accept. Anything else is silently discarded, so filter here
|
|
31
|
+
# rather than letting a warning appear at every save.
|
|
32
|
+
_ALLOWED = frozenset({
|
|
33
|
+
"Title", "Author", "Subject", "Keywords", "Creator", "Producer",
|
|
34
|
+
"CreationDate", "ModDate", "Trapped",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def summarize_scope(save_name: str, subjids=None, dates=None) -> str:
|
|
39
|
+
"""The one-line human summary that lands in ``Title``.
|
|
40
|
+
|
|
41
|
+
e.g. ``"accuracy sub-040,045,066 20251124-20260203"``.
|
|
42
|
+
"""
|
|
43
|
+
parts = [save_name] if save_name else []
|
|
44
|
+
subj = _as_list(subjids)
|
|
45
|
+
date = _as_list(dates)
|
|
46
|
+
if subj:
|
|
47
|
+
parts.append("sub-" + ",".join(_fmt_subj(s) for s in subj))
|
|
48
|
+
if date:
|
|
49
|
+
dates_sorted = sorted(str(d) for d in date)
|
|
50
|
+
parts.append(
|
|
51
|
+
dates_sorted[0] if len(dates_sorted) == 1
|
|
52
|
+
else f"{dates_sorted[0]}-{dates_sorted[-1]}"
|
|
53
|
+
)
|
|
54
|
+
return " ".join(parts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _as_list(value):
|
|
58
|
+
if value is None:
|
|
59
|
+
return []
|
|
60
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
61
|
+
return list(value)
|
|
62
|
+
return [value]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fmt_subj(value):
|
|
66
|
+
try:
|
|
67
|
+
return f"{int(value):03d}"
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
return str(value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_pdf_metadata(save_name: str, *, subjids=None, dates=None,
|
|
73
|
+
provenance: dict | None = None, call: dict | None = None,
|
|
74
|
+
extra: dict | None = None) -> dict:
|
|
75
|
+
"""The matplotlib ``metadata=`` dict for one figure.
|
|
76
|
+
|
|
77
|
+
``provenance`` overrides capture entirely; otherwise a record is built from ``call``
|
|
78
|
+
(a `capture_call` result) or, failing that, captured here.
|
|
79
|
+
"""
|
|
80
|
+
record = provenance if provenance is not None else _provenance(call=call)
|
|
81
|
+
if subjids is not None:
|
|
82
|
+
record.setdefault("subjids", [_fmt_subj(s) for s in _as_list(subjids)])
|
|
83
|
+
if dates is not None:
|
|
84
|
+
record.setdefault("dates", [str(d) for d in _as_list(dates)])
|
|
85
|
+
if extra:
|
|
86
|
+
record.update(extra)
|
|
87
|
+
|
|
88
|
+
blob = _dump(record)
|
|
89
|
+
creator = "hypnose"
|
|
90
|
+
if record.get("version"):
|
|
91
|
+
creator = f"{record.get('module', 'hypnose')} {record['version']}"
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"Title": summarize_scope(save_name, subjids, dates),
|
|
95
|
+
"Subject": blob,
|
|
96
|
+
"Creator": creator,
|
|
97
|
+
"CreationDate": datetime.now(), # must be a datetime, not a string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _dump(record: dict) -> str:
|
|
102
|
+
"""JSON, ASCII-only, shrunk by dropping the bulkiest field if it will not fit.
|
|
103
|
+
|
|
104
|
+
``ensure_ascii=True`` is load-bearing, not tidiness: matplotlib writes a PDF string
|
|
105
|
+
containing any non-ASCII character as UTF-16BE with a byte-order mark instead of
|
|
106
|
+
latin-1. Keeping the blob ASCII keeps the encoding predictable for every reader.
|
|
107
|
+
"""
|
|
108
|
+
blob = json.dumps(record, default=str)
|
|
109
|
+
if len(blob) <= MAX_BLOB:
|
|
110
|
+
return blob
|
|
111
|
+
trimmed = dict(record)
|
|
112
|
+
trimmed["params"] = {"...": "omitted (too large)"}
|
|
113
|
+
blob = json.dumps(trimmed, default=str)
|
|
114
|
+
return blob if len(blob) <= MAX_BLOB else blob[: MAX_BLOB - 3] + "..."
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def filter_metadata(metadata: dict) -> dict:
|
|
118
|
+
"""Drop keys matplotlib would reject, so saving never emits a UserWarning."""
|
|
119
|
+
return {k: v for k, v in (metadata or {}).items() if k in _ALLOWED}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- reading back ---------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def read_figure_metadata(path) -> dict:
|
|
126
|
+
"""Recover a saved figure's provenance.
|
|
127
|
+
|
|
128
|
+
Returns the parsed ``Subject`` record, plus ``_title`` and ``_creator``.
|
|
129
|
+
|
|
130
|
+
Yields ``{}`` for a PDF carrying no provenance, an unreadable one, or a missing
|
|
131
|
+
file -- never raises, since this is a diagnostic tool and failing hard on an old
|
|
132
|
+
figure helps nobody. Note the emptiness is deliberate rather than incidental:
|
|
133
|
+
matplotlib stamps every PDF with its own ``Creator``, so returning that alone would
|
|
134
|
+
make ``if not read_figure_metadata(p)`` stop meaning "no provenance here".
|
|
135
|
+
"""
|
|
136
|
+
path = Path(path)
|
|
137
|
+
if not path.exists():
|
|
138
|
+
return {}
|
|
139
|
+
info = _read_info_pypdf(path)
|
|
140
|
+
if info is None:
|
|
141
|
+
info = _read_info_stdlib(path)
|
|
142
|
+
|
|
143
|
+
subject = (info or {}).get("Subject")
|
|
144
|
+
if not subject:
|
|
145
|
+
return {}
|
|
146
|
+
|
|
147
|
+
out: dict = {}
|
|
148
|
+
try:
|
|
149
|
+
parsed = json.loads(subject)
|
|
150
|
+
except (ValueError, TypeError):
|
|
151
|
+
parsed = None
|
|
152
|
+
if isinstance(parsed, dict):
|
|
153
|
+
out.update(parsed)
|
|
154
|
+
else:
|
|
155
|
+
out["_subject"] = subject
|
|
156
|
+
if info.get("Title"):
|
|
157
|
+
out["_title"] = info["Title"]
|
|
158
|
+
if info.get("Creator"):
|
|
159
|
+
out["_creator"] = info["Creator"]
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _read_info_pypdf(path: Path) -> dict | None:
|
|
164
|
+
try:
|
|
165
|
+
from pypdf import PdfReader
|
|
166
|
+
except Exception:
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
meta = PdfReader(str(path)).metadata or {}
|
|
170
|
+
return {str(k).lstrip("/"): str(v) for k, v in meta.items()}
|
|
171
|
+
except Exception:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# `/Subject (....)` where the closing paren is not escaped. matplotlib writes the info
|
|
176
|
+
# dictionary uncompressed, so this finds it without a PDF parser.
|
|
177
|
+
_ENTRY_RE = r"/{key}\s*\((.*?)(?<!\\)\)"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _read_info_stdlib(path: Path) -> dict:
|
|
181
|
+
try:
|
|
182
|
+
raw = path.read_bytes()
|
|
183
|
+
except OSError:
|
|
184
|
+
return {}
|
|
185
|
+
info = {}
|
|
186
|
+
for key in ("Title", "Subject", "Creator"):
|
|
187
|
+
match = re.search(_ENTRY_RE.format(key=key).encode(), raw, re.S)
|
|
188
|
+
if match:
|
|
189
|
+
info[key] = _unescape(match.group(1))
|
|
190
|
+
return info
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_ESCAPES = {"n": "\n", "r": "\r", "t": "\t", "b": "\b", "f": "\f"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _unescape(value: bytes) -> str:
|
|
197
|
+
"""Undo PDF literal-string escaping and decode.
|
|
198
|
+
|
|
199
|
+
Two things that are easy to get wrong:
|
|
200
|
+
|
|
201
|
+
* Unescaping is one left-to-right pass, not a sequence of `str.replace` calls --
|
|
202
|
+
replacing ``\\(`` before ``\\\\`` corrupts a literal backslash preceding a paren.
|
|
203
|
+
* A PDF string holding any non-ASCII character is written as UTF-16BE behind a
|
|
204
|
+
byte-order mark. We keep our own blob ASCII, but a figure saved by other code (or
|
|
205
|
+
by an older version of this one) may not be, so honour the BOM.
|
|
206
|
+
"""
|
|
207
|
+
if value[:2] in (b"\xfe\xff", b"\xff\xfe"):
|
|
208
|
+
text = value.decode("utf-16", errors="replace")
|
|
209
|
+
else:
|
|
210
|
+
text = value.decode("utf-8", errors="replace")
|
|
211
|
+
return re.sub(r"\\(.)", lambda m: _ESCAPES.get(m.group(1), m.group(1)), text)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = [
|
|
215
|
+
"build_pdf_metadata", "read_figure_metadata", "summarize_scope", "filter_metadata",
|
|
216
|
+
"MAX_BLOB",
|
|
217
|
+
]
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""What produced this output: commit, version, and the call that made it.
|
|
2
|
+
|
|
3
|
+
A figure or a results file that cannot be traced back to the code that made it is a
|
|
4
|
+
figure you have to reproduce from scratch before you can trust it. This module builds a
|
|
5
|
+
small, JSON-safe record answering "which code, which version, called how" -- embedded in
|
|
6
|
+
saved PDFs (`metadata`) and, from Phase 7, in `manifest.json` as well. One
|
|
7
|
+
implementation, so the two cannot drift.
|
|
8
|
+
|
|
9
|
+
**Anchoring, and why there is no default.** `git_commit()` takes the path of the code
|
|
10
|
+
whose commit you want. It must be the *caller's* file: backstamp is installed as a
|
|
11
|
+
library, so anchoring on ``__file__`` here would stamp every figure from every repo with
|
|
12
|
+
the backstamp commit -- plausible-looking and wrong, the same silent-resolution failure
|
|
13
|
+
that `io/paths` hit in restructure_2 Phase 2a and `io/layout` in 2b. `capture_call()`
|
|
14
|
+
finds the calling frame, and `provenance()` anchors on its file.
|
|
15
|
+
|
|
16
|
+
Nothing here raises. Provenance is a nice-to-have attached to real work, so a detached
|
|
17
|
+
HEAD, a missing `git`, an unreadable frame or an exotic argument type degrades to a
|
|
18
|
+
`None` field or a `<TypeName>` placeholder -- never a failed save.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import inspect
|
|
23
|
+
import subprocess
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# Truncation limits. The PDF info dictionary is not a database: the record has to stay
|
|
28
|
+
# small enough to sit in one string value, so containers are sampled rather than stored.
|
|
29
|
+
MAX_ITEMS = 10
|
|
30
|
+
MAX_STR = 80
|
|
31
|
+
MAX_PARAMS = 15
|
|
32
|
+
|
|
33
|
+
# How many enclosing function names to keep alongside `function`. Four reaches past a
|
|
34
|
+
# local _save_fig closure and its enclosing analysis function without bloating the blob.
|
|
35
|
+
MAX_CHAIN = 4
|
|
36
|
+
|
|
37
|
+
# Frames belonging to the saving machinery itself, never the answer to "who plotted this".
|
|
38
|
+
_SKIP_MODULES = (
|
|
39
|
+
"backstamp.provenance",
|
|
40
|
+
"backstamp.save",
|
|
41
|
+
"backstamp.metadata",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_GIT_TIMEOUT = 5
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- git ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _git(args, cwd) -> str | None:
|
|
51
|
+
try:
|
|
52
|
+
out = subprocess.run(
|
|
53
|
+
["git", *args], cwd=str(cwd), capture_output=True, text=True,
|
|
54
|
+
timeout=_GIT_TIMEOUT,
|
|
55
|
+
)
|
|
56
|
+
except (OSError, subprocess.SubprocessError):
|
|
57
|
+
return None
|
|
58
|
+
return out.stdout.strip() if out.returncode == 0 else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def git_commit(anchor) -> str | None:
|
|
62
|
+
"""Short commit of the repo containing ``anchor``, ``-dirty`` if it has changes.
|
|
63
|
+
|
|
64
|
+
``anchor`` is a file or directory *inside the repo you want described* -- see the
|
|
65
|
+
module docstring on why this has no default.
|
|
66
|
+
|
|
67
|
+
The dirty suffix is not decoration: a bare hash on a modified tree points at code
|
|
68
|
+
that is not what ran, which is worse than recording nothing.
|
|
69
|
+
"""
|
|
70
|
+
if anchor is None:
|
|
71
|
+
return None
|
|
72
|
+
anchor = Path(anchor)
|
|
73
|
+
cwd = anchor.parent if anchor.is_file() else anchor
|
|
74
|
+
if not cwd.exists():
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
commit = _git(["rev-parse", "--short", "HEAD"], cwd)
|
|
78
|
+
if not commit:
|
|
79
|
+
return None
|
|
80
|
+
# `git status` can be slow on a large tree; this is the cheap equivalent that still
|
|
81
|
+
# sees both tracked modifications and untracked files.
|
|
82
|
+
status = _git(["status", "--porcelain", "--untracked-files=normal"], cwd)
|
|
83
|
+
return f"{commit}-dirty" if status else commit
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def package_version(module_name: str | None) -> str | None:
|
|
87
|
+
"""Installed version of the distribution providing ``module_name``'s top package.
|
|
88
|
+
|
|
89
|
+
The import package and the distribution are routinely named differently -- this
|
|
90
|
+
family ships `hypnose_behavior` from `hypnose-behavior-analysis` -- so guessing by
|
|
91
|
+
swapping underscores for hyphens finds nothing. `packages_distributions()` is the
|
|
92
|
+
real mapping; the guess is only a fallback for Python 3.9, which lacks it.
|
|
93
|
+
"""
|
|
94
|
+
if not module_name:
|
|
95
|
+
return None
|
|
96
|
+
root = module_name.split(".")[0]
|
|
97
|
+
try:
|
|
98
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
99
|
+
except Exception:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
from importlib.metadata import packages_distributions
|
|
104
|
+
|
|
105
|
+
for dist in packages_distributions().get(root, ()):
|
|
106
|
+
try:
|
|
107
|
+
return version(dist)
|
|
108
|
+
except PackageNotFoundError:
|
|
109
|
+
continue
|
|
110
|
+
except ImportError:
|
|
111
|
+
pass # Python 3.9
|
|
112
|
+
|
|
113
|
+
for candidate in (root, root.replace("_", "-")):
|
|
114
|
+
try:
|
|
115
|
+
return version(candidate)
|
|
116
|
+
except Exception:
|
|
117
|
+
continue
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- value summarising ----------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def summarize_value(value, *, max_items: int = MAX_ITEMS, max_str: int = MAX_STR):
|
|
125
|
+
"""Render one call argument as something small and JSON-safe.
|
|
126
|
+
|
|
127
|
+
Scalars pass through, strings truncate, containers are sampled, and arrays and
|
|
128
|
+
frames become shape descriptors (``<DataFrame 50x2>``) rather than being serialised
|
|
129
|
+
-- the point is to recognise the call later, not to reconstruct its inputs.
|
|
130
|
+
"""
|
|
131
|
+
if value is None or isinstance(value, bool):
|
|
132
|
+
return value
|
|
133
|
+
if isinstance(value, int):
|
|
134
|
+
return value
|
|
135
|
+
if isinstance(value, float):
|
|
136
|
+
# Guard against NaN/inf, which are not valid JSON.
|
|
137
|
+
return value if value == value and abs(value) != float("inf") else str(value)
|
|
138
|
+
if isinstance(value, str):
|
|
139
|
+
return value if len(value) <= max_str else value[: max_str - 3] + "..."
|
|
140
|
+
if isinstance(value, Path):
|
|
141
|
+
return summarize_value(str(value), max_str=max_str)
|
|
142
|
+
|
|
143
|
+
described = _describe_scientific(value)
|
|
144
|
+
if described is not None:
|
|
145
|
+
return described
|
|
146
|
+
|
|
147
|
+
if isinstance(value, dict):
|
|
148
|
+
out = {
|
|
149
|
+
str(k): summarize_value(v, max_items=max_items, max_str=max_str)
|
|
150
|
+
for k, v in list(value.items())[:max_items]
|
|
151
|
+
}
|
|
152
|
+
if len(value) > max_items:
|
|
153
|
+
out["..."] = f"+{len(value) - max_items} more"
|
|
154
|
+
return out
|
|
155
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
156
|
+
items = list(value)
|
|
157
|
+
out = [summarize_value(v, max_items=max_items, max_str=max_str)
|
|
158
|
+
for v in items[:max_items]]
|
|
159
|
+
if len(items) > max_items:
|
|
160
|
+
out.append(f"...+{len(items) - max_items} more")
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
return f"<{type(value).__name__}>"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _describe_scientific(value) -> str | None:
|
|
167
|
+
"""Shape descriptors for pandas/numpy/matplotlib objects, without importing them.
|
|
168
|
+
|
|
169
|
+
Dispatching on the type's module keeps this module importable (and fast) in an
|
|
170
|
+
environment where numpy or matplotlib is absent, and avoids paying an import just to
|
|
171
|
+
describe an argument.
|
|
172
|
+
"""
|
|
173
|
+
module = type(value).__module__.split(".")[0]
|
|
174
|
+
name = type(value).__name__
|
|
175
|
+
|
|
176
|
+
if module == "pandas":
|
|
177
|
+
if name == "DataFrame":
|
|
178
|
+
try:
|
|
179
|
+
return f"<DataFrame {value.shape[0]}x{value.shape[1]}>"
|
|
180
|
+
except Exception:
|
|
181
|
+
return "<DataFrame>"
|
|
182
|
+
if name == "Series":
|
|
183
|
+
try:
|
|
184
|
+
return f"<Series {len(value)} {value.dtype}>"
|
|
185
|
+
except Exception:
|
|
186
|
+
return "<Series>"
|
|
187
|
+
if module == "numpy" and name == "ndarray":
|
|
188
|
+
try:
|
|
189
|
+
return f"<ndarray {tuple(value.shape)} {value.dtype}>"
|
|
190
|
+
except Exception:
|
|
191
|
+
return "<ndarray>"
|
|
192
|
+
if module == "matplotlib":
|
|
193
|
+
return f"<{name}>"
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# --- the calling frame ----------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def capture_call(*, skip_modules=(), max_params: int = MAX_PARAMS) -> dict | None:
|
|
201
|
+
"""Describe the nearest frame that is not saving machinery.
|
|
202
|
+
|
|
203
|
+
Returns ``{"function", "file", "lineno", "params"}``, where ``params`` covers the
|
|
204
|
+
named arguments *and* anything that arrived via ``**kwargs``.
|
|
205
|
+
|
|
206
|
+
Also returns ``chain``: the next few enclosing function names. Frame-walking is
|
|
207
|
+
inherently fragile -- ``function`` is only ever "the nearest frame we did not skip",
|
|
208
|
+
and in real code that is often a local ``_save_fig`` closure rather than the analysis
|
|
209
|
+
that produced the figure. The chain makes the real caller recoverable anyway, which a
|
|
210
|
+
single frame cannot. Extend ``skip_modules``, or pass an explicit record to
|
|
211
|
+
`save`, when even that is not enough.
|
|
212
|
+
"""
|
|
213
|
+
skip = set(_SKIP_MODULES) | set(skip_modules)
|
|
214
|
+
try:
|
|
215
|
+
stack = inspect.stack()
|
|
216
|
+
except Exception:
|
|
217
|
+
return None
|
|
218
|
+
try:
|
|
219
|
+
kept = [
|
|
220
|
+
entry for entry in stack[1:]
|
|
221
|
+
if (entry.frame.f_globals.get("__name__", "") not in skip)
|
|
222
|
+
and not entry.frame.f_globals.get("__name__", "").startswith(
|
|
223
|
+
("importlib", "runpy"))
|
|
224
|
+
]
|
|
225
|
+
if not kept:
|
|
226
|
+
return None
|
|
227
|
+
entry = kept[0]
|
|
228
|
+
return {
|
|
229
|
+
"function": entry.function,
|
|
230
|
+
"module": entry.frame.f_globals.get("__name__", ""),
|
|
231
|
+
"file": Path(entry.filename).name,
|
|
232
|
+
"path": entry.filename,
|
|
233
|
+
"lineno": entry.lineno,
|
|
234
|
+
"chain": [e.function for e in kept[:MAX_CHAIN]],
|
|
235
|
+
"params": _frame_params(entry.frame, max_params),
|
|
236
|
+
}
|
|
237
|
+
except Exception:
|
|
238
|
+
return None
|
|
239
|
+
finally:
|
|
240
|
+
# inspect.stack() holds frame references; dropping them promptly keeps large
|
|
241
|
+
# plotting locals from being kept alive by a reference cycle.
|
|
242
|
+
del stack
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _frame_params(frame, max_params: int) -> dict:
|
|
246
|
+
try:
|
|
247
|
+
info = inspect.getargvalues(frame)
|
|
248
|
+
except Exception:
|
|
249
|
+
return {}
|
|
250
|
+
params: dict = {}
|
|
251
|
+
for name in info.args:
|
|
252
|
+
if name in ("self", "cls"):
|
|
253
|
+
continue
|
|
254
|
+
params[name] = summarize_value(info.locals.get(name))
|
|
255
|
+
if info.keywords: # the **kwargs catch-all: record what was actually passed
|
|
256
|
+
for name, value in (info.locals.get(info.keywords) or {}).items():
|
|
257
|
+
params[str(name)] = summarize_value(value)
|
|
258
|
+
if len(params) > max_params:
|
|
259
|
+
kept = dict(list(params.items())[:max_params])
|
|
260
|
+
kept["..."] = f"+{len(params) - max_params} more"
|
|
261
|
+
return kept
|
|
262
|
+
return params
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# --- the record -----------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def provenance(*, anchor=None, call: dict | None = None, extra: dict | None = None,
|
|
269
|
+
skip_modules=()) -> dict:
|
|
270
|
+
"""A record of what produced this output.
|
|
271
|
+
|
|
272
|
+
``call`` is a `capture_call` result; when omitted, one is captured here. ``anchor``
|
|
273
|
+
defaults to the calling code's own file, so the commit describes the repo that ran
|
|
274
|
+
rather than whichever repo happens to host this function.
|
|
275
|
+
|
|
276
|
+
``skip_modules`` is forwarded to `capture_call`. A repo whose own save function is
|
|
277
|
+
a thin wrapper **must** pass its module name, or the capture names the wrapper
|
|
278
|
+
instead of the plotting function that called it.
|
|
279
|
+
"""
|
|
280
|
+
if call is None:
|
|
281
|
+
call = capture_call(skip_modules=skip_modules)
|
|
282
|
+
if anchor is None and call is not None:
|
|
283
|
+
anchor = call.get("path")
|
|
284
|
+
|
|
285
|
+
record = {
|
|
286
|
+
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
287
|
+
"commit": git_commit(anchor),
|
|
288
|
+
"version": package_version(call.get("module") if call else None),
|
|
289
|
+
}
|
|
290
|
+
if call is not None:
|
|
291
|
+
record.update(
|
|
292
|
+
{k: call[k] for k in
|
|
293
|
+
("function", "module", "file", "lineno", "chain", "params") if k in call}
|
|
294
|
+
)
|
|
295
|
+
if extra:
|
|
296
|
+
record.update(extra)
|
|
297
|
+
return {k: v for k, v in record.items() if v is not None}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
__all__ = [
|
|
301
|
+
"provenance", "git_commit", "package_version", "summarize_value", "capture_call",
|
|
302
|
+
"MAX_ITEMS", "MAX_STR", "MAX_PARAMS", "MAX_CHAIN",
|
|
303
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Save a figure as PDF with its provenance record embedded."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .metadata import build_pdf_metadata, filter_metadata
|
|
7
|
+
from .provenance import provenance as _provenance
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def save(fig, name: str, *, fig_dir, filename=None, subjids=None, dates=None,
|
|
11
|
+
dpi: int = 600, bbox_inches=None, provenance=None, metadata=None,
|
|
12
|
+
skip_modules=()) -> Path:
|
|
13
|
+
"""Save ``fig`` as ``fig_dir / filename``, with its provenance embedded.
|
|
14
|
+
|
|
15
|
+
- ``name`` heads the ``Title`` summary; ``filename`` defaults to ``{name}.pdf``.
|
|
16
|
+
- ``fig_dir`` is created if absent.
|
|
17
|
+
- ``subjids`` / ``dates`` go into the record and the ``Title``.
|
|
18
|
+
- ``provenance``: a `provenance.provenance()` record to embed instead of capturing one.
|
|
19
|
+
- ``skip_modules``: modules the frame walk passes over. A wrapper around `save` must
|
|
20
|
+
pass its own, or the record names the wrapper instead of the plotting function.
|
|
21
|
+
- ``metadata``: extra PDF info-dictionary entries; keys outside the PDF standard set
|
|
22
|
+
are dropped.
|
|
23
|
+
|
|
24
|
+
Read it back with `metadata.read_figure_metadata(path)`.
|
|
25
|
+
"""
|
|
26
|
+
# Imported here, so `import backstamp.provenance` stays standard library only.
|
|
27
|
+
import matplotlib as mpl
|
|
28
|
+
|
|
29
|
+
if fig is None:
|
|
30
|
+
raise ValueError("fig cannot be None")
|
|
31
|
+
if not name:
|
|
32
|
+
raise ValueError("name must be non-empty")
|
|
33
|
+
|
|
34
|
+
fig_dir = Path(fig_dir)
|
|
35
|
+
fig_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
out_path = fig_dir / (filename or f"{name}.pdf")
|
|
37
|
+
|
|
38
|
+
bbox = bbox_inches if bbox_inches is not None else "tight"
|
|
39
|
+
|
|
40
|
+
# Provenance is attached to real work, so it must never be the reason a save fails:
|
|
41
|
+
# a broken frame walk or an unreadable git tree costs the metadata, not the figure.
|
|
42
|
+
try:
|
|
43
|
+
record = provenance if provenance is not None else _provenance(
|
|
44
|
+
skip_modules=skip_modules)
|
|
45
|
+
info = build_pdf_metadata(name, subjids=subjids, dates=dates, provenance=record)
|
|
46
|
+
info.update(metadata or {})
|
|
47
|
+
info = filter_metadata(info)
|
|
48
|
+
except Exception:
|
|
49
|
+
info = filter_metadata(metadata or {})
|
|
50
|
+
|
|
51
|
+
# Type 42 (TrueType) keeps PDF text editable/searchable; matplotlib's default of
|
|
52
|
+
# Type 3 does not, and journals reject it. Enforced here, scoped, so a caller that
|
|
53
|
+
# applied no style still gets it.
|
|
54
|
+
with mpl.rc_context({"pdf.fonttype": 42, "ps.fonttype": 42}):
|
|
55
|
+
fig.savefig(out_path, bbox_inches=bbox, dpi=dpi, metadata=info)
|
|
56
|
+
|
|
57
|
+
return out_path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["save"]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: backstamp
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Record how a figure was made inside the figure file itself: commit, calling function, parameters.
|
|
5
|
+
Author: Joschua Geuter
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Repository, https://github.com/Joschua21/backstamp
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: Framework :: Matplotlib
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: matplotlib
|
|
17
|
+
Requires-Dist: pypdf>=3
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest; extra == "test"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# backstamp
|
|
23
|
+
|
|
24
|
+
A saved figure says nothing about the code that made it. Weeks later, finding the commit,
|
|
25
|
+
the function and the parameters behind a plot means searching old scripts, or making it
|
|
26
|
+
again.
|
|
27
|
+
|
|
28
|
+
backstamp writes that record into the figure file when it is saved, and reads it back.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install backstamp
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Python 3.9 or later. Installs matplotlib and pypdf.
|
|
37
|
+
|
|
38
|
+
## Save
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import backstamp
|
|
42
|
+
|
|
43
|
+
def plot_accuracy(trials, window=50):
|
|
44
|
+
fig, ax = plt.subplots()
|
|
45
|
+
...
|
|
46
|
+
backstamp.save(fig, "accuracy", fig_dir="figures/")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This writes `figures/accuracy.pdf` with a record of:
|
|
50
|
+
|
|
51
|
+
- the commit of the repository the calling code lives in, marked `-dirty` when it has
|
|
52
|
+
uncommitted changes
|
|
53
|
+
- the installed version of the calling package
|
|
54
|
+
- the function that called `save`: its module, file, line, and the functions enclosing it
|
|
55
|
+
- that function's parameters. Data objects are stored as their type and shape
|
|
56
|
+
(`<DataFrame 50x2>`, `<ndarray (3, 4) float64>`), not their values.
|
|
57
|
+
- when the figure was made
|
|
58
|
+
|
|
59
|
+
Parameters are recorded with the values they have when `save` runs, so a parameter
|
|
60
|
+
reassigned inside the function is recorded with its new value.
|
|
61
|
+
|
|
62
|
+
The record is JSON in the PDF's `Subject` field, with a one-line summary in `Title`.
|
|
63
|
+
|
|
64
|
+
## Read
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from backstamp.metadata import read_figure_metadata
|
|
68
|
+
|
|
69
|
+
read_figure_metadata("figures/accuracy.pdf")
|
|
70
|
+
# {"commit": "a1b2c3d", "function": "plot_accuracy",
|
|
71
|
+
# "params": {"trials": "<DataFrame 50x2>", "window": 50}, ...}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Returns `{}` for a figure without a record, a file that is not a readable PDF, or a missing
|
|
75
|
+
file.
|
|
76
|
+
|
|
77
|
+
## Wrapping `save`
|
|
78
|
+
|
|
79
|
+
The record names the nearest calling function outside backstamp. A function that wraps
|
|
80
|
+
`save` passes its own module, so the record names the plotting function instead of the
|
|
81
|
+
wrapper:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
backstamp.save(fig, name, fig_dir=out_dir, skip_modules=(__name__,))
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Licence
|
|
88
|
+
|
|
89
|
+
BSD-3-Clause
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/backstamp/__init__.py
|
|
5
|
+
src/backstamp/metadata.py
|
|
6
|
+
src/backstamp/provenance.py
|
|
7
|
+
src/backstamp/save.py
|
|
8
|
+
src/backstamp.egg-info/PKG-INFO
|
|
9
|
+
src/backstamp.egg-info/SOURCES.txt
|
|
10
|
+
src/backstamp.egg-info/dependency_links.txt
|
|
11
|
+
src/backstamp.egg-info/requires.txt
|
|
12
|
+
src/backstamp.egg-info/top_level.txt
|
|
13
|
+
tests/test_provenance.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
backstamp
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Unit tests for `backstamp.provenance`, `backstamp.metadata` and `backstamp.save`.
|
|
3
|
+
|
|
4
|
+
Run directly (no pytest needed)::
|
|
5
|
+
|
|
6
|
+
python tests/test_provenance.py
|
|
7
|
+
|
|
8
|
+
The value summariser is where the work is: every argument any plotting function has ever
|
|
9
|
+
been called with has to become small, JSON-safe text without raising. Most of the cases
|
|
10
|
+
below are that.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
21
|
+
|
|
22
|
+
import matplotlib # noqa: E402
|
|
23
|
+
matplotlib.use("Agg")
|
|
24
|
+
import matplotlib.pyplot as plt # noqa: E402
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import pandas as pd # noqa: E402
|
|
28
|
+
except ImportError: # not a dependency; the pandas cases run where it is installed
|
|
29
|
+
pd = None
|
|
30
|
+
|
|
31
|
+
from backstamp.provenance import ( # noqa: E402
|
|
32
|
+
capture_call, git_commit, provenance, summarize_value,
|
|
33
|
+
)
|
|
34
|
+
from backstamp.metadata import ( # noqa: E402
|
|
35
|
+
MAX_BLOB, build_pdf_metadata, filter_metadata, read_figure_metadata, summarize_scope,
|
|
36
|
+
)
|
|
37
|
+
from backstamp.save import save # noqa: E402
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --- the value summariser -------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_scalars_pass_through():
|
|
44
|
+
for value in (None, True, False, 0, -3, 2.5, "short"):
|
|
45
|
+
assert summarize_value(value) == value, value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_long_strings_truncate():
|
|
49
|
+
out = summarize_value("x" * 500)
|
|
50
|
+
assert len(out) <= 80 and out.endswith("...")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_non_finite_floats_become_strings():
|
|
54
|
+
"""NaN and inf are not valid JSON; json.dumps emits them anyway, so catch them here."""
|
|
55
|
+
for value in (float("nan"), float("inf"), float("-inf")):
|
|
56
|
+
assert isinstance(summarize_value(value), str)
|
|
57
|
+
assert json.loads(json.dumps({"v": summarize_value(float("nan"))}))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_containers_truncate_and_report_the_remainder():
|
|
61
|
+
out = summarize_value(list(range(50)))
|
|
62
|
+
assert len(out) == 11 and out[-1] == "...+40 more"
|
|
63
|
+
out = summarize_value({f"k{i}": i for i in range(50)})
|
|
64
|
+
assert len(out) == 11 and out["..."] == "+40 more"
|
|
65
|
+
assert summarize_value((1, 2)) == [1, 2]
|
|
66
|
+
assert sorted(summarize_value({3, 1, 2})) == [1, 2, 3]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_nested_containers_are_summarised_recursively():
|
|
70
|
+
assert summarize_value({"a": [1, {"b": "x" * 500}]})["a"][1]["b"].endswith("...")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_scientific_types_become_descriptors():
|
|
74
|
+
import numpy as np
|
|
75
|
+
|
|
76
|
+
assert summarize_value(np.zeros((100, 3))) == "<ndarray (100, 3) float64>"
|
|
77
|
+
fig, ax = plt.subplots()
|
|
78
|
+
assert summarize_value(ax).startswith("<Axes")
|
|
79
|
+
plt.close(fig)
|
|
80
|
+
if pd is not None:
|
|
81
|
+
assert summarize_value(pd.DataFrame({"a": [1, 2], "b": [3, 4]})) == "<DataFrame 2x2>"
|
|
82
|
+
assert summarize_value(pd.Series([1, 2, 3])).startswith("<Series 3 ")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_unknown_objects_become_type_names():
|
|
86
|
+
class Widget:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
assert summarize_value(Widget()) == "<Widget>"
|
|
90
|
+
assert summarize_value(Path("/tmp/x")) == "/tmp/x"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_summariser_output_is_json_serialisable():
|
|
94
|
+
import numpy as np
|
|
95
|
+
|
|
96
|
+
payload = {"arr": np.zeros(3), "nested": [{"x": None}],
|
|
97
|
+
"big": list(range(100)), "nan": float("nan")}
|
|
98
|
+
if pd is not None:
|
|
99
|
+
payload["df"] = pd.DataFrame({"a": [1]})
|
|
100
|
+
json.dumps(summarize_value(payload)) # must not raise
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- capturing the call ---------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _plotter(data, subjids, *, window=30, **kwargs):
|
|
107
|
+
return capture_call()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_capture_records_function_args_and_kwargs():
|
|
111
|
+
import numpy as np
|
|
112
|
+
|
|
113
|
+
got = _plotter(np.zeros((2, 1)), [40, 45], window=7, color="red")
|
|
114
|
+
assert got["function"] == "_plotter"
|
|
115
|
+
assert got["file"] == "test_provenance.py"
|
|
116
|
+
assert isinstance(got["lineno"], int)
|
|
117
|
+
assert got["params"]["data"] == "<ndarray (2, 1) float64>"
|
|
118
|
+
assert got["params"]["subjids"] == [40, 45]
|
|
119
|
+
assert got["params"]["window"] == 7
|
|
120
|
+
assert got["params"]["color"] == "red" # arrived via **kwargs
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_capture_skips_the_saving_machinery():
|
|
124
|
+
"""The first frame must never be provenance.py itself."""
|
|
125
|
+
assert capture_call()["function"] == "test_capture_skips_the_saving_machinery"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --- git ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_git_commit_reports_dirty_and_tolerates_a_non_repo():
|
|
132
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
133
|
+
root = Path(tmp)
|
|
134
|
+
assert git_commit(root) is None # not a repo -> None, never an exception
|
|
135
|
+
|
|
136
|
+
run = lambda *a: subprocess.run(a, cwd=root, capture_output=True) # noqa: E731
|
|
137
|
+
run("git", "init", "-q")
|
|
138
|
+
run("git", "config", "user.email", "t@t")
|
|
139
|
+
run("git", "config", "user.name", "t")
|
|
140
|
+
(root / "a.txt").write_text("one")
|
|
141
|
+
run("git", "add", "-A")
|
|
142
|
+
run("git", "commit", "-qm", "first")
|
|
143
|
+
|
|
144
|
+
clean = git_commit(root)
|
|
145
|
+
assert clean and not clean.endswith("-dirty")
|
|
146
|
+
|
|
147
|
+
(root / "a.txt").write_text("two")
|
|
148
|
+
assert git_commit(root) == f"{clean}-dirty"
|
|
149
|
+
|
|
150
|
+
assert git_commit(None) is None
|
|
151
|
+
assert git_commit(Path("/nonexistent/xyz")) is None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_provenance_anchors_on_the_caller_not_on_the_library():
|
|
155
|
+
"""The whole point: the commit must describe the code that ran.
|
|
156
|
+
|
|
157
|
+
backstamp is installed as a library, so anchoring on its own __file__ would stamp
|
|
158
|
+
every figure from every repo with the backstamp commit.
|
|
159
|
+
"""
|
|
160
|
+
record = provenance()
|
|
161
|
+
assert record["function"] == "test_provenance_anchors_on_the_caller_not_on_the_library"
|
|
162
|
+
assert record["file"] == "test_provenance.py"
|
|
163
|
+
assert "created_at" in record
|
|
164
|
+
json.dumps(record)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- the PDF ---------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_package_version_resolves_a_renamed_distribution():
|
|
171
|
+
"""Import package and distribution names often differ.
|
|
172
|
+
|
|
173
|
+
`hypnose_behavior` ships from `hypnose-behavior-analysis`, so swapping underscores
|
|
174
|
+
for hyphens finds nothing; only packages_distributions() maps it, from Python 3.10.
|
|
175
|
+
"""
|
|
176
|
+
from backstamp.provenance import package_version
|
|
177
|
+
|
|
178
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
179
|
+
info = Path(tmp) / "renamed_dist-1.2.3.dist-info"
|
|
180
|
+
info.mkdir()
|
|
181
|
+
(info / "METADATA").write_text(
|
|
182
|
+
"Metadata-Version: 2.1\nName: renamed-dist\nVersion: 1.2.3\n")
|
|
183
|
+
(info / "top_level.txt").write_text("other_name\n")
|
|
184
|
+
sys.path.insert(0, tmp)
|
|
185
|
+
try:
|
|
186
|
+
renamed = package_version("other_name.sub")
|
|
187
|
+
finally:
|
|
188
|
+
sys.path.remove(tmp)
|
|
189
|
+
if sys.version_info >= (3, 10):
|
|
190
|
+
assert renamed == "1.2.3", renamed
|
|
191
|
+
|
|
192
|
+
assert package_version("matplotlib.figure")
|
|
193
|
+
assert package_version("__main__") is None
|
|
194
|
+
assert package_version("definitely_not_installed_xyz") is None
|
|
195
|
+
assert package_version(None) is None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_summarize_scope():
|
|
199
|
+
assert summarize_scope("accuracy", 66, 20260709) == "accuracy sub-066 20260709"
|
|
200
|
+
assert summarize_scope("accuracy", [40, 45, 66], ["20251124", "20260203"]) == (
|
|
201
|
+
"accuracy sub-040,045,066 20251124-20260203")
|
|
202
|
+
assert summarize_scope("plain") == "plain"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_filter_metadata_drops_keys_matplotlib_would_reject():
|
|
206
|
+
out = filter_metadata({"Title": "t", "Subject": "s", "Custom": "x"})
|
|
207
|
+
assert out == {"Title": "t", "Subject": "s"}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_oversized_records_are_shrunk_not_dropped():
|
|
211
|
+
huge = {"function": "f", "params": {f"k{i}": "v" * 100 for i in range(200)}}
|
|
212
|
+
blob = build_pdf_metadata("n", provenance=huge)["Subject"]
|
|
213
|
+
assert len(blob) <= MAX_BLOB
|
|
214
|
+
assert json.loads(blob)["function"] == "f" # the identifying fields survive
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_pdf_round_trip():
|
|
218
|
+
"""Save a real figure and read its provenance back out."""
|
|
219
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
220
|
+
fig, ax = plt.subplots()
|
|
221
|
+
ax.plot([1, 2, 3], [1, 4, 9])
|
|
222
|
+
path = save(fig, "accuracy", fig_dir=tmp, subjids=[40, 66],
|
|
223
|
+
dates=[20251124, 20260203])
|
|
224
|
+
plt.close(fig)
|
|
225
|
+
assert path.exists() and path.name == "accuracy.pdf"
|
|
226
|
+
|
|
227
|
+
got = read_figure_metadata(path)
|
|
228
|
+
assert got["function"] == "test_pdf_round_trip"
|
|
229
|
+
assert got["file"] == "test_provenance.py"
|
|
230
|
+
assert got["subjids"] == ["040", "066"]
|
|
231
|
+
assert got["dates"] == ["20251124", "20260203"]
|
|
232
|
+
assert got["_title"] == "accuracy sub-040,066 20251124-20260203"
|
|
233
|
+
assert "created_at" in got
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_filename_and_a_new_fig_dir():
|
|
237
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
238
|
+
fig, ax = plt.subplots()
|
|
239
|
+
path = save(fig, "x", fig_dir=Path(tmp) / "new", filename="x_sub-040.pdf")
|
|
240
|
+
plt.close(fig)
|
|
241
|
+
assert path == Path(tmp) / "new" / "x_sub-040.pdf" and path.exists()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_explicit_provenance_overrides_introspection():
|
|
245
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
246
|
+
fig, ax = plt.subplots()
|
|
247
|
+
path = save(fig, "x", fig_dir=tmp, subjids=1,
|
|
248
|
+
provenance={"function": "the_real_plotter", "note": "explicit"})
|
|
249
|
+
plt.close(fig)
|
|
250
|
+
got = read_figure_metadata(path)
|
|
251
|
+
assert got["function"] == "the_real_plotter" and got["note"] == "explicit"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_saving_never_warns_about_infodict_keys():
|
|
255
|
+
import warnings
|
|
256
|
+
|
|
257
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
258
|
+
fig, ax = plt.subplots()
|
|
259
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
260
|
+
warnings.simplefilter("always")
|
|
261
|
+
save(fig, "x", fig_dir=tmp, subjids=1, metadata={"Bogus": "dropped"})
|
|
262
|
+
plt.close(fig)
|
|
263
|
+
assert not [w for w in caught if "infodict" in str(w.message)]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_reading_a_provenance_free_or_missing_pdf_returns_empty():
|
|
267
|
+
"""Empty must mean "no provenance", so `if not read_figure_metadata(p)` is usable.
|
|
268
|
+
|
|
269
|
+
matplotlib stamps a Creator on every PDF it writes, so a bare figure is not an
|
|
270
|
+
empty info dictionary -- it just has nothing of ours in it.
|
|
271
|
+
"""
|
|
272
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
273
|
+
fig, ax = plt.subplots()
|
|
274
|
+
bare = Path(tmp) / "bare.pdf"
|
|
275
|
+
fig.savefig(bare)
|
|
276
|
+
plt.close(fig)
|
|
277
|
+
assert read_figure_metadata(bare) == {}
|
|
278
|
+
assert read_figure_metadata(Path(tmp) / "nope.pdf") == {}
|
|
279
|
+
(Path(tmp) / "junk.pdf").write_bytes(b"not a pdf")
|
|
280
|
+
assert read_figure_metadata(Path(tmp) / "junk.pdf") == {}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_stdlib_reader_matches_on_awkward_characters():
|
|
284
|
+
"""Both readers: pypdf through read_figure_metadata, and the stdlib fallback directly."""
|
|
285
|
+
from backstamp.metadata import _read_info_stdlib
|
|
286
|
+
|
|
287
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
288
|
+
fig, ax = plt.subplots()
|
|
289
|
+
note = r"parens ( ) and \ backslash"
|
|
290
|
+
path = save(fig, "x", fig_dir=tmp, subjids=1,
|
|
291
|
+
provenance={"function": "f", "note": note})
|
|
292
|
+
plt.close(fig)
|
|
293
|
+
assert json.loads(_read_info_stdlib(path)["Subject"])["note"] == note
|
|
294
|
+
assert read_figure_metadata(path)["note"] == note
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_a_wrapper_can_exclude_itself():
|
|
298
|
+
"""A repo whose save function is a thin wrapper must not name itself as the plotter.
|
|
299
|
+
|
|
300
|
+
Capturing inside the wrapper is not enough -- capture_call still returns the
|
|
301
|
+
wrapper's own frame. Only the skip list fixes it.
|
|
302
|
+
"""
|
|
303
|
+
import types
|
|
304
|
+
|
|
305
|
+
# A stand-in for hypnose_behavior.io.save, in its own module namespace -- the
|
|
306
|
+
# wrapper and the plotter must not share one, or skipping hides both.
|
|
307
|
+
wrapper_mod = types.ModuleType("fake_repo.io.save")
|
|
308
|
+
wrapper_mod.provenance = provenance
|
|
309
|
+
exec(
|
|
310
|
+
"def save_figure():\n"
|
|
311
|
+
" return provenance(), provenance(skip_modules=('fake_repo.io.save',))\n",
|
|
312
|
+
wrapper_mod.__dict__,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def real_plotter():
|
|
316
|
+
return wrapper_mod.save_figure()
|
|
317
|
+
|
|
318
|
+
naive, skipped = real_plotter()
|
|
319
|
+
assert naive["function"] == "save_figure" # the bug this guards against
|
|
320
|
+
assert skipped["function"] == "real_plotter" # what we actually want
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_save_forwards_skip_modules():
|
|
324
|
+
import types
|
|
325
|
+
|
|
326
|
+
wrapper_mod = types.ModuleType("fake_repo.io.save")
|
|
327
|
+
wrapper_mod.save = save
|
|
328
|
+
exec(
|
|
329
|
+
"def save_figure(fig, fig_dir):\n"
|
|
330
|
+
" return save(fig, 'x', fig_dir=fig_dir, skip_modules=(__name__,))\n",
|
|
331
|
+
wrapper_mod.__dict__,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def real_plotter(fig_dir):
|
|
335
|
+
fig, ax = plt.subplots()
|
|
336
|
+
path = wrapper_mod.save_figure(fig, fig_dir)
|
|
337
|
+
plt.close(fig)
|
|
338
|
+
return path
|
|
339
|
+
|
|
340
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
341
|
+
assert read_figure_metadata(real_plotter(tmp))["function"] == "real_plotter"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_chain_reaches_past_an_inner_save_closure():
|
|
345
|
+
"""The real case from movement_analysis_utils: a nested `_save_fig` helper.
|
|
346
|
+
|
|
347
|
+
`function` can only ever be "the nearest frame we did not skip", and in real
|
|
348
|
+
plotting code that is often a local closure rather than the analysis that produced
|
|
349
|
+
the figure. The chain is what makes the enclosing function recoverable.
|
|
350
|
+
"""
|
|
351
|
+
def run_movement_stats_batch():
|
|
352
|
+
def _save_fig(): # the closure that actually calls save
|
|
353
|
+
return provenance()
|
|
354
|
+
return _save_fig()
|
|
355
|
+
|
|
356
|
+
record = run_movement_stats_batch()
|
|
357
|
+
assert record["function"] == "_save_fig"
|
|
358
|
+
assert record["chain"][:2] == ["_save_fig", "run_movement_stats_batch"]
|
|
359
|
+
assert "test_chain_reaches_past_an_inner_save_closure" in record["chain"]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_chain_survives_the_pdf_round_trip():
|
|
363
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
364
|
+
def analysis():
|
|
365
|
+
def _save_fig():
|
|
366
|
+
fig, ax = plt.subplots()
|
|
367
|
+
path = save(fig, "x", fig_dir=tmp, subjids=1)
|
|
368
|
+
plt.close(fig)
|
|
369
|
+
return path
|
|
370
|
+
return _save_fig()
|
|
371
|
+
|
|
372
|
+
got = read_figure_metadata(analysis())
|
|
373
|
+
assert got["chain"][:2] == ["_save_fig", "analysis"]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def test_blob_stays_ascii_so_the_pdf_string_encoding_is_predictable():
|
|
377
|
+
"""Any non-ASCII character makes matplotlib write the string as UTF-16BE.
|
|
378
|
+
|
|
379
|
+
The truncation markers used to be "…", which silently switched the encoding and
|
|
380
|
+
broke the stdlib reader for exactly the records that needed truncating.
|
|
381
|
+
"""
|
|
382
|
+
from backstamp.metadata import _dump
|
|
383
|
+
|
|
384
|
+
blob = _dump({"params": summarize_value({"big": list(range(99)), "s": "y" * 300})})
|
|
385
|
+
blob.encode("ascii") # must not raise
|
|
386
|
+
assert "..." in blob
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_round_trip_survives_truncation_and_non_ascii_arguments():
|
|
390
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
391
|
+
fig, ax = plt.subplots()
|
|
392
|
+
path = save(fig, "x", fig_dir=tmp, subjids=1, provenance={
|
|
393
|
+
"function": "f",
|
|
394
|
+
"params": summarize_value({"many": list(range(99)), "unicode": "µ ± σ °C"}),
|
|
395
|
+
})
|
|
396
|
+
plt.close(fig)
|
|
397
|
+
got = read_figure_metadata(path)
|
|
398
|
+
assert got["function"] == "f"
|
|
399
|
+
assert got["params"]["many"][-1] == "...+89 more"
|
|
400
|
+
assert got["params"]["unicode"] == "µ ± σ °C"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def main() -> int:
|
|
404
|
+
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
|
405
|
+
failures = []
|
|
406
|
+
for test in tests:
|
|
407
|
+
try:
|
|
408
|
+
test()
|
|
409
|
+
except Exception as exc: # noqa: BLE001
|
|
410
|
+
failures.append(test.__name__)
|
|
411
|
+
print(f" [FAIL] {test.__name__}: {type(exc).__name__}: {exc}")
|
|
412
|
+
else:
|
|
413
|
+
print(f" [ok] {test.__name__}")
|
|
414
|
+
print(f"\n{len(tests) - len(failures)}/{len(tests)} passed")
|
|
415
|
+
return 1 if failures else 0
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
if __name__ == "__main__":
|
|
419
|
+
raise SystemExit(main())
|