Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 84 additions & 48 deletions scripts/check_deps.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,97 @@
#!/usr/bin/env python3
"""Check for dependency conflicts in pyproject.toml minimum versions."""

import subprocess
import json
import re
import sys
from urllib.error import HTTPError, URLError
from urllib.request import urlopen

try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib

with open("pyproject.toml", "rb") as f:
data = tomllib.load(f)

deps = {}
for dep in data["project"]["dependencies"]:
match = re.match(r"([a-zA-Z0-9_-]+)>=([0-9.]+)", dep)
if match:
pkg, ver = match.groups()
deps[pkg] = ver

conflicts = []
for pkg, ver in deps.items():
result = subprocess.run(
f"curl -s https://pypi.org/pypi/{pkg}/{ver}/json",
shell=True,
capture_output=True,
text=True,
)
if result.returncode == 0:

def load_dependencies() -> dict[str, str]:
"""Load minimum dependency versions from pyproject.toml."""
with open("pyproject.toml", "rb") as f:
data = tomllib.load(f)

deps = {}
for dep in data["project"]["dependencies"]:
match = re.match(r"([a-zA-Z0-9_-]+)>=([0-9.]+)", dep)
if match:
pkg, ver = match.groups()
deps[pkg] = ver
return deps


def fetch_package_metadata(pkg: str, ver: str) -> dict:
"""Fetch PyPI metadata for a package version."""
url = f"https://pypi.org/pypi/{pkg}/{ver}/json"
with urlopen(url, timeout=15) as response:
return json.loads(response.read().decode("utf-8"))


def version_parts(version: str) -> list[int]:
"""Convert a dotted version string to comparable integer parts."""
return [int(x) for x in version.split(".")]


def requires_higher_version(requirement: str, package: str, current_version: str) -> str | None:
"""Return the required version if a requirement exceeds the current minimum."""
if not (requirement.startswith(package + ">") or requirement.startswith(package + " ")):
return None

match = re.search(r">=(\d+\.\d+(?:\.\d+)?)", requirement)
if not match:
return None

required_version = match.group(1)
required_parts = version_parts(required_version)
current_parts = version_parts(current_version)

while len(required_parts) < len(current_parts):
required_parts.append(0)
while len(current_parts) < len(required_parts):
current_parts.append(0)

return required_version if required_parts > current_parts else None


def find_conflicts(deps: dict[str, str]) -> list[str]:
"""Find dependency minimum-version conflicts."""
conflicts = []
for pkg, ver in deps.items():
try:
pkg_data = json.loads(result.stdout)
requires = pkg_data.get("info", {}).get("requires_dist", [])
for req in requires:
if "extra ==" in req:
continue
for our_pkg, our_ver in deps.items():
if req.startswith(our_pkg + ">") or req.startswith(our_pkg + " "):
match = re.search(r">=(\d+\.\d+(?:\.\d+)?)", req)
if match:
required_ver = match.group(1)
req_parts = [int(x) for x in required_ver.split(".")]
our_parts = [int(x) for x in our_ver.split(".")]
while len(req_parts) < len(our_parts):
req_parts.append(0)
while len(our_parts) < len(req_parts):
our_parts.append(0)
if req_parts > our_parts:
conflicts.append(
f" ❌ {pkg}=={ver} requires {our_pkg}>={required_ver}, but we have >={our_ver}"
)
except Exception:
pass

if conflicts:
print("\n⚠️ Dependency conflicts found:")
for c in conflicts:
print(c)
sys.exit(1)
pkg_data = fetch_package_metadata(pkg, ver)
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError):
continue

requires = pkg_data.get("info", {}).get("requires_dist", [])
for req in requires:
if "extra ==" in req:
continue
for our_pkg, our_ver in deps.items():
required_ver = requires_higher_version(req, our_pkg, our_ver)
if required_ver:
conflicts.append(
f" ❌ {pkg}=={ver} requires {our_pkg}>={required_ver}, but we have >={our_ver}"
)
return conflicts


def main() -> int:
"""Check dependency minimum-version conflicts."""
conflicts = find_conflicts(load_dependencies())
if conflicts:
print("\n⚠️ Dependency conflicts found:")
for c in conflicts:
print(c)
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
10 changes: 10 additions & 0 deletions tests/test_check_deps_script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Tests for the dependency conflict checker script."""

from pathlib import Path


def test_check_deps_script_does_not_use_shell_true():
"""The dependency checker should not invoke network calls through a shell."""
source = Path("scripts/check_deps.py").read_text(encoding="utf-8")

assert "shell=True" not in source
Loading