/ duties.py
duties.py
1 """Development tasks.""" 2 3 from __future__ import annotations 4 5 import os 6 import re 7 import sys 8 from contextlib import contextmanager 9 from importlib.metadata import version as pkgversion 10 from pathlib import Path 11 from typing import TYPE_CHECKING 12 13 from duty import duty, tools 14 15 if TYPE_CHECKING: 16 from collections.abc import Iterator 17 18 from duty.context import Context 19 20 21 PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts")) 22 PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS) 23 PY_SRC = " ".join(PY_SRC_LIST) 24 CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""} 25 WINDOWS = os.name == "nt" 26 PTY = not WINDOWS and not CI 27 MULTIRUN = os.environ.get("MULTIRUN", "0") == "1" 28 29 30 def pyprefix(title: str) -> str: 31 if MULTIRUN: 32 prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})" 33 return f"{prefix:14}{title}" 34 return title 35 36 37 @contextmanager 38 def material_insiders() -> Iterator[bool]: 39 if "+insiders" in pkgversion("mkdocs-material"): 40 os.environ["MATERIAL_INSIDERS"] = "true" 41 try: 42 yield True 43 finally: 44 os.environ.pop("MATERIAL_INSIDERS") 45 else: 46 yield False 47 48 49 def _get_changelog_version() -> str: 50 changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$") 51 with Path(__file__).parent.joinpath("CHANGELOG.md").open("r", encoding="utf8") as file: 52 return next(filter(bool, map(changelog_version_re.match, file))).group(1) # type: ignore[union-attr] 53 54 55 @duty 56 def changelog(ctx: Context, bump: str = "") -> None: 57 """Update the changelog in-place with latest commits. 58 59 Parameters: 60 bump: Bump option passed to git-changelog. 61 """ 62 ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") 63 ctx.run(tools.yore.check(bump=bump or _get_changelog_version()), title="Checking legacy code") 64 65 66 @duty(pre=["check-quality", "check-types", "check-docs", "check-api"]) 67 def check(ctx: Context) -> None: 68 """Check it all!""" 69 70 71 @duty 72 def check_quality(ctx: Context) -> None: 73 """Check the code quality.""" 74 ctx.run( 75 tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), 76 title=pyprefix("Checking code quality"), 77 ) 78 79 80 @duty 81 def check_docs(ctx: Context) -> None: 82 """Check if the documentation builds correctly.""" 83 Path("htmlcov").mkdir(parents=True, exist_ok=True) 84 Path("htmlcov/index.html").touch(exist_ok=True) 85 with material_insiders(): 86 ctx.run( 87 tools.mkdocs.build(strict=True, verbose=True), 88 title=pyprefix("Building documentation"), 89 ) 90 91 92 @duty 93 def check_types(ctx: Context) -> None: 94 """Check that the code is correctly typed.""" 95 os.environ["FORCE_COLOR"] = "1" 96 ctx.run( 97 tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), 98 title=pyprefix("Type-checking"), 99 ) 100 101 102 @duty 103 def check_api(ctx: Context, *cli_args: str) -> None: 104 """Check for API breaking changes.""" 105 ctx.run( 106 tools.griffe.check("failprint", search=["src"], color=True).add_args(*cli_args), 107 title="Checking for API breaking changes", 108 nofail=True, 109 ) 110 111 112 @duty 113 def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: 114 """Serve the documentation (localhost:8000). 115 116 Parameters: 117 host: The host to serve the docs from. 118 port: The port to serve the docs on. 119 """ 120 with material_insiders(): 121 ctx.run( 122 tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), 123 title="Serving documentation", 124 capture=False, 125 ) 126 127 128 @duty 129 def docs_deploy(ctx: Context) -> None: 130 """Deploy the documentation to GitHub pages.""" 131 os.environ["DEPLOY"] = "true" 132 with material_insiders() as insiders: 133 if not insiders: 134 ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!") 135 ctx.run(tools.mkdocs.gh_deploy(), title="Deploying documentation") 136 137 138 @duty 139 def format(ctx: Context) -> None: 140 """Run formatting tools on the code.""" 141 ctx.run( 142 tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), 143 title="Auto-fixing code", 144 ) 145 ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") 146 147 148 @duty 149 def build(ctx: Context) -> None: 150 """Build source and wheel distributions.""" 151 ctx.run( 152 tools.build(), 153 title="Building source and wheel distributions", 154 pty=PTY, 155 ) 156 157 158 @duty 159 def publish(ctx: Context) -> None: 160 """Publish source and wheel distributions to PyPI.""" 161 if not Path("dist").exists(): 162 ctx.run("false", title="No distribution files found") 163 dists = [str(dist) for dist in Path("dist").iterdir()] 164 ctx.run( 165 tools.twine.upload(*dists, skip_existing=True), 166 title="Publishing source and wheel distributions to PyPI", 167 pty=PTY, 168 ) 169 170 171 @duty(post=["build", "publish", "docs-deploy"]) 172 def release(ctx: Context, version: str = "") -> None: 173 """Release a new Python package. 174 175 Parameters: 176 version: The new version number to use. 177 """ 178 if not (version := (version or input("> Version to release: ")).strip()): 179 ctx.run("false", title="A version must be provided") 180 ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY) 181 ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY) 182 ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY) 183 ctx.run("git push", title="Pushing commits", pty=False) 184 ctx.run("git push --tags", title="Pushing tags", pty=False) 185 186 187 @duty(silent=True, aliases=["cov"]) 188 def coverage(ctx: Context) -> None: 189 """Report coverage as text and HTML.""" 190 ctx.run(tools.coverage.combine(), nofail=True) 191 ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False) 192 ctx.run(tools.coverage.html(rcfile="config/coverage.ini")) 193 194 195 @duty 196 def test(ctx: Context, *cli_args: str, match: str = "") -> None: # noqa: PT028 197 """Run the test suite. 198 199 Parameters: 200 match: A pytest expression to filter selected tests. 201 """ 202 py_version = f"{sys.version_info.major}{sys.version_info.minor}" 203 os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" 204 ctx.run( 205 tools.pytest( 206 "tests", 207 config_file="config/pytest.ini", 208 select=match, 209 color="yes", 210 ).add_args("-n", "auto", *cli_args), 211 title=pyprefix("Running tests"), 212 )