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.
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
```py title=”noxfile.py” linenums=”1” 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
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.sessions = [“lint”, “export”, “tests”]
if “CONTAINER_ENV” in os.environ: CONTAINER_ENV: bool = os.environ[“CONTAINER_ENV”] else: CONTAINER_ENV: bool = False
log: logging.Logger = logging.getLogger(“nox”)
PY_VERSIONS: list[str] = [“3.12”, “3.11”]
PY_VER_TUPLE: tuple[str, str, str] = platform.python_version_tuple()
DEFAULT_PYTHON: str = f”{PY_VER_TUPLE[0]}.{PY_VER_TUPLE[1]}”
PDM_VER: str = “2.15.4”
LINT_PATHS: list[str] = [“src”, “tests”]
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 representing 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}]”)
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(".")
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](/redkb/docs/programming/python/nox/nox_extra-module/) for information on extending `nox` with custom sessions.
## noxfile.py helper functions
### Install UV project
You can install your UV project in the `noxfile.py` with function. Copy/paste this somewhere towards the top of your code, then add to any sessions where you want to install the whole project.
```python title="Install UV in session" linenums="1"
import nox
from pathlib import Path
# this VENV_DIR constant specifies the name of the dir that the `dev`
# session will create, containing the virtualenv;
# the `resolve()` makes it portable
VENV_DIR = Path("./.venv").resolve()
def install_uv_project(session: nox.Session, external: bool = False) -> None:
"""Method to install uv and the current project in a nox session."""
log.info("Installing uv in session")
session.install("uv")
log.info("Syncing uv project")
session.run("uv", "sync", external=external)
log.info("Installing project")
session.run("uv", "pip", "install", ".", external=external)
Example session using install_uv_project()
:
```python title=”Session that uses install_uv_project()” linenums=”1” @nox.session(name=”dev-env”, tags=[“setup”]) def dev(session: nox.Session) -> None: “"”Sets up a python development environment for the project.
Run this on a fresh clone of the repository to automate building the project with uv.
"""
install_uv_project(session, external=True)
### @cd context manager
This context manager allows you to change the directory a command is run from.
```python title="@cd context manager" linenums="1"
import nox
from contextlib import contextmanager
import os
import importlib.util
import typing as t
@contextmanager
def cd(new_dir) -> t.Generator[None, importlib.util.Any, None]: # type: ignore
"""Context manager to change a directory before executing command."""
prev_dir: str = os.getcwd()
os.chdir(os.path.expanduser(new_dir))
try:
yield
finally:
os.chdir(prev_dir)
This function can find a free port using the socket
library. This is useful for sessions that run a service, like mkdocs-serve
. Instead of hard-coding the port and risking an in-use port, this function can find a free port to pass to the function.
```python title=”find_free_port()” linenums=”1” import nox import socket
def find_free_port(start_port=8000) -> int: “"”Find a free port starting from a specific port number.””” port = start_port while True: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: try: sock.bind((“0.0.0.0”, port)) return port except socket.error: log.info(f”Port {port} is in use, trying the next port.”) port += 1
Example usage:
```python title="Example usage for find_free_port()" linenums="1"
import nox
@nox.session(name="serve-mkdocs", tags=["mkdocs", "serve"])
def serve_mkdocs(session: nox.Session) -> None:
install_uv_project(session)
free_port = find_free_port(start_port=8000)
log.info(f"Serving MKDocs site on port {free_port}")
try:
session.run("mkdocs", "serve", "--dev-addr", f"0.0.0.0:{free_port}")
except Exception as exc:
msg = f"({type(exc)}) Unhandled exception serving MKDocs site. Details: {exc}"
log.error(msg)