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.
Files changed (46) hide show
  1. notedelta/__init__.py +15 -0
  2. notedelta/__main__.py +3 -0
  3. notedelta/_version.py +1 -0
  4. notedelta/cli.py +473 -0
  5. notedelta/config/__init__.py +4 -0
  6. notedelta/config/loader.py +137 -0
  7. notedelta/config/schema.py +326 -0
  8. notedelta/engine.py +761 -0
  9. notedelta/environment/__init__.py +0 -0
  10. notedelta/environment/deps.py +503 -0
  11. notedelta/environment/notebook_env.py +183 -0
  12. notedelta/environment/seeds.py +175 -0
  13. notedelta/errors.py +44 -0
  14. notedelta/execution/__init__.py +0 -0
  15. notedelta/execution/runner.py +143 -0
  16. notedelta/gitref.py +127 -0
  17. notedelta/limits.py +29 -0
  18. notedelta/matching/__init__.py +3 -0
  19. notedelta/matching/cells.py +284 -0
  20. notedelta/metrics/__init__.py +0 -0
  21. notedelta/metrics/extract.py +160 -0
  22. notedelta/model.py +300 -0
  23. notedelta/notebooks/__init__.py +4 -0
  24. notedelta/notebooks/loader.py +259 -0
  25. notedelta/notebooks/model.py +179 -0
  26. notedelta/numeric/__init__.py +0 -0
  27. notedelta/numeric/compare.py +227 -0
  28. notedelta/numeric/discover.py +181 -0
  29. notedelta/numeric/literal.py +456 -0
  30. notedelta/numeric/numbers.py +183 -0
  31. notedelta/numeric/table.py +264 -0
  32. notedelta/numeric/text.py +190 -0
  33. notedelta/regression/__init__.py +0 -0
  34. notedelta/regression/classify.py +275 -0
  35. notedelta/reporting/__init__.py +11 -0
  36. notedelta/reporting/common.py +144 -0
  37. notedelta/reporting/html.py +503 -0
  38. notedelta/reporting/markdown.py +104 -0
  39. notedelta/reporting/redact.py +70 -0
  40. notedelta/reporting/terminal.py +170 -0
  41. notedelta/stability.py +111 -0
  42. notedelta-0.1.0.dist-info/METADATA +187 -0
  43. notedelta-0.1.0.dist-info/RECORD +46 -0
  44. notedelta-0.1.0.dist-info/WHEEL +4 -0
  45. notedelta-0.1.0.dist-info/entry_points.txt +2 -0
  46. 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
@@ -0,0 +1,3 @@
1
+ from notedelta.cli import main
2
+
3
+ raise SystemExit(main())
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,4 @@
1
+ from notedelta.config.loader import STARTER_CONFIG, find_config, load_config, parse_config
2
+ from notedelta.config.schema import MetricSpec, NoteDeltaConfig
3
+
4
+ __all__ = ["STARTER_CONFIG", "MetricSpec", "NoteDeltaConfig", "find_config", "load_config", "parse_config"]
@@ -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
+ """