Skip to content

Commit a139e88

Browse files
authored
ci: lint project dependency ranges (#15199)
## Description Add a lint to check the version ranges of dependencies defined in `pyproject.toml`. This will help ensure any modifications or additions of dependencies will conform to an upper bound definition. ## Testing <!-- Describe your testing strategy or note what tests are included --> ## Risks We aren't checking dependencies of dependencies here. ## Additional Notes <!-- Any other information that would be helpful for reviewers -->
1 parent 1c3361c commit a139e88

File tree

5 files changed

+205
-15
lines changed

5 files changed

+205
-15
lines changed

lib-injection/sources/requirements.csv

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
Dependency,Version Specifier,Python Version
2-
bytecode,>=0.17.0,python_version>='3.14.0'
3-
bytecode,>=0.16.0,python_version>='3.13.0'
4-
bytecode,>=0.15.1,python_version~='3.12.0'
5-
bytecode,>=0.14.0,python_version~='3.11.0'
6-
bytecode,>=0.13.0,python_version<'3.11'
2+
bytecode,">=0.17.0,<1",python_version>='3.14.0'
3+
bytecode,">=0.16.0,<1",python_version>='3.13.0'
4+
bytecode,">=0.15.1,<1",python_version~='3.12.0'
5+
bytecode,">=0.14.0,<1",python_version~='3.11.0'
6+
bytecode,">=0.13.0,<1",python_version<'3.11'
77
envier,~=0.6.1,
88
opentelemetry-api,">=1,<2",
99
wrapt,">=1,<3",

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ classifiers = [
3535
"Programming Language :: Python :: 3.14",
3636
]
3737
dependencies = [
38-
"bytecode>=0.17.0; python_version>='3.14.0'",
39-
"bytecode>=0.16.0; python_version>='3.13.0'",
40-
"bytecode>=0.15.1; python_version~='3.12.0'",
41-
"bytecode>=0.14.0; python_version~='3.11.0'",
42-
"bytecode>=0.13.0; python_version<'3.11'",
38+
"bytecode>=0.17.0,<1; python_version>='3.14.0'",
39+
"bytecode>=0.16.0,<1; python_version>='3.13.0'",
40+
"bytecode>=0.15.1,<1; python_version~='3.12.0'",
41+
"bytecode>=0.14.0,<1; python_version~='3.11.0'",
42+
"bytecode>=0.13.0,<1; python_version<'3.11'",
4343
"envier~=0.6.1",
4444
"opentelemetry-api>=1,<2",
4545
"wrapt>=1,<3",

requirements.csv

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
Dependency,Version Specifier,Python Version
2-
bytecode,>=0.17.0,python_version>='3.14.0'
3-
bytecode,>=0.16.0,python_version>='3.13.0'
4-
bytecode,>=0.15.1,python_version~='3.12.0'
5-
bytecode,>=0.14.0,python_version~='3.11.0'
6-
bytecode,>=0.13.0,python_version<'3.11'
2+
bytecode,">=0.17.0,<1",python_version>='3.14.0'
3+
bytecode,">=0.16.0,<1",python_version>='3.13.0'
4+
bytecode,">=0.15.1,<1",python_version~='3.12.0'
5+
bytecode,">=0.14.0,<1",python_version~='3.11.0'
6+
bytecode,">=0.13.0,<1",python_version<'3.11'
77
envier,~=0.6.1,
88
opentelemetry-api,">=1,<2",
99
wrapt,">=1,<3",

scripts/check-dependency-bounds

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#!/usr/bin/env scripts/uv-run-script
2+
# -*- mode: python -*-
3+
# /// script
4+
# dependencies = [
5+
# "packaging>=23.1,<24",
6+
# ]
7+
# ///
8+
"""
9+
Validate that all project dependencies have well-defined version ranges.
10+
11+
This script checks that dependencies in pyproject.toml have both lower and upper
12+
bounds to prevent unexpected breaking changes from transitive dependencies.
13+
14+
Acceptable formats:
15+
- >=X.Y,<X.Z (explicit bounds)
16+
- ~=X.Y.Z (compatible release with at least x.y format, implies upper bound)
17+
18+
Unacceptable formats:
19+
- >=X.Y (open-ended, no upper bound)
20+
- <X.Y (only upper bound)
21+
- ~=X (only single version component, too open-ended)
22+
- package (no version specified)
23+
"""
24+
25+
import sys
26+
import tomllib
27+
from pathlib import Path
28+
from typing import Dict, List, Tuple
29+
30+
from packaging.specifiers import SpecifierSet
31+
32+
33+
def load_pyproject() -> Dict:
34+
"""Load and parse pyproject.toml from the current directory."""
35+
pyproject_path = Path("pyproject.toml")
36+
if not pyproject_path.exists():
37+
print(f"Error: {pyproject_path} not found")
38+
sys.exit(1)
39+
40+
with open(pyproject_path, "rb") as f:
41+
return tomllib.load(f)
42+
43+
44+
def has_lower_bound(spec_set: SpecifierSet) -> bool:
45+
"""Check if specifier set has a lower bound."""
46+
for spec in spec_set:
47+
operator = spec.operator
48+
if operator in (">=", ">", "~=", "=="):
49+
return True
50+
return False
51+
52+
53+
def has_upper_bound(spec_set: SpecifierSet) -> bool:
54+
"""Check if specifier set has an upper bound."""
55+
for spec in spec_set:
56+
operator = spec.operator
57+
if operator in ("<", "<=", "~=", "=="):
58+
return True
59+
return False
60+
61+
62+
def is_well_defined(spec_string: str) -> Tuple[bool, str]:
63+
"""
64+
Check if a version specifier is well-defined.
65+
66+
Returns:
67+
Tuple of (is_valid, reason_if_invalid)
68+
"""
69+
# Handle empty string (no version specified)
70+
if not spec_string.strip():
71+
return False, "no version specified"
72+
73+
try:
74+
spec_set = SpecifierSet(spec_string)
75+
except Exception as e:
76+
return False, f"invalid specifier: {e}"
77+
78+
# Check for lower bound
79+
if not has_lower_bound(spec_set):
80+
return False, "missing lower bound (no >=, >, ~=, or ==)"
81+
82+
# Check for upper bound
83+
if not has_upper_bound(spec_set):
84+
return False, "missing upper bound (no <, <=, ~=, or ==)"
85+
86+
# If using compatible release (~=), ensure it has at least x.y format
87+
for spec in spec_set:
88+
if spec.operator == "~=":
89+
version_parts = spec.version.split(".")
90+
if len(version_parts) < 2:
91+
return False, f"compatible release ~= requires at least x.y format (got ~={spec.version})"
92+
93+
return True, ""
94+
95+
96+
def check_dependencies(data: Dict) -> List[str]:
97+
"""
98+
Check all project dependencies for well-defined version ranges.
99+
100+
Returns:
101+
List of error messages for violations found
102+
"""
103+
violations = []
104+
105+
# Check project.dependencies
106+
if "project" in data and "dependencies" in data["project"]:
107+
violations.extend(
108+
check_dependency_section(
109+
data["project"]["dependencies"],
110+
"project.dependencies",
111+
)
112+
)
113+
114+
# Check project.optional-dependencies
115+
if "project" in data and "optional-dependencies" in data["project"]:
116+
optional_deps = data["project"]["optional-dependencies"]
117+
for group_name, deps in optional_deps.items():
118+
violations.extend(
119+
check_dependency_section(
120+
deps,
121+
f"project.optional-dependencies[{group_name}]",
122+
)
123+
)
124+
125+
return violations
126+
127+
128+
def check_dependency_section(deps: List[str], section_name: str) -> List[str]:
129+
"""Check a single dependency section for violations."""
130+
violations = []
131+
132+
for dep_line in deps:
133+
# Parse package name and version specifier
134+
# Format: "package_name[extras]version_spec" or just "package_name"
135+
parts = dep_line.split(";", 1) # Split on environment marker
136+
dep_spec = parts[0].strip()
137+
138+
# Extract package name and version specifier
139+
# Find where the version specifier starts (first operator: >, <, =, ~, !)
140+
package_name = ""
141+
version_spec = ""
142+
143+
for i, char in enumerate(dep_spec):
144+
if char in (">=", "<", "=", "~", "!", ">", "<") or dep_spec[i:].startswith((">=", "<=", "~=")):
145+
package_name = dep_spec[:i].strip()
146+
version_spec = dep_spec[i:].strip()
147+
break
148+
else:
149+
# No version specifier found
150+
package_name = dep_spec.strip()
151+
version_spec = ""
152+
153+
# Remove environment markers from package name if present
154+
if "[" in package_name:
155+
package_name = package_name.split("[")[0]
156+
157+
is_valid, reason = is_well_defined(version_spec)
158+
159+
if not is_valid:
160+
violations.append(
161+
f"{section_name}: '{package_name}' has {reason} (specifier: '{version_spec}')"
162+
)
163+
164+
return violations
165+
166+
167+
def main() -> int:
168+
"""Main entry point."""
169+
data = load_pyproject()
170+
violations = check_dependencies(data)
171+
172+
if violations:
173+
print("❌ Dependency validation failed:\n")
174+
for violation in violations:
175+
print(f" {violation}")
176+
print("\nAll dependencies must have both lower and upper bounds.")
177+
print("Acceptable formats: >=X.Y,<X.Z or ~=X.Y.Z (compatible release)")
178+
return 1
179+
180+
print("✅ All dependencies have well-defined version ranges")
181+
return 0
182+
183+
184+
if __name__ == "__main__":
185+
sys.exit(main())

scripts/gen_gitlab_config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,11 @@ def check(name: str, command: str, paths: t.Set[str]) -> None:
266266
command="hatch run lint:error-log-check",
267267
paths={"ddtrace/contrib/**/*.py"},
268268
)
269+
check(
270+
name="Check project dependency bounds",
271+
command="scripts/check-dependency-bounds",
272+
paths={"pyproject.toml"},
273+
)
269274
if not checks:
270275
return
271276

0 commit comments

Comments
 (0)