driftfix 0.2.0__tar.gz → 0.2.1__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 (79) hide show
  1. {driftfix-0.2.0 → driftfix-0.2.1}/CHANGELOG.md +5 -0
  2. {driftfix-0.2.0 → driftfix-0.2.1}/PKG-INFO +30 -4
  3. {driftfix-0.2.0 → driftfix-0.2.1}/README.md +28 -3
  4. {driftfix-0.2.0 → driftfix-0.2.1}/pyproject.toml +2 -2
  5. {driftfix-0.2.0 → driftfix-0.2.1}/src/driftfix/cli.py +31 -11
  6. {driftfix-0.2.0 → driftfix-0.2.1}/tests/test_fix.py +38 -0
  7. {driftfix-0.2.0 → driftfix-0.2.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  8. {driftfix-0.2.0 → driftfix-0.2.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  9. {driftfix-0.2.0 → driftfix-0.2.1}/.github/ISSUE_TEMPLATE/failed_fix.yml +0 -0
  10. {driftfix-0.2.0 → driftfix-0.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  11. {driftfix-0.2.0 → driftfix-0.2.1}/.github/dependabot.yml +0 -0
  12. {driftfix-0.2.0 → driftfix-0.2.1}/.github/workflows/ci.yml +0 -0
  13. {driftfix-0.2.0 → driftfix-0.2.1}/.github/workflows/release.yml +0 -0
  14. {driftfix-0.2.0 → driftfix-0.2.1}/.gitignore +0 -0
  15. {driftfix-0.2.0 → driftfix-0.2.1}/CODE_OF_CONDUCT.md +0 -0
  16. {driftfix-0.2.0 → driftfix-0.2.1}/CONTRIBUTING.md +0 -0
  17. {driftfix-0.2.0 → driftfix-0.2.1}/LICENSE +0 -0
  18. {driftfix-0.2.0 → driftfix-0.2.1}/SECURITY.md +0 -0
  19. {driftfix-0.2.0 → driftfix-0.2.1}/action.yml +0 -0
  20. {driftfix-0.2.0 → driftfix-0.2.1}/bench/RESULTS.md +0 -0
  21. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/django/app.py +0 -0
  22. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/django/case.json +0 -0
  23. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/django/pytest.ini +0 -0
  24. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/django/tests/conftest.py +0 -0
  25. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/django/tests/test_app.py +0 -0
  26. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/flask/app.py +0 -0
  27. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/flask/case.json +0 -0
  28. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/flask/pytest.ini +0 -0
  29. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/flask/tests/test_app.py +0 -0
  30. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/httpx/app.py +0 -0
  31. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/httpx/case.json +0 -0
  32. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/httpx/pytest.ini +0 -0
  33. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/httpx/tests/test_app.py +0 -0
  34. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/numpy/app.py +0 -0
  35. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/numpy/case.json +0 -0
  36. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/numpy/pytest.ini +0 -0
  37. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/numpy/tests/test_app.py +0 -0
  38. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/openai/app.py +0 -0
  39. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/openai/case.json +0 -0
  40. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/openai/pytest.ini +0 -0
  41. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/openai/tests/test_app.py +0 -0
  42. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pandas/app.py +0 -0
  43. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pandas/case.json +0 -0
  44. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pandas/pytest.ini +0 -0
  45. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pandas/tests/test_app.py +0 -0
  46. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pillow/app.py +0 -0
  47. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pillow/case.json +0 -0
  48. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pillow/pytest.ini +0 -0
  49. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pillow/tests/test_app.py +0 -0
  50. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic/app.py +0 -0
  51. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic/case.json +0 -0
  52. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic/pytest.ini +0 -0
  53. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic/tests/test_app.py +0 -0
  54. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-settings/app.py +0 -0
  55. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-settings/case.json +0 -0
  56. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-settings/pytest.ini +0 -0
  57. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-settings/requirements.txt +0 -0
  58. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-settings/tests/test_app.py +0 -0
  59. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-shop/case.json +0 -0
  60. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-shop/pytest.ini +0 -0
  61. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-shop/shop/__init__.py +0 -0
  62. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-shop/shop/base.py +0 -0
  63. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-shop/shop/catalog.py +0 -0
  64. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-shop/shop/models.py +0 -0
  65. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-shop/shop/service.py +0 -0
  66. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-shop/tests/test_models.py +0 -0
  67. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pydantic-shop/tests/test_service.py +0 -0
  68. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pyyaml/app.py +0 -0
  69. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pyyaml/build-constraints.txt +0 -0
  70. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pyyaml/case.json +0 -0
  71. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pyyaml/pytest.ini +0 -0
  72. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/pyyaml/tests/test_app.py +0 -0
  73. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/sqlalchemy/app.py +0 -0
  74. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/sqlalchemy/case.json +0 -0
  75. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/sqlalchemy/pytest.ini +0 -0
  76. {driftfix-0.2.0 → driftfix-0.2.1}/bench/cases/sqlalchemy/tests/test_app.py +0 -0
  77. {driftfix-0.2.0 → driftfix-0.2.1}/bench/run.py +0 -0
  78. {driftfix-0.2.0 → driftfix-0.2.1}/pytest.ini +0 -0
  79. {driftfix-0.2.0 → driftfix-0.2.1}/src/driftfix/__init__.py +0 -0
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+ - Test commands no longer see `ANTHROPIC_API_KEY` or GitHub tokens
5
+ - Report the real cost when the agent stops on its budget (was $0.00)
6
+ - The pinning-back guard now allows widening the requirement to admit the new version (e.g. dropping `<2.0.0`)
7
+
3
8
  ## 0.2.0
4
9
  - 5 more benchmark cases: pandas 2, Django 5, Flask 3, Pillow 10, PyYAML 6
5
10
  - `driftfix --version`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: driftfix
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Dependabot bumps it. CI breaks. driftfix fixes the code.
5
5
  Project-URL: Homepage, https://github.com/Gthejesraj/driftfix
6
6
  Project-URL: Issues, https://github.com/Gthejesraj/driftfix/issues
@@ -22,6 +22,7 @@ Classifier: Topic :: Software Development :: Build Tools
22
22
  Classifier: Topic :: Software Development :: Quality Assurance
23
23
  Requires-Python: >=3.10
24
24
  Requires-Dist: claude-agent-sdk>=0.2
25
+ Requires-Dist: packaging>=23
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: pytest>=8; extra == 'dev'
27
28
  Requires-Dist: ruff>=0.6; extra == 'dev'
@@ -84,6 +85,23 @@ for free with `python bench/run.py --check`.
84
85
  **Found an upgrade it can't fix? That's the most useful contribution:** see
85
86
  [CONTRIBUTING.md](CONTRIBUTING.md).
86
87
 
88
+ ### Real projects
89
+
90
+ The synthetic cases above are small. On real open-source projects broken by
91
+ pydantic 1 → 2 ([study](https://github.com/Gthejesraj/driftfix-study), 39
92
+ candidates → 4 real breaks):
93
+
94
+ | Project | Tests broken | Result | Cost |
95
+ |---|---|---|---|
96
+ | internetarchive/fatcat-scholar | 15 of 115 | ✅ fixed, 11 files | $2.38 |
97
+ | antonagestam/phantom-types | 2 of 677 | ✅ fixed, one test assertion edit to review | $2.93 |
98
+ | epi2me-labs/ezcharts | 5 of 37 | ✅ fixed, including the model generator | ~$1 |
99
+ | bdd100k/bdd100k | 2 of 46 | ⚠️ tests pass, but via a workaround for a third-party library | $0.95 |
100
+
101
+ Real fixes cost $1–3, not $0.20, so set `budget` to at least 3 for real
102
+ codebases. Always read the diff: "tests pass" and "correct" aren't the same
103
+ thing. Details in the study's [RESULTS.md](https://github.com/Gthejesraj/driftfix-study/blob/main/RESULTS.md).
104
+
87
105
  ## Quick start: GitHub Action
88
106
 
89
107
  1. Add `.github/workflows/driftfix.yml`:
@@ -140,9 +158,10 @@ Exit codes: `0` fixed or nothing to fix, `1` not fixed, `2` dirty working tree.
140
158
 
141
159
  - **Your tests decide.** driftfix reruns them after the agent finishes. The
142
160
  agent saying it's done doesn't count.
143
- - **No pinning back.** Changing the upgraded package's requirement or editing
144
- lock files is rejected. Adding a package the upgrade split out (like
145
- `pydantic-settings`) is allowed.
161
+ - **No pinning back.** A requirement that excludes the new version, or any
162
+ lock file edit, is rejected. Widening a requirement so the new version is
163
+ allowed (dropping an old `<2` cap) is fine, and so is adding a package the
164
+ upgrade split out (like `pydantic-settings`).
146
165
  - **No deleting or skipping tests.** The agent is told not to, and every test
147
166
  change shows up in the diff you review.
148
167
  - **Reviewable.** It needs a clean git tree, so every change is a diff, and it
@@ -172,6 +191,13 @@ Native Renovate support is on the roadmap.
172
191
  workflows from commits pushed with the default `GITHUB_TOKEN`. Pass a personal
173
192
  access token or GitHub App token to `actions/checkout` if you want that.
174
193
 
194
+ **Can it fix deprecations before they break?** Yes. Make the test command
195
+ treat them as errors, e.g. `pytest -q -W error::DeprecationWarning` (some
196
+ libraries use their own warning class, like
197
+ `-W error::starlette.exceptions.StarletteDeprecationWarning`). On a real
198
+ 31-file FastAPI app, this caught Starlette's `httpx` → `httpx2` deprecation and
199
+ fixed it with a one-line dependency swap ($0.40).
200
+
175
201
  **Mocked tests?** driftfix can only catch what your tests catch. A test that
176
202
  mocks the library can pass while the real call is broken.
177
203
 
@@ -55,6 +55,23 @@ for free with `python bench/run.py --check`.
55
55
  **Found an upgrade it can't fix? That's the most useful contribution:** see
56
56
  [CONTRIBUTING.md](CONTRIBUTING.md).
57
57
 
58
+ ### Real projects
59
+
60
+ The synthetic cases above are small. On real open-source projects broken by
61
+ pydantic 1 → 2 ([study](https://github.com/Gthejesraj/driftfix-study), 39
62
+ candidates → 4 real breaks):
63
+
64
+ | Project | Tests broken | Result | Cost |
65
+ |---|---|---|---|
66
+ | internetarchive/fatcat-scholar | 15 of 115 | ✅ fixed, 11 files | $2.38 |
67
+ | antonagestam/phantom-types | 2 of 677 | ✅ fixed, one test assertion edit to review | $2.93 |
68
+ | epi2me-labs/ezcharts | 5 of 37 | ✅ fixed, including the model generator | ~$1 |
69
+ | bdd100k/bdd100k | 2 of 46 | ⚠️ tests pass, but via a workaround for a third-party library | $0.95 |
70
+
71
+ Real fixes cost $1–3, not $0.20, so set `budget` to at least 3 for real
72
+ codebases. Always read the diff: "tests pass" and "correct" aren't the same
73
+ thing. Details in the study's [RESULTS.md](https://github.com/Gthejesraj/driftfix-study/blob/main/RESULTS.md).
74
+
58
75
  ## Quick start: GitHub Action
59
76
 
60
77
  1. Add `.github/workflows/driftfix.yml`:
@@ -111,9 +128,10 @@ Exit codes: `0` fixed or nothing to fix, `1` not fixed, `2` dirty working tree.
111
128
 
112
129
  - **Your tests decide.** driftfix reruns them after the agent finishes. The
113
130
  agent saying it's done doesn't count.
114
- - **No pinning back.** Changing the upgraded package's requirement or editing
115
- lock files is rejected. Adding a package the upgrade split out (like
116
- `pydantic-settings`) is allowed.
131
+ - **No pinning back.** A requirement that excludes the new version, or any
132
+ lock file edit, is rejected. Widening a requirement so the new version is
133
+ allowed (dropping an old `<2` cap) is fine, and so is adding a package the
134
+ upgrade split out (like `pydantic-settings`).
117
135
  - **No deleting or skipping tests.** The agent is told not to, and every test
118
136
  change shows up in the diff you review.
119
137
  - **Reviewable.** It needs a clean git tree, so every change is a diff, and it
@@ -143,6 +161,13 @@ Native Renovate support is on the roadmap.
143
161
  workflows from commits pushed with the default `GITHUB_TOKEN`. Pass a personal
144
162
  access token or GitHub App token to `actions/checkout` if you want that.
145
163
 
164
+ **Can it fix deprecations before they break?** Yes. Make the test command
165
+ treat them as errors, e.g. `pytest -q -W error::DeprecationWarning` (some
166
+ libraries use their own warning class, like
167
+ `-W error::starlette.exceptions.StarletteDeprecationWarning`). On a real
168
+ 31-file FastAPI app, this caught Starlette's `httpx` → `httpx2` deprecation and
169
+ fixed it with a one-line dependency swap ($0.40).
170
+
146
171
  **Mocked tests?** driftfix can only catch what your tests catch. A test that
147
172
  mocks the library can pass while the real call is broken.
148
173
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "driftfix"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Dependabot bumps it. CI breaks. driftfix fixes the code."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -24,7 +24,7 @@ classifiers = [
24
24
  "Topic :: Software Development :: Build Tools",
25
25
  "Topic :: Software Development :: Quality Assurance",
26
26
  ]
27
- dependencies = ["claude-agent-sdk>=0.2"]
27
+ dependencies = ["claude-agent-sdk>=0.2", "packaging>=23"]
28
28
 
29
29
  [project.optional-dependencies]
30
30
  dev = ["pytest>=8", "ruff>=0.6"]
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import asyncio
7
+ import os
7
8
  import re
8
9
  import subprocess
9
10
  import sys
@@ -11,8 +12,13 @@ from dataclasses import dataclass
11
12
  from importlib.metadata import version
12
13
  from pathlib import Path
13
14
 
15
+ from packaging.requirements import InvalidRequirement, Requirement
16
+ from packaging.version import InvalidVersion, Version
17
+
14
18
  # Pinning back to the old version is not a fix. Adding a split-out package is fine.
15
19
  MANIFESTS = {"requirements.txt", "pyproject.toml", "setup.py", "setup.cfg", "Pipfile", "package.json"}
20
+ # The test suite runs the upgraded package's code; it shouldn't see our credentials.
21
+ SECRETS = {"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"}
16
22
  LOCKFILES = {"Pipfile.lock", "poetry.lock", "uv.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"}
17
23
 
18
24
  PROMPT = """\
@@ -24,7 +30,8 @@ $ {test_command}
24
30
  Fix this repository's code so it works with the new version of `{package}`.
25
31
  - Read the package's changelog / migration guide for the relevant versions if you need to.
26
32
  - Change application code. Change tests only where they use `{package}`'s API directly.
27
- - Never pin, downgrade, or change the requirement for `{package}`, and never edit lock files.
33
+ - Never pin or downgrade `{package}`, and never edit lock files. You may only widen its
34
+ requirement so the new version is allowed (e.g. drop an old upper bound).
28
35
  - If the new version moved code into a separate package, you may add that package to the
29
36
  dependency manifest and install it into the current environment.
30
37
  - Never delete or skip tests to make them pass.
@@ -41,7 +48,8 @@ class TestRun:
41
48
  def run_tests(command: str, repo: Path, timeout: int) -> TestRun:
42
49
  try:
43
50
  proc = subprocess.run(
44
- command, shell=True, cwd=repo, capture_output=True, text=True, timeout=timeout
51
+ command, shell=True, cwd=repo, capture_output=True, text=True, timeout=timeout,
52
+ env={k: v for k, v in os.environ.items() if k not in SECRETS},
45
53
  )
46
54
  except subprocess.TimeoutExpired:
47
55
  return TestRun(False, f"timed out after {timeout}s")
@@ -56,8 +64,17 @@ def changed_files(repo: Path) -> list[str]:
56
64
  return [line[3:].split(" -> ")[-1] for line in out.splitlines()]
57
65
 
58
66
 
59
- def pins_package(repo: Path, path: str, package: str) -> bool:
60
- """Did this edit touch a lock file, or any requirement line naming the upgraded package?"""
67
+ def admits(line: str, new_version: str | None) -> bool:
68
+ """Is this requirement line one that allows the upgraded version (e.g. a loosened cap)?"""
69
+ try:
70
+ req = Requirement(line.strip().strip("\"',"))
71
+ return new_version is not None and req.specifier.contains(Version(new_version), prereleases=True)
72
+ except (InvalidRequirement, InvalidVersion):
73
+ return False
74
+
75
+
76
+ def pins_package(repo: Path, path: str, package: str, new_version: str | None = None) -> bool:
77
+ """Did this edit touch a lock file, or add a requirement for the package that excludes the new version?"""
61
78
  if Path(path).name in LOCKFILES:
62
79
  return True
63
80
  if Path(path).name not in MANIFESTS:
@@ -65,13 +82,13 @@ def pins_package(repo: Path, path: str, package: str) -> bool:
65
82
  diff = subprocess.run(
66
83
  ["git", "diff", "-U0", "--", path], cwd=repo, capture_output=True, text=True
67
84
  ).stdout
68
- lines = (
69
- [line[1:] for line in diff.splitlines() if line[:1] in "+-" and line[:3] not in ("+++", "---")]
85
+ added = (
86
+ [line[1:] for line in diff.splitlines() if line[:1] == "+" and line[:3] != "+++"]
70
87
  if diff else (repo / path).read_text().splitlines() # new, untracked file
71
88
  )
72
89
  names = "|".join(re.sub(r"[-_.]", "[-_.]", n) for n in re.split(r"[,\s]+", package) if n)
73
90
  pattern = re.compile(rf"(?<![\w.-])({names})(?![\w.-])", re.IGNORECASE)
74
- return any(pattern.search(line) for line in lines)
91
+ return any(pattern.search(line) and not admits(line, new_version) for line in added)
75
92
 
76
93
 
77
94
  def build_prompt(package: str, old: str | None, new: str | None, command: str, out: str) -> str:
@@ -95,9 +112,12 @@ async def run_agent(prompt: str, repo: Path, model: str, max_turns: int, budget:
95
112
  skills=[],
96
113
  )
97
114
  summary, cost = "", 0.0
98
- async for message in query(prompt=prompt, options=options):
99
- if isinstance(message, ResultMessage):
100
- summary, cost = message.result or "", message.total_cost_usd or 0.0
115
+ try:
116
+ async for message in query(prompt=prompt, options=options):
117
+ if isinstance(message, ResultMessage):
118
+ summary, cost = message.result or "", message.total_cost_usd or 0.0
119
+ except Exception as exc: # e.g. budget reached; the cost was already reported above
120
+ return f"Agent stopped: `{exc}`", cost
101
121
  return summary, cost
102
122
 
103
123
 
@@ -120,7 +140,7 @@ def fix(args: argparse.Namespace) -> int:
120
140
  summary, cost = f"Agent error: `{exc}`", 0.0
121
141
 
122
142
  touched = [f for f in changed_files(repo) if f not in baseline]
123
- pinned = [f for f in touched if pins_package(repo, f, args.package)]
143
+ pinned = [f for f in touched if pins_package(repo, f, args.package, args.to_version)]
124
144
  after = run_tests(args.test, repo, args.timeout) # don't trust the agent's word
125
145
  ok = after.passed and bool(touched) and not pinned
126
146
 
@@ -83,3 +83,41 @@ def test_pins_package():
83
83
  assert pins_package(repo, "requirements.txt", "pydantic-core") # name normalization
84
84
  assert not pins_package(repo, "requirements.txt", "pydantic") # pydantic-core is a different package
85
85
  assert not pins_package(repo, "app.py", "pydantic")
86
+
87
+
88
+ def test_tests_cannot_see_api_key(tmp_path, monkeypatch):
89
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-secret")
90
+ leak = f"{sys.executable} -c 'import os, sys; sys.exit(\"ANTHROPIC_API_KEY\" in os.environ)'"
91
+ assert cli.run_tests(leak, tmp_path, 30).passed
92
+
93
+
94
+ def test_cost_survives_agent_error(monkeypatch):
95
+ import asyncio
96
+ import sys as _sys
97
+ import types
98
+
99
+ class ResultMessage:
100
+ result, total_cost_usd = "", 1.02
101
+
102
+ async def query(**kw):
103
+ yield ResultMessage()
104
+ raise RuntimeError("Reached maximum budget ($1)")
105
+
106
+ fake = types.SimpleNamespace(
107
+ ClaudeAgentOptions=lambda **kw: None, ResultMessage=ResultMessage, query=query
108
+ )
109
+ monkeypatch.setitem(_sys.modules, "claude_agent_sdk", fake)
110
+ summary, cost = asyncio.run(cli.run_agent("p", Path("."), "m", 1, 1.0))
111
+ assert cost == 1.02 and "maximum budget" in summary
112
+
113
+
114
+ def test_loosening_cap_is_allowed_pinning_back_is_not(tmp_path):
115
+ from driftfix.cli import pins_package
116
+ repo = make_repo(tmp_path, True)
117
+ (repo / "requirements.txt").write_text("lib>=2.0.0\n") # was lib==2, loosened
118
+ assert not pins_package(repo, "requirements.txt", "lib", "2.13.5")
119
+ (repo / "requirements.txt").write_text("lib<2\n") # pinned back
120
+ assert pins_package(repo, "requirements.txt", "lib", "2.13.5")
121
+ (repo / "requirements.txt").write_text("lib>=2.0.0\n") # unknown target version: be strict
122
+ assert pins_package(repo, "requirements.txt", "lib", None)
123
+ assert pins_package(repo, "requirements.txt", "lib", "1.x") # unparsable version: be strict
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes