From 531fbad0dc31be89ac7e6c75c6180b63f7f11131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Fri, 24 May 2024 16:22:00 +0200 Subject: [PATCH] chore: Template upgrade --- .copier-answers.yml | 2 +- .github/workflows/ci.yml | 3 - Makefile | 1 - config/pytest.ini | 2 - config/vscode/tasks.json | 6 - devdeps.txt | 39 ++-- duties.py | 88 +++----- scripts/make | 443 ++++++++++++++++++--------------------- 8 files changed, 250 insertions(+), 334 deletions(-) diff --git a/.copier-answers.yml b/.copier-answers.yml index 3a9ffe1..7c18143 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: 1.2.4 +_commit: 1.2.7 _src_path: gh:pawamoy/copier-uv author_email: dev@pawamoy.fr author_fullname: Timothée Mazzucotelli diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51cea12..93a7505 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,9 +49,6 @@ jobs: - name: Check if the code is correctly typed run: make check-types - - name: Check for vulnerabilities in dependencies - run: make check-dependencies - - name: Check for breaking changes in the API run: make check-api diff --git a/Makefile b/Makefile index aede0fe..3fa7254 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,6 @@ actions = \ changelog \ check \ check-api \ - check-dependencies \ check-docs \ check-quality \ check-types \ diff --git a/config/pytest.ini b/config/pytest.ini index 796da7d..3a0fa52 100644 --- a/config/pytest.ini +++ b/config/pytest.ini @@ -1,8 +1,6 @@ [pytest] python_files = test_*.py - *_test.py - tests.py addopts = --cov --cov-append diff --git a/config/vscode/tasks.json b/config/vscode/tasks.json index 30008cf..73145ee 100644 --- a/config/vscode/tasks.json +++ b/config/vscode/tasks.json @@ -31,12 +31,6 @@ "command": "scripts/make", "args": ["check-docs"] }, - { - "label": "check-dependencies", - "type": "process", - "command": "scripts/make", - "args": ["check-dependencies"] - }, { "label": "check-api", "type": "process", diff --git a/devdeps.txt b/devdeps.txt index 7f44351..2789064 100644 --- a/devdeps.txt +++ b/devdeps.txt @@ -2,34 +2,33 @@ editables>=0.5 # maintenance -build>=1.0 -git-changelog>=2.3 -twine>=5.0 +build>=1.2 +git-changelog>=2.5 +twine>=5.1; python_version < '3.13' # ci -duty>=0.10 -ruff>=0.0 +duty>=1.4 +ruff>=0.4 pygments>=2.16 pymdown-extensions>=10.0 -pytest>=7.4 -pytest-cov>=4.1 +pytest>=8.2 +pytest-cov>=5.0 pytest-randomly>=3.15 -pytest-xdist>=3.3 -mypy>=1.5 -types-markdown>=3.5 +pytest-xdist>=3.6 +mypy>=1.10 +types-markdown>=3.6 types-pyyaml>=6.0 -safety>=2.3 # docs -black>=23.9 -markdown-callouts>=0.3 -markdown-exec>=1.7 -mkdocs>=1.5 +black>=24.4 +markdown-callouts>=0.4 +markdown-exec>=1.8 +mkdocs>=1.6 mkdocs-coverage>=1.0 mkdocs-gen-files>=0.5 -mkdocs-git-committers-plugin-2>=1.2 +mkdocs-git-committers-plugin-2>=2.3 mkdocs-literate-nav>=0.6 -mkdocs-material>=9.4 -mkdocs-minify-plugin>=0.7 -mkdocstrings[python]>=0.23 -tomli>=2.0; python_version < '3.11' \ No newline at end of file +mkdocs-material>=9.5 +mkdocs-minify-plugin>=0.8 +mkdocstrings[python]>=0.25 +tomli>=2.0; python_version < '3.11' diff --git a/duties.py b/duties.py index 59d449f..4e7cf7a 100644 --- a/duties.py +++ b/duties.py @@ -9,8 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Iterator -from duty import duty -from duty.callables import coverage, lazy, mkdocs, mypy, pytest, ruff, safety +from duty import duty, tools if TYPE_CHECKING: from duty.context import Context @@ -51,10 +50,7 @@ def changelog(ctx: Context, bump: str = "") -> None: Parameters: bump: Bump option passed to git-changelog. """ - from git_changelog.cli import main as git_changelog - - args = [f"--bump={bump}"] if bump else [] - ctx.run(git_changelog, args=[args], title="Updating changelog", command="git-changelog") + ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") @duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies", "check-api"]) @@ -66,26 +62,8 @@ def check(ctx: Context) -> None: # noqa: ARG001 def check_quality(ctx: Context) -> None: """Check the code quality.""" ctx.run( - ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), + tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), title=pyprefix("Checking code quality"), - command=f"ruff check --config config/ruff.toml {PY_SRC}", - ) - - -@duty -def check_dependencies(ctx: Context) -> None: - """Check for vulnerabilities in dependencies.""" - # retrieve the list of dependencies - requirements = ctx.run( - ["uv", "pip", "freeze"], - silent=True, - allow_overrides=False, - ) - - ctx.run( - safety.check(requirements), - title="Checking dependencies", - command="uv pip freeze | safety check --stdin", ) @@ -96,9 +74,8 @@ def check_docs(ctx: Context) -> None: Path("htmlcov/index.html").touch(exist_ok=True) with material_insiders(): ctx.run( - mkdocs.build(strict=True, verbose=True), + tools.mkdocs.build(strict=True, verbose=True), title=pyprefix("Building documentation"), - command="mkdocs build -vs", ) @@ -106,28 +83,23 @@ def check_docs(ctx: Context) -> None: def check_types(ctx: Context) -> None: """Check that the code is correctly typed.""" ctx.run( - mypy.run(*PY_SRC_LIST, config_file="config/mypy.ini"), + tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), title=pyprefix("Type-checking"), - command=f"mypy --config-file config/mypy.ini {PY_SRC}", ) @duty -def check_api(ctx: Context) -> None: +def check_api(ctx: Context, *cli_args: str) -> None: """Check for API breaking changes.""" - from griffe.cli import check as g_check - - griffe_check = lazy(g_check, name="griffe.check") ctx.run( - griffe_check("mkdocs_autorefs", search_paths=["src"], color=True), + tools.griffe.check("mkdocs_autorefs", search=["src"], color=True).add_args(*cli_args), title="Checking for API breaking changes", - command="griffe check -ssrc mkdocs_autorefs", nofail=True, ) @duty -def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: +def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: """Serve the documentation (localhost:8000). Parameters: @@ -136,7 +108,7 @@ def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: """ with material_insiders(): ctx.run( - mkdocs.serve(dev_addr=f"{host}:{port}"), + tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), title="Serving documentation", capture=False, ) @@ -144,34 +116,30 @@ def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: @duty def docs_deploy(ctx: Context) -> None: - """Deploy the documentation on GitHub pages.""" + """Deploy the documentation to GitHub pages.""" os.environ["DEPLOY"] = "true" with material_insiders() as insiders: if not insiders: ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!") - ctx.run(mkdocs.gh_deploy(), title="Deploying documentation") + ctx.run(tools.mkdocs.gh_deploy(), title="Deploying documentation") @duty def format(ctx: Context) -> None: """Run formatting tools on the code.""" ctx.run( - ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), + tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), title="Auto-fixing code", ) - ctx.run(ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") + ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") @duty def build(ctx: Context) -> None: """Build source and wheel distributions.""" - from build.__main__ import main as pyproject_build - ctx.run( - pyproject_build, - args=[()], + tools.build(), title="Building source and wheel distributions", - command="pyproject-build", pty=PTY, ) @@ -179,16 +147,12 @@ def build(ctx: Context) -> None: @duty def publish(ctx: Context) -> None: """Publish source and wheel distributions to PyPI.""" - from twine.cli import dispatch as twine_upload - if not Path("dist").exists(): ctx.run("false", title="No distribution files found") dists = [str(dist) for dist in Path("dist").iterdir()] ctx.run( - twine_upload, - args=[["upload", "-r", "pypi", "--skip-existing", *dists]], - title="Publish source and wheel distributions to PyPI", - command="twine upload -r pypi --skip-existing dist/*", + tools.twine.upload(*dists, skip_existing=True), + title="Publishing source and wheel distributions to PyPI", pty=PTY, ) @@ -209,16 +173,16 @@ def release(ctx: Context, version: str = "") -> None: ctx.run("git push --tags", title="Pushing tags", pty=False) -@duty(silent=True, aliases=["coverage"]) -def cov(ctx: Context) -> None: +@duty(silent=True, aliases=["cov"]) +def coverage(ctx: Context) -> None: """Report coverage as text and HTML.""" - ctx.run(coverage.combine, nofail=True) - ctx.run(coverage.report(rcfile="config/coverage.ini"), capture=False) - ctx.run(coverage.html(rcfile="config/coverage.ini")) + ctx.run(tools.coverage.combine(), nofail=True) + ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False) + ctx.run(tools.coverage.html(rcfile="config/coverage.ini")) @duty -def test(ctx: Context, match: str = "") -> None: +def test(ctx: Context, *cli_args: str, match: str = "") -> None: """Run the test suite. Parameters: @@ -227,7 +191,11 @@ def test(ctx: Context, match: str = "") -> None: py_version = f"{sys.version_info.major}{sys.version_info.minor}" os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" ctx.run( - pytest.run("-n", "auto", "tests", config_file="config/pytest.ini", select=match, color="yes"), + tools.pytest( + "tests", + config_file="config/pytest.ini", + select=match, + color="yes", + ).add_args("-n", "auto", *cli_args), title=pyprefix("Running tests"), - command=f"pytest -c config/pytest.ini -n auto -k{match!r} --color=yes tests", ) diff --git a/scripts/make b/scripts/make index 11a3c5f..c097985 100755 --- a/scripts/make +++ b/scripts/make @@ -1,242 +1,203 @@ -#!/usr/bin/env bash - -set -e -export PYTHON_VERSIONS=${PYTHON_VERSIONS-3.8 3.9 3.10 3.11 3.12 3.13} - -exe="" -prefix="" - - -# Install runtime and development dependencies, -# as well as current project in editable mode. -uv_install() { - local uv_opts - if [ -n "${UV_RESOLUTION}" ]; then - uv_opts="--resolution=${UV_RESOLUTION}" - fi - uv pip compile ${uv_opts} pyproject.toml devdeps.txt | uv pip install -r - - if [ -z "${CI}" ]; then - uv pip install --no-deps -e . - else - uv pip install --no-deps . - fi -} - - -# Setup the development environment by installing dependencies -# in multiple Python virtual environments with uv: -# one venv per Python version in `.venvs/$py`, -# and an additional default venv in `.venv`. -setup() { - if ! command -v uv &>/dev/null; then - echo "make: setup: uv must be installed, see https://github.com/astral-sh/uv" >&2 - return 1 - fi - - if [ -n "${PYTHON_VERSIONS}" ]; then - for version in ${PYTHON_VERSIONS}; do - if [ ! -d ".venvs/${version}" ]; then - uv venv --python "${version}" ".venvs/${version}" - fi - VIRTUAL_ENV="${PWD}/.venvs/${version}" uv_install - done - fi - - if [ ! -d .venv ]; then uv venv --python python; fi - uv_install -} - - -# Activate a Python virtual environments. -# The annoying operating system also requires -# that we set some global variables to help it find commands... -activate() { - local path - if [ -f "$1/bin/activate" ]; then - source "$1/bin/activate" +#!/usr/bin/env python3 +"""Management commands.""" + +import os +import shutil +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.8 3.9 3.10 3.11 3.12 3.13").split() + +exe = "" +prefix = "" + + +def shell(cmd: str) -> None: + """Run a shell command.""" + subprocess.run(cmd, shell=True, check=True) # noqa: S602 + + +@contextmanager +def environ(**kwargs: str) -> Iterator[None]: + """Temporarily set environment variables.""" + original = dict(os.environ) + os.environ.update(kwargs) + try: + yield + finally: + os.environ.clear() + os.environ.update(original) + + +def uv_install() -> None: + """Install dependencies using uv.""" + uv_opts = "" + if "UV_RESOLUTION" in os.environ: + uv_opts = f"--resolution={os.getenv('UV_RESOLUTION')}" + cmd = f"uv pip compile {uv_opts} pyproject.toml devdeps.txt | uv pip install -r -" + shell(cmd) + if "CI" not in os.environ: + shell("uv pip install --no-deps -e .") + else: + shell("uv pip install --no-deps .") + + +def setup() -> None: + """Setup the project.""" + if not shutil.which("uv"): + raise ValueError("make: setup: uv must be installed, see https://github.com/astral-sh/uv") + + print("Installing dependencies (default environment)") # noqa: T201 + default_venv = Path(".venv") + if not default_venv.exists(): + shell("uv venv --python python") + uv_install() + + if PYTHON_VERSIONS: + for version in PYTHON_VERSIONS: + print(f"\nInstalling dependencies (python{version})") # noqa: T201 + venv_path = Path(f".venvs/{version}") + if not venv_path.exists(): + shell(f"uv venv --python {version} {venv_path}") + with environ(VIRTUAL_ENV=str(venv_path.resolve())): + uv_install() + + +def activate(path: str) -> None: + """Activate a virtual environment.""" + global exe, prefix # noqa: PLW0603 + + if (bin := Path(path, "bin")).exists(): + activate_script = bin / "activate_this.py" + elif (scripts := Path(path, "Scripts")).exists(): + activate_script = scripts / "activate_this.py" + exe = ".exe" + prefix = f"{path}/Scripts/" + else: + raise ValueError(f"make: activate: Cannot find activation script in {path}") + + if not activate_script.exists(): + raise ValueError(f"make: activate: Cannot find activation script in {path}") + + exec(activate_script.read_text(), {"__file__": str(activate_script)}) # noqa: S102 + + +def run(version: str, cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command in a virtual environment.""" + kwargs = {"check": True, **kwargs} + if version == "default": + activate(".venv") + subprocess.run([f"{prefix}{cmd}{exe}", *args], **kwargs) # noqa: S603, PLW1510 + else: + activate(f".venvs/{version}") + os.environ["MULTIRUN"] = "1" + subprocess.run([f"{prefix}{cmd}{exe}", *args], **kwargs) # noqa: S603, PLW1510 + + +def multirun(cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command for all configured Python versions.""" + if PYTHON_VERSIONS: + for version in PYTHON_VERSIONS: + run(version, cmd, *args, **kwargs) + else: + run("default", cmd, *args, **kwargs) + + +def allrun(cmd: str, *args: str, **kwargs: Any) -> None: + """Run a command in all virtual environments.""" + run("default", cmd, *args, **kwargs) + if PYTHON_VERSIONS: + multirun(cmd, *args, **kwargs) + + +def clean() -> None: + """Delete build artifacts and cache files.""" + paths_to_clean = ["build", "dist", "htmlcov", "site", ".coverage*", ".pdm-build"] + for path in paths_to_clean: + shell(f"rm -rf {path}") + + cache_dirs = [".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__"] + for dirpath in Path(".").rglob("*"): + if any(dirpath.match(pattern) for pattern in cache_dirs) and not (dirpath.match(".venv") or dirpath.match(".venvs")): + shutil.rmtree(path, ignore_errors=True) + + +def vscode() -> None: + """Configure VSCode to work on this project.""" + Path(".vscode").mkdir(parents=True, exist_ok=True) + shell("cp -v config/vscode/* .vscode") + + +def main() -> int: + """Main entry point.""" + args = list(sys.argv[1:]) + if not args or args[0] == "help": + if len(args) > 1: + run("default", "duty", "--help", args[1]) + else: + print("Available commands") # noqa: T201 + print(" help Print this help. Add task name to print help.") # noqa: T201 + print(" setup Setup all virtual environments (install dependencies).") # noqa: T201 + print(" run Run a command in the default virtual environment.") # noqa: T201 + print(" multirun Run a command for all configured Python versions.") # noqa: T201 + print(" allrun Run a command in all virtual environments.") # noqa: T201 + print(" 3.x Run a command in the virtual environment for Python 3.x.") # noqa: T201 + print(" clean Delete build artifacts and cache files.") # noqa: T201 + print(" vscode Configure VSCode to work on this project.") # noqa: T201 + try: + run("default", "python", "-V", capture_output=True) + except (subprocess.CalledProcessError, ValueError): + pass + else: + print("\nAvailable tasks") # noqa: T201 + run("default", "duty", "--list") return 0 - fi - if [ -f "$1/Scripts/activate.bat" ]; then - "$1/Scripts/activate.bat" - exe=".exe" - prefix="$1/Scripts/" - return 0 - fi - echo "run: Cannot activate venv $1" >&2 - return 1 -} - -# Run a command in a specific virtual environment. -run() { - local version="$1" - local cmd="$2" - shift 2 - - if [ "${version}" = "default" ]; then - (activate .venv && "${prefix}${cmd}${exe}" "$@") - else - (activate ".venvs/${version}" && MULTIRUN=1 "${prefix}${cmd}${exe}" "$@") - fi -} - - -# Run a command in all configured Python virtual environments. -# We allow `PYTHON_VERSIONS` to be empty, and in that case -# we run the command in the default virtual environment only. -multirun() { - if [ -n "${PYTHON_VERSIONS}" ]; then - for version in ${PYTHON_VERSIONS}; do - run "${version}" "$@" - done - else - run default "$@" - fi -} - - -# Run a command in all configured Python virtual environments, -# as well as in the default virtual environment. -allrun() { - run default "$@" - if [ -n "${PYTHON_VERSIONS}" ]; then - multirun "$@" - fi -} - - -# Clean project by deleting build artifacts and cache files. -clean() { - rm -rf build - rm -rf dist - rm -rf htmlcov - rm -rf site - rm -rf .coverage* - rm -rf .pdm-build - - find . -type d \ - -path ./.venv -prune \ - -path ./.venvs -prune \ - -o -name .cache \ - -o -name .pytest_cache \ - -o -name .mypy_cache \ - -o -name .ruff_cache \ - -o -name __pycache__ | - xargs rm -rf -} - -# Configure VSCode. -# This task will overwrite the following files, so make sure to back them up: -# - `.vscode/launch.json` -# - `.vscode/settings.json` -# - `.vscode/tasks.json` -vscode() { - mkdir -p .vscode &>/dev/null - cp -v config/vscode/* .vscode -} - -# Record options following a command name, -# until a non-option argument is met or there are no more arguments. -# Output each option on a new line, so the parent caller can store them in an array. -# Return the number of times the parent caller must shift arguments. -options() { - local shift_count=0 - for arg in "$@"; do - if [[ "${arg}" =~ ^- || "${arg}" =~ ^.+= ]]; then - echo "${arg}" - ((shift_count++)) - else - break - fi - done - return ${shift_count} -} - - -# Main function. -main() { - local cmd - - if [ $# -eq 0 ] || [ "$1" = "help" ]; then - if [ -n "$2" ]; then - run default duty --help "$2" - else - echo "Available commands" - echo " help Print this help. Add task name to print help." - echo " setup Setup all virtual environments (install dependencies)." - echo " run Run a command in the default virtual environment." - echo " multirun Run a command for all configured Python versions." - echo " allrun Run a command in all virtual environments." - echo " 3.x Run a command in the virtual environment for Python 3.x." - echo " clean Delete build artifacts and cache files." - echo " vscode Configure VSCode to work on this project." - if run default python -V &>/dev/null; then - echo - echo "Available tasks" - run default duty --list - fi - fi - exit 0 - fi - - while [ $# -ne 0 ]; do - cmd="$1" - shift - - # Handle `run` early to simplify `case` below. - if [ "${cmd}" = "run" ]; then - run default "$@" - exit $? - fi - - # Handle `multirun` early to simplify `case` below. - if [ "${cmd}" = "multirun" ]; then - multirun "$@" - exit $? - fi - - # Handle `allrun` early to simplify `case` below. - if [ "${cmd}" = "allrun" ]; then - allrun "$@" - exit $? - fi - - # Handle `3.x` early to simplify `case` below. - if [[ "${cmd}" = 3.* ]]; then - run "${cmd}" "$@" - exit $? - fi - - # All commands except `run` and `multirun` can be chained on a single line. - # Some of them accept options in two formats: `-f`, `--flag` and `param=value`. - # Some of them don't, and will print warnings/errors if options were given. - # The following statement reads options into an array. A syntax quirk means - # that with no options, the array still contains a single empty string. - # In that case, the `options` function returned 0, so we can empty the array. - opts=("$(options "$@")") && opts=() || shift $? - - case "${cmd}" in - # The following commands require special handling. - check) - multirun duty check-quality check-types check-docs - run default duty check-dependencies check-api - ;; - clean|setup|vscode) - "${cmd}" ;; - - # The following commands run in all venvs. - check-quality|\ - check-docs|\ - check-types|\ - test) - multirun duty "${cmd}" "${opts[@]}" ;; - - # The following commands run in the default venv only. - *) - run default duty "${cmd}" "${opts[@]}" ;; - esac - done -} - - -# Execute the main function. -main "$@" + + while args: + cmd = args.pop(0) + + if cmd == "run": + run("default", *args) + return 0 + + if cmd == "multirun": + multirun(*args) + return 0 + + if cmd == "allrun": + allrun(*args) + return 0 + + if cmd.startswith("3."): + run(cmd, *args) + return 0 + + opts = [] + while args and (args[0].startswith("-") or "=" in args[0]): + opts.append(args.pop(0)) + + if cmd == "clean": + clean() + elif cmd == "setup": + setup() + elif cmd == "vscode": + vscode() + elif cmd == "check": + multirun("duty", "check-quality", "check-types", "check-docs") + run("default", "duty", "check-api") + elif cmd in {"check-quality", "check-docs", "check-types", "test"}: + multirun("duty", cmd, *opts) + else: + run("default", "duty", cmd, *opts) + + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: # noqa: BLE001 + sys.exit(1)