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
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ outdated:
$(PCU) pyproject.toml -t latest --extra dev --fail_on_update
@echo "✅ Dependency outdated check passed."

# compatibility: Checks each dependencies for python version compatibility
.PHONY: compatibility
compatibility:
@echo "🔍 Checking dependencies for python version compatibility..."
ifdef py_version
$(PYTHON) check_compatibility.py $(py_version)
else
$(PYTHON) check_compatibility.py
endif
@echo "✅ Compatibility check done."

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

environment variables.

The `make compatibility` accepts a parameter example `make compatibility py_version=3.9` to mark dependencies
that are not compatible with the given target version.

## Usage

Once the library (module) is published or just built locally, it can be used.
Expand Down
139 changes: 139 additions & 0 deletions check_compatibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import json
import re
import sys
import urllib.request
from typing import List, Tuple


def parse_dependencies(file_path: str) -> List[Tuple[str, str]]:
dependencies = []
try:
with open(file_path, "r") as f:
content = f.read()
except FileNotFoundError:
print(f"Error: File '{file_path}' not found.")
sys.exit(1)

# Extract [project] dependencies
project_deps_match = re.search(r"dependencies\s*=\s*\[(.*?)\]", content, re.DOTALL)
if project_deps_match:
raw_deps = project_deps_match.group(1)
dependencies.extend(extract_deps_from_string(raw_deps))

# Extract [project.optional-dependencies] dev
dev_deps_match = re.search(r"dev\s*=\s*\[(.*?)\]", content, re.DOTALL)
if dev_deps_match:
raw_deps = dev_deps_match.group(1)
dependencies.extend(extract_deps_from_string(raw_deps))

return dependencies


def extract_deps_from_string(raw_string: str) -> List[Tuple[str, str]]:
deps = []
matches = re.findall(r'"(.*?)"', raw_string)
for match in matches:
# Split package name and version specifier
parts = re.split(r"==|>=|<=|~=", match)
name = parts[0].strip()
version = parts[1].strip() if len(parts) > 1 else "latest"
deps.append((name, version))
return deps


def get_python_requires(package: str, version: str) -> str:
if version == "latest":
url = f"https://pypi.org/pypi/{package}/json"
else:
url = f"https://pypi.org/pypi/{package}/{version}/json"

try:
with urllib.request.urlopen(url) as response: # nosec B310
data = json.loads(response.read().decode())
return data["info"].get("requires_python") or "Unknown"
except Exception:
# Fallback to latest if specific version fails
try:
url = f"https://pypi.org/pypi/{package}/json"
with urllib.request.urlopen(url) as response: # nosec B310
data = json.loads(response.read().decode())
return data["info"].get("requires_python") or "Unknown"
except Exception as e:
return f"Error: {e}"


def is_compatible(requires_python: str, target_version: str) -> bool:
if requires_python == "Unknown" or requires_python.startswith("Error"):
return True

# Clean up the requires_python string
req = requires_python.replace(" ", "")
conditions = req.split(",")

try:
target_ver_tuple = tuple(map(int, target_version.split(".")))
except ValueError:
return True # Invalid target version format, assume compatible

def to_tuple(v_str):
return tuple(map(int, v_str.split(".")))

for condition in conditions:
try:
if condition.startswith(">="):
v_tuple = to_tuple(condition[2:])
if target_ver_tuple < v_tuple:
return False
elif condition.startswith(">"):
v_tuple = to_tuple(condition[1:])
if target_ver_tuple <= v_tuple:
return False
elif condition.startswith("<="):
v_tuple = to_tuple(condition[2:])
if target_ver_tuple > v_tuple:
return False
elif condition.startswith("<"):
v_tuple = to_tuple(condition[1:])
if target_ver_tuple >= v_tuple:
return False
# Ignoring ==, !=, ~= for simplicity as they are less common for python_requires
except ValueError:
continue # Skip malformed version strings in requires_python

return True


def main():
"""
Check the minimum python version compatibility for each dependency in pyproject.toml.
Accepts an optional target version parameter example "3.9" and marks dependencies that
are not compatible with the given target version.
If the optional parameter is not provided then marker is not displayed.

Usage:
python3 check_compatibility.py
python3 check_compatibility.py 3.9
"""
target_version = None
if len(sys.argv) > 1:
target_version = sys.argv[1]
print(f"Checking compatibility for Python {target_version}...\n")

print(f"{'':<2} {'Dependency':<25} | {'Version':<15} | {'Min Python Version'}")
print("-" * 70)

deps = parse_dependencies("pyproject.toml")

for name, version in deps:
requires_python = get_python_requires(name, version)

marker = " "
if target_version:
if not is_compatible(requires_python, target_version):
marker = "* "

print(f"{marker}{name:<25} | {version:<15} | {requires_python}")


if __name__ == "__main__":
main()