diff --git a/.github/utils/run_hooks.py b/.github/utils/run_hooks.py index ab0fc690..4ddb248e 100755 --- a/.github/utils/run_hooks.py +++ b/.github/utils/run_hooks.py @@ -5,7 +5,6 @@ """ from __future__ import annotations -# import platform import subprocess # nosec import sys diff --git a/.github/workflows/_local_ci_tests.yml b/.github/workflows/_local_ci_tests.yml index 6c9194d8..6d6b890c 100644 --- a/.github/workflows/_local_ci_tests.yml +++ b/.github/workflows/_local_ci_tests.yml @@ -40,7 +40,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] os: ["ubuntu-latest", "windows-latest"] steps: @@ -51,6 +51,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version}} + allow-prereleases: true - name: Install Python dependencies run: | @@ -76,7 +77,8 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.10"] + # Use lowest and highest supported (stable) Python version + python-version: ["3.7", "3.12"] os: ["ubuntu-latest", "windows-latest"] steps: diff --git a/.github/workflows/cd_release.yml b/.github/workflows/cd_release.yml index 7bee6bdf..4a95c3da 100644 --- a/.github/workflows/cd_release.yml +++ b/.github/workflows/cd_release.yml @@ -158,6 +158,14 @@ jobs: runs-on: ubuntu-latest steps: + - name: Validate inputs + run: | + if [[ ! "${{ inputs.python_version_build}}" =~ ^3\.([7-9]|1[0-3])(\..*)?$ ]]; then + echo "Python version '${{ inputs.python_version_build}}' is not supported." + echo "Supported versions are: 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13." + exit 1 + fi + - name: Checkout ${{ github.repository }} uses: actions/checkout@v4 with: @@ -167,6 +175,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: "${{ inputs.python_version_build }}" + allow-prereleases: true - name: Install Python dependencies run: | @@ -199,7 +208,7 @@ jobs: - name: Update changelog uses: docker://githubchangeloggenerator/github-changelog-generator:1.16.2 with: - args: --user "${{ github.repository_owner }}" ${{ env.CHANGELOG_PROJECT }} --token "${{ secrets.PAT || secrets.GITHUB_TOKEN }}" --release-branch "${{ inputs.release_branch }}" ${{ env.CHANGELOG_EXCLUDE_TAGS_REGEX }} ${{ env.CHANGELOG_EXCLUDE_LABELS }} + args: --user "${{ github.repository_owner }}" ${CHANGELOG_PROJECT} --token "${{ secrets.PAT || secrets.GITHUB_TOKEN }}" --release-branch "${{ inputs.release_branch }}" ${CHANGELOG_EXCLUDE_TAGS_REGEX} ${CHANGELOG_EXCLUDE_LABELS} - name: Set up git user run: | @@ -287,7 +296,7 @@ jobs: - name: Create release-specific changelog uses: docker://githubchangeloggenerator/github-changelog-generator:1.16.2 with: - args: --user "${{ github.repository_owner }}" ${{ env.CHANGELOG_PROJECT }} --token "${{ secrets.PAT || secrets.GITHUB_TOKEN }}" --release-branch "${{ inputs.release_branch }}" --since-tag "${{ env.PREVIOUS_VERSION }}" --output release_changelog.md --usernames-as-github-logins ${{ env.CHANGELOG_EXCLUDE_TAGS_REGEX }} ${{ env.CHANGELOG_EXCLUDE_LABELS }} + args: --user "${{ github.repository_owner }}" ${CHANGELOG_PROJECT} --token "${{ secrets.PAT || secrets.GITHUB_TOKEN }}" --release-branch "${{ inputs.release_branch }}" --since-tag "${PREVIOUS_VERSION}" --output release_changelog.md --usernames-as-github-logins ${CHANGELOG_EXCLUDE_TAGS_REGEX} ${CHANGELOG_EXCLUDE_LABELS} - name: Append changelog to release body run: | @@ -341,6 +350,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: "${{ inputs.python_version_docs }}" + allow-prereleases: true - name: Install system dependencies if: inputs.system_dependencies != '' @@ -367,6 +377,20 @@ jobs: pip install -U setuptools wheel pip install ${EDITABLE}.${{ inputs.doc_extras || inputs.install_extras }} + INSTALLED_PACKAGES=$(pip freeze) + if [ "${{ inputs.docs_framework }}" == "mkdocs" ]; then + if [[ $(echo -e "${INSTALLED_PACKAGES}" | grep -ciE '(mkdocs|mike)==') -eq 2 ]]; then + echo "Missing one or more of the following packages: mkdocs, mike" + exit 1 + elif [ "${{ inputs.docs_framework }}" == "sphinx" ]; then + if [[ $(echo -e "${INSTALLED_PACKAGES}" | grep -ci 'sphinx==') -eq 1 ]]; then + echo "Missing one or more of the following packages: sphinx" + exit 1 + else + echo "Unknown framework: ${{ inputs.docs_framework }}" + exit 1 + fi + - name: Set up git user run: | git config --global user.name "${{ inputs.git_username }}" diff --git a/.github/workflows/ci_cd_updated_default_branch.yml b/.github/workflows/ci_cd_updated_default_branch.yml index c297b6be..508285e3 100644 --- a/.github/workflows/ci_cd_updated_default_branch.yml +++ b/.github/workflows/ci_cd_updated_default_branch.yml @@ -164,15 +164,6 @@ jobs: UPDATE_DEFAULT_BRANCH: false steps: - - name: Check input - run: | - valid_frameworks=("mkdocs sphinx") - if [[ ! " ${valid_frameworks[*]} " =~ " ${{ inputs.docs_framework}} " ]]; then - echo "The input '${{ inputs.docs_framework}}' is not supported." - echo "Valid inputs are: ${valid_frameworks[*]}" - exit 1 - fi - - name: Release check run: | COMMIT_MSG="$(gh api /repos/${{ github.repository}}/commits/${{ inputs.default_repo_branch }} --jq '.commit.message')" @@ -186,6 +177,22 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} + - name: Validate inputs + if: env.RELEASE_RUN == 'false' + run: | + valid_frameworks=("mkdocs sphinx") + if [[ ! " ${valid_frameworks[*]} " =~ " ${{ inputs.docs_framework}} " ]]; then + echo "The input '${{ inputs.docs_framework}}' is not supported." + echo "Valid inputs are: ${valid_frameworks[*]}" + exit 1 + fi + + if [[ ! "${{ inputs.python_version}}" =~ ^3\.([7-9]|1[0-3])(\..*)?$ ]]; then + echo "Python version '${{ inputs.python_version}}' is not supported." + echo "Supported versions are: 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13." + exit 1 + fi + - name: Checkout ${{ github.repository }} if: env.RELEASE_RUN == 'false' uses: actions/checkout@v4 @@ -197,6 +204,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: "${{ inputs.python_version }}" + allow-prereleases: true - name: Install system dependencies if: env.RELEASE_RUN == 'false' && inputs.system_dependencies != '' @@ -225,6 +233,20 @@ jobs: pip install ${EDITABLE}.${{ inputs.doc_extras }} pip install git+https://github.com/SINTEF/ci-cd.git@v2.6.0 + INSTALLED_PACKAGES=$(pip freeze) + if [ "${{ inputs.docs_framework }}" == "mkdocs" ]; then + if [[ $(echo -e "${INSTALLED_PACKAGES}" | grep -ciE '(mkdocs|mike)==') -eq 2 ]]; then + echo "Missing one or more of the following packages: mkdocs, mike" + exit 1 + elif [ "${{ inputs.docs_framework }}" == "sphinx" ]; then + if [[ $(echo -e "${INSTALLED_PACKAGES}" | grep -ci 'sphinx==') -eq 1 ]]; then + echo "Missing one or more of the following packages: sphinx" + exit 1 + else + echo "Unknown framework: ${{ inputs.docs_framework }}" + exit 1 + fi + - name: Set up git user if: env.RELEASE_RUN == 'false' run: | @@ -357,7 +379,7 @@ jobs: if: env.RELEASE_RUN == 'false' uses: docker://githubchangeloggenerator/github-changelog-generator:1.16.2 with: - args: --user "${{ github.repository_owner }}" ${{ env.CHANGELOG_PROJECT }} --token "${{ secrets.PAT || secrets.GITHUB_TOKEN }}" --release-branch "${{ inputs.default_repo_branch }}" --future-release "Unreleased changes" ${{ env.CHANGELOG_EXCLUDE_TAGS_REGEX }} ${{ env.CHANGELOG_EXCLUDE_LABELS }} + args: --user "${{ github.repository_owner }}" ${CHANGELOG_PROJECT} --token "${{ secrets.PAT || secrets.GITHUB_TOKEN }}" --release-branch "${{ inputs.default_repo_branch }}" --future-release "Unreleased changes" ${CHANGELOG_EXCLUDE_TAGS_REGEX} ${CHANGELOG_EXCLUDE_LABELS} - name: Build (& deploy MkDocs) documentation if: env.RELEASE_RUN == 'false' && ( ! inputs.test ) @@ -377,7 +399,7 @@ jobs: if [ "${{ inputs.test }}" == "true" ]; then if [ "${{ inputs.docs_framework }}" == "mkdocs" ]; then - echo "Will here deploy documentation using 'mike' called 'latest' with alias '${{ inputs.release_branch }}'" + echo "Will here deploy documentation using 'mike' called 'latest' with alias '${{ inputs.default_repo_branch }}'" elif [ "${{ inputs.docs_framework }}" == "sphinx" ]; then echo "Will here deploy documentation using 'sphinx-build'." echo "sphinx-build options: ${SPHINX_OPTIONS[@]}" @@ -448,7 +470,7 @@ jobs: git fetch origin LATEST_PR_BODY="$(gh api /repos/${{ github.repository}}/pulls -X GET -f state=closed -f per_page=1 -f sort=updated -f direction=desc --jq '.[].body')" - cat ${{ env.PR_BODY_FILE }} | head -8 > .tmp_file.txt + cat ${PR_BODY_FILE} | head -8 > .tmp_file.txt if [ -z "$(printf '%s\n' "${LATEST_PR_BODY}" | head -8 | diff - .tmp_file.txt --strip-trailing-cr)" ]; then echo "The dependencies have just been updated! Reset to ${{ inputs.default_repo_branch }}." git reset --hard origin/${{ inputs.default_repo_branch }} @@ -459,8 +481,8 @@ jobs: echo "FORCE_PUSH=no" >> $GITHUB_ENV fi - if [ "${{ env.REMOVE_PR_BODY_FILE }}" == "true" ] && [ -f "${{ env.PR_BODY_FILE }}" ]; then - rm -f "${{ env.PR_BODY_FILE }}" + if [ "${REMOVE_PR_BODY_FILE}" == "true" ] && [ -f "${PR_BODY_FILE}" ]; then + rm -f "${PR_BODY_FILE}" fi if [ -f ".tmp_file.txt" ]; then rm -f .tmp_file.txt; fi env: diff --git a/.github/workflows/ci_check_pyproject_dependencies.yml b/.github/workflows/ci_check_pyproject_dependencies.yml index 29548f2a..653d9842 100644 --- a/.github/workflows/ci_check_pyproject_dependencies.yml +++ b/.github/workflows/ci_check_pyproject_dependencies.yml @@ -67,6 +67,14 @@ jobs: runs-on: ubuntu-latest steps: + - name: Validate inputs + run: | + if [[ ! "${{ inputs.python_version}}" =~ ^3\.([7-9]|1[0-3])(\..*)?$ ]]; then + echo "Python version '${{ inputs.python_version}}' is not supported." + echo "Supported versions are: 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13." + exit 1 + fi + - name: Checkout permanent dependencies branch in ${{ github.repository }} uses: actions/checkout@v4 with: @@ -77,6 +85,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: "${{ inputs.python_version }}" + allow-prereleases: true - name: Install Python dependencies run: | @@ -167,7 +176,7 @@ jobs: - name: Remove temporary file if: env.REMOVE_PR_BODY_FILE == 'true' - run: rm -f ${{ env.PR_BODY_FILE }} + run: rm -f ${PR_BODY_FILE} - name: Create PR if: env.UPDATE_DEPS == 'true' diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index aa26d2db..34453f53 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -213,6 +213,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: "${{ inputs.python_version_pre-commit }}" + allow-prereleases: true - name: Install Python dependencies run: | @@ -238,6 +239,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: "${{ inputs.python_version_pylint_safety }}" + allow-prereleases: true - name: Install dependencies run: | @@ -306,6 +308,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: "${{ inputs.python_version_package }}" + allow-prereleases: true - name: Install Python dependencies run: | @@ -345,6 +348,14 @@ jobs: exit 1 fi + - name: Validate inputs + run: | + if [[ "${framework}" == "mkdocs" && ! "${{ inputs.python_version_docs}}" =~ ^3\.([7-9]|1[0-3])(\..*)?$ ]]; then + echo "Python version '${{ inputs.python_version_docs}}' is not supported." + echo "Supported versions are: 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13." + exit 1 + fi + - name: Checkout ${{ github.repository }} uses: actions/checkout@v4 with: @@ -354,6 +365,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: "${{ inputs.python_version_docs }}" + allow-prereleases: true - name: Install system dependencies if: inputs.system_dependencies != '' @@ -454,12 +466,12 @@ jobs: # Set STRICT option if [ "${{ inputs.warnings_as_errors }}" == "true" ]; then - if [ "${{ env.framework }}" == "mkdocs" ]; then + if [ "${framework}" == "mkdocs" ]; then STRICT="--strict" - elif [ "${{ env.framework }}" == "sphinx" ]; then + elif [ "${framework}" == "sphinx" ]; then STRICT="-W" else - echo "Unknown framework: ${{ env.framework }}" + echo "Unknown framework: ${framework}" exit 1 fi @@ -470,10 +482,10 @@ jobs: # Run build command - if [ "${{ env.framework }}" == "mkdocs" ]; then + if [ "${framework}" == "mkdocs" ]; then mkdocs build ${STRICT} - elif [ "${{ env.framework }}" == "sphinx" ]; then + elif [ "${framework}" == "sphinx" ]; then if [[ "${{ inputs.sphinx-build_options }}" =~ \n ]]; then # Expected to be a multi-line string SPHINX_OPTIONS=() @@ -490,6 +502,6 @@ jobs: ${{ inputs.docs_folder }} ${{ inputs.build_target_folder }} else - echo "Unknown framework: ${{ env.framework }}" + echo "Unknown framework: ${framework}" exit 1 fi diff --git a/.github/workflows/ci_update_dependencies.yml b/.github/workflows/ci_update_dependencies.yml index 0f78d886..072e2283 100644 --- a/.github/workflows/ci_update_dependencies.yml +++ b/.github/workflows/ci_update_dependencies.yml @@ -87,6 +87,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: "${{ inputs.python_version }}" + allow-prereleases: true - name: Install `pre-commit` and dependencies if: inputs.update_pre-commit @@ -114,14 +115,14 @@ jobs: if: inputs.update_pre-commit continue-on-error: true # Still create the PR if this step fails run: | - if [ "${{ env.UPDATED_PRE_COMMIT_HOOKS }}" == "true" ]; then + if [ "${UPDATED_PRE_COMMIT_HOOKS}" == "true" ]; then SKIP=${{ inputs.skip_pre-commit_hooks }} pre-commit run --all-files fi - name: Possibly commit changes and updates if: inputs.update_pre-commit run: | - if [ "${{ env.UPDATED_PRE_COMMIT_HOOKS }}" == "true" ]; then + if [ "${UPDATED_PRE_COMMIT_HOOKS}" == "true" ]; then git commit -am "Update \`pre-commit\` hooks" fi @@ -148,7 +149,7 @@ jobs: - name: Remove temporary file if: env.REMOVE_PR_BODY_FILE == 'true' - run: rm -f ${{ env.PR_BODY_FILE }} + run: rm -f ${PR_BODY_FILE} - name: Create PR id: cpr diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bd6a611b..24deffbf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,8 @@ # To install the git pre-commit hook run: # pre-commit install -# To update the pre-commit hooks run: -# pre-commit autoupdate +default_language_version: + python: python3.9 + repos: # pre-commit-hooks supplies a multitude of small hooks # To get an overview of them all as well as the ones used here, please see @@ -24,7 +25,21 @@ repos: rev: 5.12.0 hooks: - id: isort - args: ["--profile", "black", "--filter-files", "--skip-gitignore"] + args: + - "--profile=black" + - "--filter-files" + - "--skip-gitignore" + - "--add-import=from __future__ import annotations" + + # pyupgrade is a tool to automatically upgrade Python syntax for newer versions + # It works on files in-place + - repo: https://github.com/asottile/pyupgrade + # Latest version for Python 3.7: 3.3.2 + # Latest version for Python 3.8: 3.8.0 + rev: v3.15.0 + hooks: + - id: pyupgrade + args: ["--py37-plus"] # Black is a code style and formatter # It works on files in-place diff --git a/ci_cd/__init__.py b/ci_cd/__init__.py index 946bfb3f..b931b20f 100644 --- a/ci_cd/__init__.py +++ b/ci_cd/__init__.py @@ -1,4 +1,6 @@ """CI/CD Tools. Tiny package to run invoke tasks as a standalone program.""" +from __future__ import annotations + import logging __version__ = "2.6.0" diff --git a/ci_cd/exceptions.py b/ci_cd/exceptions.py index 1a645495..ae1ac9f0 100644 --- a/ci_cd/exceptions.py +++ b/ci_cd/exceptions.py @@ -1,4 +1,5 @@ """CI/CD-specific exceptions.""" +from __future__ import annotations class CICDException(Exception): diff --git a/ci_cd/main.py b/ci_cd/main.py index 65cedcec..ea224be9 100644 --- a/ci_cd/main.py +++ b/ci_cd/main.py @@ -3,6 +3,8 @@ See [invoke documentation](https://docs.pyinvoke.org/en/stable/concepts/library.html) for more information. """ +from __future__ import annotations + from invoke import Collection, Program from ci_cd import __version__, tasks diff --git a/ci_cd/tasks/__init__.py b/ci_cd/tasks/__init__.py index 78deeeba..53b22d52 100644 --- a/ci_cd/tasks/__init__.py +++ b/ci_cd/tasks/__init__.py @@ -3,6 +3,8 @@ Repository management tasks powered by `invoke`. More information on `invoke` can be found at [pyinvoke.org](http://www.pyinvoke.org/). """ +from __future__ import annotations + from .api_reference_docs import create_api_reference_docs from .docs_index import create_docs_index from .setver import setver diff --git a/ci_cd/tasks/api_reference_docs.py b/ci_cd/tasks/api_reference_docs.py index 937bfdce..6b4def54 100644 --- a/ci_cd/tasks/api_reference_docs.py +++ b/ci_cd/tasks/api_reference_docs.py @@ -3,6 +3,8 @@ Create Python API reference in the documentation. This is specifically to be used with the MkDocs and mkdocstrings framework. """ +from __future__ import annotations + # pylint: disable=duplicate-code import logging import os @@ -112,7 +114,7 @@ def create_api_reference_docs( # pylint: disable=too-many-locals,too-many-branc ): """Create the Python API Reference in the documentation.""" if TYPE_CHECKING: # pragma: no cover - context: "Context" = context # type: ignore[no-redef] + context: Context = context # type: ignore[no-redef] pre_clean: bool = pre_clean # type: ignore[no-redef] pre_commit: bool = pre_commit # type: ignore[no-redef] root_repo_path: str = root_repo_path # type: ignore[no-redef] @@ -149,7 +151,7 @@ def write_file(full_path: Path, content: str) -> None: if pre_commit: # Ensure git is installed - result: "Result" = context.run("git --version", hide=True) + result: Result = context.run("git --version", hide=True) if result.exited != 0: sys.exit( "Git is not installed. Please install it before running this task." diff --git a/ci_cd/tasks/docs_index.py b/ci_cd/tasks/docs_index.py index df8ae09f..3a1a1b02 100644 --- a/ci_cd/tasks/docs_index.py +++ b/ci_cd/tasks/docs_index.py @@ -2,6 +2,8 @@ Create the documentation index (home) page from `README.md`. """ +from __future__ import annotations + import re import sys from pathlib import Path @@ -48,7 +50,7 @@ def create_docs_index( # pylint: disable=too-many-locals ): """Create the documentation index page from README.md.""" if TYPE_CHECKING: # pragma: no cover - context: "Context" = context # type: ignore[no-redef] + context: Context = context # type: ignore[no-redef] pre_commit: bool = pre_commit # type: ignore[no-redef] root_repo_path: str = root_repo_path # type: ignore[no-redef] replacement_separator: str = replacement_separator # type: ignore[no-redef] @@ -61,7 +63,7 @@ def create_docs_index( # pylint: disable=too-many-locals if pre_commit and root_repo_path == ".": # Use git to determine repo root - result: "Result" = context.run("git rev-parse --show-toplevel", hide=True) + result: Result = context.run("git rev-parse --show-toplevel", hide=True) root_repo_path = result.stdout.strip("\n") root_repo_path: Path = Path(root_repo_path).resolve() @@ -90,7 +92,7 @@ def create_docs_index( # pylint: disable=too-many-locals # NOTE: Concerning the weird regular expression, see: # http://manpages.ubuntu.com/manpages/precise/en/man1/git-status.1.html - result: "Result" = context.run( # type: ignore[no-redef] + result: Result = context.run( # type: ignore[no-redef] f'git -C "{root_repo_path}" status --porcelain ' f"{docs_index.relative_to(root_repo_path)}", hide=True, diff --git a/ci_cd/tasks/setver.py b/ci_cd/tasks/setver.py index cc3f7005..ef5d18f9 100644 --- a/ci_cd/tasks/setver.py +++ b/ci_cd/tasks/setver.py @@ -2,6 +2,8 @@ Set the specified version. """ +from __future__ import annotations + import logging import re import sys diff --git a/ci_cd/tasks/update_deps.py b/ci_cd/tasks/update_deps.py index 31440d04..d53c4c59 100644 --- a/ci_cd/tasks/update_deps.py +++ b/ci_cd/tasks/update_deps.py @@ -105,7 +105,7 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s ): """Update dependencies in specified Python package's `pyproject.toml`.""" if TYPE_CHECKING: # pragma: no cover - context: "Context" = context # type: ignore[no-redef] + context: Context = context # type: ignore[no-redef] root_repo_path: str = root_repo_path # type: ignore[no-redef] fail_fast: bool = fail_fast # type: ignore[no-redef] pre_commit: bool = pre_commit # type: ignore[no-redef] @@ -130,7 +130,7 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s if pre_commit and root_repo_path == ".": # Use git to determine repo root - result: "Result" = context.run("git rev-parse --show-toplevel", hide=True) + result: Result = context.run("git rev-parse --show-toplevel", hide=True) root_repo_path = result.stdout.strip("\n") pyproject_path = Path(root_repo_path).resolve() / "pyproject.toml" @@ -261,7 +261,7 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s LOGGER.debug("Min/max Python version from marker: %s", marker_py_version) # Check version from PyPI's online package index - out: "Result" = context.run( + out: Result = context.run( "pip index versions " f"--python-version {marker_py_version or py_version} " f"{parsed_requirement.name}", @@ -342,8 +342,8 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s # Apply ignore rules if parsed_requirement.name in ignore_rules or "*" in ignore_rules: - versions: "IgnoreVersions" = [] - update_types: "IgnoreUpdateTypes" = {} + versions: IgnoreVersions = [] + update_types: IgnoreUpdateTypes = {} if "*" in ignore_rules: versions, update_types = parse_ignore_rules(ignore_rules["*"]) diff --git a/ci_cd/utils/__init__.py b/ci_cd/utils/__init__.py index e7f93c04..ea2fd125 100644 --- a/ci_cd/utils/__init__.py +++ b/ci_cd/utils/__init__.py @@ -1,4 +1,6 @@ """Utilities for CI/CD.""" +from __future__ import annotations + from .console_printing import Emoji, error_msg, info_msg, warning_msg from .file_io import update_file from .versions import ( diff --git a/ci_cd/utils/console_printing.py b/ci_cd/utils/console_printing.py index ad15d923..6e5517d2 100644 --- a/ci_cd/utils/console_printing.py +++ b/ci_cd/utils/console_printing.py @@ -1,4 +1,6 @@ """Relevant tools for printing to the console.""" +from __future__ import annotations + import platform from enum import Enum @@ -6,7 +8,7 @@ class Emoji(str, Enum): """Unicode strings for certain emojis.""" - def __new__(cls, value: str) -> "Emoji": + def __new__(cls, value: str) -> Emoji: obj = str.__new__(cls, value) if platform.system() == "Windows": # Windows does not support unicode emojis, so we replace them with @@ -25,7 +27,7 @@ def __new__(cls, value: str) -> "Emoji": class Color(str, Enum): """ANSI escape sequences for colors.""" - def __new__(cls, value: str) -> "Color": + def __new__(cls, value: str) -> Color: obj = str.__new__(cls, value) obj._value_ = value return obj @@ -47,7 +49,7 @@ def write(self, text: str) -> str: class Formatting(str, Enum): """ANSI escape sequences for formatting.""" - def __new__(cls, value: str) -> "Formatting": + def __new__(cls, value: str) -> Formatting: obj = str.__new__(cls, value) obj._value_ = value return obj diff --git a/ci_cd/utils/file_io.py b/ci_cd/utils/file_io.py index b10105da..3b083ae3 100644 --- a/ci_cd/utils/file_io.py +++ b/ci_cd/utils/file_io.py @@ -1,14 +1,15 @@ """Utilities for handling IO operations.""" +from __future__ import annotations + import re from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from pathlib import Path - from typing import Optional, Tuple def update_file( - filename: "Path", sub_line: "Tuple[str, str]", strip: "Optional[str]" = None + filename: Path, sub_line: tuple[str, str], strip: str | None = None ) -> None: """Utility function for tasks to read, update, and write files""" if strip is None and filename.suffix == ".md": diff --git a/ci_cd/utils/versions.py b/ci_cd/utils/versions.py index ed67ee8c..a1f00e60 100644 --- a/ci_cd/utils/versions.py +++ b/ci_cd/utils/versions.py @@ -13,18 +13,19 @@ from ci_cd.exceptions import InputError, InputParserError, UnableToResolve if TYPE_CHECKING: # pragma: no cover - from typing import Any, Literal, Optional, Union + from typing import Any, Dict, List from packaging.requirements import Requirement + from typing_extensions import Literal - IgnoreEntry = dict[Literal["dependency-name", "versions", "update-types"], str] + IgnoreEntry = Dict[Literal["dependency-name", "versions", "update-types"], str] - IgnoreRules = dict[Literal["versions", "update-types"], list[str]] - IgnoreRulesCollection = dict[str, IgnoreRules] + IgnoreRules = Dict[Literal["versions", "update-types"], List[str]] + IgnoreRulesCollection = Dict[str, IgnoreRules] - IgnoreVersions = list[dict[Literal["operator", "version"], str]] - IgnoreUpdateTypes = dict[ - Literal["version-update"], list[Literal["major", "minor", "patch"]] + IgnoreVersions = List[Dict[Literal["operator", "version"], str]] + IgnoreUpdateTypes = Dict[ + Literal["version-update"], List[Literal["major", "minor", "patch"]] ] @@ -81,21 +82,21 @@ class SemanticVersion(str): @no_type_check def __new__( - cls, version: "Optional[str]" = None, **kwargs: "Union[str, int]" - ) -> "SemanticVersion": + cls, version: str | None = None, **kwargs: str | int + ) -> SemanticVersion: return super().__new__( cls, version if version else cls._build_version(**kwargs) ) def __init__( self, - version: "Optional[str]" = None, + version: str | None = None, *, - major: "Union[str, int]" = "", - minor: "Optional[Union[str, int]]" = None, - patch: "Optional[Union[str, int]]" = None, - pre_release: "Optional[str]" = None, - build: "Optional[str]" = None, + major: str | int = "", + minor: str | int | None = None, + patch: str | int | None = None, + pre_release: str | None = None, + build: str | None = None, ) -> None: if version is not None: if major or minor or patch or pre_release or build: @@ -120,11 +121,11 @@ def __init__( @classmethod def _build_version( cls, - major: "Optional[Union[str, int]]" = None, - minor: "Optional[Union[str, int]]" = None, - patch: "Optional[Union[str, int]]" = None, - pre_release: "Optional[str]" = None, - build: "Optional[str]" = None, + major: str | int | None = None, + minor: str | int | None = None, + patch: str | int | None = None, + pre_release: str | None = None, + build: str | None = None, ) -> str: """Build a version from the given parameters.""" if major is None: @@ -170,7 +171,7 @@ def patch(self) -> int: return self._patch @property - def pre_release(self) -> "Union[None, str]": + def pre_release(self) -> None | str: """The pre-release part of the version This is the part supplied after a minus (`-`), but before a plus (`+`). @@ -178,7 +179,7 @@ def pre_release(self) -> "Union[None, str]": return self._pre_release @property - def build(self) -> "Union[None, str]": + def build(self) -> None | str: """The build metadata part of the version. This is the part supplied at the end of the version, after a plus (`+`). @@ -197,7 +198,7 @@ def __repr__(self) -> str: """Return the string representation of the object.""" return repr(self.__str__()) - def _validate_other_type(self, other: "Any") -> "SemanticVersion": + def _validate_other_type(self, other: Any) -> SemanticVersion: """Initial check/validation of `other` before rich comparisons.""" not_implemented_exc = NotImplementedError( f"Rich comparison not implemented between {self.__class__.__name__} and " @@ -215,7 +216,7 @@ def _validate_other_type(self, other: "Any") -> "SemanticVersion": raise not_implemented_exc - def __lt__(self, other: "Any") -> bool: + def __lt__(self, other: Any) -> bool: """Less than (`<`) rich comparison.""" other_semver = self._validate_other_type(other) @@ -235,11 +236,11 @@ def __lt__(self, other: "Any") -> bool: return self.pre_release < other_semver.pre_release return False - def __le__(self, other: "Any") -> bool: + def __le__(self, other: Any) -> bool: """Less than or equal to (`<=`) rich comparison.""" return self.__lt__(other) or self.__eq__(other) - def __eq__(self, other: "Any") -> bool: + def __eq__(self, other: Any) -> bool: """Equal to (`==`) rich comparison.""" other_semver = self._validate_other_type(other) @@ -250,19 +251,19 @@ def __eq__(self, other: "Any") -> bool: and self.pre_release == other_semver.pre_release ) - def __ne__(self, other: "Any") -> bool: + def __ne__(self, other: Any) -> bool: """Not equal to (`!=`) rich comparison.""" return not self.__eq__(other) - def __ge__(self, other: "Any") -> bool: + def __ge__(self, other: Any) -> bool: """Greater than or equal to (`>=`) rich comparison.""" return not self.__lt__(other) - def __gt__(self, other: "Any") -> bool: + def __gt__(self, other: Any) -> bool: """Greater than (`>`) rich comparison.""" return not self.__le__(other) - def next_version(self, version_part: str) -> "SemanticVersion": + def next_version(self, version_part: str) -> SemanticVersion: """Return the next version for the specified version part. Parameters: @@ -291,8 +292,8 @@ def next_version(self, version_part: str) -> "SemanticVersion": return self.__class__(next_version) def previous_version( - self, version_part: str, max_filler: "Optional[Union[str, int]]" = 99 - ) -> "SemanticVersion": + self, version_part: str, max_filler: str | int | None = 99 + ) -> SemanticVersion: """Return the previous version for the specified version part. Parameters: @@ -359,7 +360,7 @@ def shortened(self) -> str: return f"{self.major}.{self.minor}.{self.patch}" -def parse_ignore_entries(entries: list[str], separator: str) -> "IgnoreRulesCollection": +def parse_ignore_entries(entries: list[str], separator: str) -> IgnoreRulesCollection: """Parser for the `--ignore` option. The `--ignore` option values are given as key/value-pairs in the form: @@ -374,7 +375,7 @@ def parse_ignore_entries(entries: list[str], separator: str) -> "IgnoreRulesColl A parsed mapping of dependencies to ignore rules. """ - ignore_entries: "IgnoreRulesCollection" = {} + ignore_entries: IgnoreRulesCollection = {} for entry in entries: pairs = entry.split(separator, maxsplit=2) @@ -386,7 +387,7 @@ def parse_ignore_entries(entries: list[str], separator: str) -> "IgnoreRulesColl f"value: --ignore={entry!r}" ) - ignore_entry: "IgnoreEntry" = {} + ignore_entry: IgnoreEntry = {} for pair in pairs: match = re.match( r"^(?Pdependency-name|versions|update-types)=(?P.*)$", @@ -425,8 +426,8 @@ def parse_ignore_entries(entries: list[str], separator: str) -> "IgnoreRulesColl def parse_ignore_rules( - rules: "IgnoreRules", -) -> "tuple[IgnoreVersions, IgnoreUpdateTypes]": + rules: IgnoreRules, +) -> tuple[IgnoreVersions, IgnoreUpdateTypes]: """Parser for a specific set of ignore rules. Parameters: @@ -440,8 +441,8 @@ def parse_ignore_rules( # Ignore package altogether return [{"operator": ">=", "version": "0"}], {} - versions: "IgnoreVersions" = [] - update_types: "IgnoreUpdateTypes" = {} + versions: IgnoreVersions = [] + update_types: IgnoreUpdateTypes = {} if "versions" in rules: for versions_entry in rules["versions"]: @@ -478,7 +479,7 @@ def parse_ignore_rules( return versions, update_types -def create_ignore_rules(specifier_set: SpecifierSet) -> "IgnoreRules": +def create_ignore_rules(specifier_set: SpecifierSet) -> IgnoreRules: """Create ignore rules based on version specifier set. The only ignore rules needed are related to versions that should be explicitly @@ -495,7 +496,7 @@ def create_ignore_rules(specifier_set: SpecifierSet) -> "IgnoreRules": def _ignore_version_rules_semver( - latest: list[str], version_rules: "IgnoreVersions" + latest: list[str], version_rules: IgnoreVersions ) -> bool: # pragma: no cover """Determine whether to ignore package based on `versions` input. @@ -566,7 +567,7 @@ def _ignore_version_rules_semver( def _ignore_version_rules_specifier_set( - latest: list[str], version_rules: "IgnoreVersions" + latest: list[str], version_rules: IgnoreVersions ) -> bool: """Determine whether to ignore package based on `versions` input. @@ -587,7 +588,7 @@ def _ignore_version_rules_specifier_set( def _ignore_semver_rules( current: list[str], latest: list[str], - semver_rules: "IgnoreUpdateTypes", + semver_rules: IgnoreUpdateTypes, ) -> bool: """If ANY of the semver rules are True, ignore the version.""" if any( @@ -627,8 +628,8 @@ def _ignore_semver_rules( def ignore_version( current: list[str], latest: list[str], - version_rules: "IgnoreVersions", - semver_rules: "IgnoreUpdateTypes", + version_rules: IgnoreVersions, + semver_rules: IgnoreUpdateTypes, ) -> bool: """Determine whether the latest version can be ignored. @@ -665,13 +666,13 @@ def ignore_version( def regenerate_requirement( - requirement: "Requirement", + requirement: Requirement, *, - name: "Optional[str]" = None, - extras: "Optional[set[str]]" = None, - specifier: "Optional[Union[SpecifierSet, str]]" = None, - url: "Optional[str]" = None, - marker: "Optional[Union[Marker, str]]" = None, + name: str | None = None, + extras: set[str] | None = None, + specifier: SpecifierSet | str | None = None, + url: str | None = None, + marker: Marker | str | None = None, post_name_space: bool = False, ) -> str: """Regenerate a requirement string including the given parameters. @@ -723,7 +724,7 @@ def regenerate_requirement( def update_specifier_set( # pylint: disable=too-many-statements - latest_version: "Union[SemanticVersion, str]", current_specifier_set: SpecifierSet + latest_version: SemanticVersion | str, current_specifier_set: SpecifierSet ) -> SpecifierSet: """Update the specifier set to include the latest version.""" logger = logging.getLogger(__name__) @@ -831,7 +832,7 @@ def update_specifier_set( # pylint: disable=too-many-statements # current specifier set is valid as is and already includes the latest version if updated_specifiers != [""]: # Otherwise, add updated specifier(s) to new specifier set - new_specifier_set |= set(Specifier(_) for _ in updated_specifiers) + new_specifier_set |= {Specifier(_) for _ in updated_specifiers} else: raise UnableToResolve( "Cannot resolve how to update specifier set to include latest version." @@ -867,7 +868,7 @@ def _semi_valid_python_version(version: SemanticVersion) -> bool: def get_min_max_py_version( # pylint: disable=too-many-branches,too-many-statements - requires_python: "Union[str, Marker]", + requires_python: str | Marker, ) -> str: """Get minimum or maximum Python version from `requires_python`. @@ -1016,7 +1017,7 @@ def get_min_max_py_version( # pylint: disable=too-many-branches,too-many-statem return py_version -def find_minimum_py_version(marker: "Marker", project_py_version: str) -> str: +def find_minimum_py_version(marker: Marker, project_py_version: str) -> str: """Find the minimum Python version from a marker.""" split_py_version = project_py_version.split(".") diff --git a/pyproject.toml b/pyproject.toml index dfdd0e66..f585d809 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,9 @@ classifiers = [ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Natural Language :: English", "Operating System :: OS Independent", "Private :: Do Not Upload", @@ -32,6 +35,8 @@ dependencies = [ "invoke ~=2.2", "packaging ~=23.2", "tomlkit ~=0.12.3", + "typing-extensions ~=4.7; python_version < '3.8'", + "typing-extensions ~=4.8; python_version >= '3.8'", ] [project.optional-dependencies] @@ -39,7 +44,8 @@ docs = [ "mike ~=2.0", "mkdocs ~=1.5", "mkdocs-awesome-pages-plugin ~=2.9", - "mkdocs-material ~=9.2", + "mkdocs-material ~=9.2; python_version < '3.8'", + "mkdocs-material ~=9.4; python_version >= '3.8'", "mkdocstrings[python-legacy] ~=0.22.0; python_version < '3.8'", "mkdocstrings[python-legacy] ~=0.24.0; python_version >= '3.8'", ] @@ -66,7 +72,7 @@ Changelog = "https://SINTEF.github.io/ci-cd/latest/CHANGELOG" "ci-cd" = "ci_cd.main:program.run" [tool.mypy] -python_version = "3.9" +python_version = "3.7" ignore_missing_imports = true scripts_are_modules = true warn_unused_configs = true @@ -82,9 +88,6 @@ max-branches = 18 max-returns = 10 [tool.pytest.ini_options] -minversion = "7.0" -filterwarnings = [ - "ignore:.*imp module.*:DeprecationWarning", - # Remove when invoke updates to `inspect.signature()` or similar: - "ignore:.*inspect.getargspec().*:DeprecationWarning", -] +minversion = "7.4" +addopts = ["--cov=ci_cd", "--cov-report=term-missing"] +filterwarnings = ["error"] diff --git a/tests/conftest.py b/tests/conftest.py index 22b57de8..c5b86ba9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,6 @@ """Test fixtures.""" +from __future__ import annotations + import pytest diff --git a/tests/tasks/test_api_reference_docs.py b/tests/tasks/test_api_reference_docs.py index b631c139..fd5e7ef8 100644 --- a/tests/tasks/test_api_reference_docs.py +++ b/tests/tasks/test_api_reference_docs.py @@ -1,12 +1,14 @@ """Test `ci_cd.tasks.api_reference_docs`.""" # pylint: disable=too-many-locals +from __future__ import annotations + from typing import TYPE_CHECKING if TYPE_CHECKING: from pathlib import Path -def test_default_run(tmp_path: "Path") -> None: +def test_default_run(tmp_path: Path) -> None: """Check create_api_reference_docs runs with defaults.""" import os import shutil @@ -94,7 +96,7 @@ def test_default_run(tmp_path: "Path") -> None: ) == "# versions\n\n::: ci_cd.utils.versions\n" -def test_nested_package(tmp_path: "Path") -> None: +def test_nested_package(tmp_path: Path) -> None: """Check create_api_reference_docs generates correct link to sub-nested package directory.""" import os @@ -184,7 +186,7 @@ def test_nested_package(tmp_path: "Path") -> None: ) == "# versions\n\n::: src.ci_cd.ci_cd.utils.versions\n" -def test_special_options(tmp_path: "Path") -> None: +def test_special_options(tmp_path: Path) -> None: """Check create_api_reference_docs generates correct markdown files with `--special-option`.""" import os @@ -309,7 +311,7 @@ def test_special_options(tmp_path: "Path") -> None: ) == "# versions\n\n::: ci_cd.utils.versions\n" -def test_special_options_multiple_packages(tmp_path: "Path") -> None: +def test_special_options_multiple_packages(tmp_path: Path) -> None: """Check create_api_reference_docs generates correct markdown files with `--special-option` for a multi-package repository.""" import os @@ -477,7 +479,7 @@ def test_special_options_multiple_packages(tmp_path: "Path") -> None: ) == f"# versions\n\n::: {package_name}.utils.versions\n" -def test_larger_package(tmp_path: "Path") -> None: +def test_larger_package(tmp_path: Path) -> None: """Check create_api_reference_docs runs with a more 'complete' package.""" import os import shutil @@ -647,7 +649,7 @@ def test_larger_package(tmp_path: "Path") -> None: ) -def test_larger_multi_packages(tmp_path: "Path") -> None: +def test_larger_multi_packages(tmp_path: Path) -> None: """Check create_api_reference_docs runs with a set of more 'complete' packages.""" import os import shutil diff --git a/tests/tasks/test_update_deps.py b/tests/tasks/test_update_deps.py index 760a0e58..59b28674 100644 --- a/tests/tasks/test_update_deps.py +++ b/tests/tasks/test_update_deps.py @@ -10,7 +10,7 @@ from pathlib import Path -def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None: +def test_update_deps(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: """Check update_deps runs with defaults.""" import re @@ -317,7 +317,7 @@ def test_update_deps(tmp_path: "Path", caplog: pytest.LogCaptureFixture) -> None ], ) def test_ignore_rules_logic( - tmp_path: "Path", ignore_rules: list[str], expected_result: dict[str, str] + tmp_path: Path, ignore_rules: list[str], expected_result: dict[str, str] ) -> None: """Check the workflow of multiple interconnecting ignore rules are respected.""" import re @@ -402,7 +402,7 @@ def test_ignore_rules_logic( def test_python_version_marker( - tmp_path: "Path", caplog: pytest.LogCaptureFixture + tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Check the python version marker is respected.""" import re @@ -458,7 +458,7 @@ def test_python_version_marker( def test_no_warn_when_project_name( - tmp_path: "Path", caplog: pytest.LogCaptureFixture + tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Check no warning is emitted if a dependency is also the project name. diff --git a/tests/utils/test_versions.py b/tests/utils/test_versions.py index f608ea97..d384e911 100644 --- a/tests/utils/test_versions.py +++ b/tests/utils/test_versions.py @@ -200,14 +200,14 @@ def test_semanticversion_next_version_invalid() -> None: def _parametrize_ignore_version() -> ( - "dict[str, tuple[str, str, IgnoreVersions, IgnoreUpdateTypes, bool]]" + dict[str, tuple[str, str, IgnoreVersions, IgnoreUpdateTypes, bool]] ): """Utility function for `test_ignore_version()`. The parametrized inputs are created in this function in order to have more meaningful IDs in the runtime overview. """ - test_cases: "list[tuple[str, str, IgnoreVersions, IgnoreUpdateTypes, bool]]" = [ + test_cases: list[tuple[str, str, IgnoreVersions, IgnoreUpdateTypes, bool]] = [ ("1.1.1", "2.2.2", [{"operator": ">", "version": "2.2.2"}], {}, False), ("1.1.1", "2.2.2", [{"operator": ">", "version": "2.2"}], {}, True), ("1.1.1", "2.2.2", [{"operator": ">", "version": "2"}], {}, True), @@ -724,7 +724,7 @@ def _parametrize_ignore_version() -> ( ), ("1.1.1", "1.1.2", [], {}, True), ] - res: "dict[str, tuple[str, str, IgnoreVersions, IgnoreUpdateTypes, bool]]" = {} + res: dict[str, tuple[str, str, IgnoreVersions, IgnoreUpdateTypes, bool]] = {} for test_case in test_cases: if test_case[2] and test_case[3]: operator_version = ",".join( @@ -756,8 +756,8 @@ def _parametrize_ignore_version() -> ( def test_ignore_version( current: str, latest: str, - version_rules: "IgnoreVersions", - semver_rules: "IgnoreUpdateTypes", + version_rules: IgnoreVersions, + semver_rules: IgnoreUpdateTypes, expected_outcome: bool, ) -> None: """Check the expected ignore rules are resolved correctly.""" @@ -886,7 +886,7 @@ def test_ignore_version( def test_parse_ignore_entries( entries: list[str], separator: str, - expected_outcome: "dict[str, IgnoreEntry]", + expected_outcome: dict[str, IgnoreEntry], ) -> None: """Check the `--ignore` option values are parsed as expected.""" from ci_cd.utils.versions import parse_ignore_entries @@ -950,8 +950,8 @@ def test_parse_ignore_entries( ], ) def test_parse_ignore_rules( - rules: "IgnoreRules", - expected_outcome: "tuple[IgnoreVersions, IgnoreUpdateTypes]", + rules: IgnoreRules, + expected_outcome: tuple[IgnoreVersions, IgnoreUpdateTypes], ) -> None: """Check a specific set of ignore rules is parsed as expected.""" from ci_cd.utils.versions import parse_ignore_rules