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