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