/ duties.py
duties.py
1 """Development tasks.""" 2 3 from __future__ import annotations 4 5 import os 6 import sys 7 from collections.abc import Iterator 8 from contextlib import contextmanager 9 from cProfile import Profile 10 from importlib.metadata import version as pkgversion 11 from pathlib import Path 12 from pstats import SortKey, Stats 13 from tempfile import TemporaryDirectory 14 from typing import TYPE_CHECKING 15 16 from duty import duty, tools 17 18 if TYPE_CHECKING: 19 from collections.abc import Iterator 20 21 from duty.context import Context 22 23 24 PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts")) 25 PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS) 26 PY_SRC = " ".join(PY_SRC_LIST) 27 CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""} 28 WINDOWS = os.name == "nt" 29 PTY = not WINDOWS and not CI 30 MULTIRUN = os.environ.get("MULTIRUN", "0") == "1" 31 32 33 def pyprefix(title: str) -> str: # noqa: D103 34 if MULTIRUN: 35 prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})" 36 return f"{prefix:14}{title}" 37 return title 38 39 40 @contextmanager 41 def material_insiders() -> Iterator[bool]: # noqa: D103 42 if "+insiders" in pkgversion("mkdocs-material"): 43 os.environ["MATERIAL_INSIDERS"] = "true" 44 try: 45 yield True 46 finally: 47 os.environ.pop("MATERIAL_INSIDERS") 48 else: 49 yield False 50 51 52 @duty 53 def changelog(ctx: Context, bump: str = "") -> None: 54 """Update the changelog in-place with latest commits. 55 56 Parameters: 57 bump: Bump option passed to git-changelog. 58 """ 59 ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") 60 61 62 @duty(pre=["check-quality", "check-types", "check-docs", "check-api"]) 63 def check(ctx: Context) -> None: 64 """Check it all!""" 65 66 67 @duty 68 def check_quality(ctx: Context) -> None: 69 """Check the code quality.""" 70 ctx.run( 71 tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), 72 title=pyprefix("Checking code quality"), 73 ) 74 75 76 @duty 77 def check_docs(ctx: Context) -> None: 78 """Check if the documentation builds correctly.""" 79 Path("htmlcov").mkdir(parents=True, exist_ok=True) 80 Path("htmlcov/index.html").touch(exist_ok=True) 81 with material_insiders(): 82 ctx.run( 83 tools.mkdocs.build(strict=True, verbose=True), 84 title=pyprefix("Building documentation"), 85 ) 86 87 88 @duty 89 def check_types(ctx: Context) -> None: 90 """Check that the code is correctly typed.""" 91 ctx.run( 92 tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), 93 title=pyprefix("Type-checking"), 94 ) 95 96 97 @duty 98 def check_api(ctx: Context, *cli_args: str) -> None: 99 """Check for API breaking changes.""" 100 ctx.run( 101 tools.griffe.check("git_changelog", search=["src"], color=True).add_args(*cli_args), 102 title="Checking for API breaking changes", 103 nofail=True, 104 ) 105 106 107 @duty 108 def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: 109 """Serve the documentation (localhost:8000). 110 111 Parameters: 112 host: The host to serve the docs from. 113 port: The port to serve the docs on. 114 """ 115 with material_insiders(): 116 ctx.run( 117 tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), 118 title="Serving documentation", 119 capture=False, 120 ) 121 122 123 @duty 124 def docs_deploy(ctx: Context) -> None: 125 """Deploy the documentation to GitHub pages.""" 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 ctx.run(tools.mkdocs.gh_deploy(), title="Deploying documentation") 131 132 133 @duty 134 def format(ctx: Context) -> None: 135 """Run formatting tools on the code.""" 136 ctx.run( 137 tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), 138 title="Auto-fixing code", 139 ) 140 ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") 141 142 143 @duty 144 def build(ctx: Context) -> None: 145 """Build source and wheel distributions.""" 146 ctx.run( 147 tools.build(), 148 title="Building source and wheel distributions", 149 pty=PTY, 150 ) 151 152 153 @duty 154 def publish(ctx: Context) -> None: 155 """Publish source and wheel distributions to PyPI.""" 156 if not Path("dist").exists(): 157 ctx.run("false", title="No distribution files found") 158 dists = [str(dist) for dist in Path("dist").iterdir()] 159 ctx.run( 160 tools.twine.upload(*dists, skip_existing=True), 161 title="Publishing source and wheel distributions to PyPI", 162 pty=PTY, 163 ) 164 165 166 @duty(post=["build", "publish", "docs-deploy"]) 167 def release(ctx: Context, version: str = "") -> None: 168 """Release a new Python package. 169 170 Parameters: 171 version: The new version number to use. 172 """ 173 if not (version := (version or input("> Version to release: ")).strip()): 174 ctx.run("false", title="A version must be provided") 175 ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY) 176 ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY) 177 ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY) 178 ctx.run("git push", title="Pushing commits", pty=False) 179 ctx.run("git push --tags", title="Pushing tags", pty=False) 180 181 182 @duty(silent=True, aliases=["cov"]) 183 def coverage(ctx: Context) -> None: 184 """Report coverage as text and HTML.""" 185 ctx.run(tools.coverage.combine(), nofail=True) 186 ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False) 187 ctx.run(tools.coverage.html(rcfile="config/coverage.ini")) 188 189 190 @duty 191 def test(ctx: Context, *cli_args: str, match: str = "") -> None: 192 """Run the test suite. 193 194 Parameters: 195 match: A pytest expression to filter selected tests. 196 """ 197 py_version = f"{sys.version_info.major}{sys.version_info.minor}" 198 os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" 199 ctx.run( 200 tools.pytest( 201 "tests", 202 config_file="config/pytest.ini", 203 select=match, 204 color="yes", 205 ).add_args("-n", "auto", *cli_args), 206 title=pyprefix("Running tests"), 207 ) 208 209 210 @duty 211 def profile(ctx: Context, merge: int = 15) -> None: 212 """Profile the parsing and grouping of commits. 213 214 Parameters: 215 merge: Number of times to merge a branch in the temporary repository. 216 """ 217 # Do not import those from the top level, 218 # as it prevents the pytest-cov plugin for marking 219 # lines executed at import time as covered. 220 from git_changelog import Changelog 221 from git_changelog.commit import AngularConvention 222 223 try: 224 from tests.helpers import GitRepo 225 except ModuleNotFoundError: 226 import sys 227 228 sys.path.insert(0, str(Path(__file__).parent)) 229 from tests.helpers import GitRepo 230 231 def merge_branches(repo: GitRepo, branch: str = "feat", times: int = 15) -> None: 232 for _ in range(times): 233 repo.branch(branch) 234 repo.checkout(branch) 235 repo.commit(f"feat: {branch}") 236 repo.checkout("main") 237 repo.merge(branch) 238 repo.git("branch", "-d", branch) 239 240 with TemporaryDirectory() as tmp_dir: 241 repo = GitRepo(Path(tmp_dir) / "repo") 242 ctx.run(merge_branches, args=(repo, "feat", merge), title="Creating temporary repository") 243 244 with Profile() as profile: 245 Changelog(repo.path, convention=AngularConvention) 246 Stats(profile).strip_dirs().sort_stats(SortKey.TIME).print_stats()