Skip to content

Commit daf0b35

Browse files
lesebclaude
andauthored
fix(ci): update constraint deps script to handle missing and regular dependencies (#5865)
# What does this PR do? The Dependabot constraint-update workflow was silently skipping dependencies not already listed in `constraint-dependencies` (e.g. `google-genai`), causing Dependabot PRs to miss version floor updates in `pyproject.toml`. See [CI run](https://github.com/ogx-ai/ogx/actions/runs/25972495462/job/76346870212) for the `SKIP: google-genai not found in constraint-dependencies` output. This PR teaches the `update_constraint_deps.py` script a three-way lookup strategy: 1. **constraint-dependencies** (checked first, authoritative) — if found, update the `>=` floor there. Upper-bound constraints like `<2.12.0` are respected. 2. **Regular dependency arrays** (`[project] dependencies`, `[project.optional-dependencies]`, `[dependency-groups]`) — if found with a `>=` floor, update all occurrences in place. 3. **Neither** — insert a new entry into `constraint-dependencies` in alphabetical order. Also adds `find_dependency_lines()` to search all non-constraint dependency arrays, and `insert_constraint()` for alphabetically-ordered insertion. ## Test Plan Unit tests expanded from 31 to 42, covering all three lookup paths, alphabetical insertion edge cases, and the fall-through behavior when a bare dep (no `>=` floor) exists in regular deps but a floor exists in constraint-deps. ```bash uv run pytest tests/unit/test_update_constraint_deps.py -x --tb=short -v ``` Output: ``` 42 passed in 1.62s ``` Pre-commit: ```bash uv run pre-commit run --all-files # All hooks passed ``` Signed-off-by: Sébastien Han <seb@redhat.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 564e7e9 commit daf0b35

3 files changed

Lines changed: 267 additions & 20 deletions

File tree

.github/scripts/update_constraint_deps.py

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@
55
# This source code is licensed under the terms described in the LICENSE file in
66
# the root directory of this source tree.
77

8-
"""Update constraint-dependencies in pyproject.toml for Dependabot PRs.
8+
"""Update dependency version floors in pyproject.toml for Dependabot PRs.
99
1010
Dependabot's uv ecosystem only modifies uv.lock directly. This script
11-
updates the >= lower bound in [tool.uv] constraint-dependencies so that
12-
pyproject.toml remains the source of truth.
11+
keeps pyproject.toml in sync by updating the >= lower bound wherever the
12+
dependency is declared:
13+
14+
1. If found in a regular dependency array ([project] dependencies,
15+
[project.optional-dependencies], [dependency-groups]) — update in place.
16+
2. Else if found in [tool.uv] constraint-dependencies — update in place.
17+
3. Else — add a new entry to constraint-dependencies.
1318
"""
1419

1520
import argparse
@@ -51,6 +56,51 @@ def find_constraint_line(lines: list[str], pkg_name: str) -> int | None:
5156
return None
5257

5358

59+
def find_dependency_lines(lines: list[str], pkg_name: str) -> list[int]:
60+
"""Find all dependency lines for a package outside constraint-dependencies."""
61+
constraint_section = find_constraint_section(lines)
62+
exclude_start, exclude_end = constraint_section if constraint_section else (-1, -1)
63+
64+
pattern = re.compile(rf'^\s*"{normalize_pkg_pattern(pkg_name)}[\[>=<,"\s]', re.IGNORECASE)
65+
matches = []
66+
for i, line in enumerate(lines):
67+
if exclude_start <= i <= exclude_end:
68+
continue
69+
if pattern.match(line):
70+
matches.append(i)
71+
return matches
72+
73+
74+
def _canonical_sort_key(line: str) -> str:
75+
"""Extract the package name from a constraint line for alphabetical sorting."""
76+
m = re.match(r'^\s*"([^>=<\[" ]+)', line)
77+
return m.group(1).lower().replace("-", "").replace("_", "").replace(".", "") if m else ""
78+
79+
80+
def insert_constraint(lines: list[str], pkg_name: str, version: str) -> tuple[list[str], bool, str]:
81+
"""Insert a new constraint-dependencies entry in alphabetical order.
82+
83+
Returns (new_lines, changed, reason).
84+
"""
85+
section = find_constraint_section(lines)
86+
if section is None:
87+
return lines, False, "constraint-dependencies section not found in pyproject.toml"
88+
89+
start, end = section
90+
91+
new_entry = f' "{pkg_name}>={version}",\n'
92+
new_key = _canonical_sort_key(new_entry)
93+
94+
insert_at = end
95+
for i in range(start + 1, end + 1):
96+
if _canonical_sort_key(lines[i]) > new_key:
97+
insert_at = i
98+
break
99+
100+
lines.insert(insert_at, new_entry)
101+
return lines, True, f"{pkg_name}: added to constraint-dependencies with >={version}"
102+
103+
54104
def update_constraint(line: str, pkg_name: str, new_version: str) -> tuple[str, bool, str]:
55105
"""Update the >= lower bound in a constraint-dependencies line.
56106
@@ -96,22 +146,53 @@ def main() -> int:
96146
content = pyproject_path.read_text()
97147
lines = content.splitlines(keepends=True)
98148

99-
line_idx = find_constraint_line(lines, args.dependency_name)
100-
if line_idx is None:
101-
print(f"SKIP: {args.dependency_name} not found in constraint-dependencies")
102-
print("updated=false")
149+
constraint_idx = find_constraint_line(lines, args.dependency_name)
150+
dep_indices = find_dependency_lines(lines, args.dependency_name)
151+
152+
# 1. If in constraint-dependencies, that's the authoritative version spec — update there
153+
if constraint_idx is not None:
154+
new_line, changed, reason = update_constraint(
155+
lines[constraint_idx], args.dependency_name, args.dependency_version
156+
)
157+
if not changed:
158+
print(f"SKIP: {reason}")
159+
print("updated=false")
160+
return 0
161+
lines[constraint_idx] = new_line
162+
pyproject_path.write_text("".join(lines))
163+
print(f"UPDATED: {reason}")
164+
print("updated=true")
103165
return 0
104166

105-
new_line, changed, reason = update_constraint(lines[line_idx], args.dependency_name, args.dependency_version)
167+
# 2. Try updating >= floors in regular dependency arrays
168+
if dep_indices:
169+
any_changed = False
170+
skip_reasons = []
171+
for idx in dep_indices:
172+
new_line, changed, reason = update_constraint(lines[idx], args.dependency_name, args.dependency_version)
173+
if changed:
174+
lines[idx] = new_line
175+
any_changed = True
176+
print(f"UPDATED (dependencies): {reason}")
177+
else:
178+
skip_reasons.append(reason)
179+
if any_changed:
180+
pyproject_path.write_text("".join(lines))
181+
print("updated=true")
182+
else:
183+
for r in skip_reasons:
184+
print(f"SKIP (dependencies): {r}")
185+
print("updated=false")
186+
return 0
106187

188+
# 3. Not found anywhere — add to constraint-dependencies
189+
lines, changed, reason = insert_constraint(lines, args.dependency_name, args.dependency_version)
107190
if not changed:
108191
print(f"SKIP: {reason}")
109192
print("updated=false")
110193
return 0
111-
112-
lines[line_idx] = new_line
113194
pyproject_path.write_text("".join(lines))
114-
print(f"UPDATED: {reason}")
195+
print(f"ADDED: {reason}")
115196
print("updated=true")
116197
return 0
117198

.github/workflows/dependabot-constraints.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ jobs:
6868
} >> "$GITHUB_OUTPUT"
6969
echo "Parsed: $dep_name $dep_version"
7070
71-
- name: Update constraint-dependencies in pyproject.toml
71+
- name: Update dependency version floors in pyproject.toml
7272
if: steps.parse.outputs.skip != 'true'
7373
id: update
7474
env:

tests/unit/test_update_constraint_deps.py

Lines changed: 174 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
normalize_pkg_pattern = _mod.normalize_pkg_pattern
2121
find_constraint_section = _mod.find_constraint_section
2222
find_constraint_line = _mod.find_constraint_line
23+
find_dependency_lines = _mod.find_dependency_lines
24+
insert_constraint = _mod.insert_constraint
2325
update_constraint = _mod.update_constraint
2426
main = _mod.main
2527

@@ -44,6 +46,17 @@
4446
"requests>=2.28.0",
4547
"pydantic>=2.11.9",
4648
]
49+
50+
[project.optional-dependencies]
51+
starter = [
52+
"aiohttp",
53+
"google-genai>=1.69.0",
54+
]
55+
56+
[dependency-groups]
57+
test = [
58+
"google-genai>=1.69.0",
59+
]
4760
""")
4861

4962

@@ -135,6 +148,78 @@ def test_returns_none_for_unknown_package(self):
135148
assert find_constraint_line(lines, "nonexistent-pkg") is None
136149

137150

151+
class TestFindDependencyLines:
152+
def test_finds_in_project_dependencies(self):
153+
lines = SAMPLE_PYPROJECT.splitlines(keepends=True)
154+
indices = find_dependency_lines(lines, "requests")
155+
assert len(indices) == 1
156+
assert "requests" in lines[indices[0]]
157+
158+
def test_finds_in_optional_and_groups(self):
159+
lines = SAMPLE_PYPROJECT.splitlines(keepends=True)
160+
indices = find_dependency_lines(lines, "google-genai")
161+
assert len(indices) == 2
162+
for idx in indices:
163+
assert "google-genai" in lines[idx]
164+
165+
def test_excludes_constraint_dependencies(self):
166+
lines = SAMPLE_PYPROJECT.splitlines(keepends=True)
167+
indices = find_dependency_lines(lines, "urllib3")
168+
assert len(indices) == 0
169+
170+
def test_finds_bare_dep_without_version(self):
171+
lines = SAMPLE_PYPROJECT.splitlines(keepends=True)
172+
indices = find_dependency_lines(lines, "aiohttp")
173+
assert len(indices) == 1
174+
assert '"aiohttp"' in lines[indices[0]]
175+
176+
def test_returns_empty_for_unknown(self):
177+
lines = SAMPLE_PYPROJECT.splitlines(keepends=True)
178+
assert find_dependency_lines(lines, "nonexistent") == []
179+
180+
181+
class TestInsertConstraint:
182+
def test_inserts_alphabetically_middle(self):
183+
lines = SAMPLE_PYPROJECT.splitlines(keepends=True)
184+
new_lines, changed, reason = insert_constraint(lines, "google-genai", "2.3.0")
185+
assert changed is True
186+
assert "added to constraint-dependencies" in reason
187+
joined = "".join(new_lines)
188+
assert '"google-genai>=2.3.0"' in joined
189+
constraint_lines = [line.strip() for line in new_lines if "google-genai" in line or "litellm" in line]
190+
assert constraint_lines.index(' "google-genai>=2.3.0",'.strip()) < constraint_lines.index(
191+
' "litellm<1.83.7", # upper-bound only'.strip()
192+
)
193+
194+
def test_inserts_alphabetically_beginning(self):
195+
lines = SAMPLE_PYPROJECT.splitlines(keepends=True)
196+
new_lines, changed, _ = insert_constraint(lines, "aaa-first", "1.0.0")
197+
assert changed is True
198+
joined = "".join(new_lines)
199+
aaa_pos = joined.index('"aaa-first>=1.0.0"')
200+
aiohttp_pos = joined.index('"aiohttp>=')
201+
assert aaa_pos < aiohttp_pos
202+
203+
def test_inserts_alphabetically_end(self):
204+
lines = SAMPLE_PYPROJECT.splitlines(keepends=True)
205+
new_lines, changed, _ = insert_constraint(lines, "zzz-last", "1.0.0")
206+
assert changed is True
207+
joined = "".join(new_lines)
208+
zzz_pos = joined.index('"zzz-last>=1.0.0"')
209+
urllib3_pos = joined.index('"urllib3>=')
210+
assert zzz_pos > urllib3_pos
211+
212+
def test_returns_false_when_no_constraint_section(self):
213+
lines = textwrap.dedent("""\
214+
[project]
215+
name = "no-constraints"
216+
dependencies = ["requests"]
217+
""").splitlines(keepends=True)
218+
_, changed, reason = insert_constraint(lines, "pkg", "1.0.0")
219+
assert changed is False
220+
assert "not found" in reason
221+
222+
138223
class TestUpdateConstraint:
139224
def test_updates_lower_bound(self):
140225
line = ' "aiohttp>=3.13.4", # CVE-2026-34514\n'
@@ -221,7 +306,8 @@ def test_updates_constraint_in_file(self, tmp_path):
221306
assert "aiohttp>=3.14.0" in content
222307
assert "CVE-2026-34514" in content
223308

224-
def test_skips_unknown_package(self, tmp_path):
309+
def test_updates_dependency_in_place(self, tmp_path):
310+
"""A dep in optional-dependencies/dependency-groups gets updated in place."""
225311
pyproject = tmp_path / "pyproject.toml"
226312
pyproject.write_text(SAMPLE_PYPROJECT)
227313

@@ -230,7 +316,32 @@ def test_skips_unknown_package(self, tmp_path):
230316
"python3",
231317
str(_script_path),
232318
"--dependency-name",
233-
"nonexistent",
319+
"google-genai",
320+
"--dependency-version",
321+
"2.3.0",
322+
"--pyproject",
323+
str(pyproject),
324+
],
325+
capture_output=True,
326+
text=True,
327+
)
328+
assert result.returncode == 0
329+
assert "updated=true" in result.stdout
330+
assert "UPDATED (dependencies)" in result.stdout
331+
content = pyproject.read_text()
332+
assert content.count("google-genai>=2.3.0") == 2
333+
assert "google-genai>=1.69.0" not in content
334+
335+
def test_adds_unknown_package(self, tmp_path):
336+
pyproject = tmp_path / "pyproject.toml"
337+
pyproject.write_text(SAMPLE_PYPROJECT)
338+
339+
result = subprocess.run(
340+
[
341+
"python3",
342+
str(_script_path),
343+
"--dependency-name",
344+
"some-new-pkg",
234345
"--dependency-version",
235346
"1.0.0",
236347
"--pyproject",
@@ -240,8 +351,10 @@ def test_skips_unknown_package(self, tmp_path):
240351
text=True,
241352
)
242353
assert result.returncode == 0
243-
assert "updated=false" in result.stdout
244-
assert pyproject.read_text() == SAMPLE_PYPROJECT
354+
assert "updated=true" in result.stdout
355+
assert "ADDED" in result.stdout
356+
content = pyproject.read_text()
357+
assert '"some-new-pkg>=1.0.0"' in content
245358

246359
def test_skips_upper_bound_conflict(self, tmp_path):
247360
pyproject = tmp_path / "pyproject.toml"
@@ -265,9 +378,9 @@ def test_skips_upper_bound_conflict(self, tmp_path):
265378
assert "updated=false" in result.stdout
266379
assert "pydantic>=2.11.9,<2.12.0" in pyproject.read_text()
267380

268-
def test_does_not_modify_project_dependencies(self, tmp_path):
269-
"""Updating a package that exists in [project] dependencies but not in
270-
constraint-dependencies must not touch pyproject.toml."""
381+
def test_updates_project_dependency_in_place(self, tmp_path):
382+
"""A package in [project] dependencies gets updated in place, not added
383+
to constraint-dependencies."""
271384
pyproject = tmp_path / "pyproject.toml"
272385
pyproject.write_text(SAMPLE_PYPROJECT)
273386

@@ -286,8 +399,61 @@ def test_does_not_modify_project_dependencies(self, tmp_path):
286399
text=True,
287400
)
288401
assert result.returncode == 0
402+
assert "updated=true" in result.stdout
403+
assert "UPDATED (dependencies)" in result.stdout
404+
content = pyproject.read_text()
405+
assert "requests>=2.32.0" in content
406+
407+
def test_skips_dep_in_deps_when_version_not_newer(self, tmp_path):
408+
"""A dep in dependencies with a >= floor should skip when the new version
409+
is not newer, and NOT fall through to add to constraint-dependencies."""
410+
pyproject = tmp_path / "pyproject.toml"
411+
pyproject.write_text(SAMPLE_PYPROJECT)
412+
original = pyproject.read_text()
413+
414+
result = subprocess.run(
415+
[
416+
"python3",
417+
str(_script_path),
418+
"--dependency-name",
419+
"google-genai",
420+
"--dependency-version",
421+
"1.50.0",
422+
"--pyproject",
423+
str(pyproject),
424+
],
425+
capture_output=True,
426+
text=True,
427+
)
428+
assert result.returncode == 0
289429
assert "updated=false" in result.stdout
290-
assert pyproject.read_text() == SAMPLE_PYPROJECT
430+
assert pyproject.read_text() == original
431+
432+
def test_bare_dep_falls_through_to_constraint(self, tmp_path):
433+
"""A dep without a >= floor in dependencies falls through to
434+
constraint-dependencies if present there."""
435+
pyproject = tmp_path / "pyproject.toml"
436+
pyproject.write_text(SAMPLE_PYPROJECT)
437+
438+
result = subprocess.run(
439+
[
440+
"python3",
441+
str(_script_path),
442+
"--dependency-name",
443+
"aiohttp",
444+
"--dependency-version",
445+
"3.14.0",
446+
"--pyproject",
447+
str(pyproject),
448+
],
449+
capture_output=True,
450+
text=True,
451+
)
452+
assert result.returncode == 0
453+
assert "updated=true" in result.stdout
454+
content = pyproject.read_text()
455+
assert "aiohttp>=3.14.0" in content
456+
assert "CVE-2026-34514" in content
291457

292458
def test_missing_pyproject_returns_error(self, tmp_path):
293459
result = subprocess.run(

0 commit comments

Comments
 (0)