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