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