1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189
|
#!/usr/bin/env python3
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from contextlib import contextmanager
from pathlib import Path
from textwrap import dedent
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.9 3.10 3.11 3.12 3.13 3.14").split()
def shell(cmd: str, *, capture_output: bool = False, **kwargs: Any) -> str | None:
"""Run a shell command."""
if capture_output:
return subprocess.check_output(cmd, shell=True, text=True, **kwargs) # noqa: S602
subprocess.run(cmd, shell=True, check=True, stderr=subprocess.STDOUT, **kwargs) # noqa: S602
return None
@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(venv: Path) -> None:
"""Install dependencies using uv."""
with environ(UV_PROJECT_ENVIRONMENT=str(venv), PYO3_USE_ABI3_FORWARD_COMPATIBILITY="1"):
if "CI" in os.environ:
shell("uv sync --no-editable")
else:
shell("uv sync")
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)")
default_venv = Path(".venv")
if not default_venv.exists():
shell("uv venv")
uv_install(default_venv)
if PYTHON_VERSIONS:
for version in PYTHON_VERSIONS:
print(f"\nInstalling dependencies (python{version})")
venv_path = Path(f".venvs/{version}")
if not venv_path.exists():
shell(f"uv venv --python {version} {venv_path}")
with environ(UV_PROJECT_ENVIRONMENT=str(venv_path.resolve())):
uv_install(venv_path)
def run(version: str, cmd: str, *args: str, **kwargs: Any) -> None:
"""Run a command in a virtual environment."""
kwargs = {"check": True, **kwargs}
uv_run = ["uv", "run", "--no-sync"]
if version == "default":
with environ(UV_PROJECT_ENVIRONMENT=".venv"):
subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510
else:
with environ(UV_PROJECT_ENVIRONMENT=f".venvs/{version}", MULTIRUN="1"):
subprocess.run([*uv_run, cmd, *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:
shutil.rmtree(path, ignore_errors=True)
cache_dirs = {".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__"}
for dirpath in Path(".").rglob("*/"):
if dirpath.parts[0] not in (".venv", ".venvs") and dirpath.name in cache_dirs:
shutil.rmtree(dirpath, ignore_errors=True)
def vscode() -> None:
"""Configure VSCode to work on this project."""
shutil.copytree("config/vscode", ".vscode", dirs_exist_ok=True)
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(
dedent(
"""
Available commands
help Print this help. Add task name to print help.
setup Setup all virtual environments (install dependencies).
run Run a command in the default virtual environment.
multirun Run a command for all configured Python versions.
allrun Run a command in all virtual environments.
3.x Run a command in the virtual environment for Python 3.x.
clean Delete build artifacts and cache files.
vscode Configure VSCode to work on this project.
""",
),
flush=True,
)
if os.path.exists(".venv"):
print("\nAvailable tasks", flush=True)
run("default", "duty", "--list")
return 0
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 subprocess.CalledProcessError as process:
if process.output:
print(process.output, file=sys.stderr)
sys.exit(process.returncode)
|