patchpilot 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.
patchpilot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """patchpilot: an AI agent that upgrades dependencies and fixes what breaks."""
2
+
3
+ __version__ = "0.1.0"
patchpilot/agent.py ADDED
@@ -0,0 +1,90 @@
1
+ """The agent loop: send the conversation to Claude, run the tools it asks for, repeat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ import anthropic
8
+
9
+ from patchpilot.tools import TOOL_FUNCTIONS, ToolError, Workspace
10
+
11
+ MODEL = "claude-opus-5-5"
12
+
13
+ # Anthropic-hosted tool: Claude fetches URLs (like changelogs) itself, no code needed on our side.
14
+ # It only fetches URLs that already appear in the conversation, e.g. from get_latest_version.
15
+ WEB_FETCH_TOOL = {"type": "web_fetch_20260209", "name": "web_fetch", "max_uses": 8}
16
+
17
+
18
+ def execute_tool(ws: Workspace, name: str, tool_input: dict) -> tuple[str, bool]:
19
+ """Run one tool. Returns (result_text, is_error)."""
20
+ func = TOOL_FUNCTIONS.get(name)
21
+ if func is None:
22
+ return f"Unknown tool '{name}'.", True
23
+ try:
24
+ return func(ws, **tool_input), False
25
+ except ToolError as e:
26
+ return str(e), True
27
+ except TypeError as e:
28
+ return f"Invalid arguments for '{name}': {e}", True
29
+
30
+
31
+ def run_agent(
32
+ ws: Workspace,
33
+ system: str,
34
+ task: str,
35
+ tools: list[dict],
36
+ max_turns: int = 30,
37
+ client: anthropic.Anthropic | None = None,
38
+ on_tool_call: Callable[[str, dict], None] | None = None,
39
+ ) -> str:
40
+ """Run the agent until it finishes and return its final text."""
41
+ client = client or anthropic.Anthropic()
42
+ allowed = {t["name"] for t in tools if "input_schema" in t}
43
+ messages: list[dict] = [{"role": "user", "content": task}]
44
+
45
+ for _ in range(max_turns):
46
+ response = client.beta.messages.create(
47
+ model=MODEL,
48
+ max_tokens=16000,
49
+ system=system,
50
+ tools=tools,
51
+ messages=messages,
52
+ output_config={"effort": "high"},
53
+ # If a request is declined by a safety classifier, retry on the server's default
54
+ # fallback model instead of failing outright.
55
+ betas=["server-side-fallback-2026-07-01"],
56
+ fallbacks="default",
57
+ )
58
+ messages.append({"role": "assistant", "content": response.content})
59
+
60
+ if response.stop_reason == "refusal":
61
+ raise RuntimeError("Claude declined this request.")
62
+ if response.stop_reason == "max_tokens":
63
+ raise RuntimeError("Response hit max_tokens before finishing.")
64
+ if response.stop_reason == "pause_turn":
65
+ continue
66
+
67
+ tool_uses = [b for b in response.content if b.type == "tool_use"]
68
+ if not tool_uses:
69
+ return "\n".join(b.text for b in response.content if b.type == "text")
70
+
71
+ tool_results = []
72
+ for block in tool_uses:
73
+ if on_tool_call:
74
+ on_tool_call(block.name, block.input)
75
+ if block.name in allowed:
76
+ result, is_error = execute_tool(ws, block.name, block.input)
77
+ else:
78
+ result, is_error = f"Tool '{block.name}' is not available here.", True
79
+ tool_results.append(
80
+ {
81
+ "type": "tool_result",
82
+ "tool_use_id": block.id,
83
+ "content": result,
84
+ "is_error": is_error,
85
+ }
86
+ )
87
+ # All results for one turn go back in a single user message.
88
+ messages.append({"role": "user", "content": tool_results})
89
+
90
+ raise RuntimeError(f"Agent did not finish within {max_turns} turns.")
patchpilot/analyze.py ADDED
@@ -0,0 +1,36 @@
1
+ """`patchpilot analyze`: a read-only report of what could be upgraded."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from patchpilot.agent import run_agent
8
+ from patchpilot.tools import READ_TOOL_SCHEMAS, Workspace
9
+
10
+ SYSTEM_PROMPT = """\
11
+ You are patchpilot, an agent that keeps a Python project's dependencies up to date.
12
+
13
+ Your job right now is to produce an upgrade report. Use the tools to:
14
+ 1. Read the project's declared dependencies.
15
+ 2. Look up the latest version of each one on PyPI.
16
+ 3. Run the test suite to establish a baseline.
17
+
18
+ Then finish with a concise report in Markdown:
19
+ - A table of dependencies: name, current specifier, latest version, and whether the
20
+ current specifier already allows the latest version.
21
+ - Which upgrades look like major-version jumps that are likely to need code changes.
22
+ - The baseline test result.
23
+ - A recommended order to upgrade in, with one line of reasoning each.
24
+
25
+ You can only read and run tests in this phase; do not suggest that you changed any files.\
26
+ """
27
+
28
+
29
+ def analyze(ws: Workspace, on_tool_call: Callable[[str, dict], None] | None = None) -> str:
30
+ return run_agent(
31
+ ws,
32
+ system=SYSTEM_PROMPT,
33
+ task=f"Analyze the project at {ws.root} and write the report.",
34
+ tools=READ_TOOL_SCHEMAS,
35
+ on_tool_call=on_tool_call,
36
+ )
patchpilot/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """Command-line entry point.
2
+
3
+ patchpilot analyze <project> read-only upgrade report
4
+ patchpilot upgrade <project> <package> upgrade one package and fix what breaks
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import shlex
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import anthropic
16
+ from dotenv import find_dotenv, load_dotenv
17
+
18
+ from patchpilot.agent import MODEL
19
+ from patchpilot.analyze import analyze
20
+ from patchpilot.github import GitHubError
21
+ from patchpilot.sandbox import DEFAULT_IMAGE, SandboxError
22
+ from patchpilot.tools import Workspace
23
+ from patchpilot.upgrade import UpgradeError, upgrade
24
+
25
+
26
+ def log_tool_call(name: str, tool_input: dict) -> None:
27
+ # File contents can be long; show just the path for writes.
28
+ shown = {k: v for k, v in tool_input.items() if k not in ("content", "old_text", "new_text")}
29
+ print(f" → {name} {json.dumps(shown)}", file=sys.stderr)
30
+
31
+
32
+ def explain_api_error(e: anthropic.AnthropicError) -> str:
33
+ """Turn an Anthropic SDK error into one sentence the user can act on."""
34
+ detail = str(e)
35
+ if isinstance(e, anthropic.APIStatusError) and isinstance(e.body, dict):
36
+ detail = e.body.get("error", {}).get("message", detail)
37
+
38
+ if isinstance(e, anthropic.BadRequestError) and "credit balance" in detail:
39
+ return (
40
+ "Your Anthropic account is out of credit. Add some at "
41
+ "https://console.anthropic.com/settings/billing and run patchpilot again."
42
+ )
43
+ if isinstance(e, anthropic.AuthenticationError):
44
+ return (
45
+ "Your Anthropic API key was rejected. Check ANTHROPIC_API_KEY in your .env file "
46
+ "(keys are at https://console.anthropic.com/settings/keys)."
47
+ )
48
+ if isinstance(e, anthropic.PermissionDeniedError):
49
+ return f"Your API key doesn't have access to {MODEL}: {detail}"
50
+ if isinstance(e, anthropic.RateLimitError):
51
+ return "The Anthropic API is rate-limiting this key. Wait a minute and try again."
52
+ if isinstance(e, anthropic.APIConnectionError):
53
+ return "Couldn't reach the Anthropic API. Check your internet connection and try again."
54
+ if isinstance(e, anthropic.APIStatusError):
55
+ return f"The Anthropic API returned an error ({e.status_code}): {detail}"
56
+ # e.g. no API key configured at all
57
+ return f"Couldn't call the Anthropic API: {detail}"
58
+
59
+
60
+ def log_status(message: str) -> None:
61
+ print(f"• {message}", file=sys.stderr)
62
+
63
+
64
+ def main(argv: list[str] | None = None) -> int:
65
+ parser = argparse.ArgumentParser(
66
+ prog="patchpilot",
67
+ description="An AI agent that upgrades your dependencies and fixes what breaks.",
68
+ )
69
+ sub = parser.add_subparsers(dest="command", required=True)
70
+
71
+ analyze_p = sub.add_parser("analyze", help="Report what could be upgraded (changes nothing).")
72
+ analyze_p.add_argument("project", type=Path, help="Path to the project.")
73
+
74
+ upgrade_p = sub.add_parser(
75
+ "upgrade", help="Upgrade one package, fix the code, and commit it to a new branch."
76
+ )
77
+ upgrade_p.add_argument("project", type=Path, help="Path to the project (inside a git repo).")
78
+ upgrade_p.add_argument("package", help="Name of the package to upgrade, e.g. pydantic.")
79
+ upgrade_p.add_argument(
80
+ "--image",
81
+ default=DEFAULT_IMAGE,
82
+ help="Docker image to run the project in; pick one matching your Python version "
83
+ "(default: %(default)s).",
84
+ )
85
+ upgrade_p.add_argument(
86
+ "--pr",
87
+ action="store_true",
88
+ help="Push the branch and open a GitHub pull request (needs the gh CLI, logged in).",
89
+ )
90
+ upgrade_p.add_argument(
91
+ "--no-docker",
92
+ action="store_true",
93
+ help="Run in a local virtualenv instead of Docker. Faster, but the project's code "
94
+ "runs directly on your machine.",
95
+ )
96
+
97
+ for p in (analyze_p, upgrade_p):
98
+ p.add_argument(
99
+ "--test-command",
100
+ default="python -m pytest -q",
101
+ help="Command that runs the project's tests (default: %(default)s).",
102
+ )
103
+
104
+ args = parser.parse_args(argv)
105
+ project = args.project.expanduser().resolve()
106
+ if not project.is_dir():
107
+ parser.error(f"{project} is not a directory")
108
+ test_command = shlex.split(args.test_command)
109
+
110
+ # Look for .env in the directory patchpilot is run from, not where it's installed.
111
+ load_dotenv(find_dotenv(usecwd=True))
112
+
113
+ try:
114
+ if args.command == "analyze":
115
+ ws = Workspace(root=project, test_command=test_command)
116
+ print(analyze(ws, on_tool_call=log_tool_call))
117
+ return 0
118
+
119
+ result = upgrade(
120
+ project,
121
+ args.package,
122
+ test_command=test_command,
123
+ on_status=log_status,
124
+ on_tool_call=log_tool_call,
125
+ use_docker=not args.no_docker,
126
+ image=args.image,
127
+ open_pr=args.pr,
128
+ )
129
+ except (RuntimeError, UpgradeError, SandboxError, GitHubError) as e:
130
+ print(f"patchpilot: {e}", file=sys.stderr)
131
+ return 1
132
+ except anthropic.AnthropicError as e:
133
+ print(f"patchpilot: {explain_api_error(e)}", file=sys.stderr)
134
+ return 1
135
+
136
+ print(result.report)
137
+ print(file=sys.stderr)
138
+ if not result.committed:
139
+ log_status("The agent didn't change any files, so no branch was created.")
140
+ return 1
141
+ verdict = "✅ tests pass" if result.tests_passed else "❌ tests still failing"
142
+ log_status(f"Committed to branch {result.branch} ({verdict})")
143
+ if result.pr_url:
144
+ log_status(f"Pull request: {result.pr_url}")
145
+ else:
146
+ log_status(f"Review it with: git diff HEAD...{result.branch}")
147
+ return 0 if result.tests_passed else 1
148
+
149
+
150
+ if __name__ == "__main__":
151
+ sys.exit(main())
patchpilot/github.py ADDED
@@ -0,0 +1,83 @@
1
+ """Push the upgrade branch and open a pull request, using the GitHub CLI (`gh`).
2
+
3
+ Going through `gh` means patchpilot never handles a GitHub token itself: it uses
4
+ whatever account the user already logged in with (`gh auth login`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+
14
+ class GitHubError(Exception):
15
+ """Pushing or opening the PR failed; the message is meant for the user."""
16
+
17
+
18
+ def _run(cmd: list[str], cwd: Path) -> str:
19
+ proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
20
+ if proc.returncode != 0:
21
+ raise GitHubError(f"`{' '.join(cmd[:3])}` failed:\n{proc.stderr.strip()}")
22
+ return proc.stdout.strip()
23
+
24
+
25
+ def check_ready(repo_root: Path, branch: str, remote: str = "origin") -> str:
26
+ """Check everything a PR needs before any work starts. Returns the base branch."""
27
+ if shutil.which("gh") is None:
28
+ raise GitHubError(
29
+ "--pr needs the GitHub CLI. Install it (brew install gh) and run: gh auth login"
30
+ )
31
+ if subprocess.run(["gh", "auth", "status"], capture_output=True).returncode != 0:
32
+ raise GitHubError("You're not logged in to the GitHub CLI. Run: gh auth login")
33
+
34
+ remotes = _run(["git", "remote"], repo_root).split()
35
+ if remote not in remotes:
36
+ raise GitHubError(
37
+ f"This repo has no '{remote}' remote, so there's nowhere to open a PR. "
38
+ "Push it to GitHub first."
39
+ )
40
+
41
+ base = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], repo_root)
42
+ if base == "HEAD":
43
+ raise GitHubError("You're in 'detached HEAD' state. Check out a branch first.")
44
+ on_remote = subprocess.run(
45
+ ["git", "ls-remote", "--exit-code", "--heads", remote, base],
46
+ cwd=repo_root,
47
+ capture_output=True,
48
+ )
49
+ if on_remote.returncode != 0:
50
+ raise GitHubError(
51
+ f"Branch '{base}' isn't on GitHub yet, so a PR can't target it. "
52
+ f"Push it first: git push -u {remote} {base}"
53
+ )
54
+ taken = subprocess.run(
55
+ ["git", "ls-remote", "--exit-code", "--heads", remote, branch],
56
+ cwd=repo_root,
57
+ capture_output=True,
58
+ )
59
+ if taken.returncode == 0:
60
+ raise GitHubError(
61
+ f"Branch '{branch}' already exists on GitHub (probably an earlier patchpilot PR). "
62
+ f"Merge or close that PR and delete the branch first."
63
+ )
64
+ return base
65
+
66
+
67
+ def open_pull_request(
68
+ repo_root: Path,
69
+ branch: str,
70
+ base: str,
71
+ title: str,
72
+ body: str,
73
+ draft: bool,
74
+ remote: str = "origin",
75
+ ) -> str:
76
+ """Push the branch and open a PR. Returns the PR's URL."""
77
+ _run(["git", "push", "--quiet", "--set-upstream", remote, branch], repo_root)
78
+ cmd = ["gh", "pr", "create", "--head", branch, "--base", base, "--title", title]
79
+ cmd += ["--body", body]
80
+ if draft:
81
+ cmd.append("--draft")
82
+ # gh prints the new PR's URL as the last line of its output.
83
+ return _run(cmd, repo_root).splitlines()[-1]
patchpilot/sandbox.py ADDED
@@ -0,0 +1,114 @@
1
+ """Where the project's own code runs: a Docker container (default) or a local virtualenv.
2
+
3
+ Only commands run here: installing packages and running the test suite. File reads and
4
+ edits happen on the host, inside the temporary worktree, and the agent loop (with the API
5
+ key) never enters the sandbox.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import uuid
15
+ from pathlib import Path
16
+
17
+ DEFAULT_IMAGE = "python:3.13-slim"
18
+ CONTAINER_ROOT = "/workspace"
19
+ CONTAINER_VENV = "/tmp/venv"
20
+
21
+
22
+ class SandboxError(Exception):
23
+ """The sandbox couldn't be created; the message is meant for the user."""
24
+
25
+
26
+ class LocalSandbox:
27
+ """A fresh virtualenv on the host. No isolation beyond Python packages."""
28
+
29
+ def __init__(self, project: Path, venv_dir: Path):
30
+ self.project = project
31
+ self.venv_dir = venv_dir
32
+ self.python = str(venv_dir / "bin" / "python")
33
+
34
+ def __enter__(self) -> LocalSandbox:
35
+ subprocess.run([sys.executable, "-m", "venv", str(self.venv_dir)], check=True)
36
+ return self
37
+
38
+ def __exit__(self, *exc) -> None:
39
+ shutil.rmtree(self.venv_dir, ignore_errors=True)
40
+
41
+ def run(self, cmd: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
42
+ return subprocess.run(
43
+ cmd, cwd=self.project, capture_output=True, text=True, timeout=timeout
44
+ )
45
+
46
+
47
+ class DockerSandbox:
48
+ """A locked-down container that can see only the temporary worktree.
49
+
50
+ - mounts the worktree at /workspace and nothing else from the host
51
+ - runs as the host user (not root), with all Linux capabilities dropped
52
+ - caps memory, CPU and process count so runaway tests can't take down the machine
53
+ """
54
+
55
+ def __init__(self, worktree: Path, project_rel: Path, image: str = DEFAULT_IMAGE):
56
+ self.worktree = worktree
57
+ rel = project_rel.as_posix()
58
+ self.workdir = CONTAINER_ROOT if rel == "." else f"{CONTAINER_ROOT}/{rel}"
59
+ self.image = image
60
+ self.name = f"patchpilot-{uuid.uuid4().hex[:12]}"
61
+ self.python = f"{CONTAINER_VENV}/bin/python"
62
+
63
+ @staticmethod
64
+ def check_available() -> None:
65
+ if shutil.which("docker") is None:
66
+ raise SandboxError(
67
+ "Docker isn't installed. Install Docker Desktop, or run with --no-docker "
68
+ "to use a local virtualenv instead (less isolated)."
69
+ )
70
+ info = subprocess.run(["docker", "info"], capture_output=True, text=True)
71
+ if info.returncode != 0:
72
+ raise SandboxError(
73
+ "Docker is installed but not running. Start Docker Desktop and try again."
74
+ )
75
+
76
+ def __enter__(self) -> DockerSandbox:
77
+ self.check_available()
78
+ cmd = [
79
+ "docker", "run", "--detach", "--rm",
80
+ "--name", self.name,
81
+ "--volume", f"{self.worktree}:{CONTAINER_ROOT}",
82
+ "--workdir", self.workdir,
83
+ "--user", f"{os.getuid()}:{os.getgid()}",
84
+ "--env", "HOME=/tmp",
85
+ "--env", "PIP_DISABLE_PIP_VERSION_CHECK=1",
86
+ "--cap-drop", "ALL",
87
+ "--security-opt", "no-new-privileges",
88
+ "--memory", "2g",
89
+ "--cpus", "2",
90
+ "--pids-limit", "512",
91
+ self.image,
92
+ "sleep", "infinity",
93
+ ] # fmt: skip
94
+ proc = subprocess.run(cmd, capture_output=True, text=True)
95
+ if proc.returncode != 0:
96
+ raise SandboxError(f"Couldn't start the Docker sandbox:\n{proc.stderr.strip()}")
97
+ venv = self.run(["python", "-m", "venv", CONTAINER_VENV], timeout=120)
98
+ if venv.returncode != 0:
99
+ self.__exit__()
100
+ raise SandboxError(f"Couldn't create a virtualenv in the sandbox:\n{venv.stderr}")
101
+ return self
102
+
103
+ def __exit__(self, *exc) -> None:
104
+ subprocess.run(["docker", "rm", "--force", self.name], capture_output=True)
105
+
106
+ def run(self, cmd: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
107
+ # `timeout` inside the container kills the command itself; killing only the
108
+ # `docker exec` client would leave it running in the container.
109
+ return subprocess.run(
110
+ ["docker", "exec", self.name, "timeout", str(timeout), *cmd],
111
+ capture_output=True,
112
+ text=True,
113
+ timeout=timeout + 30,
114
+ )
patchpilot/tools.py ADDED
@@ -0,0 +1,331 @@
1
+ """Tools the agent can call, plus the JSON schemas that describe them to Claude.
2
+
3
+ Every tool works inside a single project directory (the "workspace"). Paths the
4
+ model sends are resolved against that directory and rejected if they escape it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import subprocess
12
+ import tomllib
13
+ import urllib.error
14
+ import urllib.request
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Protocol
18
+
19
+ MAX_OUTPUT_CHARS = 20_000
20
+ IGNORED_DIRS = {".git", ".venv", "venv", "__pycache__", "node_modules", ".pytest_cache"}
21
+
22
+ # Matches "name", "name==1.2", "name[extra]>=1.0,<2", etc. Group 1 is the name, group 3 the spec.
23
+ REQUIREMENT_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*([^;#]*)")
24
+ # What pip_install accepts: a name, optional extras, optional version specifier. No flags or URLs.
25
+ INSTALLABLE_RE = re.compile(
26
+ r"[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9._,-]+\])?([<>=!~]=?[0-9A-Za-z.*,<>=!~]+)?"
27
+ )
28
+
29
+
30
+ class ToolError(Exception):
31
+ """A tool failed in a way the model should hear about and can recover from."""
32
+
33
+
34
+ class Sandbox(Protocol):
35
+ """Somewhere to run the project's commands (see patchpilot.sandbox)."""
36
+
37
+ python: str
38
+
39
+ def run(self, cmd: list[str], timeout: int) -> subprocess.CompletedProcess[str]: ...
40
+
41
+
42
+ @dataclass
43
+ class Workspace:
44
+ root: Path
45
+ test_command: list[str]
46
+ # Where commands run in upgrade mode. None runs them directly in `root`, using whatever
47
+ # Python is on PATH (analyze mode, which never installs anything).
48
+ sandbox: Sandbox | None = None
49
+ changed_files: set[str] = field(default_factory=set)
50
+
51
+ def resolve(self, relative_path: str) -> Path:
52
+ path = (self.root / relative_path).resolve()
53
+ if not path.is_relative_to(self.root.resolve()):
54
+ raise ToolError(f"Path '{relative_path}' is outside the project directory.")
55
+ return path
56
+
57
+ def command(self, cmd: list[str]) -> list[str]:
58
+ """Point a leading `python` at the sandbox's own interpreter."""
59
+ if self.sandbox and cmd and cmd[0] in ("python", "python3"):
60
+ return [self.sandbox.python, *cmd[1:]]
61
+ return cmd
62
+
63
+ def run(self, cmd: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
64
+ cmd = self.command(cmd)
65
+ try:
66
+ if self.sandbox:
67
+ proc = self.sandbox.run(cmd, timeout)
68
+ else:
69
+ proc = subprocess.run(
70
+ cmd, cwd=self.root, capture_output=True, text=True, timeout=timeout
71
+ )
72
+ except FileNotFoundError as e:
73
+ raise ToolError(f"Command not found: {cmd[0]}") from e
74
+ except subprocess.TimeoutExpired as e:
75
+ raise ToolError(f"Command timed out after {timeout} seconds.") from e
76
+ if self.sandbox and proc.returncode == 124: # exit code of coreutils `timeout`
77
+ raise ToolError(f"Command timed out after {timeout} seconds.")
78
+ return proc
79
+
80
+
81
+ def _truncate(text: str) -> str:
82
+ if len(text) <= MAX_OUTPUT_CHARS:
83
+ return text
84
+ # Test failures usually matter most at the end of the output, so keep the tail.
85
+ dropped = len(text) - MAX_OUTPUT_CHARS
86
+ return f"[... {dropped} characters truncated ...]\n" + text[-MAX_OUTPUT_CHARS:]
87
+
88
+
89
+ def _format_result(proc: subprocess.CompletedProcess[str]) -> str:
90
+ status = "PASSED" if proc.returncode == 0 else f"FAILED (exit code {proc.returncode})"
91
+ return _truncate(f"{status}\n\n--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}")
92
+
93
+
94
+ def list_files(ws: Workspace, directory: str = ".") -> str:
95
+ base = ws.resolve(directory)
96
+ if not base.is_dir():
97
+ raise ToolError(f"'{directory}' is not a directory.")
98
+ files = []
99
+ for path in sorted(base.rglob("*")):
100
+ rel = path.relative_to(ws.root.resolve())
101
+ if any(part in IGNORED_DIRS for part in rel.parts) or not path.is_file():
102
+ continue
103
+ files.append(str(rel))
104
+ return "\n".join(files) if files else "(no files)"
105
+
106
+
107
+ def read_file(ws: Workspace, path: str) -> str:
108
+ target = ws.resolve(path)
109
+ if not target.is_file():
110
+ raise ToolError(f"File '{path}' does not exist.")
111
+ return _truncate(target.read_text(errors="replace"))
112
+
113
+
114
+ def _writable(ws: Workspace, path: str) -> Path:
115
+ target = ws.resolve(path)
116
+ rel = target.relative_to(ws.root.resolve())
117
+ if any(part in IGNORED_DIRS for part in rel.parts):
118
+ raise ToolError(f"Refusing to write inside '{rel.parts[0]}'.")
119
+ return target
120
+
121
+
122
+ def write_file(ws: Workspace, path: str, content: str) -> str:
123
+ target = _writable(ws, path)
124
+ target.parent.mkdir(parents=True, exist_ok=True)
125
+ target.write_text(content)
126
+ ws.changed_files.add(path)
127
+ return f"Wrote {len(content)} characters to {path}."
128
+
129
+
130
+ def edit_file(ws: Workspace, path: str, old_text: str, new_text: str) -> str:
131
+ target = _writable(ws, path)
132
+ if not target.is_file():
133
+ raise ToolError(f"File '{path}' does not exist. Use write_file to create it.")
134
+ text = target.read_text()
135
+ count = text.count(old_text)
136
+ if count == 0:
137
+ raise ToolError(f"old_text was not found in {path}. Read the file and copy it exactly.")
138
+ if count > 1:
139
+ raise ToolError(f"old_text appears {count} times in {path}. Include more context.")
140
+ target.write_text(text.replace(old_text, new_text))
141
+ ws.changed_files.add(path)
142
+ return f"Edited {path}."
143
+
144
+
145
+ def parse_requirements(text: str) -> list[dict[str, str]]:
146
+ deps = []
147
+ for line in text.splitlines():
148
+ line = line.strip()
149
+ if not line or line.startswith(("#", "-")):
150
+ continue
151
+ match = REQUIREMENT_RE.match(line)
152
+ if match:
153
+ deps.append({"name": match.group(1), "spec": match.group(3).strip()})
154
+ return deps
155
+
156
+
157
+ def normalize_name(name: str) -> str:
158
+ """PyPI treats 'Foo_Bar', 'foo-bar' and 'foo.bar' as the same package (PEP 503)."""
159
+ return re.sub(r"[-_.]+", "-", name).lower()
160
+
161
+
162
+ def collect_dependencies(root: Path) -> dict[str, list[dict[str, str]]]:
163
+ """Declared dependencies from requirements*.txt and pyproject.toml, keyed by source."""
164
+ found: dict[str, list[dict[str, str]]] = {}
165
+ root = root.resolve()
166
+
167
+ for req_file in sorted(root.glob("requirements*.txt")):
168
+ found[req_file.name] = parse_requirements(req_file.read_text())
169
+
170
+ pyproject = root / "pyproject.toml"
171
+ if pyproject.is_file():
172
+ data = tomllib.loads(pyproject.read_text())
173
+ project = data.get("project", {})
174
+ found["pyproject.toml [project.dependencies]"] = parse_requirements(
175
+ "\n".join(project.get("dependencies", []))
176
+ )
177
+ for group, reqs in project.get("optional-dependencies", {}).items():
178
+ found[f"pyproject.toml [optional-dependencies.{group}]"] = parse_requirements(
179
+ "\n".join(reqs)
180
+ )
181
+ return found
182
+
183
+
184
+ def read_dependencies(ws: Workspace) -> str:
185
+ found = collect_dependencies(ws.root)
186
+ if not found:
187
+ raise ToolError("No requirements*.txt or pyproject.toml found in the project.")
188
+ return json.dumps(found, indent=2)
189
+
190
+
191
+ def get_latest_version(ws: Workspace, package: str) -> str:
192
+ """Look up the newest release of a package on PyPI."""
193
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", package):
194
+ raise ToolError(f"'{package}' is not a valid package name.")
195
+ url = f"https://pypi.org/pypi/{package}/json"
196
+ try:
197
+ with urllib.request.urlopen(url, timeout=15) as resp:
198
+ info = json.load(resp)["info"]
199
+ except urllib.error.HTTPError as e:
200
+ raise ToolError(f"PyPI returned {e.code} for '{package}'.") from e
201
+ except urllib.error.URLError as e:
202
+ raise ToolError(f"Could not reach PyPI: {e.reason}") from e
203
+ return json.dumps(
204
+ {
205
+ "name": info["name"],
206
+ "latest_version": info["version"],
207
+ "requires_python": info.get("requires_python"),
208
+ "project_urls": info.get("project_urls"),
209
+ },
210
+ indent=2,
211
+ )
212
+
213
+
214
+ def pip_install(ws: Workspace, requirement: str) -> str:
215
+ """Install one requirement (e.g. 'pydantic==2.9.2') into the sandbox's environment."""
216
+ if ws.sandbox is None:
217
+ raise ToolError("Installing packages is only available in upgrade mode.")
218
+ if not INSTALLABLE_RE.fullmatch(requirement):
219
+ raise ToolError(
220
+ f"'{requirement}' is not allowed. Pass a single requirement like 'name==1.2.3'."
221
+ )
222
+ return _format_result(ws.run(["python", "-m", "pip", "install", requirement], timeout=600))
223
+
224
+
225
+ def run_tests(ws: Workspace) -> str:
226
+ return _format_result(ws.run(ws.test_command, timeout=600))
227
+
228
+
229
+ TOOL_FUNCTIONS = {
230
+ "list_files": list_files,
231
+ "read_file": read_file,
232
+ "write_file": write_file,
233
+ "edit_file": edit_file,
234
+ "read_dependencies": read_dependencies,
235
+ "get_latest_version": get_latest_version,
236
+ "pip_install": pip_install,
237
+ "run_tests": run_tests,
238
+ }
239
+
240
+
241
+ def _schema(properties: dict, required: list[str]) -> dict:
242
+ return {
243
+ "type": "object",
244
+ "properties": properties,
245
+ "required": required,
246
+ "additionalProperties": False,
247
+ }
248
+
249
+
250
+ _PATH = {"type": "string", "description": "Path relative to the project root."}
251
+
252
+ READ_TOOL_SCHEMAS = [
253
+ {
254
+ "name": "list_files",
255
+ "description": "List project files recursively, skipping .git, virtualenvs and caches.",
256
+ "input_schema": _schema(
257
+ {
258
+ "directory": {
259
+ "type": "string",
260
+ "description": "Directory relative to the project root. Defaults to '.'.",
261
+ }
262
+ },
263
+ [],
264
+ ),
265
+ },
266
+ {
267
+ "name": "read_file",
268
+ "description": "Read a text file from the project.",
269
+ "input_schema": _schema({"path": _PATH}, ["path"]),
270
+ },
271
+ {
272
+ "name": "read_dependencies",
273
+ "description": (
274
+ "Return the project's declared dependencies and version specifiers from "
275
+ "requirements*.txt and pyproject.toml, grouped by source file."
276
+ ),
277
+ "input_schema": _schema({}, []),
278
+ },
279
+ {
280
+ "name": "get_latest_version",
281
+ "description": (
282
+ "Look up the latest released version of a package on PyPI, plus its project URLs "
283
+ "(often including a changelog link)."
284
+ ),
285
+ "input_schema": _schema(
286
+ {"package": {"type": "string", "description": "PyPI package name."}}, ["package"]
287
+ ),
288
+ },
289
+ {
290
+ "name": "run_tests",
291
+ "description": "Run the project's test suite and return pass/fail status plus output.",
292
+ "input_schema": _schema({}, []),
293
+ },
294
+ ]
295
+
296
+ WRITE_TOOL_SCHEMAS = [
297
+ {
298
+ "name": "edit_file",
299
+ "description": (
300
+ "Replace one exact occurrence of old_text with new_text in a file. old_text must "
301
+ "match the file exactly (including indentation) and appear only once."
302
+ ),
303
+ "input_schema": _schema(
304
+ {
305
+ "path": _PATH,
306
+ "old_text": {"type": "string", "description": "Exact text to replace."},
307
+ "new_text": {"type": "string", "description": "Replacement text."},
308
+ },
309
+ ["path", "old_text", "new_text"],
310
+ ),
311
+ },
312
+ {
313
+ "name": "write_file",
314
+ "description": "Create a file, or overwrite one completely. Prefer edit_file for changes.",
315
+ "input_schema": _schema(
316
+ {"path": _PATH, "content": {"type": "string", "description": "Full file content."}},
317
+ ["path", "content"],
318
+ ),
319
+ },
320
+ {
321
+ "name": "pip_install",
322
+ "description": (
323
+ "Install a single requirement into the project's isolated environment, "
324
+ "e.g. 'pydantic==2.9.2' or 'pydantic-settings'."
325
+ ),
326
+ "input_schema": _schema(
327
+ {"requirement": {"type": "string", "description": "One requirement specifier."}},
328
+ ["requirement"],
329
+ ),
330
+ },
331
+ ]
patchpilot/upgrade.py ADDED
@@ -0,0 +1,287 @@
1
+ """`patchpilot upgrade`: upgrade one package, fix what breaks, commit the result to a branch.
2
+
3
+ The user's own checkout is never modified. All work happens in a temporary git worktree
4
+ (a second checkout of the same repo), and the project's code only ever runs inside a
5
+ sandbox (see patchpilot.sandbox). Only the finished commit, on a new
6
+ `patchpilot/upgrade-<package>` branch, is left behind.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ import tomllib
16
+ from collections.abc import Callable
17
+ from contextlib import ExitStack
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ from patchpilot import github
22
+ from patchpilot.agent import WEB_FETCH_TOOL, run_agent
23
+ from patchpilot.sandbox import DEFAULT_IMAGE, DockerSandbox, LocalSandbox
24
+ from patchpilot.tools import (
25
+ IGNORED_DIRS,
26
+ READ_TOOL_SCHEMAS,
27
+ WRITE_TOOL_SCHEMAS,
28
+ Workspace,
29
+ collect_dependencies,
30
+ normalize_name,
31
+ run_tests,
32
+ )
33
+
34
+ DEFAULT_TEST_COMMAND = ["python", "-m", "pytest", "-q"]
35
+
36
+ SYSTEM_PROMPT = """\
37
+ You are patchpilot, an agent that upgrades one dependency in a Python project and fixes
38
+ the code that breaks because of it.
39
+
40
+ You are working in an isolated copy of the project with its own virtualenv. The test
41
+ suite passed before you started. Your job:
42
+ 1. Find out the latest version of the package and install it with pip_install.
43
+ 2. Update the declared version specifier in requirements*.txt or pyproject.toml so it
44
+ requires the new version.
45
+ 3. Run the tests. If they fail, work out why: read the failing code, and read the package's
46
+ changelog or migration guide with web_fetch (use URLs from get_latest_version).
47
+ 4. Fix the project's code with small, targeted edits, then run the tests again. Repeat
48
+ until they pass.
49
+
50
+ Rules:
51
+ - Only upgrade the requested package. If the new version moved features into a separate
52
+ package (for example pydantic-settings), install it and add it to the declared
53
+ dependencies too.
54
+ - Never delete, skip or weaken tests to make them pass. Change a test only when the test
55
+ itself uses an API that was removed, and keep what it checks the same.
56
+ - Keep changes minimal and in the existing code style.
57
+ - If you can't get the tests passing, stop and explain what's left.
58
+
59
+ When you're done, reply with only this Markdown report (it becomes the commit message and
60
+ the pull request description):
61
+
62
+ ## Summary
63
+ One or two sentences: what was upgraded, from which version to which.
64
+
65
+ ## Breaking changes
66
+ Each breaking change in the new version that affected this project, and how it was fixed.
67
+
68
+ ## Files changed
69
+ A bullet per file with a short description of the change.
70
+
71
+ ## Needs your attention
72
+ Anything you couldn't fix or a human should double-check. Write "Nothing." if none.\
73
+ """
74
+
75
+
76
+ class UpgradeError(Exception):
77
+ """Upgrade could not start or finish; the message is meant for the user."""
78
+
79
+
80
+ @dataclass
81
+ class UpgradeResult:
82
+ branch: str
83
+ tests_passed: bool
84
+ report: str
85
+ changed_files: list[str] = field(default_factory=list)
86
+ committed: bool = False
87
+ pr_url: str | None = None
88
+
89
+
90
+ def _git(*args: str, cwd: Path) -> str:
91
+ proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
92
+ if proc.returncode != 0:
93
+ raise UpgradeError(f"git {' '.join(args)} failed:\n{proc.stderr.strip()}")
94
+ return proc.stdout.strip()
95
+
96
+
97
+ def _pip(ws: Workspace, args: list[str]) -> None:
98
+ proc = ws.run(["python", "-m", "pip", "install", "-q", *args], timeout=900)
99
+ if proc.returncode != 0:
100
+ raise UpgradeError(f"Installing the project failed:\n{proc.stderr.strip()[-3000:]}")
101
+
102
+
103
+ def install_project(ws: Workspace) -> None:
104
+ """Install the project and its declared dependencies into the workspace's sandbox."""
105
+ project = ws.root
106
+ for req_file in sorted(project.glob("requirements*.txt")):
107
+ _pip(ws, ["-r", req_file.name])
108
+
109
+ pyproject = project / "pyproject.toml"
110
+ if pyproject.is_file() and "project" in tomllib.loads(pyproject.read_text()):
111
+ extras = tomllib.loads(pyproject.read_text())["project"].get("optional-dependencies", {})
112
+ target = f".[{','.join(extras)}]" if extras else "."
113
+ _pip(ws, ["-e", target])
114
+
115
+ if ws.test_command == DEFAULT_TEST_COMMAND:
116
+ if ws.run(["python", "-m", "pytest", "--version"], timeout=60).returncode != 0:
117
+ _pip(ws, ["pytest"])
118
+
119
+
120
+ def _declares(directory: Path, package: str) -> list[str]:
121
+ """Which of the directory's dependency files declare the package."""
122
+ wanted = normalize_name(package)
123
+ return [
124
+ source
125
+ for source, deps in collect_dependencies(directory).items()
126
+ if any(normalize_name(d["name"]) == wanted for d in deps)
127
+ ]
128
+
129
+
130
+ def check_declared(project: Path, package: str, repo_root: Path) -> None:
131
+ """Fail early, with a hint, if the package isn't a dependency of this project.
132
+
133
+ Otherwise the agent would run against a test suite that never exercises the package,
134
+ and "tests pass" would mean nothing.
135
+ """
136
+ if _declares(project, package):
137
+ return
138
+ elsewhere = []
139
+ for dep_file in sorted(
140
+ [*repo_root.rglob("requirements*.txt"), *repo_root.rglob("pyproject.toml")]
141
+ ):
142
+ rel = dep_file.relative_to(repo_root)
143
+ if any(part in IGNORED_DIRS for part in rel.parts) or dep_file.parent == project:
144
+ continue
145
+ if dep_file.parent not in elsewhere and _declares(dep_file.parent, package):
146
+ elsewhere.append(dep_file.parent)
147
+
148
+ message = f"{package} isn't a dependency of {project}."
149
+ if elsewhere:
150
+ dirs = [os.path.relpath(d) for d in elsewhere]
151
+ message += "\nIt's declared in: " + ", ".join(dirs)
152
+ message += f"\nTry: patchpilot upgrade {dirs[0]} {package}"
153
+ raise UpgradeError(message)
154
+
155
+
156
+ def upgrade(
157
+ project: Path,
158
+ package: str,
159
+ test_command: list[str] | None = None,
160
+ on_status: Callable[[str], None] = lambda _: None,
161
+ on_tool_call: Callable[[str, dict], None] | None = None,
162
+ use_docker: bool = True,
163
+ image: str = DEFAULT_IMAGE,
164
+ open_pr: bool = False,
165
+ ) -> UpgradeResult:
166
+ test_command = test_command or DEFAULT_TEST_COMMAND
167
+ project = project.resolve()
168
+ repo_root = Path(_git("rev-parse", "--show-toplevel", cwd=project)).resolve()
169
+ project_rel = project.relative_to(repo_root)
170
+ check_declared(project, package, repo_root)
171
+ branch = f"patchpilot/upgrade-{package.lower()}"
172
+
173
+ exists = subprocess.run(
174
+ ["git", "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=repo_root
175
+ )
176
+ if exists.returncode == 0:
177
+ raise UpgradeError(
178
+ f"Branch '{branch}' already exists. Delete it first: git branch -D {branch}"
179
+ )
180
+ if use_docker:
181
+ DockerSandbox.check_available()
182
+ # Check GitHub before spending anything on the agent, not after.
183
+ base = github.check_ready(repo_root, branch) if open_pr else None
184
+
185
+ tmp = Path(tempfile.mkdtemp(prefix="patchpilot-"))
186
+ worktree = tmp / "repo"
187
+ committed = False
188
+ with ExitStack() as cleanup:
189
+ cleanup.callback(_remove_worktree, repo_root, worktree, branch, lambda: committed, tmp)
190
+
191
+ on_status(f"Creating an isolated copy of the repo on branch {branch}")
192
+ _git("worktree", "add", "-q", "-b", branch, str(worktree), "HEAD", cwd=repo_root)
193
+ work_project = worktree / project_rel
194
+
195
+ if use_docker:
196
+ on_status(f"Starting a Docker sandbox ({image}) and installing the project")
197
+ sandbox = DockerSandbox(worktree, project_rel, image)
198
+ else:
199
+ on_status("Creating a local virtualenv and installing the project (no Docker)")
200
+ sandbox = LocalSandbox(work_project, tmp / "venv")
201
+ cleanup.enter_context(sandbox)
202
+
203
+ ws = Workspace(root=work_project, test_command=test_command, sandbox=sandbox)
204
+ install_project(ws)
205
+ on_status("Running the tests before upgrading")
206
+ baseline = run_tests(ws)
207
+ if not baseline.startswith("PASSED"):
208
+ raise UpgradeError(
209
+ "The tests fail before any upgrade, so patchpilot can't tell what the upgrade "
210
+ f"breaks. Fix them first.\n\n{baseline[-3000:]}"
211
+ )
212
+
213
+ on_status(f"Agent is upgrading {package}")
214
+ report = run_agent(
215
+ ws,
216
+ system=SYSTEM_PROMPT,
217
+ task=f"Upgrade '{package}' to its latest version in the project at {work_project}.",
218
+ tools=[*READ_TOOL_SCHEMAS, *WRITE_TOOL_SCHEMAS, WEB_FETCH_TOOL],
219
+ max_turns=60,
220
+ on_tool_call=on_tool_call,
221
+ )
222
+
223
+ on_status("Re-running the tests to verify the agent's work")
224
+ tests_passed = run_tests(ws).startswith("PASSED")
225
+ version = installed_version(ws, package)
226
+ result = UpgradeResult(
227
+ branch=branch,
228
+ tests_passed=tests_passed,
229
+ report=report,
230
+ changed_files=sorted(ws.changed_files),
231
+ )
232
+
233
+ if result.changed_files:
234
+ paths = [str(project_rel / p) for p in result.changed_files]
235
+ _git("add", "--", *paths, cwd=worktree)
236
+ title = f"Upgrade {package}" + (f" to {version}" if version else "")
237
+ title += "" if tests_passed else " (tests still failing)"
238
+ _git("commit", "-q", "-m", title, "-m", report, cwd=worktree)
239
+ result.committed = committed = True
240
+
241
+ if open_pr and result.committed:
242
+ on_status("Pushing the branch and opening a pull request")
243
+ where = f"in the Docker sandbox, {image}" if use_docker else "in a local virtualenv"
244
+ result.pr_url = github.open_pull_request(
245
+ repo_root,
246
+ branch,
247
+ base,
248
+ title,
249
+ pr_body(report, tests_passed, where),
250
+ draft=not tests_passed,
251
+ )
252
+ return result
253
+
254
+
255
+ def installed_version(ws: Workspace, package: str) -> str | None:
256
+ proc = ws.run(["python", "-m", "pip", "show", package], timeout=60)
257
+ for line in proc.stdout.splitlines():
258
+ if line.startswith("Version:"):
259
+ return line.split(":", 1)[1].strip()
260
+ return None
261
+
262
+
263
+ def pr_body(report: str, tests_passed: bool, sandbox: str) -> str:
264
+ verdict = (
265
+ "✅ The test suite passes."
266
+ if tests_passed
267
+ else "❌ The test suite still fails. This PR is a draft; see *Needs your attention*."
268
+ )
269
+ return (
270
+ f"{report}\n\n---\n\n"
271
+ f"**Verified by patchpilot:** after the agent finished, patchpilot re-ran the tests "
272
+ f"itself ({sandbox}). {verdict}\n\n"
273
+ "<sub>Opened by [patchpilot](https://github.com/ilmeedesilva/patchpilot), an AI agent "
274
+ "that upgrades dependencies and fixes what breaks. Review it like any other PR.</sub>"
275
+ )
276
+
277
+
278
+ def _remove_worktree(
279
+ repo_root: Path, worktree: Path, branch: str, committed: Callable[[], bool], tmp: Path
280
+ ) -> None:
281
+ subprocess.run(
282
+ ["git", "worktree", "remove", "--force", str(worktree)], cwd=repo_root, capture_output=True
283
+ )
284
+ # No commit means nothing worth reviewing, so don't leave an empty branch behind.
285
+ if not committed():
286
+ subprocess.run(["git", "branch", "-D", branch], cwd=repo_root, capture_output=True)
287
+ shutil.rmtree(tmp, ignore_errors=True)
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.5
2
+ Name: patchpilot
3
+ Version: 0.1.0
4
+ Summary: An AI agent that upgrades your dependencies, fixes what breaks, and opens a PR
5
+ Project-URL: Homepage, https://github.com/ilmeedesilva/patchpilot
6
+ Project-URL: Issues, https://github.com/ilmeedesilva/patchpilot/issues
7
+ Project-URL: Demo, https://github.com/ilmeedesilva/patchpilot-demo
8
+ Author: Ilmee De Silva
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agent,claude,dependencies,developer-tools,llm,upgrade
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Build Tools
22
+ Classifier: Topic :: Software Development :: Quality Assurance
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: anthropic>=2.3.0
25
+ Requires-Dist: python-dotenv>=1.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: build>=1.2; extra == 'dev'
28
+ Requires-Dist: pytest>=9; extra == 'dev'
29
+ Requires-Dist: ruff>=0.17; extra == 'dev'
30
+ Requires-Dist: twine>=6; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # patchpilot
34
+
35
+ [![CI](https://github.com/ilmeedesilva/patchpilot/actions/workflows/ci.yml/badge.svg)](https://github.com/ilmeedesilva/patchpilot/actions/workflows/ci.yml)
36
+ ![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)
37
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/ilmeedesilva/patchpilot/blob/main/LICENSE)
38
+
39
+ **An AI agent that upgrades a dependency, fixes the code that breaks, and opens a pull request explaining what changed and why.**
40
+
41
+ Dependabot and Renovate bump version numbers. When the new version breaks your build, the fix is still on you. patchpilot does that part: it upgrades the package inside a sandbox, runs your tests, reads the failures and the changelog, edits your code until the tests pass, and hands you a reviewable PR.
42
+
43
+ <!-- TODO: demo GIF here: `patchpilot upgrade . pydantic --pr` → PR appears -->
44
+
45
+ ```console
46
+ $ patchpilot upgrade . pydantic --pr
47
+ • Creating an isolated copy of the repo on branch patchpilot/upgrade-pydantic
48
+ • Starting a Docker sandbox (python:3.13-slim) and installing the project
49
+ • Running the tests before upgrading
50
+ • Agent is upgrading pydantic
51
+ → read_dependencies {}
52
+ → get_latest_version {"package": "pydantic"}
53
+ → pip_install {"requirement": "pydantic==2.14.1"}
54
+ → run_tests {} # fails: BaseSettings moved out of pydantic
55
+ → pip_install {"requirement": "pydantic-settings==2.16.2"}
56
+ → write_file {"path": "app/config.py"}
57
+ → write_file {"path": "app/models.py"}
58
+ → run_tests {} # passes
59
+ • Re-running the tests to verify the agent's work
60
+ • Pull request: https://github.com/<you>/<repo>/pull/1
61
+ ```
62
+
63
+ The PR description is written by the agent, and includes:
64
+
65
+ > **Breaking changes**
66
+ > - **`BaseSettings` moved to `pydantic-settings`**: importing it from `pydantic` now fails. Installed `pydantic-settings`, added it to `requirements.txt`, and now import from `pydantic_settings`.
67
+ > - **`@root_validator` without `skip_on_failure=True` is an error in v2**: replaced with `@model_validator(mode="after")`.
68
+ > - …
69
+ >
70
+ > **Needs your attention**
71
+ > - In v1 the password check ran even when another field had failed; in v2 it only runs once all fields are valid, so a bad email plus mismatched passwords now reports only the email error.
72
+
73
+ *(Trimmed output and report from a real run on [`examples/pydantic-demo`](https://github.com/ilmeedesilva/patchpilot/tree/main/examples/pydantic-demo).)*
74
+
75
+ ## Quick start
76
+
77
+ You need Python 3.11+, [Docker Desktop](https://www.docker.com/products/docker-desktop/) running, and an [Anthropic API key](https://console.anthropic.com/settings/keys).
78
+
79
+ ```bash
80
+ pipx install git+https://github.com/ilmeedesilva/patchpilot.git # soon: pipx install patchpilot
81
+ export ANTHROPIC_API_KEY=sk-ant-... # or put it in a .env file where you run patchpilot
82
+
83
+ cd your-project # a git repo with a test suite
84
+ patchpilot analyze . # read-only report: what's outdated, what's risky
85
+ patchpilot upgrade . pydantic # upgrade + fix, committed to a new branch
86
+ patchpilot upgrade . pydantic --pr # ...and open a pull request
87
+ ```
88
+
89
+ Each run uses Anthropic API credit. The amount depends on the size of the project and how much breaks.
90
+
91
+ ## Usage
92
+
93
+ ### `patchpilot analyze <project>`
94
+
95
+ Reads your dependency files, checks PyPI for the latest versions and runs your tests once, then prints a Markdown report. It covers what's outdated, which upgrades are major-version jumps likely to need code changes, and a suggested order. It changes nothing.
96
+
97
+ ### `patchpilot upgrade <project> <package>`
98
+
99
+ 1. Checks that `<package>` really is a dependency of `<project>`. If it's declared in a subfolder instead, it tells you which one.
100
+ 2. Makes a separate checkout of your repo (a git worktree) on a new branch, `patchpilot/upgrade-<package>`. **Your working copy is never touched.**
101
+ 3. Starts a Docker sandbox, installs your project into it, and checks that the tests pass *before* changing anything.
102
+ 4. Lets the agent work: upgrade the package, run the tests, read the failures and changelog, edit the code, and repeat.
103
+ 5. **Re-runs the tests itself** instead of trusting the agent's word, then commits to the branch with the agent's report as the commit message.
104
+
105
+ Review the result with `git diff HEAD...patchpilot/upgrade-<package>`, then merge it or delete it.
106
+
107
+ | Option | |
108
+ |---|---|
109
+ | `--pr` | Push the branch and open a GitHub pull request. If the tests still fail, it's opened as a **draft**. Uses the [GitHub CLI](https://cli.github.com/), so patchpilot never handles a GitHub token. |
110
+ | `--test-command "..."` | How to run your tests (default: `python -m pytest -q`). |
111
+ | `--image python:3.11-slim` | Docker image to run in. Match it to your project's Python version (default: `python:3.13-slim`). |
112
+ | `--no-docker` | Use a local virtualenv instead of Docker. Faster, but your project's code runs directly on your machine. |
113
+
114
+ ## How it works
115
+
116
+ ```mermaid
117
+ flowchart LR
118
+ subgraph host["Your machine"]
119
+ CLI["patchpilot CLI"] --> Loop["Agent loop"]
120
+ Loop <-->|"tool calls ⇄ results"| Claude["Claude API"]
121
+ Loop --> Files["Read / edit files<br/>in a temporary worktree"]
122
+ end
123
+ subgraph sandbox["Docker sandbox: sees only the worktree"]
124
+ Pip["pip install &lt;one package&gt;"]
125
+ Tests["your test suite"]
126
+ end
127
+ Loop --> Pip
128
+ Loop --> Tests
129
+ Loop -->|"verify, commit"| Branch["patchpilot/upgrade-&lt;pkg&gt;"]
130
+ Branch -->|"--pr"| PR["GitHub pull request"]
131
+ ```
132
+
133
+ At the centre is a hand-written [tool-use loop](https://github.com/ilmeedesilva/patchpilot/blob/main/src/patchpilot/agent.py). patchpilot sends Claude the task and a list of tools. Claude replies with the tool it wants to call, patchpilot runs it and sends back the result, and this repeats until Claude writes its final report. Claude decides *what* to do next; patchpilot's code is the only thing that *does* anything.
134
+
135
+ | Tool | Runs where | What it does |
136
+ |---|---|---|
137
+ | `read_dependencies` | host | Parses `requirements*.txt` and `pyproject.toml` |
138
+ | `get_latest_version` | host | Queries PyPI, including changelog URLs |
139
+ | `list_files`, `read_file` | host | Read-only, confined to the project folder |
140
+ | `edit_file`, `write_file` | host | Exact-match edits; refuses `.git/` and virtualenvs |
141
+ | `pip_install` | **sandbox** | One requirement at a time; flags, URLs and file paths are rejected |
142
+ | `run_tests` | **sandbox** | Your test command, with a timeout |
143
+ | `web_fetch` | Anthropic | Reads changelogs and migration guides |
144
+
145
+ ### Safety
146
+
147
+ Upgrading means running code that isn't yours: your tests, and whatever a package runs when it installs. So that code runs inside a Docker container that:
148
+
149
+ - mounts **only the temporary worktree**, not your home folder, SSH keys or other projects
150
+ - runs as your user, not root, with all Linux capabilities dropped and `no-new-privileges`
151
+ - is capped at 2 CPUs, 2 GB of memory and 512 processes
152
+ - is deleted when the run ends, even if the run fails
153
+
154
+ The agent loop and your API key never enter the container. These properties are checked by [integration tests](https://github.com/ilmeedesilva/patchpilot/blob/main/tests/test_sandbox.py), not just described here.
155
+
156
+ ### Design decisions
157
+
158
+ - **Verify, don't trust.** The agent says when it's done, but patchpilot re-runs the tests itself before committing. The ✅ in the output is patchpilot's verdict, not the model's.
159
+ - **Refuse to run when "tests pass" would mean nothing.** patchpilot stops if the tests already fail before the upgrade, or if the package isn't a dependency of the folder you pointed it at. Otherwise a green result could be meaningless.
160
+ - **Fail before spending, not after.** Docker, GitHub login, remotes and branch names are all checked before the first API call.
161
+ - **Leave nothing behind.** The worktree, container and virtualenv are cleaned up on success, failure or Ctrl-C. A branch only survives if it has a commit worth reviewing.
162
+ - **A small, explicit tool surface** instead of a general shell. It's easier to reason about what the agent can do, and easier to sandbox.
163
+
164
+ ## Limitations
165
+
166
+ - Python projects only, with `requirements*.txt` or a PEP 621 `pyproject.toml` (no Poetry or uv lockfiles yet).
167
+ - One package per run.
168
+ - The fix is only as good as your tests. If they don't cover what changed, patchpilot can't catch it, though the agent flags behaviour changes it notices under **Needs your attention**.
169
+ - The sandbox needs network access to install packages, so code inside it can reach the internet.
170
+
171
+ ## Roadmap
172
+
173
+ - [ ] Publish to PyPI (`pipx install patchpilot`)
174
+ - [ ] GitHub Action: run weekly and open PRs automatically
175
+ - [ ] Upgrade several packages in one run, ordered by risk
176
+ - [ ] Turn off the sandbox's network access while tests run
177
+ - [ ] uv and Poetry lockfile support
178
+
179
+ ## Development
180
+
181
+ ```bash
182
+ git clone git@github.com:ilmeedesilva/patchpilot.git && cd patchpilot
183
+ python3 -m venv .venv && source .venv/bin/activate
184
+ pip install -e ".[dev]"
185
+ pytest # Docker tests are skipped if Docker isn't running
186
+ ruff check . && ruff format --check .
187
+ ```
188
+
189
+ The test suite doesn't call the Anthropic API: the agent is replaced with a fake that makes known edits, and GitHub with a local bare repo plus a stand-in `gh`.
190
+
191
+ ## License
192
+
193
+ [MIT](https://github.com/ilmeedesilva/patchpilot/blob/main/LICENSE)
@@ -0,0 +1,13 @@
1
+ patchpilot/__init__.py,sha256=UBmfZOHxiEFyeFNtKSjPAinbxPgDIzrwL61976nrzAM,103
2
+ patchpilot/agent.py,sha256=LXESN7HH-R8R2FYI_roT268ln9YiSF07VOia54yDOnw,3374
3
+ patchpilot/analyze.py,sha256=Asa30FY7g1lU3FJICbrmJHyhx4ZC_bk63QJaChvWKNc,1336
4
+ patchpilot/cli.py,sha256=zVHOI3eXtJMks6CqjJYlPkQyoaffbwe1kQJYxpghqpw,5742
5
+ patchpilot/github.py,sha256=jl-6PzEJOyBsAFptr4OA4xVhL12invTRx_b5r_Pcmhk,2992
6
+ patchpilot/sandbox.py,sha256=_-i8WAwLbn7JR4LBhKRaKi4VTVlFH91f_L2BFVNf92I,4329
7
+ patchpilot/tools.py,sha256=eSCXFe7nXgvopqQmlBuWRtf4eoIDUFzkwwRYSG6h7EU,11987
8
+ patchpilot/upgrade.py,sha256=3CJJXGJxIFWXSVxzPhKp_zJujE681ztIs6qt5aNz_vw,11262
9
+ patchpilot-0.1.0.dist-info/METADATA,sha256=lPF--XCDpT2hDigld0nt6t2dgbLd8Xi4FXPjxsQgSOQ,10709
10
+ patchpilot-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ patchpilot-0.1.0.dist-info/entry_points.txt,sha256=E7vHakGW5WsfwwJOG4zOW730gZXQnm-GX2dDZwMz-Pg,51
12
+ patchpilot-0.1.0.dist-info/licenses/LICENSE,sha256=pQ5TGEEuo0j5qmeDIbgM6_iu5qmD-gCwLJ6YKcOx_X4,1071
13
+ patchpilot-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ patchpilot = patchpilot.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ilmee De Silva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.