/ 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 changelog(ctx: Context, bump: str = "") -> None:
 50      """Update the changelog in-place with latest commits.
 51  
 52      Parameters:
 53          bump: Bump option passed to git-changelog.
 54      """
 55      ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog")
 56  
 57  
 58  @duty(pre=["check-quality", "check-types", "check-docs", "check-api"])
 59  def check(ctx: Context) -> None:
 60      """Check it all!"""
 61  
 62  
 63  @duty
 64  def check_quality(ctx: Context) -> None:
 65      """Check the code quality."""
 66      ctx.run(
 67          tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"),
 68          title=pyprefix("Checking code quality"),
 69      )
 70  
 71  
 72  @duty
 73  def check_docs(ctx: Context) -> None:
 74      """Check if the documentation builds correctly."""
 75      Path("htmlcov").mkdir(parents=True, exist_ok=True)
 76      Path("htmlcov/index.html").touch(exist_ok=True)
 77      with material_insiders():
 78          ctx.run(
 79              tools.mkdocs.build(strict=True, verbose=True),
 80              title=pyprefix("Building documentation"),
 81          )
 82  
 83  
 84  @duty
 85  def check_types(ctx: Context) -> None:
 86      """Check that the code is correctly typed."""
 87      os.environ["FORCE_COLOR"] = "1"
 88      ctx.run(
 89          tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"),
 90          title=pyprefix("Type-checking"),
 91      )
 92  
 93  
 94  @duty
 95  def check_api(ctx: Context, *cli_args: str) -> None:
 96      """Check for API breaking changes."""
 97      ctx.run(
 98          tools.griffe.check("griffe_typingdoc", search=["src"], color=True).add_args(*cli_args),
 99          title="Checking for API breaking changes",
100          nofail=True,
101      )
102  
103  
104  @duty
105  def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None:
106      """Serve the documentation (localhost:8000).
107  
108      Parameters:
109          host: The host to serve the docs from.
110          port: The port to serve the docs on.
111      """
112      with material_insiders():
113          ctx.run(
114              tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args),
115              title="Serving documentation",
116              capture=False,
117          )
118  
119  
120  @duty
121  def docs_deploy(ctx: Context) -> None:
122      """Deploy the documentation to GitHub pages."""
123      os.environ["DEPLOY"] = "true"
124      with material_insiders() as insiders:
125          if not insiders:
126              ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!")
127          ctx.run(tools.mkdocs.gh_deploy(), title="Deploying documentation")
128  
129  
130  @duty
131  def format(ctx: Context) -> None:
132      """Run formatting tools on the code."""
133      ctx.run(
134          tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True),
135          title="Auto-fixing code",
136      )
137      ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code")
138  
139  
140  @duty
141  def build(ctx: Context) -> None:
142      """Build source and wheel distributions."""
143      ctx.run(
144          tools.build(),
145          title="Building source and wheel distributions",
146          pty=PTY,
147      )
148  
149  
150  @duty
151  def publish(ctx: Context) -> None:
152      """Publish source and wheel distributions to PyPI."""
153      if not Path("dist").exists():
154          ctx.run("false", title="No distribution files found")
155      dists = [str(dist) for dist in Path("dist").iterdir()]
156      ctx.run(
157          tools.twine.upload(*dists, skip_existing=True),
158          title="Publishing source and wheel distributions to PyPI",
159          pty=PTY,
160      )
161  
162  
163  @duty(post=["build", "publish", "docs-deploy"])
164  def release(ctx: Context, version: str = "") -> None:
165      """Release a new Python package.
166  
167      Parameters:
168          version: The new version number to use.
169      """
170      if not (version := (version or input("> Version to release: ")).strip()):
171          ctx.run("false", title="A version must be provided")
172      ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY)
173      ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY)
174      ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY)
175      ctx.run("git push", title="Pushing commits", pty=False)
176      ctx.run("git push --tags", title="Pushing tags", pty=False)
177  
178  
179  @duty(silent=True, aliases=["cov"])
180  def coverage(ctx: Context) -> None:
181      """Report coverage as text and HTML."""
182      ctx.run(tools.coverage.combine(), nofail=True)
183      ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False)
184      ctx.run(tools.coverage.html(rcfile="config/coverage.ini"))
185  
186  
187  @duty
188  def test(ctx: Context, *cli_args: str, match: str = "") -> None:
189      """Run the test suite.
190  
191      Parameters:
192          match: A pytest expression to filter selected tests.
193      """
194      py_version = f"{sys.version_info.major}{sys.version_info.minor}"
195      os.environ["COVERAGE_FILE"] = f".coverage.{py_version}"
196      ctx.run(
197          tools.pytest(
198              "tests",
199              config_file="config/pytest.ini",
200              select=match,
201              color="yes",
202          ).add_args("-n", "auto", *cli_args),
203          title=pyprefix("Running tests"),
204      )