ci: check supported versions once a week (#1419)
Some checks are pending
Docs - build & deploy / docs (push) Waiting to run
Run tests / build (ubuntu-latest, 3.10) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.11) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.12) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.13) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.8) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.9) (push) Waiting to run
Run tests / build (windows-latest, 3.10) (push) Waiting to run
Run tests / build (windows-latest, 3.11) (push) Waiting to run
Run tests / build (windows-latest, 3.12) (push) Waiting to run
Run tests / build (windows-latest, 3.13) (push) Waiting to run
Run tests / build (windows-latest, 3.8) (push) Waiting to run
Run tests / build (windows-latest, 3.9) (push) Waiting to run
Run tests / test_docs (3.13) (push) Waiting to run
Run tests / test_sampleproject (3.13) (push) Waiting to run

This commit is contained in:
Juro Oravec 2025-10-03 09:34:18 +02:00 committed by GitHub
parent 91012829ff
commit 8a979cd821
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 558 additions and 33 deletions

View file

@ -0,0 +1,36 @@
name: Check supported versions
on:
schedule:
# Run every Sunday at 2:00 AM UTC
- cron: '0 2 * * 0'
workflow_dispatch: # Allow manual triggering
permissions:
contents: read
issues: write
jobs:
check-versions:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: "pip"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements-dev.txt
- name: Check supported versions
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python scripts/supported_versions.py check

View file

@ -1,10 +1,10 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.9
- repo: local
hooks:
# Run the linter.
- id: ruff-check
args: [ --fix ]
# Run the formatter.
- id: ruff-format
# See https://stackoverflow.com/questions/70778806
- id: tox
name: tox
entry: .venv/bin/tox
args: ["-e", "mypy,ruff"]
language: system
pass_filenames: false

View file

@ -310,14 +310,21 @@ Head over to [Dev guides](./devguides/dependency_mgmt.md) for a deep dive into h
### Updating supported versions
The `scripts/supported_versions.py` script can be used to update the supported versions.
The `scripts/supported_versions.py` script manages the supported Python and Django versions for the project.
The script runs automatically via GitHub Actions once a week to check for version updates. If changes are detected, it creates a GitHub issue with the necessary updates. See the [`maint-supported-versions.yml`](https://github.com/django-components/django-components/blob/master/.github/workflows/maint-supported-versions.yml) workflow.
You can also run the script manually:
```sh
python scripts/supported_versions.py
# Check if versions need updating
python scripts/supported_versions.py check
# Generate configuration snippets for manual updates
python scripts/supported_versions.py generate
```
This will check the current versions of Django and Python, and will print to the terminal
all the places that need updating and what to set them to.
The `generate` command will print to the terminal all the places that need updating and what to set them to.
### Updating link references

View file

@ -1,14 +1,73 @@
# ruff: noqa: T201, S310
# ruff: noqa: BLE001, PLW2901, RUF001, S310, T201
"""
This script manages the info about supported Python and Django versions.
The script fetches the latest supported version information from official sources:
- Python versions from https://devguide.python.org/versions/
- Django versions and compatibility matrix from https://docs.djangoproject.com/
Commands:
generate: Generates instructions for updating various files (tox.ini, pyproject.toml,
GitHub Actions, documentation) based on current supported versions
check: Compares the current compatibility table in `docs/overview/compatibility.md`
with the latest official version information. If differences are found,
creates a GitHub issue to track the needed updates.
Usage:
python scripts/supported_versions.py generate
python scripts/supported_versions.py check
# For GitHub issue creation (check command):
GITHUB_TOKEN=your_token python scripts/supported_versions.py check
Files updated by this script:
- docs/overview/compatibility.md (compatibility table)
- tox.ini (test environments)
- pyproject.toml (Python classifiers)
- .github/workflows/tests.yml (CI matrix)
- docs/community/development.md (development setup)
"""
import argparse
import json
import os
import re
import sys
import textwrap
from collections import defaultdict
from typing import Any, Callable, Dict, List, Tuple
from pathlib import Path
from typing import Any, Callable, Dict, List, NamedTuple, Tuple
from urllib import request
Version = Tuple[int, ...]
VersionMapping = Dict[Version, List[Version]]
class DjangoVersionChanges(NamedTuple):
added: List[Version]
removed: List[Version]
class VersionDifferences(NamedTuple):
added_python_versions: List[Version]
removed_python_versions: List[Version]
changed_django_versions: Dict[Version, DjangoVersionChanges]
has_changes: bool
######################################
# GET DATA FROM OFFICIAL SOURCES
######################################
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; django-components version script)"}
def filter_dict(d: Dict, filter_fn: Callable[[Any], bool]) -> Dict:
return dict(filter(filter_fn, d.items()))
def cut_by_content(content: str, cut_from: str, cut_to: str) -> str:
return content.split(cut_from)[1].split(cut_to)[0]
@ -18,7 +77,8 @@ def keys_from_content(content: str) -> List[str]:
def get_python_supported_version(url: str) -> List[Version]:
with request.urlopen(url) as response:
req = request.Request(url, headers=HEADERS)
with request.urlopen(req) as response:
response_content = response.read()
content = response_content.decode("utf-8")
@ -39,7 +99,8 @@ def get_python_supported_version(url: str) -> List[Version]:
def get_django_to_python_versions(url: str) -> VersionMapping:
with request.urlopen(url) as response:
req = request.Request(url, headers=HEADERS)
with request.urlopen(req) as response:
response_content = response.read()
content = response_content.decode("utf-8")
@ -69,7 +130,8 @@ def get_django_to_python_versions(url: str) -> VersionMapping:
def get_django_supported_versions(url: str) -> List[Tuple[int, ...]]:
"""Extract Django versions from the HTML content, e.g. `5.0` or `4.2`"""
with request.urlopen(url) as response:
req = request.Request(url, headers=HEADERS)
with request.urlopen(req) as response:
response_content = response.read()
content = response_content.decode("utf-8")
@ -94,7 +156,8 @@ def get_django_supported_versions(url: str) -> List[Tuple[int, ...]]:
def get_latest_version(url: str) -> Version:
with request.urlopen(url) as response:
req = request.Request(url, headers=HEADERS)
with request.urlopen(req) as response:
response_content = response.read()
content = response_content.decode("utf-8")
@ -117,6 +180,28 @@ def build_python_to_django(django_to_python: VersionMapping, latest_version: Ver
return python_to_django
def get_python_to_django() -> VersionMapping:
"""Get the Python to Django version mapping as extracted from the websites."""
django_to_python = get_django_to_python_versions("https://docs.djangoproject.com/en/dev/faq/install/")
django_supported_versions = get_django_supported_versions("https://www.djangoproject.com/download/")
latest_version = get_latest_version("https://www.djangoproject.com/download/")
supported_django_to_python = filter_dict(django_to_python, lambda item: item[0] in django_supported_versions)
python_to_django = build_python_to_django(supported_django_to_python, latest_version)
# NOTE: Uncomment the below if you want to include only those Python versions
# that are still actively supported. Otherwise, we include all Python versions
# that are compatible with supported Django versions.
# active_python = get_python_supported_version("https://devguide.python.org/versions/")
# python_to_django = filter_dict(python_to_django, lambda item: item[0] in active_python)
return python_to_django
######################################
# GENERATE COMMAND
######################################
def env_format(version_tuple: Version, divider: str = "") -> str:
return divider.join(str(num) for num in version_tuple)
@ -222,26 +307,15 @@ def build_pyenv(python_to_django: VersionMapping) -> str:
def build_ci_python_versions(python_to_django: VersionMapping) -> str:
# Outputs python-version, like: ['3.8', '3.9', '3.10', '3.11', '3.12']
lines = [
f"'{env_format(python_version, divider='.')}'" for python_version, django_versions in python_to_django.items()
f"'{env_format(python_version, divider='.')}'" for python_version, _django_versions in python_to_django.items()
]
lines_formatted = " " * 8 + f"python-version: [{', '.join(lines)}]"
return lines_formatted
def filter_dict(d: Dict, filter_fn: Callable[[Any], bool]) -> Dict:
return dict(filter(filter_fn, d.items()))
def main() -> None:
active_python = get_python_supported_version("https://devguide.python.org/versions/")
django_to_python = get_django_to_python_versions("https://docs.djangoproject.com/en/dev/faq/install/")
django_supported_versions = get_django_supported_versions("https://www.djangoproject.com/download/")
latest_version = get_latest_version("https://www.djangoproject.com/download/")
supported_django_to_python = filter_dict(django_to_python, lambda item: item[0] in django_supported_versions)
python_to_django = build_python_to_django(supported_django_to_python, latest_version)
python_to_django = filter_dict(python_to_django, lambda item: item[0] in active_python)
def command_generate() -> None:
print("🔄 Fetching latest version information...")
python_to_django = get_python_to_django()
tox_envlist = build_tox_envlist(python_to_django)
print("Add this to tox.ini:\n")
@ -285,5 +359,413 @@ def main() -> None:
print()
######################################
# CHECK COMMAND
######################################
def parse_compatibility_markdown(file_path: Path) -> VersionMapping:
"""
Extract compatibility table from markdown file with following format:
```
| Python version | Django version |
|----------------|----------------|
| 3.9 | 4.2 |
| 3.10 | 4.2, 5.1, 5.2 |
| 3.11 | 4.2, 5.1, 5.2 |
| 3.12 | 4.2, 5.1, 5.2 |
| 3.13 | 5.1, 5.2 |
```
"""
try:
with file_path.open(encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
print(f"Error: Could not find compatibility file at {file_path}")
sys.exit(1)
# Find the table section
lines = content.split("\n")
table_start = -1
table_end = -1
for i, line in enumerate(lines):
# Search for the table headers line
# `| Python version | Django version |`
if re.search(r"\|\s*Python\s+version\s*\|\s*Django\s+version\s*\|", line, re.IGNORECASE):
table_start = i + 2 # Skip header and separator line
# Search for the end of the table
elif table_start != -1 and (line.strip() == "" or not line.startswith("|")):
table_end = i
break
if table_start == -1:
print("Error: Could not find compatibility table in markdown file")
sys.exit(1)
if table_end == -1:
# If the end of the table is not found, use the last line of the file
table_end = len(lines)
# Parse table rows
# `| 3.10 | 4.2, 5.1, 5.2 |`
python_to_django: VersionMapping = {}
for i in range(table_start, table_end):
# Skip empty and non-table lines
line = lines[i].strip()
if not line or not line.startswith("|"):
continue
# Split by | and clean up
parts = [part.strip() for part in line.split("|")[1:-1]] # Remove empty first/last
if len(parts) != 2:
raise ValueError(f"Unexpected table row: {line}")
python_version_str = parts[0].strip()
django_versions_str = parts[1].strip()
try:
python_version = version_to_tuple(python_version_str)
django_versions = []
for version_str in django_versions_str.split(","):
version_str = version_str.strip()
if version_str:
django_versions.append(version_to_tuple(version_str))
if django_versions:
python_to_django[python_version] = django_versions
except ValueError as e:
raise ValueError(f"Invalid version string in table row '{line}': {e}") from e
return python_to_django
def compare_version_mappings(current: VersionMapping, expected: VersionMapping) -> VersionDifferences:
current_pythons = set(current.keys())
expected_pythons = set(expected.keys())
# Find added/removed Python versions
added_pythons = expected_pythons - current_pythons
removed_pythons = current_pythons - expected_pythons
added_python_versions = sorted(added_pythons)
removed_python_versions = sorted(removed_pythons)
# Find changed Django versions for existing Python versions
changed_django_versions = {}
common_pythons = current_pythons & expected_pythons
for python_version in common_pythons:
current_djangos = set(current[python_version])
expected_djangos = set(expected[python_version])
if current_djangos != expected_djangos:
changed_django_versions[python_version] = DjangoVersionChanges(
added=sorted(expected_djangos - current_djangos),
removed=sorted(current_djangos - expected_djangos),
)
# Check if there are any changes
has_changes = bool(added_pythons) or bool(removed_pythons) or bool(changed_django_versions)
return VersionDifferences(
added_python_versions=added_python_versions,
removed_python_versions=removed_python_versions,
changed_django_versions=changed_django_versions,
has_changes=has_changes,
)
def command_check(repo_owner: str = "django-components", repo_name: str = "django-components") -> None:
"""Check if supported versions need updating and create GitHub issue if needed"""
print("🔄 Checking supported versions...")
# Get current versions from markdown
compatibility_file = Path("docs/overview/compatibility.md")
try:
current_python_to_django = parse_compatibility_markdown(compatibility_file)
print(f"📖 Parsed current versions from {compatibility_file}")
except Exception as e:
print(f"❌ Error parsing compatibility file: {e}")
sys.exit(1)
# Get expected versions from official sources
try:
expected_python_to_django = get_python_to_django()
print("🌐 Fetched expected versions from official sources")
except Exception as e:
print(f"❌ Error fetching expected versions: {e}")
sys.exit(1)
# Compare versions
differences = compare_version_mappings(current_python_to_django, expected_python_to_django)
if not differences.has_changes:
print("✅ Supported versions are up to date!")
return
print("⚠️ Supported versions need updating!")
# Print differences
if differences.added_python_versions:
print(" Added Python versions:")
for version in differences.added_python_versions:
print(f" - Python {env_format(version, divider='.')}")
if differences.removed_python_versions:
print(" Removed Python versions:")
for version in differences.removed_python_versions:
print(f" - Python {env_format(version, divider='.')}")
if differences.changed_django_versions:
print("🔄 Changed Django version support:")
for python_version, changes in differences.changed_django_versions.items():
python_str = env_format(python_version, divider=".")
print(f" Python {python_str}:")
for django_version in changes.added:
django_str = env_format(django_version, divider=".")
print(f" ✅ Added Django {django_str}")
for django_version in changes.removed:
django_str = env_format(django_version, divider=".")
print(f" ❌ Removed Django {django_str}")
# Create GitHub issue
github_token = os.environ.get("GITHUB_TOKEN")
if not github_token:
print("\n⚠️ GITHUB_TOKEN environment variable not set.")
print("Set GITHUB_TOKEN to create GitHub issues automatically.")
print("Run `python scripts/supported_versions.py generate` to get updated configurations.")
return
# Generate issue title and body
title = create_github_issue_title(differences)
body = generate_issue_body(differences, current_python_to_django, expected_python_to_django)
# Check for existing issues
print("🔍 Checking for existing issues...")
if check_existing_github_issue(title, repo_owner, repo_name, github_token):
print(" Similar issue already exists. Skipping issue creation.")
return
# Create the issue
print(f"📝 Creating GitHub issue: {title}")
success = create_github_issue(title, body, repo_owner, repo_name, github_token)
if not success:
print("❌ Failed to create GitHub issue")
sys.exit(1)
######################################
# CHECK COMMAND - GITHUB ISSUE
######################################
def create_github_issue_title(differences: VersionDifferences) -> str:
"""
Generate a GitHub issue title based on version differences
We rely on this title to find the issue in the future to avoid duplicates.
"""
parts = []
if differences.added_python_versions:
for version in differences.added_python_versions:
version_str = env_format(version, divider=".")
parts.append(f"Add Python {version_str}")
if differences.removed_python_versions:
for version in differences.removed_python_versions:
version_str = env_format(version, divider=".")
parts.append(f"Remove Python {version_str}")
# Check for Django version changes
for python_version, changes in differences.changed_django_versions.items():
python_str = env_format(python_version, divider=".")
if changes.added:
for django_version in changes.added:
django_str = env_format(django_version, divider=".")
parts.append(f"Add Django {django_str} for Python {python_str}")
if changes.removed:
for django_version in changes.removed:
django_str = env_format(django_version, divider=".")
parts.append(f"Remove Django {django_str} for Python {python_str}")
if not parts:
return "[maint] Update supported versions"
# Create a concise title
if len(parts) == 1:
return f"[maint] {parts[0]} to supported versions"
return f"[maint] Update supported versions ({len(parts)} changes)"
def check_existing_github_issue(title: str, repo_owner: str, repo_name: str, token: str) -> bool:
"""Check if a GitHub issue with similar title already exists"""
# Search for issues with similar titles
search_url = "https://api.github.com/search/issues"
repo_name = f"{repo_owner}/{repo_name}"
params = {
"q": f'repo:{repo_name} is:issue "{title}"'.replace(" ", "%20"),
"sort": "created",
"order": "desc",
}
headers = {"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json", **HEADERS}
try:
# Build query string manually
query_string = "&".join([f"{k}={v}" for k, v in params.items()])
full_url = f"{search_url}?{query_string}"
req = request.Request(full_url, headers=headers)
with request.urlopen(req) as response:
data = json.loads(response.read().decode("utf-8"))
# Check if any existing issues match our pattern
for issue in data.get("items", []):
issue_title = issue["title"].lower()
if "supported versions" in issue_title and ("[maint]" in issue_title or "maint" in issue_title):
return True
return False
except Exception as e:
print(f"Warning: Could not check existing issues: {e}")
return False
def create_github_issue(title: str, body: str, repo_owner: str, repo_name: str, token: str) -> bool:
url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/issues"
data = {"title": title, "body": body, "labels": ["maintenance", "dependencies"]}
headers = {
"Authorization": f"token {token}",
"Accept": "application/vnd.github.v3+json",
"Content-Type": "application/json",
**HEADERS,
}
try:
req = request.Request(url, data=json.dumps(data).encode("utf-8"), headers=headers, method="POST")
with request.urlopen(req) as response:
if response.status == 201:
issue_data = json.loads(response.read().decode("utf-8"))
print(f"✅ Created GitHub issue: {issue_data['html_url']}")
return True
print(f"❌ Failed to create issue. Status: {response.status}")
return False
except Exception as e:
print(f"❌ Error creating GitHub issue: {e}")
return False
def generate_issue_body(differences: VersionDifferences, _current: VersionMapping, expected: VersionMapping) -> str:
body = "## Supported versions need updating\n\n"
body += (
"The supported Python/Django version combinations have changed and "
"need to be updated in the documentation.\n\n"
)
if differences.added_python_versions:
body += "### Added Python versions\n"
for version in differences.added_python_versions:
version_str = env_format(version, divider=".")
body += f"- Python {version_str}\n"
body += "\n"
if differences.removed_python_versions:
body += "### Removed Python versions\n"
for version in differences.removed_python_versions:
version_str = env_format(version, divider=".")
body += f"- Python {version_str}\n"
body += "\n"
if differences.changed_django_versions:
body += "### Changed Django version support\n"
for python_version, changes in differences.changed_django_versions.items():
python_str = env_format(python_version, divider=".")
body += f"**Python {python_str}:**\n"
if changes.added:
for django_version in changes.added:
django_str = env_format(django_version, divider=".")
body += f"- ✅ Added Django {django_str}\n"
if changes.removed:
for django_version in changes.removed:
django_str = env_format(django_version, divider=".")
body += f"- ❌ Removed Django {django_str}\n"
body += "\n"
body += "### Expected compatibility table\n\n"
body += build_readme(expected)
body += "\n\n"
body += "### Files to update\n"
body += "- `docs/overview/compatibility.md`\n"
body += "- `tox.ini`\n"
body += "- `pyproject.toml`\n"
body += "- `.github/workflows/tests.yml`\n\n"
body += "Run `python scripts/supported_versions.py generate` to get the updated configurations."
return body
######################################
# MAIN
######################################
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Manage supported Python/Django version combinations",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""
Commands:
generate Generate configuration for current supported versions
check Check if versions need updating and create GitHub issue
Environment variables:
GITHUB_TOKEN Required for 'check' command to create GitHub issues
Examples:
python scripts/supported_versions.py generate
GITHUB_TOKEN=your_token python scripts/supported_versions.py check
"""),
)
parser.add_argument("command", choices=["generate", "check"], help="Command to execute")
parser.add_argument(
"--repo-owner", default="django-components", help="GitHub repository owner (default: django-components)"
)
parser.add_argument(
"--repo-name", default="django-components", help="GitHub repository name (default: django-components)"
)
return parser
def main() -> None:
parser = create_parser()
args = parser.parse_args()
try:
if args.command == "generate":
command_generate()
elif args.command == "check":
command_check(args.repo_owner, args.repo_name)
else:
parser.error(f"Invalid command: {args.command}")
except KeyboardInterrupt:
print("\n❌ Interrupted by user")
sys.exit(1)
except Exception as e:
print(f"❌ Unexpected error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()