Python Automation with Nox
Recently, I stumbled across Nox while looking for a solution to replace the bash scripts I have written for my CI / CD pipeline’s lint, test, and release stages. In summary, the solution I was looking for must run the following tasks for each stage
- Linting
- Run the fixer
black
andisort
- Run the linter
pylint
- Run the fixer
- Testing
- Run
coverage
withpytest
- Produce an HTML report from
coverage
to be used as an artifact
- Run
- Release
- Build a wheel for the package
- Upload the wheel to
pypi
for the release step
After reading this article, you will learn how to leverage nox
to write your automation in Python. Code for this article can be found in Gitlab
Project Setup
For this article, we will set up a simple Python package that performs calculations. The project structure will be as follows:
sample_project/
├── README.rst
├── pyproject.toml
├── src
│ └── pynoxa
│ ├── __init__.py
│ └── main.py
└── tests
├── __init__.py
└── test_operations.py
Our sample functions are add
and subtract
defined in the main.py
file.
"""Simple Calculator"""
def add(a: int, b: int) -> int:
"""Adds two integers together"""
return a + b
def subtract(a: int, b: int) -> int:
"""Subtracts two integers"""
return a - b
For testing, we will be using pytest
"""
Test Operations from pynoxa
"""
import pytest
from pynoxa import add, subtract
@pytest.mark.parametrize(("a", "b", "c"), [
(1, 2, 3),
(0, 1, 1),
])
def test_add(a: int, b: int, c: int) -> None:
"""
Test addition operations
"""
assert c == add(a, b)
@pytest.mark.parametrize(("a", "b", "c"), [
(3, 2, 1),
(1, 0, 1),
])
def test_subtract(a: int, b: int, c: int) -> None:
"""
Test addition operations
"""
assert c == subtract(a, b)
Basics
Installation
Firstly, we should install nox in our Python environment.
python3 -m pip install nox
Next, we will install poetry a dependency and package management tool for Python.
osx / bash/ bashwindows
curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python -
windows powershell
(Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py -UseBasicParsing).Content | python -
or pip
python3 -m pip install poetry==1.1.13
Making your first Session
In nox, a Session is an object that runs operations in a virtual environment located in $PROJECT_ROOT/.nox/<SESSION_NAME>
. To create Sessions for nox, we need to create a noxfile.py
configuration file.
"""
noxfile.py Configuration file for Nox
"""
import nox
@nox.session
def tests(session):
""" Runs tests """
session.run("poetry", "install", external=True)
session.run("pytest")
Firstly, we install the package we are developing with and its required dependencies into the new virtual environment with
session.run("poetry", "install", external=True)
This step also installs the local package in an editable mode similar to python setup.py develop
Next, we install and run the pytest
command in the virtual environment.
session.install("pytest")
session.run("pytest")
After we have made our configuration file we can run the nox
cli to execute the tests
session
$ nox
nox > Running session tests
nox > Creating virtual environment (virtualenv) using python3.10 in .nox/tests
nox > poetry install
Installing dependencies from lock file
Package operations: 8 installs, 0 updates, 0 removals
• Installing pyparsing (3.0.8)
• Installing attrs (21.4.0)
• Installing iniconfig (1.1.1)
• Installing packaging (21.3)
• Installing pluggy (1.0.0)
• Installing py (1.11.0)
• Installing toml (0.10.2)
• Installing pytest (6.2.5)
Installing the current project: pynoxa (0.1.0)
nox > python -m pip install pytest
nox > pytest
====================================================== test session starts =======================================================
platform linux -- Python 3.10.0, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /home/kirito/code/python-nox-automation
collected 4 items
tests/test_operations.py .... [100%]
======================================================= 4 passed in 0.01s ========================================================
nox > Session tests was successful.
By default, running nox
will run all sessions specified in the configuration file noxfile.py.
We can select the sessions we want to run with the -s
argument such as :
$ nox -s tests
This results in nox only running the tests
session.
Re-using virtualenv for Session
By default, nox will create a new vritualenv on each run, to re-use an existing virtualenv with can run nox with the -r
flag like so
$ nox -r
or specify the nox option at the top of the noxfile.py
"""
noxfile.py Configuration file for Nox
"""
import nox
nox.options.reuse_existing_virtualenvs = True
# Code Snippet
Adding coverage for reports
Next, we will be using coverage.py to generate our code test coverage reports in XML and HTML. To do that, we will run the pytest
command as a module with coverage run
. The tests session will be modified as below.
"""
noxfile.py Configuration file for Nox
"""
import nox
nox.options.reuse_existing_virtualenvs = True
@nox.session
def tests(session):
""" Runs tests """
session.run("poetry", "install", external=True)
session.run("coverage", "run", "--source", "src", "-m", "pytest", "-sv")
session.run("coverage", "report")
session.run("coverage", "xml")
session.run("coverage", "html")
Now the tests session will produce the following artifacts
- coverage.xml
- HTML coverage in
htmlcov
directory
nox > coverage report
Name Stmts Miss Cover
--------------------------------------------
src/pynoxa/__init__.py 2 0 100%
src/pynoxa/main.py 4 0 100%
--------------------------------------------
TOTAL 6 0 100%
nox > coverage xml
Wrote XML report to coverage.xml
nox > coverage html
Wrote HTML report to htmlcov/index.html
nox > Session tests was successful.
Automate Linting with lint Session
For our next task, we want to automate the use of linters and fixers with Nox. We will be using the following tools for linting. To create a new Session, we simply have to write a new function called lint
and apply the same decorator @nox.session
# Code Snippet #
@nox.session
def lint(session):
""" Runs linters and fixers """
session.run("poetry", "install", external=True)
# Code Snippet #
Again, we will use Poetry to install our local packages and dependencies. In the next section, we will discuss the tools we use for the lint
Session.
Black
black is a code formatter and fixer that conforms to the PEP8
style guide. It will detect and automatically fixes code that is not in compliance. We will later run pylint
which also conforms to the same PEP8 style guide to reveal errors to the user that Black was unable to fix.
To add black
to our automation, we simply need to add the following call to our lint Session
session.run("black", "src")
Isort
Next, isort is another code formatter and fixer that fixes the import statements by sorting them alphabetically and by section and type. It can be configured to follow black
’s formatting style to prevent conflicts.
session.run("isort", "--profile", "black", "src")
Pylint
Lastly, pylint is a widely used code formatting tool and linter for Python. It complies with the same PEP8 codings style as black with the additional benefit of being customizable by the end-user and can be used to enforce a standard coding style in development teams.
Putting it all together,
# Code Snippet #
@nox.session
def lint(session):
""" Runs linters and fixers """
session.run("poetry", "install", external=True)
session.run("black", "src")
session.run("isort", "--profile", "black", "src")
session.run("pylint", "src")
# Code Snippet #
Now we can run nox -s lint
to run the lint Session
nox > black src
reformatted src/pynoxa/__init__.py
All done! ✨ 🍰 ✨
1 file reformatted, 1 file left unchanged.
nox > isort --profile black src
Fixing /home/kirito/code/python-nox-automation/src/pynoxa/__init__.py
nox > pylint src
************* Module src.pynoxa.main
src/pynoxa/main.py:4: [C0103(invalid-name), add] Argument name "a" doesn't conform to snake_case naming style
src/pynoxa/main.py:4: [C0103(invalid-name), add] Argument name "b" doesn't conform to snake_case naming style
src/pynoxa/main.py:9: [C0103(invalid-name), subtract] Argument name "a" doesn't conform to snake_case naming style
src/pynoxa/main.py:9: [C0103(invalid-name), subtract] Argument name "b" doesn't conform to snake_case naming style
************* Module src.pynoxa
src/pynoxa/__init__.py:1: [C0114(missing-module-docstring), ] Missing module docstring
------------------------------------------------------------------
Your code has been rated at 1.67/10 (previous run: 3.33/10, -1.67)
nox > Command pylint src failed with exit code 16
Automate Releasing to Pypi
For this exercise, we will upload the package to pypi’s test repository. First, we need to add the test repository to the poetry’s list of repositories.
$ poetry config repositories.testpypi https://test.pypi.org/legacy
We can check if the repository has been successfully added with
$ poetry config repositories
{'testpypi': {'url': 'https://test.pypi.org/legacy/'}}
Next, we will add the function for the release Session
## Code Snippet
@nox.session
def release(session):
"""Build and release to a repository"""
pypi_user: str = os.environ.get("PYPI_USER")
pypi_pass: str = os.environ.get("PYPI_PASS")
if not pypi_user or not pypi_pass:
session.error(
"Environment variables for release: PYPI_USER, PYPI_PASS are missing!",
)
session.run("poetry", "install", external=True)
session.run("poetry", "build", external=True)
session.run(
"poetry",
"publish",
"-r",
"testpypi",
"-u",
pypi_user,
"-p",
pypi_pass,
external=True,
)
## Code Snippet
In the first section of the release Session, we read the username and password credentials from the environment and cause the session to error out if either environment variable is missing.
pypi_user: str = os.environ.get("PYPI_USER")
pypi_pass: str = os.environ.get("PYPI_PASS")
if not pypi_user or not pypi_pass:
session.error(
"Environment variables for release: PYPI_USER, PYPI_PASS are missing!",
)
By doing this, we follow the good practice of accessing credentials from the environment and also failing the Session early when there are issues.
We can then run the release Session with the nox -s release
nox > Running session release
nox > Creating virtual environment (virtualenv) using python3.10 in .nox/release
nox > poetry install
Installing dependencies from lock file
Package operations: 22 installs, 0 updates, 0 removals
• Installing lazy-object-proxy (1.7.1)
• Installing pyparsing (3.0.8)
• Installing wrapt (1.14.0)
• Installing astroid (2.11.3)
• Installing attrs (21.4.0)
• Installing click (8.1.3)
• Installing dill (0.3.4)
• Installing iniconfig (1.1.1)
• Installing isort (5.10.1)
• Installing mccabe (0.7.0)
• Installing mypy-extensions (0.4.3)
• Installing packaging (21.3)
• Installing pathspec (0.9.0)
• Installing platformdirs (2.5.2)
• Installing pluggy (1.0.0)
• Installing py (1.11.0)
• Installing toml (0.10.2)
• Installing tomli (2.0.1)
• Installing black (22.3.0)
• Installing coverage (6.3.2)
• Installing pylint (2.13.7)
• Installing pytest (6.2.5)
Installing the current project: pynoxa (0.1.0)
nox > poetry build
Building pynoxa (0.1.0)
- Building sdist
- Built pynoxa-0.1.0.tar.gz
- Building wheel
- Built pynoxa-0.1.0-py3-none-any.whl
nox > poetry publish -r testpypi -u testuser -p 'xxx'
Publishing pynoxa (0.1.0) to testpypi
- Uploading pynoxa-0.1.0-py3-none-any.whl 100%
- Uploading pynoxa-0.1.0.tar.gz 100%
nox > Session release was successful.
Remove a session from defaults
Lastly, when we run the nox
command without arguments, it will run all sessions in order of the functions written. In some cases, we will want to remove sessions from the default run-list such as release
, hence we can configure nox to ignore release
with
nox.options.sessions = ["lint", "tests"]
Summary
The final nox configuration file is as follows:
"""
noxfile.py Configuration file for Nox
"""
# pylint: disable=import-error
import os
import nox
nox.options.reuse_existing_virtualenvs = True
nox.options.sessions = ["lint", "tests"]
@nox.session
def lint(session):
""" Runs linters and fixers """
session.run("poetry", "install", external=True)
session.run("black", "src")
session.run("isort", "--profile", "black", "src")
session.run("pylint", "src")
@nox.session
def tests(session):
""" Runs tests """
session.run("poetry", "install", external=True)
session.run("coverage", "run", "--source", "src", "-m", "pytest", "-sv")
session.run("coverage", "report")
session.run("coverage", "xml")
session.run("coverage", "html")
@nox.session
def release(session):
"""Build and release to a repository"""
pypi_user: str = os.environ.get("PYPI_USER")
pypi_pass: str = os.environ.get("PYPI_PASS")
if not pypi_user or not pypi_pass:
session.error(
"Environment variables for release: PYPI_USER, PYPI_PASS are missing!",
)
session.run("poetry", "install", external=True)
session.run("poetry", "build", external=True)
session.run(
"poetry",
"publish",
"-r",
"testpypi",
"-u",
pypi_user,
"-p",
pypi_pass,
external=True,
)
In this article, we have discovered how to use nox to automate routine tasks in Python development such as linting and fixing source code, running tests and generating coverage reports, and lastly, building and releasing Python packages to a PyPi repository.