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