driftfix 0.1.0__tar.gz

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 (43) hide show
  1. driftfix-0.1.0/.github/workflows/ci.yml +10 -0
  2. driftfix-0.1.0/.github/workflows/release.yml +13 -0
  3. driftfix-0.1.0/.gitignore +6 -0
  4. driftfix-0.1.0/LICENSE +21 -0
  5. driftfix-0.1.0/PKG-INFO +104 -0
  6. driftfix-0.1.0/README.md +91 -0
  7. driftfix-0.1.0/action.yml +66 -0
  8. driftfix-0.1.0/bench/RESULTS.md +8 -0
  9. driftfix-0.1.0/bench/cases/httpx/app.py +11 -0
  10. driftfix-0.1.0/bench/cases/httpx/case.json +1 -0
  11. driftfix-0.1.0/bench/cases/httpx/pytest.ini +2 -0
  12. driftfix-0.1.0/bench/cases/httpx/tests/test_app.py +14 -0
  13. driftfix-0.1.0/bench/cases/numpy/app.py +19 -0
  14. driftfix-0.1.0/bench/cases/numpy/case.json +1 -0
  15. driftfix-0.1.0/bench/cases/numpy/pytest.ini +2 -0
  16. driftfix-0.1.0/bench/cases/numpy/tests/test_app.py +17 -0
  17. driftfix-0.1.0/bench/cases/openai/app.py +9 -0
  18. driftfix-0.1.0/bench/cases/openai/case.json +1 -0
  19. driftfix-0.1.0/bench/cases/openai/pytest.ini +2 -0
  20. driftfix-0.1.0/bench/cases/openai/tests/test_app.py +43 -0
  21. driftfix-0.1.0/bench/cases/pydantic/app.py +33 -0
  22. driftfix-0.1.0/bench/cases/pydantic/case.json +1 -0
  23. driftfix-0.1.0/bench/cases/pydantic/pytest.ini +2 -0
  24. driftfix-0.1.0/bench/cases/pydantic/tests/test_app.py +16 -0
  25. driftfix-0.1.0/bench/cases/pydantic-shop/case.json +1 -0
  26. driftfix-0.1.0/bench/cases/pydantic-shop/pytest.ini +3 -0
  27. driftfix-0.1.0/bench/cases/pydantic-shop/shop/__init__.py +0 -0
  28. driftfix-0.1.0/bench/cases/pydantic-shop/shop/base.py +17 -0
  29. driftfix-0.1.0/bench/cases/pydantic-shop/shop/catalog.py +13 -0
  30. driftfix-0.1.0/bench/cases/pydantic-shop/shop/models.py +35 -0
  31. driftfix-0.1.0/bench/cases/pydantic-shop/shop/service.py +20 -0
  32. driftfix-0.1.0/bench/cases/pydantic-shop/tests/test_models.py +35 -0
  33. driftfix-0.1.0/bench/cases/pydantic-shop/tests/test_service.py +42 -0
  34. driftfix-0.1.0/bench/cases/sqlalchemy/app.py +29 -0
  35. driftfix-0.1.0/bench/cases/sqlalchemy/case.json +1 -0
  36. driftfix-0.1.0/bench/cases/sqlalchemy/pytest.ini +2 -0
  37. driftfix-0.1.0/bench/cases/sqlalchemy/tests/test_app.py +9 -0
  38. driftfix-0.1.0/bench/run.py +78 -0
  39. driftfix-0.1.0/pyproject.toml +21 -0
  40. driftfix-0.1.0/pytest.ini +2 -0
  41. driftfix-0.1.0/src/driftfix/__init__.py +0 -0
  42. driftfix-0.1.0/src/driftfix/cli.py +138 -0
  43. driftfix-0.1.0/tests/test_fix.py +62 -0
@@ -0,0 +1,10 @@
1
+ name: CI
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ steps:
7
+ - uses: actions/checkout@v4
8
+ - uses: actions/setup-python@v5
9
+ with: { python-version: "3.12" }
10
+ - run: pip install -e '.[dev]' && pytest -q tests
@@ -0,0 +1,13 @@
1
+ name: release
2
+ on:
3
+ release:
4
+ types: [published]
5
+ jobs:
6
+ pypi:
7
+ runs-on: ubuntu-latest
8
+ permissions: { id-token: write } # PyPI trusted publishing, no token needed
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: astral-sh/setup-uv@v6
12
+ - run: uv build
13
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
driftfix-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 driftfix contributors
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.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.5
2
+ Name: driftfix
3
+ Version: 0.1.0
4
+ Summary: Dependabot bumps it. CI breaks. driftfix fixes the code.
5
+ Project-URL: Repository, https://github.com/Gthejesraj/driftfix
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: claude-agent-sdk>=0.2
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # driftfix
15
+
16
+ **Dependabot bumps it. CI breaks. driftfix fixes the code.**
17
+
18
+ Dependency bots open the upgrade PR, then leave you to fix whatever broke.
19
+ driftfix finishes the job: it reads the failing tests, has Claude adapt your
20
+ code to the new version, reruns your tests itself, and pushes the fix onto
21
+ the same PR. If it can't get to green, it comments what it tried and commits
22
+ nothing.
23
+
24
+ ```
25
+ Dependabot PR (openai 0.28 → 1.x) → tests fail → Claude fixes code → tests rerun → ✅ commit on the PR
26
+ └→ ❌ comment only
27
+ ```
28
+
29
+ ## Benchmark
30
+
31
+ Real breaking upgrades, each a small app with tests that pass on the old version and fail on the new one ([`bench/`](bench/)):
32
+
33
+ | Package | Upgrade | Fixed | Cost | Diff |
34
+ |---|---|---|---|---|
35
+ | httpx | 0.27.2 → 0.28.1 | ✅ | $0.12 | 1 file changed, 4 insertions(+), 1 deletion(-) |
36
+ | numpy | 1.26.4 → 2.5.3 | ✅ | $0.14 | 1 file changed, 5 insertions(+), 5 deletions(-) |
37
+ | openai | 0.28.1 → 3.24.0 | ✅ | $0.29 | 1 file changed, 4 insertions(+), 3 deletions(-) |
38
+ | pydantic | 1.10.21 → 2.13.5 | ✅ | $0.20 | 1 file changed, 11 insertions(+), 11 deletions(-) |
39
+ | sqlalchemy | 1.4.54 → 2.1.3 | ✅ | $0.19 | 1 file changed, 7 insertions(+), 4 deletions(-) |
40
+ | pydantic (6-file app, shared base model) | 1.10.21 → 2.13.5 | ✅ | $0.51 | 4 files changed, 47 insertions(+), 29 deletions(-) |
41
+
42
+ 6/6 fixed for $1.45 total, no test edits. The 6-file case runs with deprecation warnings as errors and includes a silent behavior change (v2 stops coercing numeric SKUs to strings). Rerun: `python bench/run.py` (or `--check` to validate cases for free).
43
+ More cases welcome: add a folder under `bench/cases/`.
44
+
45
+ ## GitHub Action
46
+
47
+ ```yaml
48
+ # .github/workflows/driftfix.yml
49
+ name: driftfix
50
+ on: pull_request
51
+ permissions: { contents: write, pull-requests: write }
52
+ jobs:
53
+ fix:
54
+ if: github.actor == 'dependabot[bot]'
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - uses: actions/checkout@v4
58
+ with: { ref: "${{ github.head_ref }}" }
59
+ - uses: Gthejesraj/driftfix@main
60
+ with:
61
+ anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
62
+ setup: pip install -r requirements.txt
63
+ test: pytest -q
64
+ ```
65
+
66
+ **Important:** workflows triggered by Dependabot can't read your normal repo
67
+ secrets. Add `ANTHROPIC_API_KEY` under **Settings → Secrets → Dependabot**.
68
+
69
+ Commits pushed with the default `GITHUB_TOKEN` don't re-trigger CI. Use a PAT
70
+ or GitHub App token in `actions/checkout` if you want CI to rerun on the fix.
71
+
72
+ ## CLI
73
+
74
+ ```bash
75
+ pip install driftfix
76
+ # after upgrading a dependency and seeing tests fail:
77
+ driftfix fix --package openai --from 0.28 --to 1.40 --test "pytest -q"
78
+ ```
79
+
80
+ | Flag | Default | |
81
+ |---|---|---|
82
+ | `--test` | `pytest -q` | any command; exit 0 = green |
83
+ | `--model` | `claude-opus-5` | |
84
+ | `--budget` | `5` | max USD per run |
85
+ | `--max-turns` | `50` | |
86
+ | `--summary FILE` | | write the markdown report |
87
+
88
+ ## Guardrails
89
+
90
+ - Your tests decide. driftfix reruns them after the agent finishes; the agent's word doesn't count.
91
+ - Edits to dependency files (pinning back) are rejected.
92
+ - Requires a clean git tree, so every change is a reviewable diff.
93
+ - Never merges anything. The PR is still yours to review.
94
+ - The agent runs with full tool access inside the checkout. Run it in CI or a throwaway clone.
95
+
96
+ ## Roadmap
97
+
98
+ - More benchmark cases (pandas 2, Django, langchain), harder multi-file repos
99
+ - npm / Renovate support
100
+ - Provider mode: packages ship migration notes that driftfix reads
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,91 @@
1
+ # driftfix
2
+
3
+ **Dependabot bumps it. CI breaks. driftfix fixes the code.**
4
+
5
+ Dependency bots open the upgrade PR, then leave you to fix whatever broke.
6
+ driftfix finishes the job: it reads the failing tests, has Claude adapt your
7
+ code to the new version, reruns your tests itself, and pushes the fix onto
8
+ the same PR. If it can't get to green, it comments what it tried and commits
9
+ nothing.
10
+
11
+ ```
12
+ Dependabot PR (openai 0.28 → 1.x) → tests fail → Claude fixes code → tests rerun → ✅ commit on the PR
13
+ └→ ❌ comment only
14
+ ```
15
+
16
+ ## Benchmark
17
+
18
+ Real breaking upgrades, each a small app with tests that pass on the old version and fail on the new one ([`bench/`](bench/)):
19
+
20
+ | Package | Upgrade | Fixed | Cost | Diff |
21
+ |---|---|---|---|---|
22
+ | httpx | 0.27.2 → 0.28.1 | ✅ | $0.12 | 1 file changed, 4 insertions(+), 1 deletion(-) |
23
+ | numpy | 1.26.4 → 2.5.3 | ✅ | $0.14 | 1 file changed, 5 insertions(+), 5 deletions(-) |
24
+ | openai | 0.28.1 → 3.24.0 | ✅ | $0.29 | 1 file changed, 4 insertions(+), 3 deletions(-) |
25
+ | pydantic | 1.10.21 → 2.13.5 | ✅ | $0.20 | 1 file changed, 11 insertions(+), 11 deletions(-) |
26
+ | sqlalchemy | 1.4.54 → 2.1.3 | ✅ | $0.19 | 1 file changed, 7 insertions(+), 4 deletions(-) |
27
+ | pydantic (6-file app, shared base model) | 1.10.21 → 2.13.5 | ✅ | $0.51 | 4 files changed, 47 insertions(+), 29 deletions(-) |
28
+
29
+ 6/6 fixed for $1.45 total, no test edits. The 6-file case runs with deprecation warnings as errors and includes a silent behavior change (v2 stops coercing numeric SKUs to strings). Rerun: `python bench/run.py` (or `--check` to validate cases for free).
30
+ More cases welcome: add a folder under `bench/cases/`.
31
+
32
+ ## GitHub Action
33
+
34
+ ```yaml
35
+ # .github/workflows/driftfix.yml
36
+ name: driftfix
37
+ on: pull_request
38
+ permissions: { contents: write, pull-requests: write }
39
+ jobs:
40
+ fix:
41
+ if: github.actor == 'dependabot[bot]'
42
+ runs-on: ubuntu-latest
43
+ steps:
44
+ - uses: actions/checkout@v4
45
+ with: { ref: "${{ github.head_ref }}" }
46
+ - uses: Gthejesraj/driftfix@main
47
+ with:
48
+ anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
49
+ setup: pip install -r requirements.txt
50
+ test: pytest -q
51
+ ```
52
+
53
+ **Important:** workflows triggered by Dependabot can't read your normal repo
54
+ secrets. Add `ANTHROPIC_API_KEY` under **Settings → Secrets → Dependabot**.
55
+
56
+ Commits pushed with the default `GITHUB_TOKEN` don't re-trigger CI. Use a PAT
57
+ or GitHub App token in `actions/checkout` if you want CI to rerun on the fix.
58
+
59
+ ## CLI
60
+
61
+ ```bash
62
+ pip install driftfix
63
+ # after upgrading a dependency and seeing tests fail:
64
+ driftfix fix --package openai --from 0.28 --to 1.40 --test "pytest -q"
65
+ ```
66
+
67
+ | Flag | Default | |
68
+ |---|---|---|
69
+ | `--test` | `pytest -q` | any command; exit 0 = green |
70
+ | `--model` | `claude-opus-5` | |
71
+ | `--budget` | `5` | max USD per run |
72
+ | `--max-turns` | `50` | |
73
+ | `--summary FILE` | | write the markdown report |
74
+
75
+ ## Guardrails
76
+
77
+ - Your tests decide. driftfix reruns them after the agent finishes; the agent's word doesn't count.
78
+ - Edits to dependency files (pinning back) are rejected.
79
+ - Requires a clean git tree, so every change is a reviewable diff.
80
+ - Never merges anything. The PR is still yours to review.
81
+ - The agent runs with full tool access inside the checkout. Run it in CI or a throwaway clone.
82
+
83
+ ## Roadmap
84
+
85
+ - More benchmark cases (pandas 2, Django, langchain), harder multi-file repos
86
+ - npm / Renovate support
87
+ - Provider mode: packages ship migration notes that driftfix reads
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,66 @@
1
+ name: driftfix
2
+ description: Dependabot bumps it. CI breaks. driftfix fixes the code and pushes it to the PR.
3
+ branding: { icon: tool, color: green }
4
+
5
+ inputs:
6
+ anthropic-api-key: { required: true }
7
+ setup: { description: Command that installs your project's dependencies, default: "pip install -r requirements.txt" }
8
+ test: { description: Test command, default: "pytest -q" }
9
+ package: { description: Package that was upgraded (auto-detected on Dependabot PRs), default: "" }
10
+ model: { default: claude-opus-5 }
11
+ budget: { description: Max USD per run, default: "5" }
12
+
13
+ runs:
14
+ using: composite
15
+ steps:
16
+ - id: meta
17
+ if: inputs.package == ''
18
+ uses: dependabot/fetch-metadata@v2
19
+
20
+ - uses: actions/setup-python@v5
21
+ with: { python-version: "3.12" }
22
+
23
+ - shell: bash
24
+ run: |
25
+ pip install -q "${{ github.action_path }}"
26
+ ${{ inputs.setup }}
27
+
28
+ - id: fix
29
+ shell: bash
30
+ env:
31
+ ANTHROPIC_API_KEY: ${{ inputs.anthropic-api-key }}
32
+ run: |
33
+ if [ -z "$ANTHROPIC_API_KEY" ]; then
34
+ echo "::error::ANTHROPIC_API_KEY is empty. Dependabot PRs only see Dependabot secrets: Settings → Secrets and variables → Dependabot."
35
+ exit 1
36
+ fi
37
+ set +e
38
+ driftfix fix \
39
+ --package "${{ inputs.package || steps.meta.outputs.dependency-names }}" \
40
+ --from "${{ steps.meta.outputs.previous-version }}" \
41
+ --to "${{ steps.meta.outputs.new-version }}" \
42
+ --test "${{ inputs.test }}" --model "${{ inputs.model }}" --budget "${{ inputs.budget }}" \
43
+ --summary "$RUNNER_TEMP/driftfix.md"
44
+ echo "code=$?" >> "$GITHUB_OUTPUT"
45
+
46
+ - if: steps.fix.outputs.code == '0'
47
+ shell: bash
48
+ run: |
49
+ git add -A && git reset -q -- ':(glob)**/__pycache__/**' ':(glob).pytest_cache/**'
50
+ git diff --cached --quiet && exit 0
51
+ git -c user.name="driftfix[bot]" -c user.email="driftfix@users.noreply.github.com" \
52
+ commit -qm "fix: adapt code to ${{ inputs.package || steps.meta.outputs.dependency-names }} upgrade"
53
+ git push
54
+
55
+ - if: always()
56
+ shell: bash
57
+ env: { GH_TOKEN: "${{ github.token }}" }
58
+ run: |
59
+ [ -f "$RUNNER_TEMP/driftfix.md" ] || exit 0
60
+ gh pr comment "${{ github.event.pull_request.number }}" --body-file "$RUNNER_TEMP/driftfix.md"
61
+
62
+ - if: steps.fix.outputs.code != '0'
63
+ shell: bash
64
+ run: |
65
+ echo "::error::driftfix could not fix this upgrade (exit ${{ steps.fix.outputs.code }}). See the PR comment."
66
+ exit 1
@@ -0,0 +1,8 @@
1
+ | Package | Upgrade | Fixed | Cost | Diff |
2
+ |---|---|---|---|---|
3
+ | httpx | 0.27.2 → 0.28.1 | ✅ | $0.12 | 1 file changed, 4 insertions(+), 1 deletion(-) |
4
+ | numpy | 1.26.4 → 2.5.3 | ✅ | $0.14 | 1 file changed, 5 insertions(+), 5 deletions(-) |
5
+ | openai | 0.28.1 → 3.24.0 | ✅ | $0.29 | 1 file changed, 4 insertions(+), 3 deletions(-) |
6
+ | pydantic | 1.10.21 → 2.13.5 | ✅ | $0.20 | 1 file changed, 11 insertions(+), 11 deletions(-) |
7
+ | sqlalchemy | 1.4.54 → 2.1.3 | ✅ | $0.19 | 1 file changed, 7 insertions(+), 4 deletions(-) |
8
+ | pydantic (6-file app, shared base model) | 1.10.21 → 2.13.5 | ✅ | $0.51 | 4 files changed, 47 insertions(+), 29 deletions(-) |
@@ -0,0 +1,11 @@
1
+ import httpx
2
+
3
+
4
+ def make_client(wsgi_app) -> httpx.Client:
5
+ return httpx.Client(app=wsgi_app, base_url="http://testserver")
6
+
7
+
8
+ def greeting(client: httpx.Client, name: str) -> str:
9
+ response = client.get("/hello", params={"name": name})
10
+ response.raise_for_status()
11
+ return response.text
@@ -0,0 +1 @@
1
+ {"package": "httpx", "old": "httpx==0.27.2", "new": "httpx>=0.28,<1"}
@@ -0,0 +1,2 @@
1
+ [pytest]
2
+ pythonpath = .
@@ -0,0 +1,14 @@
1
+ from urllib.parse import parse_qs
2
+
3
+ import app
4
+
5
+
6
+ def hello(environ, start_response):
7
+ name = parse_qs(environ["QUERY_STRING"])["name"][0]
8
+ start_response("200 OK", [("Content-Type", "text/plain")])
9
+ return [f"hello {name}".encode()]
10
+
11
+
12
+ def test_greeting():
13
+ with app.make_client(hello) as client:
14
+ assert app.greeting(client, "ada") == "hello ada"
@@ -0,0 +1,19 @@
1
+ import numpy as np
2
+
3
+
4
+ def clean(xs: list[float]) -> np.ndarray:
5
+ a = np.array(xs, dtype=np.float_)
6
+ a[a < 0] = np.NaN
7
+ return a
8
+
9
+
10
+ def total(xs: list[float]) -> float:
11
+ return float(np.product(xs))
12
+
13
+
14
+ def all_positive(xs: list[float]) -> bool:
15
+ return bool(np.alltrue(np.array(xs) > 0))
16
+
17
+
18
+ def as_bytes(xs: list[int]) -> np.ndarray:
19
+ return np.array(xs).astype(np.string_)
@@ -0,0 +1 @@
1
+ {"package": "numpy", "old": "numpy==1.26.4", "new": "numpy>=2,<3"}
@@ -0,0 +1,2 @@
1
+ [pytest]
2
+ pythonpath = .
@@ -0,0 +1,17 @@
1
+ import math
2
+
3
+ import app
4
+
5
+
6
+ def test_clean():
7
+ out = app.clean([1.0, -1.0])
8
+ assert out[0] == 1.0 and math.isnan(out[1])
9
+
10
+
11
+ def test_math():
12
+ assert app.total([2, 3]) == 6
13
+ assert app.all_positive([1, 2]) and not app.all_positive([1, -2])
14
+
15
+
16
+ def test_bytes():
17
+ assert app.as_bytes([1, 22]).tolist() == [b"1", b"22"]
@@ -0,0 +1,9 @@
1
+ import openai
2
+
3
+
4
+ def summarize(text: str) -> str:
5
+ resp = openai.ChatCompletion.create(
6
+ model="gpt-4o-mini",
7
+ messages=[{"role": "user", "content": f"Summarize: {text}"}],
8
+ )
9
+ return resp["choices"][0]["message"]["content"]
@@ -0,0 +1 @@
1
+ {"package": "openai", "old": "openai==0.28.1", "new": "openai>=1"}
@@ -0,0 +1,2 @@
1
+ [pytest]
2
+ pythonpath = .
@@ -0,0 +1,43 @@
1
+ import json
2
+ import threading
3
+ from http.server import BaseHTTPRequestHandler, HTTPServer
4
+
5
+ import pytest
6
+
7
+ REPLY = {
8
+ "id": "x", "object": "chat.completion", "created": 0, "model": "gpt-4o-mini",
9
+ "choices": [{"index": 0, "finish_reason": "stop",
10
+ "message": {"role": "assistant", "content": "short"}}],
11
+ }
12
+
13
+
14
+ class FakeOpenAI(BaseHTTPRequestHandler):
15
+ def do_POST(self):
16
+ self.rfile.read(int(self.headers["Content-Length"]))
17
+ body = json.dumps(REPLY).encode()
18
+ self.send_response(200)
19
+ self.send_header("Content-Type", "application/json")
20
+ self.send_header("Content-Length", str(len(body)))
21
+ self.end_headers()
22
+ self.wfile.write(body)
23
+
24
+ def log_message(self, *args):
25
+ pass
26
+
27
+
28
+ @pytest.fixture
29
+ def fake_openai(monkeypatch):
30
+ server = HTTPServer(("127.0.0.1", 0), FakeOpenAI)
31
+ threading.Thread(target=server.serve_forever, daemon=True).start()
32
+ url = f"http://127.0.0.1:{server.server_port}/v1"
33
+ monkeypatch.setenv("OPENAI_API_KEY", "test")
34
+ monkeypatch.setenv("OPENAI_BASE_URL", url) # openai>=1
35
+ monkeypatch.setenv("OPENAI_API_BASE", url) # openai<1
36
+ yield
37
+ server.shutdown()
38
+
39
+
40
+ def test_summarize(fake_openai):
41
+ import app
42
+
43
+ assert app.summarize("long text") == "short"
@@ -0,0 +1,33 @@
1
+ from pydantic import BaseModel, validator
2
+
3
+
4
+ class User(BaseModel):
5
+ name: str
6
+ email: str
7
+
8
+ class Config:
9
+ orm_mode = True
10
+
11
+ @validator("email")
12
+ def lowercase(cls, v):
13
+ return v.lower()
14
+
15
+
16
+ class Tags(BaseModel):
17
+ __root__: list[str]
18
+
19
+
20
+ def load(data: dict) -> User:
21
+ return User.parse_obj(data)
22
+
23
+
24
+ def dump(user: User) -> dict:
25
+ return user.dict()
26
+
27
+
28
+ def from_row(row) -> User:
29
+ return User.from_orm(row)
30
+
31
+
32
+ def tags(raw: list[str]) -> list[str]:
33
+ return Tags.parse_obj(raw).__root__
@@ -0,0 +1 @@
1
+ {"package": "pydantic", "old": "pydantic==1.10.21", "new": "pydantic>=2,<3"}
@@ -0,0 +1,2 @@
1
+ [pytest]
2
+ pythonpath = .
@@ -0,0 +1,16 @@
1
+ import app
2
+
3
+
4
+ def test_roundtrip():
5
+ user = app.load({"name": "Ada", "email": "ADA@X.COM"})
6
+ assert app.dump(user) == {"name": "Ada", "email": "ada@x.com"}
7
+
8
+
9
+ def test_from_row():
10
+ class Row:
11
+ name, email = "Bob", "B@Y.COM"
12
+ assert app.from_row(Row()).email == "b@y.com"
13
+
14
+
15
+ def test_tags():
16
+ assert app.tags(["a", "b"]) == ["a", "b"]
@@ -0,0 +1 @@
1
+ {"package": "pydantic", "old": "pydantic==1.10.21", "new": "pydantic>=2,<3"}
@@ -0,0 +1,3 @@
1
+ [pytest]
2
+ pythonpath = .
3
+ filterwarnings = error::DeprecationWarning
@@ -0,0 +1,17 @@
1
+ import json
2
+ from decimal import Decimal
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class ShopModel(BaseModel):
8
+ """Base for every model in the shop. Payloads go out camelCase, without nulls."""
9
+
10
+ class Config:
11
+ orm_mode = True
12
+ allow_population_by_field_name = True
13
+ anystr_strip_whitespace = True
14
+ json_encoders = {Decimal: str}
15
+
16
+ def to_payload(self) -> dict:
17
+ return json.loads(self.json(by_alias=True, exclude_none=True))
@@ -0,0 +1,13 @@
1
+ from shop.base import ShopModel
2
+ from shop.models import Product
3
+
4
+
5
+ class Catalog(ShopModel):
6
+ __root__: list[Product]
7
+
8
+ def skus(self) -> list[str]:
9
+ return [p.sku for p in self.__root__]
10
+
11
+
12
+ def parse_catalog(raw_json: str) -> Catalog:
13
+ return Catalog.parse_raw(raw_json)
@@ -0,0 +1,35 @@
1
+ from decimal import Decimal
2
+ from typing import Optional
3
+
4
+ from pydantic import Field, conlist, constr, root_validator, validator
5
+
6
+ from shop.base import ShopModel
7
+
8
+
9
+ class Product(ShopModel):
10
+ sku: str = Field(..., alias="productId")
11
+ name: constr(min_length=1)
12
+ price: Decimal
13
+ color: Optional[constr(regex=r"^#[0-9a-f]{6}$")] = None
14
+
15
+ @validator("price")
16
+ def positive(cls, v):
17
+ if v <= 0:
18
+ raise ValueError("price must be positive")
19
+ return v
20
+
21
+
22
+ class LineItem(ShopModel):
23
+ product: Product
24
+ quantity: int = 1
25
+
26
+
27
+ class Order(ShopModel):
28
+ items: conlist(LineItem, min_items=1)
29
+ total: Decimal = Decimal(0)
30
+
31
+ @root_validator
32
+ def compute_total(cls, values):
33
+ items = values.get("items") or []
34
+ values["total"] = sum((i.product.price * i.quantity for i in items), Decimal(0))
35
+ return values
@@ -0,0 +1,20 @@
1
+ from decimal import Decimal
2
+
3
+ from shop.models import LineItem, Order, Product
4
+
5
+
6
+ def discounted(product: Product, percent: int) -> Product:
7
+ new_price = (product.price * (100 - percent) / 100).quantize(Decimal("0.01"))
8
+ return product.copy(update={"price": new_price})
9
+
10
+
11
+ def place_order(rows: list[dict]) -> Order:
12
+ return Order(items=[LineItem.parse_obj(r) for r in rows])
13
+
14
+
15
+ def product_fields() -> list[str]:
16
+ return list(Product.__fields__)
17
+
18
+
19
+ def product_from_record(record) -> Product:
20
+ return Product.from_orm(record)
@@ -0,0 +1,35 @@
1
+ from decimal import Decimal
2
+
3
+ import pytest
4
+ from pydantic import ValidationError
5
+
6
+ from shop.models import Product
7
+
8
+
9
+ LEGACY = {"productId": 123, "name": " Mug ", "price": "9.99"} # upstream API sends numeric SKUs
10
+
11
+
12
+ def test_legacy_payload_is_normalized():
13
+ p = Product(**LEGACY)
14
+ assert p.sku == "123"
15
+ assert p.name == "Mug"
16
+ assert p.price == Decimal("9.99")
17
+
18
+
19
+ def test_payload_is_camel_case_without_nulls():
20
+ p = Product(**LEGACY)
21
+ assert p.to_payload() == {"productId": "123", "name": "Mug", "price": "9.99"}
22
+
23
+
24
+ def test_populate_by_field_name():
25
+ assert Product(sku="A1", name="Cup", price=1).sku == "A1"
26
+
27
+
28
+ @pytest.mark.parametrize("bad", [{"price": 0}, {"name": ""}, {"color": "red"}])
29
+ def test_rejects_invalid(bad):
30
+ with pytest.raises(ValidationError):
31
+ Product(**{**LEGACY, **bad})
32
+
33
+
34
+ def test_color_ok():
35
+ assert Product(**LEGACY, color="#a1b2c3").color == "#a1b2c3"
@@ -0,0 +1,42 @@
1
+ import json
2
+ from decimal import Decimal
3
+
4
+ import pytest
5
+ from pydantic import ValidationError
6
+
7
+ from shop.catalog import parse_catalog
8
+ from shop.service import discounted, place_order, product_fields, product_from_record
9
+ from shop.models import Product
10
+
11
+ MUG = {"productId": "M1", "name": "Mug", "price": "10.00"}
12
+
13
+
14
+ def test_discount_does_not_mutate():
15
+ p = Product(**MUG)
16
+ d = discounted(p, 15)
17
+ assert (p.price, d.price) == (Decimal("10.00"), Decimal("8.50"))
18
+
19
+
20
+ def test_order_total():
21
+ order = place_order([{"product": MUG, "quantity": 3}, {"product": {**MUG, "price": "1.50"}}])
22
+ assert order.total == Decimal("31.50")
23
+ assert order.to_payload()["total"] == "31.50"
24
+
25
+
26
+ def test_empty_order_rejected():
27
+ with pytest.raises(ValidationError):
28
+ place_order([])
29
+
30
+
31
+ def test_catalog():
32
+ assert parse_catalog(json.dumps([MUG, {**MUG, "productId": "M2"}])).skus() == ["M1", "M2"]
33
+
34
+
35
+ def test_fields():
36
+ assert product_fields() == ["sku", "name", "price", "color"]
37
+
38
+
39
+ def test_from_record():
40
+ class Record:
41
+ sku, name, price, color = "R1", "Plate", Decimal(3), None
42
+ assert product_from_record(Record()).sku == "R1"
@@ -0,0 +1,29 @@
1
+ from sqlalchemy import Column, Integer, String, create_engine, select
2
+ from sqlalchemy.ext.declarative import declarative_base
3
+
4
+ Base = declarative_base()
5
+
6
+
7
+ class User(Base):
8
+ __tablename__ = "users"
9
+ id = Column(Integer, primary_key=True)
10
+ name = Column(String)
11
+
12
+
13
+ def make_engine():
14
+ engine = create_engine("sqlite://")
15
+ Base.metadata.create_all(engine)
16
+ return engine
17
+
18
+
19
+ def add_user(engine, name: str) -> None:
20
+ with engine.connect() as conn:
21
+ conn.execute(User.__table__.insert(), {"name": name})
22
+
23
+
24
+ def names(engine) -> list[str]:
25
+ return [row[0] for row in engine.execute(select([User.name]).order_by(User.name))]
26
+
27
+
28
+ def count(engine) -> int:
29
+ return engine.execute("SELECT count(*) FROM users").scalar()
@@ -0,0 +1 @@
1
+ {"package": "sqlalchemy", "old": "sqlalchemy==1.4.54", "new": "sqlalchemy>=2,<3"}
@@ -0,0 +1,2 @@
1
+ [pytest]
2
+ pythonpath = .
@@ -0,0 +1,9 @@
1
+ import app
2
+
3
+
4
+ def test_users():
5
+ engine = app.make_engine()
6
+ app.add_user(engine, "bob")
7
+ app.add_user(engine, "alice")
8
+ assert app.names(engine) == ["alice", "bob"]
9
+ assert app.count(engine) == 2
@@ -0,0 +1,78 @@
1
+ """Benchmark driftfix on real breaking upgrades.
2
+
3
+ python bench/run.py --check # sanity: each case passes on old, fails on new (free)
4
+ python bench/run.py [case ...] # run driftfix on each case (costs API credits)
5
+ """
6
+
7
+ import json
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ CASES = Path(__file__).parent / "cases"
16
+ TEST = ".venv/bin/python -m pytest -q"
17
+
18
+
19
+ def sh(cmd: str, cwd: Path) -> subprocess.CompletedProcess:
20
+ return subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True)
21
+
22
+
23
+ def setup(case: Path, spec: str) -> Path:
24
+ """Copy a case into a fresh git repo with `spec` installed in .venv."""
25
+ repo = Path(tempfile.mkdtemp(prefix=f"driftfix-{case.name}-"))
26
+ shutil.copytree(case, repo, dirs_exist_ok=True)
27
+ (repo / ".gitignore").write_text(".venv/\n__pycache__/\n.pytest_cache/\n")
28
+ for cmd in (
29
+ "uv venv -q -p 3.12 .venv",
30
+ f"uv pip install -q -p .venv/bin/python pytest '{spec}'",
31
+ "git init -q && git add -A && git -c user.email=b@b -c user.name=bench commit -qm init",
32
+ ):
33
+ if (r := sh(cmd, repo)).returncode:
34
+ sys.exit(f"{case.name}: `{cmd}` failed\n{r.stderr}")
35
+ return repo
36
+
37
+
38
+ def version(repo: Path, package: str) -> str:
39
+ code = f"import importlib.metadata as m; print(m.version('{package}'))"
40
+ return sh(f".venv/bin/python -c \"{code}\"", repo).stdout.strip()
41
+
42
+
43
+ def main(args: list[str]) -> None:
44
+ check = "--check" in args
45
+ names = [a for a in args if a != "--check"] or sorted(p.name for p in CASES.iterdir())
46
+ rows = []
47
+ for name in names:
48
+ case = CASES / name
49
+ meta = json.loads((case / "case.json").read_text())
50
+ if check:
51
+ old_ok = sh(TEST, setup(case, meta["old"])).returncode == 0
52
+ new_ok = sh(TEST, setup(case, meta["new"])).returncode == 0
53
+ print(f"{name:12} old {'pass' if old_ok else 'FAIL'} new {'pass' if new_ok else 'fail'}"
54
+ f" {'ok' if old_ok and not new_ok else '<-- broken case'}")
55
+ continue
56
+
57
+ repo = setup(case, meta["new"])
58
+ old = re.sub(r"^[^0-9]*", "", meta["old"])
59
+ new = version(repo, meta["package"])
60
+ report = repo / ".driftfix.md"
61
+ r = sh(f"driftfix fix --package {meta['package']} --from {old} --to {new} "
62
+ f"--test '{TEST}' --budget 1.5 --summary {report}", repo)
63
+ text = report.read_text() if report.exists() else r.stdout + r.stderr
64
+ cost = re.search(r"cost \$([\d.]+)", text)
65
+ diff = sh("git diff --stat -- . ':!.driftfix.md' | tail -1", repo).stdout.strip()
66
+ rows.append((name, f"{old} → {new}", "✅" if r.returncode == 0 else "❌",
67
+ f"${cost.group(1)}" if cost else "?", diff))
68
+ print(*rows[-1], repo, sep=" | ", flush=True)
69
+
70
+ if rows:
71
+ table = "| Package | Upgrade | Fixed | Cost | Diff |\n|---|---|---|---|---|\n"
72
+ table += "".join(f"| {' | '.join(row)} |\n" for row in rows)
73
+ (Path(__file__).parent / "RESULTS.md").write_text(table)
74
+ print("\n" + table)
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main(sys.argv[1:])
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "driftfix"
7
+ version = "0.1.0"
8
+ description = "Dependabot bumps it. CI breaks. driftfix fixes the code."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = ["claude-agent-sdk>=0.2"]
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest>=8"]
16
+
17
+ [project.scripts]
18
+ driftfix = "driftfix.cli:main"
19
+
20
+ [project.urls]
21
+ Repository = "https://github.com/Gthejesraj/driftfix"
@@ -0,0 +1,2 @@
1
+ [pytest]
2
+ testpaths = tests
File without changes
@@ -0,0 +1,138 @@
1
+ """driftfix: a dependency upgrade broke your tests, Claude fixes the code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import subprocess
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ # Pinning back to the old version is not a fix.
13
+ MANIFESTS = {
14
+ "requirements.txt", "pyproject.toml", "setup.py", "setup.cfg", "Pipfile", "Pipfile.lock",
15
+ "poetry.lock", "uv.lock", "package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
16
+ }
17
+
18
+ PROMPT = """\
19
+ The dependency `{package}` was upgraded{versions}. The test suite now fails:
20
+
21
+ $ {test_command}
22
+ {output}
23
+
24
+ Fix this repository's code so it works with the new version of `{package}`.
25
+ - Read the package's changelog / migration guide for the relevant versions if you need to.
26
+ - Change application code. Change tests only where they use `{package}`'s API directly.
27
+ - Never pin, downgrade, or edit dependency files ({manifests}).
28
+ - Never delete or skip tests to make them pass.
29
+ - Run `{test_command}` to confirm the fix.
30
+ Your final message must be only a short markdown summary of what changed and why, for a PR description."""
31
+
32
+
33
+ @dataclass
34
+ class TestRun:
35
+ passed: bool
36
+ output: str
37
+
38
+
39
+ def run_tests(command: str, repo: Path, timeout: int) -> TestRun:
40
+ try:
41
+ proc = subprocess.run(
42
+ command, shell=True, cwd=repo, capture_output=True, text=True, timeout=timeout
43
+ )
44
+ except subprocess.TimeoutExpired:
45
+ return TestRun(False, f"timed out after {timeout}s")
46
+ lines = (proc.stdout + proc.stderr).splitlines()
47
+ return TestRun(proc.returncode == 0, "\n".join(lines[-150:]))
48
+
49
+
50
+ def changed_files(repo: Path) -> list[str]:
51
+ out = subprocess.run(
52
+ ["git", "status", "--porcelain"], cwd=repo, capture_output=True, text=True, check=True
53
+ ).stdout
54
+ return [line[3:].split(" -> ")[-1] for line in out.splitlines()]
55
+
56
+
57
+ def build_prompt(package: str, old: str | None, new: str | None, command: str, out: str) -> str:
58
+ versions = f" from {old or '?'} to {new}" if new else (f" from {old}" if old else "")
59
+ return PROMPT.format(
60
+ package=package, versions=versions, test_command=command, output=out,
61
+ manifests=", ".join(sorted(MANIFESTS)),
62
+ )
63
+
64
+
65
+ async def run_agent(prompt: str, repo: Path, model: str, max_turns: int, budget: float) -> tuple[str, float]:
66
+ from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
67
+
68
+ options = ClaudeAgentOptions(
69
+ cwd=repo,
70
+ model=model,
71
+ allowed_tools=["Read", "Edit", "Write", "Glob", "Grep", "Bash", "WebFetch", "WebSearch"],
72
+ permission_mode="bypassPermissions", # headless; run in CI or a throwaway checkout
73
+ max_turns=max_turns,
74
+ max_budget_usd=budget,
75
+ setting_sources=["project"], # repo's CLAUDE.md, not the user's personal config
76
+ skills=[],
77
+ )
78
+ summary, cost = "", 0.0
79
+ async for message in query(prompt=prompt, options=options):
80
+ if isinstance(message, ResultMessage):
81
+ summary, cost = message.result or "", message.total_cost_usd or 0.0
82
+ return summary, cost
83
+
84
+
85
+ def fix(args: argparse.Namespace) -> int:
86
+ repo = args.repo.resolve()
87
+ if changed_files(repo):
88
+ print("Working tree is dirty. Commit or stash first so driftfix's edits are reviewable.")
89
+ return 2
90
+ before = run_tests(args.test, repo, args.timeout)
91
+ if before.passed:
92
+ print(f"Tests pass with {args.package} as installed. Nothing to fix.")
93
+ return 0
94
+ baseline = set(changed_files(repo)) # test-run artifacts like __pycache__
95
+
96
+ print(f"Tests fail after upgrading {args.package}. Handing off to Claude ({args.model})...")
97
+ prompt = build_prompt(args.package, args.from_version, args.to_version, args.test, before.output)
98
+ try:
99
+ summary, cost = asyncio.run(run_agent(prompt, repo, args.model, args.max_turns, args.budget))
100
+ except Exception as exc: # still report on the PR instead of dying silently
101
+ summary, cost = f"Agent error: `{exc}`", 0.0
102
+
103
+ touched = [f for f in changed_files(repo) if f not in baseline]
104
+ pinned = [f for f in touched if Path(f).name in MANIFESTS]
105
+ after = run_tests(args.test, repo, args.timeout) # don't trust the agent's word
106
+ ok = after.passed and bool(touched) and not pinned
107
+
108
+ status = "✅ Fixed" if ok else "❌ Not fixed"
109
+ report = f"## driftfix: {args.package}\n\n**{status}** · cost ${cost:.2f}\n\n{summary}\n"
110
+ if pinned:
111
+ report += f"\nRejected: agent edited dependency files {pinned}.\n"
112
+ if not after.passed:
113
+ report += f"\nTests still failing:\n```\n{after.output[-3000:]}\n```\n"
114
+ print(report)
115
+ if args.summary:
116
+ args.summary.write_text(report)
117
+ return 0 if ok else 1
118
+
119
+
120
+ def main(argv: list[str] | None = None) -> int:
121
+ p = argparse.ArgumentParser(prog="driftfix", description=__doc__)
122
+ sub = p.add_subparsers(dest="command", required=True)
123
+ f = sub.add_parser("fix", help="fix code broken by a dependency upgrade")
124
+ f.add_argument("--package", required=True)
125
+ f.add_argument("--from", dest="from_version")
126
+ f.add_argument("--to", dest="to_version")
127
+ f.add_argument("--test", default="pytest -q", help="test command (default: pytest -q)")
128
+ f.add_argument("--repo", type=Path, default=Path("."))
129
+ f.add_argument("--model", default="claude-opus-5")
130
+ f.add_argument("--max-turns", type=int, default=50)
131
+ f.add_argument("--budget", type=float, default=5.0, help="max spend in USD (default: 5)")
132
+ f.add_argument("--timeout", type=int, default=600, help="test timeout in seconds")
133
+ f.add_argument("--summary", type=Path, help="write the markdown report here")
134
+ return fix(p.parse_args(argv))
135
+
136
+
137
+ if __name__ == "__main__":
138
+ sys.exit(main())
@@ -0,0 +1,62 @@
1
+ import subprocess
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from driftfix.cli import main
6
+ import driftfix.cli as cli
7
+
8
+
9
+ def make_repo(tmp_path: Path, ok: bool) -> Path:
10
+ (tmp_path / "app.py").write_text(f"OK = {ok}\n")
11
+ (tmp_path / "requirements.txt").write_text("lib==2\n")
12
+ for cmd in (["init", "-q"], ["add", "."], ["-c", "user.email=a@b", "-c", "user.name=a", "commit", "-qm", "init"]):
13
+ subprocess.run(["git", *cmd], cwd=tmp_path, check=True)
14
+ return tmp_path
15
+
16
+
17
+ TEST = f"{sys.executable} -c 'import app; assert app.OK'"
18
+
19
+
20
+ def run(tmp_path, agent, monkeypatch):
21
+ monkeypatch.setattr(cli, "run_agent", agent)
22
+ return main(["fix", "--package", "lib", "--test", TEST, "--repo", str(tmp_path)])
23
+
24
+
25
+ def test_green_tests_skip_agent(tmp_path, monkeypatch):
26
+ async def agent(*a):
27
+ raise AssertionError("agent should not run")
28
+ assert run(make_repo(tmp_path, True), agent, monkeypatch) == 0
29
+
30
+
31
+ def test_agent_fix_is_verified(tmp_path, monkeypatch):
32
+ repo = make_repo(tmp_path, False)
33
+ async def agent(prompt, *a):
34
+ assert "assert app.OK" in prompt # failure output reaches the agent
35
+ (repo / "app.py").write_text("OK = True\n")
36
+ return "fixed", 0.1
37
+ assert run(repo, agent, monkeypatch) == 0
38
+
39
+
40
+ def test_agent_lying_still_fails(tmp_path, monkeypatch):
41
+ async def agent(*a):
42
+ return "fixed, trust me", 0.1
43
+ assert run(make_repo(tmp_path, False), agent, monkeypatch) == 1
44
+
45
+
46
+ def test_pinning_back_is_rejected(tmp_path, monkeypatch):
47
+ repo = make_repo(tmp_path, False)
48
+ async def agent(*a):
49
+ (repo / "requirements.txt").write_text("lib==1\n")
50
+ (repo / "app.py").write_text("OK = True\n")
51
+ return "pinned", 0.1
52
+ assert run(repo, agent, monkeypatch) == 1
53
+
54
+
55
+ def test_agent_crash_is_reported(tmp_path, monkeypatch):
56
+ async def agent(*a):
57
+ raise RuntimeError("Not logged in")
58
+ summary = tmp_path / "report.md"
59
+ monkeypatch.setattr(cli, "run_agent", agent)
60
+ code = main(["fix", "--package", "lib", "--test", TEST, "--repo", str(make_repo(tmp_path, False)),
61
+ "--summary", str(summary)])
62
+ assert code == 1 and "Not logged in" in summary.read_text()