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