/ 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, Iterator
 11  
 12  from duty import duty
 13  from duty.callables import coverage, lazy, mkdocs, mypy, pytest, ruff, safety
 14  
 15  if TYPE_CHECKING:
 16      from duty.context import Context
 17  
 18  
 19  PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts"))
 20  PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS)
 21  PY_SRC = " ".join(PY_SRC_LIST)
 22  CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""}
 23  WINDOWS = os.name == "nt"
 24  PTY = not WINDOWS and not CI
 25  MULTIRUN = os.environ.get("PDM_MULTIRUN", "0") == "1"
 26  
 27  
 28  def pyprefix(title: str) -> str:  # noqa: D103
 29      if MULTIRUN:
 30          prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})"
 31          return f"{prefix:14}{title}"
 32      return title
 33  
 34  
 35  @contextmanager
 36  def material_insiders() -> Iterator[bool]:  # noqa: D103
 37      if "+insiders" in pkgversion("mkdocs-material"):
 38          os.environ["MATERIAL_INSIDERS"] = "true"
 39          try:
 40              yield True
 41          finally:
 42              os.environ.pop("MATERIAL_INSIDERS")
 43      else:
 44          yield False
 45  
 46  
 47  @duty
 48  def changelog(ctx: Context) -> None:
 49      """Update the changelog in-place with latest commits.
 50  
 51      Parameters:
 52          ctx: The context instance (passed automatically).
 53      """
 54      from git_changelog.cli import main as git_changelog
 55  
 56      ctx.run(git_changelog, args=[[]], title="Updating changelog")
 57  
 58  
 59  @duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies", "check-api"])
 60  def check(ctx: Context) -> None:  # noqa: ARG001
 61      """Check it all!
 62  
 63      Parameters:
 64          ctx: The context instance (passed automatically).
 65      """
 66  
 67  
 68  @duty
 69  def check_quality(ctx: Context) -> None:
 70      """Check the code quality.
 71  
 72      Parameters:
 73          ctx: The context instance (passed automatically).
 74      """
 75      ctx.run(
 76          ruff.check(*PY_SRC_LIST, config="config/ruff.toml"),
 77          title=pyprefix("Checking code quality"),
 78          command=f"ruff check --config config/ruff.toml {PY_SRC}",
 79      )
 80  
 81  
 82  @duty
 83  def check_dependencies(ctx: Context) -> None:
 84      """Check for vulnerabilities in dependencies.
 85  
 86      Parameters:
 87          ctx: The context instance (passed automatically).
 88      """
 89      # retrieve the list of dependencies
 90      requirements = ctx.run(
 91          ["pdm", "export", "-f", "requirements", "--without-hashes"],
 92          title="Exporting dependencies as requirements",
 93          allow_overrides=False,
 94      )
 95  
 96      ctx.run(
 97          safety.check(requirements),
 98          title="Checking dependencies",
 99          command="pdm export -f requirements --without-hashes | safety check --stdin",
100      )
101  
102  
103  @duty
104  def check_docs(ctx: Context) -> None:
105      """Check if the documentation builds correctly.
106  
107      Parameters:
108          ctx: The context instance (passed automatically).
109      """
110      Path("htmlcov").mkdir(parents=True, exist_ok=True)
111      Path("htmlcov/index.html").touch(exist_ok=True)
112      with material_insiders():
113          ctx.run(
114              mkdocs.build(strict=True, verbose=True),
115              title=pyprefix("Building documentation"),
116              command="mkdocs build -vs",
117          )
118  
119  
120  @duty
121  def check_types(ctx: Context) -> None:
122      """Check that the code is correctly typed.
123  
124      Parameters:
125          ctx: The context instance (passed automatically).
126      """
127      ctx.run(
128          mypy.run(*PY_SRC_LIST, config_file="config/mypy.ini"),
129          title=pyprefix("Type-checking"),
130          command=f"mypy --config-file config/mypy.ini {PY_SRC}",
131      )
132  
133  
134  @duty
135  def check_api(ctx: Context) -> None:
136      """Check for API breaking changes.
137  
138      Parameters:
139          ctx: The context instance (passed automatically).
140      """
141      from griffe.cli import check as g_check
142  
143      griffe_check = lazy(g_check, name="griffe.check")
144      ctx.run(
145          griffe_check("docstrings2pep727", search_paths=["src"], color=True),
146          title="Checking for API breaking changes",
147          command="griffe check -ssrc docstrings2pep727",
148          nofail=True,
149      )
150  
151  
152  @duty(silent=True)
153  def clean(ctx: Context) -> None:
154      """Delete temporary files.
155  
156      Parameters:
157          ctx: The context instance (passed automatically).
158      """
159      ctx.run("rm -rf .coverage*")
160      ctx.run("rm -rf .mypy_cache")
161      ctx.run("rm -rf .pytest_cache")
162      ctx.run("rm -rf tests/.pytest_cache")
163      ctx.run("rm -rf build")
164      ctx.run("rm -rf dist")
165      ctx.run("rm -rf htmlcov")
166      ctx.run("rm -rf pip-wheel-metadata")
167      ctx.run("rm -rf site")
168      ctx.run("find . -type d -name __pycache__ | xargs rm -rf")
169      ctx.run("find . -name '*.rej' -delete")
170  
171  
172  @duty
173  def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None:
174      """Serve the documentation (localhost:8000).
175  
176      Parameters:
177          ctx: The context instance (passed automatically).
178          host: The host to serve the docs from.
179          port: The port to serve the docs on.
180      """
181      with material_insiders():
182          ctx.run(
183              mkdocs.serve(dev_addr=f"{host}:{port}"),
184              title="Serving documentation",
185              capture=False,
186          )
187  
188  
189  @duty
190  def docs_deploy(ctx: Context) -> None:
191      """Deploy the documentation on GitHub pages.
192  
193      Parameters:
194          ctx: The context instance (passed automatically).
195      """
196      os.environ["DEPLOY"] = "true"
197      with material_insiders() as insiders:
198          if not insiders:
199              ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!")
200          ctx.run(mkdocs.gh_deploy(), title="Deploying documentation")
201  
202  
203  @duty
204  def format(ctx: Context) -> None:
205      """Run formatting tools on the code.
206  
207      Parameters:
208          ctx: The context instance (passed automatically).
209      """
210      ctx.run(
211          ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True),
212          title="Auto-fixing code",
213      )
214      ctx.run(ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code")
215  
216  
217  @duty(post=["docs-deploy"])
218  def release(ctx: Context, version: str) -> None:
219      """Release a new Python package.
220  
221      Parameters:
222          ctx: The context instance (passed automatically).
223          version: The new version number to use.
224      """
225      ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY)
226      ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY)
227      ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY)
228      ctx.run("git push", title="Pushing commits", pty=False)
229      ctx.run("git push --tags", title="Pushing tags", pty=False)
230      ctx.run("pdm build", title="Building dist/wheel", pty=PTY)
231      ctx.run("twine upload --skip-existing dist/*", title="Publishing version", pty=PTY)
232  
233  
234  @duty(silent=True, aliases=["coverage"])
235  def cov(ctx: Context) -> None:
236      """Report coverage as text and HTML.
237  
238      Parameters:
239          ctx: The context instance (passed automatically).
240      """
241      ctx.run(coverage.combine, nofail=True)
242      ctx.run(coverage.report(rcfile="config/coverage.ini"), capture=False)
243      ctx.run(coverage.html(rcfile="config/coverage.ini"))
244  
245  
246  @duty
247  def test(ctx: Context, match: str = "") -> None:
248      """Run the test suite.
249  
250      Parameters:
251          ctx: The context instance (passed automatically).
252          match: A pytest expression to filter selected tests.
253      """
254      py_version = f"{sys.version_info.major}{sys.version_info.minor}"
255      os.environ["COVERAGE_FILE"] = f".coverage.{py_version}"
256      ctx.run(
257          pytest.run("-n", "auto", "tests", config_file="config/pytest.ini", select=match, color="yes"),
258          title=pyprefix("Running tests"),
259          command=f"pytest -c config/pytest.ini -n auto -k{match!r} --color=yes tests",
260      )
261  
262  
263  @duty
264  def vscode(ctx: Context) -> None:
265      """Configure VSCode.
266  
267      This task will overwrite the following files,
268      so make sure to back them up:
269  
270      - `.vscode/launch.json`
271      - `.vscode/settings.json`
272      - `.vscode/tasks.json`
273  
274      Parameters:
275          ctx: The context instance (passed automatically).
276      """
277  
278      def update_config(filename: str) -> None:
279          source_file = Path("config", "vscode", filename)
280          target_file = Path(".vscode", filename)
281          target_file.parent.mkdir(exist_ok=True)
282          target_file.write_text(source_file.read_text())
283  
284      for filename in ("launch.json", "settings.json", "tasks.json"):
285          ctx.run(update_config, args=[filename], title=f"Update .vscode/{filename}")