Skip to content

Commit 60cb235

Browse files
Merge pull request #1 from gergelykovacs/feature-compatibility-check
Feature: utility script for dependency compatibility with python vers…
2 parents 27792fc + 089a634 commit 60cb235

File tree

3 files changed

+154
-0
lines changed

3 files changed

+154
-0
lines changed

Makefile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ outdated:
6868
$(PCU) pyproject.toml -t latest --extra dev --fail_on_update
6969
@echo "✅ Dependency outdated check passed."
7070

71+
# compatibility: Checks each dependencies for python version compatibility
72+
.PHONY: compatibility
73+
compatibility:
74+
@echo "🔍 Checking dependencies for python version compatibility..."
75+
ifdef py_version
76+
$(PYTHON) check_compatibility.py $(py_version)
77+
else
78+
$(PYTHON) check_compatibility.py
79+
endif
80+
@echo "✅ Compatibility check done."
81+
7182
# PIP Upgrade: upgrade PIP to its latest version
7283
.PHONY: pip-upgrade
7384
pip-upgrade:

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Check the [Makefile](./Makefile) for automation as the initial step, it defines
1818
| `make install` | Syncs the environment with locked dependencies and installs the app in editable mode. |
1919
| `make setup` | Installs dependencies and sets up git hooks (runs `install` and `pre-commit install`). |
2020
| `make outdated` | Checks for newer versions of dependencies using `pip-check-updates`. |
21+
| `make compatibility` | Checks each dependencies for python version compatibility. |
2122
| `make pip-upgrade` | Upgrades `pip` to its latest version. |
2223
| `make lint` | Checks code style using `ruff` without modifying files. |
2324
| `make format` | Automatically fixes code style issues using `ruff`. |
@@ -46,6 +47,9 @@ export TWINE_REPOSITORY_URL="https://nexus.mycompany.com/repository/pypi-interna
4647

4748
environment variables.
4849

50+
The `make compatibility` accepts a parameter example `make compatibility py_version=3.9` to mark dependencies
51+
that are not compatible with the given target version.
52+
4953
## Usage
5054

5155
Start the application

check_compatibility.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import json
2+
import re
3+
import sys
4+
import urllib.request
5+
from typing import List, Tuple
6+
7+
8+
def parse_dependencies(file_path: str) -> List[Tuple[str, str]]:
9+
dependencies = []
10+
try:
11+
with open(file_path, "r") as f:
12+
content = f.read()
13+
except FileNotFoundError:
14+
print(f"Error: File '{file_path}' not found.")
15+
sys.exit(1)
16+
17+
# Extract [project] dependencies
18+
project_deps_match = re.search(r"dependencies\s*=\s*\[(.*?)\]", content, re.DOTALL)
19+
if project_deps_match:
20+
raw_deps = project_deps_match.group(1)
21+
dependencies.extend(extract_deps_from_string(raw_deps))
22+
23+
# Extract [project.optional-dependencies] dev
24+
dev_deps_match = re.search(r"dev\s*=\s*\[(.*?)\]", content, re.DOTALL)
25+
if dev_deps_match:
26+
raw_deps = dev_deps_match.group(1)
27+
dependencies.extend(extract_deps_from_string(raw_deps))
28+
29+
return dependencies
30+
31+
32+
def extract_deps_from_string(raw_string: str) -> List[Tuple[str, str]]:
33+
deps = []
34+
matches = re.findall(r'"(.*?)"', raw_string)
35+
for match in matches:
36+
# Split package name and version specifier
37+
parts = re.split(r"==|>=|<=|~=", match)
38+
name = parts[0].strip()
39+
version = parts[1].strip() if len(parts) > 1 else "latest"
40+
deps.append((name, version))
41+
return deps
42+
43+
44+
def get_python_requires(package: str, version: str) -> str:
45+
if version == "latest":
46+
url = f"https://pypi.org/pypi/{package}/json"
47+
else:
48+
url = f"https://pypi.org/pypi/{package}/{version}/json"
49+
50+
try:
51+
with urllib.request.urlopen(url) as response: # nosec B310
52+
data = json.loads(response.read().decode())
53+
return data["info"].get("requires_python") or "Unknown"
54+
except Exception:
55+
# Fallback to latest if specific version fails
56+
try:
57+
url = f"https://pypi.org/pypi/{package}/json"
58+
with urllib.request.urlopen(url) as response: # nosec B310
59+
data = json.loads(response.read().decode())
60+
return data["info"].get("requires_python") or "Unknown"
61+
except Exception as e:
62+
return f"Error: {e}"
63+
64+
65+
def is_compatible(requires_python: str, target_version: str) -> bool:
66+
if requires_python == "Unknown" or requires_python.startswith("Error"):
67+
return True
68+
69+
# Clean up the requires_python string
70+
req = requires_python.replace(" ", "")
71+
conditions = req.split(",")
72+
73+
try:
74+
target_ver_tuple = tuple(map(int, target_version.split(".")))
75+
except ValueError:
76+
return True # Invalid target version format, assume compatible
77+
78+
def to_tuple(v_str):
79+
return tuple(map(int, v_str.split(".")))
80+
81+
for condition in conditions:
82+
try:
83+
if condition.startswith(">="):
84+
v_tuple = to_tuple(condition[2:])
85+
if target_ver_tuple < v_tuple:
86+
return False
87+
elif condition.startswith(">"):
88+
v_tuple = to_tuple(condition[1:])
89+
if target_ver_tuple <= v_tuple:
90+
return False
91+
elif condition.startswith("<="):
92+
v_tuple = to_tuple(condition[2:])
93+
if target_ver_tuple > v_tuple:
94+
return False
95+
elif condition.startswith("<"):
96+
v_tuple = to_tuple(condition[1:])
97+
if target_ver_tuple >= v_tuple:
98+
return False
99+
# Ignoring ==, !=, ~= for simplicity as they are less common for python_requires
100+
except ValueError:
101+
continue # Skip malformed version strings in requires_python
102+
103+
return True
104+
105+
106+
def main():
107+
"""
108+
Check the minimum python version compatibility for each dependency in pyproject.toml.
109+
Accepts an optional target version parameter example "3.9" and marks dependencies that
110+
are not compatible with the given target version.
111+
If the optional parameter is not provided then marker is not displayed.
112+
113+
Usage:
114+
python3 check_compatibility.py
115+
python3 check_compatibility.py 3.9
116+
"""
117+
target_version = None
118+
if len(sys.argv) > 1:
119+
target_version = sys.argv[1]
120+
print(f"Checking compatibility for Python {target_version}...\n")
121+
122+
print(f"{'':<2} {'Dependency':<25} | {'Version':<15} | {'Min Python Version'}")
123+
print("-" * 70)
124+
125+
deps = parse_dependencies("pyproject.toml")
126+
127+
for name, version in deps:
128+
requires_python = get_python_requires(name, version)
129+
130+
marker = " "
131+
if target_version:
132+
if not is_compatible(requires_python, target_version):
133+
marker = "* "
134+
135+
print(f"{marker}{name:<25} | {version:<15} | {requires_python}")
136+
137+
138+
if __name__ == "__main__":
139+
main()

0 commit comments

Comments
 (0)