Skip to content

Commit

Permalink
FIX: determine Python versions from requires-python (#92)
Browse files Browse the repository at this point in the history
  • Loading branch information
redeboer authored Oct 14, 2024
1 parent 6d22e49 commit 7a93b70
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 65 deletions.
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"mhutchie",
"noqa",
"noreply",
"pyright",
"taplo",
"tomlkit",
"tomllib"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
# Temporary files
condaenv.*.requirements.txt
node_modules/
uv.lock
7 changes: 5 additions & 2 deletions create-pytest-matrix/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@ runs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- id: set-matrix
- uses: astral-sh/setup-uv@v3
- env:
UV_SYSTEM_PYTHON: 1
id: set-matrix
name: Create run matrix in JSON form
run: |
delimiter="$(openssl rand -hex 8)"
echo "matrix<<${delimiter}" >> $GITHUB_OUTPUT
python3 $GITHUB_ACTION_PATH/main.py \
uv run $GITHUB_ACTION_PATH/main.py \
'${{ inputs.coverage-python-version }}' \
'${{ inputs.coverage-target }}' \
'${{ inputs.macos-python-version }}' \
Expand Down
117 changes: 84 additions & 33 deletions create-pytest-matrix/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
"""Print job matrix for a GitHub Actions workflow that runs `pytest`."""
# /// script
# dependencies = [
# "packaging",
# ]
# requires-python = ">=3.12"
# ///

from __future__ import annotations

Expand All @@ -7,8 +13,12 @@
import tomllib
from argparse import ArgumentParser
from configparser import ConfigParser
from dataclasses import dataclass
from typing import TYPE_CHECKING

from packaging.specifiers import SpecifierSet
from packaging.version import Version

if TYPE_CHECKING:
from collections.abc import Sequence

Expand Down Expand Up @@ -38,7 +48,7 @@ def _format_skipped_version(skipped_python_versions: str) -> set[str] | None:
return set(skipped_python_versions.split(" "))


def create_job_matrix( # noqa: C901
def create_job_matrix(
coverage_python_version: str,
coverage_target: str,
macos_python_version: str,
Expand All @@ -52,13 +62,7 @@ def create_job_matrix( # noqa: C901
set(supported_python_versions) - skipped_python_versions
)
if coverage_target:
if coverage_python_version not in supported_python_versions:
msg = (
f"Selected Python {coverage_python_version} for the coverage job, but"
" the package only supports Python"
f" {", ".join(supported_python_versions)}"
)
raise ValueError(msg)
_is_version_allowed(coverage_python_version, supported_python_versions)
if coverage_python_version in python_versions:
python_versions.remove(coverage_python_version)
includes = []
Expand All @@ -81,6 +85,7 @@ def create_job_matrix( # noqa: C901
"runs-on": "ubuntu-24.04",
})
if macos_python_version:
_is_version_allowed(macos_python_version, supported_python_versions)
includes.append({
"python-version": macos_python_version,
"runs-on": "macos-14",
Expand All @@ -96,58 +101,104 @@ def create_job_matrix( # noqa: C901
return matrix


def _is_version_allowed(version: str, supported_versions: list[str]) -> None:
if version not in supported_versions:
supported_versions_str = ", ".join(supported_versions)
msg = f"Selected Python {version}, but the package only supports Python {supported_versions_str}"
raise ValueError(msg)


PYPROJECT_TOML = "pyproject.toml"
SETUP_CFG = "setup.cfg"
CLASSIFIERS_ERROR_MSG = (
"This package does not have Python version classifiers, so cannot determine"
" intended Python versions. See https://pypi.org/classifiers."
)
VERSION_IDENTIFIER = "Programming Language :: Python :: 3."


def get_supported_python_versions() -> list[str]:
classifiers = _get_classifiers()
return _determine_python_versions(classifiers)
version_info = _get_version_info()
return _determine_python_versions(version_info)


@dataclass
class VersionInfo:
classifiers: list[str] | None
requires_python: str | None

def _get_classifiers() -> list[str]:

def _get_version_info() -> VersionInfo:
if os.path.exists(SETUP_CFG):
return __get_classifiers_from_cfg(SETUP_CFG)
return _get_version_info_from_cfg(SETUP_CFG)
if os.path.exists(PYPROJECT_TOML):
return __get_classifiers_from_toml(PYPROJECT_TOML)
return _get_version_info_from_toml(PYPROJECT_TOML)
msg = f"This project does not contain a {SETUP_CFG} or {PYPROJECT_TOML}"
raise FileNotFoundError(msg)


def __get_classifiers_from_cfg(path: str) -> list[str]:
def _get_version_info_from_cfg(path: str) -> VersionInfo:
cfg = ConfigParser()
cfg.read(path)
if not cfg.has_option("metadata", "classifiers"):
raise ValueError(CLASSIFIERS_ERROR_MSG)
return VersionInfo(
classifiers=__get(cfg, "metadata", "classifiers", typ=list),
requires_python=__get(cfg, "options", "python_requires"),
)


def __get[T](
cfg: ConfigParser, section: str, option: str, typ: type[T] = str
) -> T | None:
if not cfg.has_option(section, option):
return None
raw = cfg.get("metadata", "classifiers")
return [s.strip() for s in raw.split("\n") if s.strip()]
if typ is str:
return raw # pyright: ignore[reportReturnType]
if typ is list:
return [s.strip() for s in raw.split("\n") if s.strip()] # pyright: ignore[reportReturnType]
msg = f"Unsupported cast type: {typ}"
raise NotImplementedError(msg)


def __get_classifiers_from_toml(path: str) -> list[str]:
def _get_version_info_from_toml(path: str) -> VersionInfo:
with open(path, "rb") as f:
cfg = tomllib.load(f)
classifiers = cfg.get("project", {}).get("classifiers")
if classifiers is None:
raise ValueError(CLASSIFIERS_ERROR_MSG)
return classifiers
project_table: dict = cfg.get("project", {})
return VersionInfo(
classifiers=project_table.get("classifiers"),
requires_python=project_table.get("requires-python"),
)


def _determine_python_versions(version_info: VersionInfo) -> list[str]:
supported_versions = _determine_python_versions_from_classifiers(version_info)
if supported_versions is not None:
return supported_versions
if version_info.requires_python is not None:
return __get_allowed_versions(version_info.requires_python)
msg = "No PyPI classifiers or minimal Python version defined"
raise ValueError(msg)


def _determine_python_versions(classifiers: list[str]) -> list[str]:
versions = [s for s in classifiers if s.startswith(VERSION_IDENTIFIER)]
def _determine_python_versions_from_classifiers(
version_info: VersionInfo,
) -> list[str] | None:
if version_info.classifiers is None:
return None
versions = [s for s in version_info.classifiers if s.startswith(VERSION_IDENTIFIER)]
if not versions:
msg = (
f"{SETUP_CFG} does not have any classifiers of the form"
f' "{VERSION_IDENTIFIER}*"'
)
raise ValueError(msg)
return None
prefix = VERSION_IDENTIFIER[:-2]
return [s.replace(prefix, "") for s in versions]


def __get_allowed_versions(version_range: str) -> list[str]:
"""Get a list of allowed versions from a version range specifier.
>>> __get_allowed_versions(">=3.9,<3.13")
['3.9', '3.10', '3.11', '3.12']
"""
specifier = SpecifierSet(version_range)
allowed_versions = [f"3.{v}" for v in range(6, 13)]
versions_to_check = [Version(v) for v in sorted(allowed_versions)]
return [str(v) for v in versions_to_check if v in specifier]


if __name__ == "__main__":
raise SystemExit(main())
7 changes: 5 additions & 2 deletions create-python-version-matrix/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,14 @@ runs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- id: set-matrix
- uses: astral-sh/setup-uv@v3
- env:
UV_SYSTEM_PYTHON: 1
id: set-matrix
name: Create run matrix in JSON form
run: |
delimiter="$(openssl rand -hex 8)"
echo "matrix<<${delimiter}" >> $GITHUB_OUTPUT
python3 $GITHUB_ACTION_PATH/main.py | tee -a $GITHUB_OUTPUT
uv run $GITHUB_ACTION_PATH/main.py | tee -a $GITHUB_OUTPUT
echo "${delimiter}" >> $GITHUB_OUTPUT
shell: bash
104 changes: 76 additions & 28 deletions create-python-version-matrix/main.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
"""Print job matrix for a GitHub Actions workflow that runs `pytest`."""
# /// script
# dependencies = [
# "packaging",
# ]
# requires-python = ">=3.12"
# ///

import json
import os
import tomllib
from configparser import ConfigParser
from dataclasses import dataclass

from packaging.specifiers import SpecifierSet
from packaging.version import Version

PYPROJECT_TOML = "pyproject.toml"
SETUP_CFG = "setup.cfg"

CLASSIFIERS_ERROR_MSG = (
"This package does not have Python version classifiers, so cannot determine"
" intended Python versions. See https://pypi.org/classifiers."
)
VERSION_IDENTIFIER = "Programming Language :: Python :: 3."


Expand All @@ -28,48 +33,91 @@ def create_job_matrix() -> dict:


def get_supported_python_versions() -> list[str]:
classifiers = _get_classifiers()
return _determine_python_versions(classifiers)
version_info = _get_version_info()
return _determine_python_versions(version_info)


@dataclass
class VersionInfo:
classifiers: list[str] | None
requires_python: str | None

def _get_classifiers() -> list[str]:

def _get_version_info() -> VersionInfo:
if os.path.exists(SETUP_CFG):
return __get_classifiers_from_cfg(SETUP_CFG)
return _get_version_info_from_cfg(SETUP_CFG)
if os.path.exists(PYPROJECT_TOML):
return __get_classifiers_from_toml(PYPROJECT_TOML)
return _get_version_info_from_toml(PYPROJECT_TOML)
msg = f"This project does not contain a {SETUP_CFG} or {PYPROJECT_TOML}"
raise FileNotFoundError(msg)


def __get_classifiers_from_cfg(path: str) -> list[str]:
def _get_version_info_from_cfg(path: str) -> VersionInfo:
cfg = ConfigParser()
cfg.read(path)
if not cfg.has_option("metadata", "classifiers"):
raise ValueError(CLASSIFIERS_ERROR_MSG)
return VersionInfo(
classifiers=__get(cfg, "metadata", "classifiers", typ=list),
requires_python=__get(cfg, "options", "python_requires"),
)


def __get[T](
cfg: ConfigParser, section: str, option: str, typ: type[T] = str
) -> T | None:
if not cfg.has_option(section, option):
return None
raw = cfg.get("metadata", "classifiers")
return [s.strip() for s in raw.split("\n") if s.strip()]
if typ is str:
return raw # pyright: ignore[reportReturnType]
if typ is list:
return [s.strip() for s in raw.split("\n") if s.strip()] # pyright: ignore[reportReturnType]
msg = f"Unsupported cast type: {typ}"
raise NotImplementedError(msg)


def __get_classifiers_from_toml(path: str) -> list[str]:
def _get_version_info_from_toml(path: str) -> VersionInfo:
with open(path, "rb") as f:
cfg = tomllib.load(f)
classifiers = cfg.get("project", {}).get("classifiers")
if classifiers is None:
raise ValueError(CLASSIFIERS_ERROR_MSG)
return classifiers


def _determine_python_versions(classifiers: list[str]) -> list[str]:
versions = [s for s in classifiers if s.startswith(VERSION_IDENTIFIER)]
project_table: dict = cfg.get("project", {})
return VersionInfo(
classifiers=project_table.get("classifiers"),
requires_python=project_table.get("requires-python"),
)


def _determine_python_versions(version_info: VersionInfo) -> list[str]:
supported_versions = _determine_python_versions_from_classifiers(version_info)
if supported_versions is not None:
return supported_versions
if version_info.requires_python is not None:
return __get_allowed_versions(version_info.requires_python)
msg = "No PyPI classifiers or minimal Python version defined"
raise ValueError(msg)


def _determine_python_versions_from_classifiers(
version_info: VersionInfo,
) -> list[str] | None:
if version_info.classifiers is None:
return None
versions = [s for s in version_info.classifiers if s.startswith(VERSION_IDENTIFIER)]
if not versions:
msg = (
f"{SETUP_CFG} does not have any classifiers of the form"
f' "{VERSION_IDENTIFIER}*"'
)
raise ValueError(msg)
return None
prefix = VERSION_IDENTIFIER[:-2]
return [s.replace(prefix, "") for s in versions]


def __get_allowed_versions(version_range: str) -> list[str]:
"""Get a list of allowed versions from a version range specifier.
>>> __get_allowed_versions(">=3.9,<3.13")
['3.9', '3.10', '3.11', '3.12']
"""
specifier = SpecifierSet(version_range)
allowed_versions = [f"3.{v}" for v in range(6, 13)]
versions_to_check = [Version(v) for v in sorted(allowed_versions)]
return [str(v) for v in versions_to_check if v in specifier]


if __name__ == "__main__":
raise SystemExit(main())
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python",
]
dependencies = ["packaging"]
description = "Python scripts used by the ComPWA/actions repository"
dynamic = ["version"]
license = {text = "License :: OSI Approved :: MIT License"}
Expand Down

0 comments on commit 7a93b70

Please sign in to comment.