/ duties.py
duties.py
  1  """Development tasks."""
  2  
  3  from __future__ import annotations
  4  
  5  import os
  6  import sys
  7  from contextlib import contextmanager
  8  from importlib.metadata import version as pkgversion
  9  from pathlib import Path
 10  from typing import TYPE_CHECKING
 11  
 12  from duty import duty, tools
 13  
 14  if TYPE_CHECKING:
 15      from collections.abc import Iterator
 16  
 17      from duty.context import Context
 18  
 19  
 20  PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts"))
 21  PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS)
 22  PY_SRC = " ".join(PY_SRC_LIST)
 23  CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""}
 24  WINDOWS = os.name == "nt"
 25  PTY = not WINDOWS and not CI
 26  MULTIRUN = os.environ.get("MULTIRUN", "0") == "1"
 27  
 28  
 29  def pyprefix(title: str) -> str:  # noqa: D103
 30      if MULTIRUN:
 31          prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})"
 32          return f"{prefix:14}{title}"
 33      return title
 34  
 35  
 36  @contextmanager
 37  def material_insiders() -> Iterator[bool]:  # noqa: D103
 38      if "+insiders" in pkgversion("mkdocs-material"):
 39          os.environ["MATERIAL_INSIDERS"] = "true"
 40          try:
 41              yield True
 42          finally:
 43              os.environ.pop("MATERIAL_INSIDERS")
 44      else:
 45          yield False
 46  
 47  
 48  @duty
 49  def bundle(ctx: Context) -> None:
 50      """Build a standalone executable.
 51  
 52      Parameters:
 53          ctx: The [context][duty.logic.Context] instance (passed automatically).
 54      """
 55      ctx.run(
 56          "pyinstaller -F -n aria2p -p __pypackages__/3.8/lib src/aria2p/__main__.py",
 57          title="Bundling standalone executable",
 58          pty=PTY,
 59      )
 60  
 61  
 62  @duty
 63  def changelog(ctx: Context, bump: str = "") -> None:
 64      """Update the changelog in-place with latest commits.
 65  
 66      Parameters:
 67          bump: Bump option passed to git-changelog.
 68      """
 69      ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog")
 70  
 71  
 72  @duty(pre=["check-quality", "check-types", "check-docs", "check-api"])
 73  def check(ctx: Context) -> None:
 74      """Check it all!"""
 75  
 76  
 77  @duty
 78  def check_quality(ctx: Context) -> None:
 79      """Check the code quality."""
 80      ctx.run(
 81          tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"),
 82          title=pyprefix("Checking code quality"),
 83      )
 84  
 85  
 86  @duty
 87  def check_docs(ctx: Context) -> None:
 88      """Check if the documentation builds correctly."""
 89      Path("htmlcov").mkdir(parents=True, exist_ok=True)
 90      Path("htmlcov/index.html").touch(exist_ok=True)
 91      with material_insiders():
 92          ctx.run(
 93              tools.mkdocs.build(strict=True, verbose=True),
 94              title=pyprefix("Building documentation"),
 95          )
 96  
 97  
 98  @duty
 99  def check_types(ctx: Context) -> None:
100      """Check that the code is correctly typed."""
101      ctx.run(
102          tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"),
103          title=pyprefix("Type-checking"),
104      )
105  
106  
107  @duty
108  def check_api(ctx: Context, *cli_args: str) -> None:
109      """Check for API breaking changes."""
110      ctx.run(
111          tools.griffe.check("aria2p", search=["src"], color=True).add_args(*cli_args),
112          title="Checking for API breaking changes",
113          nofail=True,
114      )
115  
116  
117  @duty
118  def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None:
119      """Serve the documentation (localhost:8000).
120  
121      Parameters:
122          host: The host to serve the docs from.
123          port: The port to serve the docs on.
124      """
125      with material_insiders():
126          ctx.run(
127              tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args),
128              title="Serving documentation",
129              capture=False,
130          )
131  
132  
133  @duty
134  def docs_deploy(ctx: Context) -> None:
135      """Deploy the documentation to GitHub pages."""
136      os.environ["DEPLOY"] = "true"
137      with material_insiders() as insiders:
138          if not insiders:
139              ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!")
140          ctx.run(tools.mkdocs.gh_deploy(), title="Deploying documentation")
141  
142  
143  @duty
144  def format(ctx: Context) -> None:
145      """Run formatting tools on the code."""
146      ctx.run(
147          tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True),
148          title="Auto-fixing code",
149      )
150      ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code")
151  
152  
153  @duty
154  def build(ctx: Context) -> None:
155      """Build source and wheel distributions."""
156      ctx.run(
157          tools.build(),
158          title="Building source and wheel distributions",
159          pty=PTY,
160      )
161  
162  
163  @duty
164  def publish(ctx: Context) -> None:
165      """Publish source and wheel distributions to PyPI."""
166      if not Path("dist").exists():
167          ctx.run("false", title="No distribution files found")
168      dists = [str(dist) for dist in Path("dist").iterdir()]
169      ctx.run(
170          tools.twine.upload(*dists, skip_existing=True),
171          title="Publishing source and wheel distributions to PyPI",
172          pty=PTY,
173      )
174  
175  
176  @duty(post=["build", "publish", "docs-deploy"])
177  def release(ctx: Context, version: str = "") -> None:
178      """Release a new Python package.
179  
180      Parameters:
181          version: The new version number to use.
182      """
183      if not (version := (version or input("> Version to release: ")).strip()):
184          ctx.run("false", title="A version must be provided")
185      ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY)
186      ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY)
187      ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY)
188      ctx.run("git push", title="Pushing commits", pty=False)
189      ctx.run("git push --tags", title="Pushing tags", pty=False)
190  
191  
192  @duty(silent=True, aliases=["cov"])
193  def coverage(ctx: Context) -> None:
194      """Report coverage as text and HTML."""
195      ctx.run(tools.coverage.combine(), nofail=True)
196      ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False)
197      ctx.run(tools.coverage.html(rcfile="config/coverage.ini"))
198  
199  
200  @duty()
201  def test(ctx: Context, *cli_args: str, match: str = "") -> None:
202      """Run the test suite.
203  
204      Parameters:
205          match: A pytest expression to filter selected tests.
206          markers: A pytest expression to filter selected tests based on markers.
207          cpus: Number of CPUs to use, or "no", default "auto".
208          sugar: Use the sugar plugin, default True.
209          verbose: Be verbose, default False.
210          cov: Compute coverage, default True.
211      """
212      py_version = f"{sys.version_info.major}{sys.version_info.minor}"
213      os.environ["COVERAGE_FILE"] = f".coverage.{py_version}"
214      ctx.run(
215          tools.pytest(
216              "tests",
217              config_file="config/pytest.ini",
218              select=match,
219              color="yes",
220          ).add_args("-n", "auto", *cli_args),
221          title=pyprefix("Running tests"),
222      )