patchahead 0.3.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 (75) hide show
  1. patchahead/__init__.py +8 -0
  2. patchahead/analysis/__init__.py +52 -0
  3. patchahead/analysis/edits.py +143 -0
  4. patchahead/analysis/index.py +203 -0
  5. patchahead/analysis/python_ast.py +457 -0
  6. patchahead/apidiff/__init__.py +23 -0
  7. patchahead/apidiff/compare.py +366 -0
  8. patchahead/apidiff/download.py +95 -0
  9. patchahead/apidiff/surface.py +337 -0
  10. patchahead/ci.py +301 -0
  11. patchahead/cli.py +627 -0
  12. patchahead/config.py +284 -0
  13. patchahead/demo/__init__.py +256 -0
  14. patchahead/demo/fixtures/changes/field-rename.md +14 -0
  15. patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
  16. patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
  17. patchahead/demo/fixtures/changes/method-rename.md +12 -0
  18. patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
  19. patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
  20. patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
  21. patchahead/demo/fixtures/orders-service/README.md +51 -0
  22. patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
  23. patchahead/demo/fixtures/orders-service/app/client.py +15 -0
  24. patchahead/demo/fixtures/orders-service/app/models.py +10 -0
  25. patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
  26. patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
  27. patchahead/demo/fixtures/orders-service/conftest.py +6 -0
  28. patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
  29. patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
  30. patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
  31. patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
  32. patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
  33. patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
  34. patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
  35. patchahead/demo/serve.py +189 -0
  36. patchahead/domain/__init__.py +67 -0
  37. patchahead/domain/change.py +269 -0
  38. patchahead/domain/completeness.py +91 -0
  39. patchahead/domain/impact.py +248 -0
  40. patchahead/domain/patch.py +81 -0
  41. patchahead/domain/plan.py +170 -0
  42. patchahead/domain/result.py +210 -0
  43. patchahead/domain/validation.py +200 -0
  44. patchahead/engine.py +609 -0
  45. patchahead/handlers/__init__.py +35 -0
  46. patchahead/handlers/base.py +211 -0
  47. patchahead/handlers/field_rename.py +425 -0
  48. patchahead/handlers/kwarg_rename.py +201 -0
  49. patchahead/handlers/method_rename.py +608 -0
  50. patchahead/handlers/pagination.py +582 -0
  51. patchahead/ingest/__init__.py +32 -0
  52. patchahead/ingest/base.py +102 -0
  53. patchahead/ingest/markdown.py +1138 -0
  54. patchahead/ingest/structured.py +218 -0
  55. patchahead/llm/__init__.py +28 -0
  56. patchahead/llm/client.py +152 -0
  57. patchahead/llm/proposer.py +620 -0
  58. patchahead/observability.py +223 -0
  59. patchahead/reporting.py +451 -0
  60. patchahead/testing/__init__.py +22 -0
  61. patchahead/testing/discovery.py +113 -0
  62. patchahead/testing/runner.py +138 -0
  63. patchahead/validation/__init__.py +5 -0
  64. patchahead/validation/completeness.py +265 -0
  65. patchahead/validation/engine.py +531 -0
  66. patchahead/web/__init__.py +13 -0
  67. patchahead/web/server.py +279 -0
  68. patchahead/web/static/index.html +650 -0
  69. patchahead/workspace.py +382 -0
  70. patchahead-0.3.0.dist-info/METADATA +368 -0
  71. patchahead-0.3.0.dist-info/RECORD +75 -0
  72. patchahead-0.3.0.dist-info/WHEEL +5 -0
  73. patchahead-0.3.0.dist-info/entry_points.txt +2 -0
  74. patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
  75. patchahead-0.3.0.dist-info/top_level.txt +1 -0
patchahead/cli.py ADDED
@@ -0,0 +1,627 @@
1
+ """The ``patchahead`` command-line interface.
2
+
3
+ Built on ``argparse`` rather than Typer or Click so that the core tool has **no
4
+ runtime dependencies at all** on Python 3.11+. A migration tool that a team has
5
+ to vet three transitive dependencies for is a tool they will not install.
6
+
7
+ Commands
8
+ --------
9
+
10
+ ``demo``
11
+ Zero-configuration walkthrough: serve the UI on localhost against a bundled
12
+ broken repository and a set of bundled release notes. The fastest way to see
13
+ what the tool does; the same engine as every other command.
14
+ ``analyze``
15
+ Read-only. Report what a change document would affect.
16
+ ``migrate``
17
+ Plan, patch in an isolated copy, validate, and print a diff.
18
+ ``web``
19
+ The same UI as ``demo``, pointed at a repository of your own.
20
+ ``handlers``
21
+ What this version can and cannot migrate.
22
+ ``api-diff``
23
+ Read two versions of a library and write the breaking changes between them
24
+ as a change document, for when there is no release note to read.
25
+
26
+ Exit codes are meaningful, because this is meant to run in CI:
27
+
28
+ ===== ======================================================================
29
+ Code Meaning
30
+ ===== ======================================================================
31
+ 0 Success. ``analyze`` ran; ``migrate`` produced red-to-green evidence;
32
+ a dry run completed; nothing to migrate on a passing suite; or the
33
+ patch is unverified because ``--no-tests`` asked for that.
34
+ 1 Not migrated: validation failed, no plan was possible, the tests ran
35
+ (or could not start) without verifying the patch, or nothing was found
36
+ while the suite was already failing.
37
+ 2 Usage error: bad arguments, missing file, unreadable configuration.
38
+ 3 The change is real but unsupported by this version.
39
+ 4 Interrupted.
40
+ ===== ======================================================================
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import json
47
+ import logging
48
+ import sys
49
+ from pathlib import Path
50
+
51
+ from patchahead import __version__, engine, handlers, observability, reporting
52
+ from patchahead.config import Config, ConfigError
53
+ from patchahead.config import load as load_config
54
+ from patchahead.demo import DemoError
55
+ from patchahead.demo import serve as demo_serve
56
+ from patchahead.domain.change import Confidence
57
+ from patchahead.domain.result import Outcome
58
+ from patchahead.ingest import IngestError
59
+ from patchahead.workspace import RepositoryError, WorkspaceError
60
+
61
+ log = logging.getLogger("patchahead")
62
+
63
+ EXIT_OK = 0
64
+ EXIT_NOT_MIGRATED = 1
65
+ EXIT_USAGE = 2
66
+ EXIT_UNSUPPORTED = 3
67
+ EXIT_INTERRUPTED = 4
68
+
69
+ _EPILOG = """\
70
+ examples:
71
+ patchahead demo
72
+ patchahead analyze --repo ./my-service --change ./release-notes.md
73
+ patchahead migrate --repo ./my-service --change ./release-notes.md
74
+ patchahead migrate --repo ./my-service --change ./notes.md --dry-run
75
+ patchahead migrate --repo ./my-service --change ./notes.md --use-llm
76
+ patchahead handlers
77
+ patchahead api-diff storekit 4.9.0 5.0.0 --out changes.json
78
+
79
+ PatchAhead never writes to your repository. `migrate` patches a temporary copy,
80
+ runs the tests there, and prints the diff for you to review.
81
+ """
82
+
83
+
84
+ def build_parser() -> argparse.ArgumentParser:
85
+ parser = argparse.ArgumentParser(
86
+ prog="patchahead",
87
+ description=(
88
+ "Find downstream code broken by an upstream API change, propose a "
89
+ "minimal migration, and verify it with your tests."
90
+ ),
91
+ epilog=_EPILOG,
92
+ formatter_class=argparse.RawDescriptionHelpFormatter,
93
+ )
94
+ parser.add_argument("--version", action="version", version=f"patchahead {__version__}")
95
+
96
+ # Verbosity lives on a parent parser so it is accepted both before and
97
+ # after the subcommand. `patchahead migrate --repo . -v` is what people
98
+ # actually type, and rejecting it is a papercut with no upside.
99
+ verbosity_parent = argparse.ArgumentParser(add_help=False)
100
+ verbosity_group = verbosity_parent.add_mutually_exclusive_group()
101
+ verbosity_group.add_argument(
102
+ "-v",
103
+ "--verbose",
104
+ action="count",
105
+ default=0,
106
+ help="more detail; repeat (-vv) for debug logging",
107
+ )
108
+ verbosity_group.add_argument(
109
+ "-q",
110
+ "--quiet",
111
+ action="store_true",
112
+ help="only errors",
113
+ )
114
+ verbosity_parent.add_argument(
115
+ "--log-level",
116
+ choices=["debug", "info", "warning", "error"],
117
+ help="set the log level explicitly (overrides -v/-q)",
118
+ )
119
+ for action in verbosity_parent._actions:
120
+ parser._add_action(action)
121
+
122
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
123
+
124
+ def add_common(sub: argparse.ArgumentParser) -> None:
125
+ sub.add_argument(
126
+ "--repo",
127
+ required=True,
128
+ metavar="PATH",
129
+ help="path to the Python repository to analyze",
130
+ )
131
+ sub.add_argument(
132
+ "--change",
133
+ required=True,
134
+ metavar="PATH",
135
+ help="path to the change document (.md, .txt, .rst, .json, .yaml)",
136
+ )
137
+ sub.add_argument(
138
+ "--json",
139
+ action="store_true",
140
+ dest="as_json",
141
+ help="emit machine-readable JSON on stdout instead of text",
142
+ )
143
+ sub.add_argument(
144
+ "--min-confidence",
145
+ choices=["high", "medium", "low"],
146
+ help="lowest finding confidence to act on (default: medium)",
147
+ )
148
+
149
+ analyze = subparsers.add_parser(
150
+ "analyze",
151
+ parents=[verbosity_parent],
152
+ help="report what a change would affect (read-only)",
153
+ description=(
154
+ "Parse a change document, find the downstream code that uses the old "
155
+ "contract, and report it. Nothing is copied, executed, or written."
156
+ ),
157
+ )
158
+ add_common(analyze)
159
+
160
+ migrate = subparsers.add_parser(
161
+ "migrate",
162
+ parents=[verbosity_parent],
163
+ help="propose and validate a migration in an isolated workspace",
164
+ description=(
165
+ "Analyze, plan a migration, apply it to a temporary copy of the "
166
+ "repository, run the tests there, and print the diff. Your repository "
167
+ "is never modified."
168
+ ),
169
+ )
170
+ add_common(migrate)
171
+ migrate.add_argument(
172
+ "--dry-run",
173
+ action="store_true",
174
+ help="stop after planning; do not patch or run tests",
175
+ )
176
+ migrate.add_argument(
177
+ "--use-llm",
178
+ action="store_true",
179
+ help=(
180
+ "when a deterministic migration is not possible, let an LLM propose "
181
+ "one. Sends the affected functions to the Anthropic API. The same "
182
+ "validation gates still apply."
183
+ ),
184
+ )
185
+ migrate.add_argument(
186
+ "--require-complete",
187
+ action="store_true",
188
+ help=(
189
+ "exit 1 when the patched code still uses an old name anywhere: code "
190
+ "PatchAhead did not rewrite, a getattr() with the old name, or a test. "
191
+ "Mentions in strings, comments, and docs do not count."
192
+ ),
193
+ )
194
+ migrate.add_argument(
195
+ "--no-tests",
196
+ action="store_true",
197
+ dest="no_tests",
198
+ help=(
199
+ "skip the gates that execute your test command. The migration cannot "
200
+ "be verified without them."
201
+ ),
202
+ )
203
+ migrate.add_argument(
204
+ "--no-diff",
205
+ action="store_true",
206
+ help="do not print the diff",
207
+ )
208
+ migrate.add_argument(
209
+ "--output-dir",
210
+ metavar="PATH",
211
+ help="where to write the diff, plan, and result (default: .patchahead)",
212
+ )
213
+ migrate.add_argument(
214
+ "--no-artifacts",
215
+ action="store_true",
216
+ help="do not write any files to the output directory",
217
+ )
218
+ migrate.add_argument(
219
+ "--keep-workspace",
220
+ action="store_true",
221
+ help="leave the patched temporary copy on disk and print its path",
222
+ )
223
+ migrate.add_argument(
224
+ "--test-command",
225
+ metavar="CMD",
226
+ help="override the repository's configured test command",
227
+ )
228
+ migrate.add_argument(
229
+ "--pr-summary",
230
+ metavar="PATH",
231
+ help="write a Markdown pull-request summary to this path",
232
+ )
233
+
234
+ demo = subparsers.add_parser(
235
+ "demo",
236
+ parents=[verbosity_parent],
237
+ help="run the bundled walkthrough in a local browser (no setup)",
238
+ description=(
239
+ "Serve the PatchAhead UI on localhost against a bundled example "
240
+ "repository that is deliberately broken by four upstream changes. "
241
+ "Nothing to configure and nothing to clone. It is the real engine: "
242
+ "each scenario copies the bundled repository to a temporary "
243
+ "directory, patches the copy, and runs its tests there."
244
+ ),
245
+ )
246
+ demo.add_argument(
247
+ "--port",
248
+ type=int,
249
+ default=demo_serve.DEFAULT_PORT,
250
+ help=f"port to serve on (default: {demo_serve.DEFAULT_PORT}; "
251
+ f"an unspecified port moves up if busy)",
252
+ )
253
+ demo.add_argument(
254
+ "--no-browser",
255
+ action="store_true",
256
+ help="do not try to open a browser; just print the URL",
257
+ )
258
+ demo.add_argument(
259
+ "--scenario",
260
+ metavar="ID",
261
+ help="open the UI with this scenario preselected (see --list)",
262
+ )
263
+ demo.add_argument(
264
+ "--list",
265
+ action="store_true",
266
+ dest="list_scenarios",
267
+ help="list the bundled scenarios and exit, without starting a server",
268
+ )
269
+ demo.add_argument(
270
+ "--print-paths",
271
+ action="store_true",
272
+ help="print the bundled repository and change-document paths, and exit",
273
+ )
274
+
275
+ web = subparsers.add_parser(
276
+ "web",
277
+ parents=[verbosity_parent],
278
+ help="serve the same UI against a repository of your own",
279
+ description=(
280
+ "The UI from `patchahead demo`, pointed at your repository and your "
281
+ "change documents. Binds to localhost only, and runs your test "
282
+ "command -- see docs/safety.md."
283
+ ),
284
+ )
285
+ web.add_argument("--repo", metavar="PATH", help="repository to analyze")
286
+ web.add_argument("--changes", metavar="PATH", help="directory of change documents")
287
+ web.add_argument("--port", type=int, default=demo_serve.DEFAULT_PORT)
288
+
289
+ api_diff = subparsers.add_parser(
290
+ "api-diff",
291
+ parents=[verbosity_parent],
292
+ help="find breaking changes by comparing two versions of a library",
293
+ description=(
294
+ "Read two versions of a library -- from PyPI, or local directories or "
295
+ "wheels -- and report the breaking changes in its public API. Nothing "
296
+ "is installed or run: PyPI versions are downloaded as wheels and parsed. "
297
+ "With --out, the supported changes are written as a change document "
298
+ "for `patchahead migrate --change`."
299
+ ),
300
+ )
301
+ api_diff.add_argument("package", nargs="?", help="the package name on PyPI")
302
+ api_diff.add_argument("old_version", nargs="?", metavar="OLD", help="the version you use")
303
+ api_diff.add_argument("new_version", nargs="?", metavar="NEW", help="the version to upgrade to")
304
+ api_diff.add_argument("--old", metavar="PATH", help="the old version as a directory or .whl")
305
+ api_diff.add_argument("--new", metavar="PATH", help="the new version as a directory or .whl")
306
+ api_diff.add_argument(
307
+ "--out", metavar="FILE", help="write the changes as a JSON change document"
308
+ )
309
+ api_diff.add_argument("--json", dest="as_json", action="store_true", help="print JSON")
310
+
311
+ subparsers.add_parser(
312
+ "handlers",
313
+ parents=[verbosity_parent],
314
+ help="list the migration families this version supports",
315
+ description=(
316
+ "Every migration family PatchAhead can perform, and what each one "
317
+ "explicitly does not do."
318
+ ),
319
+ )
320
+ return parser
321
+
322
+
323
+ def _log_level(args: argparse.Namespace) -> int:
324
+ if args.log_level:
325
+ return getattr(logging, args.log_level.upper())
326
+ if args.quiet:
327
+ return logging.ERROR
328
+ if args.verbose >= 2:
329
+ return logging.DEBUG
330
+ return logging.INFO
331
+
332
+
333
+ def _resolve_config(args: argparse.Namespace) -> Config:
334
+ """Load the repository's config and apply CLI overrides."""
335
+ config = load_config(Path(args.repo))
336
+ if config.source_path:
337
+ log.debug("using configuration from %s", config.source_path)
338
+ minimum = Confidence(args.min_confidence) if getattr(args, "min_confidence", None) else None
339
+ return config.merged_with_cli(
340
+ test_command=getattr(args, "test_command", None),
341
+ output_dir=getattr(args, "output_dir", None),
342
+ min_confidence=minimum,
343
+ )
344
+
345
+
346
+ def _cmd_handlers(args: argparse.Namespace) -> int:
347
+ print(f"PatchAhead {__version__} supports {len(handlers.registered())} migration families.\n")
348
+ for handler in handlers.registered():
349
+ print(f" {handler.name}")
350
+ print(f" {handler.summary}")
351
+ print(f" change kinds: {', '.join(k.value for k in handler.kinds)}")
352
+ if handler.limitations:
353
+ print(" does not:")
354
+ for limitation in handler.limitations:
355
+ print(f" - {limitation}")
356
+ print()
357
+ print("Anything else is reported as unsupported rather than guessed at.")
358
+ print("To add a family, see docs/migrations.md.")
359
+ return EXIT_OK
360
+
361
+
362
+ def _cmd_api_diff(args: argparse.Namespace) -> int:
363
+ import tempfile
364
+
365
+ from patchahead import apidiff
366
+ from patchahead.ingest.structured import change_to_mapping
367
+
368
+ local = bool(args.old or args.new)
369
+ if local and not (args.old and args.new):
370
+ log.error("--old and --new go together")
371
+ return EXIT_USAGE
372
+ if not local and not (args.package and args.old_version and args.new_version):
373
+ log.error("give a PACKAGE, OLD, and NEW version, or --old PATH and --new PATH")
374
+ return EXIT_USAGE
375
+
376
+ with tempfile.TemporaryDirectory(prefix="patchahead-api-") as scratch:
377
+ try:
378
+ if local:
379
+ old_root = apidiff.unpack(Path(args.old), Path(scratch) / "old")
380
+ new_root = apidiff.unpack(Path(args.new), Path(scratch) / "new")
381
+ else:
382
+ old_root = apidiff.fetch(args.package, args.old_version, Path(scratch) / "old")
383
+ new_root = apidiff.fetch(args.package, args.new_version, Path(scratch) / "new")
384
+ except apidiff.ApiDiffError as exc:
385
+ log.error("%s", exc)
386
+ return EXIT_USAGE
387
+ old, new = apidiff.read(old_root), apidiff.read(new_root)
388
+
389
+ tops = sorted({path.split(".", 1)[0] for path in new.public})
390
+ label = args.package or ", ".join(tops) or Path(args.new).name
391
+ diff = apidiff.compare(old, new, label, (args.old_version or "", args.new_version or ""))
392
+ supported = [c for c in diff.changes if c.is_actionable]
393
+
394
+ if args.as_json:
395
+ print(json.dumps({"changes": [change_to_mapping(c) for c in diff.changes]}, indent=2))
396
+ else:
397
+ versions = f" {diff.old_version} -> {diff.new_version}" if diff.old_version else ""
398
+ print(f"{label}{versions}: compared {diff.members_compared} public member(s)")
399
+ if not old.public:
400
+ print(" no Python source was found in the old version (a compiled library?)")
401
+ for change in diff.changes:
402
+ kind = change.kind.value if change.is_actionable else "reported"
403
+ print(f" {kind:<15} {change.title}")
404
+ print(f" {'':<15} {change.classification_reason}")
405
+ if not diff.changes:
406
+ print(" no breaking changes in the public API")
407
+ for module, reason in sorted({**old.skipped, **new.skipped}.items()):
408
+ print(f" skipped {module}: {reason}")
409
+
410
+ if args.out:
411
+ if not supported:
412
+ print("nothing PatchAhead can migrate; no change document written", file=sys.stderr)
413
+ else:
414
+ document = {"changes": [change_to_mapping(c) for c in supported]}
415
+ Path(args.out).write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
416
+ print(
417
+ f"wrote {len(supported)} change(s) to {args.out}; review it, then run "
418
+ f"`patchahead migrate --change {args.out}`",
419
+ file=sys.stderr,
420
+ )
421
+ return EXIT_OK
422
+
423
+
424
+ def _render_scenarios() -> str:
425
+ """The bundled scenarios as a table, for ``patchahead demo --list``."""
426
+ from patchahead import demo as demo_module
427
+
428
+ lines = [
429
+ f"PatchAhead {__version__} ships {len(demo_module.scenarios())} demo scenarios.",
430
+ "",
431
+ ]
432
+ for scenario in demo_module.scenarios():
433
+ lines.append(f" {scenario.id}")
434
+ lines.append(f" {scenario.title} ({scenario.family})")
435
+ lines.append(f" expects: {scenario.expect.value}")
436
+ lines.append(f" {scenario.headline}")
437
+ lines.append("")
438
+ lines.append("Not every scenario succeeds, on purpose: one is refused, one is")
439
+ lines.append("rejected by the tests, and one is patched without evidence.")
440
+ return "\n".join(lines)
441
+
442
+
443
+ def _cmd_demo(args: argparse.Namespace) -> int:
444
+ from patchahead import demo as demo_module
445
+ from patchahead.demo import serve as serve_module
446
+
447
+ if args.print_paths:
448
+ # Single-token labels so the output is greppable: these paths land
449
+ # inside site-packages after a wheel install, and the first thing
450
+ # anyone wants to do with them is paste them into another command.
451
+ print(f"repository {demo_module.repo_root()}")
452
+ print(f"changes {demo_module.changes_root()}")
453
+ return EXIT_OK
454
+
455
+ if args.list_scenarios:
456
+ print(_render_scenarios())
457
+ return EXIT_OK
458
+
459
+ if args.scenario:
460
+ # Validate before starting a server, so a typo is a one-line error
461
+ # rather than a running process and a confusing page.
462
+ demo_module.find(args.scenario)
463
+
464
+ # argparse cannot tell a default from a value the user typed, and the two
465
+ # mean different things here: an occupied default moves up, an occupied
466
+ # explicit port is an error rather than a silent redirect.
467
+ explicit = any(arg == "--port" or arg.startswith("--port=") for arg in sys.argv[1:])
468
+ return serve_module.serve(
469
+ port=args.port,
470
+ port_was_explicit=explicit,
471
+ open_browser=not args.no_browser,
472
+ scenario=args.scenario or "",
473
+ )
474
+
475
+
476
+ def _cmd_web(args: argparse.Namespace) -> int:
477
+ from patchahead.web import server as web_server
478
+
479
+ argv: list[str] = ["--port", str(args.port)]
480
+ if args.repo:
481
+ argv += ["--repo", args.repo]
482
+ if args.changes:
483
+ argv += ["--changes", args.changes]
484
+ return web_server.main(argv)
485
+
486
+
487
+ def _cmd_analyze(args: argparse.Namespace) -> int:
488
+ config = _resolve_config(args)
489
+ result = engine.analyze(args.repo, args.change, config)
490
+
491
+ if args.as_json:
492
+ print(json.dumps(result.to_dict(), indent=2))
493
+ else:
494
+ print(reporting.render_analysis(result, verbose=args.verbose > 0, stream=sys.stdout))
495
+
496
+ if any(r.unsupported_reason for r in result.reports) and not result.has_impact:
497
+ return EXIT_UNSUPPORTED
498
+ return EXIT_OK
499
+
500
+
501
+ def _cmd_migrate(args: argparse.Namespace) -> int:
502
+ config = _resolve_config(args)
503
+ options = engine.EngineOptions(
504
+ dry_run=args.dry_run,
505
+ use_llm=args.use_llm,
506
+ run_tests=not args.no_tests,
507
+ write_artifacts=not args.no_artifacts and not args.dry_run,
508
+ keep_workspace=args.keep_workspace,
509
+ test_command=args.test_command or "",
510
+ )
511
+
512
+ if options.run_tests and not args.dry_run and not args.as_json:
513
+ # Running the repository's test command executes its code. Say so once,
514
+ # plainly, rather than doing it silently. See docs/safety.md.
515
+ print(
516
+ f"note: will run `{options.test_command or config.test_command}` inside a "
517
+ f"temporary copy of {args.repo}",
518
+ file=sys.stderr,
519
+ )
520
+
521
+ run = engine.migrate(args.repo, args.change, options, config)
522
+
523
+ if args.as_json:
524
+ print(json.dumps(run.to_dict(), indent=2))
525
+ else:
526
+ print(
527
+ reporting.render_run(
528
+ run,
529
+ verbose=args.verbose > 0,
530
+ show_diff=not args.no_diff,
531
+ stream=sys.stdout,
532
+ )
533
+ )
534
+
535
+ if args.pr_summary and run.results:
536
+ path = Path(args.pr_summary)
537
+ try:
538
+ path.parent.mkdir(parents=True, exist_ok=True)
539
+ path.write_text(
540
+ "\n\n---\n\n".join(reporting.render_pr_markdown(result) for result in run.results),
541
+ encoding="utf-8",
542
+ )
543
+ print(f"pr summary written to {reporting.relative(str(path))}", file=sys.stderr)
544
+ except OSError as exc:
545
+ log.error("could not write the PR summary to %s: %s", path, exc)
546
+
547
+ return _migration_exit_code(
548
+ run, tests_requested=options.run_tests, require_complete=args.require_complete
549
+ )
550
+
551
+
552
+ def _migration_exit_code(
553
+ run, *, tests_requested: bool = True, require_complete: bool = False
554
+ ) -> int:
555
+ if not run.results:
556
+ return EXIT_USAGE
557
+ outcomes = {result.outcome for result in run.results}
558
+ if outcomes == {Outcome.UNSUPPORTED_CHANGE}:
559
+ return EXIT_UNSUPPORTED
560
+ # Exit 0 is a claim a CI job will act on, so every result has to earn it.
561
+ acceptable = all(_exits_cleanly(result, tests_requested) for result in run.results)
562
+ if require_complete and not run.complete:
563
+ acceptable = False
564
+ return EXIT_OK if acceptable else EXIT_NOT_MIGRATED
565
+
566
+
567
+ def _exits_cleanly(result, tests_requested: bool) -> bool:
568
+ """Whether one result is consistent with exit 0.
569
+
570
+ A dry run attempted nothing, and an unsupported change is reported by its own
571
+ code when it is the only result. `no_impact` is clean unless the baseline
572
+ suite was already red: then the change document probably named something
573
+ PatchAhead did not find, and "nothing to migrate" would be a guess.
574
+ `patched_unverified` is clean only when the user turned the tests off -- a
575
+ run that asked for tests and got no evidence has not verified anything.
576
+ """
577
+ if result.outcome in (Outcome.DRY_RUN, Outcome.UNSUPPORTED_CHANGE):
578
+ return True
579
+ if result.outcome is Outcome.NO_IMPACT:
580
+ baseline = result.baseline_tests
581
+ return baseline is None or baseline.passed or baseline.errored
582
+ if result.outcome is Outcome.PATCHED_UNVERIFIED:
583
+ return not tests_requested
584
+ return result.succeeded
585
+
586
+
587
+ def main(argv: list[str] | None = None) -> int:
588
+ parser = build_parser()
589
+ args = parser.parse_args(argv)
590
+
591
+ if not args.command:
592
+ parser.print_help()
593
+ return EXIT_USAGE
594
+
595
+ observability.configure_logging(_log_level(args))
596
+ observability.init_error_reporting()
597
+
598
+ dispatch = {
599
+ "demo": _cmd_demo,
600
+ "analyze": _cmd_analyze,
601
+ "migrate": _cmd_migrate,
602
+ "web": _cmd_web,
603
+ "handlers": _cmd_handlers,
604
+ "api-diff": _cmd_api_diff,
605
+ }
606
+
607
+ try:
608
+ return dispatch[args.command](args)
609
+ except KeyboardInterrupt:
610
+ print("interrupted", file=sys.stderr)
611
+ return EXIT_INTERRUPTED
612
+ except (RepositoryError, IngestError, ConfigError, WorkspaceError, DemoError) as exc:
613
+ # Expected, actionable failures: say what is wrong, not a traceback.
614
+ log.error("%s", exc)
615
+ return EXIT_USAGE
616
+ except Exception as exc: # unexpected: report it, and show the traceback
617
+ observability.capture_exception(exc, command=args.command)
618
+ log.error("unexpected error: %s", exc)
619
+ log.debug("traceback:", exc_info=exc)
620
+ if args.verbose:
621
+ raise
622
+ log.error("re-run with -v to see the traceback")
623
+ return EXIT_USAGE
624
+
625
+
626
+ if __name__ == "__main__": # pragma: no cover
627
+ raise SystemExit(main())