/ 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}")