/ 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 bundle(ctx: Context) -> None: 50 """Build a standalone executable. 51 52 Parameters: 53 ctx: The [context][duty.logic.Context] instance (passed automatically). 54 """ 55 ctx.run( 56 "pyinstaller -F -n aria2p -p __pypackages__/3.8/lib src/aria2p/__main__.py", 57 title="Bundling standalone executable", 58 pty=PTY, 59 ) 60 61 62 @duty 63 def changelog(ctx: Context, bump: str = "") -> None: 64 """Update the changelog in-place with latest commits. 65 66 Parameters: 67 bump: Bump option passed to git-changelog. 68 """ 69 ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") 70 71 72 @duty(pre=["check-quality", "check-types", "check-docs", "check-api"]) 73 def check(ctx: Context) -> None: 74 """Check it all!""" 75 76 77 @duty 78 def check_quality(ctx: Context) -> None: 79 """Check the code quality.""" 80 ctx.run( 81 tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), 82 title=pyprefix("Checking code quality"), 83 ) 84 85 86 @duty 87 def check_docs(ctx: Context) -> None: 88 """Check if the documentation builds correctly.""" 89 Path("htmlcov").mkdir(parents=True, exist_ok=True) 90 Path("htmlcov/index.html").touch(exist_ok=True) 91 with material_insiders(): 92 ctx.run( 93 tools.mkdocs.build(strict=True, verbose=True), 94 title=pyprefix("Building documentation"), 95 ) 96 97 98 @duty 99 def check_types(ctx: Context) -> None: 100 """Check that the code is correctly typed.""" 101 ctx.run( 102 tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), 103 title=pyprefix("Type-checking"), 104 ) 105 106 107 @duty 108 def check_api(ctx: Context, *cli_args: str) -> None: 109 """Check for API breaking changes.""" 110 ctx.run( 111 tools.griffe.check("aria2p", search=["src"], color=True).add_args(*cli_args), 112 title="Checking for API breaking changes", 113 nofail=True, 114 ) 115 116 117 @duty 118 def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: 119 """Serve the documentation (localhost:8000). 120 121 Parameters: 122 host: The host to serve the docs from. 123 port: The port to serve the docs on. 124 """ 125 with material_insiders(): 126 ctx.run( 127 tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), 128 title="Serving documentation", 129 capture=False, 130 ) 131 132 133 @duty 134 def docs_deploy(ctx: Context) -> None: 135 """Deploy the documentation to GitHub pages.""" 136 os.environ["DEPLOY"] = "true" 137 with material_insiders() as insiders: 138 if not insiders: 139 ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!") 140 ctx.run(tools.mkdocs.gh_deploy(), title="Deploying documentation") 141 142 143 @duty 144 def format(ctx: Context) -> None: 145 """Run formatting tools on the code.""" 146 ctx.run( 147 tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), 148 title="Auto-fixing code", 149 ) 150 ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") 151 152 153 @duty 154 def build(ctx: Context) -> None: 155 """Build source and wheel distributions.""" 156 ctx.run( 157 tools.build(), 158 title="Building source and wheel distributions", 159 pty=PTY, 160 ) 161 162 163 @duty 164 def publish(ctx: Context) -> None: 165 """Publish source and wheel distributions to PyPI.""" 166 if not Path("dist").exists(): 167 ctx.run("false", title="No distribution files found") 168 dists = [str(dist) for dist in Path("dist").iterdir()] 169 ctx.run( 170 tools.twine.upload(*dists, skip_existing=True), 171 title="Publishing source and wheel distributions to PyPI", 172 pty=PTY, 173 ) 174 175 176 @duty(post=["build", "publish", "docs-deploy"]) 177 def release(ctx: Context, version: str = "") -> None: 178 """Release a new Python package. 179 180 Parameters: 181 version: The new version number to use. 182 """ 183 if not (version := (version or input("> Version to release: ")).strip()): 184 ctx.run("false", title="A version must be provided") 185 ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY) 186 ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY) 187 ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY) 188 ctx.run("git push", title="Pushing commits", pty=False) 189 ctx.run("git push --tags", title="Pushing tags", pty=False) 190 191 192 @duty(silent=True, aliases=["cov"]) 193 def coverage(ctx: Context) -> None: 194 """Report coverage as text and HTML.""" 195 ctx.run(tools.coverage.combine(), nofail=True) 196 ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False) 197 ctx.run(tools.coverage.html(rcfile="config/coverage.ini")) 198 199 200 @duty() 201 def test(ctx: Context, *cli_args: str, match: str = "") -> None: 202 """Run the test suite. 203 204 Parameters: 205 match: A pytest expression to filter selected tests. 206 markers: A pytest expression to filter selected tests based on markers. 207 cpus: Number of CPUs to use, or "no", default "auto". 208 sugar: Use the sugar plugin, default True. 209 verbose: Be verbose, default False. 210 cov: Compute coverage, default True. 211 """ 212 py_version = f"{sys.version_info.major}{sys.version_info.minor}" 213 os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" 214 ctx.run( 215 tools.pytest( 216 "tests", 217 config_file="config/pytest.ini", 218 select=match, 219 color="yes", 220 ).add_args("-n", "auto", *cli_args), 221 title=pyprefix("Running tests"), 222 )