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