notedelta 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.
- notedelta/__init__.py +15 -0
- notedelta/__main__.py +3 -0
- notedelta/_version.py +1 -0
- notedelta/cli.py +473 -0
- notedelta/config/__init__.py +4 -0
- notedelta/config/loader.py +137 -0
- notedelta/config/schema.py +326 -0
- notedelta/engine.py +761 -0
- notedelta/environment/__init__.py +0 -0
- notedelta/environment/deps.py +503 -0
- notedelta/environment/notebook_env.py +183 -0
- notedelta/environment/seeds.py +175 -0
- notedelta/errors.py +44 -0
- notedelta/execution/__init__.py +0 -0
- notedelta/execution/runner.py +143 -0
- notedelta/gitref.py +127 -0
- notedelta/limits.py +29 -0
- notedelta/matching/__init__.py +3 -0
- notedelta/matching/cells.py +284 -0
- notedelta/metrics/__init__.py +0 -0
- notedelta/metrics/extract.py +160 -0
- notedelta/model.py +300 -0
- notedelta/notebooks/__init__.py +4 -0
- notedelta/notebooks/loader.py +259 -0
- notedelta/notebooks/model.py +179 -0
- notedelta/numeric/__init__.py +0 -0
- notedelta/numeric/compare.py +227 -0
- notedelta/numeric/discover.py +181 -0
- notedelta/numeric/literal.py +456 -0
- notedelta/numeric/numbers.py +183 -0
- notedelta/numeric/table.py +264 -0
- notedelta/numeric/text.py +190 -0
- notedelta/regression/__init__.py +0 -0
- notedelta/regression/classify.py +275 -0
- notedelta/reporting/__init__.py +11 -0
- notedelta/reporting/common.py +144 -0
- notedelta/reporting/html.py +503 -0
- notedelta/reporting/markdown.py +104 -0
- notedelta/reporting/redact.py +70 -0
- notedelta/reporting/terminal.py +170 -0
- notedelta/stability.py +111 -0
- notedelta-0.1.0.dist-info/METADATA +187 -0
- notedelta-0.1.0.dist-info/RECORD +46 -0
- notedelta-0.1.0.dist-info/WHEEL +4 -0
- notedelta-0.1.0.dist-info/entry_points.txt +2 -0
- notedelta-0.1.0.dist-info/licenses/LICENSE +43 -0
notedelta/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""NoteDelta: scientific notebook review and numerical regression analysis.
|
|
2
|
+
|
|
3
|
+
The public, stable entry points are:
|
|
4
|
+
|
|
5
|
+
* :func:`notedelta.engine.compare_files` - compare two notebook files and return a
|
|
6
|
+
:class:`notedelta.model.Report`.
|
|
7
|
+
* :func:`notedelta.engine.compare_notebooks` - the same for already-loaded notebooks.
|
|
8
|
+
* The ``notedelta`` command line interface (:mod:`notedelta.cli`).
|
|
9
|
+
|
|
10
|
+
Everything else is internal and may change between minor versions.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from notedelta._version import __version__
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
notedelta/__main__.py
ADDED
notedelta/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
notedelta/cli.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""``notedelta`` command line interface.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 success, 1 fail-on condition met, 2 invalid input/configuration/usage,
|
|
4
|
+
3 internal, git or execution error. Ordinary comparison never runs notebook code and
|
|
5
|
+
never touches the network.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import glob
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import traceback
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TextIO
|
|
19
|
+
|
|
20
|
+
from notedelta._version import __version__
|
|
21
|
+
from notedelta.config.loader import STARTER_CONFIG, find_config, load_config
|
|
22
|
+
from notedelta.config.schema import FAIL_CATEGORIES, NoteDeltaConfig
|
|
23
|
+
from notedelta.errors import ExitCode, InputError, NoteDeltaError
|
|
24
|
+
from notedelta.limits import DEFAULT_LIMITS, Limits
|
|
25
|
+
from notedelta.model import Report
|
|
26
|
+
from notedelta.reporting.terminal import ASCII_MAP as _ASCII
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Parser(argparse.ArgumentParser):
|
|
30
|
+
def error(self, message: str) -> None: # type: ignore[override]
|
|
31
|
+
self.print_usage(sys.stderr)
|
|
32
|
+
sys.stderr.write(f"notedelta: error: {message}\n")
|
|
33
|
+
raise SystemExit(ExitCode.INVALID_INPUT)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _out(stream: TextIO, text: str) -> None:
|
|
37
|
+
enc = (getattr(stream, "encoding", None) or "utf-8").lower()
|
|
38
|
+
if "utf" not in enc:
|
|
39
|
+
text = text.translate(_ASCII).encode(enc, errors="replace").decode(enc)
|
|
40
|
+
stream.write(text)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fail_on(values: list[str] | None) -> list[str]:
|
|
44
|
+
result: list[str] = []
|
|
45
|
+
for v in values or []:
|
|
46
|
+
for part in v.split(","):
|
|
47
|
+
part = part.strip()
|
|
48
|
+
if not part:
|
|
49
|
+
continue
|
|
50
|
+
if part not in FAIL_CATEGORIES:
|
|
51
|
+
raise InputError(f"unknown --fail-on category {part!r}; choose from {', '.join(FAIL_CATEGORIES)}")
|
|
52
|
+
result.append(part)
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_cfg(args: argparse.Namespace, search_from: Path) -> tuple[NoteDeltaConfig, str | None]:
|
|
57
|
+
if getattr(args, "no_config", False):
|
|
58
|
+
return NoteDeltaConfig(), None
|
|
59
|
+
path: Path | None
|
|
60
|
+
if args.config:
|
|
61
|
+
path = Path(args.config)
|
|
62
|
+
if not path.is_file():
|
|
63
|
+
raise InputError(f"configuration file not found: {args.config}")
|
|
64
|
+
else:
|
|
65
|
+
path = find_config(search_from) or find_config(Path.cwd())
|
|
66
|
+
if path is None:
|
|
67
|
+
return NoteDeltaConfig(), None
|
|
68
|
+
cfg = load_config(path)
|
|
69
|
+
if args.no_discovery:
|
|
70
|
+
cfg = cfg.model_copy(update={"discovery": cfg.discovery.model_copy(update={"enabled": False})})
|
|
71
|
+
return cfg, path.name
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _limits(args: argparse.Namespace) -> Limits:
|
|
75
|
+
if args.max_notebook_mb:
|
|
76
|
+
if args.max_notebook_mb <= 0:
|
|
77
|
+
raise InputError("--max-notebook-mb must be positive")
|
|
78
|
+
return Limits(max_notebook_bytes=int(args.max_notebook_mb * 1024 * 1024))
|
|
79
|
+
return DEFAULT_LIMITS
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _options(args: argparse.Namespace, cfg: NoteDeltaConfig, source: str | None) -> object:
|
|
83
|
+
from notedelta.engine import CompareOptions
|
|
84
|
+
|
|
85
|
+
extra = _fail_on(args.fail_on)
|
|
86
|
+
if args.fail_on_regression:
|
|
87
|
+
extra.append("regression")
|
|
88
|
+
if args.no_discovery and source is None:
|
|
89
|
+
cfg = cfg.model_copy(update={"discovery": cfg.discovery.model_copy(update={"enabled": False})})
|
|
90
|
+
return CompareOptions(
|
|
91
|
+
config=cfg,
|
|
92
|
+
config_source=source,
|
|
93
|
+
baseline_project=Path(args.baseline_project) if getattr(args, "baseline_project", None) else None,
|
|
94
|
+
candidate_project=Path(args.candidate_project) if getattr(args, "candidate_project", None) else None,
|
|
95
|
+
environment=not args.no_env,
|
|
96
|
+
embed_images=args.embed_images,
|
|
97
|
+
redact=not args.no_redact,
|
|
98
|
+
limits=_limits(args),
|
|
99
|
+
extra_fail_on=extra,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _render(report: Report, fmt: str, *, verbose: bool = False, color: bool = False) -> str:
|
|
104
|
+
from notedelta.reporting import render_html, render_json, render_markdown, render_text
|
|
105
|
+
|
|
106
|
+
if fmt == "json":
|
|
107
|
+
return render_json(report)
|
|
108
|
+
if fmt == "html":
|
|
109
|
+
return render_html(report)
|
|
110
|
+
if fmt == "markdown":
|
|
111
|
+
return render_markdown(report)
|
|
112
|
+
ascii_only = "utf" not in (getattr(sys.stdout, "encoding", None) or "utf-8").lower()
|
|
113
|
+
return render_text(report, verbose=verbose, color=color, ascii_only=ascii_only)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _write_file(path: Path, text: str, protected: list[Path]) -> None:
|
|
117
|
+
resolved = path.resolve()
|
|
118
|
+
if any(resolved == p.resolve() for p in protected):
|
|
119
|
+
raise InputError(f"refusing to overwrite an input notebook: {path.name}")
|
|
120
|
+
if resolved.suffix == ".ipynb":
|
|
121
|
+
raise InputError(f"refusing to write a report to a .ipynb file: {path.name}")
|
|
122
|
+
try:
|
|
123
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
resolved.write_text(text, encoding="utf-8", newline="\n")
|
|
125
|
+
except OSError as exc:
|
|
126
|
+
raise InputError(f"cannot write {path.name}: {exc.strerror or exc}") from exc
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _emit(report: Report, args: argparse.Namespace, protected: list[Path]) -> None:
|
|
130
|
+
from notedelta.reporting.terminal import use_color
|
|
131
|
+
|
|
132
|
+
if args.json:
|
|
133
|
+
_write_file(Path(args.json), _render(report, "json"), protected)
|
|
134
|
+
if args.html:
|
|
135
|
+
_write_file(Path(args.html), _render(report, "html"), protected)
|
|
136
|
+
if args.markdown:
|
|
137
|
+
_write_file(Path(args.markdown), _render(report, "markdown"), protected)
|
|
138
|
+
if args.output:
|
|
139
|
+
_write_file(Path(args.output), _render(report, args.format), protected)
|
|
140
|
+
if not args.quiet and args.format != "text":
|
|
141
|
+
_out(sys.stderr, f"notedelta: wrote {args.format} report to {Path(args.output).name}\n")
|
|
142
|
+
elif not args.quiet:
|
|
143
|
+
if args.format == "text":
|
|
144
|
+
_out(sys.stdout, _render(report, "text", verbose=args.verbose, color=use_color(sys.stdout, args.color)))
|
|
145
|
+
else:
|
|
146
|
+
_out(sys.stdout, _render(report, args.format))
|
|
147
|
+
elif args.quiet and report.policy.failed:
|
|
148
|
+
_out(sys.stderr, "notedelta: FAIL: " + "; ".join(report.policy.reasons) + "\n")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cmd_compare(args: argparse.Namespace) -> int:
|
|
152
|
+
from notedelta.engine import CompareOptions, compare_files
|
|
153
|
+
|
|
154
|
+
base, cand = Path(args.baseline), Path(args.candidate)
|
|
155
|
+
cfg, source = _load_cfg(args, cand.resolve().parent if cand.exists() else Path.cwd())
|
|
156
|
+
opts = _options(args, cfg, source)
|
|
157
|
+
assert isinstance(opts, CompareOptions)
|
|
158
|
+
report = compare_files(base, cand, opts)
|
|
159
|
+
_emit(report, args, [base, cand])
|
|
160
|
+
return ExitCode.POLICY_FAILURE if report.policy.failed else ExitCode.OK
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _expand(patterns: list[str]) -> list[Path]:
|
|
164
|
+
paths: list[Path] = []
|
|
165
|
+
for pat in patterns:
|
|
166
|
+
if any(ch in pat for ch in "*?["):
|
|
167
|
+
matches = sorted(glob.glob(pat, recursive=True))
|
|
168
|
+
paths.extend(Path(m) for m in matches if m.endswith(".ipynb") and "/.ipynb_checkpoints/" not in m.replace("\\", "/"))
|
|
169
|
+
else:
|
|
170
|
+
paths.append(Path(pat))
|
|
171
|
+
unique: list[Path] = []
|
|
172
|
+
for p in paths:
|
|
173
|
+
if p.resolve() not in [u.resolve() for u in unique]:
|
|
174
|
+
unique.append(p)
|
|
175
|
+
return unique
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_compare_git(args: argparse.Namespace) -> int:
|
|
179
|
+
from notedelta import gitref
|
|
180
|
+
from notedelta.engine import CompareOptions, compare_files
|
|
181
|
+
from notedelta.reporting import render_markdown
|
|
182
|
+
|
|
183
|
+
notebooks = _expand(args.notebooks)
|
|
184
|
+
if not notebooks:
|
|
185
|
+
raise InputError("no notebooks matched")
|
|
186
|
+
multi = len(notebooks) > 1
|
|
187
|
+
if multi and (args.output or args.json or args.html or args.markdown):
|
|
188
|
+
raise InputError("with several notebooks use --report-dir instead of --output/--json/--html/--markdown")
|
|
189
|
+
worst = ExitCode.OK
|
|
190
|
+
summaries: list[str] = []
|
|
191
|
+
for nb in notebooks:
|
|
192
|
+
if not nb.is_file():
|
|
193
|
+
raise InputError(f"{nb.name}: file not found")
|
|
194
|
+
cfg, source = _load_cfg(args, nb.resolve().parent)
|
|
195
|
+
with tempfile.TemporaryDirectory(prefix="notedelta-git-") as tmp:
|
|
196
|
+
snap = gitref.snapshot(nb, args.ref, Path(tmp) / "baseline")
|
|
197
|
+
if args.candidate_ref:
|
|
198
|
+
csnap = gitref.snapshot(nb, args.candidate_ref, Path(tmp) / "candidate")
|
|
199
|
+
if csnap.notebook is None:
|
|
200
|
+
raise InputError(f"{nb.name} does not exist at {args.candidate_ref}")
|
|
201
|
+
cand_path, cand_project, cand_label = csnap.notebook, csnap.project_dir, csnap.label
|
|
202
|
+
else:
|
|
203
|
+
cand_path, cand_project, cand_label = nb, nb.resolve().parent, f"{nb.name} (working tree)"
|
|
204
|
+
if snap.notebook is None:
|
|
205
|
+
msg = f"{nb.name}: new notebook - it does not exist at {args.ref}, nothing to compare"
|
|
206
|
+
if not args.quiet:
|
|
207
|
+
_out(sys.stderr, f"notedelta: {msg}\n")
|
|
208
|
+
summaries.append(f"### ℹ️ NoteDelta: {nb.name}\n\n{msg}.\n")
|
|
209
|
+
continue
|
|
210
|
+
opts = _options(args, cfg, source)
|
|
211
|
+
assert isinstance(opts, CompareOptions)
|
|
212
|
+
opts.baseline_project = snap.project_dir
|
|
213
|
+
opts.candidate_project = cand_project
|
|
214
|
+
report = compare_files(snap.notebook, cand_path, opts, baseline_name=snap.label, candidate_name=cand_label)
|
|
215
|
+
if args.report_dir:
|
|
216
|
+
rd = Path(args.report_dir)
|
|
217
|
+
stem = nb.stem if not multi else nb.with_suffix("").as_posix().replace("/", "__").replace(":", "")
|
|
218
|
+
_write_file(rd / f"{stem}.html", _render(report, "html"), [nb])
|
|
219
|
+
_write_file(rd / f"{stem}.json", _render(report, "json"), [nb])
|
|
220
|
+
summaries.append(render_markdown(report, title=nb.as_posix()))
|
|
221
|
+
if multi:
|
|
222
|
+
if not args.quiet:
|
|
223
|
+
from notedelta.reporting.terminal import use_color
|
|
224
|
+
|
|
225
|
+
_out(
|
|
226
|
+
sys.stdout,
|
|
227
|
+
_render(report, "text", verbose=args.verbose, color=use_color(sys.stdout, args.color)) + "\n",
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
_emit(report, args, [nb])
|
|
231
|
+
if report.policy.failed:
|
|
232
|
+
worst = ExitCode.POLICY_FAILURE
|
|
233
|
+
if args.summary:
|
|
234
|
+
path = Path(args.summary)
|
|
235
|
+
try:
|
|
236
|
+
with path.open("a", encoding="utf-8", newline="\n") as fh:
|
|
237
|
+
fh.write("\n".join(summaries) + "\n")
|
|
238
|
+
except OSError as exc:
|
|
239
|
+
raise InputError(f"cannot append summary to {path.name}: {exc.strerror or exc}") from exc
|
|
240
|
+
return worst
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cmd_check_config(args: argparse.Namespace) -> int:
|
|
244
|
+
path = Path(args.path) if args.path else find_config(Path.cwd())
|
|
245
|
+
if path is None:
|
|
246
|
+
raise InputError("no .notedelta.yml found in this folder or its parents (create one with 'notedelta init')")
|
|
247
|
+
if not path.is_file():
|
|
248
|
+
raise InputError(f"configuration file not found: {args.path}")
|
|
249
|
+
cfg = load_config(path)
|
|
250
|
+
lines = [f"{path.name}: valid (version {cfg.version})"]
|
|
251
|
+
for m in cfg.metrics:
|
|
252
|
+
tol = []
|
|
253
|
+
if m.tolerance.absolute:
|
|
254
|
+
tol.append(f"abs {m.tolerance.absolute:g}")
|
|
255
|
+
if m.tolerance.relative:
|
|
256
|
+
tol.append(f"rel {m.tolerance.relative:g}")
|
|
257
|
+
lines.append(f" metric {m.name}: {m.extraction.type}, direction {m.direction}, tolerance {' / '.join(tol) or 'exact'}")
|
|
258
|
+
if cfg.policy.fail_on:
|
|
259
|
+
lines.append(f" policy fail_on: {', '.join(cfg.policy.fail_on)}")
|
|
260
|
+
_out(sys.stdout, "\n".join(lines) + "\n")
|
|
261
|
+
return ExitCode.OK
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
265
|
+
target = Path(args.directory) / ".notedelta.yml"
|
|
266
|
+
if target.exists() and not args.force:
|
|
267
|
+
raise InputError(f"{target.name} already exists (use --force to overwrite)")
|
|
268
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
target.write_text(STARTER_CONFIG, encoding="utf-8", newline="\n")
|
|
270
|
+
_out(sys.stdout, f"wrote {target.name} - edit the metrics section, then run 'notedelta check-config'\n")
|
|
271
|
+
return ExitCode.OK
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def cmd_schema(args: argparse.Namespace) -> int:
|
|
275
|
+
if args.which == "config":
|
|
276
|
+
schema = NoteDeltaConfig.model_json_schema()
|
|
277
|
+
schema["$id"] = "https://notedelta.dev/schema/config-v1.json"
|
|
278
|
+
else:
|
|
279
|
+
schema = Report.model_json_schema()
|
|
280
|
+
schema["$id"] = "https://notedelta.dev/schema/report-v1.json"
|
|
281
|
+
_out(sys.stdout, json.dumps(schema, indent=2) + "\n")
|
|
282
|
+
return ExitCode.OK
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def cmd_execute(args: argparse.Namespace) -> int:
|
|
286
|
+
from notedelta.execution.runner import execute_notebook
|
|
287
|
+
|
|
288
|
+
path = Path(args.notebook)
|
|
289
|
+
if not path.is_file():
|
|
290
|
+
raise InputError(f"{path.name}: file not found")
|
|
291
|
+
warning = (
|
|
292
|
+
f"Executing {path.name} runs its code with your user permissions. This is NOT a sandbox: the code can "
|
|
293
|
+
"read and modify your files and use the network."
|
|
294
|
+
)
|
|
295
|
+
if not args.trust:
|
|
296
|
+
if not sys.stdin.isatty():
|
|
297
|
+
raise InputError(warning + " Pass --trust to confirm you trust this notebook.")
|
|
298
|
+
_out(sys.stderr, warning + "\nType 'yes' to execute: ")
|
|
299
|
+
if input().strip().lower() != "yes":
|
|
300
|
+
_out(sys.stderr, "notedelta: execution cancelled\n")
|
|
301
|
+
return ExitCode.INVALID_INPUT
|
|
302
|
+
records = execute_notebook(
|
|
303
|
+
path,
|
|
304
|
+
output_dir=Path(args.output_dir),
|
|
305
|
+
kernel=args.kernel,
|
|
306
|
+
timeout=args.timeout,
|
|
307
|
+
repeat=args.repeat,
|
|
308
|
+
workdir=args.workdir,
|
|
309
|
+
allow_errors=args.allow_errors,
|
|
310
|
+
force=args.force,
|
|
311
|
+
)
|
|
312
|
+
failed = False
|
|
313
|
+
for r in records:
|
|
314
|
+
state = "ok" if r.status == "ok" else f"{r.status}: {r.error}"
|
|
315
|
+
_out(sys.stdout, f"run {r.run}: {r.output} {r.duration_seconds:.1f}s kernel {r.kernel} {state}\n")
|
|
316
|
+
failed |= r.status != "ok"
|
|
317
|
+
if len(records) > 1:
|
|
318
|
+
_out(
|
|
319
|
+
sys.stdout,
|
|
320
|
+
"compare runs with: notedelta stability " + " ".join(str(Path(args.output_dir) / r.output) for r in records) + "\n",
|
|
321
|
+
)
|
|
322
|
+
return ExitCode.INTERNAL_ERROR if failed else ExitCode.OK
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cmd_stability(args: argparse.Namespace) -> int:
|
|
326
|
+
from notedelta.stability import analyze, render_stability_text
|
|
327
|
+
|
|
328
|
+
paths = _expand(args.notebooks)
|
|
329
|
+
if len(paths) < 2:
|
|
330
|
+
raise InputError("stability needs at least two notebooks (for example the outputs of 'notedelta execute --repeat 3')")
|
|
331
|
+
args.no_discovery = False
|
|
332
|
+
cfg, _ = _load_cfg(args, paths[0].resolve().parent)
|
|
333
|
+
if not cfg.metrics:
|
|
334
|
+
raise InputError("no metrics configured; stability analysis uses the metrics in .notedelta.yml")
|
|
335
|
+
rep = analyze(paths, cfg)
|
|
336
|
+
_out(sys.stdout, rep.model_dump_json(indent=2) + "\n" if args.format == "json" else render_stability_text(rep))
|
|
337
|
+
return ExitCode.OK
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _add_compare_options(p: argparse.ArgumentParser) -> None:
|
|
341
|
+
g = p.add_argument_group("configuration")
|
|
342
|
+
g.add_argument("--config", metavar="FILE", help="path to .notedelta.yml (default: searched upwards from the candidate)")
|
|
343
|
+
g.add_argument("--no-config", action="store_true", help="ignore any .notedelta.yml")
|
|
344
|
+
g.add_argument("--no-discovery", action="store_true", help="do not report automatically discovered numbers")
|
|
345
|
+
g.add_argument("--no-env", action="store_true", help="skip environment and reproducibility diagnostics")
|
|
346
|
+
o = p.add_argument_group("output")
|
|
347
|
+
o.add_argument(
|
|
348
|
+
"-f",
|
|
349
|
+
"--format",
|
|
350
|
+
choices=["text", "json", "html", "markdown"],
|
|
351
|
+
default="text",
|
|
352
|
+
help="format written to stdout or --output (default: text)",
|
|
353
|
+
)
|
|
354
|
+
o.add_argument("-o", "--output", metavar="FILE", help="write the --format output to FILE instead of stdout")
|
|
355
|
+
o.add_argument("--json", metavar="FILE", help="also write a JSON report")
|
|
356
|
+
o.add_argument("--html", metavar="FILE", help="also write a self-contained HTML report")
|
|
357
|
+
o.add_argument("--markdown", metavar="FILE", help="also write a Markdown summary")
|
|
358
|
+
o.add_argument("--embed-images", action="store_true", help="embed changed raster images in the HTML report")
|
|
359
|
+
o.add_argument("--no-redact", action="store_true", help="do not redact secrets and home-directory paths in excerpts")
|
|
360
|
+
o.add_argument("-v", "--verbose", action="store_true", help="list every cell change and notice")
|
|
361
|
+
o.add_argument("-q", "--quiet", action="store_true", help="print nothing except failures (use the exit code)")
|
|
362
|
+
o.add_argument("--color", choices=["auto", "always", "never"], default="auto")
|
|
363
|
+
c = p.add_argument_group("checks")
|
|
364
|
+
c.add_argument("--fail-on-regression", action="store_true", help="exit with status 1 if a configured metric regressed")
|
|
365
|
+
c.add_argument(
|
|
366
|
+
"--fail-on",
|
|
367
|
+
action="append",
|
|
368
|
+
metavar="CATS",
|
|
369
|
+
help="exit 1 on any of these (comma separated): " + ", ".join(FAIL_CATEGORIES),
|
|
370
|
+
)
|
|
371
|
+
c.add_argument("--max-notebook-mb", type=float, metavar="MB", help="refuse notebooks larger than this (default 256)")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
375
|
+
parser = _Parser(
|
|
376
|
+
prog="notedelta",
|
|
377
|
+
description="Know what changed in your scientific results, not just your code. "
|
|
378
|
+
"Compares Jupyter notebooks offline; never executes notebook code unless you run 'notedelta execute'.",
|
|
379
|
+
epilog="Exit codes: 0 ok, 1 fail-on condition met, 2 invalid input or configuration, 3 internal/git/execution error.",
|
|
380
|
+
)
|
|
381
|
+
parser.add_argument("--version", action="version", version=f"notedelta {__version__}")
|
|
382
|
+
parser.add_argument("--debug", action="store_true", help=argparse.SUPPRESS)
|
|
383
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND", parser_class=_Parser)
|
|
384
|
+
|
|
385
|
+
p = sub.add_parser(
|
|
386
|
+
"compare", help="compare two notebooks", description="Compare a baseline notebook with a candidate notebook."
|
|
387
|
+
)
|
|
388
|
+
p.add_argument("baseline", help="baseline .ipynb (the reference)")
|
|
389
|
+
p.add_argument("candidate", help="candidate .ipynb (the new version)")
|
|
390
|
+
p.add_argument("--baseline-project", metavar="DIR", help="folder whose dependency files describe the baseline environment")
|
|
391
|
+
p.add_argument("--candidate-project", metavar="DIR", help="folder whose dependency files describe the candidate environment")
|
|
392
|
+
_add_compare_options(p)
|
|
393
|
+
p.set_defaults(func=cmd_compare)
|
|
394
|
+
|
|
395
|
+
g = sub.add_parser(
|
|
396
|
+
"compare-git",
|
|
397
|
+
help="compare notebooks with their version at a git revision",
|
|
398
|
+
description="Compare each notebook (working tree) with the same file at a git revision. "
|
|
399
|
+
"Dependency files are read from that revision too.",
|
|
400
|
+
)
|
|
401
|
+
g.add_argument("notebooks", nargs="+", metavar="NOTEBOOK", help="notebook path(s) or glob(s), e.g. 'notebooks/**/*.ipynb'")
|
|
402
|
+
g.add_argument("--ref", default="HEAD", help="baseline revision (default: HEAD)")
|
|
403
|
+
g.add_argument("--candidate-ref", metavar="REF", help="compare against this revision instead of the working tree")
|
|
404
|
+
g.add_argument("--report-dir", metavar="DIR", help="write <name>.html and <name>.json for each notebook here")
|
|
405
|
+
g.add_argument("--summary", metavar="FILE", help="append a Markdown summary (e.g. $GITHUB_STEP_SUMMARY)")
|
|
406
|
+
_add_compare_options(g)
|
|
407
|
+
g.set_defaults(func=cmd_compare_git)
|
|
408
|
+
|
|
409
|
+
c = sub.add_parser("check-config", help="validate a .notedelta.yml file")
|
|
410
|
+
c.add_argument("path", nargs="?", help="config file (default: search upwards from the current folder)")
|
|
411
|
+
c.set_defaults(func=cmd_check_config)
|
|
412
|
+
|
|
413
|
+
i = sub.add_parser("init", help="write a starter .notedelta.yml")
|
|
414
|
+
i.add_argument("directory", nargs="?", default=".", help="folder to write into (default: current folder)")
|
|
415
|
+
i.add_argument("--force", action="store_true", help="overwrite an existing file")
|
|
416
|
+
i.set_defaults(func=cmd_init)
|
|
417
|
+
|
|
418
|
+
s = sub.add_parser("schema", help="print the JSON Schema of the report or the configuration")
|
|
419
|
+
s.add_argument("which", choices=["report", "config"], nargs="?", default="report")
|
|
420
|
+
s.set_defaults(func=cmd_schema)
|
|
421
|
+
|
|
422
|
+
x = sub.add_parser(
|
|
423
|
+
"execute",
|
|
424
|
+
help="re-run a notebook into a copy (opt-in; runs code; not sandboxed)",
|
|
425
|
+
description="Execute a notebook with a Jupyter kernel and save executed copies. "
|
|
426
|
+
"The original notebook is not modified. Requires: pip install 'notedelta[execute]'. "
|
|
427
|
+
"WARNING: this runs the notebook's code with your permissions; it is not a sandbox.",
|
|
428
|
+
)
|
|
429
|
+
x.add_argument("notebook")
|
|
430
|
+
x.add_argument("--trust", action="store_true", help="confirm that you trust this notebook's code")
|
|
431
|
+
x.add_argument("--kernel", help="kernel name (default: the notebook's kernelspec, else python3)")
|
|
432
|
+
x.add_argument("--timeout", type=int, default=600, help="per-cell timeout in seconds (default 600)")
|
|
433
|
+
x.add_argument("--repeat", type=int, default=1, help="number of independent runs (default 1)")
|
|
434
|
+
x.add_argument("--output-dir", default="notedelta-runs", help="where executed copies are written (default ./notedelta-runs)")
|
|
435
|
+
x.add_argument(
|
|
436
|
+
"--workdir",
|
|
437
|
+
choices=["notebook", "temp"],
|
|
438
|
+
default="notebook",
|
|
439
|
+
help="kernel working directory: the notebook's folder (default) or an empty temporary folder",
|
|
440
|
+
)
|
|
441
|
+
x.add_argument("--allow-errors", action="store_true", help="continue after a failing cell")
|
|
442
|
+
x.add_argument("--force", action="store_true", help="overwrite existing run files")
|
|
443
|
+
x.set_defaults(func=cmd_execute)
|
|
444
|
+
|
|
445
|
+
st = sub.add_parser("stability", help="describe metric variation across repeated runs")
|
|
446
|
+
st.add_argument("notebooks", nargs="+", metavar="NOTEBOOK")
|
|
447
|
+
st.add_argument("--config", metavar="FILE")
|
|
448
|
+
st.add_argument("--format", choices=["text", "json"], default="text")
|
|
449
|
+
st.set_defaults(func=cmd_stability, no_config=False)
|
|
450
|
+
return parser
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def main(argv: list[str] | None = None) -> int:
|
|
454
|
+
parser = build_parser()
|
|
455
|
+
args = parser.parse_args(argv)
|
|
456
|
+
if not getattr(args, "command", None):
|
|
457
|
+
parser.print_help()
|
|
458
|
+
return ExitCode.INVALID_INPUT
|
|
459
|
+
try:
|
|
460
|
+
code: int = args.func(args)
|
|
461
|
+
return code
|
|
462
|
+
except NoteDeltaError as exc:
|
|
463
|
+
_out(sys.stderr, f"notedelta: error: {exc}\n")
|
|
464
|
+
return exc.exit_code
|
|
465
|
+
except KeyboardInterrupt:
|
|
466
|
+
return 130
|
|
467
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
468
|
+
_out(sys.stderr, f"notedelta: internal error: {type(exc).__name__}: {exc}\n")
|
|
469
|
+
if args.debug or os.environ.get("NOTEDELTA_DEBUG"):
|
|
470
|
+
traceback.print_exc()
|
|
471
|
+
else:
|
|
472
|
+
_out(sys.stderr, "notedelta: re-run with --debug for a traceback, and please report the problem\n")
|
|
473
|
+
return ExitCode.INTERNAL_ERROR
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Locate, read and validate ``.notedelta.yml``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from notedelta.config.schema import NoteDeltaConfig
|
|
12
|
+
from notedelta.errors import ConfigError
|
|
13
|
+
from notedelta.limits import DEFAULT_LIMITS
|
|
14
|
+
|
|
15
|
+
CONFIG_NAMES = (".notedelta.yml", ".notedelta.yaml")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_config(start: Path, max_levels: int = 12) -> Path | None:
|
|
19
|
+
"""Search ``start`` and its parents (stopping at a git root) for a config file."""
|
|
20
|
+
cur = start.resolve()
|
|
21
|
+
if cur.is_file():
|
|
22
|
+
cur = cur.parent
|
|
23
|
+
for _ in range(max_levels):
|
|
24
|
+
for name in CONFIG_NAMES:
|
|
25
|
+
candidate = cur / name
|
|
26
|
+
if candidate.is_file():
|
|
27
|
+
return candidate
|
|
28
|
+
if (cur / ".git").exists() or cur.parent == cur:
|
|
29
|
+
return None
|
|
30
|
+
cur = cur.parent
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _format_loc(loc: tuple[Any, ...], data: Any) -> str:
|
|
35
|
+
parts: list[str] = []
|
|
36
|
+
node = data
|
|
37
|
+
for item in loc:
|
|
38
|
+
if isinstance(item, int):
|
|
39
|
+
label = f"[{item}]"
|
|
40
|
+
if isinstance(node, list) and 0 <= item < len(node):
|
|
41
|
+
node = node[item]
|
|
42
|
+
if isinstance(node, dict) and isinstance(node.get("name"), str):
|
|
43
|
+
label = f"[{item}:{node['name']}]"
|
|
44
|
+
else:
|
|
45
|
+
node = None
|
|
46
|
+
parts.append(label)
|
|
47
|
+
else:
|
|
48
|
+
# discriminated-union tags ("regex", "key_value", ...) are not user-visible keys
|
|
49
|
+
if item in ("regex", "key_value", "json_path") and isinstance(node, dict) and "type" in node:
|
|
50
|
+
continue
|
|
51
|
+
node = node.get(item) if isinstance(node, dict) else None
|
|
52
|
+
parts.append(("." if parts else "") + str(item))
|
|
53
|
+
return "".join(parts) or "(root)"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_config(text: str, source: str = ".notedelta.yml") -> NoteDeltaConfig:
|
|
57
|
+
try:
|
|
58
|
+
data = yaml.safe_load(text)
|
|
59
|
+
except yaml.YAMLError as exc:
|
|
60
|
+
mark = getattr(exc, "problem_mark", None)
|
|
61
|
+
where = f" (line {mark.line + 1}, column {mark.column + 1})" if mark is not None else ""
|
|
62
|
+
raise ConfigError(f"{source}: invalid YAML{where}: {getattr(exc, 'problem', None) or exc}") from exc
|
|
63
|
+
if data is None:
|
|
64
|
+
data = {}
|
|
65
|
+
if not isinstance(data, dict):
|
|
66
|
+
raise ConfigError(f"{source}: top level must be a mapping (key: value), got {type(data).__name__}")
|
|
67
|
+
if "version" not in data:
|
|
68
|
+
raise ConfigError(f"{source}: missing required key 'version' (use 'version: 1')")
|
|
69
|
+
if data.get("version") != 1:
|
|
70
|
+
raise ConfigError(f"{source}: unsupported config version {data.get('version')!r}; this NoteDelta supports version 1")
|
|
71
|
+
try:
|
|
72
|
+
return NoteDeltaConfig.model_validate(data)
|
|
73
|
+
except ValidationError as exc:
|
|
74
|
+
lines = []
|
|
75
|
+
for err in exc.errors()[:20]:
|
|
76
|
+
msg = err.get("msg", "invalid value")
|
|
77
|
+
if msg.startswith("Value error, "):
|
|
78
|
+
msg = msg[len("Value error, ") :]
|
|
79
|
+
if err.get("type") == "extra_forbidden":
|
|
80
|
+
msg = "unknown key (check spelling)"
|
|
81
|
+
lines.append(f" - {_format_loc(tuple(err.get('loc', ())), data)}: {msg}")
|
|
82
|
+
raise ConfigError(f"{source}: invalid configuration\n" + "\n".join(lines)) from exc
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_config(path: Path) -> NoteDeltaConfig:
|
|
86
|
+
name = path.name
|
|
87
|
+
try:
|
|
88
|
+
size = path.stat().st_size
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
raise ConfigError(f"{name}: cannot read configuration ({exc.strerror or exc})") from exc
|
|
91
|
+
if size > DEFAULT_LIMITS.max_config_bytes:
|
|
92
|
+
raise ConfigError(f"{name}: configuration file is larger than 1 MB")
|
|
93
|
+
try:
|
|
94
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
95
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
96
|
+
raise ConfigError(f"{name}: cannot read configuration ({exc})") from exc
|
|
97
|
+
return parse_config(text, name)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
STARTER_CONFIG = """\
|
|
101
|
+
# NoteDelta configuration - https://github.com/ouyang-matters/notedelta/blob/main/docs/configuration.md
|
|
102
|
+
version: 1
|
|
103
|
+
|
|
104
|
+
# Scientific metrics you care about. Each one is located in notebook outputs,
|
|
105
|
+
# compared with a tolerance, and judged using its direction.
|
|
106
|
+
metrics:
|
|
107
|
+
- name: validation_accuracy
|
|
108
|
+
label: Validation accuracy
|
|
109
|
+
direction: maximize # maximize | minimize | none
|
|
110
|
+
unit: fraction # values in [0, 1]; differences shown in percentage points
|
|
111
|
+
extraction:
|
|
112
|
+
type: key_value # matches lines like "Validation accuracy = 0.912"
|
|
113
|
+
key: Validation accuracy
|
|
114
|
+
tolerance:
|
|
115
|
+
absolute: 0.002 # changes up to 0.2 percentage points are within tolerance
|
|
116
|
+
|
|
117
|
+
# - name: reconstruction_error
|
|
118
|
+
# label: Relative reconstruction error
|
|
119
|
+
# direction: minimize
|
|
120
|
+
# extraction:
|
|
121
|
+
# type: regex
|
|
122
|
+
# pattern: 'Relative reconstruction error\\s*=\\s*([0-9.eE+-]+)'
|
|
123
|
+
# tolerance:
|
|
124
|
+
# absolute: 0.0001
|
|
125
|
+
# relative: 0.02 # 2% of the baseline value
|
|
126
|
+
|
|
127
|
+
# Numbers found automatically in changed outputs (no direction, so never "regressions").
|
|
128
|
+
discovery:
|
|
129
|
+
enabled: true
|
|
130
|
+
tolerance:
|
|
131
|
+
absolute: 1.0e-12
|
|
132
|
+
relative: 1.0e-9
|
|
133
|
+
|
|
134
|
+
# Exit with status 1 when any of these occur (also see --fail-on-regression).
|
|
135
|
+
policy:
|
|
136
|
+
fail_on: [] # e.g. [regression, output_missing, new_error]
|
|
137
|
+
"""
|