lusca 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lusca/__init__.py 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
+ )
lusca/mpl_freeze.py ADDED
@@ -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
+ ![CI](https://github.com/evmckinney9/lusca/actions/workflows/ci.yml/badge.svg?branch=main) ![Python](https://img.shields.io/badge/python-3.12-blue.svg) ![Ruff](https://img.shields.io/badge/linter-ruff-green.svg)
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,8 @@
1
+ lusca/__init__.py,sha256=-9D8d6ZCdhBlqWstPzE01K5775k5GjxF5FuSVdIii4s,1208
2
+ lusca/mpl_freeze.py,sha256=yOLlNSE2hgDPyJC0x9n4ACUet93tvn9eE2Gytmj9cfk,22506
3
+ lusca/styles/lusca.mplstyle,sha256=CammDeCeikpfArstAW6MnYPjjBVcFRoSJ0ACiHz0vZA,1245
4
+ lusca-0.1.1.dist-info/licenses/LICENSE,sha256=-kXdwi7memYDdAIl72l7FrVF33W5EYKgzkQI_255G8U,1068
5
+ lusca-0.1.1.dist-info/METADATA,sha256=SGFIdZuGxNUCzQqLooNp4zCDxDeqUfqdhXb9dixIb1M,4422
6
+ lusca-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ lusca-0.1.1.dist-info/top_level.txt,sha256=-UXrFqCtd5-iDDbhIvVDEZxZQN9WE0jFHp3W3PEGea8,6
8
+ lusca-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ lusca