sundgren 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.
sundgren/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Sundgren: what happened to source coverage because of this patch."""
2
+
3
+ __version__ = "0.1.0"
sundgren/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from sundgren.cli import main
2
+
3
+ raise SystemExit(main())
sundgren/cli.py ADDED
@@ -0,0 +1,459 @@
1
+ """Command line: diff, run, presets, ingest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Mapping, Sequence
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import sundgren.lang.python # noqa: F401 (lang packs register themselves on import)
14
+ import sundgren.lang.rust # noqa: F401
15
+ from sundgren import __version__
16
+ from sundgren.core.diff import diff
17
+ from sundgren.core.hashing import sha256_file
18
+ from sundgren.core.ingest.lcov import parse as parse_lcov
19
+ from sundgren.core.junit import parse_junit
20
+ from sundgren.core.linemap import LineMap, build_linemaps
21
+ from sundgren.core.model import Outcome, ReportWarning, Snapshot, snapshot_to_dict
22
+ from sundgren.core.normalize import normalize_snapshot
23
+ from sundgren.core.patch import Patch, parse_patch
24
+ from sundgren.core.report.json_report import build_report, dumps
25
+ from sundgren.core.report.text_report import render_text
26
+ from sundgren.run.guards import wrong_tree_paths
27
+ from sundgren.run.presets import Preset, PresetError, all_presets
28
+ from sundgren.run.runner import RunArtifacts, RunFailure, TemplateRunner, adhoc_preset
29
+ from sundgren.run.selectors import Selection, choose, restrict_to_tree
30
+ from sundgren.run.template import default_python
31
+ from sundgren.run.workspace import (
32
+ BadRefError,
33
+ GitError,
34
+ PatchApplyError,
35
+ Workspace,
36
+ is_dirty,
37
+ repo_root,
38
+ )
39
+
40
+ EXIT_OK, EXIT_FAILURE, EXIT_USAGE, EXIT_PATCH = 0, 1, 2, 3
41
+
42
+ PYTHON_MARKERS = ("pyproject.toml", "setup.cfg", "pytest.ini")
43
+ RUST_MARKERS = ("Cargo.toml",)
44
+
45
+
46
+ class CliError(Exception):
47
+ def __init__(self, message: str, code: int) -> None:
48
+ super().__init__(message)
49
+ self.code = code
50
+
51
+
52
+ def _add_output_args(p: argparse.ArgumentParser) -> None:
53
+ p.add_argument("--format", choices=("json", "text"), default="json")
54
+ p.add_argument("-o", "--output", metavar="OUT", help="write the report here instead of stdout")
55
+ p.add_argument("--full", action="store_true", help="include unchanged lines, functions, tests")
56
+ p.add_argument("--counts", action="store_true", help="fill files[].count_changed")
57
+
58
+
59
+ def build_parser() -> argparse.ArgumentParser:
60
+ parser = argparse.ArgumentParser(prog="sundgren", description=__doc__)
61
+ parser.add_argument("--version", action="version", version=f"sundgren {__version__}")
62
+ sub = parser.add_subparsers(dest="command")
63
+
64
+ d = sub.add_parser("diff", help="diff two existing lcov files")
65
+ d.add_argument("base_lcov", metavar="BASE.lcov")
66
+ d.add_argument("patched_lcov", metavar="PATCHED.lcov")
67
+ d.add_argument("--patch", metavar="FILE", help="patch between the two trees, for line mapping")
68
+ d.add_argument("--base-root", metavar="DIR")
69
+ d.add_argument("--patched-root", metavar="DIR")
70
+ d.add_argument("--strip-prefix", metavar="PREFIX", action="extend", nargs="+", default=[])
71
+ d.add_argument("--base-junit", metavar="FILE")
72
+ d.add_argument("--patched-junit", metavar="FILE")
73
+ _add_output_args(d)
74
+
75
+ r = sub.add_parser("run", help="run coverage on base and patched trees, then diff")
76
+ r.add_argument("--patch", metavar="FILE", required=True)
77
+ how = r.add_mutually_exclusive_group()
78
+ how.add_argument("--preset", metavar="NAME")
79
+ how.add_argument("--cmd", metavar="CMD", action="append", help="ad-hoc command (repeatable)")
80
+ r.add_argument("--lcov", metavar="TEMPLATE", help="lcov output path template (with --cmd)")
81
+ r.add_argument("--junit", metavar="TEMPLATE", help="JUnit output path template (with --cmd)")
82
+ r.add_argument("--shell", action="store_true", help="run --cmd via /bin/sh -c")
83
+ r.add_argument("--repo", metavar="DIR", default=".")
84
+ r.add_argument("--base", metavar="REF", default="HEAD")
85
+ r.add_argument("--tests", metavar="T", action="extend", nargs="+", default=[])
86
+ r.add_argument("--python", metavar="PATH")
87
+ r.add_argument("--env", metavar="K=V", action="append", default=[])
88
+ r.add_argument("--repeat", metavar="N", type=int, default=1)
89
+ r.add_argument("--workdir", metavar="DIR")
90
+ r.add_argument("--keep-worktrees", action="store_true")
91
+ _add_output_args(r)
92
+
93
+ pr = sub.add_parser("presets", help="list built-in and user presets")
94
+ pr.add_argument("--repo", metavar="DIR", default=".")
95
+
96
+ i = sub.add_parser("ingest", help="debug: print a normalized lcov Snapshot as JSON")
97
+ i.add_argument("lcov", metavar="FILE.lcov")
98
+ i.add_argument("--root", metavar="DIR")
99
+ return parser
100
+
101
+
102
+ def main(argv: Sequence[str] | None = None) -> int:
103
+ parser = build_parser()
104
+ args = parser.parse_args(argv)
105
+ handlers = {"diff": _cmd_diff, "run": _cmd_run, "presets": _cmd_presets, "ingest": _cmd_ingest}
106
+ if args.command is None:
107
+ parser.print_help(sys.stderr)
108
+ return EXIT_USAGE
109
+ try:
110
+ return handlers[args.command](args)
111
+ except CliError as exc:
112
+ print(f"sundgren: {exc}", file=sys.stderr)
113
+ return exc.code
114
+
115
+
116
+ # --- shared helpers ---------------------------------------------------------
117
+
118
+
119
+ def _read_text(path: Path, what: str) -> str:
120
+ try:
121
+ return path.read_text(encoding="utf-8")
122
+ except OSError as exc:
123
+ raise CliError(f"cannot read {what} {path}: {exc.strerror}", EXIT_USAGE) from None
124
+
125
+
126
+ def _load_snapshot(path: Path, roots: Sequence[str]) -> Snapshot:
127
+ try:
128
+ raw = parse_lcov(_read_text(path, "lcov"))
129
+ except ValueError as exc:
130
+ raise CliError(f"{path}: {exc}", EXIT_FAILURE) from None
131
+ return normalize_snapshot(raw, roots)
132
+
133
+
134
+ def _load_junit(path: Path | None, warnings: list[ReportWarning]) -> dict[str, Outcome] | None:
135
+ if path is None:
136
+ return None
137
+ try:
138
+ return parse_junit(_read_text(path, "JUnit file"))
139
+ except ValueError as exc:
140
+ warnings.append(ReportWarning("junit_unreadable", f"{path.name}: {exc}"))
141
+ return None
142
+
143
+
144
+ def _load_patch(path: Path) -> tuple[Patch, dict[str, LineMap], list[ReportWarning]]:
145
+ try:
146
+ # Patches are bytes: keep CRs and undecodable path bytes as they are.
147
+ text = path.read_bytes().decode("utf-8", "surrogateescape")
148
+ except OSError as exc:
149
+ raise CliError(f"cannot read patch {path}: {exc.strerror}", EXIT_USAGE) from None
150
+ return _parse_patch(text, str(path))
151
+
152
+
153
+ def _parse_patch(text: str, where: str) -> tuple[Patch, dict[str, LineMap], list[ReportWarning]]:
154
+ try:
155
+ patch = parse_patch(text)
156
+ except ValueError as exc:
157
+ raise CliError(f"{where}: {exc}", EXIT_FAILURE) from None
158
+ warnings = [ReportWarning("patch", w) for w in patch.warnings]
159
+ return patch, build_linemaps(patch), warnings
160
+
161
+
162
+ def _patch_meta(arg: str, path: Path, patch: Patch) -> dict[str, Any]:
163
+ return {"path": arg, "sha256": sha256_file(path), "files": list(patch.touched_paths())}
164
+
165
+
166
+ def _meta(**fields: Any) -> dict[str, Any]:
167
+ meta: dict[str, Any] = {
168
+ "sundgren_version": __version__,
169
+ "created_utc": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
170
+ "mode": None,
171
+ "base_ref": None,
172
+ "patch": None,
173
+ "preset": None,
174
+ "commands": None,
175
+ "selection": None,
176
+ "repeat": None,
177
+ "inputs": None,
178
+ }
179
+ meta.update(fields)
180
+ return meta
181
+
182
+
183
+ def _emit(report: Mapping[str, Any], args: argparse.Namespace, hints: Sequence[str] = ()) -> None:
184
+ text = dumps(report) if args.format == "json" else render_text(report, hints)
185
+ if args.output:
186
+ Path(args.output).write_text(text, encoding="utf-8")
187
+ else:
188
+ sys.stdout.write(text)
189
+
190
+
191
+ # --- diff -------------------------------------------------------------------
192
+
193
+
194
+ def _cmd_diff(args: argparse.Namespace) -> int:
195
+ base_root = Path(args.base_root).resolve() if args.base_root else None
196
+ patched_root = Path(args.patched_root).resolve() if args.patched_root else None
197
+ base_path, patched_path = Path(args.base_lcov), Path(args.patched_lcov)
198
+ base = _load_snapshot(base_path, [*_roots(base_root), *args.strip_prefix])
199
+ patched = _load_snapshot(patched_path, [*_roots(patched_root), *args.strip_prefix])
200
+
201
+ warnings: list[ReportWarning] = []
202
+ linemaps: dict[str, LineMap] = {}
203
+ patch_meta = None
204
+ if args.patch:
205
+ patch, linemaps, patch_warnings = _load_patch(Path(args.patch))
206
+ warnings += patch_warnings
207
+ patch_meta = _patch_meta(args.patch, Path(args.patch), patch)
208
+ base_junit = _load_junit(Path(args.base_junit) if args.base_junit else None, warnings)
209
+ patched_junit = _load_junit(Path(args.patched_junit) if args.patched_junit else None, warnings)
210
+
211
+ delta = diff(
212
+ [base],
213
+ [patched],
214
+ linemaps,
215
+ base_root=base_root,
216
+ patched_root=patched_root,
217
+ base_outcomes=base_junit,
218
+ patched_outcomes=patched_junit,
219
+ )
220
+ meta = _meta(
221
+ mode="diff",
222
+ patch=patch_meta,
223
+ inputs={
224
+ "base_lcov_sha256": [sha256_file(base_path)],
225
+ "patched_lcov_sha256": [sha256_file(patched_path)],
226
+ },
227
+ )
228
+ report = build_report(delta, meta, full=args.full, counts=args.counts, extra_warnings=warnings)
229
+ _emit(report, args)
230
+ return EXIT_OK
231
+
232
+
233
+ def _roots(root: Path | None) -> list[str]:
234
+ return [str(root)] if root is not None else []
235
+
236
+
237
+ # --- run --------------------------------------------------------------------
238
+
239
+
240
+ def _cmd_run(args: argparse.Namespace) -> int:
241
+ if args.repeat < 1:
242
+ raise CliError("--repeat must be >= 1", EXIT_USAGE)
243
+ if not args.cmd and (args.lcov or args.junit or args.shell):
244
+ raise CliError("--lcov, --junit and --shell only apply with --cmd", EXIT_USAGE)
245
+ patch_path = Path(args.patch).resolve()
246
+ if not patch_path.is_file():
247
+ raise CliError(f"patch not found: {args.patch}", EXIT_USAGE)
248
+ try:
249
+ repo = repo_root(Path(args.repo))
250
+ except (GitError, OSError) as exc:
251
+ raise CliError(f"not a git repository: {args.repo} ({exc})", EXIT_USAGE) from None
252
+
253
+ preset = _resolve_preset(args, repo)
254
+ env = _parse_env(args.env)
255
+ python = args.python or default_python()
256
+ warnings: list[ReportWarning] = []
257
+ runner = TemplateRunner(preset, python=python, env_overrides=env)
258
+ workdir = Path(args.workdir).resolve() if args.workdir else None
259
+ try:
260
+ if is_dirty(repo):
261
+ warnings.append(
262
+ ReportWarning(
263
+ "dirty_tree", "working tree has uncommitted changes; the base is the ref"
264
+ )
265
+ )
266
+ with Workspace(
267
+ repo, args.base, patch_path, workdir=workdir, keep=args.keep_worktrees
268
+ ) as ws:
269
+ # Use what git applied, not the user's hunk headers or path prefixes.
270
+ patch, linemaps, patch_warnings = _parse_patch(ws.applied_patch, "applied patch")
271
+ warnings += patch_warnings
272
+ if ws.offset_paths:
273
+ warnings.append(
274
+ ReportWarning(
275
+ "patch_offset",
276
+ f"patch applied at an offset in {', '.join(ws.offset_paths)}; "
277
+ "line maps use the applied diff",
278
+ )
279
+ )
280
+ try:
281
+ selection = choose(tuple(args.tests) or None, preset.selector, patch, ws.patched)
282
+ except KeyError as exc:
283
+ raise CliError(str(exc.args[0]), EXIT_FAILURE) from None
284
+ out = ws.workdir / "out"
285
+ base_snaps, base_junit, base_sha = _run_side(
286
+ "base",
287
+ runner,
288
+ preset,
289
+ selection,
290
+ ws.base,
291
+ out / "base",
292
+ args.repeat,
293
+ repo,
294
+ warnings,
295
+ )
296
+ patched_snaps, patched_junit, patched_sha = _run_side(
297
+ "patched",
298
+ runner,
299
+ preset,
300
+ selection,
301
+ ws.patched,
302
+ out / "patched",
303
+ args.repeat,
304
+ repo,
305
+ warnings,
306
+ )
307
+ delta = diff(
308
+ base_snaps,
309
+ patched_snaps,
310
+ linemaps,
311
+ base_root=ws.base,
312
+ patched_root=ws.patched,
313
+ base_outcomes=base_junit,
314
+ patched_outcomes=patched_junit,
315
+ )
316
+ base_ref = ws.base_sha
317
+ patched_selection = restrict_to_tree(selection, ws.patched) or selection
318
+ except PatchApplyError as exc:
319
+ raise CliError(f"patch does not apply:\n{exc}", EXIT_PATCH) from None
320
+ except BadRefError as exc:
321
+ raise CliError(str(exc), EXIT_USAGE) from None
322
+ except GitError as exc:
323
+ raise CliError(f"git: {exc}", EXIT_FAILURE) from None
324
+ except RunFailure as exc:
325
+ raise CliError(f"{exc}\n--- log tail ---\n{exc.log_tail}", EXIT_FAILURE) from None
326
+
327
+ meta = _meta(
328
+ mode="run",
329
+ base_ref=base_ref,
330
+ patch=_patch_meta(args.patch, patch_path, patch),
331
+ preset=preset.name,
332
+ commands=runner.display_commands(patched_selection),
333
+ selection={
334
+ "tests": list(patched_selection.tests),
335
+ "run_all": patched_selection.run_all,
336
+ "reason": patched_selection.reason,
337
+ },
338
+ repeat=args.repeat,
339
+ inputs={"base_lcov_sha256": base_sha, "patched_lcov_sha256": patched_sha},
340
+ )
341
+ report = build_report(delta, meta, full=args.full, counts=args.counts, extra_warnings=warnings)
342
+ _emit(report, args, preset.hints)
343
+ return EXIT_OK
344
+
345
+
346
+ def _run_side(
347
+ side: str,
348
+ runner: TemplateRunner,
349
+ preset: Preset,
350
+ selection: Selection,
351
+ worktree: Path,
352
+ out: Path,
353
+ repeat: int,
354
+ repo: Path,
355
+ warnings: list[ReportWarning],
356
+ ) -> tuple[list[Snapshot], dict[str, Outcome] | None, list[str]]:
357
+ """Run one side; returns its snapshots, first-run outcomes and lcov hashes."""
358
+ side_selection: Selection | None = selection
359
+ if preset.tests_style == "args":
360
+ side_selection = restrict_to_tree(selection, worktree)
361
+ if side_selection is None:
362
+ # Every selected test is new in the patch: nothing ran on this side.
363
+ warnings.append(
364
+ ReportWarning(
365
+ "nothing_to_run",
366
+ f"no selected tests exist on the {side} side; its coverage is treated as empty",
367
+ )
368
+ )
369
+ return [Snapshot(files={}, test_names=())], {}, []
370
+
371
+ runs: list[RunArtifacts] = runner.run(worktree, side_selection, out, repeat)
372
+ snapshots: list[Snapshot] = []
373
+ hashes: list[str] = []
374
+ for art in runs:
375
+ try:
376
+ raw = parse_lcov(art.lcov.read_text(encoding="utf-8"))
377
+ except ValueError as exc:
378
+ raise CliError(f"{side}: bad lcov output: {exc}", EXIT_FAILURE) from None
379
+ wrong = wrong_tree_paths((fc.path for fc in raw.files.values()), worktree, repo)
380
+ if wrong:
381
+ raise CliError(_wrong_tree_message(side, wrong, preset), EXIT_FAILURE)
382
+ snapshots.append(normalize_snapshot(raw, [str(worktree)]))
383
+ hashes.append(sha256_file(art.lcov))
384
+ junit = _load_junit(runs[0].junit, warnings)
385
+ return snapshots, junit, hashes
386
+
387
+
388
+ def _wrong_tree_message(side: str, paths: Sequence[str], preset: Preset) -> str:
389
+ lines = [
390
+ f"the {side} run measured your main checkout instead of its worktree "
391
+ "(editable install or stale build?):",
392
+ *(f" {p}" for p in paths[:20]),
393
+ ]
394
+ if len(paths) > 20:
395
+ lines.append(f" ... and {len(paths) - 20} more")
396
+ if preset.hints:
397
+ lines.append("hints:")
398
+ lines.extend(f" - {h}" for h in preset.hints)
399
+ return "\n".join(lines)
400
+
401
+
402
+ def _resolve_preset(args: argparse.Namespace, repo: Path) -> Preset:
403
+ if args.cmd:
404
+ if not args.lcov:
405
+ raise CliError("--cmd needs --lcov", EXIT_USAGE)
406
+ return adhoc_preset(tuple(args.cmd), args.lcov, args.junit, shell=args.shell)
407
+ try:
408
+ presets = all_presets(repo)
409
+ except PresetError as exc:
410
+ raise CliError(str(exc), EXIT_USAGE) from None
411
+ name = args.preset or _default_preset_name(repo)
412
+ if name not in presets:
413
+ known = ", ".join(sorted(presets))
414
+ raise CliError(f"unknown preset {name!r} (known: {known})", EXIT_USAGE)
415
+ return presets[name]
416
+
417
+
418
+ def _default_preset_name(repo: Path) -> str:
419
+ python = any((repo / m).exists() for m in PYTHON_MARKERS)
420
+ rust = any((repo / m).exists() for m in RUST_MARKERS)
421
+ if python and not rust:
422
+ return "pytest"
423
+ if rust and not python:
424
+ return "cargo-llvm-cov"
425
+ raise CliError("can't pick a default preset here; pass --preset or --cmd", EXIT_USAGE)
426
+
427
+
428
+ def _parse_env(items: Sequence[str]) -> dict[str, str]:
429
+ env: dict[str, str] = {}
430
+ for item in items:
431
+ key, sep, value = item.partition("=")
432
+ if not sep or not key:
433
+ raise CliError(f"--env expects K=V, got {item!r}", EXIT_USAGE)
434
+ env[key] = value
435
+ return env
436
+
437
+
438
+ # --- presets / ingest -------------------------------------------------------
439
+
440
+
441
+ def _cmd_presets(args: argparse.Namespace) -> int:
442
+ try:
443
+ presets = all_presets(Path(args.repo))
444
+ except PresetError as exc:
445
+ raise CliError(str(exc), EXIT_USAGE) from None
446
+ for name in sorted(presets):
447
+ p = presets[name]
448
+ print(f"{name} [{p.source}] {p.description}")
449
+ print(f" tests: {p.tests_style}, selector: {p.selector or '-'}")
450
+ for cmd in p.commands:
451
+ print(f" $ {cmd}")
452
+ return EXIT_OK
453
+
454
+
455
+ def _cmd_ingest(args: argparse.Namespace) -> int:
456
+ roots = _roots(Path(args.root).resolve() if args.root else None)
457
+ snapshot = _load_snapshot(Path(args.lcov), roots)
458
+ sys.stdout.write(json.dumps(snapshot_to_dict(snapshot), indent=2, sort_keys=True) + "\n")
459
+ return EXIT_OK
@@ -0,0 +1 @@
1
+ """Language-agnostic core: lcov, unified diffs, JUnit XML, the output schema."""