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.
- patchahead/__init__.py +8 -0
- patchahead/analysis/__init__.py +52 -0
- patchahead/analysis/edits.py +143 -0
- patchahead/analysis/index.py +203 -0
- patchahead/analysis/python_ast.py +457 -0
- patchahead/apidiff/__init__.py +23 -0
- patchahead/apidiff/compare.py +366 -0
- patchahead/apidiff/download.py +95 -0
- patchahead/apidiff/surface.py +337 -0
- patchahead/ci.py +301 -0
- patchahead/cli.py +627 -0
- patchahead/config.py +284 -0
- patchahead/demo/__init__.py +256 -0
- patchahead/demo/fixtures/changes/field-rename.md +14 -0
- patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
- patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
- patchahead/demo/fixtures/changes/method-rename.md +12 -0
- patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
- patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
- patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
- patchahead/demo/fixtures/orders-service/README.md +51 -0
- patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/app/client.py +15 -0
- patchahead/demo/fixtures/orders-service/app/models.py +10 -0
- patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
- patchahead/demo/fixtures/orders-service/conftest.py +6 -0
- patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
- patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
- patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
- patchahead/demo/serve.py +189 -0
- patchahead/domain/__init__.py +67 -0
- patchahead/domain/change.py +269 -0
- patchahead/domain/completeness.py +91 -0
- patchahead/domain/impact.py +248 -0
- patchahead/domain/patch.py +81 -0
- patchahead/domain/plan.py +170 -0
- patchahead/domain/result.py +210 -0
- patchahead/domain/validation.py +200 -0
- patchahead/engine.py +609 -0
- patchahead/handlers/__init__.py +35 -0
- patchahead/handlers/base.py +211 -0
- patchahead/handlers/field_rename.py +425 -0
- patchahead/handlers/kwarg_rename.py +201 -0
- patchahead/handlers/method_rename.py +608 -0
- patchahead/handlers/pagination.py +582 -0
- patchahead/ingest/__init__.py +32 -0
- patchahead/ingest/base.py +102 -0
- patchahead/ingest/markdown.py +1138 -0
- patchahead/ingest/structured.py +218 -0
- patchahead/llm/__init__.py +28 -0
- patchahead/llm/client.py +152 -0
- patchahead/llm/proposer.py +620 -0
- patchahead/observability.py +223 -0
- patchahead/reporting.py +451 -0
- patchahead/testing/__init__.py +22 -0
- patchahead/testing/discovery.py +113 -0
- patchahead/testing/runner.py +138 -0
- patchahead/validation/__init__.py +5 -0
- patchahead/validation/completeness.py +265 -0
- patchahead/validation/engine.py +531 -0
- patchahead/web/__init__.py +13 -0
- patchahead/web/server.py +279 -0
- patchahead/web/static/index.html +650 -0
- patchahead/workspace.py +382 -0
- patchahead-0.3.0.dist-info/METADATA +368 -0
- patchahead-0.3.0.dist-info/RECORD +75 -0
- patchahead-0.3.0.dist-info/WHEEL +5 -0
- patchahead-0.3.0.dist-info/entry_points.txt +2 -0
- patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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())
|