from __future__ import annotations import os import shlex import shutil from contextlib import suppress from glob import iglob from pathlib import Path from typing import cast import nox LINT_FILES: tuple[str, ...] = ( "hacking/pr_labeler/pr_labeler", "hacking/tagger/tag.py", "noxfile.py", *iglob("docs/bin/*.py"), *iglob("tests/checkers/rst-yamllint*.py"), # TODO: also lint others ) PINNED = os.environ.get("PINNED", "true").lower() in {"1", "true"} nox.options.sessions = ("clone-core", "lint", "checkers", "make") def _set_env_verbose(session: nox.Session, **env: str) -> dict[str, str]: """ Helper function to verbosely set environment variables """ final_env: dict[str, str] = {} for key, value in env.items(): final_env[key] = value session.log(f"export {key}={shlex.quote(value)}") return final_env def install(session: nox.Session, *args, req: str, **kwargs): if PINNED: pip_constraint = f"tests/{req}.txt" # Set constraint environment variables for both pip and uv to support # the nox uv backend env = _set_env_verbose( session, PIP_CONSTRAINT=pip_constraint, UV_CONSTRAINT=pip_constraint, UV_BUILD_CONSTRAINT=pip_constraint, ) kwargs.setdefault("env", {}).update(env) session.install("-r", f"tests/{req}.in", *args, **kwargs) CONTAINER_ENGINES = ("podman", "docker") CHOSEN_CONTAINER_ENGINE = os.environ.get("CONTAINER_ENGINE") ACTIONLINT_IMAGE = "docker.io/rhysd/actionlint" def _get_container_engine(session: nox.Session) -> str: path: str | None = None if CHOSEN_CONTAINER_ENGINE: path = shutil.which(CHOSEN_CONTAINER_ENGINE) if not path: session.error( f"CONTAINER_ENGINE {CHOSEN_CONTAINER_ENGINE!r} does not exist!" ) return path for engine in CONTAINER_ENGINES: if path := shutil.which(engine): return path session.error( f"None of the following container engines were found: {CONTAINER_ENGINES}." f" {session.name} requires a container engine installed." ) @nox.session def static(session: nox.Session): """ Run static checkers """ install(session, req="static") session.run("ruff", "check", *session.posargs, *LINT_FILES) @nox.session def formatters(session: nox.Session): """ Reformat code """ install(session, req="formatters") session.run("isort", *session.posargs, *LINT_FILES) session.run("black", "--target-version", "py312", *session.posargs, *LINT_FILES) @nox.session def formatters_check(session: nox.Session): """ Check code formatting without making changes """ install(session, req="formatters") session.run("isort", "--check", *session.posargs, *LINT_FILES) session.run( "black", "--check", "--target-version", "py312", *session.posargs, *LINT_FILES ) @nox.session def typing(session: nox.Session): install(session, req="typing") session.run("mypy", *session.posargs, *LINT_FILES) @nox.session def spelling(session: nox.Session): """ Spell check RST documentation """ install(session, req="spelling") session.run( "codespell", "docs/docsite", *session.posargs, ) @nox.session def actionlint(session: nox.Session) -> None: """ Run actionlint to lint Github Actions workflows. The actionlint tool is run in a Podman/Docker container. """ engine = _get_container_engine(session) session.run_always(engine, "pull", ACTIONLINT_IMAGE, external=True) session.run( engine, "run", "--rm", # fmt: off "--volume", f"{Path.cwd()}:/pwd:z", "--workdir", "/pwd", # fmt: on ACTIONLINT_IMAGE, *session.posargs, external=True, ) @nox.session def zizmor(session: nox.Session) -> None: """ Run zizmor, a Github Actions security checker """ args: list[str] = list(session.posargs) if not any(a.startswith("--persona") for a in args): args.append("--persona=regular") install(session, req="zizmor") session.run("zizmor", *args, ".github/workflows") @nox.session def lint(session: nox.Session): session.notify("typing") session.notify("static") session.notify("formatters") session.notify("spelling") session.notify("actionlint") session.notify("zizmor") requirements_files = list( {path.name.replace(".in", "") for path in Path("tests").glob("*in")} - {"constraints", "constraints-base"} ) @nox.session(name="pip-compile", python="3.12") @nox.parametrize(["req"], requirements_files, requirements_files) def pip_compile(session: nox.Session, req: str): """ Update dependency lockfiles in tests/ directory with uv pip compile. In addition to the usual flags supported by uv pip compile, this nox session implements two custom custom flags: --no-upgrade By default, the noxfile passes --upgrade to uv pip compile which updates all package versions in the lockfiles. Pass --no-upgrade to keep existing package versions as they are and only make the most minimal changes to sync the lockfiles with the input (.in) files. --check Run uv pip compile without --upgrade and fail if any changes were made. This ensures the lockfiles are in sync with the input files. """ install(session, req="pip-compile") args = list(session.posargs) check_mode = "--check" in args if check_mode: # Remove from args, as pip compile doesn't actually support --check. args.remove("--check") elif not any( arg.startswith(("-P", "--upgrade-package", "--no-upgrade")) for arg in args ): # Use --upgrade by default unless the user passes a conflicting flag. args.append("--upgrade") # Like --check, also remove --no-upgrade from args if it's present. with suppress(ValueError): args.remove("--no-upgrade") # fmt: off session.run( "uv", "pip", "compile", "--universal", "--quiet", "--output-file", f"tests/{req}.txt", *args, f"tests/{req}.in", ) # fmt: on if check_mode and session.run("git", "diff", "tests", silent=True, external=True): session.error("Check mode: files were changed") @nox.session(name="clone-core") def clone_core(session: nox.Session): """ Clone relevant portions of ansible-core from ansible/ansible into the current source tree to facilitate building docs. """ session.run_always("python", "docs/bin/clone-core.py", *session.posargs) checker_tests = [ path.with_suffix("").name for path in Path("tests/checkers/").glob("*.py") ] def _clone_core_check(session: nox.Session) -> None: """ Helper function to run the clone-core script with "--check" """ session.run("python", "docs/bin/clone-core.py", "--check") def _env_python(session: nox.Session) -> str: """ Get the full path to an environment's python executable """ out = cast( str, session.run("python", "-c", "import sys; print(sys.executable)", silent=True), ) return out.strip() @nox.session @nox.parametrize(["test"], checker_tests, checker_tests) def checkers(session: nox.Session, test: str): """ Run docs build checkers """ install(session, req="requirements") _clone_core_check(session) session.run("make", "-C", "docs/docsite", "clean", external=True) session.run("python", "tests/checkers.py", test) @nox.session def make(session: nox.Session): """ Generate HTML from documentation source using the Makefile """ make_args = session.posargs or ["clean", "coredocs"] install(session, req="requirements") _clone_core_check(session) session.run( "make", "-C", "docs/docsite", f"PYTHON={_env_python(session)}", *make_args, external=True, ) @nox.session def tag(session: nox.Session): """ Check the core repo for new releases and create tags in ansible-documentation """ install(session, req="tag") args = list(session.posargs) # If run without any arguments, default to "tag" if not any(arg.startswith(("hash", "mantag", "new-tags", "tag")) for arg in args): args.append("tag") session.run("python", "hacking/tagger/tag.py", *args)