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.
@@ -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,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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ matplotlib
2
+ pypdf>=3
3
+
4
+ [test]
5
+ pytest
@@ -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())