Skip to content

Commit

Permalink
Add workflow to bump unmanaged dependency versions
Browse files Browse the repository at this point in the history
  • Loading branch information
rmartin16 committed Mar 6, 2024
1 parent 82f4a38 commit 9772dec
Show file tree
Hide file tree
Showing 4 changed files with 548 additions and 0 deletions.
136 changes: 136 additions & 0 deletions .github/workflows/dep-version-bump.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: Update Dependency Versions

#######
# Updates versions for dependencies that are otherwise unmanaged by other processes.
#######

on:
schedule:
- cron: "0 20 * * SUN" # Sunday @ 2000 UTC
workflow_dispatch:
workflow_call:
inputs:
subdirectory:
description: "Whitespace-delimited list of directories containing pyproject.toml and tox.ini files; defaults to repo's base directory."
default: ""
type: string
create-changenote:
description: "Defaults 'true' to create a misc changenote in the './changes' directory."
default: true
type: boolean
workflow-repo:
# Only needed for PRs in other repos wanting to test new workflow changes before they are merged.
# These inputs should not be specified by another repo on their main branch.
description: "The repo to use to run additional workflows and actions."
default: "beeware/.github"
type: string
workflow-repo-ref:
description: "The repo ref to use to run additional workflows and actions."
default: ""
type: string
secrets:
BRUTUS_PAT_TOKEN:
required: true

permissions:
pull-requests: write

env:
BRANCH_PREFIX: "autoupdates"
CHANGENOTE_DIR: "./changes"
FORCE_COLOR: "1"

defaults:
run:
shell: bash

jobs:
dep-version-bump:
name: Bump Config File Dependencies
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout ${{ github.repository }}
uses: actions/[email protected]
with:
token: ${{ secrets.BRUTUS_PAT_TOKEN }}
path: "repo"

- name: Checkout ${{ inputs.workflow-repo }}${{ inputs.workflow-repo-ref && format('@{0}', inputs.workflow-repo-ref) || '' }}
uses: actions/[email protected]
with:
repository: ${{ inputs.workflow-repo }}
ref: ${{ inputs.workflow-repo-ref }}
path: "beeware-.github"

- name: Configure git
working-directory: "repo"
run: |
git config user.email "[email protected]"
git config user.name "Brutus (robot)"
- name: Set up Python
uses: actions/[email protected]
with:
python-version: 3.X
cache: pip
cache-dependency-path: |
**/setup.cfg
**/pyproject.toml
- name: Install Dependencies
run: |
python -m pip install pip --upgrade
python -m pip install configupdater packaging requests tomlkit --upgrade --upgrade-strategy eager
- name: Update Versions
working-directory: "repo"
run: |
if [ "${{ inputs.subdirectory }}" == "" ]; then
python ../beeware-.github/scripts/bump_versions.py
else
for SUBDIR in ${{ inputs.subdirectory }}; do
python ../beeware-.github/scripts/bump_versions.py ${SUBDIR}
done
fi
- name: PR Needed?
id: pr
working-directory: "repo"
run: |
if [[ $(git status --porcelain) ]]; then
echo "needed=true" >> ${GITHUB_OUTPUT}
else
echo "needed=false" >> ${GITHUB_OUTPUT}
fi
- name: Create Pull Request
id: created-pr
if: steps.pr.outputs.needed == 'true'
uses: peter-evans/[email protected]
with:
token: ${{ secrets.BRUTUS_PAT_TOKEN }}
path: "repo"
title: "Bump dependencies in pyproject.toml and tox.ini"
branch: "${{ env.BRANCH_PREFIX }}/config-files"
commit-message: "Bump dependencies in pyproject.toml and tox.ini"
committer: "Brutus (robot) <[email protected]>"
author: "Brutus (robot) <[email protected]>"
body: "Bumps versions for dependencies in pyproject.toml and tox.ini."
labels: "dependencies"

- name: Add changenote
if: (inputs.create-changenote == true) && (steps.created-pr.outputs.pull-request-number != '')
working-directory: "repo"
run: |
BRANCH_NAME="${{ env.BRANCH_PREFIX }}/config-files"
git fetch origin
git checkout "${BRANCH_NAME}"
FILENAME="${{ env.CHANGENOTE_DIR }}/${{ steps.created-pr.outputs.pull-request-number }}.misc.rst"
printf 'The pinned dependencies in pyproject.toml and tox.ini were updated to their latest versions.\n' > "${FILENAME}"
git add "${FILENAME}"
git commit -m "Add changenote."
git push --set-upstream origin "${BRANCH_NAME}"
25 changes: 25 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,28 @@ repos:
- id: trailing-whitespace
- id: check-json
- id: check-xml
- repo: https://github.com/asottile/pyupgrade
rev: v3.15.1
hooks:
- id: pyupgrade
args: [--py38-plus]
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
hooks:
- id: isort
args: [--profile=black, --split-on-trailing-comma, --combine-as]
- repo: https://github.com/PyCQA/docformatter
rev: v1.7.5
hooks:
- id: docformatter
args: [--in-place, --black]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.2.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/PyCQA/flake8
rev: 7.0.0
hooks:
- id: flake8
args: [--max-line-length=119]
208 changes: 208 additions & 0 deletions scripts/bump_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# bump_versions.py - Bumps versions for Python packages not managed by Dependabot
#
# Usage
# -----
# $ python bump_versions.py [subdirectory]
#
# Finds pinned dependencies in pyproject.toml and tox.ini and updates them to the
# latest version available on PyPI.
#
# positional arguments:
# subdirectory Directory that contains pyproject.toml/tox.ini; defaults to
# current directory
# Dependencies
# ------------
# configupdater packaging requests tomlkit

from __future__ import annotations

import sys
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from functools import lru_cache
from pathlib import Path
from shutil import get_terminal_size

import configupdater
import requests
import tomlkit
from packaging.requirements import InvalidRequirement, Requirement, SpecifierSet
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


class BumpVersionError(Exception):
def __init__(self, msg: str, error_no: int):
self.msg = msg
self.error_no = error_no


def validate_directory(subdirectory: str) -> Path:
subdirectory = Path.cwd() / subdirectory

if subdirectory == Path.cwd() or Path.cwd() in subdirectory.parents:
return subdirectory

raise BumpVersionError(
f"{subdirectory} is not a subdirectory of {Path.cwd()}", error_no=10
)


def parse_args():
width = max(min(get_terminal_size().columns, 80) - 2, 20)
parser = ArgumentParser(
description="Bumps versions for Python packages not managed by Dependabot",
formatter_class=lambda prog: RawDescriptionHelpFormatter(prog, width=width),
)
parser.add_argument(
"subdirectory",
default=".",
type=validate_directory,
nargs="?",
help=(
"Directory that contains pyproject.toml/tox.ini; "
"defaults to current directory"
),
)

args = parser.parse_args()
print(f"\nEvaluating {args.subdirectory}")

return args


def is_filepath_exist(filepath: Path) -> bool:
if not filepath.exists():
print(f"\nSkipping {filepath.relative_to(Path.cwd())}; not found")
return False

print(f"\n{filepath.relative_to(Path.cwd())}")
return True


def read_toml_file(file_path: Path) -> tomlkit.TOMLDocument:
with open(file_path, encoding="utf=8") as f:
return tomlkit.load(f)


def read_ini_file(file_path: Path) -> configupdater.ConfigUpdater:
config = configupdater.ConfigUpdater()
with open(file_path, encoding="utf=8") as f:
config.read_file(f)
return config


@lru_cache
def http_session() -> requests.Session:
sess = requests.Session()
adapter = HTTPAdapter(max_retries=Retry(status_forcelist={500, 502, 504}))
sess.mount("http://", adapter)
sess.mount("https://", adapter)
return sess


@lru_cache
def latest_pypi_version(name: str) -> str | None:
"""Fetch the latest version for a package from PyPI."""
resp = http_session().get(f"https://pypi.org/pypi/{name}/json", timeout=(3.1, 30))
try:
return resp.json()["info"]["version"]
except KeyError:
return None


def bump_version(req: str) -> str:
"""Bump the version for a requirement to its latest version.
Requires the requirement only uses == operator for version.
:param req: requirement to bump, e.g. build==1.0.5
:returns: requirement with bumped version or input requirement if cannot bump
"""
if req.startswith("#"):
return req

try:
req_parsed = Requirement(req)
except InvalidRequirement:
print(f" 𐄂 {req}; invalid requirement")
return req

if not (latest_version := latest_pypi_version(req_parsed.name)):
print(f" 𐄂 {req}; cannot determine latest version")
return req

if len(req_parsed.specifier) != 1:
print(f" 𐄂 {req}; requires exactly one specifier (latest: {latest_version})")
return req

spec = next(iter(req_parsed.specifier))

if spec.operator != "==":
print(f" 𐄂 {req}; must use == operator (latest: {latest_version})")
return req

if spec.version != latest_version:
print(f" ↑ {req_parsed.name} from {spec.version} to {latest_version}")
req_parsed.specifier = SpecifierSet(f"=={latest_version}")
return str(req_parsed)
else:
print(f" βœ“ {req} is already the latest version")

return req


def update_pyproject_toml(base_dir: Path):
"""Update pinned build-system requirements in pyproject.toml."""
pyproject_path = base_dir / "pyproject.toml"

if not is_filepath_exist(pyproject_path):
return

pyproject_toml = read_toml_file(pyproject_path)

if build_requires := pyproject_toml.get("build-system", {}).get("requires", []):
print(" build-system.requires")
for idx, req in enumerate(build_requires.copy()):
# update list directly to avoid losing existing formatting/comments
build_requires[idx] = bump_version(req)

pyproject_toml["build-system"]["requires"] = build_requires

with open(pyproject_path, "w") as f:
tomlkit.dump(pyproject_toml, f)


def update_tox_ini(base_dir: Path):
"""Update pinned requirements in tox.ini."""
tox_ini_path = base_dir / "tox.ini"

if not is_filepath_exist(tox_ini_path):
return

tox_ini = read_ini_file(tox_ini_path)

for section in tox_ini:
if reqs := tox_ini[section].get("deps"):
print(f" {section.split('{')[0]}")
tox_ini[section]["deps"].set_values(
bump_version(req) for req in reqs.value.splitlines() if req
)

with open(tox_ini_path, "w", encoding="utf-8") as f:
tox_ini.write(f)


def main():
ret_code = 0
try:
args = parse_args()
update_pyproject_toml(base_dir=args.subdirectory)
update_tox_ini(base_dir=args.subdirectory)
except BumpVersionError as e:
print(e.msg)
ret_code = e.error_no
return ret_code


if __name__ == "__main__":
sys.exit(main())
Loading

0 comments on commit 9772dec

Please sign in to comment.