/ 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      ctx.run(
 88          tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"),
 89          title=pyprefix("Type-checking"),
 90      )
 91  
 92  
 93  @duty
 94  def check_api(ctx: Context, *cli_args: str) -> None:
 95      """Check for API breaking changes."""
 96      ctx.run(
 97          tools.griffe.check("griffe_warnings_deprecated", search=["src"], color=True).add_args(*cli_args),
 98          title="Checking for API breaking changes",
 99          nofail=True,
100      )
101  
102  
103  @duty
104  def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None:
105      """Serve the documentation (localhost:8000).
106  
107      Parameters:
108          host: The host to serve the docs from.
109          port: The port to serve the docs on.
110      """
111      with material_insiders():
112          ctx.run(
113              tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args),
114              title="Serving documentation",
115              capture=False,
116          )
117  
118  
119  @duty
120  def docs_deploy(ctx: Context, *, force: bool = False) -> None:
121      """Deploy the documentation to GitHub pages.
122  
123      Parameters:
124          force: Whether to force deployment, even from non-Insiders version.
125      """
126      os.environ["DEPLOY"] = "true"
127      with material_insiders() as insiders:
128          if not insiders:
129              ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!")
130          origin = ctx.run("git config --get remote.origin.url", silent=True, allow_overrides=False)
131          if "pawamoy-insiders/griffe-warnings-deprecated" in origin:
132              ctx.run(
133                  "git remote add upstream git@github.com:mkdocstrings/griffe-warnings-deprecated",
134                  silent=True,
135                  nofail=True,
136                  allow_overrides=False,
137              )
138              ctx.run(
139                  tools.mkdocs.gh_deploy(remote_name="upstream", force=True),
140                  title="Deploying documentation",
141              )
142          elif force:
143              ctx.run(
144                  tools.mkdocs.gh_deploy(force=True),
145                  title="Deploying documentation",
146              )
147          else:
148              ctx.run(
149                  lambda: False,
150                  title="Not deploying docs from public repository (do that from insiders instead!)",
151                  nofail=True,
152              )
153  
154  
155  @duty
156  def format(ctx: Context) -> None:
157      """Run formatting tools on the code."""
158      ctx.run(
159          tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True),
160          title="Auto-fixing code",
161      )
162      ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code")
163  
164  
165  @duty
166  def build(ctx: Context) -> None:
167      """Build source and wheel distributions."""
168      ctx.run(
169          tools.build(),
170          title="Building source and wheel distributions",
171          pty=PTY,
172      )
173  
174  
175  @duty
176  def publish(ctx: Context) -> None:
177      """Publish source and wheel distributions to PyPI."""
178      if not Path("dist").exists():
179          ctx.run("false", title="No distribution files found")
180      dists = [str(dist) for dist in Path("dist").iterdir()]
181      ctx.run(
182          tools.twine.upload(*dists, skip_existing=True),
183          title="Publishing source and wheel distributions to PyPI",
184          pty=PTY,
185      )
186  
187  
188  @duty(post=["build", "publish", "docs-deploy"])
189  def release(ctx: Context, version: str = "") -> None:
190      """Release a new Python package.
191  
192      Parameters:
193          version: The new version number to use.
194      """
195      origin = ctx.run("git config --get remote.origin.url", silent=True)
196      if "pawamoy-insiders/griffe-warnings-deprecated" in origin:
197          ctx.run(
198              lambda: False,
199              title="Not releasing from insiders repository (do that from public repo instead!)",
200          )
201      if not (version := (version or input("> Version to release: ")).strip()):
202          ctx.run("false", title="A version must be provided")
203      ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY)
204      ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY)
205      ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY)
206      ctx.run("git push", title="Pushing commits", pty=False)
207      ctx.run("git push --tags", title="Pushing tags", pty=False)
208  
209  
210  @duty(silent=True, aliases=["cov"])
211  def coverage(ctx: Context) -> None:
212      """Report coverage as text and HTML."""
213      ctx.run(tools.coverage.combine(), nofail=True)
214      ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False)
215      ctx.run(tools.coverage.html(rcfile="config/coverage.ini"))
216  
217  
218  @duty
219  def test(ctx: Context, *cli_args: str, match: str = "") -> None:
220      """Run the test suite.
221  
222      Parameters:
223          match: A pytest expression to filter selected tests.
224      """
225      py_version = f"{sys.version_info.major}{sys.version_info.minor}"
226      os.environ["COVERAGE_FILE"] = f".coverage.{py_version}"
227      ctx.run(
228          tools.pytest(
229              "tests",
230              config_file="config/pytest.ini",
231              select=match,
232              color="yes",
233          ).add_args("-n", "auto", *cli_args),
234          title=pyprefix("Running tests"),
235      )