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