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