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.
- driftfix-0.1.0/.github/workflows/ci.yml +10 -0
- driftfix-0.1.0/.github/workflows/release.yml +13 -0
- driftfix-0.1.0/.gitignore +6 -0
- driftfix-0.1.0/LICENSE +21 -0
- driftfix-0.1.0/PKG-INFO +104 -0
- driftfix-0.1.0/README.md +91 -0
- driftfix-0.1.0/action.yml +66 -0
- driftfix-0.1.0/bench/RESULTS.md +8 -0
- driftfix-0.1.0/bench/cases/httpx/app.py +11 -0
- driftfix-0.1.0/bench/cases/httpx/case.json +1 -0
- driftfix-0.1.0/bench/cases/httpx/pytest.ini +2 -0
- driftfix-0.1.0/bench/cases/httpx/tests/test_app.py +14 -0
- driftfix-0.1.0/bench/cases/numpy/app.py +19 -0
- driftfix-0.1.0/bench/cases/numpy/case.json +1 -0
- driftfix-0.1.0/bench/cases/numpy/pytest.ini +2 -0
- driftfix-0.1.0/bench/cases/numpy/tests/test_app.py +17 -0
- driftfix-0.1.0/bench/cases/openai/app.py +9 -0
- driftfix-0.1.0/bench/cases/openai/case.json +1 -0
- driftfix-0.1.0/bench/cases/openai/pytest.ini +2 -0
- driftfix-0.1.0/bench/cases/openai/tests/test_app.py +43 -0
- driftfix-0.1.0/bench/cases/pydantic/app.py +33 -0
- driftfix-0.1.0/bench/cases/pydantic/case.json +1 -0
- driftfix-0.1.0/bench/cases/pydantic/pytest.ini +2 -0
- driftfix-0.1.0/bench/cases/pydantic/tests/test_app.py +16 -0
- driftfix-0.1.0/bench/cases/pydantic-shop/case.json +1 -0
- driftfix-0.1.0/bench/cases/pydantic-shop/pytest.ini +3 -0
- driftfix-0.1.0/bench/cases/pydantic-shop/shop/__init__.py +0 -0
- driftfix-0.1.0/bench/cases/pydantic-shop/shop/base.py +17 -0
- driftfix-0.1.0/bench/cases/pydantic-shop/shop/catalog.py +13 -0
- driftfix-0.1.0/bench/cases/pydantic-shop/shop/models.py +35 -0
- driftfix-0.1.0/bench/cases/pydantic-shop/shop/service.py +20 -0
- driftfix-0.1.0/bench/cases/pydantic-shop/tests/test_models.py +35 -0
- driftfix-0.1.0/bench/cases/pydantic-shop/tests/test_service.py +42 -0
- driftfix-0.1.0/bench/cases/sqlalchemy/app.py +29 -0
- driftfix-0.1.0/bench/cases/sqlalchemy/case.json +1 -0
- driftfix-0.1.0/bench/cases/sqlalchemy/pytest.ini +2 -0
- driftfix-0.1.0/bench/cases/sqlalchemy/tests/test_app.py +9 -0
- driftfix-0.1.0/bench/run.py +78 -0
- driftfix-0.1.0/pyproject.toml +21 -0
- driftfix-0.1.0/pytest.ini +2 -0
- driftfix-0.1.0/src/driftfix/__init__.py +0 -0
- driftfix-0.1.0/src/driftfix/cli.py +138 -0
- driftfix-0.1.0/tests/test_fix.py +62 -0
|
@@ -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
|
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.
|
driftfix-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
driftfix-0.1.0/README.md
ADDED
|
@@ -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,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,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 @@
|
|
|
1
|
+
{"package": "openai", "old": "openai==0.28.1", "new": "openai>=1"}
|
|
@@ -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,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"}
|
|
File without changes
|
|
@@ -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,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"
|
|
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()
|