Python Automation with Nox

April 30, 2022 python 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

  1. Linting
    1. Run the fixer black and isort
    2. Run the linter pylint
  2. Testing
    1. Run coverage with pytest
    2. Produce an HTML report from coverage to be used as an artifact
  3. Release
    1. Build a wheel for the package
    2. 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.

About

A software developer passionate about DevOps practices, Kubernetes and Python. Based in Singapore

Topics

nox python

Subscribe for updates

* indicates required