/ scripts / make.py
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").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)