lusca 0.1.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.
- lusca-0.1.1/LICENSE +21 -0
- lusca-0.1.1/PKG-INFO +121 -0
- lusca-0.1.1/README.md +81 -0
- lusca-0.1.1/pyproject.toml +73 -0
- lusca-0.1.1/setup.cfg +4 -0
- lusca-0.1.1/src/lusca/__init__.py +29 -0
- lusca-0.1.1/src/lusca/mpl_freeze.py +615 -0
- lusca-0.1.1/src/lusca/styles/lusca.mplstyle +50 -0
- lusca-0.1.1/src/lusca.egg-info/PKG-INFO +121 -0
- lusca-0.1.1/src/lusca.egg-info/SOURCES.txt +11 -0
- lusca-0.1.1/src/lusca.egg-info/dependency_links.txt +1 -0
- lusca-0.1.1/src/lusca.egg-info/requires.txt +15 -0
- lusca-0.1.1/src/lusca.egg-info/top_level.txt +1 -0
lusca-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 evmckinney9
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
lusca-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lusca
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A Jupyter magic command for creating reproducible matplotlib figures.
|
|
5
|
+
Author-email: Evan McKinney <evmckinney9@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/evmckinney9/lusca
|
|
8
|
+
Project-URL: Repository, https://github.com/evmckinney9/lusca
|
|
9
|
+
Project-URL: Issues, https://github.com/evmckinney9/lusca/issues
|
|
10
|
+
Keywords: jupyter,ipython-magic,matplotlib,reproducibility,figures,scientific-plotting
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Plugins
|
|
13
|
+
Classifier: Framework :: IPython
|
|
14
|
+
Classifier: Framework :: Jupyter
|
|
15
|
+
Classifier: Framework :: Matplotlib
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: numpy
|
|
28
|
+
Requires-Dist: matplotlib
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: ipykernel; extra == "dev"
|
|
31
|
+
Requires-Dist: pylatexenc; extra == "dev"
|
|
32
|
+
Requires-Dist: ipywidgets; extra == "dev"
|
|
33
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
34
|
+
Provides-Extra: format
|
|
35
|
+
Requires-Dist: pre-commit; extra == "format"
|
|
36
|
+
Requires-Dist: ruff; extra == "format"
|
|
37
|
+
Provides-Extra: test
|
|
38
|
+
Requires-Dist: pytest; extra == "test"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# lusca
|
|
42
|
+
|
|
43
|
+
  
|
|
44
|
+
|
|
45
|
+
`lusca` is a Python library for creating reproducible matplotlib figures using Jupyter magic commands.
|
|
46
|
+
|
|
47
|
+
Often, you want to use Jupyter for experiments but may not want to rerun the entire notebook to recreate plots. Additionally, saving data and figures is essential for artifact generation and reproducibility.
|
|
48
|
+
|
|
49
|
+
## 📊 `%%mplfreeze` Command
|
|
50
|
+
|
|
51
|
+
The `%%mplfreeze` magic command:
|
|
52
|
+
- Captures the data used in your plots and saves it in a compressed NPZ file.
|
|
53
|
+
- Automatically exports your figures in multiple useful formats.
|
|
54
|
+
- Creates a minimal standalone script that reproduces the figure.
|
|
55
|
+
- Snapshots Python/package versions and the git commit into `<name>.meta.json`.
|
|
56
|
+
- Statically checks the cell for unsaved free names *before* writing anything,
|
|
57
|
+
then runs the generated replot in a subprocess to confirm the bundle
|
|
58
|
+
actually reproduces the figure — if `%%mplfreeze` succeeds, the replot is
|
|
59
|
+
guaranteed to work.
|
|
60
|
+
- Leverages `lusca`'s built-in stylesheet.
|
|
61
|
+
|
|
62
|
+
Once you're satisfied with your plot, add the `%%mplfreeze` command to the cell.
|
|
63
|
+
```python
|
|
64
|
+
%%mplfreeze <name> [vars ...] [--outdir DIR]
|
|
65
|
+
```
|
|
66
|
+
- `<name>`: Base name for outputs (folder + files).
|
|
67
|
+
- `[vars ...]`: Variable names to save into the NPZ file.
|
|
68
|
+
- `[--outdir DIR]`: (Optional) Parent output directory (default: `docs/figs`).
|
|
69
|
+
|
|
70
|
+
### Example
|
|
71
|
+
|
|
72
|
+
1. Import and load the magic command
|
|
73
|
+
```python
|
|
74
|
+
import matplotlib.pyplot as plt
|
|
75
|
+
import numpy as np
|
|
76
|
+
import lusca
|
|
77
|
+
%load_ext lusca
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
2. Some data
|
|
81
|
+
```python
|
|
82
|
+
x_data = np.linspace(-10, 10, 100)
|
|
83
|
+
sine = np.sin(x_data)
|
|
84
|
+
cosine = np.cos(x_data)
|
|
85
|
+
```
|
|
86
|
+
3. Plot + save
|
|
87
|
+
```python
|
|
88
|
+
%%mplfreeze trig_demo x_data sine cosine
|
|
89
|
+
with plt.style.context("lusca"):
|
|
90
|
+
fig, ax = plt.subplots(1, 1, figsize=(3.5, 2.6), sharey=True)
|
|
91
|
+
ax.plot(x_data, sine, label="Sine")
|
|
92
|
+
ax.plot(x_data, cosine, label="Cosine")
|
|
93
|
+
plt.show()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
An example notebook is available in `src/demo.ipynb`. The generated plots are saved in `docs/figs/` with the following structure:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
name_stamp/
|
|
100
|
+
name.npz # saved variables
|
|
101
|
+
name.pdf # exported figure
|
|
102
|
+
name.png
|
|
103
|
+
name.svg
|
|
104
|
+
name.meta.json # python/package versions + git commit
|
|
105
|
+
replot_name.py # standalone replot script
|
|
106
|
+
```
|
|
107
|
+
## Installation
|
|
108
|
+
|
|
109
|
+
Install `lusca` directly from GitHub:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pip install -e git+https://github.com/evmckinney9/lusca#egg=lusca
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### Note
|
|
116
|
+
|
|
117
|
+
If you are using VS Code, you can set the workspace root as the default directory for saving figures by adding the following setting to your `settings.json` file. Otherwise, output paths will be relative to the notebook location.
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
"jupyter.notebookFileRoot": "${workspaceFolder}"
|
|
121
|
+
```
|
lusca-0.1.1/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# lusca
|
|
2
|
+
|
|
3
|
+
  
|
|
4
|
+
|
|
5
|
+
`lusca` is a Python library for creating reproducible matplotlib figures using Jupyter magic commands.
|
|
6
|
+
|
|
7
|
+
Often, you want to use Jupyter for experiments but may not want to rerun the entire notebook to recreate plots. Additionally, saving data and figures is essential for artifact generation and reproducibility.
|
|
8
|
+
|
|
9
|
+
## 📊 `%%mplfreeze` Command
|
|
10
|
+
|
|
11
|
+
The `%%mplfreeze` magic command:
|
|
12
|
+
- Captures the data used in your plots and saves it in a compressed NPZ file.
|
|
13
|
+
- Automatically exports your figures in multiple useful formats.
|
|
14
|
+
- Creates a minimal standalone script that reproduces the figure.
|
|
15
|
+
- Snapshots Python/package versions and the git commit into `<name>.meta.json`.
|
|
16
|
+
- Statically checks the cell for unsaved free names *before* writing anything,
|
|
17
|
+
then runs the generated replot in a subprocess to confirm the bundle
|
|
18
|
+
actually reproduces the figure — if `%%mplfreeze` succeeds, the replot is
|
|
19
|
+
guaranteed to work.
|
|
20
|
+
- Leverages `lusca`'s built-in stylesheet.
|
|
21
|
+
|
|
22
|
+
Once you're satisfied with your plot, add the `%%mplfreeze` command to the cell.
|
|
23
|
+
```python
|
|
24
|
+
%%mplfreeze <name> [vars ...] [--outdir DIR]
|
|
25
|
+
```
|
|
26
|
+
- `<name>`: Base name for outputs (folder + files).
|
|
27
|
+
- `[vars ...]`: Variable names to save into the NPZ file.
|
|
28
|
+
- `[--outdir DIR]`: (Optional) Parent output directory (default: `docs/figs`).
|
|
29
|
+
|
|
30
|
+
### Example
|
|
31
|
+
|
|
32
|
+
1. Import and load the magic command
|
|
33
|
+
```python
|
|
34
|
+
import matplotlib.pyplot as plt
|
|
35
|
+
import numpy as np
|
|
36
|
+
import lusca
|
|
37
|
+
%load_ext lusca
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
2. Some data
|
|
41
|
+
```python
|
|
42
|
+
x_data = np.linspace(-10, 10, 100)
|
|
43
|
+
sine = np.sin(x_data)
|
|
44
|
+
cosine = np.cos(x_data)
|
|
45
|
+
```
|
|
46
|
+
3. Plot + save
|
|
47
|
+
```python
|
|
48
|
+
%%mplfreeze trig_demo x_data sine cosine
|
|
49
|
+
with plt.style.context("lusca"):
|
|
50
|
+
fig, ax = plt.subplots(1, 1, figsize=(3.5, 2.6), sharey=True)
|
|
51
|
+
ax.plot(x_data, sine, label="Sine")
|
|
52
|
+
ax.plot(x_data, cosine, label="Cosine")
|
|
53
|
+
plt.show()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
An example notebook is available in `src/demo.ipynb`. The generated plots are saved in `docs/figs/` with the following structure:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
name_stamp/
|
|
60
|
+
name.npz # saved variables
|
|
61
|
+
name.pdf # exported figure
|
|
62
|
+
name.png
|
|
63
|
+
name.svg
|
|
64
|
+
name.meta.json # python/package versions + git commit
|
|
65
|
+
replot_name.py # standalone replot script
|
|
66
|
+
```
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
Install `lusca` directly from GitHub:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install -e git+https://github.com/evmckinney9/lusca#egg=lusca
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
#### Note
|
|
76
|
+
|
|
77
|
+
If you are using VS Code, you can set the workspace root as the default directory for saving figures by adding the following setting to your `settings.json` file. Otherwise, output paths will be relative to the notebook location.
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
"jupyter.notebookFileRoot": "${workspaceFolder}"
|
|
81
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lusca"
|
|
7
|
+
description = "A Jupyter magic command for creating reproducible matplotlib figures."
|
|
8
|
+
version = "0.1.1"
|
|
9
|
+
authors = [{ name = "Evan McKinney", email = "evmckinney9@gmail.com" }]
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
keywords = [
|
|
14
|
+
"jupyter",
|
|
15
|
+
"ipython-magic",
|
|
16
|
+
"matplotlib",
|
|
17
|
+
"reproducibility",
|
|
18
|
+
"figures",
|
|
19
|
+
"scientific-plotting",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 3 - Alpha",
|
|
23
|
+
"Environment :: Plugins",
|
|
24
|
+
"Framework :: IPython",
|
|
25
|
+
"Framework :: Jupyter",
|
|
26
|
+
"Framework :: Matplotlib",
|
|
27
|
+
"Intended Audience :: Science/Research",
|
|
28
|
+
"License :: OSI Approved :: MIT License",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.10",
|
|
31
|
+
"Programming Language :: Python :: 3.11",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Programming Language :: Python :: 3.13",
|
|
34
|
+
"Topic :: Scientific/Engineering :: Visualization",
|
|
35
|
+
]
|
|
36
|
+
dependencies = ["numpy", "matplotlib"]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/evmckinney9/lusca"
|
|
40
|
+
Repository = "https://github.com/evmckinney9/lusca"
|
|
41
|
+
Issues = "https://github.com/evmckinney9/lusca/issues"
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
dev = ["ipykernel", "pylatexenc", "ipywidgets", "pre-commit"]
|
|
45
|
+
format = ["pre-commit", "ruff"]
|
|
46
|
+
test = ["pytest"]
|
|
47
|
+
|
|
48
|
+
[tool.setuptools]
|
|
49
|
+
package-dir = {"" = "src"}
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.packages.find]
|
|
52
|
+
where = ["src"]
|
|
53
|
+
include = ["lusca*"]
|
|
54
|
+
|
|
55
|
+
[tool.setuptools.package-data]
|
|
56
|
+
lusca = ["styles/**/*.mplstyle"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
target-version = "py312"
|
|
60
|
+
fix = true
|
|
61
|
+
extend-include = ["*.ipynb"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["D"]
|
|
65
|
+
ignore = ["D105"] # magic methods like __init__ don't need docstrings
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint.per-file-ignores]
|
|
68
|
+
"scripts/**" = ["D"]
|
|
69
|
+
"src/tests/**/*.py" = ["D1"]
|
|
70
|
+
"**/*.ipynb" = ["D"]
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint.pydocstyle]
|
|
73
|
+
convention = "google"
|
lusca-0.1.1/setup.cfg
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# https://github.com/garrettj403/SciencePlots/blob/master/scienceplots/__init__.py
|
|
2
|
+
import os # pathlib.Path.walk not available in Python <3.12
|
|
3
|
+
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
|
|
6
|
+
import lusca
|
|
7
|
+
|
|
8
|
+
# register the bundled stylesheets in the matplotlib style library
|
|
9
|
+
styles_path = lusca.__path__[0]
|
|
10
|
+
# styles_path = os.path.join(scienceplots_path, "styles")
|
|
11
|
+
|
|
12
|
+
# Reads styles in /styles folder and all subfolders
|
|
13
|
+
stylesheets = {} # plt.style.library is a dictionary
|
|
14
|
+
for folder, _, _ in os.walk(styles_path):
|
|
15
|
+
new_stylesheets = plt.style.core.read_style_directory(folder)
|
|
16
|
+
stylesheets.update(new_stylesheets)
|
|
17
|
+
|
|
18
|
+
# Update dictionary of styles - plt.style.library
|
|
19
|
+
plt.style.core.update_nested_dict(plt.style.library, stylesheets)
|
|
20
|
+
# Update `plt.style.available`, copy-paste from:
|
|
21
|
+
# https://github.com/matplotlib/matplotlib/blob/a170539a421623bb2967a45a24bb7926e2feb542/lib/matplotlib/style/core.py#L266 # noqa: E501
|
|
22
|
+
plt.style.core.available[:] = sorted(plt.style.library.keys())
|
|
23
|
+
|
|
24
|
+
# Re-export the magic's IPython hooks so users can do `%load_ext lusca`
|
|
25
|
+
# instead of the longer `%load_ext lusca.mpl_freeze`.
|
|
26
|
+
from lusca.mpl_freeze import ( # noqa: E402, F401
|
|
27
|
+
load_ipython_extension,
|
|
28
|
+
unload_ipython_extension,
|
|
29
|
+
)
|
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"""Jupyter magic for freezing matplotlib plots and saving data.
|
|
2
|
+
|
|
3
|
+
This module provides the %%mplfreeze magic command for Jupyter/IPython, which captures
|
|
4
|
+
plotting cells, saves specified variables to compressed NPZ files, exports figures in
|
|
5
|
+
multiple formats, and generates standalone replot scripts for reproducibility.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import ast
|
|
12
|
+
import builtins as _builtins
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import platform
|
|
17
|
+
import shlex
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import textwrap
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from importlib import metadata as importlib_metadata
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
# Names the generated replot script binds before executing the captured cell.
|
|
29
|
+
_REPLOT_PROVIDED: frozenset[str] = frozenset(
|
|
30
|
+
{"np", "plt", "lusca", "os", "Path", "data"}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---- parse: %%mplfreeze <name> [vars ...] [--outdir DIR] ----
|
|
35
|
+
def _parse_line(line: str):
|
|
36
|
+
p = argparse.ArgumentParser(prog="%%mplfreeze", add_help=False)
|
|
37
|
+
p.add_argument("name", help="Base name for outputs (folder + files)")
|
|
38
|
+
p.add_argument("vars", nargs="*", help="Variable names to save into the NPZ")
|
|
39
|
+
p.add_argument("--outdir", default="docs/figs", help="Parent output directory")
|
|
40
|
+
a = p.parse_args(shlex.split(line))
|
|
41
|
+
return a.name, a.vars, a.outdir
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _warn_on_reserved_varnames(varnames: list[str]) -> None:
|
|
45
|
+
"""Warn if any saved varname shadows a name the replot pre-binds.
|
|
46
|
+
|
|
47
|
+
The replot does ``import numpy as np`` etc. before binding NPZ data,
|
|
48
|
+
so a saved variable named ``np`` will silently overwrite the numpy
|
|
49
|
+
import in the replot's namespace. Almost always a mistake.
|
|
50
|
+
"""
|
|
51
|
+
clashes = sorted(set(varnames) & _REPLOT_PROVIDED)
|
|
52
|
+
if clashes:
|
|
53
|
+
logging.warning(
|
|
54
|
+
f"[mplfreeze] saved variable(s) {clashes} shadow names the replot "
|
|
55
|
+
f"pre-binds {sorted(_REPLOT_PROVIDED)}; the NPZ value will overwrite "
|
|
56
|
+
f"the import (e.g. saving 'np' replaces numpy). Rename if "
|
|
57
|
+
f"unintentional."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _warn_on_extra_figures(fignums: list[int], kept_num: int) -> None:
|
|
62
|
+
"""Warn if the cell created multiple figures; only ``kept_num`` is saved."""
|
|
63
|
+
if len(fignums) > 1:
|
|
64
|
+
logging.warning(
|
|
65
|
+
f"[mplfreeze] cell created {len(fignums)} figures "
|
|
66
|
+
f"(numbers={sorted(fignums)}); only fig#{kept_num} was saved. "
|
|
67
|
+
f"To capture the others, split them into separate %%mplfreeze "
|
|
68
|
+
f"cells or assign the desired one to `fig`."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _collect_loaded_names(tree: ast.AST) -> set[str]:
|
|
73
|
+
return {
|
|
74
|
+
n.id
|
|
75
|
+
for n in ast.walk(tree)
|
|
76
|
+
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _collect_cell_defined_names(tree: ast.AST) -> set[str]:
|
|
81
|
+
"""Best-effort set of names bound anywhere in the cell.
|
|
82
|
+
|
|
83
|
+
Conservative: walks the whole tree and treats any binding (assignment,
|
|
84
|
+
import, function/class/lambda parameter, comprehension target, except
|
|
85
|
+
handler, walrus) as cell-scope. Star-imports cannot be enumerated; we
|
|
86
|
+
insert the sentinel "*" so the caller can warn.
|
|
87
|
+
"""
|
|
88
|
+
defined: set[str] = set()
|
|
89
|
+
|
|
90
|
+
def _add_target(node: ast.AST) -> None:
|
|
91
|
+
if isinstance(node, ast.Name):
|
|
92
|
+
defined.add(node.id)
|
|
93
|
+
elif isinstance(node, (ast.Tuple, ast.List)):
|
|
94
|
+
for elt in node.elts:
|
|
95
|
+
_add_target(elt)
|
|
96
|
+
elif isinstance(node, ast.Starred):
|
|
97
|
+
_add_target(node.value)
|
|
98
|
+
# Attribute / Subscript targets bind nothing new.
|
|
99
|
+
|
|
100
|
+
def _add_args(args: ast.arguments) -> None:
|
|
101
|
+
for a in (*args.posonlyargs, *args.args, *args.kwonlyargs):
|
|
102
|
+
defined.add(a.arg)
|
|
103
|
+
if args.vararg:
|
|
104
|
+
defined.add(args.vararg.arg)
|
|
105
|
+
if args.kwarg:
|
|
106
|
+
defined.add(args.kwarg.arg)
|
|
107
|
+
|
|
108
|
+
for node in ast.walk(tree):
|
|
109
|
+
if isinstance(node, ast.Assign):
|
|
110
|
+
for t in node.targets:
|
|
111
|
+
_add_target(t)
|
|
112
|
+
elif isinstance(node, (ast.AugAssign, ast.AnnAssign)):
|
|
113
|
+
_add_target(node.target)
|
|
114
|
+
elif isinstance(node, (ast.For, ast.AsyncFor)):
|
|
115
|
+
_add_target(node.target)
|
|
116
|
+
elif isinstance(node, (ast.With, ast.AsyncWith)):
|
|
117
|
+
for item in node.items:
|
|
118
|
+
if item.optional_vars is not None:
|
|
119
|
+
_add_target(item.optional_vars)
|
|
120
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
121
|
+
defined.add(node.name)
|
|
122
|
+
_add_args(node.args)
|
|
123
|
+
elif isinstance(node, ast.ClassDef):
|
|
124
|
+
defined.add(node.name)
|
|
125
|
+
elif isinstance(node, ast.Lambda):
|
|
126
|
+
_add_args(node.args)
|
|
127
|
+
elif isinstance(
|
|
128
|
+
node, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)
|
|
129
|
+
):
|
|
130
|
+
for gen in node.generators:
|
|
131
|
+
_add_target(gen.target)
|
|
132
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
133
|
+
for alias in node.names:
|
|
134
|
+
if alias.name == "*":
|
|
135
|
+
defined.add("*")
|
|
136
|
+
else:
|
|
137
|
+
defined.add(alias.asname or alias.name.split(".")[0])
|
|
138
|
+
elif isinstance(node, ast.ExceptHandler):
|
|
139
|
+
if node.name:
|
|
140
|
+
defined.add(node.name)
|
|
141
|
+
elif isinstance(node, ast.NamedExpr):
|
|
142
|
+
_add_target(node.target)
|
|
143
|
+
|
|
144
|
+
return defined
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _check_free_names(cell_src: str, varnames: list[str]) -> None:
|
|
148
|
+
"""Raise RuntimeError if the cell references names not bound at replot time.
|
|
149
|
+
|
|
150
|
+
The replot script binds: numpy as np, pyplot as plt, lusca, os, Path,
|
|
151
|
+
data, plus each saved variable. Anything else used in the cell must
|
|
152
|
+
either be a builtin or be defined inside the cell itself.
|
|
153
|
+
"""
|
|
154
|
+
try:
|
|
155
|
+
tree = ast.parse(cell_src)
|
|
156
|
+
except SyntaxError:
|
|
157
|
+
logging.warning(
|
|
158
|
+
"[mplfreeze] could not parse captured cell as Python (likely "
|
|
159
|
+
"contains IPython magics or shell escapes); skipping free-name "
|
|
160
|
+
"check."
|
|
161
|
+
)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
defined_in_cell = _collect_cell_defined_names(tree)
|
|
165
|
+
if "*" in defined_in_cell:
|
|
166
|
+
logging.warning(
|
|
167
|
+
"[mplfreeze] captured cell uses `from ... import *`; free-name "
|
|
168
|
+
"check cannot enumerate names provided by the star import."
|
|
169
|
+
)
|
|
170
|
+
defined_in_cell.discard("*")
|
|
171
|
+
|
|
172
|
+
loaded = _collect_loaded_names(tree)
|
|
173
|
+
provided = (
|
|
174
|
+
set(varnames) | set(_REPLOT_PROVIDED) | set(dir(_builtins)) | defined_in_cell
|
|
175
|
+
)
|
|
176
|
+
missing = sorted(loaded - provided)
|
|
177
|
+
if missing:
|
|
178
|
+
raise RuntimeError(
|
|
179
|
+
f"[mplfreeze] captured cell references unsaved free names: "
|
|
180
|
+
f"{missing}. Add these to the %%mplfreeze line, or inline their "
|
|
181
|
+
f"values inside the cell."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _save_npz(path: Path, ns: dict, varnames: list[str]) -> dict[str, dict]:
|
|
186
|
+
"""Save varnames from ns into a compressed NPZ; return per-variable metadata.
|
|
187
|
+
|
|
188
|
+
Emits a logging.warning for any variable whose ``np.asarray`` coercion
|
|
189
|
+
yields ``dtype=object`` — that artifact will require ``allow_pickle=True``
|
|
190
|
+
to load and is brittle across NumPy/Python versions.
|
|
191
|
+
"""
|
|
192
|
+
arrays: dict[str, np.ndarray] = {}
|
|
193
|
+
info: dict[str, dict] = {}
|
|
194
|
+
for v in varnames:
|
|
195
|
+
if v not in ns:
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
f"[mplfreeze] Variable '{v}' not found in the notebook namespace."
|
|
198
|
+
)
|
|
199
|
+
arr = np.asarray(ns[v])
|
|
200
|
+
arrays[v] = arr
|
|
201
|
+
info[v] = {"shape": arr.shape, "dtype": arr.dtype}
|
|
202
|
+
if arr.dtype == object:
|
|
203
|
+
logging.warning(
|
|
204
|
+
f"[mplfreeze] Variable {v!r} coerced to a dtype=object array "
|
|
205
|
+
f"(shape={arr.shape}); the saved .npz will use pickle and "
|
|
206
|
+
f"requires allow_pickle=True to load. Pickled .npz is brittle "
|
|
207
|
+
f"across NumPy/Python versions — consider flattening {v!r} "
|
|
208
|
+
f"into rectangular numeric arrays for long-term reproducibility."
|
|
209
|
+
)
|
|
210
|
+
if not arrays:
|
|
211
|
+
raise RuntimeError(
|
|
212
|
+
"[mplfreeze] No variables provided. Use: %%mplfreeze name x y ..."
|
|
213
|
+
)
|
|
214
|
+
np.savez_compressed(path, **arrays)
|
|
215
|
+
return info
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _write_replot(
|
|
219
|
+
root: Path,
|
|
220
|
+
cell_src: str,
|
|
221
|
+
base: str,
|
|
222
|
+
varnames: list[str],
|
|
223
|
+
info: dict[str, dict],
|
|
224
|
+
) -> None:
|
|
225
|
+
bind_lines = []
|
|
226
|
+
for v in varnames:
|
|
227
|
+
meta = info[v]
|
|
228
|
+
if meta["dtype"] == object and meta["shape"] == ():
|
|
229
|
+
# 0-d object array — unwrap so users get the original Python value.
|
|
230
|
+
bind_lines.append(f" {v} = data[{v!r}].item()")
|
|
231
|
+
else:
|
|
232
|
+
bind_lines.append(f" {v} = data[{v!r}]")
|
|
233
|
+
binds = "\n".join(bind_lines)
|
|
234
|
+
code = f'''# replot_{base}.py — auto-generated by %%mplfreeze
|
|
235
|
+
import argparse
|
|
236
|
+
import os
|
|
237
|
+
from pathlib import Path
|
|
238
|
+
import numpy as np, matplotlib.pyplot as plt
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
import lusca # noqa: F401 — registers bundled matplotlib stylesheets
|
|
242
|
+
except ImportError:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
HERE = Path(__file__).parent
|
|
246
|
+
NPZ = HERE / "{base}.npz"
|
|
247
|
+
|
|
248
|
+
def main(out_path=None):
|
|
249
|
+
_prev_cwd = os.getcwd()
|
|
250
|
+
os.chdir(HERE)
|
|
251
|
+
try:
|
|
252
|
+
data = np.load(NPZ, allow_pickle=True)
|
|
253
|
+
{binds}
|
|
254
|
+
|
|
255
|
+
# ---- begin captured plotting cell ----
|
|
256
|
+
{textwrap.indent(cell_src.strip(), " ")}
|
|
257
|
+
# ---- end captured plotting cell ----
|
|
258
|
+
|
|
259
|
+
fig = plt.gcf()
|
|
260
|
+
if out_path:
|
|
261
|
+
fig.savefig(out_path)
|
|
262
|
+
return fig
|
|
263
|
+
finally:
|
|
264
|
+
os.chdir(_prev_cwd)
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
# parse_known_args so that argv pollution from a Jupyter kernel launch
|
|
268
|
+
# (e.g. `-f /path/to/kernel-XXX.json`) is ignored rather than mistaken
|
|
269
|
+
# for a savefig destination.
|
|
270
|
+
_p = argparse.ArgumentParser()
|
|
271
|
+
_p.add_argument("--out", default=None, help="Optional path to save the figure")
|
|
272
|
+
_args, _ = _p.parse_known_args()
|
|
273
|
+
main(_args.out)
|
|
274
|
+
'''
|
|
275
|
+
(root / f"replot_{base}.py").write_text(code)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _git_snapshot(cwd: Path) -> dict | None:
|
|
279
|
+
"""Return {commit, branch, dirty} for the git repo at cwd, or None."""
|
|
280
|
+
|
|
281
|
+
def _git(*args: str) -> str | None:
|
|
282
|
+
try:
|
|
283
|
+
r = subprocess.run(
|
|
284
|
+
["git", "-C", str(cwd), *args],
|
|
285
|
+
capture_output=True,
|
|
286
|
+
text=True,
|
|
287
|
+
timeout=5,
|
|
288
|
+
check=False,
|
|
289
|
+
)
|
|
290
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
291
|
+
return None
|
|
292
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
293
|
+
|
|
294
|
+
commit = _git("rev-parse", "HEAD")
|
|
295
|
+
if commit is None:
|
|
296
|
+
return None
|
|
297
|
+
branch = _git("rev-parse", "--abbrev-ref", "HEAD")
|
|
298
|
+
status = _git("status", "--porcelain")
|
|
299
|
+
return {
|
|
300
|
+
"commit": commit,
|
|
301
|
+
"branch": branch,
|
|
302
|
+
"dirty": bool(status) if status is not None else None,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _package_version(name: str) -> str | None:
|
|
307
|
+
try:
|
|
308
|
+
return importlib_metadata.version(name)
|
|
309
|
+
except importlib_metadata.PackageNotFoundError:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _write_metadata(
|
|
314
|
+
root: Path,
|
|
315
|
+
base: str,
|
|
316
|
+
line: str,
|
|
317
|
+
varnames: list[str],
|
|
318
|
+
outdir: str,
|
|
319
|
+
) -> None:
|
|
320
|
+
"""Write `<base>.meta.json` snapshotting the freeze environment.
|
|
321
|
+
|
|
322
|
+
Captures Python/numpy/matplotlib/lusca versions, the platform string,
|
|
323
|
+
git commit + dirty state (if cwd is in a repo), and the magic invocation.
|
|
324
|
+
Future-you needs this when matplotlib's defaults shift and the frozen
|
|
325
|
+
PNG no longer matches what the replot draws.
|
|
326
|
+
"""
|
|
327
|
+
meta = {
|
|
328
|
+
"freeze_time": datetime.now().isoformat(timespec="seconds"),
|
|
329
|
+
"magic_line": line,
|
|
330
|
+
"base": base,
|
|
331
|
+
"varnames": list(varnames),
|
|
332
|
+
"outdir": outdir,
|
|
333
|
+
"python": platform.python_version(),
|
|
334
|
+
"platform": platform.platform(),
|
|
335
|
+
"packages": {
|
|
336
|
+
"numpy": _package_version("numpy"),
|
|
337
|
+
"matplotlib": _package_version("matplotlib"),
|
|
338
|
+
"lusca": _package_version("lusca"),
|
|
339
|
+
},
|
|
340
|
+
"git": _git_snapshot(Path.cwd()),
|
|
341
|
+
}
|
|
342
|
+
(root / f"{base}.meta.json").write_text(json.dumps(meta, indent=2) + "\n")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _maybe_dedupe_against_latest(outdir: Path, base: str, root: Path) -> Path:
|
|
346
|
+
"""If ``{base}_latest`` already points at an identical run, delete root.
|
|
347
|
+
|
|
348
|
+
"Identical" means the NPZ and the generated replot script are byte-equal
|
|
349
|
+
(the figure exports may differ in embedded timestamps even for matching
|
|
350
|
+
inputs, so we don't compare them). When the user re-runs an unchanged
|
|
351
|
+
cell, this prevents the output directory from accumulating one new
|
|
352
|
+
timestamped folder per execution.
|
|
353
|
+
|
|
354
|
+
Returns the run folder that should be considered the "current" one —
|
|
355
|
+
either the prior one (if dedup happened) or ``root`` unchanged.
|
|
356
|
+
"""
|
|
357
|
+
latest = outdir / f"{base}_latest"
|
|
358
|
+
if not latest.is_symlink():
|
|
359
|
+
return root
|
|
360
|
+
try:
|
|
361
|
+
prior = (outdir / os.readlink(latest)).resolve()
|
|
362
|
+
except OSError:
|
|
363
|
+
return root
|
|
364
|
+
if not prior.is_dir() or prior == root.resolve():
|
|
365
|
+
return root
|
|
366
|
+
npz_a, npz_b = root / f"{base}.npz", prior / f"{base}.npz"
|
|
367
|
+
rep_a, rep_b = root / f"replot_{base}.py", prior / f"replot_{base}.py"
|
|
368
|
+
if not (npz_b.exists() and rep_b.exists()):
|
|
369
|
+
return root
|
|
370
|
+
if (
|
|
371
|
+
npz_a.read_bytes() == npz_b.read_bytes()
|
|
372
|
+
and rep_a.read_bytes() == rep_b.read_bytes()
|
|
373
|
+
):
|
|
374
|
+
shutil.rmtree(root)
|
|
375
|
+
logging.info(
|
|
376
|
+
f"[mplfreeze] new run is identical to {prior.name}; removed "
|
|
377
|
+
f"redundant {root.name} and kept the existing folder."
|
|
378
|
+
)
|
|
379
|
+
return prior
|
|
380
|
+
return root
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _update_latest_symlink(outdir: Path, base: str, target: Path) -> None:
|
|
384
|
+
"""Point ``{base}_latest`` at ``target`` (a sibling folder).
|
|
385
|
+
|
|
386
|
+
Falls back to writing ``{base}_latest.txt`` containing the target name
|
|
387
|
+
on platforms / filesystems where symlinks are unavailable (notably
|
|
388
|
+
Windows without developer mode).
|
|
389
|
+
"""
|
|
390
|
+
link = outdir / f"{base}_latest"
|
|
391
|
+
target_name = target.name
|
|
392
|
+
if link.is_symlink() or link.exists():
|
|
393
|
+
try:
|
|
394
|
+
link.unlink()
|
|
395
|
+
except OSError:
|
|
396
|
+
pass
|
|
397
|
+
try:
|
|
398
|
+
link.symlink_to(target_name, target_is_directory=True)
|
|
399
|
+
except OSError as e:
|
|
400
|
+
logging.warning(
|
|
401
|
+
f"[mplfreeze] could not create symlink {link.name} → "
|
|
402
|
+
f"{target_name} ({e}); writing {base}_latest.txt pointer instead."
|
|
403
|
+
)
|
|
404
|
+
(outdir / f"{base}_latest.txt").write_text(target_name + "\n")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _smoke_test_replot(
|
|
408
|
+
root: Path,
|
|
409
|
+
base: str,
|
|
410
|
+
pixel_diff: float = 0.05,
|
|
411
|
+
pixel_fraction: float = 0.005,
|
|
412
|
+
) -> None:
|
|
413
|
+
"""Run the generated replot in a subprocess and verify it reproduces the figure.
|
|
414
|
+
|
|
415
|
+
Raises RuntimeError if the replot fails to execute or fails to produce a
|
|
416
|
+
figure file. Logs a warning when more than ``pixel_fraction`` of pixels
|
|
417
|
+
differ from the canonical by more than ``pixel_diff`` (mpimg returns
|
|
418
|
+
floats in [0, 1]). The metric is fraction-based rather than max-based
|
|
419
|
+
because matplotlib's anti-aliasing flips a handful of pixels along line
|
|
420
|
+
edges from light to dark across runs — visually identical, but a strict
|
|
421
|
+
max_diff check would flag every reasonable freeze.
|
|
422
|
+
"""
|
|
423
|
+
import matplotlib.image as mpimg
|
|
424
|
+
|
|
425
|
+
# Absolute paths matter: the replot subprocess chdir's to its own folder
|
|
426
|
+
# before resolving any path it received, so a relative check_png would
|
|
427
|
+
# be interpreted relative to the new cwd and fail.
|
|
428
|
+
root = root.resolve()
|
|
429
|
+
replot = root / f"replot_{base}.py"
|
|
430
|
+
canonical_png = root / f"{base}.png"
|
|
431
|
+
check_png = root / f".{base}.smoke.png"
|
|
432
|
+
check_png.unlink(missing_ok=True)
|
|
433
|
+
|
|
434
|
+
env = {**os.environ, "MPLBACKEND": "Agg"}
|
|
435
|
+
proc = subprocess.run(
|
|
436
|
+
[sys.executable, str(replot), "--out", str(check_png)],
|
|
437
|
+
capture_output=True,
|
|
438
|
+
text=True,
|
|
439
|
+
env=env,
|
|
440
|
+
timeout=300,
|
|
441
|
+
check=False,
|
|
442
|
+
)
|
|
443
|
+
if proc.returncode != 0:
|
|
444
|
+
raise RuntimeError(
|
|
445
|
+
f"[mplfreeze] replot smoke-test FAILED: {replot.name} exited "
|
|
446
|
+
f"with code {proc.returncode}. The frozen run is NOT reproducible "
|
|
447
|
+
f"as-is.\n--- replot stderr ---\n{proc.stderr.rstrip()}"
|
|
448
|
+
)
|
|
449
|
+
if not check_png.exists():
|
|
450
|
+
raise RuntimeError(
|
|
451
|
+
f"[mplfreeze] replot smoke-test ran but produced no figure. The "
|
|
452
|
+
f"captured cell may close all figures before main() returns."
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
canon = mpimg.imread(canonical_png)
|
|
457
|
+
check = mpimg.imread(check_png)
|
|
458
|
+
finally:
|
|
459
|
+
check_png.unlink(missing_ok=True)
|
|
460
|
+
|
|
461
|
+
if canon.shape != check.shape:
|
|
462
|
+
raise RuntimeError(
|
|
463
|
+
f"[mplfreeze] replot smoke-test: figure shape {check.shape} does "
|
|
464
|
+
f"not match canonical {canon.shape}."
|
|
465
|
+
)
|
|
466
|
+
diff = np.abs(canon.astype(float) - check.astype(float)).max(axis=2)
|
|
467
|
+
drift = float((diff > pixel_diff).sum() / diff.size)
|
|
468
|
+
if drift > pixel_fraction:
|
|
469
|
+
logging.warning(
|
|
470
|
+
f"[mplfreeze] replot smoke-test: {drift:.2%} of pixels differ "
|
|
471
|
+
f"from canonical by more than {pixel_diff} (threshold "
|
|
472
|
+
f"{pixel_fraction:.2%}). Replot runs but the figure is not "
|
|
473
|
+
f"pixel-faithful — likely non-deterministic content in the "
|
|
474
|
+
f"cell (timestamps, unseeded RNG, etc.)."
|
|
475
|
+
)
|
|
476
|
+
else:
|
|
477
|
+
logging.info(
|
|
478
|
+
f"[mplfreeze] replot smoke-test passed ({drift:.4%} of pixels "
|
|
479
|
+
f"differ; threshold {pixel_fraction:.2%})."
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def mplfreeze(line: str, cell: str):
|
|
484
|
+
"""Freeze a matplotlib cell into a reproducible artifact bundle.
|
|
485
|
+
|
|
486
|
+
Captures the cell, saves the named variables into a compressed NPZ,
|
|
487
|
+
exports the figure as PDF/SVG/PNG, writes a standalone
|
|
488
|
+
``replot_<name>.py`` that regenerates the figure from the NPZ,
|
|
489
|
+
snapshots the env (Python + package versions + git commit) into
|
|
490
|
+
``<name>.meta.json``, then *runs* the generated replot in a
|
|
491
|
+
subprocess to confirm the bundle reproduces the figure before
|
|
492
|
+
declaring success.
|
|
493
|
+
|
|
494
|
+
Static checks before any I/O:
|
|
495
|
+
* Saved variable names that shadow replot-bound names
|
|
496
|
+
(``np``, ``plt``, ``lusca``, ``os``, ``Path``, ``data``) → warning.
|
|
497
|
+
* Free names referenced in the cell that aren't saved or
|
|
498
|
+
defined in-cell → RuntimeError.
|
|
499
|
+
|
|
500
|
+
Runtime checks after the cell executes:
|
|
501
|
+
* Multiple open figures (only ``plt.gcf()`` is saved) → warning.
|
|
502
|
+
* Replot subprocess exit ≠0 or missing figure → RuntimeError.
|
|
503
|
+
* Replotted PNG differs from the canonical by > 0.01 → warning
|
|
504
|
+
(not an error; non-determinism like timestamps or unseeded RNG
|
|
505
|
+
is the most common cause).
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
line: ``"name var1 var2 ... [--outdir DIR]"``. Default outdir
|
|
509
|
+
is ``docs/figs``.
|
|
510
|
+
cell: Python source of the cell to capture and execute.
|
|
511
|
+
|
|
512
|
+
Example:
|
|
513
|
+
%%mplfreeze trig_demo x_data sine cosine tanh
|
|
514
|
+
with plt.style.context("lusca"):
|
|
515
|
+
fig, axes = plt.subplots(1, 2, figsize=(7.0, 2.6), sharey=True)
|
|
516
|
+
axes[0].plot(x_data, sine); axes[0].plot(x_data, cosine)
|
|
517
|
+
axes[1].plot(sine, tanh, linestyle="--")
|
|
518
|
+
axes[1].plot(cosine, tanh, linestyle="--")
|
|
519
|
+
|
|
520
|
+
Output layout under ``<outdir>/<name>_<timestamp>/``::
|
|
521
|
+
|
|
522
|
+
<name>.npz # saved variables
|
|
523
|
+
<name>.{pdf,svg,png} # exported figure
|
|
524
|
+
<name>.meta.json # env + git snapshot
|
|
525
|
+
replot_<name>.py # standalone replot script
|
|
526
|
+
|
|
527
|
+
Raises:
|
|
528
|
+
RuntimeError: not in IPython/Jupyter; no figure produced; cell
|
|
529
|
+
references unsaved free names; or the replot smoke-test
|
|
530
|
+
fails (subprocess non-zero exit, missing figure, shape
|
|
531
|
+
mismatch).
|
|
532
|
+
"""
|
|
533
|
+
import matplotlib.pyplot as plt
|
|
534
|
+
from IPython import get_ipython
|
|
535
|
+
from matplotlib.figure import Figure
|
|
536
|
+
|
|
537
|
+
ip = get_ipython()
|
|
538
|
+
if ip is None:
|
|
539
|
+
raise RuntimeError("%%mplfreeze must run inside IPython/Jupyter.")
|
|
540
|
+
ns = ip.user_ns
|
|
541
|
+
|
|
542
|
+
base, varnames, outdir = _parse_line(line)
|
|
543
|
+
|
|
544
|
+
# Validate the captured cell can run standalone before touching the disk.
|
|
545
|
+
_warn_on_reserved_varnames(varnames)
|
|
546
|
+
_check_free_names(cell, varnames)
|
|
547
|
+
|
|
548
|
+
# create run folder
|
|
549
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
550
|
+
root = Path(outdir) / f"{base}_{stamp}"
|
|
551
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
552
|
+
|
|
553
|
+
# save arrays
|
|
554
|
+
info = _save_npz(root / f"{base}.npz", ns, varnames)
|
|
555
|
+
logging.info(f"Saved {len(varnames)} arrays → {root / f'{base}.npz'}")
|
|
556
|
+
|
|
557
|
+
# run the plotting cell now
|
|
558
|
+
ip.run_cell(cell)
|
|
559
|
+
|
|
560
|
+
# snapshot the single figure
|
|
561
|
+
fig = ns.get("fig", None)
|
|
562
|
+
if not isinstance(fig, Figure):
|
|
563
|
+
fig = plt.gcf()
|
|
564
|
+
if not isinstance(fig, Figure):
|
|
565
|
+
raise RuntimeError(
|
|
566
|
+
"[mplfreeze] No Matplotlib Figure found as 'fig' or current figure."
|
|
567
|
+
)
|
|
568
|
+
_warn_on_extra_figures(plt.get_fignums(), fig.number)
|
|
569
|
+
for ext in ("pdf", "svg", "png"):
|
|
570
|
+
fig.savefig(root / f"{base}.{ext}")
|
|
571
|
+
logging.info(f"Saved figure → {root}/{base}.{{pdf,svg,png}}")
|
|
572
|
+
|
|
573
|
+
# write replot script with explicit local bindings
|
|
574
|
+
_write_replot(root, cell, base, varnames, info)
|
|
575
|
+
logging.info(f"Wrote {root / f'replot_{base}.py'}")
|
|
576
|
+
|
|
577
|
+
# snapshot environment versions / git state for future debugging
|
|
578
|
+
_write_metadata(root, base, line, varnames, outdir)
|
|
579
|
+
logging.info(f"Wrote {root / f'{base}.meta.json'}")
|
|
580
|
+
|
|
581
|
+
# Smoke-test: actually exec the replot and confirm it produces the same
|
|
582
|
+
# figure. If freeze succeeds, the replot is *guaranteed* to work later.
|
|
583
|
+
_smoke_test_replot(root, base)
|
|
584
|
+
|
|
585
|
+
# Dedupe vs. the prior `{base}_latest` if the new run is identical, then
|
|
586
|
+
# update the symlink so callers can embed a stable path in their docs.
|
|
587
|
+
outdir_path = Path(outdir)
|
|
588
|
+
final_root = _maybe_dedupe_against_latest(outdir_path, base, root)
|
|
589
|
+
_update_latest_symlink(outdir_path, base, final_root)
|
|
590
|
+
|
|
591
|
+
logging.info(f"Run folder: {final_root} (latest → {base}_latest)")
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# ---- IPython extension hooks ----
|
|
595
|
+
def load_ipython_extension(ip):
|
|
596
|
+
"""Load the mplfreeze magic command into IPython.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
ip: The IPython instance to register the magic command with.
|
|
600
|
+
"""
|
|
601
|
+
mgr = ip.magics_manager.magics
|
|
602
|
+
if "cell" in mgr and "mplfreeze" in mgr["cell"]:
|
|
603
|
+
del mgr["cell"]["mplfreeze"]
|
|
604
|
+
ip.register_magic_function(mplfreeze, magic_kind="cell", magic_name="mplfreeze")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def unload_ipython_extension(ip):
|
|
608
|
+
"""Unload the mplfreeze magic command from IPython.
|
|
609
|
+
|
|
610
|
+
Args:
|
|
611
|
+
ip: The IPython instance to unregister the magic command from.
|
|
612
|
+
"""
|
|
613
|
+
mgr = ip.magics_manager.magics
|
|
614
|
+
if "cell" in mgr and "mplfreeze" in mgr["cell"]:
|
|
615
|
+
del mgr["cell"]["mplfreeze"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# === Figure ===
|
|
2
|
+
figure.figsize: 3.5, 2.6 # compact, journal-friendly
|
|
3
|
+
figure.dpi: 600
|
|
4
|
+
|
|
5
|
+
# === Fonts ===
|
|
6
|
+
font.family: DejaVu Sans # simple, consistent with your prefs
|
|
7
|
+
font.size: 10 # between 8 and 16; scales well
|
|
8
|
+
mathtext.fontset: dejavusans # TeX-like look without usetex
|
|
9
|
+
axes.unicode_minus: True
|
|
10
|
+
|
|
11
|
+
# === Axes & Lines ===
|
|
12
|
+
axes.linewidth: 1.0 # not too heavy (compromise: 0.5 vs 3)
|
|
13
|
+
axes.labelpad: 4
|
|
14
|
+
grid.linewidth: 0.5
|
|
15
|
+
|
|
16
|
+
# Clean, readable categorical cycle
|
|
17
|
+
# axes.prop_cycle: cycler('color', ['0C5DA5', '00B945', 'FF9500', 'FF2C00', '845B97', '474747', '9e9e9e'])
|
|
18
|
+
lines.linewidth: 1.5
|
|
19
|
+
lines.markersize: 4
|
|
20
|
+
lines.markeredgewidth: 0.8
|
|
21
|
+
lines.markerfacecolor: w
|
|
22
|
+
|
|
23
|
+
# === Ticks ===
|
|
24
|
+
xtick.top: True
|
|
25
|
+
ytick.right: True
|
|
26
|
+
xtick.direction: in
|
|
27
|
+
ytick.direction: in
|
|
28
|
+
xtick.minor.visible: False
|
|
29
|
+
ytick.minor.visible: False
|
|
30
|
+
|
|
31
|
+
# Compromise sizes/widths (between big/heavy and tiny/light)
|
|
32
|
+
xtick.major.size: 5
|
|
33
|
+
xtick.major.width: 1.0
|
|
34
|
+
xtick.minor.size: 3
|
|
35
|
+
xtick.minor.width: 0.8
|
|
36
|
+
ytick.major.size: 5
|
|
37
|
+
ytick.major.width: 1.0
|
|
38
|
+
ytick.minor.size: 3
|
|
39
|
+
ytick.minor.width: 0.8
|
|
40
|
+
|
|
41
|
+
# === Legend ===
|
|
42
|
+
# Legend properties (base.mplstyle)
|
|
43
|
+
legend.frameon: False
|
|
44
|
+
legend.framealpha: 0
|
|
45
|
+
legend.loc: lower center
|
|
46
|
+
legend.handletextpad: 0.4
|
|
47
|
+
|
|
48
|
+
# === Saving ===
|
|
49
|
+
# savefig.bbox: tight
|
|
50
|
+
savefig.pad_inches: 0.02
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lusca
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A Jupyter magic command for creating reproducible matplotlib figures.
|
|
5
|
+
Author-email: Evan McKinney <evmckinney9@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/evmckinney9/lusca
|
|
8
|
+
Project-URL: Repository, https://github.com/evmckinney9/lusca
|
|
9
|
+
Project-URL: Issues, https://github.com/evmckinney9/lusca/issues
|
|
10
|
+
Keywords: jupyter,ipython-magic,matplotlib,reproducibility,figures,scientific-plotting
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Plugins
|
|
13
|
+
Classifier: Framework :: IPython
|
|
14
|
+
Classifier: Framework :: Jupyter
|
|
15
|
+
Classifier: Framework :: Matplotlib
|
|
16
|
+
Classifier: Intended Audience :: Science/Research
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: numpy
|
|
28
|
+
Requires-Dist: matplotlib
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: ipykernel; extra == "dev"
|
|
31
|
+
Requires-Dist: pylatexenc; extra == "dev"
|
|
32
|
+
Requires-Dist: ipywidgets; extra == "dev"
|
|
33
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
34
|
+
Provides-Extra: format
|
|
35
|
+
Requires-Dist: pre-commit; extra == "format"
|
|
36
|
+
Requires-Dist: ruff; extra == "format"
|
|
37
|
+
Provides-Extra: test
|
|
38
|
+
Requires-Dist: pytest; extra == "test"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# lusca
|
|
42
|
+
|
|
43
|
+
  
|
|
44
|
+
|
|
45
|
+
`lusca` is a Python library for creating reproducible matplotlib figures using Jupyter magic commands.
|
|
46
|
+
|
|
47
|
+
Often, you want to use Jupyter for experiments but may not want to rerun the entire notebook to recreate plots. Additionally, saving data and figures is essential for artifact generation and reproducibility.
|
|
48
|
+
|
|
49
|
+
## 📊 `%%mplfreeze` Command
|
|
50
|
+
|
|
51
|
+
The `%%mplfreeze` magic command:
|
|
52
|
+
- Captures the data used in your plots and saves it in a compressed NPZ file.
|
|
53
|
+
- Automatically exports your figures in multiple useful formats.
|
|
54
|
+
- Creates a minimal standalone script that reproduces the figure.
|
|
55
|
+
- Snapshots Python/package versions and the git commit into `<name>.meta.json`.
|
|
56
|
+
- Statically checks the cell for unsaved free names *before* writing anything,
|
|
57
|
+
then runs the generated replot in a subprocess to confirm the bundle
|
|
58
|
+
actually reproduces the figure — if `%%mplfreeze` succeeds, the replot is
|
|
59
|
+
guaranteed to work.
|
|
60
|
+
- Leverages `lusca`'s built-in stylesheet.
|
|
61
|
+
|
|
62
|
+
Once you're satisfied with your plot, add the `%%mplfreeze` command to the cell.
|
|
63
|
+
```python
|
|
64
|
+
%%mplfreeze <name> [vars ...] [--outdir DIR]
|
|
65
|
+
```
|
|
66
|
+
- `<name>`: Base name for outputs (folder + files).
|
|
67
|
+
- `[vars ...]`: Variable names to save into the NPZ file.
|
|
68
|
+
- `[--outdir DIR]`: (Optional) Parent output directory (default: `docs/figs`).
|
|
69
|
+
|
|
70
|
+
### Example
|
|
71
|
+
|
|
72
|
+
1. Import and load the magic command
|
|
73
|
+
```python
|
|
74
|
+
import matplotlib.pyplot as plt
|
|
75
|
+
import numpy as np
|
|
76
|
+
import lusca
|
|
77
|
+
%load_ext lusca
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
2. Some data
|
|
81
|
+
```python
|
|
82
|
+
x_data = np.linspace(-10, 10, 100)
|
|
83
|
+
sine = np.sin(x_data)
|
|
84
|
+
cosine = np.cos(x_data)
|
|
85
|
+
```
|
|
86
|
+
3. Plot + save
|
|
87
|
+
```python
|
|
88
|
+
%%mplfreeze trig_demo x_data sine cosine
|
|
89
|
+
with plt.style.context("lusca"):
|
|
90
|
+
fig, ax = plt.subplots(1, 1, figsize=(3.5, 2.6), sharey=True)
|
|
91
|
+
ax.plot(x_data, sine, label="Sine")
|
|
92
|
+
ax.plot(x_data, cosine, label="Cosine")
|
|
93
|
+
plt.show()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
An example notebook is available in `src/demo.ipynb`. The generated plots are saved in `docs/figs/` with the following structure:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
name_stamp/
|
|
100
|
+
name.npz # saved variables
|
|
101
|
+
name.pdf # exported figure
|
|
102
|
+
name.png
|
|
103
|
+
name.svg
|
|
104
|
+
name.meta.json # python/package versions + git commit
|
|
105
|
+
replot_name.py # standalone replot script
|
|
106
|
+
```
|
|
107
|
+
## Installation
|
|
108
|
+
|
|
109
|
+
Install `lusca` directly from GitHub:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pip install -e git+https://github.com/evmckinney9/lusca#egg=lusca
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### Note
|
|
116
|
+
|
|
117
|
+
If you are using VS Code, you can set the workspace root as the default directory for saving figures by adding the following setting to your `settings.json` file. Otherwise, output paths will be relative to the notebook location.
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
"jupyter.notebookFileRoot": "${workspaceFolder}"
|
|
121
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/lusca/__init__.py
|
|
5
|
+
src/lusca/mpl_freeze.py
|
|
6
|
+
src/lusca.egg-info/PKG-INFO
|
|
7
|
+
src/lusca.egg-info/SOURCES.txt
|
|
8
|
+
src/lusca.egg-info/dependency_links.txt
|
|
9
|
+
src/lusca.egg-info/requires.txt
|
|
10
|
+
src/lusca.egg-info/top_level.txt
|
|
11
|
+
src/lusca/styles/lusca.mplstyle
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lusca
|