Nox: автоматизация тестов, линтеров и сборки Python-проекта

В любом Python-проекте рано или поздно появляется ритуал: запустить тесты, прогнать линтер, отформатировать код, пересобрать документацию, собрать исполняемый файл. Каждый шаг — своя команда с флагами, и пропустить один из них очень легко. Именно так в релиз попадают баги, которые тесты поймали бы за секунду.
Nox решает эту проблему: все шаги описываются один раз в обычном Python-файле, а потом запускаются одной командой. Ниже — как устроен Nox, как с его помощью тестировать проект на нескольких версиях Python, собирать документацию и сборку, и чем он отличается от tox.
Что такое Nox
Nox — инструмент автоматизации для Python-проектов. Конфигурация лежит в файле noxfile.py в корне проекта, а каждая задача — это функция-сессия, помеченная декоратором @nox.session. Для каждой сессии Nox создаёт изолированное виртуальное окружение, ставит туда зависимости и выполняет команды.
Ставится Nox как обычный инструмент командной строки:
uv tool install nox
# или
pipx install nox
После этого в каталоге проекта достаточно набрать nox.
Учебный проект и первая сессия
Возьмём небольшой проект: в каталоге project модуль string_utils.py с двумя функциями — разворот строки и проверка палиндрома, а в tests — тесты на pytest. В каталоге с тестами должен быть __init__.py, иначе импорт модуля проекта может не сработать.
Первая сессия просто запускает тесты:
import nox
@nox.session(python=["3.12", "3.13"])
def tests(session: nox.Session) -> None:
session.install("pytest")
session.run("pytest", "-v", "tests")
Команда nox создаст два окружения и прогонит тесты на Python 3.12 и 3.13. Сессии получат имена tests-3.12 и tests-3.13.
Несколько версий Python одной командой
Зачем тестировать сразу на нескольких версиях? Поведение стандартной библиотеки и сторонних пакетов меняется между версиями: функция, которая есть в одной версии, может отсутствовать или вести себя иначе в другой. Если ваша библиотека обещает поддержку Python 3.10–3.13, проверять это вручную долго и ненадёжно. С Nox достаточно перечислить версии в декораторе, и одна команда покажет, на какой из них что-то сломалось.
Окружения быстрее создавать через uv. Бэкенды можно перечислить через |: Nox возьмёт первый доступный.
@nox.session(python=["3.12", "3.13"], venv_backend="uv|virtualenv")
def tests(session: nox.Session) -> None:
session.install("pytest")
session.run("pytest", "-v", "tests")
Если uv нет в системе, будет использован virtualenv — конфиг остаётся рабочим на любой машине.
Документация и зависимые сессии
Документацию по docstring удобно генерировать pdoc. Сессия — это обычный вызов команды:
@nox.session
def docs(session: nox.Session) -> None:
session.install("pdoc")
session.run("pdoc", "project", "-o", "docs")
После запуска появляется каталог docs с index.html: модули, сигнатуры функций, текст docstring и исходный код по кнопке.
Сессии могут зависеть друг от друга. Параметр requires гарантирует, что одна сессия запустится только после другой:
@nox.session(requires=["docs"])
def open_docs(session: nox.Session) -> None:
session.log("Документация: docs/index.html")
Запустить конкретную сессию можно флагом -s: nox -s docs. А чтобы по умолчанию запускалось не всё подряд, в начале файла указывают список сессий:
nox.options.sessions = ["tests"]
Теперь просто nox прогонит только тесты, а документацию вы соберёте явно, когда она нужна.
Теги: группы сессий
Сессии удобно группировать тегами. Например, проверки стиля и автоисправление:
@nox.session(tags=["style", "fix"])
def ruff_format(session: nox.Session) -> None:
session.install("ruff")
session.run("ruff", "format", "project", "tests")
@nox.session(tags=["style"])
def lint(session: nox.Session) -> None:
session.install("ruff")
session.run("ruff", "check", "project", "tests", "noxfile.py")
nox -t fix выполнит только исправление, nox -t style — все проверки стиля. Линтер, кстати, сразу находит мелочи вроде неиспользуемого импорта sys в самом noxfile.py.
Сборка исполняемых файлов
Nox подходит не только для проверок, но и для сборки. Пример сессии, которая собирает приложение через PyInstaller в один файл без консоли и удаляет временный каталог build:
import secrets
import shutil
from pathlib import Path
@nox.session(python=["3.12", "3.13"])
def build(session: nox.Session) -> None:
session.install("pyinstaller")
name = f"app-{session.python}-{secrets.token_hex(4)}"
session.run(
"pyinstaller", "--onefile", "--noconsole",
"--name", name, "project/string_utils.py",
)
if Path("build").exists():
shutil.rmtree("build")
Сборка на двух версиях Python сразу показывает, где проект не собирается. У меня первая попытка упала из-за забытого импорта — и это хорошо: ошибка всплыла на этапе сборки, а не у пользователя. В итоге одна команда nox и тестирует код, и проверяет стиль, и собирает документацию, и выдаёт готовые файлы в dist.
Матрица зависимостей и requirements.txt
Иногда нужно проверить код не на разных версиях Python, а на разных версиях библиотеки. Для этого есть nox.parametrize:
@nox.session
@nox.parametrize("django", ["4.2", "5.1"])
def tests_django(session: nox.Session, django: str) -> None:
session.install(f"django=={django}", "pytest")
session.run("pytest", "tests")
Если зависимости перечислены в файле, их ставят одной строкой: session.install("-r", "requirements.txt"). Список всех сессий, включая параметризованные, показывает nox -l.
Nox или tox
Многие используют tox, и он отлично работает. Главное отличие — формат конфигурации. tox описывается декларативно в tox.ini или pyproject.toml, а Nox — обычным Python-кодом. Внутри сессии можно написать любую логику: условия, циклы, работу с файлами, генерацию имён, очистку каталогов, отправку отчёта в Telegram.
| Nox | tox | |
|---|---|---|
| Конфигурация | Python-код (noxfile.py) |
INI или TOML |
| Произвольная логика | Да, это обычный Python | Ограниченно |
| Несколько версий Python | Да | Да |
| Порог входа | Нужно знать Python | Нужно знать формат конфига |
Nox используют крупные проекты — например, pip и urllib3, а также фреймворк Hydra. Если вы и так пишете на Python, конфиг на Python читается и поддерживается проще.
Итог
Nox превращает набор разрозненных команд в один воспроизводимый сценарий: тесты на нескольких версиях Python, линтер, форматирование, документация и сборка запускаются одной командой и одинаково работают у вас, у коллег и в CI. Начните с одной сессии для тестов и постепенно переносите туда остальные ручные шаги.
Хорошая автоматизация особенно ценна, когда проект растёт и в нём появляются классы, модули и слои. Как проектировать такой код, чтобы его было легко тестировать, — подробно разбираю в курсе по Python ООП. А о том, как не допустить утечек памяти в коллекциях, — в статье почему Python list может съесть всю память.