apifix 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. apifix/__init__.py +3 -0
  2. apifix/api/__init__.py +1 -0
  3. apifix/cli.py +498 -0
  4. apifix/engine/__init__.py +1 -0
  5. apifix/engine/agent.py +3058 -0
  6. apifix/engine/agent_tools.py +328 -0
  7. apifix/engine/already_migrated.py +107 -0
  8. apifix/engine/build_info.py +45 -0
  9. apifix/engine/cleanup.py +187 -0
  10. apifix/engine/code_checks.py +1829 -0
  11. apifix/engine/context.py +257 -0
  12. apifix/engine/dependencies.py +1014 -0
  13. apifix/engine/diffing.py +130 -0
  14. apifix/engine/docker.py +202 -0
  15. apifix/engine/github.py +416 -0
  16. apifix/engine/gitops.py +140 -0
  17. apifix/engine/imports.py +239 -0
  18. apifix/engine/llm.py +334 -0
  19. apifix/engine/llm_anthropic.py +241 -0
  20. apifix/engine/llm_openai_compat.py +901 -0
  21. apifix/engine/local_paths.py +59 -0
  22. apifix/engine/migrations/__init__.py +155 -0
  23. apifix/engine/migrations/guide.py +44 -0
  24. apifix/engine/migrations/openai_v1/__init__.py +186 -0
  25. apifix/engine/migrations/openai_v1/config_checks.py +271 -0
  26. apifix/engine/migrations/openai_v1/guide.md +300 -0
  27. apifix/engine/migrations/openai_v1/guide_parts.py +91 -0
  28. apifix/engine/migrations/openai_v1/python_checks.py +1050 -0
  29. apifix/engine/migrations/openai_v1/repo_findings.py +83 -0
  30. apifix/engine/migrations/openai_v1/rules.py +620 -0
  31. apifix/engine/migrations/openai_v1/streaming.py +325 -0
  32. apifix/engine/models.py +110 -0
  33. apifix/engine/pin_edits.py +876 -0
  34. apifix/engine/pr_body.py +488 -0
  35. apifix/engine/project.py +1153 -0
  36. apifix/engine/pull_request.py +411 -0
  37. apifix/engine/regions.py +1020 -0
  38. apifix/engine/sandbox.py +1002 -0
  39. apifix/engine/sandbox_driver.py +215 -0
  40. apifix/engine/scanner.py +133 -0
  41. apifix/engine/scope.py +119 -0
  42. apifix/engine/suspicious.py +229 -0
  43. apifix/engine/tampering.py +550 -0
  44. apifix/engine/test_results.py +223 -0
  45. apifix/engine/unrelated.py +1150 -0
  46. apifix/engine/verifier.py +1682 -0
  47. apifix/engine/verify_diff.py +256 -0
  48. apifix/engine/walk.py +77 -0
  49. apifix/engine/whole_file.py +786 -0
  50. apifix/worker/__init__.py +1 -0
  51. apifix-0.1.0.dist-info/METADATA +107 -0
  52. apifix-0.1.0.dist-info/RECORD +55 -0
  53. apifix-0.1.0.dist-info/WHEEL +4 -0
  54. apifix-0.1.0.dist-info/entry_points.txt +2 -0
  55. apifix-0.1.0.dist-info/licenses/LICENSE +21 -0
apifix/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """apifix: self-maintaining API migrations."""
2
+
3
+ __version__ = "0.1.0"
apifix/api/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """FastAPI service. Not built yet (see docs/notes.md, "Later")."""
apifix/cli.py ADDED
@@ -0,0 +1,498 @@
1
+ """Command-line entry point.
2
+
3
+ apifix scan <path> --migration openai-v1 [--json]
4
+ apifix fix <path> --migration openai-v1 [--model M] [--max-cost USD] [--out DIR]
5
+ [--edit-mode agent|whole-file] [--max-file-bytes N]
6
+ [--file-edits auto|whole|regions] [--region-threshold BYTES]
7
+ apifix fix <path> --migration openai-v1 --provider openai-compat --model M [--base-url URL]
8
+ [--strict-tool-calls] [--api-key-env NAME] [--request-timeout S] [--max-output-tokens N]
9
+ [--reasoning-effort low|medium|high]
10
+ apifix verify <path> --migration openai-v1 --diff FILE [--out DIR]
11
+ apply a saved diff to a copy and verify it (no LLM)
12
+ apifix fix|verify ... --sandbox-requirements FILE
13
+ install FILE in the sandbox instead of the repo's detected
14
+ dependencies (original code and fix; the fix's own
15
+ dependency edits still apply)
16
+ apifix pr RUN_DIR --repo CHECKOUT
17
+ dry run: a local branch with one commit holding a verified
18
+ run's fix, plus pr-title.txt, pr-body.md and pr.json in RUN_DIR
19
+ (needs APIFIX_GIT_NAME and APIFIX_GIT_EMAIL, a noreply
20
+ address; pushes nothing, no LLM)
21
+ apifix pr RUN_DIR --repo CHECKOUT --push [--github-user LOGIN] [--upstream OWNER/REPO] [--ready]
22
+ also fork (unless you own the upstream), push the branch and
23
+ open a draft pull request with the `gh` CLI (--ready: not a
24
+ draft). LOGIN, or $APIFIX_GITHUB_USER, is the account gh must
25
+ be logged in as; any other account is refused
26
+ apifix cleanup [--dry-run] remove containers/temp dirs left by interrupted runs
27
+
28
+ Exit codes: 0 clean (fix: verified; pr: branch made), 1 findings (fix: not verified),
29
+ 2 usage error or aborted (pr: refused, with the reason on stderr),
30
+ 3 (fix) already migrated: the findings are all in test files that look out of date with code
31
+ already on the new API; nothing was sent to an LLM or run.
32
+
33
+ report.md, verification.json and trajectory.json write the home directory as ~ (fix.diff is
34
+ unchanged).
35
+
36
+ fix and verify keep a pip cache for the sandbox's dependency installs
37
+ ($APIFIX_PIP_CACHE, else ~/.cache/apifix/pip); --no-pip-cache turns it off.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import hashlib
44
+ import json
45
+ import sys
46
+ from dataclasses import asdict
47
+ from datetime import datetime
48
+ from pathlib import Path
49
+
50
+ from apifix.engine.cleanup import CleanupReport, cleanup
51
+ from apifix.engine.docker import DockerCLI
52
+ from apifix.engine.migrations import available_migrations
53
+ from apifix.engine.models import ScanResult
54
+ from apifix.engine.scanner import scan
55
+
56
+ EXIT_CLEAN, EXIT_FINDINGS, EXIT_USAGE, EXIT_ALREADY_MIGRATED = 0, 1, 2, 3
57
+
58
+
59
+ def main(argv: list[str] | None = None) -> int:
60
+ parser = argparse.ArgumentParser(prog="apifix")
61
+ sub = parser.add_subparsers(dest="command", required=True)
62
+ p_scan = sub.add_parser("scan", help="report code affected by a migration")
63
+ p_scan.add_argument("path", help="local repo directory or file")
64
+ p_scan.add_argument("--migration", required=True, choices=available_migrations())
65
+ p_scan.add_argument("--json", action="store_true", help="machine-readable output")
66
+ p_fix = sub.add_parser("fix", help="fix a copy of the repo with an LLM agent, then verify it")
67
+ p_fix.add_argument("path", help="local repo directory (never modified)")
68
+ p_fix.add_argument("--migration", required=True, choices=available_migrations())
69
+ p_fix.add_argument(
70
+ "--provider",
71
+ choices=["anthropic", "openai-compat"],
72
+ help="LLM provider (default: $APIFIX_PROVIDER or anthropic)",
73
+ )
74
+ p_fix.add_argument(
75
+ "--model",
76
+ help="model (default: $APIFIX_MODEL, else claude-opus-5-5; required for openai-compat)",
77
+ )
78
+ p_fix.add_argument(
79
+ "--base-url",
80
+ help="openai-compat endpoint (default: $APIFIX_BASE_URL or http://localhost:11434/v1)",
81
+ )
82
+ p_fix.add_argument(
83
+ "--api-key-env",
84
+ help="openai-compat: env var holding the API key (default: APIFIX_LLM_API_KEY)",
85
+ )
86
+ p_fix.add_argument(
87
+ "--request-timeout",
88
+ "--timeout",
89
+ dest="request_timeout",
90
+ type=float,
91
+ help="seconds to wait for one LLM reply (default 600; openai-compat on a local "
92
+ "endpoint: 3600)",
93
+ )
94
+ p_fix.add_argument(
95
+ "--max-output-tokens",
96
+ type=int,
97
+ help="reply tokens each LLM request allows for (default 16000); lower it when a hosted "
98
+ "free tier counts them against a per-minute token limit",
99
+ )
100
+ p_fix.add_argument(
101
+ "--edit-mode",
102
+ choices=["agent", "whole-file"],
103
+ default="agent",
104
+ help="agent: the model edits through tools (default). whole-file: no tools; the model "
105
+ "rewrites one affected file per request (recommended for small local models)",
106
+ )
107
+ p_fix.add_argument(
108
+ "--max-file-bytes",
109
+ type=int,
110
+ help="whole-file: byte budget for one file plus its read-only context; larger files "
111
+ "are not sent (default 24000)",
112
+ )
113
+ p_fix.add_argument(
114
+ "--file-edits",
115
+ choices=["auto", "whole", "regions"],
116
+ help="whole-file: send each Python file whole or only the regions that need changing "
117
+ "(top-level functions, methods, statements with findings, plus the imports); auto "
118
+ "(default) sends files over --region-threshold as regions",
119
+ )
120
+ p_fix.add_argument(
121
+ "--region-threshold",
122
+ type=int,
123
+ metavar="BYTES",
124
+ help="whole-file, --file-edits auto: Python files over this size are sent as regions "
125
+ "(default 8000)",
126
+ )
127
+ p_fix.add_argument(
128
+ "--strict-tool-calls",
129
+ action="store_true",
130
+ help="openai-compat: only use the server's tool-call field; never parse a reply that "
131
+ "is written as one JSON tool call",
132
+ )
133
+ p_fix.add_argument(
134
+ "--reasoning-effort",
135
+ choices=["low", "medium", "high"],
136
+ help="openai-compat: sent as reasoning_effort (reasoning models such as gpt-oss); "
137
+ "lower effort leaves more of each reply's tokens for the answer (default: not sent)",
138
+ )
139
+ p_fix.add_argument("--prices", help="TOML file overriding model prices ($APIFIX_PRICES)")
140
+ cost = p_fix.add_mutually_exclusive_group()
141
+ cost.add_argument(
142
+ "--max-cost", type=float, default=5.0, help="US dollar limit for the job (default 5)"
143
+ )
144
+ cost.add_argument(
145
+ "--no-cost-limit",
146
+ action="store_true",
147
+ help="no dollar limit (e.g. an unpriced hosted endpoint); token limits still apply",
148
+ )
149
+ p_fix.add_argument("--max-retries", type=int, default=3)
150
+ p_fix.add_argument("--out", help="output directory (default: ./apifix-out/<timestamp>)")
151
+ p_verify = sub.add_parser(
152
+ "verify", help="apply a saved diff to a copy of the repo and verify it (no LLM)"
153
+ )
154
+ p_verify.add_argument("path", help="local repo directory (never modified)")
155
+ p_verify.add_argument("--migration", required=True, choices=available_migrations())
156
+ p_verify.add_argument(
157
+ "--diff", required=True, help="unified diff to apply (e.g. a previous run's fix.diff)"
158
+ )
159
+ p_verify.add_argument("--out", help="output directory (default: ./apifix-out/<timestamp>)")
160
+ for p in (p_fix, p_verify):
161
+ p.add_argument(
162
+ "--no-pip-cache",
163
+ action="store_true",
164
+ help="download every dependency again instead of using apifix's pip cache",
165
+ )
166
+ p.add_argument(
167
+ "--sandbox-requirements",
168
+ metavar="FILE",
169
+ help="requirements file installed in the sandbox instead of the repo's detected "
170
+ "dependencies (original code and fix); names the fix's dependency edits change "
171
+ "take the fix's version. Only requirements and --index-url / --extra-index-url",
172
+ )
173
+ p_pr = sub.add_parser(
174
+ "pr", help="make a local branch and pull request text for a verified run (pushes nothing)"
175
+ )
176
+ p_pr.add_argument("run_dir", help="output directory of a verified `fix` or `verify` run")
177
+ p_pr.add_argument(
178
+ "--repo", required=True, help="clean checkout of the repo the run was made on"
179
+ )
180
+ p_pr.add_argument(
181
+ "--push",
182
+ action="store_true",
183
+ help="also push the branch (to your fork, or to the upstream you own) and open a draft "
184
+ "pull request with gh",
185
+ )
186
+ p_pr.add_argument(
187
+ "--github-user",
188
+ help="the GitHub login gh must be logged in as ($APIFIX_GITHUB_USER); needed with --push",
189
+ )
190
+ p_pr.add_argument(
191
+ "--upstream",
192
+ metavar="OWNER/REPO",
193
+ help="repository to open the pull request in (default: the checkout's origin)",
194
+ )
195
+ p_pr.add_argument(
196
+ "--ready", action="store_true", help="open the pull request ready for review, not a draft"
197
+ )
198
+ p_clean = sub.add_parser(
199
+ "cleanup",
200
+ help="remove apifix containers and temp dirs left behind by crashed or killed runs",
201
+ )
202
+ p_clean.add_argument("--dry-run", action="store_true", help="only list what would go")
203
+ args = parser.parse_args(argv)
204
+
205
+ if args.command == "fix":
206
+ return _fix(args)
207
+ if args.command == "verify":
208
+ return _verify(args)
209
+ if args.command == "pr":
210
+ return _pr(args)
211
+ if args.command == "cleanup":
212
+ report = cleanup(DockerCLI(), dry_run=args.dry_run)
213
+ print(_cleanup_text(report))
214
+ return EXIT_FINDINGS if report.errors else EXIT_CLEAN
215
+
216
+ if args.path.startswith(("http://", "https://", "git@")):
217
+ print("apifix: GitHub URLs are not supported yet; clone locally first", file=sys.stderr)
218
+ return EXIT_USAGE
219
+ try:
220
+ result = scan(args.path, args.migration)
221
+ except FileNotFoundError as exc:
222
+ print(f"apifix: {exc}", file=sys.stderr)
223
+ return EXIT_USAGE
224
+
225
+ print(_to_json(result) if args.json else _to_text(result))
226
+ return EXIT_FINDINGS if result.findings else EXIT_CLEAN
227
+
228
+
229
+ def _sandbox(args: argparse.Namespace):
230
+ """The sandbox for fix/verify; ValueError if --sandbox-requirements is unusable."""
231
+ from apifix.engine.project import load_sandbox_requirements
232
+ from apifix.engine.sandbox import Sandbox, SandboxConfig, default_pip_cache_dir
233
+
234
+ cache = None if args.no_pip_cache else default_pip_cache_dir()
235
+ override = (
236
+ load_sandbox_requirements(args.sandbox_requirements) if args.sandbox_requirements else None
237
+ )
238
+ return Sandbox(SandboxConfig(pip_cache_dir=cache, requirements_override=override))
239
+
240
+
241
+ def _out_dir(args: argparse.Namespace) -> Path:
242
+ out = Path(args.out or Path("apifix-out") / datetime.now().strftime("%Y%m%d-%H%M%S"))
243
+ out.mkdir(parents=True, exist_ok=True)
244
+ return out
245
+
246
+
247
+ def _write_outputs(
248
+ out: Path, diff: str, report: str, verification: dict | None, trajectory: dict
249
+ ) -> None:
250
+ from apifix.engine.local_paths import redact_local_paths
251
+
252
+ # Outputs get published as evidence: home dir -> ~ (the diff stays byte-for-byte).
253
+ # fix.diff's digest ties a later `apifix pr` to exactly the diff that was verified.
254
+ trajectory["job"]["diff_sha256"] = hashlib.sha256(diff.encode("utf-8")).hexdigest()
255
+ report, verification, trajectory = redact_local_paths((report, verification, trajectory))
256
+ (out / "fix.diff").write_text(diff, newline="") # CRLF lines of CRLF files stay
257
+ (out / "report.md").write_text(report)
258
+ (out / "verification.json").write_text(json.dumps(verification, indent=2) + "\n")
259
+ (out / "trajectory.json").write_text(json.dumps(trajectory, indent=2) + "\n")
260
+
261
+
262
+ def _verify(args: argparse.Namespace) -> int:
263
+ from apifix.engine.verify_diff import VerifyError, verify_diff
264
+
265
+ if args.path.startswith(("http://", "https://", "git@")):
266
+ print("apifix: GitHub URLs are not supported yet; clone locally first", file=sys.stderr)
267
+ return EXIT_USAGE
268
+ try:
269
+ result = verify_diff(args.path, args.migration, args.diff, sandbox=_sandbox(args))
270
+ except (VerifyError, ValueError) as exc:
271
+ print(f"apifix: {exc}", file=sys.stderr)
272
+ return EXIT_USAGE
273
+ out = _out_dir(args)
274
+ _write_outputs(
275
+ out,
276
+ result.diff,
277
+ result.report_markdown,
278
+ result.verification.to_dict(),
279
+ result.trajectory,
280
+ )
281
+ print(result.reason)
282
+ print(f"no LLM used; wrote fix.diff, report.md, verification.json, trajectory.json to {out}")
283
+ return EXIT_CLEAN if result.outcome in ("verified", "verified_weak") else EXIT_FINDINGS
284
+
285
+
286
+ def _fix(args: argparse.Namespace) -> int:
287
+ from apifix.engine.agent import AgentConfig, fix
288
+ from apifix.engine.llm import LLMError, get_llm
289
+
290
+ if args.path.startswith(("http://", "https://", "git@")):
291
+ print("apifix: GitHub URLs are not supported yet; clone locally first", file=sys.stderr)
292
+ return EXIT_USAGE
293
+ if not Path(args.path).is_dir():
294
+ print(f"apifix: not a directory: {args.path}", file=sys.stderr)
295
+ return EXIT_USAGE
296
+ if args.max_file_bytes is not None and args.edit_mode != "whole-file":
297
+ print("apifix: --max-file-bytes only applies to --edit-mode whole-file", file=sys.stderr)
298
+ return EXIT_USAGE
299
+ if args.max_file_bytes is not None and args.max_file_bytes <= 0:
300
+ print("apifix: --max-file-bytes must be positive", file=sys.stderr)
301
+ return EXIT_USAGE
302
+ for flag, value in (
303
+ ("--file-edits", args.file_edits),
304
+ ("--region-threshold", args.region_threshold),
305
+ ):
306
+ if value is not None and args.edit_mode != "whole-file":
307
+ print(f"apifix: {flag} only applies to --edit-mode whole-file", file=sys.stderr)
308
+ return EXIT_USAGE
309
+ if args.region_threshold is not None and args.region_threshold <= 0:
310
+ print("apifix: --region-threshold must be positive", file=sys.stderr)
311
+ return EXIT_USAGE
312
+ if args.request_timeout is not None and args.request_timeout <= 0:
313
+ print("apifix: --request-timeout must be positive", file=sys.stderr)
314
+ return EXIT_USAGE
315
+ if args.max_output_tokens is not None and args.max_output_tokens <= 0:
316
+ print("apifix: --max-output-tokens must be positive", file=sys.stderr)
317
+ return EXIT_USAGE
318
+ options = {
319
+ key: value
320
+ for key, value in (
321
+ ("base_url", args.base_url),
322
+ ("api_key_env", args.api_key_env),
323
+ ("timeout_s", args.request_timeout),
324
+ ("strict_tool_calls", args.strict_tool_calls or None),
325
+ ("reasoning_effort", args.reasoning_effort),
326
+ )
327
+ if value is not None
328
+ }
329
+ try:
330
+ sandbox = _sandbox(args) # checks --sandbox-requirements before any LLM setup
331
+ llm = get_llm(args.provider, args.model, prices=args.prices, **options)
332
+ notice = getattr(llm, "privacy_notice", None)
333
+ if notice:
334
+ print(notice, file=sys.stderr)
335
+ max_cost = None if args.no_cost_limit else args.max_cost
336
+ limits = {
337
+ key: value
338
+ for key, value in (
339
+ ("whole_file_max_bytes", args.max_file_bytes),
340
+ ("max_output_tokens", args.max_output_tokens),
341
+ ("file_edits", args.file_edits),
342
+ ("region_threshold_bytes", args.region_threshold),
343
+ )
344
+ if value is not None
345
+ }
346
+ config = AgentConfig(
347
+ max_cost_usd=max_cost,
348
+ max_retries=args.max_retries,
349
+ edit_mode=args.edit_mode,
350
+ **limits,
351
+ )
352
+ result = fix(args.path, args.migration, llm=llm, sandbox=sandbox, config=config)
353
+ except (LLMError, ValueError) as exc:
354
+ print(f"apifix: {exc}", file=sys.stderr)
355
+ return EXIT_USAGE
356
+
357
+ out = _out_dir(args)
358
+ verification = result.verification.to_dict() if result.verification else None
359
+ _write_outputs(out, result.diff, result.report_markdown, verification, result.trajectory)
360
+ print(f"{result.outcome}: {result.reason}")
361
+ if result.outcome == "already_migrated":
362
+ print(_findings_text(result.trajectory["findings"]))
363
+ print(
364
+ f"{result.attempts} attempt(s), {result.usage.total:,} tokens, ${result.cost_usd:.2f}; "
365
+ f"wrote fix.diff, report.md, verification.json, trajectory.json to {out}"
366
+ )
367
+ if result.outcome in ("verified", "verified_weak", "no_findings"):
368
+ return EXIT_CLEAN
369
+ if result.outcome == "already_migrated":
370
+ return EXIT_ALREADY_MIGRATED
371
+ return EXIT_USAGE if result.outcome == "aborted" else EXIT_FINDINGS
372
+
373
+
374
+ def _plural(n: int, noun: str) -> str:
375
+ """`1 file`, `3 files`."""
376
+ return f"{n} {noun}" + ("" if n == 1 else "s")
377
+
378
+
379
+ def _pr(args: argparse.Namespace) -> int:
380
+ import os
381
+ from pathlib import Path
382
+
383
+ from apifix.engine.github import GitHub, GitHubError, PullRequestPublisher
384
+ from apifix.engine.pull_request import PullRequestError, make_pull_request
385
+
386
+ def refuse(reason: str) -> int:
387
+ print(f"apifix: refusing to make a pull request: {reason}", file=sys.stderr)
388
+ return EXIT_USAGE
389
+
390
+ user = args.github_user or os.environ.get("APIFIX_GITHUB_USER", "")
391
+ if not args.push:
392
+ used = [
393
+ flag
394
+ for flag, given in (
395
+ ("--github-user", args.github_user),
396
+ ("--upstream", args.upstream),
397
+ ("--ready", args.ready),
398
+ )
399
+ if given
400
+ ]
401
+ if used:
402
+ return refuse(f"{', '.join(used)} only apply with --push")
403
+ elif not user.strip():
404
+ return refuse(
405
+ "--push needs the GitHub login you expect gh to be logged in as: pass --github-user "
406
+ "LOGIN or set APIFIX_GITHUB_USER (a wrong account is refused)"
407
+ )
408
+ publisher = None
409
+ try:
410
+ if args.push:
411
+ publisher = PullRequestPublisher(
412
+ GitHub(),
413
+ Path(args.repo).resolve(),
414
+ expected_user=user,
415
+ upstream=args.upstream,
416
+ ready=args.ready,
417
+ )
418
+ result = make_pull_request(
419
+ args.run_dir,
420
+ args.repo,
421
+ env=os.environ,
422
+ for_push=args.push,
423
+ preflight=publisher.preflight if publisher else None,
424
+ )
425
+ except (PullRequestError, GitHubError) as exc:
426
+ return refuse(str(exc))
427
+ print(f"{result.verdict}: branch {result.branch} ({result.commit[:12]}) in {args.repo}")
428
+ verb = ""
429
+ if result.resumed:
430
+ verb = "reused the branch " + (
431
+ "from the dry run: " if result.from_dry_run else "of an unfinished push: "
432
+ )
433
+ print(f"{verb}{_plural(len(result.files), 'file')} in one commit on {result.base[:12]}")
434
+ if result.dirty_apifix:
435
+ print("note: the run was made with a dirty apifix build; the PR body says so")
436
+ print(f"wrote {', '.join(result.outputs)} to {args.run_dir}")
437
+ if not publisher:
438
+ print("nothing was pushed")
439
+ return EXIT_CLEAN
440
+ print(f"GitHub account: {publisher.user} (as gh is logged in)")
441
+ try:
442
+ done = publisher.publish(Path(args.run_dir).resolve(), result.branch)
443
+ except GitHubError as exc:
444
+ print(f"apifix: the pull request was not opened: {exc}", file=sys.stderr)
445
+ return EXIT_USAGE
446
+ where = f"{done.fork} (fork of {done.upstream})" if done.fork else done.upstream
447
+ print(f"pushed {result.branch} to {where}")
448
+ print(f"opened a {'draft ' if done.draft else ''}pull request: {done.pr_url}")
449
+ return EXIT_CLEAN
450
+
451
+
452
+ def _cleanup_text(report: CleanupReport) -> str:
453
+ verb = "would remove" if report.dry_run else "removed"
454
+ lines = [f"{verb} container {c}" for c in report.removed_containers]
455
+ lines += [f"{verb} temp dir {d}" for d in report.removed_dirs]
456
+ lines += [f"skipped {s}" for s in report.skipped]
457
+ lines += [f"error: {e}" for e in report.errors]
458
+ summary = (
459
+ f"{len(report.removed_containers)} container(s), {len(report.removed_dirs)} temp dir(s)"
460
+ )
461
+ lines.append(f"{verb} {summary}" + ("" if lines else ": nothing left over"))
462
+ return "\n".join(lines)
463
+
464
+
465
+ def _to_json(result: ScanResult) -> str:
466
+ return json.dumps(asdict(result), indent=2)
467
+
468
+
469
+ def _findings_text(findings: list[dict]) -> str:
470
+ """One line per finding (fix's console output for an already-migrated repo)."""
471
+ return "\n".join(
472
+ f" {f['file']}:{f['line']}:{f['col']}: {f['category']} [{f['confidence']}] "
473
+ f"{f['old_pattern']}"
474
+ for f in findings
475
+ )
476
+
477
+
478
+ def _to_text(result: ScanResult) -> str:
479
+ lines = []
480
+ if result.already_migrated:
481
+ lines.append(result.already_migrated.message)
482
+ lines += [f" {line}" for line in result.already_migrated.report_lines()]
483
+ for f in result.findings:
484
+ lines.append(f"{f.file}:{f.line}:{f.col}: {f.category} [{f.confidence}]")
485
+ lines.append(f" old: {f.old_pattern}")
486
+ lines.append(f" fix: {f.suggestion}")
487
+ for e in result.parse_errors:
488
+ lines.append(f"{e.file}: could not parse ({e.error})")
489
+ files = len({f.file for f in result.findings})
490
+ lines.append(
491
+ f"{len(result.findings)} finding(s) in {files} file(s); "
492
+ f"{result.files_scanned} file(s) scanned, {len(result.parse_errors)} parse error(s)"
493
+ )
494
+ return "\n".join(lines)
495
+
496
+
497
+ if __name__ == "__main__":
498
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Core engine: scanning, migrations, (later) agent, sandbox, verifier."""