Skip to content

Nox

nox is a very useful utility for running tasks in a project. By creating a noxfile.py and adding nox sessions to it, you can automate tasks like building the project, exporting requirements.txt, and linting code (and more).

nox is versatile. If you can run a command in your shell, you should be able to automate it with nox. The idea is that nox sessions can run the same way in any environment (local, CI/CD pipelines, and cross-platform). For example, my Ansible homelab repository uses a noxfile.py to automate running Ansible playbooks.

Note

Check the documentation for making your noxfile.py modular to keep your noxfile.py short & clean by import sessions from a nox_extra/ directory in your project.

noxfile.py base

The basis for most/all of my projects' noxfile.py.

Note

If running all sessions with $ nox, only the sessions defined in nox.sessions will be executed. The list of sessions is conservative to start in order to maintain as generic a nox environment as possible.

Enabled sessions:

  • lint
  • export
  • tests
noxfile.py
from __future__ import annotations

import logging
import logging.config
import logging.handlers
import os
from pathlib import Path
import platform
import shutil
import importlib.util

import nox

## Set nox options
if importlib.util.find_spec("uv"):
    nox.options.default_venv_backend = "uv|virtualenv"
else:
    nox.options.default_venv_backend = "virtualenv"
nox.options.reuse_existing_virtualenvs = True
nox.options.error_on_external_run = False
nox.options.error_on_missing_interpreters = False
# nox.options.report = True

## Define sessions to run when no session is specified
nox.sessions = ["lint", "export", "tests"]

## Detect container env, or default to False
if "CONTAINER_ENV" in os.environ:
    CONTAINER_ENV: bool = os.environ["CONTAINER_ENV"]
else:
    CONTAINER_ENV: bool = False

## Create logger for this module
log: logging.Logger = logging.getLogger("nox")

## Define versions to test
PY_VERSIONS: list[str] = ["3.12", "3.11"]
## Get tuple of Python ver ('maj', 'min', 'mic')
PY_VER_TUPLE: tuple[str, str, str] = platform.python_version_tuple()
## Dynamically set Python version
DEFAULT_PYTHON: str = f"{PY_VER_TUPLE[0]}.{PY_VER_TUPLE[1]}"

## Set PDM version to install throughout
PDM_VER: str = "2.15.4"
## Set paths to lint with the lint session
LINT_PATHS: list[str] = ["src", "tests"]

## Set directory for requirements.txt file output
REQUIREMENTS_OUTPUT_DIR: Path = Path("./requirements")


def setup_nox_logging(
    level_name: str = "DEBUG", disable_loggers: list[str] | None = []
) -> None:
    """Configure a logger for the Nox module.

    Params:
        level_name (str): The uppercase string repesenting a logging logLevel.
        disable_loggers (list[str] | None): A list of logger names to disable, i.e. for 3rd party apps.
            Note: Disabling means setting the logLevel to `WARNING`, so you can still see errors.

    """
    ## If container environment detected, default to logging.DEBUG
    if CONTAINER_ENV:
        level_name: str = "DEBUG"

    logging_config: dict = {
        "version": 1,
        "disable_existing_loggers": False,
        "loggers": {
            "nox": {
                "level": level_name.upper(),
                "handlers": ["console"],
                "propagate": False,
            }
        },
        "handlers": {
            "console": {
                "class": "logging.StreamHandler",
                "formatter": "nox",
                "level": "DEBUG",
                "stream": "ext://sys.stdout",
            }
        },
        "formatters": {
            "nox": {
                "format": "[NOX] [%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s",
                "datefmt": "%Y-%m-%D %H:%M:%S",
            }
        },
    }

    ## Configure logging. Only run this once in an application
    logging.config.dictConfig(config=logging_config)

    ## Disable loggers by name. Sets logLevel to logging.WARNING to suppress all but warnings & errors
    for _logger in disable_loggers:
        logging.getLogger(_logger).setLevel(logging.WARNING)


def append_lint_paths(search_patterns: str | list[str] = None, lint_paths: list[str] = None):
    if lint_paths is None:
        lint_paths = []

    if search_patterns is None:
        return lint_paths

    if isinstance(search_patterns, str):
        search_patterns = [search_patterns]

    for pattern in search_patterns:
        for path in Path('.').rglob(pattern):
            relative_path = Path('.').joinpath(path).resolve().relative_to(Path('.').resolve())

            if f"{relative_path}" not in lint_paths:
                lint_paths.append(f"./{relative_path}")

    log.debug(f"Lint paths: {lint_paths}")
    return lint_paths


setup_nox_logging()

log.info(f"[container_env:{CONTAINER_ENV}]")

## Ensure REQUIREMENTS_OUTPUT_DIR path exists
if not REQUIREMENTS_OUTPUT_DIR.exists():
    try:
        REQUIREMENTS_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    except Exception as exc:
        msg = Exception(
            f"Unable to create requirements export directory: '{REQUIREMENTS_OUTPUT_DIR}'. Details: {exc}"
        )
        log.error(msg)

        REQUIREMENTS_OUTPUT_DIR: Path = Path(".")

## List of dicts describing a src file to copy to a specified destination file
#  Ex: {"src": "config/.secrets.example.toml", "dest": "config/.secrets.toml"}
INIT_COPY_FILES: list[dict[str, str]] = []


@nox.session(python=PY_VERSIONS, name="build-env", tags=["env", "build", "setup"])
@nox.parametrize("pdm_ver", [PDM_VER])
def setup_base_testenv(session: nox.Session, pdm_ver: str):
    log.debug(f"Default Python: {DEFAULT_PYTHON}")
    session.install(f"pdm>={pdm_ver}")

    log.info("Installing dependencies with PDM")
    session.run("pdm", "sync")
    session.run("pdm", "install")


@nox.session(python=[DEFAULT_PYTHON], name="lint", tags=["quality"])
def run_linter(session: nox.Session):
    session.install("ruff")
    session.install("black")

    log.info("Linting code")
    for d in LINT_PATHS:
        if not Path(d).exists():
            log.warning(f"Skipping lint path '{d}', could not find path")
            pass
        else:
            lint_path: Path = Path(d)
            log.info(f"Running ruff imports sort on '{d}'")
            session.run(
                "ruff",
                "check",
                lint_path,
                "--select",
                "I",
                "--fix",
            )

            log.info(f"Formatting '{d}' with Black")
            session.run(
                "black",
                lint_path,
            )

            log.info(f"Running ruff checks on '{d}' with --fix")
            session.run(
                "ruff",
                "check",
                lint_path,
                "--fix",
            )

    log.info("Linting noxfile.py")
    session.run(
        "ruff",
        "check",
        f"{Path('./noxfile.py')}",
        "--fix",
    )


@nox.session(python=[DEFAULT_PYTHON], name="export", tags=["requirements"])
@nox.parametrize("pdm_ver", [PDM_VER])
def export_requirements(session: nox.Session, pdm_ver: str):
    session.install(f"pdm>={pdm_ver}")

    log.info("Exporting production requirements")
    session.run(
        "pdm",
        "export",
        "--prod",
        "-o",
        f"{REQUIREMENTS_OUTPUT_DIR}/requirements.txt",
        "--without-hashes",
    )

    log.info("Exporting development requirements")
    session.run(
        "pdm",
        "export",
        "-d",
        "-o",
        f"{REQUIREMENTS_OUTPUT_DIR}/requirements.dev.txt",
        "--without-hashes",
    )


@nox.session(python=PY_VERSIONS, name="tests", tags=["test"])
@nox.parametrize("pdm_ver", [PDM_VER])
def run_tests(session: nox.Session, pdm_ver: str):
    session.install(f"pdm>={pdm_ver}")
    session.run("pdm", "install")

    log.info("Running Pytest tests")
    session.run(
        "pdm",
        "run",
        "pytest",
        "-n",
        "auto",
        "--tb=auto",
        "-v",
        "-rsXxfP",
    )


@nox.session(python=PY_VERSIONS, name="pre-commit-all", tags=["repo", "pre-commit"])
def run_pre_commit_all(session: nox.Session):
    session.install("pre-commit")
    session.run("pre-commit")

    log.info("Running all pre-commit hooks")
    session.run("pre-commit", "run")


@nox.session(python=PY_VERSIONS, name="pre-commit-update", tags=["repo", "pre-commit"])
def run_pre_commit_autoupdate(session: nox.Session):
    session.install(f"pre-commit")

    log.info("Running pre-commit autoupdate")
    session.run("pre-commit", "autoupdate")


@nox.session(python=PY_VERSIONS, name="pre-commit-nbstripout", tags=["repo", "pre-commit"])
def run_pre_commit_nbstripout(session: nox.Session):
    session.install(f"pre-commit")

    log.info("Running nbstripout pre-commit hook")
    session.run("pre-commit", "run", "nbstripout")


@nox.session(python=[PY_VER_TUPLE], name="init-setup", tags=["setup"])
def run_initial_setup(session: nox.Session):
    log.info(f"Running initial setup.")
    if INIT_COPY_FILES is None:
        log.warning(f"INIT_COPY_FILES is empty. Skipping")
        pass

    else:

        for pair_dict in INIT_COPY_FILES:
            src = Path(pair_dict["src"])
            dest = Path(pair_dict["dest"])
            if not dest.exists():
                log.info(f"Copying {src} to {dest}")
                try:
                    shutil.copy(src, dest)
                except Exception as exc:
                    msg = Exception(
                        f"Unhandled exception copying file from '{src}' to '{dest}'. Details: {exc}"
                    )
                    log.error(msg)

Extending the noxfile

Check the nox_extra module for information on extending nox with custom sessions.