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 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
+ ]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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