mpl-plot-report 0.1.0__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.
@@ -0,0 +1,5 @@
1
+ """Public API for mpl_plot_report."""
2
+
3
+ from mpl_plot_report.api import build_report, dump_report
4
+
5
+ __all__ = ["build_report", "dump_report"]
mpl_plot_report/api.py ADDED
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Mapping
6
+
7
+ import matplotlib
8
+
9
+ from mpl_plot_report.extract import extract_figure
10
+ from mpl_plot_report.render_md import render_report_full
11
+ from mpl_plot_report.rules import RuleResult, coerce_ruleset
12
+ from mpl_plot_report.schema import SCHEMA_VERSION
13
+ from mpl_plot_report.util import hash_rcparams, round_floats
14
+
15
+
16
+ def build_report(
17
+ fig,
18
+ *,
19
+ context: Mapping[str, Any] | None = None,
20
+ ruleset=None,
21
+ include_data: bool = True,
22
+ max_points: int = 2000,
23
+ float_precision: int = 6,
24
+ ) -> dict[str, Any]:
25
+ context_payload = dict(context or {})
26
+ rcparams_hash = hash_rcparams(matplotlib.rcParams)
27
+ context_payload.setdefault(
28
+ "style",
29
+ {
30
+ "name": context_payload.get("style_name"),
31
+ "rcparams_hash": rcparams_hash,
32
+ },
33
+ )
34
+
35
+ axes_payload, warnings = extract_figure(
36
+ fig,
37
+ include_data=include_data,
38
+ max_points=max_points,
39
+ )
40
+
41
+ report: dict[str, Any] = {
42
+ "schema_version": SCHEMA_VERSION,
43
+ "context": context_payload,
44
+ "figure": {
45
+ "dpi": float(fig.dpi),
46
+ "size_inches": [float(v) for v in fig.get_size_inches()],
47
+ "backend": matplotlib.get_backend(),
48
+ },
49
+ "axes": axes_payload,
50
+ "invariants": [],
51
+ "warnings": warnings,
52
+ }
53
+
54
+ rules = coerce_ruleset(ruleset)
55
+ if rules:
56
+ invariants: list[RuleResult] = rules.evaluate(report)
57
+ report["invariants"] = [inv.__dict__ for inv in invariants]
58
+
59
+ return round_floats(report, float_precision)
60
+
61
+
62
+ def dump_report(
63
+ fig,
64
+ out_dir: str | Path,
65
+ stem: str,
66
+ *,
67
+ context: Mapping[str, Any] | None = None,
68
+ ruleset=None,
69
+ include_data: bool = True,
70
+ max_points: int = 2000,
71
+ float_precision: int = 6,
72
+ md_mode: str = "full",
73
+ md_data_sample: int = 5,
74
+ md_include_png_embed: bool = True,
75
+ ) -> dict[str, Any]:
76
+ report = build_report(
77
+ fig,
78
+ context=context,
79
+ ruleset=ruleset,
80
+ include_data=include_data,
81
+ max_points=max_points,
82
+ float_precision=float_precision,
83
+ )
84
+
85
+ out_path = Path(out_dir)
86
+ out_path.mkdir(parents=True, exist_ok=True)
87
+
88
+ png_path = out_path / f"{stem}.png"
89
+ json_path = out_path / f"{stem}.plot.json"
90
+ md_path = out_path / f"{stem}.plot.md"
91
+
92
+ fig.savefig(png_path)
93
+
94
+ json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
95
+ if md_mode == "summary":
96
+ md_content = render_report_full(
97
+ report,
98
+ stem=stem,
99
+ md_data_sample=md_data_sample,
100
+ md_include_png_embed=md_include_png_embed,
101
+ )
102
+ else:
103
+ md_content = render_report_full(
104
+ report,
105
+ stem=stem,
106
+ md_data_sample=md_data_sample,
107
+ md_include_png_embed=md_include_png_embed,
108
+ )
109
+ md_path.write_text(md_content, encoding="utf-8")
110
+
111
+ return report
mpl_plot_report/cli.py ADDED
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from importlib import import_module
7
+ from typing import Any, Callable
8
+
9
+ import matplotlib
10
+
11
+ from mpl_plot_report.api import dump_report
12
+
13
+
14
+ def _load_callable(module_path: str, callable_name: str) -> Callable[..., Any]:
15
+ module = import_module(module_path)
16
+ try:
17
+ target = getattr(module, callable_name)
18
+ except AttributeError as exc:
19
+ raise SystemExit(
20
+ f"Callable '{callable_name}' not found in module '{module_path}'."
21
+ ) from exc
22
+ if not callable(target):
23
+ raise SystemExit(f"{module_path}.{callable_name} is not callable.")
24
+ return target
25
+
26
+
27
+ def _parse_args(argv: list[str]) -> argparse.Namespace:
28
+ parser = argparse.ArgumentParser(description="Export Matplotlib plot reports.")
29
+ parser.add_argument(
30
+ "--module",
31
+ required=True,
32
+ help="Module path containing a callable that returns a Figure.",
33
+ )
34
+ parser.add_argument(
35
+ "--callable",
36
+ default="make_figure",
37
+ help="Callable name that returns a Matplotlib Figure.",
38
+ )
39
+ parser.add_argument("--out-dir", help="Output directory.")
40
+ parser.add_argument("--stem", required=True, help="Output file stem.")
41
+ parser.add_argument(
42
+ "--run-id",
43
+ help="Run identifier; uses plots/<run_id> when --out-dir is omitted.",
44
+ )
45
+ parser.add_argument(
46
+ "--context",
47
+ default="{}",
48
+ help="JSON string for context metadata.",
49
+ )
50
+ parser.add_argument(
51
+ "--max-points",
52
+ type=int,
53
+ default=2000,
54
+ help="Maximum number of points per series.",
55
+ )
56
+ parser.add_argument(
57
+ "--float-precision",
58
+ type=int,
59
+ default=6,
60
+ help="Float rounding precision for report output.",
61
+ )
62
+ parser.add_argument(
63
+ "--no-data",
64
+ action="store_true",
65
+ help="Exclude raw series data from JSON output.",
66
+ )
67
+ return parser.parse_args(argv)
68
+
69
+
70
+ def main(argv: list[str] | None = None) -> int:
71
+ args = _parse_args(argv or sys.argv[1:])
72
+ matplotlib.use("Agg")
73
+
74
+ if args.out_dir is None and not args.run_id:
75
+ raise SystemExit("Provide --out-dir or --run-id.")
76
+ out_dir = args.out_dir or f"plots/{args.run_id}"
77
+
78
+ try:
79
+ context = json.loads(args.context)
80
+ except json.JSONDecodeError as exc:
81
+ raise SystemExit("--context must be valid JSON.") from exc
82
+
83
+ figure_factory = _load_callable(args.module, args.callable)
84
+ fig = figure_factory()
85
+
86
+ dump_report(
87
+ fig,
88
+ out_dir,
89
+ args.stem,
90
+ context=context,
91
+ include_data=not args.no_data,
92
+ max_points=args.max_points,
93
+ float_precision=args.float_precision,
94
+ )
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import numpy as np
6
+
7
+
8
+ def _safe_stat(values: np.ndarray, func) -> float | None:
9
+ if values.size == 0:
10
+ return None
11
+ return float(func(values))
12
+
13
+
14
+ def _robust_z_stats(values: np.ndarray) -> tuple[float | None, int]:
15
+ if values.size == 0:
16
+ return None, 0
17
+ median = float(np.median(values))
18
+ mad = float(np.median(np.abs(values - median)))
19
+ if mad == 0.0:
20
+ return 0.0, 0
21
+ z = 0.6745 * (values - median) / mad
22
+ max_abs = float(np.max(np.abs(z)))
23
+ outliers = int(np.sum(np.abs(z) > 3.5))
24
+ return max_abs, outliers
25
+
26
+
27
+ def compute_diagnostics(x: np.ndarray, y: np.ndarray) -> dict[str, Any]:
28
+ diagnostics: dict[str, Any] = {}
29
+
30
+ n = int(len(x))
31
+ finite_mask = np.isfinite(x) & np.isfinite(y)
32
+ diagnostics["n"] = n
33
+ diagnostics["non_finite_count"] = int(n - np.count_nonzero(finite_mask))
34
+
35
+ x_finite = x[np.isfinite(x)]
36
+ dx = np.diff(x_finite) if x_finite.size > 1 else np.array([])
37
+ diagnostics["dx_min"] = _safe_stat(dx, np.min)
38
+ diagnostics["dx_median"] = _safe_stat(dx, np.median)
39
+ diagnostics["dx_non_positive_count"] = int(np.sum(dx <= 0)) if dx.size else 0
40
+
41
+ y_finite = y[finite_mask]
42
+ x_for_slope = x[finite_mask]
43
+ dy = np.diff(y_finite) if y_finite.size > 1 else np.array([])
44
+ diagnostics["dy_max_abs"] = _safe_stat(np.abs(dy), np.max)
45
+ robust_z, outlier_count = _robust_z_stats(dy)
46
+ diagnostics["dy_robust_z_max"] = robust_z
47
+ diagnostics["dy_outlier_count"] = outlier_count
48
+
49
+ slope = np.array([])
50
+ if x_for_slope.size > 1:
51
+ dx_slope = np.diff(x_for_slope)
52
+ dy_slope = np.diff(y_finite)
53
+ valid = dx_slope != 0
54
+ if np.any(valid):
55
+ slope = dy_slope[valid] / dx_slope[valid]
56
+
57
+ diagnostics["slope_mean"] = _safe_stat(slope, np.mean)
58
+ diagnostics["slope_median"] = _safe_stat(slope, np.median)
59
+ diagnostics["slope_std"] = _safe_stat(slope, np.std)
60
+ diagnostics["slope_p10"] = _safe_stat(slope, lambda v: np.quantile(v, 0.1))
61
+ diagnostics["slope_p90"] = _safe_stat(slope, lambda v: np.quantile(v, 0.9))
62
+
63
+ slope_signs = np.sign(slope)
64
+ slope_signs = slope_signs[slope_signs != 0]
65
+ diagnostics["wiggle_sign_changes"] = (
66
+ int(np.sum(slope_signs[1:] != slope_signs[:-1])) if slope_signs.size > 1 else 0
67
+ )
68
+
69
+ curvature = np.diff(y_finite, n=2) if y_finite.size > 2 else np.array([])
70
+ diagnostics["curvature_std"] = _safe_stat(curvature, np.std)
71
+ diagnostics["curvature_max_abs"] = _safe_stat(np.abs(curvature), np.max)
72
+ curvature_signs = np.sign(curvature)
73
+ curvature_signs = curvature_signs[curvature_signs != 0]
74
+ diagnostics["curvature_sign_changes"] = (
75
+ int(np.sum(curvature_signs[1:] != curvature_signs[:-1]))
76
+ if curvature_signs.size > 1
77
+ else 0
78
+ )
79
+
80
+ return diagnostics
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import numpy as np
6
+ from matplotlib.lines import Line2D
7
+
8
+ from mpl_plot_report.diagnostics import compute_diagnostics
9
+ from mpl_plot_report.util import decimate_series, make_stable_id
10
+
11
+
12
+ def _safe_min(values: np.ndarray) -> float | None:
13
+ if values.size == 0:
14
+ return None
15
+ finite = values[np.isfinite(values)]
16
+ if finite.size == 0:
17
+ return None
18
+ return float(np.min(finite))
19
+
20
+
21
+ def _safe_max(values: np.ndarray) -> float | None:
22
+ if values.size == 0:
23
+ return None
24
+ finite = values[np.isfinite(values)]
25
+ if finite.size == 0:
26
+ return None
27
+ return float(np.max(finite))
28
+
29
+
30
+ def _series_stats(x: np.ndarray, y: np.ndarray) -> dict[str, Any]:
31
+ stats: dict[str, Any] = {
32
+ "n": int(len(x)),
33
+ "x_min": _safe_min(x),
34
+ "x_max": _safe_max(x),
35
+ "y_min": _safe_min(y),
36
+ "y_max": _safe_max(y),
37
+ "x_endpoints": [float(x[0]), float(x[-1])] if len(x) else None,
38
+ "y_endpoints": [float(y[0]), float(y[-1])] if len(y) else None,
39
+ }
40
+ return stats
41
+
42
+
43
+ def extract_figure(
44
+ fig,
45
+ *,
46
+ include_data: bool,
47
+ max_points: int,
48
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
49
+ axes_payload: list[dict[str, Any]] = []
50
+ warnings: list[dict[str, Any]] = []
51
+
52
+ for axis_index, ax in enumerate(fig.axes):
53
+ legend_labels = []
54
+ if ax.get_legend() is not None:
55
+ _, labels = ax.get_legend_handles_labels()
56
+ legend_labels = [label for label in labels if label]
57
+
58
+ axis_payload: dict[str, Any] = {
59
+ "index": axis_index,
60
+ "title": ax.get_title(),
61
+ "xlabel": ax.get_xlabel(),
62
+ "ylabel": ax.get_ylabel(),
63
+ "xscale": ax.get_xscale(),
64
+ "yscale": ax.get_yscale(),
65
+ "xlim": list(ax.get_xlim()),
66
+ "ylim": list(ax.get_ylim()),
67
+ "legend": legend_labels,
68
+ "series": [],
69
+ }
70
+
71
+ for series_index, line in enumerate(ax.lines):
72
+ if not isinstance(line, Line2D):
73
+ continue
74
+ x = np.asarray(line.get_xdata(), dtype=float)
75
+ y = np.asarray(line.get_ydata(), dtype=float)
76
+
77
+ if x.size != y.size:
78
+ min_len = min(x.size, y.size)
79
+ warnings.append(
80
+ {
81
+ "code": "length_mismatch",
82
+ "message": "Line2D x/y lengths do not match; truncating.",
83
+ "where": {
84
+ "axis_index": axis_index,
85
+ "series_index": series_index,
86
+ },
87
+ }
88
+ )
89
+ x = x[:min_len]
90
+ y = y[:min_len]
91
+
92
+ label = line.get_label() or ""
93
+ if not label or label.startswith("_"):
94
+ label = f"series_{series_index}"
95
+
96
+ series_id = make_stable_id(axis_index, series_index, label)
97
+ diagnostics = compute_diagnostics(x, y)
98
+ stats = _series_stats(x, y)
99
+
100
+ if diagnostics.get("non_finite_count", 0) > 0:
101
+ warnings.append(
102
+ {
103
+ "code": "non_finite",
104
+ "message": "Series contains non-finite values.",
105
+ "where": {
106
+ "axis_index": axis_index,
107
+ "series_index": series_index,
108
+ },
109
+ }
110
+ )
111
+
112
+ data_payload = None
113
+ if include_data:
114
+ x_use, y_use, decimation = decimate_series(x, y, max_points)
115
+ data_payload = {
116
+ "x": x_use.tolist(),
117
+ "y": y_use.tolist(),
118
+ "decimation": decimation,
119
+ }
120
+
121
+ axis_payload["series"].append(
122
+ {
123
+ "id": series_id,
124
+ "label": label,
125
+ "kind": "line2d",
126
+ "data": data_payload,
127
+ "stats": stats,
128
+ "diagnostics": diagnostics,
129
+ }
130
+ )
131
+
132
+ axes_payload.append(axis_payload)
133
+
134
+ return axes_payload, warnings
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ import json
5
+
6
+ _DIAGNOSTIC_KEYS = [
7
+ "n",
8
+ "non_finite_count",
9
+ "dx_min",
10
+ "dx_median",
11
+ "dx_non_positive_count",
12
+ "dy_max_abs",
13
+ "dy_robust_z_max",
14
+ "dy_outlier_count",
15
+ "slope_mean",
16
+ "slope_median",
17
+ "slope_std",
18
+ "slope_p10",
19
+ "slope_p90",
20
+ "wiggle_sign_changes",
21
+ "curvature_std",
22
+ "curvature_max_abs",
23
+ "curvature_sign_changes",
24
+ ]
25
+
26
+
27
+ def _format_value(value: Any) -> str:
28
+ if value is None:
29
+ return "None"
30
+ if isinstance(value, float):
31
+ return f"{value}"
32
+ return str(value)
33
+
34
+
35
+ def _sample_pairs(x: list[float], y: list[float], sample: int) -> dict[str, list]:
36
+ if sample <= 0 or not x or not y:
37
+ return {"head": [], "tail": []}
38
+ n = min(len(x), len(y))
39
+ head_count = min(sample, n)
40
+ tail_count = min(sample, n)
41
+ head = list(zip(x[:head_count], y[:head_count]))
42
+ tail = list(zip(x[-tail_count:], y[-tail_count:]))
43
+ return {"head": head, "tail": tail}
44
+
45
+
46
+ def _scrub_points(report: dict[str, Any], sample: int) -> dict[str, Any]:
47
+ clone = json.loads(json.dumps(report))
48
+ for axis in clone.get("axes", []):
49
+ for series in axis.get("series", []):
50
+ data = series.get("data")
51
+ if not data:
52
+ continue
53
+ x = data.get("x") or []
54
+ y = data.get("y") or []
55
+ series["data"] = {
56
+ "omitted": True,
57
+ "n": len(x),
58
+ "sample": _sample_pairs(x, y, sample),
59
+ "decimation": data.get("decimation"),
60
+ }
61
+ return clone
62
+
63
+
64
+ def render_report_full(
65
+ report: dict[str, Any],
66
+ *,
67
+ stem: str | None = None,
68
+ md_data_sample: int = 5,
69
+ md_include_png_embed: bool = True,
70
+ ) -> str:
71
+ lines: list[str] = []
72
+ title = stem or "plot"
73
+ lines.append(f"# Plot Report: {title}")
74
+ lines.append("")
75
+
76
+ context = report.get("context", {})
77
+ style = context.get("style") or {}
78
+ inputs = context.get("inputs")
79
+ parameters = context.get("parameters")
80
+
81
+ lines.append("## Context")
82
+ lines.append(f"- schema_version: {report.get('schema_version')}")
83
+ lines.append(f"- run_id: {_format_value(context.get('run_id'))}")
84
+ lines.append(f"- timestamp: {_format_value(context.get('timestamp'))}")
85
+ lines.append(f"- git_hash: {_format_value(context.get('git_hash'))}")
86
+ lines.append(f"- inputs: {_format_value(inputs)}")
87
+ lines.append(f"- parameters: {_format_value(parameters)}")
88
+ lines.append(
89
+ "- style: name={name} rcparams_hash={hash}".format(
90
+ name=_format_value(style.get("name")),
91
+ hash=_format_value(style.get("rcparams_hash")),
92
+ )
93
+ )
94
+
95
+ lines.append("")
96
+ lines.append("## PNG")
97
+ png_name = f"{title}.png"
98
+ if md_include_png_embed:
99
+ lines.append(f"![]({png_name})")
100
+ lines.append(f"- png_path: {png_name}")
101
+
102
+ warnings = report.get("warnings", [])
103
+ lines.append("")
104
+ lines.append("## Warnings")
105
+ if warnings:
106
+ for warning in warnings:
107
+ code = warning.get("code", "warning")
108
+ message = warning.get("message", "")
109
+ where = warning.get("where")
110
+ where_text = f" where={where}" if where else ""
111
+ lines.append(f"- {code}: {message}{where_text}")
112
+ else:
113
+ lines.append("- None")
114
+
115
+ invariants = report.get("invariants", [])
116
+ lines.append("")
117
+ lines.append("## Invariant Results")
118
+ if invariants:
119
+ fails = [inv for inv in invariants if not inv.get("passed")]
120
+ warns = [
121
+ inv
122
+ for inv in invariants
123
+ if inv.get("passed") and inv.get("severity") == "warn"
124
+ ]
125
+ passes = [
126
+ inv
127
+ for inv in invariants
128
+ if inv.get("passed") and inv.get("severity") not in {"warn", "fail"}
129
+ ]
130
+
131
+ for group_name, group in (
132
+ ("FAIL", fails),
133
+ ("WARN", warns),
134
+ ("PASS", passes),
135
+ ):
136
+ if not group:
137
+ continue
138
+ lines.append(f"- {group_name}:")
139
+ for inv in group:
140
+ where = inv.get("where")
141
+ where_text = f" where={where}" if where else ""
142
+ lines.append(
143
+ " - {rule_id} severity={severity} passed={passed} message={message}{where}".format(
144
+ rule_id=inv.get("rule_id"),
145
+ severity=inv.get("severity"),
146
+ passed=inv.get("passed"),
147
+ message=inv.get("message"),
148
+ where=where_text,
149
+ )
150
+ )
151
+ else:
152
+ lines.append("- None")
153
+
154
+ axes = report.get("axes", [])
155
+ lines.append("")
156
+ lines.append("## Axes")
157
+ if not axes:
158
+ lines.append("- None")
159
+
160
+ for axis in axes:
161
+ lines.append("")
162
+ lines.append(f"### Axis {axis.get('index')}")
163
+ lines.append(f"- title: {_format_value(axis.get('title'))}")
164
+ lines.append(f"- xlabel: {_format_value(axis.get('xlabel'))}")
165
+ lines.append(f"- ylabel: {_format_value(axis.get('ylabel'))}")
166
+ lines.append(f"- xscale: {_format_value(axis.get('xscale'))}")
167
+ lines.append(f"- yscale: {_format_value(axis.get('yscale'))}")
168
+ lines.append(f"- xlim: {_format_value(axis.get('xlim'))}")
169
+ lines.append(f"- ylim: {_format_value(axis.get('ylim'))}")
170
+ legend = axis.get("legend") or []
171
+ legend_text = legend if legend else "None"
172
+ lines.append(f"- legend: {_format_value(legend_text)}")
173
+
174
+ series_list = axis.get("series", [])
175
+ if not series_list:
176
+ lines.append("- series: None")
177
+ continue
178
+
179
+ lines.append("- series:")
180
+ for series in series_list:
181
+ lines.append("")
182
+ lines.append(f"#### Series {series.get('id')}")
183
+ lines.append(f"- label: {_format_value(series.get('label'))}")
184
+ lines.append(f"- kind: {_format_value(series.get('kind'))}")
185
+
186
+ stats = series.get("stats", {})
187
+ lines.append("- stats:")
188
+ lines.append(f" - n: {_format_value(stats.get('n'))}")
189
+ lines.append(
190
+ " - x_min/x_max: {xmin} / {xmax}".format(
191
+ xmin=_format_value(stats.get("x_min")),
192
+ xmax=_format_value(stats.get("x_max")),
193
+ )
194
+ )
195
+ lines.append(
196
+ " - y_min/y_max: {ymin} / {ymax}".format(
197
+ ymin=_format_value(stats.get("y_min")),
198
+ ymax=_format_value(stats.get("y_max")),
199
+ )
200
+ )
201
+ lines.append(
202
+ " - first/last: {first} / {last}".format(
203
+ first=_format_value(stats.get("x_endpoints")),
204
+ last=_format_value(stats.get("y_endpoints")),
205
+ )
206
+ )
207
+
208
+ diagnostics = series.get("diagnostics", {})
209
+ lines.append("- diagnostics:")
210
+ for key in _DIAGNOSTIC_KEYS:
211
+ if key in diagnostics:
212
+ lines.append(f" - {key}: {_format_value(diagnostics.get(key))}")
213
+
214
+ data = series.get("data")
215
+ if data:
216
+ decimation = data.get("decimation", {})
217
+ x = data.get("x") or []
218
+ y = data.get("y") or []
219
+ samples = _sample_pairs(x, y, md_data_sample)
220
+ lines.append("- data_sample:")
221
+ lines.append(
222
+ " - decimation: original_n={n_original} max_points={max_points} method={method} decimated={decimated}".format(
223
+ n_original=decimation.get("n_original"),
224
+ max_points=decimation.get("max_points"),
225
+ method=decimation.get("method"),
226
+ decimated=decimation.get("decimated"),
227
+ )
228
+ )
229
+ lines.append(f" - head: {samples['head']}")
230
+ lines.append(f" - tail: {samples['tail']}")
231
+
232
+ lines.append("")
233
+ lines.append("## Raw JSON (minus points)")
234
+ stripped = _scrub_points(report, md_data_sample)
235
+ lines.append("```json")
236
+ lines.append(json.dumps(stripped, indent=2, sort_keys=True))
237
+ lines.append("```")
238
+ lines.append("")
239
+
240
+ return "\n".join(lines)
241
+
242
+
243
+ def render_report(report: dict[str, Any]) -> str:
244
+ return render_report_full(report)
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, Iterable, Sequence
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class RuleResult:
9
+ rule_id: str
10
+ severity: str
11
+ passed: bool
12
+ message: str
13
+ where: dict[str, Any] | None = None
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Rule:
18
+ rule_id: str
19
+ severity: str
20
+ evaluator: Callable[[dict[str, Any]], tuple[bool, str, dict[str, Any] | None]]
21
+
22
+ def evaluate(self, report: dict[str, Any]) -> RuleResult:
23
+ passed, message, where = self.evaluator(report)
24
+ return RuleResult(
25
+ rule_id=self.rule_id,
26
+ severity=self.severity,
27
+ passed=passed,
28
+ message=message,
29
+ where=where,
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class RuleSet:
35
+ rules: Sequence[Rule]
36
+
37
+ def evaluate(self, report: dict[str, Any]) -> list[RuleResult]:
38
+ return [rule.evaluate(report) for rule in self.rules]
39
+
40
+
41
+ def coerce_ruleset(ruleset: RuleSet | Iterable[Rule] | None) -> RuleSet | None:
42
+ if ruleset is None:
43
+ return None
44
+ if isinstance(ruleset, RuleSet):
45
+ return ruleset
46
+ return RuleSet(list(ruleset))
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ SCHEMA_VERSION = "1.0"
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any, Mapping
6
+
7
+ import numpy as np
8
+
9
+
10
+ def make_stable_id(axis_index: int, series_index: int, label: str) -> str:
11
+ safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in label.strip())
12
+ safe = safe or "series"
13
+ return f"ax{axis_index}_s{series_index}_{safe}"
14
+
15
+
16
+ def hash_rcparams(rcparams: Mapping[str, Any]) -> str:
17
+ payload = json.dumps(rcparams, sort_keys=True, default=repr)
18
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
19
+
20
+
21
+ def round_floats(value: Any, precision: int) -> Any:
22
+ if isinstance(value, float):
23
+ return round(value, precision)
24
+ if isinstance(value, list):
25
+ return [round_floats(item, precision) for item in value]
26
+ if isinstance(value, tuple):
27
+ return [round_floats(item, precision) for item in value]
28
+ if isinstance(value, dict):
29
+ return {key: round_floats(item, precision) for key, item in value.items()}
30
+ return value
31
+
32
+
33
+ def decimate_series(
34
+ x: np.ndarray, y: np.ndarray, max_points: int
35
+ ) -> tuple[np.ndarray, np.ndarray, dict[str, Any]]:
36
+ n = len(x)
37
+ if max_points <= 0:
38
+ max_points = 1
39
+ if n <= max_points:
40
+ return (
41
+ x,
42
+ y,
43
+ {
44
+ "decimated": False,
45
+ "method": None,
46
+ "n_original": n,
47
+ "max_points": max_points,
48
+ },
49
+ )
50
+
51
+ target = max(max_points, 2)
52
+ idx = np.linspace(0, n - 1, target, dtype=int)
53
+ idx = np.unique(idx)
54
+ return (
55
+ x[idx],
56
+ y[idx],
57
+ {
58
+ "decimated": True,
59
+ "method": "uniform",
60
+ "n_original": n,
61
+ "max_points": max_points,
62
+ },
63
+ )
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: mpl-plot-report
3
+ Version: 0.1.0
4
+ Summary: Structured plot metadata and diagnostics for Matplotlib (agent-friendly sidecars)
5
+ Author: Pierre Lacerte
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: matplotlib
10
+ Requires-Dist: numpy
11
+ Description-Content-Type: text/markdown
12
+
13
+ # mpl-plot-report
14
+
15
+ Export Matplotlib figures as deterministic JSON + Markdown sidecars for
16
+ LLM-friendly plot debugging. PNGs are emitted as secondary artifacts.
17
+
18
+ ## Installation
19
+
20
+ Using uv:
21
+
22
+ ```bash
23
+ uv sync
24
+ ```
25
+
26
+ Using pip:
27
+
28
+ ```bash
29
+ python -m venv .venv
30
+ . .venv/bin/activate
31
+ pip install -e .[dev]
32
+ ```
33
+
34
+ ## Minimal usage
35
+
36
+ ```python
37
+ import matplotlib
38
+
39
+ matplotlib.use("Agg")
40
+ import matplotlib.pyplot as plt
41
+ import numpy as np
42
+
43
+ from mpl_plot_report import dump_report
44
+
45
+ x = np.linspace(0, 10, 200)
46
+ y = np.sin(x)
47
+ fig, ax = plt.subplots()
48
+ ax.plot(x, y, label="signal")
49
+
50
+ dump_report(
51
+ fig,
52
+ out_dir="plots/run_001",
53
+ stem="signal",
54
+ context={"run_id": "run_001", "style_name": "default"},
55
+ )
56
+ ```
57
+
58
+ ## CLI usage
59
+
60
+ You can point the CLI at a module that exposes a callable returning a
61
+ Matplotlib Figure (default callable name: `make_figure`).
62
+
63
+ ```bash
64
+ uv run mpl-plot-report --module my_module --callable make_figure --out-dir plots/run_001 --stem signal
65
+ ```
66
+
67
+ Or provide a run id and let the CLI create `plots/<run_id>` automatically:
68
+
69
+ ```bash
70
+ uv run mpl-plot-report --module my_module --run-id run_001 --stem signal
71
+ ```
72
+
73
+ ## Output files
74
+
75
+ For a stem like `signal`, the exporter writes:
76
+
77
+ - `signal.png`
78
+ - `signal.plot.json`
79
+ - `signal.plot.md`
80
+
81
+ The JSON is the source of truth for agents and CI. Markdown is a concise
82
+ human-facing summary, and PNGs are optional.
83
+
84
+ ## Rulesets
85
+
86
+ Domain rules should live in consumer projects and be injected at runtime.
87
+ Define a `RuleSet` (or iterable of `Rule`) and pass it to `dump_report`:
88
+
89
+ ```python
90
+ from mpl_plot_report import dump_report
91
+ from mpl_plot_report.rules import Rule, RuleSet
92
+
93
+
94
+ def always_ok(report):
95
+ return True, "ok", None
96
+
97
+
98
+ ruleset = RuleSet([Rule("demo.always_ok", "warn", always_ok)])
99
+ dump_report(fig, out_dir="plots", stem="example", ruleset=ruleset)
100
+ ```
101
+
102
+ See a minimal module example at `examples/ruleset_demo.py`.
103
+
104
+ ## Tests
105
+
106
+ ```bash
107
+ uv run pytest
108
+ ```
109
+
110
+ Single test examples:
111
+
112
+ ```bash
113
+ uv run pytest tests/test_report.py
114
+ uv run pytest tests/test_report.py::test_dump_report_creates_sidecars
115
+ ```
116
+
117
+ ## Fixtures
118
+
119
+ Synthetic fixtures live under `tests/fixtures/expected/`. Regenerate them with:
120
+
121
+ ```bash
122
+ uv run python scripts/generate_fixtures.py
123
+ ```
@@ -0,0 +1,13 @@
1
+ mpl_plot_report/__init__.py,sha256=9WPl5-gzHUNZtQRO8OU-FYTOM5lPhgC3J23HSsR1nP4,140
2
+ mpl_plot_report/api.py,sha256=YJRtAt6rVKGOhRJ5uMlSQe9q7Yjo-uHmsxSajFkPjoQ,3004
3
+ mpl_plot_report/cli.py,sha256=c0sp5TeWpizd3f7EVwGUTKJDwutM5eSeLLJHxkEJGxY,2801
4
+ mpl_plot_report/diagnostics.py,sha256=m6JuBM172oqXgpFrjajxZ3fGPEK2IyvI5acENaIlEm0,2849
5
+ mpl_plot_report/extract.py,sha256=j42xKjDP9hWTDOc3bFgiX3MXgCYBLp0aUNCR_LAvVI4,4245
6
+ mpl_plot_report/render_md.py,sha256=-IC8dNHoLjF99V5lZAa3ODArrrWXgNJtBTuvphpVCbg,8395
7
+ mpl_plot_report/rules.py,sha256=0-CNmUsvfshOwqH2fCHVL8oz6ZUbSHulauqmnAH66EE,1144
8
+ mpl_plot_report/schema.py,sha256=98Zk27gyartYRngW5GZYH4YI23xq7BLhsU165WF-0dw,59
9
+ mpl_plot_report/util.py,sha256=7McU8VHSX4S_lTa_4tFtm88_zjalrVIK_WpvQc7o8CA,1749
10
+ mpl_plot_report-0.1.0.dist-info/METADATA,sha256=B4DdwC6EjjBOT-1BCro-JK5rtsz0viZ9jrA_bDjHMzg,2514
11
+ mpl_plot_report-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ mpl_plot_report-0.1.0.dist-info/licenses/LICENSE,sha256=PfedwwQNUs7785-aIIOQhDpM5-l2DzAQaeMPlaM3_Ho,1065
13
+ mpl_plot_report-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 placerte
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.