DEV Community

Chris White
Chris White

Posted on

Automating Your Python Dev Tools With Tox

So far the code for our beginner's project has become fairly stable and there are linting and testing tools to give confidence in basic functionality. It's almost at the level of being ready to release to the public. Before we do this, however, we need to fix one underlying issue to future project development: running all our tools manually. In this article we'll be looking at tox to centralize our tooling runs.

Tooling Centralization

So right now we have the following functionality as part of our python project:

  • flake8: generalized linting
  • pylint: more in depth linting
  • pytest: test suite and test coverage
  • sphinx: document generation

This is also the order we want to execute them as well. Running each of these manually every time isn't very efficient. To deal with this issue we'll be using the python tool tox. As tox is a development tool we'll need to install it as a traditional dev dependency with pdm. Along with that, there's actually a python package to make working with tox easier in a pdm project. I'll go ahead and install both:

> pdm add -dG dev tox tox-pdm

Tox Configuration

Now tox handles its configuration via an ini file called tox.ini. Let's go ahead and build this up slowly for each of the tools mentioned above. To start out create the tox.ini file in your project's toplevel directory. Then add the following:

[tox]
env_list = py311
Enter fullscreen mode Exit fullscreen mode

This is declaring an environment that will be run under python 3.11. It's used on the backend for setting up a virtual environment. Next we'll setup the tests:

[tox]
env_list = py311

[testenv]
groups = dev
commands =
    flake8
    pylint --recursive=y .
    pytest --cov=my_pdm_project
Enter fullscreen mode Exit fullscreen mode

We'll get to the docs in a later part as that requires an additional change. As far as the layout goes testenv is the default settings that tox uses for all environments. In this case the default is to install dev group dependencies and run the listed commands. env_list is something we'll look at in a bit but lets us diversify what we want to run. The current setting simply indicates that we wish to run the commands using python 3.11, shorthanded to py311.

Now before actually running anything tox will create a .tox folder when executed that acts much like our .venv folder. So this means we'll need to update flake8 and pylint to ignore it. Open up .flake8 in your project root directory and change the content to:

[flake8]
max-line-length = 99
exclude =
  .venv/*
  .tox/*
  docs/*
Enter fullscreen mode Exit fullscreen mode

Then open up pyproject.toml to update this section to add the ignore as well:

[tool.pylint.MASTER]
ignore-paths = [ "^.venv/.*$", "^.tox/.*$", "^docs/*" ]
Enter fullscreen mode Exit fullscreen mode

Note that I've also added docs/ because sphinx from our last article also uses python which we don't have much control over. Now for the moment of truth:

> pdm run tox
py311: commands[0]> flake8
py311: commands[1]> pylint --recursive=y .

--------------------------------------------------------------------
Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)

py311: commands[2]> pytest --cov=my_pdm_project
<snip>
py311: OK (6.58=setup[3.38]+cmd[0.55,2.11,0.55] seconds)
congratulations :) (6.66 seconds)
Enter fullscreen mode Exit fullscreen mode

I've focused the output but you can see tox is running all the normal commands for us. All of this is condensed into a single easy to run command!

Tox Organization

Now while everything is centralized, there's a few issues that can come up in larger projects:

  • Sometimes you just want to run only linting or only tests
  • Linting doesn't require the package to be installed, which tox will do every time

Fortunately for us there's a way to deal with it. tox supports the concept of multiple environments. That's why the first item is env_list. It also supports installation groups that we've defined in pyproject.toml (in this case the dev group with all of our dev tools). Up until now I've recommended installing everything that's not for the base code to be installed in the dev group for simplicity purposes. Now that we've come further in the project it's time to organize these for making our tox runs cleaner. Right now everything looks like this (save the versions of packages might be different):

[tool.pdm.dev-dependencies]
dev = [
    "flake8>=6.1.0",
    "pylint>=3.0.1",
    "pytest>=7.4.2",
    "pytest-cov>=4.1.0",
    "requests-mock>=1.11.0",
    "sphinx>=7.2.6",
    "tox>=4.11.4",
    "tox-pdm>=0.7.0",
]
Enter fullscreen mode Exit fullscreen mode

tox and tox-pdm are fine as-is in the dev group. The rest we'll break up into linting, testing, and doc. Simply add each group as group_name = [] with the respective version entries inside. Let's do linting first:

[tool.pdm.dev-dependencies]
dev = [
    "pytest>=7.4.2",
    "pytest-cov>=4.1.0",
    "requests-mock>=1.11.0",
    "sphinx>=7.2.6",
    "tox>=4.11.4",
    "tox-pdm>=0.7.0",
]
lint = [
    "flake8>=6.1.0",
    "pylint>=3.0.1",
]
Enter fullscreen mode Exit fullscreen mode

So here pylint and flake8 are now in a dedicate lint group. Now we'll do the same for testing and docs:

[tool.pdm.dev-dependencies]
dev = [
    "tox>=4.11.4",
    "tox-pdm>=0.7.0",
]
lint = [
    "flake8>=6.1.0",
    "pylint>=3.0.1",
]
testing = [
    "pytest>=7.4.2",
    "pytest-cov>=4.1.0",
    "requests-mock>=1.11.0",
]
docs = [
    "sphinx>=7.2.6",
]
Enter fullscreen mode Exit fullscreen mode

As tox references the pdm.lock file, it will need to have all the groups refreshed that is listed within it. This can be done with:

> pdm lock -G:all
Enter fullscreen mode Exit fullscreen mode

Now in tox.ini it's time to break everything up. We'll get everything we have currently updated first:

[tox]
env_list = lint, test

[testenv:lint]
groups = testing, lint
commands =
  flake8
  pylint --recursive=y .

[testenv:test]
groups = testing
commands =
  pytest --cov=my_pdm_project
Enter fullscreen mode Exit fullscreen mode

The major difference here is that we have a testenv:lint and testenv:test. This is known as a "named environment". It's useful for cases of breaking out specific functionality. env_list will run in the order provided with lint first followed by test. For linting the reason why the testing packages is required is because pylint is running against our tests and as such needs to be able to resolve the modules we're using. Let's try this out with a quick run:

> pdm run tox
lint: install_deps> pdm sync --no-self --group testing --group lint
lint: commands[0]> flake8
lint: commands[1]> pylint --recursive=y .

--------------------------------------------------------------------
Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)

lint: OK ✔ in 6.22 seconds
test: install_deps> pdm sync --no-self --group testing
test: commands[0]> pytest --cov=my_pdm_project
================================================= test session starts =================================================
tests\test_mymath.py .........                                                                                   [100%]

---------- coverage: platform win32, python 3.11.5-final-0 -----------
Name                                                     Stmts   Miss  Cover
----------------------------------------------------------------------------
.tox\test\Lib\site-packages\my_pdm_project\__init__.py       0      0   100%
.tox\test\Lib\site-packages\my_pdm_project\mymath.py        25      0   100%
----------------------------------------------------------------------------
TOTAL                                                       25      0   100%


================================================== 9 passed in 0.17s ==================================================
  lint: OK (6.22=setup[3.17]+cmd[0.55,2.50] seconds)
  test: OK (3.31=setup[2.75]+cmd[0.56] seconds)
  congratulations :) (9.61 seconds)
Enter fullscreen mode Exit fullscreen mode

Again I've reduced the output to showcase the important parts. Since everything is broken up into separate parts, we can even choose to run only linting for example:

> pdm run tox -e lint
lint: install_deps> pdm sync --no-self --group testing --group lint
lint: commands[0]> flake8
lint: commands[1]> pylint --recursive=y .

--------------------------------------------------------------------
Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)

  lint: OK (4.53=setup[0.86]+cmd[0.56,3.11] seconds)
  congratulations :) (4.62 seconds)
Enter fullscreen mode Exit fullscreen mode

This is extremely useful for working on different parts of the development phase where you've passed linting but are trying to just get the tests to pass. Then once the tests are fixed you can run the whole suite to make sure everything as a whole looks fine. For the documentation generation it's slightly similar except we need to enter the docs directory first:

[tox]
env_list = lint, test, docs

[testenv:lint]
groups = testing, lint
commands =
  flake8
  pylint --recursive=y .

[testenv:test]
groups = testing
commands =
  pytest --cov=my_pdm_project

[testenv:docs]
groups = docs
changedir = docs
commands = sphinx-build source/ build/
Enter fullscreen mode Exit fullscreen mode

Sphinx itself is installed thanks to be part of the docs group we setup in pyproject.toml. Running again we can see docs are now being generated:

docs: commands[0] C:\Users\johnsmith\my-pdm-project\docs> sphinx-build source/ build/
Running Sphinx v7.2.6
loading pickled environment... done
building [mo]: targets for 0 po files that are out of date
writing output...
building [html]: targets for 2 source files that are out of date
updating environment: 0 added, 0 changed, 0 removed
reading sources...
looking for now-outdated files... none found
preparing documents... done
copying assets... copying static files... done
copying extra files... done
done
writing output... [100%] mymath
generating indices... genindex py-modindex done
writing additional pages... search done
dumping search index in English (code: en)... done
dumping object inventory... done
build succeeded.

The HTML pages are in build
Enter fullscreen mode Exit fullscreen mode

Another issue here is that while sphinx and pytest need our package installed to work properly, linting is just working against the code itself. This means installation of our package just adds unnecessary time. We can skip the process by using skip_install = true in our lint section:

[testenv:lint]
groups = testing, lint
skip_install = true
commands =
  flake8
  pylint --recursive=y .
Enter fullscreen mode Exit fullscreen mode

Running tox again shows it's no longer installing our package for the linting session:

> pdm run tox
lint: install_deps> pdm sync --no-self --group testing --group lint
lint: commands[0]> flake8
lint: commands[1]> pylint --recursive=y .

--------------------------------------------------------------------
Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)

lint: OK ✔ in 4.59 seconds
Enter fullscreen mode Exit fullscreen mode

Now unlike the previous definition where we had py311 there's nothing here indicating which version of python is used. In this case tox simply uses the one from the current environment (python 3.11 in this case, which is what we created the pdm virtual environment with). It turns out you can be explicit with python versions by utilizing an interesting feature called factors.

Tox Factors

This is somewhat of a fancy way of saying "names separated by hyphens", with a twist when it comes to python versions. I'm going to go ahead and make an isolated test case to showcase this:

[tox]
env_list = py311, py3.12

[testenv]
commands = python --version
Enter fullscreen mode Exit fullscreen mode

Now to go ahead and run this:

> pdm run tox
py311: commands[0]> python --version
Python 3.11.5
py3.12: commands[0]> python --version
Python 3.12.0
Enter fullscreen mode Exit fullscreen mode

So what's happening is that the format pyMajorMinor and pyMajor.Minor are special cases called "default factors" and they map to specific python versions. In this case py311 maps to python 3.11 and py3.12 maps to python 3.12. The tox documentation has the full details on special python interpreter factors. I will note that this only works if both python 3.11 and python 3.12 are installed on your system. Multiple python versions is an interesting topic, though might be somewhat more of an intermediate discussion. I've written about here if you're interested. This isn't just for python versions though, you can even get a bit more advanced by testing against multiple environment combinations. Let's say for example you made a webapp that you want to ensure works against database A and database B:

[tox]
env_list = py311-databaseA, py3.12-databaseB

[testenv]
commands = 
  python --version
  databaseA: python -c 'print("uses databaseA")'
  databaseB: python -c 'print("uses databaseB")'
Enter fullscreen mode Exit fullscreen mode

So let's see what happens:

> pdm run tox
py311-databaseA: commands[0]> python --version
Python 3.11.5
py311-databaseA: commands[1]> python -c "print(\"uses databaseA\")"
uses databaseA
py311-databaseA: OK ✔ in 3.47 seconds

py3.12-databaseB: commands[0]> python --version
Python 3.12.0
py3.12-databaseB: commands[1]> python -c "print(\"uses databaseB\")"
uses databaseB
Enter fullscreen mode Exit fullscreen mode

This is because what's actually happening is:

  1. py311-databaseA = factor py311 + factor databaseA
  2. py3.12-databaseB = factor py3.12 + factor databaseB
[testenv]
commands = 
  python --version
  databaseA: python -c 'print("uses databaseA")'
  databaseB: python -c 'print("uses databaseB")'
Enter fullscreen mode Exit fullscreen mode

So here we can target specific factors, even if all are not included, to take on specific commands or install specific package dependencies. This makes tox an incredibly powerful tool. Even so our current needs are simple so leaving out the python version factors is fine as we're only testing the version set by pdm. Once you get farther in your python development career you'll be able to better understand how powerful the more advanced usages are.

Conclusion

My original intention was to combine this with uploading your package via twine. Then when I started writing it came to realization that the amount of explanation required would have made it a bit too verbose for my liking. I'd also like to note that I'm currently open for work if you like what you see. To not sound too spammy I'll just say check my dev.to profile for more information. In the next section we'll see the final installment of this series where we upload our python code for others to use.

Top comments (0)