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