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.
- apifix/__init__.py +3 -0
- apifix/api/__init__.py +1 -0
- apifix/cli.py +498 -0
- apifix/engine/__init__.py +1 -0
- apifix/engine/agent.py +3058 -0
- apifix/engine/agent_tools.py +328 -0
- apifix/engine/already_migrated.py +107 -0
- apifix/engine/build_info.py +45 -0
- apifix/engine/cleanup.py +187 -0
- apifix/engine/code_checks.py +1829 -0
- apifix/engine/context.py +257 -0
- apifix/engine/dependencies.py +1014 -0
- apifix/engine/diffing.py +130 -0
- apifix/engine/docker.py +202 -0
- apifix/engine/github.py +416 -0
- apifix/engine/gitops.py +140 -0
- apifix/engine/imports.py +239 -0
- apifix/engine/llm.py +334 -0
- apifix/engine/llm_anthropic.py +241 -0
- apifix/engine/llm_openai_compat.py +901 -0
- apifix/engine/local_paths.py +59 -0
- apifix/engine/migrations/__init__.py +155 -0
- apifix/engine/migrations/guide.py +44 -0
- apifix/engine/migrations/openai_v1/__init__.py +186 -0
- apifix/engine/migrations/openai_v1/config_checks.py +271 -0
- apifix/engine/migrations/openai_v1/guide.md +300 -0
- apifix/engine/migrations/openai_v1/guide_parts.py +91 -0
- apifix/engine/migrations/openai_v1/python_checks.py +1050 -0
- apifix/engine/migrations/openai_v1/repo_findings.py +83 -0
- apifix/engine/migrations/openai_v1/rules.py +620 -0
- apifix/engine/migrations/openai_v1/streaming.py +325 -0
- apifix/engine/models.py +110 -0
- apifix/engine/pin_edits.py +876 -0
- apifix/engine/pr_body.py +488 -0
- apifix/engine/project.py +1153 -0
- apifix/engine/pull_request.py +411 -0
- apifix/engine/regions.py +1020 -0
- apifix/engine/sandbox.py +1002 -0
- apifix/engine/sandbox_driver.py +215 -0
- apifix/engine/scanner.py +133 -0
- apifix/engine/scope.py +119 -0
- apifix/engine/suspicious.py +229 -0
- apifix/engine/tampering.py +550 -0
- apifix/engine/test_results.py +223 -0
- apifix/engine/unrelated.py +1150 -0
- apifix/engine/verifier.py +1682 -0
- apifix/engine/verify_diff.py +256 -0
- apifix/engine/walk.py +77 -0
- apifix/engine/whole_file.py +786 -0
- apifix/worker/__init__.py +1 -0
- apifix-0.1.0.dist-info/METADATA +107 -0
- apifix-0.1.0.dist-info/RECORD +55 -0
- apifix-0.1.0.dist-info/WHEEL +4 -0
- apifix-0.1.0.dist-info/entry_points.txt +2 -0
- apifix-0.1.0.dist-info/licenses/LICENSE +21 -0
apifix/__init__.py
ADDED
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."""
|