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.10 3.11 3.12 3.13").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 run("default", *args) 148 return 0 149 150 if cmd == "multirun": 151 multirun(*args) 152 return 0 153 154 if cmd == "allrun": 155 allrun(*args) 156 return 0 157 158 if cmd.startswith("3."): 159 run(cmd, *args) 160 return 0 161 162 opts = [] 163 while args and (args[0].startswith("-") or "=" in args[0]): 164 opts.append(args.pop(0)) 165 166 if cmd == "clean": 167 clean() 168 elif cmd == "setup": 169 setup() 170 elif cmd == "vscode": 171 vscode() 172 elif cmd == "check": 173 multirun("duty", "check-quality", "check-types", "check-docs") 174 run("default", "duty", "check-api") 175 elif cmd in {"check-quality", "check-docs", "check-types", "test"}: 176 multirun("duty", cmd, *opts) 177 else: 178 run("default", "duty", cmd, *opts) 179 180 return 0 181 182 183 if __name__ == "__main__": 184 try: 185 sys.exit(main()) 186 except subprocess.CalledProcessError as process: 187 if process.output: 188 print(process.output, file=sys.stderr) 189 sys.exit(process.returncode)