make
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 Any, Iterator 14 15 PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.9 3.10 3.11 3.12 3.13 3.14").split() 16 17 18 def shell(cmd: str, capture_output: bool = False, **kwargs: Any) -> str | None: 19 """Run a shell command.""" 20 if capture_output: 21 return subprocess.check_output(cmd, shell=True, text=True, **kwargs) # noqa: S602 22 subprocess.run(cmd, shell=True, check=True, stderr=subprocess.STDOUT, **kwargs) # noqa: S602 23 return None 24 25 26 @contextmanager 27 def environ(**kwargs: str) -> Iterator[None]: 28 """Temporarily set environment variables.""" 29 original = dict(os.environ) 30 os.environ.update(kwargs) 31 try: 32 yield 33 finally: 34 os.environ.clear() 35 os.environ.update(original) 36 37 38 def uv_install(venv: Path) -> None: 39 """Install dependencies using uv.""" 40 with environ(UV_PROJECT_ENVIRONMENT=str(venv), PYO3_USE_ABI3_FORWARD_COMPATIBILITY="1"): 41 if "CI" in os.environ: 42 shell("uv sync --no-editable") 43 else: 44 shell("uv sync") 45 46 47 def setup() -> None: 48 """Setup the project.""" 49 if not shutil.which("uv"): 50 raise ValueError("make: setup: uv must be installed, see https://github.com/astral-sh/uv") 51 52 print("Installing dependencies (default environment)") # noqa: T201 53 default_venv = Path(".venv") 54 if not default_venv.exists(): 55 shell("uv venv --python python") 56 uv_install(default_venv) 57 58 if PYTHON_VERSIONS: 59 for version in PYTHON_VERSIONS: 60 print(f"\nInstalling dependencies (python{version})") # noqa: T201 61 venv_path = Path(f".venvs/{version}") 62 if not venv_path.exists(): 63 shell(f"uv venv --python {version} {venv_path}") 64 with environ(UV_PROJECT_ENVIRONMENT=str(venv_path.resolve())): 65 uv_install(venv_path) 66 67 68 def run(version: str, cmd: str, *args: str, no_sync: bool = False, **kwargs: Any) -> None: 69 """Run a command in a virtual environment.""" 70 kwargs = {"check": True, **kwargs} 71 uv_run = ["uv", "run"] 72 if no_sync: 73 uv_run.append("--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 shell(f"rm -rf {path}") 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 Path(".vscode").mkdir(parents=True, exist_ok=True) 113 shell("cp -v config/vscode/* .vscode") 114 115 116 def main() -> int: 117 """Main entry point.""" 118 args = list(sys.argv[1:]) 119 if not args or args[0] == "help": 120 if len(args) > 1: 121 run("default", "duty", "--help", args[1]) 122 else: 123 print( 124 dedent( 125 """ 126 Available commands 127 help Print this help. Add task name to print help. 128 setup Setup all virtual environments (install dependencies). 129 run Run a command in the default virtual environment. 130 multirun Run a command for all configured Python versions. 131 allrun Run a command in all virtual environments. 132 3.x Run a command in the virtual environment for Python 3.x. 133 clean Delete build artifacts and cache files. 134 vscode Configure VSCode to work on this project. 135 """ 136 ), 137 flush=True, 138 ) # noqa: T201 139 if os.path.exists(".venv"): 140 print("\nAvailable tasks", flush=True) # noqa: T201 141 run("default", "duty", "--list", no_sync=True) 142 return 0 143 144 while args: 145 cmd = args.pop(0) 146 147 if cmd == "run": 148 run("default", *args) 149 return 0 150 151 if cmd == "multirun": 152 multirun(*args) 153 return 0 154 155 if cmd == "allrun": 156 allrun(*args) 157 return 0 158 159 if cmd.startswith("3."): 160 run(cmd, *args) 161 return 0 162 163 opts = [] 164 while args and (args[0].startswith("-") or "=" in args[0]): 165 opts.append(args.pop(0)) 166 167 if cmd == "clean": 168 clean() 169 elif cmd == "setup": 170 setup() 171 elif cmd == "vscode": 172 vscode() 173 elif cmd == "check": 174 multirun("duty", "check-quality", "check-types", "check-docs") 175 run("default", "duty", "check-api") 176 elif cmd in {"check-quality", "check-docs", "check-types", "test"}: 177 multirun("duty", cmd, *opts) 178 else: 179 run("default", "duty", cmd, *opts) 180 181 return 0 182 183 184 if __name__ == "__main__": 185 try: 186 sys.exit(main()) 187 except subprocess.CalledProcessError as process: 188 if process.output: 189 print(process.output, file=sys.stderr) # noqa: T201 190 sys.exit(process.returncode)