diffcone 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.
diffcone/cli.py ADDED
@@ -0,0 +1,886 @@
1
+ """Command-line interface.
2
+
3
+ Exit codes (plan / discover):
4
+ 0 plan produced, analysis complete
5
+ 1 plan produced, but analysis errors forced a conservative fallback
6
+ 2 no plan (bad arguments, unreadable manifest, unknown revision, a crash)
7
+ 3 plan produced, but discovery may be short of what the runner collects
8
+
9
+ A manifest written by ``discover`` keeps its notes, so a plan made from it
10
+ exits 3 as a plan that discovered the targets itself would.
11
+
12
+ 1 and 3 are opposite failures: 1 means too much was selected (the analysis
13
+ gave up safely), 3 means the target list itself may be incomplete, so running
14
+ only the selected targets would skip tests. 3 wins when both apply.
15
+
16
+ ``run`` refuses to execute an incomplete plan (exit 3) unless
17
+ --allow-incomplete-discovery is given, and refuses (exit 2) when the working
18
+ tree it would run differs, under the source roots, from the snapshot the plan
19
+ analysed, unless --allow-mismatched-worktree is given; otherwise it exits with
20
+ the runner's exit code (0 when nothing was selected or with --dry-run; pytest's
21
+ 5 counts as 0), or 3 when a selected target was not collected. ``validate`` exits
22
+ 0 when every outcome change was selected, 1 when some were missed, 2 on
23
+ errors. ``check`` exits 0 when every new failure of the full run was
24
+ selected, 1 when the plan missed one, 2 when an input cannot be read.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import shlex
32
+ import subprocess
33
+ import sys
34
+ import time
35
+ import traceback
36
+ from pathlib import Path
37
+
38
+ from diffcone import check as checking
39
+ from diffcone.cache import IndexCache, default_cache_dir
40
+ from diffcone.discovery import RUNNERS, DiscoveryOptions, discover
41
+ from diffcone.evidence import (
42
+ FLAG_SUBPROCESS,
43
+ FLAG_UNSTABLE,
44
+ EvidenceError,
45
+ find_store,
46
+ list_stores,
47
+ load_store,
48
+ )
49
+ from diffcone.execution import (
50
+ advance_refusal,
51
+ collect_evidence,
52
+ corpus_to_dict,
53
+ corpus_to_text,
54
+ corpus_validation,
55
+ environment_differences,
56
+ run_selected,
57
+ run_with_evidence,
58
+ validate_pytest,
59
+ validation_to_dict,
60
+ validation_to_text,
61
+ worktree_mismatch,
62
+ )
63
+ from diffcone.indexer import build_index
64
+ from diffcone.manifest import ManifestError, load_manifest, manifest_to_dict
65
+ from diffcone.planner import plan
66
+ from diffcone.report import snapshot_to_dict, to_json, to_text
67
+ from diffcone.snapshot import GitError, read_snapshot, resolve_commit
68
+
69
+
70
+ def _add_common(p: argparse.ArgumentParser) -> None:
71
+ p.add_argument("--repo", default=".", help="path to the git repository (default: .)")
72
+ p.add_argument(
73
+ "--source-root",
74
+ action="append",
75
+ dest="source_roots",
76
+ metavar="DIR[=PREFIX]",
77
+ help="repo-relative directory whose .py files are analyzed as a module tree "
78
+ "(repeatable; overrides the manifest's source_roots; default: .). DIR=PREFIX names "
79
+ "its modules PREFIX.<path>, for per-package test trees whose files share names",
80
+ )
81
+ p.add_argument(
82
+ "--discover",
83
+ action="append",
84
+ dest="discover",
85
+ choices=RUNNERS,
86
+ metavar="RUNNER",
87
+ help=f"statically discover targets for a runner ({', '.join(RUNNERS)}); repeatable",
88
+ )
89
+ p.add_argument(
90
+ "--assume-external-fixture",
91
+ action="append",
92
+ dest="external_fixtures",
93
+ metavar="NAME",
94
+ default=[],
95
+ help="pytest fixture provided by an installed plugin; not reported as unresolved "
96
+ "(fixtures of well-known plugins such as pytest-mock's mocker are assumed by default)",
97
+ )
98
+ p.add_argument(
99
+ "--no-well-known-fixtures",
100
+ action="store_true",
101
+ help="do not assume fixtures of well-known pytest plugins; report them as unresolved",
102
+ )
103
+ p.add_argument(
104
+ "--output",
105
+ "-o",
106
+ help="write the result to this file instead of stdout (run: the command with "
107
+ "--dry-run, otherwise the plan that ran, as plan writes it in JSON)",
108
+ )
109
+ p.add_argument(
110
+ "--no-cache",
111
+ action="store_true",
112
+ help="do not read or write the cache (<repo>/.diffcone/cache): whole indexes of "
113
+ "committed snapshots and per-module results, which also serve WORKTREE and INDEX",
114
+ )
115
+ p.add_argument("--cache-dir", help="where to keep the cache (default: <repo>/.diffcone/cache)")
116
+
117
+
118
+ def _add_evidence(p: argparse.ArgumentParser) -> None:
119
+ p.add_argument(
120
+ "--evidence",
121
+ metavar="auto|PATH",
122
+ help="opt-in execution evidence (see `diffcone collect`): select pytest targets on "
123
+ "what each test executed when recorded. auto picks the recording at the nearest "
124
+ "ancestor commit; other runners' targets are planned statically",
125
+ )
126
+
127
+
128
+ def build_parser() -> argparse.ArgumentParser:
129
+ parser = argparse.ArgumentParser(
130
+ prog="diffcone",
131
+ description="Static-first, function-level change-impact planning for Python.",
132
+ )
133
+ from diffcone import __version__
134
+
135
+ parser.add_argument("--version", action="version", version=f"diffcone {__version__}")
136
+ sub = parser.add_subparsers(dest="command", required=True)
137
+
138
+ p = sub.add_parser(
139
+ "plan",
140
+ help="produce a selection plan between two snapshots (revisions, INDEX or WORKTREE)",
141
+ description=(
142
+ "Compare two snapshots and report which targets are affected. A snapshot is a "
143
+ "git revision, INDEX (staged content) or WORKTREE (files on disk). The report "
144
+ "states exactly which kind was read. Targets come from --targets, from "
145
+ "--discover, or both. Project code is never executed."
146
+ ),
147
+ )
148
+ p.add_argument("--base", required=True, help="base snapshot: a git revision, INDEX or WORKTREE")
149
+ p.add_argument(
150
+ "--head",
151
+ required=True,
152
+ help="head snapshot: a git revision, INDEX (staged content) or WORKTREE (files on "
153
+ "disk, ignored files excluded)",
154
+ )
155
+ p.add_argument("--targets", help="path to a JSON target manifest")
156
+ _add_common(p)
157
+ _add_evidence(p)
158
+ p.add_argument("--format", choices=("json", "text"), default="json")
159
+ p.add_argument(
160
+ "runner_args",
161
+ nargs="*",
162
+ help="pytest arguments the run will use (after --): options that change what pytest "
163
+ "collects, such as --doctest-modules, are read by discovery",
164
+ )
165
+
166
+ r = sub.add_parser(
167
+ "run",
168
+ help="plan, then execute only the selected targets with the runner's CLI",
169
+ description=(
170
+ "Build a plan exactly like `plan`, then invoke the runner on the selected "
171
+ "targets (pytest node ids, or an asv --bench pattern). Nothing is executed "
172
+ "during analysis. Arguments after `--` are passed to the runner."
173
+ ),
174
+ )
175
+ r.add_argument("--base", required=True, help="base snapshot: a git revision, INDEX or WORKTREE")
176
+ r.add_argument("--head", required=True, help="head snapshot: a git revision, INDEX or WORKTREE")
177
+ r.add_argument("--targets", help="path to a JSON target manifest")
178
+ r.add_argument("--runner", choices=RUNNERS, default="pytest", help="which runner to execute")
179
+ r.add_argument(
180
+ "--command",
181
+ dest="runner_command",
182
+ metavar="COMMAND",
183
+ help=(
184
+ 'runner command line (default: "python -m pytest", or "asv run --python=same", '
185
+ "which benchmarks the checkout in the current environment); run in --repo"
186
+ ),
187
+ )
188
+ r.add_argument("--dry-run", action="store_true", help="print the command instead of running")
189
+ r.add_argument(
190
+ "--allow-mismatched-worktree",
191
+ action="store_true",
192
+ help="run even when the checkout is not the snapshot the plan analysed",
193
+ )
194
+ r.add_argument(
195
+ "--allow-incomplete-discovery",
196
+ action="store_true",
197
+ help="run even when discovery reports tests the runner may collect that are not targets",
198
+ )
199
+ _add_common(r)
200
+ _add_evidence(r)
201
+ r.add_argument(
202
+ "--collect",
203
+ action="store_true",
204
+ help="with --evidence: record the selected tests too and advance the recording to head "
205
+ "(their new records, the old ones for every other test); needs a clean checkout of "
206
+ "head and the pytest arguments the recording was made with",
207
+ )
208
+ r.add_argument("runner_args", nargs="*", help="extra runner arguments (after --)")
209
+
210
+ v = sub.add_parser(
211
+ "validate",
212
+ help="run the full pytest suite at both snapshots and check the plan against it",
213
+ description=(
214
+ "Outcome-based validation: runs the whole pytest suite at base and head "
215
+ "(commits in temporary git worktrees, WORKTREE in place), then reports every "
216
+ "test whose pass/fail outcome changed but was not selected. Behaviour changes "
217
+ "that keep the same outcome are invisible to this check."
218
+ ),
219
+ )
220
+ v.add_argument("--base", required=True, help="base snapshot: a git revision or WORKTREE")
221
+ v.add_argument("--head", required=True, help="head snapshot: a git revision or WORKTREE")
222
+ v.add_argument("--targets", help="path to a JSON target manifest")
223
+ v.add_argument(
224
+ "--command",
225
+ dest="runner_command",
226
+ metavar="COMMAND",
227
+ help='pytest command line (default: "python -m pytest")',
228
+ )
229
+ v.add_argument(
230
+ "--coverage",
231
+ action="store_true",
232
+ help="also run the head suite under pytest-cov with per-test contexts and require "
233
+ "every test that executed a changed symbol to be selected (reports recall/precision)",
234
+ )
235
+ v.add_argument(
236
+ "--setup-command",
237
+ help="shell command run inside each temporary checkout before its suite (recreate "
238
+ "build-generated files such as a setuptools-scm _version.py)",
239
+ )
240
+ _add_common(v)
241
+ _add_evidence(v)
242
+ v.add_argument("--format", choices=("json", "text"), default="text")
243
+
244
+ c = sub.add_parser(
245
+ "corpus",
246
+ help="validate the plan for every commit in a range and aggregate recall/precision",
247
+ description=(
248
+ "Replay history: for each commit in A..B (first-parent order) plan parent -> "
249
+ "commit and validate it like `validate`, then aggregate outcome misses, "
250
+ "coverage recall/precision and selection savings. Each commit's suite runs "
251
+ "once; coverage runs are per pair. Commits touching no .py file are skipped "
252
+ "unless --all-commits is given."
253
+ ),
254
+ )
255
+ c.add_argument(
256
+ "--range", required=True, dest="revision_range", help="git range, e.g. main~20..main"
257
+ )
258
+ c.add_argument("--targets", help="path to a JSON target manifest")
259
+ c.add_argument(
260
+ "--command",
261
+ dest="runner_command",
262
+ metavar="COMMAND",
263
+ help='pytest command line (default: "python -m pytest")',
264
+ )
265
+ c.add_argument("--coverage", action="store_true", help="also measure coverage recall/precision")
266
+ c.add_argument(
267
+ "--setup-command",
268
+ help="shell command run inside each temporary checkout before its suite",
269
+ )
270
+ c.add_argument(
271
+ "--all-commits", action="store_true", help="validate commits without .py changes too"
272
+ )
273
+ c.add_argument(
274
+ "--max", type=int, dest="max_commits", help="only the last N commits of the range"
275
+ )
276
+ c.add_argument(
277
+ "--evidence",
278
+ metavar="auto|PATH",
279
+ help="plan every pair with execution evidence: a fixed recording, or auto for the "
280
+ "recording at the nearest ancestor of each commit",
281
+ )
282
+ c.add_argument(
283
+ "--jobs",
284
+ type=int,
285
+ default=1,
286
+ help="validate this many pairs in parallel, each in its own temporary worktrees "
287
+ "(default 1; suites that write to shared locations can interfere)",
288
+ )
289
+ _add_common(c)
290
+ c.add_argument("--format", choices=("json", "text"), default="text")
291
+
292
+ e = sub.add_parser(
293
+ "collect",
294
+ help="run the whole pytest suite under the evidence recorder and store what each "
295
+ "test executed",
296
+ description=(
297
+ "Execution evidence (opt-in): run the whole suite once with diffcone's recorder "
298
+ "(Python 3.12+) and write .diffcone/evidence/<commit>-<environment>.sqlite: the "
299
+ "symbols each test executed and the repository files it touched, at a commit. "
300
+ "Without --rev the repository itself runs and must be clean. Arguments after "
301
+ "`--` are passed to pytest."
302
+ ),
303
+ )
304
+ e.add_argument("--repo", default=".", help="path to the git repository (default: .)")
305
+ e.add_argument(
306
+ "--source-root",
307
+ action="append",
308
+ dest="source_roots",
309
+ metavar="DIR[=PREFIX]",
310
+ help="as for plan (repeatable; default: .)",
311
+ )
312
+ e.add_argument(
313
+ "--rev", help="collect at this commit, in a temporary worktree (default: the clean HEAD)"
314
+ )
315
+ e.add_argument(
316
+ "--command",
317
+ dest="runner_command",
318
+ metavar="COMMAND",
319
+ help='pytest command line (default: "python -m pytest")',
320
+ )
321
+ e.add_argument(
322
+ "--setup-command", help="shell command run inside the temporary checkout (with --rev)"
323
+ )
324
+ e.add_argument(
325
+ "--reverse-check",
326
+ action="store_true",
327
+ help="run the suite a second time in reverse order; tests whose records differ are "
328
+ "marked unstable and always selected",
329
+ )
330
+ e.add_argument(
331
+ "--env-var",
332
+ action="append",
333
+ dest="env_variables",
334
+ default=[],
335
+ metavar="NAME",
336
+ help="an environment variable that changes what tests do (a feature flag, say): "
337
+ "recorded with the environment, and a run where it differs uses no evidence "
338
+ "(repeatable)",
339
+ )
340
+ e.add_argument("--no-cache", action="store_true", help="do not use the index cache")
341
+ e.add_argument("--cache-dir", help="where to keep the cache (default: <repo>/.diffcone/cache)")
342
+ e.add_argument("runner_args", nargs="*", help="extra pytest arguments (after --)")
343
+
344
+ k = sub.add_parser(
345
+ "check",
346
+ help="check a plan against a full run's JUnit XML: was every failing test selected?",
347
+ description=(
348
+ "Read a plan (diffcone plan --format json) and the JUnit XML of a full pytest "
349
+ "run, and report every test that failed or errored there but was not selected. "
350
+ "A failure the --baseline run also had is reported as already failing. Each "
351
+ "--run is a selective run (diffcone's own, or another selector's such as "
352
+ "pytest-testmon) compared on the same failures. Runs nothing."
353
+ ),
354
+ )
355
+ k.add_argument("--plan", required=True, help="the plan, as JSON")
356
+ k.add_argument(
357
+ "--full",
358
+ required=True,
359
+ action="append",
360
+ metavar="JUNIT",
361
+ help="JUnit XML of the full run (repeatable: several files are one run)",
362
+ )
363
+ k.add_argument(
364
+ "--baseline",
365
+ action="append",
366
+ metavar="JUNIT",
367
+ help="JUnit XML of a run without the change (e.g. the nightly run at the evidence "
368
+ "commit): its failures are not misses",
369
+ )
370
+ k.add_argument(
371
+ "--run",
372
+ action="append",
373
+ default=[],
374
+ metavar="NAME=JUNIT",
375
+ help="JUnit XML of a selective run to compare (repeatable)",
376
+ )
377
+ k.add_argument("--format", choices=("text", "markdown", "json"), default="text")
378
+ k.add_argument("--output", "-o", help="write the result to this file instead of stdout")
379
+
380
+ pr = sub.add_parser(
381
+ "prune",
382
+ help="shrink the cache to what planning at given commits reads",
383
+ description=(
384
+ "Delete every cached index and discovery result except those of the --keep "
385
+ "commits, and every per-module record except those of their files (which also "
386
+ "serve later commits and the working tree sharing them). For a cache shipped "
387
+ "between CI runs: keep the commit the evidence was recorded at."
388
+ ),
389
+ )
390
+ pr.add_argument("--repo", default=".", help="path to the git repository (default: .)")
391
+ pr.add_argument(
392
+ "--keep", action="append", required=True, metavar="REV", help="commit to keep (repeatable)"
393
+ )
394
+ pr.add_argument(
395
+ "--source-root",
396
+ action="append",
397
+ dest="source_roots",
398
+ metavar="DIR[=PREFIX]",
399
+ help="as for plan (repeatable; default: .)",
400
+ )
401
+ pr.add_argument("--cache-dir", help="the cache (default: <repo>/.diffcone/cache)")
402
+
403
+ ls = sub.add_parser("evidence", help="list the evidence recordings of a repository")
404
+ ls.add_argument("--repo", default=".", help="path to the git repository (default: .)")
405
+
406
+ d = sub.add_parser(
407
+ "discover",
408
+ help="statically discover targets in a snapshot and emit a manifest",
409
+ description=(
410
+ "Discover pytest tests and/or ASV benchmarks in a snapshot (a git revision, INDEX "
411
+ "or WORKTREE) without importing them, and print a target manifest (JSON) for "
412
+ "`diffcone plan --targets`. The output states which snapshot kind was read."
413
+ ),
414
+ )
415
+ d.add_argument(
416
+ "--rev", default="HEAD", help="snapshot to discover in: revision, INDEX or WORKTREE"
417
+ )
418
+ _add_common(d)
419
+ return parser
420
+
421
+
422
+ def _prune(args: argparse.Namespace) -> int:
423
+ from diffcone.cache import ModuleCache, prune
424
+ from diffcone.snapshot import module_name_for
425
+
426
+ repo = Path(args.repo)
427
+ roots = args.source_roots or ["."]
428
+ try:
429
+ commits, keys = set(), set()
430
+ for revision in args.keep:
431
+ commit = resolve_commit(repo, revision)
432
+ commits.add(commit)
433
+ snapshot = read_snapshot(repo, commit, roots)
434
+ for path, content in snapshot.files.items():
435
+ module = module_name_for(path, snapshot.source_roots)
436
+ if module is not None:
437
+ keys.add(ModuleCache.key(module, path, content))
438
+ except GitError as exc:
439
+ print(f"diffcone: error: {exc}", file=sys.stderr)
440
+ return 2
441
+ directory = Path(args.cache_dir) if args.cache_dir else default_cache_dir(repo)
442
+ result = prune(directory, commits, roots, keys)
443
+ mb = 1024 * 1024
444
+ print(
445
+ f"diffcone: kept {result.files_kept} cached file(s) and {result.rows_kept} module "
446
+ f"record(s), removed {result.files_removed} and {result.rows_removed}; "
447
+ f"{result.bytes_before / mb:.1f} MB -> {result.bytes_after / mb:.1f} MB",
448
+ file=sys.stderr,
449
+ )
450
+ return 0
451
+
452
+
453
+ def _check(args: argparse.Namespace) -> int:
454
+ def cases(paths: list[str]) -> list[checking.Case]:
455
+ return [case for path in paths for case in checking.read_junit(path)]
456
+
457
+ try:
458
+ runs = {}
459
+ for spec in args.run:
460
+ name, sep, path = spec.partition("=")
461
+ if not sep or not name or not path:
462
+ raise checking.CheckError(f"--run takes NAME=JUNIT, not {spec!r}")
463
+ runs[name] = cases([path])
464
+ report = checking.check(
465
+ checking.load_plan(args.plan),
466
+ cases(args.full),
467
+ baseline=cases(args.baseline) if args.baseline else None,
468
+ runs=runs,
469
+ )
470
+ except checking.CheckError as exc:
471
+ print(f"diffcone: error: {exc}", file=sys.stderr)
472
+ return 2
473
+ render = {
474
+ "text": checking.to_text,
475
+ "markdown": checking.to_markdown,
476
+ "json": lambda r: json.dumps(checking.to_dict(r), indent=2) + "\n",
477
+ }[args.format]
478
+ code = _write(render(report), args.output)
479
+ return code if code else (0 if report.ok else 1)
480
+
481
+
482
+ def _write(text: str, output: str | None) -> int:
483
+ if output:
484
+ try:
485
+ Path(output).write_text(text, "utf-8")
486
+ except OSError as exc:
487
+ print(f"diffcone: error: cannot write {output}: {exc}", file=sys.stderr)
488
+ return 2
489
+ else:
490
+ sys.stdout.write(text)
491
+ return 0
492
+
493
+
494
+ def _list_evidence(repo: Path) -> int:
495
+ stores = list_stores(repo)
496
+ if not stores:
497
+ print("no evidence recordings (diffcone collect writes one)")
498
+ return 0
499
+ for store in stores:
500
+ when = time.strftime("%Y-%m-%d %H:%M", time.localtime(store.created))
501
+ print(
502
+ f"{store.commit[:12]} env {store.environment_hash} python {store.python} "
503
+ f"{store.tests} tests roots {','.join(store.source_roots)} {when} {store.path}"
504
+ )
505
+ if store.advanced_from:
506
+ print(
507
+ f" advanced from {store.advanced_from[:12]}; last full collection "
508
+ f"{(store.full_commit or '')[:12]}"
509
+ )
510
+ return 0
511
+
512
+
513
+ def _collect(args: argparse.Namespace) -> int:
514
+ repo = Path(args.repo)
515
+ cache = None
516
+ if not args.no_cache:
517
+ cache = IndexCache(Path(args.cache_dir) if args.cache_dir else default_cache_dir(repo))
518
+ try:
519
+ result = collect_evidence(
520
+ repo,
521
+ command=args.runner_command,
522
+ source_roots=args.source_roots or ["."],
523
+ rev=args.rev,
524
+ setup_command=args.setup_command,
525
+ reverse_check=args.reverse_check,
526
+ extra=args.runner_args,
527
+ cache=cache,
528
+ env_variables=args.env_variables,
529
+ )
530
+ except (GitError, EvidenceError) as exc:
531
+ print(f"diffcone: error: {exc}", file=sys.stderr)
532
+ return 2
533
+ ev = result.evidence
534
+ flags = [r.flags for r in ev.tests.values()]
535
+ print(
536
+ f"diffcone: recorded {len(ev.tests)} tests at {ev.commit[:12]} "
537
+ f"(environment {ev.environment_hash}): {len(ev.symbols)} symbols executed, "
538
+ f"{sum(1 for f in flags if f & FLAG_SUBPROCESS)} started a subprocess"
539
+ + (
540
+ f", {sum(1 for f in flags if f & FLAG_UNSTABLE)} unstable" if ev.reverse_checked else ""
541
+ ),
542
+ file=sys.stderr,
543
+ )
544
+ print(str(result.store))
545
+ return 0
546
+
547
+
548
+ def _run_exit_code(returncode: int, missing: bool) -> int:
549
+ """``run``'s exit code from the runner's. Selected targets that did not run
550
+ make it 3 (the run may have skipped tests) unless the runner failed on its
551
+ own; pytest's 5 (no tests ran) is 0 when every selected test was collected
552
+ and skipped or deselected by the project's own options."""
553
+ if missing:
554
+ return returncode if returncode not in (0, 5) else 3
555
+ return 0 if returncode == 5 else returncode
556
+
557
+
558
+ def _repo_problem(repo: str) -> str | None:
559
+ """Why ``--repo`` cannot be used: it must be the top level of a git
560
+ working tree, since module names and runner ids are relative to it and a
561
+ subdirectory would silently analyse something else."""
562
+ try:
563
+ top = subprocess.run(
564
+ ["git", "rev-parse", "--show-toplevel"],
565
+ cwd=repo,
566
+ capture_output=True,
567
+ text=True,
568
+ check=True,
569
+ ).stdout.strip()
570
+ except (OSError, subprocess.CalledProcessError):
571
+ return None # not a repository: the command reports that itself
572
+ if top and Path(top).resolve() != Path(repo).resolve():
573
+ try:
574
+ sub = Path(repo).resolve().relative_to(Path(top).resolve()).as_posix()
575
+ except ValueError:
576
+ sub = repo
577
+ return (
578
+ f"--repo {repo} is inside the repository at {top}, not its top level; pass "
579
+ f"--repo {top} (and --source-root {sub} to analyse that directory)"
580
+ )
581
+ return None
582
+
583
+
584
+ def main(argv: list[str] | None = None) -> int:
585
+ parser = build_parser()
586
+ args = parser.parse_args(argv)
587
+ if getattr(args, "repo", None) is not None:
588
+ problem = _repo_problem(args.repo)
589
+ if problem:
590
+ print(f"diffcone: error: {problem}", file=sys.stderr)
591
+ return 2
592
+ if args.command == "evidence":
593
+ return _list_evidence(Path(args.repo))
594
+ if args.command == "collect":
595
+ return _collect(args)
596
+ if args.command == "check":
597
+ return _check(args)
598
+ if args.command == "prune":
599
+ return _prune(args)
600
+ options = DiscoveryOptions(
601
+ external_fixtures=frozenset(args.external_fixtures),
602
+ well_known_fixtures=not args.no_well_known_fixtures,
603
+ runner_args=tuple(getattr(args, "runner_args", None) or ()),
604
+ )
605
+
606
+ cache = None
607
+ if not args.no_cache:
608
+ cache = IndexCache(
609
+ Path(args.cache_dir) if args.cache_dir else default_cache_dir(Path(args.repo))
610
+ )
611
+
612
+ def build_plan(evidence=None):
613
+ if not args.targets and not args.discover:
614
+ parser.error(f"{args.command} requires --targets and/or --discover")
615
+ manifest = load_manifest(args.targets) if args.targets else None
616
+ return plan(
617
+ Path(args.repo),
618
+ args.base,
619
+ args.head,
620
+ manifest,
621
+ source_roots=args.source_roots,
622
+ discover_runners=args.discover or (),
623
+ discovery_options=options,
624
+ cache=cache,
625
+ evidence=evidence,
626
+ )
627
+
628
+ def load_evidence():
629
+ if not getattr(args, "evidence", None):
630
+ return None
631
+ manifest_roots = load_manifest(args.targets).source_roots if args.targets else None
632
+ roots = list(args.source_roots or manifest_roots or ["."])
633
+ head = args.head if args.head not in ("WORKTREE", "INDEX") else "HEAD"
634
+ reference = resolve_commit(Path(args.repo), head)
635
+ return load_store(find_store(Path(args.repo), args.evidence, roots, reference))
636
+
637
+ try:
638
+ if args.command == "plan":
639
+ result = build_plan(load_evidence())
640
+ text = to_json(result) if args.format == "json" else to_text(result)
641
+ code = _write(text, args.output)
642
+ if code:
643
+ return code
644
+ if result.incomplete_discovery:
645
+ return 3
646
+ return 1 if result.degraded else 0
647
+ if args.command == "run":
648
+ if args.collect and (not args.evidence or args.runner != "pytest" or args.dry_run):
649
+ parser.error("--collect needs --evidence and the pytest runner, without --dry-run")
650
+ evidence = load_evidence()
651
+ result = build_plan(evidence)
652
+ ran_plan = result
653
+ will_run = any(d.selected and d.target.runner == args.runner for d in result.decisions)
654
+ # Only worth refusing when something would actually execute.
655
+ mismatch = worktree_mismatch(Path(args.repo), result) if will_run else None
656
+ if mismatch and not args.allow_mismatched_worktree:
657
+ print(
658
+ f"diffcone: {mismatch}; running it would execute code the plan never "
659
+ "analysed. Plan with --head WORKTREE, check the snapshot out, or pass "
660
+ "--allow-mismatched-worktree.",
661
+ file=sys.stderr,
662
+ )
663
+ return 2
664
+ incomplete = result.incomplete_discovery
665
+ if incomplete and not args.allow_incomplete_discovery:
666
+ print(
667
+ f"diffcone: discovery reports {len(incomplete)} place(s) where "
668
+ f"{args.runner} may collect tests that are not targets; running the "
669
+ "selected targets would skip them. Pass --allow-incomplete-discovery "
670
+ "to run anyway, or declare those tests in a manifest.",
671
+ file=sys.stderr,
672
+ )
673
+ for note in incomplete[:5]:
674
+ print(f" {note.kind}: {note.detail}", file=sys.stderr)
675
+ if len(incomplete) > 5:
676
+ print(f" ... and {len(incomplete) - 5} more", file=sys.stderr)
677
+ return 3
678
+ if evidence is not None and args.runner == "pytest":
679
+ if args.collect:
680
+ refusal = advance_refusal(
681
+ Path(args.repo), result, evidence, args.runner_command, args.runner_args
682
+ )
683
+ if refusal:
684
+ print(f"diffcone: cannot advance the evidence: {refusal}", file=sys.stderr)
685
+ return 2
686
+ static_plans: list = []
687
+
688
+ def static_plan():
689
+ static_plans.append(build_plan(None))
690
+ return static_plans[-1]
691
+
692
+ checked = run_with_evidence(
693
+ result,
694
+ static_plan,
695
+ cwd=Path(args.repo),
696
+ command=args.runner_command,
697
+ extra=args.runner_args,
698
+ dry_run=args.dry_run,
699
+ advance_from=evidence if args.collect else None,
700
+ allow_incomplete=args.allow_incomplete_discovery,
701
+ )
702
+ if checked.advanced is not None:
703
+ print(
704
+ f"diffcone: evidence advanced to {checked.advanced.name} "
705
+ f"({len(checked.result.selected)} test(s) recorded afresh)",
706
+ file=sys.stderr,
707
+ )
708
+ elif args.collect:
709
+ reason = checked.not_advanced or "the environment differs from the recording's"
710
+ print(f"diffcone: evidence not advanced: {reason}", file=sys.stderr)
711
+ if checked.mismatch is not None:
712
+ recorded = load_store(Path(result.evidence["store"])).environment
713
+ print(
714
+ "diffcone: the environment differs from the one the evidence was "
715
+ "recorded in, so it says nothing here; ran "
716
+ + (
717
+ "the whole suite instead (the static plan's discovery may be "
718
+ "incomplete):"
719
+ if checked.static_whole
720
+ else "the static plan instead:"
721
+ ),
722
+ file=sys.stderr,
723
+ )
724
+ for line in environment_differences(recorded, checked.mismatch)[:8]:
725
+ print(f" {line}", file=sys.stderr)
726
+ outcome = checked.ran
727
+ if checked.static is not None and static_plans:
728
+ ran_plan = static_plans[-1]
729
+ else:
730
+ outcome = run_selected(
731
+ result,
732
+ args.runner,
733
+ cwd=Path(args.repo),
734
+ command=args.runner_command,
735
+ extra=args.runner_args,
736
+ dry_run=args.dry_run,
737
+ )
738
+ if args.output and not args.dry_run:
739
+ # The plan that ran (the static one when the evidence did not
740
+ # apply), as ``plan -o`` writes it.
741
+ code = _write(to_json(ran_plan), args.output)
742
+ if code:
743
+ return code
744
+ if outcome.unknown:
745
+ print(
746
+ f"diffcone: pytest collected {len(outcome.unknown)} test(s) the plan does not "
747
+ f"know (e.g. {outcome.unknown[0]}); they were run too, since the plan cannot "
748
+ "say whether the change reaches them",
749
+ file=sys.stderr,
750
+ )
751
+ if outcome.missing:
752
+ print(
753
+ f"diffcone: error: pytest did not collect {len(outcome.missing)} selected "
754
+ f"target(s), so they did not run (e.g. {outcome.missing[0]}); discovery "
755
+ "and collection disagree",
756
+ file=sys.stderr,
757
+ )
758
+ status = "degraded" if result.degraded else "complete"
759
+ print(
760
+ f"diffcone: {len(outcome.selected)} of {outcome.total} {args.runner} target(s) "
761
+ f"selected (plan {status})",
762
+ file=sys.stderr,
763
+ )
764
+ if args.dry_run:
765
+ if not outcome.selected:
766
+ print("diffcone: nothing selected; not running", file=sys.stderr)
767
+ return 0
768
+ return _write(" ".join(shlex.quote(a) for a in outcome.command) + "\n", args.output)
769
+ if outcome.returncode is None:
770
+ # Nothing ran: an empty selection (a whole-suite fallback ran
771
+ # something even when no static target was selected).
772
+ print("diffcone: nothing selected; not running", file=sys.stderr)
773
+ return 0
774
+ return _run_exit_code(outcome.returncode, bool(outcome.missing))
775
+ if args.command == "validate":
776
+ result = build_plan(load_evidence())
777
+ validation = validate_pytest(
778
+ result,
779
+ repo=Path(args.repo),
780
+ command=args.runner_command,
781
+ coverage=args.coverage,
782
+ setup_command=args.setup_command,
783
+ )
784
+ text = (
785
+ json.dumps(validation_to_dict(validation), indent=2) + "\n"
786
+ if args.format == "json"
787
+ else validation_to_text(validation)
788
+ )
789
+ code = _write(text, args.output)
790
+ return code if code else (0 if validation.ok else 1)
791
+ if args.command == "corpus":
792
+ if not args.targets and not args.discover:
793
+ parser.error("corpus requires --targets and/or --discover")
794
+ manifest = load_manifest(args.targets) if args.targets else None
795
+ fixed = load_store(Path(args.evidence)) if args.evidence not in (None, "auto") else None
796
+
797
+ def make_plan(base: str, head: str):
798
+ evidence = fixed
799
+ if args.evidence == "auto":
800
+ roots = list(
801
+ args.source_roots or (manifest.source_roots if manifest else None) or ["."]
802
+ )
803
+ evidence = load_store(find_store(Path(args.repo), "auto", roots, head))
804
+ return plan(
805
+ Path(args.repo),
806
+ base,
807
+ head,
808
+ manifest,
809
+ source_roots=args.source_roots,
810
+ discover_runners=args.discover or (),
811
+ discovery_options=options,
812
+ cache=cache,
813
+ evidence=evidence,
814
+ )
815
+
816
+ def progress(entry) -> None:
817
+ verb = "skipping" if entry.skipped else "validating"
818
+ print(f"diffcone: {verb} {entry.commit[:10]} {entry.subject}", file=sys.stderr)
819
+
820
+ report = corpus_validation(
821
+ Path(args.repo),
822
+ args.revision_range,
823
+ make_plan,
824
+ command=args.runner_command,
825
+ coverage=args.coverage,
826
+ only_python_changes=not args.all_commits,
827
+ max_commits=args.max_commits,
828
+ progress=progress,
829
+ setup_command=args.setup_command,
830
+ jobs=max(1, args.jobs),
831
+ )
832
+ text = (
833
+ json.dumps(corpus_to_dict(report), indent=2) + "\n"
834
+ if args.format == "json"
835
+ else corpus_to_text(report)
836
+ )
837
+ code = _write(text, args.output)
838
+ return code if code else (0 if report.ok else 1)
839
+ if args.command == "discover":
840
+ runners = args.discover or list(RUNNERS)
841
+ roots = args.source_roots or ["."]
842
+ snapshot = read_snapshot(Path(args.repo), args.rev, roots, with_config=True)
843
+ index = build_index(snapshot)
844
+ results = [discover(r, snapshot, index, options) for r in runners]
845
+ data = manifest_to_dict([t for r in results for t in r.targets], roots)
846
+ data["discovery"] = {
847
+ "snapshot": snapshot_to_dict(snapshot.info),
848
+ "revision": snapshot.revision,
849
+ "commit": snapshot.commit,
850
+ "runners": [
851
+ {
852
+ "runner": r.runner,
853
+ "targets": len(r.targets),
854
+ "config": r.config,
855
+ "notes": [
856
+ {"kind": n.kind, "detail": n.detail, "path": n.path} for n in r.notes
857
+ ],
858
+ }
859
+ for r in results
860
+ ],
861
+ }
862
+ code = _write(json.dumps(data, indent=2) + "\n", args.output)
863
+ if code:
864
+ return code
865
+ # The plan's codes: 3 when a runner may collect tests that are not
866
+ # targets, 1 when files could not be analysed.
867
+ if any(r.incomplete for r in results):
868
+ return 3
869
+ return 1 if index.errors else 0
870
+ except (ManifestError, GitError, EvidenceError, ValueError, OSError) as exc:
871
+ print(f"diffcone: error: {exc}", file=sys.stderr)
872
+ return 2
873
+ except Exception:
874
+ # Exit 1 means "a plan, degraded": a crash must never look like one.
875
+ traceback.print_exc()
876
+ print(
877
+ "diffcone: internal error (a bug; please report it with the traceback above)",
878
+ file=sys.stderr,
879
+ )
880
+ return 2
881
+ parser.error("unknown command") # pragma: no cover
882
+ return 2 # pragma: no cover
883
+
884
+
885
+ if __name__ == "__main__": # pragma: no cover
886
+ sys.exit(main())