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.
- mpl_plot_report/__init__.py +5 -0
- mpl_plot_report/api.py +111 -0
- mpl_plot_report/cli.py +99 -0
- mpl_plot_report/diagnostics.py +80 -0
- mpl_plot_report/extract.py +134 -0
- mpl_plot_report/render_md.py +244 -0
- mpl_plot_report/rules.py +46 -0
- mpl_plot_report/schema.py +3 -0
- mpl_plot_report/util.py +63 -0
- mpl_plot_report-0.1.0.dist-info/METADATA +123 -0
- mpl_plot_report-0.1.0.dist-info/RECORD +13 -0
- mpl_plot_report-0.1.0.dist-info/WHEEL +4 -0
- mpl_plot_report-0.1.0.dist-info/licenses/LICENSE +21 -0
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"")
|
|
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)
|
mpl_plot_report/rules.py
ADDED
|
@@ -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))
|
mpl_plot_report/util.py
ADDED
|
@@ -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,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.
|