From e4383361248351fceb3afc281e689e342a5d4e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Fri, 7 Jun 2024 12:48:24 +0200 Subject: [PATCH] chore: Template upgrade --- .copier-answers.yml | 2 +- .github/workflows/ci.yml | 3 - Makefile | 3 +- config/pytest.ini | 2 - config/vscode/tasks.json | 6 - devdeps.txt | 37 ++- docs/insiders/installation.md | 124 +--------- duties.py | 88 +++---- scripts/make | 443 ++++++++++++++++------------------ 9 files changed, 256 insertions(+), 452 deletions(-) diff --git a/.copier-answers.yml b/.copier-answers.yml index 0a0c200a..83729035 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.8 _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 bf0a14c2..e3a101a2 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 aede0fe8..5e88121d 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,6 @@ actions = \ changelog \ check \ check-api \ - check-dependencies \ check-docs \ check-quality \ check-types \ @@ -26,4 +25,4 @@ actions = \ .PHONY: $(actions) $(actions): - @bash scripts/make "$@" + @python scripts/make "$@" diff --git a/config/pytest.ini b/config/pytest.ini index ac2e77fb..2b36076c 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-config config/coverage.ini diff --git a/config/vscode/tasks.json b/config/vscode/tasks.json index 30008cf2..73145eec 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 bcf32d1b..93f20539 100644 --- a/devdeps.txt +++ b/devdeps.txt @@ -2,37 +2,36 @@ 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 jsonschema>=4.17 pysource-codegen>=0.4 pysource-minimize>=0.5 -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 +black>=24.4 griffe-inherited-docstrings>=1.0 -markdown-callouts>=0.3 -markdown-exec>=1.7 -mkdocs>=1.5 +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 +mkdocs-material>=9.5 +mkdocs-minify-plugin>=0.8 +mkdocstrings[python]>=0.25 rich>=12.6 tomli>=2.0; python_version < '3.11' diff --git a/docs/insiders/installation.md b/docs/insiders/installation.md index 24d91c0d..d2726844 100644 --- a/docs/insiders/installation.md +++ b/docs/insiders/installation.md @@ -23,6 +23,9 @@ of Insiders projects in the PyPI index of your choice See [how to install it](https://pawamoy.github.io/pypi-insiders/#installation) and [how to use it](https://pawamoy.github.io/pypi-insiders/#usage). +**We kindly ask that you do not upload the distributions to public registries, +as it is against our [Terms of use](index.md#terms).** + ### with pip (ssh/https) *Griffe Insiders* can be installed with `pip` [using SSH][using ssh]: @@ -58,130 +61,15 @@ pip install git+https://${GH_TOKEN}@github.com/pawamoy-insiders/griffe.git > token must be kept secret at all times, as it allows the owner to access your > private repositories. -### with pip (self-hosted) - -Self-hosting the Insiders package makes it possible to depend on *Griffe* normally, -while transparently downloading and installing the Insiders version locally. -It means that you can specify your dependencies normally, and your contributors without access -to Insiders will get the public version, while you get the Insiders version on your machine. - -WARNING: **Limitation** -With this method, there is no way to force the installation of an Insiders version -rather than a public version. If there is a public version that is more recent -than your self-hosted Insiders version, the public version will take precedence. -Remember to regularly update your self-hosted versions by uploading latest distributions. - -You can build the distributions for Insiders yourself, by cloning the repository -and using [build] to build the distributions, -or you can download them from our [GitHub Releases]. -You can upload these distributions to a private PyPI-like registry -([Artifactory], [Google Cloud], [pypiserver], etc.) -with [Twine]: - - [build]: https://pypi.org/project/build/ - [Artifactory]: https://jfrog.com/help/r/jfrog-artifactory-documentation/pypi-repositories - [Google Cloud]: https://cloud.google.com/artifact-registry/docs/python - [pypiserver]: https://pypi.org/project/pypiserver/ - [Github Releases]: https://github.com/pawamoy-insiders/griffe/releases - [Twine]: https://pypi.org/project/twine/ - -```bash -# download distributions in ~/dists, then upload with: -twine upload --repository-url https://your-private-index.com ~/dists/* -``` - -You might also need to provide a username and password/token to authenticate against the registry. -Please check [Twine's documentation][twine docs]. - - [twine docs]: https://twine.readthedocs.io/en/stable/ - -You can then configure pip (or other tools) to look for packages into your package index. -For example, with pip: - -```bash -pip config set global.extra-index-url https://your-private-index.com/simple -``` - -Note that the URL might differ depending on whether your are uploading a package (with Twine) -or installing a package (with pip), and depending on the registry you are using (Artifactory, Google Cloud, etc.). -Please check the documentation of your registry to learn how to configure your environment. - -**We kindly ask that you do not upload the distributions to public registries, -as it is against our [Terms of use](index.md#terms).** +### with Git ->? TIP: **Full example with `pypiserver`** -> In this example we use [pypiserver] to serve a local PyPI index. -> -> ```bash -> pip install --user pypiserver -> # or pipx install pypiserver -> -> # create a packages directory -> mkdir -p ~/.local/pypiserver/packages -> -> # run the pypi server without authentication -> pypi-server run -p 8080 -a . -P . ~/.local/pypiserver/packages & -> ``` -> -> We can configure the credentials to access the server in [`~/.pypirc`][pypirc]: -> -> [pypirc]: https://packaging.python.org/en/latest/specifications/pypirc/ -> -> ```ini title=".pypirc" -> [distutils] -> index-servers = -> local -> -> [local] -> repository: http://localhost:8080 -> username: -> password: -> ``` -> -> We then clone the Insiders repository, build distributions and upload them to our local server: -> -> ```bash -> # clone the repository -> git clone git@github.com:pawamoy-insiders/griffe -> cd griffe -> -> # install build -> pip install --user build -> # or pipx install build -> -> # checkout latest tag -> git checkout $(git describe --tags --abbrev=0) -> -> # build the distributions -> pyproject-build -> -> # upload them to our local server -> twine upload -r local dist/* --skip-existing -> ``` -> -> Finally, we configure pip, and for example [PDM][pdm], to use our local index to find packages: -> -> ```bash -> pip config set global.extra-index-url http://localhost:8080/simple -> pdm config pypi.extra.url http://localhost:8080/simple -> ``` -> -> [pdm]: https://pdm.fming.dev/latest/ -> -> Now when running `pip install griffe`, -> or resolving dependencies with PDM, -> both tools will look into our local index and find the Insiders version. -> **Remember to update your local index regularly!** - -### with git - -Of course, you can use *Griffe Insiders* directly from `git`: +Of course, you can use *Griffe Insiders* directly using Git: ``` git clone git@github.com:pawamoy-insiders/griffe ``` -When cloning from `git`, the package must be installed: +When cloning with Git, the package must be installed: ``` pip install -e griffe diff --git a/duties.py b/duties.py index 12a5ba5b..b31c7772 100644 --- a/duties.py +++ b/duties.py @@ -10,8 +10,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 @@ -52,10 +51,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"]) @@ -67,26 +63,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", ) @@ -97,9 +75,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", ) @@ -107,28 +84,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("griffe", search_paths=["src"], color=True), + tools.griffe.check("griffe", search=["src"], color=True).add_args(*cli_args), title="Checking for API breaking changes", - command="griffe check -ssrc griffe", 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: @@ -137,7 +109,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, ) @@ -145,7 +117,7 @@ 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: @@ -154,7 +126,7 @@ def docs_deploy(ctx: Context) -> None: if "pawamoy-insiders/griffe" in origin: ctx.run("git remote add upstream git@github.com:mkdocstrings/griffe", silent=True, nofail=True) ctx.run( - mkdocs.gh_deploy(remote_name="upstream", force=True), + tools.mkdocs.gh_deploy(remote_name="upstream", force=True), title="Deploying documentation", ) else: @@ -169,22 +141,18 @@ def docs_deploy(ctx: Context) -> None: 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, ) @@ -192,16 +160,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, ) @@ -228,16 +192,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: @@ -246,9 +210,13 @@ 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", verbosity=10), + 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 11a3c5f7..c097985e 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)