2024-09-10 01:21:37 +02:00
|
|
|
"""Maintenance tasks run in isolated environments."""
|
|
|
|
|
|
|
|
import collections
|
2024-09-10 01:27:34 +02:00
|
|
|
import pathlib
|
2024-09-10 02:01:23 +02:00
|
|
|
import random
|
2024-09-10 01:27:34 +02:00
|
|
|
import re
|
|
|
|
import tempfile
|
2024-09-10 01:21:37 +02:00
|
|
|
from collections.abc import Mapping
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
import nox
|
|
|
|
from packaging import version as pkg_version
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
from nox_poetry import session as nox_session
|
|
|
|
except ImportError:
|
|
|
|
nox_session = nox.session
|
2024-09-10 01:27:34 +02:00
|
|
|
nox_poetry_available = False
|
|
|
|
else:
|
|
|
|
nox_poetry_available = True
|
2024-09-10 01:21:37 +02:00
|
|
|
|
|
|
|
|
|
|
|
def nested_defaultdict() -> collections.defaultdict[str, Any]:
|
|
|
|
"""Create a multi-level `defaultdict` with variable depth.
|
|
|
|
|
|
|
|
The returned `dict`ionary never raises a `KeyError`
|
|
|
|
but always returns an empty `dict`ionary instead.
|
|
|
|
This behavior is occurs recursively.
|
|
|
|
|
|
|
|
Adjusted from: https://stackoverflow.com/a/8702435
|
|
|
|
"""
|
|
|
|
return collections.defaultdict(nested_defaultdict)
|
|
|
|
|
|
|
|
|
|
|
|
def defaultify(obj: Any) -> Any:
|
|
|
|
"""Turn nested `dict`s into nested `defaultdict`s."""
|
|
|
|
if isinstance(obj, Mapping):
|
|
|
|
return collections.defaultdict(
|
|
|
|
nested_defaultdict,
|
|
|
|
{key: defaultify(val) for key, val in obj.items()},
|
|
|
|
)
|
|
|
|
return obj
|
|
|
|
|
|
|
|
|
|
|
|
def load_pyproject_toml() -> collections.defaultdict[str, Any]:
|
|
|
|
"""Load the contents of the pyproject.toml file.
|
|
|
|
|
|
|
|
The contents are represented as a `nested_defaultdict`;
|
|
|
|
so, missing keys and tables (i.e., "sections" in the .ini format)
|
|
|
|
do not result in `KeyError`s but return empty `nested_defaultdict`s.
|
|
|
|
"""
|
|
|
|
return defaultify(nox.project.load_toml("pyproject.toml"))
|
|
|
|
|
|
|
|
|
|
|
|
def load_supported_python_versions(*, reverse: bool = False) -> list[str]:
|
|
|
|
"""Parse the Python versions from the pyproject.toml file."""
|
|
|
|
pyproject = load_pyproject_toml()
|
|
|
|
version_names = {
|
|
|
|
classifier.rsplit(" ")[-1]
|
|
|
|
for classifier in pyproject["tool"]["poetry"]["classifiers"]
|
|
|
|
if classifier.startswith("Programming Language :: Python :: ")
|
|
|
|
}
|
|
|
|
return sorted(version_names, key=pkg_version.Version, reverse=reverse)
|
|
|
|
|
|
|
|
|
|
|
|
SUPPORTED_PYTHONS = load_supported_python_versions(reverse=True)
|
|
|
|
MAIN_PYTHON = "3.12"
|
|
|
|
|
2024-09-10 01:38:26 +02:00
|
|
|
TESTS_LOCATION = "tests/"
|
|
|
|
SRC_LOCATIONS = ("./noxfile.py", "src/", TESTS_LOCATION)
|
2024-09-10 01:21:37 +02:00
|
|
|
|
|
|
|
|
|
|
|
nox.options.envdir = ".cache/nox"
|
|
|
|
nox.options.error_on_external_run = True # only `git` and `poetry` are external
|
|
|
|
nox.options.reuse_venv = "no"
|
|
|
|
nox.options.sessions = ( # run by default when invoking `nox` on the CLI
|
2024-09-10 01:27:34 +02:00
|
|
|
"format",
|
2024-09-10 01:33:54 +02:00
|
|
|
"lint",
|
2024-09-10 01:57:02 +02:00
|
|
|
"test-docstrings",
|
2024-09-10 01:38:26 +02:00
|
|
|
f"test-{MAIN_PYTHON}",
|
2024-09-10 01:21:37 +02:00
|
|
|
)
|
|
|
|
nox.options.stop_on_first_error = True
|
|
|
|
|
|
|
|
|
2024-09-10 01:27:34 +02:00
|
|
|
@nox_session(name="format", python=MAIN_PYTHON)
|
|
|
|
def format_(session: nox.Session) -> None:
|
|
|
|
"""Format source files with `autoflake`, `black`, and `isort`."""
|
|
|
|
start(session)
|
|
|
|
|
2024-09-10 01:33:54 +02:00
|
|
|
install_pinned(session, "autoflake", "black", "isort", "ruff")
|
2024-09-10 01:27:34 +02:00
|
|
|
|
|
|
|
locations = session.posargs or SRC_LOCATIONS
|
|
|
|
|
|
|
|
session.run("autoflake", "--version")
|
|
|
|
session.run("autoflake", *locations)
|
|
|
|
|
|
|
|
session.run("black", "--version")
|
|
|
|
session.run("black", *locations)
|
|
|
|
|
|
|
|
session.run("isort", "--version-number")
|
|
|
|
session.run("isort", *locations)
|
|
|
|
|
2024-09-10 01:33:54 +02:00
|
|
|
session.run("ruff", "--version")
|
|
|
|
session.run("ruff", "check", "--fix-only", *locations)
|
|
|
|
|
|
|
|
|
|
|
|
@nox_session(python=MAIN_PYTHON)
|
|
|
|
def lint(session: nox.Session) -> None:
|
|
|
|
"""Lint source files with `flake8`, `mypy`, and `ruff`."""
|
|
|
|
start(session)
|
|
|
|
|
|
|
|
install_pinned(
|
|
|
|
session,
|
|
|
|
"flake8",
|
|
|
|
"flake8-annotations",
|
|
|
|
"flake8-bandit",
|
|
|
|
"flake8-black",
|
|
|
|
"flake8-broken-line",
|
|
|
|
"flake8-bugbear",
|
|
|
|
"flake8-commas",
|
|
|
|
"flake8-comprehensions",
|
|
|
|
"flake8-debugger",
|
|
|
|
"flake8-docstrings",
|
|
|
|
"flake8-eradicate",
|
|
|
|
"flake8-isort",
|
|
|
|
"flake8-quotes",
|
|
|
|
"flake8-string-format",
|
|
|
|
"flake8-pyproject",
|
2024-09-10 01:38:26 +02:00
|
|
|
"flake8-pytest-style",
|
2024-09-10 01:33:54 +02:00
|
|
|
"mypy",
|
|
|
|
"pep8-naming", # flake8 plug-in
|
|
|
|
"pydoclint[flake8]",
|
|
|
|
"ruff",
|
|
|
|
)
|
|
|
|
|
|
|
|
locations = session.posargs or SRC_LOCATIONS
|
|
|
|
|
|
|
|
session.run("flake8", "--version")
|
|
|
|
session.run("flake8", *locations)
|
|
|
|
|
|
|
|
session.run("mypy", "--version")
|
|
|
|
session.run("mypy", *locations)
|
|
|
|
|
|
|
|
session.run("ruff", "--version")
|
|
|
|
session.run("ruff", "check", *locations)
|
|
|
|
|
2024-09-10 01:27:34 +02:00
|
|
|
|
2024-09-10 02:01:23 +02:00
|
|
|
TEST_DEPENDENCIES = (
|
|
|
|
"packaging",
|
|
|
|
"pytest",
|
|
|
|
"pytest-cov",
|
|
|
|
"semver",
|
|
|
|
"xdoctest",
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2024-09-10 01:38:26 +02:00
|
|
|
@nox_session(python=SUPPORTED_PYTHONS)
|
|
|
|
def test(session: nox.Session) -> None:
|
|
|
|
"""Test code with `pytest`."""
|
|
|
|
start(session)
|
|
|
|
|
|
|
|
install_unpinned(session, "-e", ".") # "-e" makes session reuseable
|
2024-09-10 02:01:23 +02:00
|
|
|
install_pinned(session, *TEST_DEPENDENCIES)
|
2024-09-10 01:38:26 +02:00
|
|
|
|
|
|
|
args = session.posargs or (
|
|
|
|
"--cov",
|
|
|
|
"--no-cov-on-fail",
|
|
|
|
TESTS_LOCATION,
|
|
|
|
)
|
|
|
|
|
2024-09-10 01:45:47 +02:00
|
|
|
session.run("pytest", *args)
|
2024-09-10 01:38:26 +02:00
|
|
|
|
|
|
|
|
2024-09-10 02:01:23 +02:00
|
|
|
_magic_number = random.randint(0, 987654321) # noqa: S311
|
|
|
|
|
|
|
|
|
|
|
|
@nox_session(name="test-coverage", python=MAIN_PYTHON, reuse_venv=True)
|
|
|
|
def test_coverage(session: nox.Session) -> None:
|
|
|
|
"""Report the combined coverage statistics.
|
|
|
|
|
|
|
|
Run the test suite for all supported Python versions
|
|
|
|
and combine the coverage statistics.
|
|
|
|
"""
|
|
|
|
install_pinned(session, "coverage")
|
|
|
|
|
|
|
|
session.run("python", "-m", "coverage", "erase")
|
|
|
|
|
|
|
|
for version in SUPPORTED_PYTHONS:
|
|
|
|
session.notify(f"_test-coverage-run-{version}", (_magic_number,))
|
|
|
|
session.notify("_test-coverage-report", (_magic_number,))
|
|
|
|
|
|
|
|
|
|
|
|
@nox_session(name="_test-coverage-run", python=SUPPORTED_PYTHONS, reuse_venv=False)
|
|
|
|
def test_coverage_run(session: nox.Session) -> None:
|
|
|
|
"""Measure the test coverage."""
|
|
|
|
do_not_reuse(session)
|
|
|
|
do_not_run_directly(session)
|
|
|
|
|
|
|
|
start(session)
|
|
|
|
|
|
|
|
session.install(".")
|
|
|
|
install_pinned(session, "coverage", *TEST_DEPENDENCIES)
|
|
|
|
|
|
|
|
session.run(
|
|
|
|
"python",
|
|
|
|
"-m",
|
|
|
|
"coverage",
|
|
|
|
"run",
|
|
|
|
"-m",
|
|
|
|
"pytest",
|
|
|
|
TESTS_LOCATION,
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@nox_session(name="_test-coverage-report", python=MAIN_PYTHON, reuse_venv=True)
|
|
|
|
def test_coverage_report(session: nox.Session) -> None:
|
|
|
|
"""Report the combined coverage statistics."""
|
|
|
|
do_not_run_directly(session)
|
|
|
|
|
|
|
|
install_pinned(session, "coverage")
|
|
|
|
|
|
|
|
session.run("python", "-m", "coverage", "combine")
|
|
|
|
session.run("python", "-m", "coverage", "report", "--fail-under=100")
|
|
|
|
|
|
|
|
|
2024-09-10 01:57:02 +02:00
|
|
|
@nox_session(name="test-docstrings", python=MAIN_PYTHON)
|
|
|
|
def test_docstrings(session: nox.Session) -> None:
|
|
|
|
"""Test docstrings with `xdoctest`."""
|
|
|
|
start(session)
|
|
|
|
install_pinned(session, "xdoctest[colors]")
|
|
|
|
|
|
|
|
session.run("xdoctest", "--version")
|
|
|
|
session.run("xdoctest", "src/lalib")
|
|
|
|
|
|
|
|
|
2024-09-10 02:01:23 +02:00
|
|
|
def do_not_reuse(session: nox.Session, *, raise_error: bool = True) -> None:
|
|
|
|
"""Do not reuse a session with the "-r" flag."""
|
|
|
|
if session._runner.venv._reused: # noqa:SLF001
|
|
|
|
if raise_error:
|
|
|
|
session.error('The session must be run without the "-r" flag')
|
|
|
|
else:
|
|
|
|
session.warn('The session must be run without the "-r" flag')
|
|
|
|
|
|
|
|
|
|
|
|
def do_not_run_directly(session: nox.Session) -> None:
|
|
|
|
"""Do not run a session with `nox -s SESSION_NAME` directly."""
|
|
|
|
if not session.posargs or session.posargs[0] != _magic_number:
|
|
|
|
session.error("This session must not be run directly")
|
|
|
|
|
|
|
|
|
2024-09-10 01:21:37 +02:00
|
|
|
def start(session: nox.Session) -> None:
|
|
|
|
"""Show generic info about a session."""
|
|
|
|
if session.posargs:
|
|
|
|
session.debug(f"Received extra arguments: {session.posargs}")
|
|
|
|
|
|
|
|
session.debug("Some generic information about the environment")
|
|
|
|
session.run("python", "--version")
|
|
|
|
session.run("python", "-c", "import sys; print(sys.executable)")
|
|
|
|
session.run("python", "-c", "import sys; print(sys.path)")
|
|
|
|
session.run("python", "-c", "import os; print(os.getcwd())")
|
|
|
|
session.run("python", "-c", 'import os; print(os.environ["PATH"])')
|
|
|
|
|
2024-09-10 01:27:34 +02:00
|
|
|
session.env["BLACK_CACHE_DIR"] = ".cache/black"
|
2024-09-10 01:21:37 +02:00
|
|
|
session.env["PIP_CACHE_DIR"] = ".cache/pip"
|
|
|
|
session.env["PIP_DISABLE_PIP_VERSION_CHECK"] = "true"
|
|
|
|
|
|
|
|
|
2024-09-10 01:27:34 +02:00
|
|
|
def install_pinned(
|
|
|
|
session: nox.Session,
|
|
|
|
*packages_or_pip_args: str,
|
|
|
|
**kwargs: Any,
|
|
|
|
) -> None:
|
|
|
|
"""Install packages respecting the "poetry.lock" file.
|
|
|
|
|
|
|
|
Wraps `nox.sessions.Session.install()` such that it installs
|
|
|
|
packages respecting the pinned versions specified in poetry's
|
|
|
|
lock file. This makes nox sessions more deterministic.
|
|
|
|
"""
|
|
|
|
session.debug("Install packages respecting the poetry.lock file")
|
|
|
|
|
|
|
|
session.run( # temporary fix to avoid poetry's future warning
|
|
|
|
"poetry",
|
|
|
|
"config",
|
|
|
|
"--local",
|
|
|
|
"warnings.export",
|
|
|
|
"false",
|
|
|
|
external=True,
|
|
|
|
log=False, # because it's just a fix
|
|
|
|
)
|
|
|
|
|
|
|
|
if nox_poetry_available:
|
|
|
|
session.install(*packages_or_pip_args, **kwargs)
|
|
|
|
return
|
|
|
|
|
|
|
|
with tempfile.NamedTemporaryFile() as requirements_txt:
|
|
|
|
session.run(
|
|
|
|
"poetry",
|
|
|
|
"export",
|
|
|
|
"--format=requirements.txt",
|
|
|
|
f"--output={requirements_txt.name}",
|
|
|
|
"--with=dev",
|
|
|
|
"--without-hashes",
|
|
|
|
external=True,
|
|
|
|
)
|
|
|
|
|
|
|
|
# `pip install --constraint ...` raises an error if the
|
|
|
|
# dependencies in requirements.txt contain "extras"
|
|
|
|
# => Strip "package[extras]==1.2.3" into "package==1.2.3"
|
|
|
|
dependencies = pathlib.Path(requirements_txt.name).read_text().split("\n")
|
|
|
|
dependencies = [re.sub(r"\[.*\]==", "==", dep) for dep in dependencies]
|
|
|
|
pathlib.Path(requirements_txt.name).write_text("\n".join(dependencies))
|
|
|
|
|
|
|
|
session.install(
|
|
|
|
f"--constraint={requirements_txt.name}",
|
|
|
|
*packages_or_pip_args,
|
|
|
|
**kwargs,
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2024-09-10 01:38:26 +02:00
|
|
|
def install_unpinned(
|
|
|
|
session: nox.Session,
|
|
|
|
*packages_or_pip_args: str,
|
|
|
|
**kwargs: Any,
|
|
|
|
) -> None:
|
|
|
|
"""Install the latest PyPI versions of packages."""
|
|
|
|
# Same logic to skip package installation as in core nox
|
|
|
|
# See: https://github.com/wntrblm/nox/blob/2024.04.15/nox/sessions.py#L775
|
|
|
|
venv = session._runner.venv # noqa: SLF001
|
|
|
|
if session._runner.global_config.no_install and venv._reused: # noqa: SLF001
|
|
|
|
return
|
|
|
|
|
|
|
|
if kwargs.get("silent") is None:
|
|
|
|
kwargs["silent"] = True
|
|
|
|
|
|
|
|
# Cannot use `session.install(...)` here because
|
|
|
|
# with "nox-poetry" installed this leads to an
|
|
|
|
# installation respecting the "poetry.lock" file
|
|
|
|
session.run(
|
|
|
|
"python",
|
|
|
|
"-m",
|
|
|
|
"pip",
|
|
|
|
"install",
|
|
|
|
*packages_or_pip_args,
|
|
|
|
**kwargs,
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2024-09-10 01:21:37 +02:00
|
|
|
if MAIN_PYTHON not in SUPPORTED_PYTHONS:
|
|
|
|
msg = f"MAIN_PYTHON version, v{MAIN_PYTHON}, is not in SUPPORTED_PYTHONS"
|
|
|
|
raise RuntimeError(msg)
|