backstamp 0.0.1__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.
- backstamp/__init__.py +11 -0
- backstamp/metadata.py +217 -0
- backstamp/provenance.py +303 -0
- backstamp/save.py +60 -0
- backstamp-0.0.1.dist-info/METADATA +89 -0
- backstamp-0.0.1.dist-info/RECORD +9 -0
- backstamp-0.0.1.dist-info/WHEEL +5 -0
- backstamp-0.0.1.dist-info/licenses/LICENSE +28 -0
- backstamp-0.0.1.dist-info/top_level.txt +1 -0
backstamp/__init__.py
ADDED
|
@@ -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__"]
|
backstamp/metadata.py
ADDED
|
@@ -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
|
+
]
|
backstamp/provenance.py
ADDED
|
@@ -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
|
+
]
|
backstamp/save.py
ADDED
|
@@ -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,9 @@
|
|
|
1
|
+
backstamp/__init__.py,sha256=0p1ZjruU1kPNsNsOzRztjYN_77jy5o0S9axI4aYCSHI,353
|
|
2
|
+
backstamp/metadata.py,sha256=SgYY8gMBx1YCEAFMzVrk2pqagI-xTzvu-UxuOEy7GBQ,7589
|
|
3
|
+
backstamp/provenance.py,sha256=jq8cI-VoXwiJWGD_MbZ1r-O4uGd2aTNI8-ovSFQKKkg,11464
|
|
4
|
+
backstamp/save.py,sha256=GzntzUN96EO3-s9lsTqprdjl-dv_UxFDyWmNPWBllxc,2500
|
|
5
|
+
backstamp-0.0.1.dist-info/licenses/LICENSE,sha256=9256eAIU7G8scvyv3n_5SuPMdKZy8jyCWG316IcnmQg,1501
|
|
6
|
+
backstamp-0.0.1.dist-info/METADATA,sha256=YZh8WTPjeyWnK1hVGTdXHBs6NmAczCAm_Nh_OIz7SHM,2656
|
|
7
|
+
backstamp-0.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
backstamp-0.0.1.dist-info/top_level.txt,sha256=BxuB0S9TFEFCnO7pOjifxuUXrp0MTJ1in86oL3CtjqU,10
|
|
9
|
+
backstamp-0.0.1.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
backstamp
|