Plugin Architecture For Your Code Using pyplugs in Python3

Plugin Architecture For Your Code Using pyplugs in Python3

Advanced

Once your core application is complete, a plugin architecture can help you to extend the functionality very easily. With a plugin architecture, you can simply write the core application, and then extend the functionality in the future much more easily. Without a plugin architecture, it can be quite difficult to do this since you will be afraid that you will break the original functionality.

So why don’t do this all the time? Well it does take more planning effort in the beginning in order to reap the rewards in the future, and most of us (myself included) are often too impatient to do that. However, there are some methods that you can take in order to embed a plugin desirable to extend the functionality. Last time we looked at using importlib (see our previous article “A Plugin Architecture using importlib“), and this time we have an even simpler library called pyplugs.

When to use plugin architecture

So when should you use a plugin architecture? Here are several scenarios – they are all around separating the code from the core to the variations:

  • Separate Functionality: When you can split the problem you’re trying to solve/application from core functionality (the main “engine”) to the variations: e.g. ranking cheapest flights where data is from different websites. The core application/engine is the ranking logic. The data extraction from different websites would each be a plugin – website 1 = plugin 1, website 2 = plugin2. When you want to add a new website, you just need to add a new plugin
  • Distribute Development Effort: When you want to work in a team to easily separate the focus from core functionality to variations: e.g. suppose you have an application to do image recognition. Team 1 (e.g. data science team) can work on the core engine of doing the image recognition, while you can have Team 2-4 work on creating different plugins for different image formats (e.g. Team 2: read in JPG files, Team 3: read in PNG files, etc)
  • Launch sooner and add functionality in future: When you want to launch an application as quickly as possible. e.g. Suppose you want to create an application to return the number of working days from different countries. To begin with, you can just start by launching this for United States and Australia. Then, you can add more countries in the future. Since you designed the plugin architecture from the start, it’ll be safer to add more countries.

There are many more, but the disadvantage is that you have to plan for it upfront. Invest now in a plugin architecture, and then reap the benefits in the future.

Invest now in a plugin architecture, and then reap the benefits in the future

Plan ahead at the start to make your applications extendible

Let’s explore this third example of a public holiday counter application and show how the pyplugs library can help.

Example Problem: Extracting Public Holidays

The application we’d like to create is a command line application that can be used to pass in a location (country and/or state), and then return the list of public holidays in 2020:

The pseudo-code will be as follows:

1. Get location  
2. If data for location not available, then error
3. Get the list of all holidays from the location
4. Return the list of working days  

As you probably guessed, it’s step 3 that can be converted into a plugin. However, let’s start without a plugin architecture and do this the normal way.

First let’s see where we can get the data from – for UK data you can get this from publicholidays.co.uk:

And then for Singapore data, you can get it from jalanow.com:

In both cases, the data is in a HTML Table view where the data is in a <td> tag. We will need to use regular expressions to extract the data.

Here’s the code for non-plugin approach:

#pubholiday.py
import argparse
import requests, re 

G_COUNTRIES = ['UK', 'SG']

def get_working_days(args):
	if args.countrycode =='UK':
		r = requests.get( 'https://publicholidays.co.uk/2020-dates/')
		m = re.findall('<tr class.+?><td>(.+?)<\/td>', r.text)
		return list(set(m))
	elif args.countrycode =='SG':
		r = requests.get('https://www.jalanow.com/singapore-holidays-2021.htm')
		m = re.findall('<td class\=\"crDate\">(.+?)<\/td>', r.text)
		return list(set(m)) 


def setup_args():
	parser = argparse.ArgumentParser(description='Get list of public holidays in a given year')

	parser.add_argument('-c', '--countrycode', required=True, type=str, choices=G_COUNTRIES, help='Country code') 
	return parser

if __name__ == '__main__':
	parser =  setup_args()
	args = parser.parse_args()
	print( get_working_days(args) )

Running the above with no arguments gives the following – the argparse is a useful library to create arguments very easily – see our other article How to use argparse to manage arguments.

Now, when we run the application with either UK or SG, we get the following data:

The way the code works is all from the function get_working_days:

def get_working_days(args):
	if args.countrycode =='UK':
		r = requests.get( 'https://publicholidays.co.uk/2020-dates/')
		m = re.findall('<tr class.+?><td>(.+?)<\/td>', r.text)
		return list(set(m))
	elif args.countrycode =='SG':
		r = requests.get('https://www.jalanow.com/singapore-holidays-2021.htm')
		m = re.findall('<td class\=\"crDate\">(.+?)<\/td>', r.text)
		return list(set(m)) 

The code for UK, for examples works the following way:

1. Get the data using the requests to the website.  All the data will be in a r.text
2. Next, run a regular expression to extract the date data from the <TD> tag
3. Finally, remove duplicates with the list(set(m)) code

The disadvantage with this code is that if we add more countries, the function get_working_days() will become longer and longer with complex IF statements. The other challenge is testing it, either manually or with pytest will become quite painful. We can always have it call a dynamic function, but then we end up having difficult to read code.

What we need is a dynamic way to call a function for each country so that it can be easily maintainable and extendible… this is where a plugin architecture will help.

Extracting Public Holidays with a plugin architecture using pyplugs

What we will do now is to separate the main core logic from the plugins. So the file structure will be as follows:

|--- pubholidays.py
|___ plugins\
|___________ __init__.py
|___________ reader_UK.py
|___________ reader_SG.py

So there will be the main functionality still in pubholidays.py, however all the country readers will all be in the plugins package (and subdirectory).

But first, let’s install the pyplugs library

Installing pyplugs

PyPlugs is available at PyPI. You can install it using pip:

 python -m pip install pyplugs  

Or, using pip directly:

 pip install pyplugs 

Pyplugs is composed of three levels:

  • Plug-in packages: Directories containing files with plug-ins
  • Plug-ins: Modules containing registered functions or classes
  • Plug-in functions: Several registered functions in the same file

Core logic in plugin architecture

The core logic will be simplified to the following:

#pubholiday_pi.py
import argparse
import requests, re 
import plugins

G_COUNTRIES = ['UK', 'SG']

def get_working_days(args): 
	return plugins.read( 'reader_' + args.countrycode)

def setup_args():
	parser = argparse.ArgumentParser(description='Get list of public holidays in a given year')
	parser.add_argument('-c', '--countrycode', required=True, type=str, choices=G_COUNTRIES, help='Country code') 
	return parser

if __name__ == '__main__':
	parser =  setup_args()
	args = parser.parse_args()
	print( get_working_days(args) )

Now the get_working_days() function has been significant simplified. It calls the “read” function from the plugins/__init__.py package file. The ‘reader_’ + args.countrycode refers to the function and the module name.

Plugin logic

The plugsin/__init__.py is setup as follows:

# plugins/__init__.py
# Import the pyplugs libs
import pyplugs

# All function names are going to be stored under names
names = pyplugs.names_factory(__package__)

# When read function is called, it will call a function received as parameter
read = pyplugs.call_factory(__package__)  

The “read” is the same “read” that is referenced by get_working_days() function from the main pubholiday_pi.py files.

The plugin files/functions are each to be stored in files called “reader_<country code>.py”. The following is the UK file:

#plugins/reader_UK.py
import re, requests
import pyplugs

@pyplugs.register
def reader_UK():
	r = requests.get('https://www.jalanow.com/singapore-holidays-2021.htm')
	m = re.findall('<td class\=\"crDate\">(.+?)<\/td>', r.text)
	return list(set(m)) 

And then finally the SG file:

#plugins/reader_SG.py
import re, requests
import pyplugs

@pyplugs.register
def reader_SG():
	r = requests.get('https://www.jalanow.com/singapore-holidays-2021.htm')
	m = re.findall('<td class\=\"crDate\">(.+?)<\/td>', r.text)
	return list(set(m)) 

In Conclusion

So there is no change when you run the application – you still get the same output:

However, you have a much more maintainable application.

So we started with a monolithic file, and now we extended this to a plugin architecture where the variations are all stored in the “plugins/” folder. In order to add more country public holidays where the data may come from different websites, all that needs to be done is to: (1) add the country code into variable G_COUNTRIES to ensure the command line argument validation works, and (2) add the new file called reader_<country code>.py in the plugins directory with a function name also called reader_<country code>(). That’s it, everything else will work.

You can also see how we used importlib to achieve a similar outcome as well: A plugin architecture using importlib.

Get Notified Automatically Of New Articles

How To Use Python tox for Multi-Environment Test Automation

How To Use Python tox for Multi-Environment Test Automation

Intermediate

You have a Python library that works perfectly on your machine running Python 3.11. Then a user files an issue — it crashes on 3.9. Another user is on 3.12. You fix the 3.9 bug and accidentally break 3.11 compatibility. Sound familiar? Testing across multiple Python versions manually means juggling virtual environments, remembering which one to activate, and running your test suite in each — a process so tedious it simply doesn’t happen. Bugs slip through. Users get hurt.

tox solves this by automating multi-environment testing in a single command. It reads a configuration file that lists which Python versions and dependencies to test against, creates isolated virtual environments for each one, installs your package into them, and runs your test suite in every environment — reporting failures per environment. One command, all Python versions, zero manual juggling.

In this article you will learn how to install and configure tox, write a tox.ini file from scratch, run tests across Python 3.9 through 3.12, pass environment variables and extra dependencies, run only a subset of environments, integrate tox with pytest and coverage, use the modern pyproject.toml configuration style, and build a real-world tox setup for a small utility library. By the end you will have a test automation setup that works identically on your laptop and in CI.

Running tox: Quick Example

Before diving into configuration details, here is a minimal tox setup that runs pytest across two Python versions. Create a small project directory, add a module, a test, and a tox.ini file, then run tox.

# project layout
# mylib/
# ├── mylib/
# │   └── utils.py
# ├── tests/
# │   └── test_utils.py
# ├── tox.ini
# └── setup.py (or pyproject.toml)

# mylib/utils.py
def add(a, b):
    return a + b

def greet(name):
    return f"Hello, {name}!"
# tests/test_utils.py
from mylib.utils import add, greet

def test_add():
    assert add(2, 3) == 5

def test_greet():
    assert greet("Alice") == "Hello, Alice!"
# tox.ini
[tox]
envlist = py39, py311

[testenv]
deps = pytest
commands = pytest tests/
# Run from the project root
$ tox
py39 create: /home/user/mylib/.tox/py39
py39 installdeps: pytest
py39 inst: /home/user/mylib/.tox/.tmp/package/1/mylib-0.1.0.tar.gz
py39 run-test: pytest tests/
============================= test session starts ==============================
platform linux -- Python 3.9.18, pytest-8.2.0
collected 2 items
tests/test_utils.py ..                                                   [100%]
============================== 2 passed in 0.12s ===============================
py311 create: /home/user/mylib/.tox/py311
py311 installdeps: pytest
py311 inst: /home/user/mylib/.tox/.tmp/package/1/mylib-0.1.0.tar.gz
py311 run-test: pytest tests/
============================= test session starts ==============================
platform linux -- Python 3.11.8, pytest-8.2.0
collected 2 items
tests/test_utils.py ..                                                   [100%]
============================== 2 passed in 0.12s ===============================
___________________________________ summary ___________________________________
  py39: commands succeeded
  py311: commands succeeded
  congratulations :)

Tox created two completely separate virtual environments — one for Python 3.9, one for 3.11 — installed your package and pytest into each, ran the test suite, and reported results. The key parts are envlist (which environments to run), deps (what to install), and commands (what to execute). Everything below digs into these and more.

What Is tox and Why Use It?

Tox is a generic virtualenv management and test command-line tool. At its core it does three things: creates isolated virtual environments, installs specified dependencies into each, and runs your commands inside them. It was originally designed for testing Python packages across multiple interpreter versions, but it handles any environment-based task — linting, type checking, building docs, running formatters.

The key insight is that tox installs your package into each environment from a source distribution, the same way a user would install it with pip install mylib. This means your tests run against the installed package, not the raw source files. If you forget to list a dependency in your setup.py or pyproject.toml, tox will catch it — the test environment simply won’t have that import available.

ApproachWhat it solvesWhat it misses
Manually activate venvsIsolationRepeatability, automation
pytest onlyTest runningMulti-version, missing deps detection
toxMulti-version + isolation + automationRequires Python versions to be installed
tox + pyenvEverythingSlightly more setup upfront

Install tox into your system Python or a dedicated virtual environment — do not install it inside the project venv you are testing, as this creates circular dependency problems:

# install_tox.sh
pip install tox --break-system-packages
# or into a dedicated tools venv
python -m venv ~/.venvs/tox && ~/.venvs/tox/bin/pip install tox
tox --version
tox 4.15.0 from /home/user/.local/lib/python3.11/site-packages/tox/__init__.py

Tox 4 (released 2022) changed several configuration defaults from tox 3. This article uses tox 4 conventions throughout. The most important difference: tox 4 no longer requires a setup.py — it works with pyproject.toml out of the box.

Debug Dee at futuristic control panel configuring test environments
tox spins up a fresh venv for each envlist entry. No shared state, no surprises.

The tox.ini Configuration File

The tox.ini file lives in your project root alongside setup.py or pyproject.toml. It uses INI syntax with sections that map to environments. Understanding the full set of options unlocks tox’s real power.

[tox] — Global Settings

# tox.ini — full global section
[tox]
# Environments to run when you type just "tox"
envlist = py39, py310, py311, py312

# Minimum tox version required
minversion = 4.0

# Skip missing Python interpreters instead of failing
skip_missing_interpreters = true

# Where to store environment data (default: {toxinidir}/.tox)
toxworkdir = {toxinidir}/.tox

skip_missing_interpreters = true is extremely useful in CI: if you have only Python 3.11 and 3.12 installed, tox skips the 3.9 and 3.10 environments with a warning rather than failing the build. On a developer laptop you might have only one or two Python versions — this setting keeps things friendly. Set it to false in CI if you want strict enforcement.

[testenv] — The Base Environment

The [testenv] section defines defaults inherited by all environments. Any specific environment like [testenv:py39] inherits everything from [testenv] and can override individual values.

# tox.ini — complete testenv section
[tox]
envlist = py39, py311, py312

[testenv]
# Dependencies to install (separate from your package's requirements)
deps =
    pytest>=8.0
    pytest-cov
    requests-mock

# The command to run
commands =
    pytest {posargs:tests/} --cov=mylib --cov-report=term-missing

# Environment variables to pass through or set
setenv =
    PYTHONPATH = {toxinidir}/src
    APP_ENV = testing

# Pass these variables from your shell into the environment
passenv =
    HOME
    CI
    GITHUB_*

# Install the package itself (default: true)
# Set to false for environments that don't need it (e.g., linting)
package = wheel
# Run tox and pass extra pytest args via posargs
$ tox -- -k test_add -v
py39 run-test: pytest tests/ -k test_add -v --cov=mylib --cov-report=term-missing
...
PASSED tests/test_utils.py::test_add

The {posargs} placeholder is how you forward extra arguments to the underlying command. Everything after -- on the tox command line becomes {posargs}. This lets you run a single test or pass -x to stop on first failure without changing tox.ini.

Per-Environment Overrides

Sometimes a specific Python version needs different dependencies or commands. Define a named environment section to override just those values:

# tox.ini — per-environment overrides
[tox]
envlist = py39, py311, py312, lint, typecheck

[testenv]
deps =
    pytest
    pytest-cov
commands = pytest tests/ --cov=mylib

# py39 needs a backport not required on 3.11+
[testenv:py39]
deps =
    pytest
    pytest-cov
    importlib-metadata>=4.0

# Linting environment — no package install needed
[testenv:lint]
package = skip
deps =
    ruff
    black
commands =
    ruff check mylib/ tests/
    black --check mylib/ tests/

# Type checking
[testenv:typecheck]
package = skip
deps = mypy
commands = mypy mylib/ --strict
# Run only the lint environment
$ tox -e lint

# Run multiple specific environments
$ tox -e py311,typecheck

# List all configured environments
$ tox list
default environments:
py39        -> [no description]
py311       -> [no description]
py312       -> [no description]
lint        -> [no description]
typecheck   -> [no description]

The package = skip setting tells tox not to build and install your package for that environment. This speeds up linting and type checking runs significantly since they only need the source files, not a full package installation.

API Alice with clipboard celebrating all tests passing
tox.ini: four lines to test against Python 3.9, 3.10, 3.11, and 3.12 simultaneously.

Integrating pytest and Coverage

Tox and pytest work seamlessly together. The most useful addition is coverage reporting — knowing not just that your tests pass, but that they actually exercise your code.

# tox.ini — pytest + coverage setup
[tox]
envlist = py311, py312

[testenv]
deps =
    pytest>=8.0
    pytest-cov

commands =
    pytest tests/ \
        --cov=mylib \
        --cov-report=term-missing \
        --cov-report=html:htmlcov \
        --cov-fail-under=80

# Separate environment to combine coverage from all Python versions
[testenv:coverage-report]
package = skip
deps = coverage[toml]
commands =
    coverage combine
    coverage report --fail-under=80
    coverage html

To combine coverage data across all Python version environments, add --cov-append to your pytest command and ensure all environments write to the same .coverage file:

# tox.ini — combined coverage across Python versions
[testenv]
deps =
    pytest
    pytest-cov

setenv =
    COVERAGE_FILE = {toxinidir}/.coverage.{envname}

commands =
    pytest tests/ --cov=mylib --cov-report= --cov-append

[testenv:coverage-report]
package = skip
deps = coverage
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage

commands =
    coverage combine .coverage.py311 .coverage.py312
    coverage report --show-missing
$ tox -e py311,py312,coverage-report
Name                 Stmts   Miss  Cover   Missing
--------------------------------------------------
mylib/utils.py          12      1    92%   45
mylib/parser.py         30      4    87%   22-25
--------------------------------------------------
TOTAL                   42      5    88%

The combined coverage report aggregates line hit data from every Python version. A line that only executes under Python 3.9’s sys.version_info branch will now be properly credited, giving you a truer picture of what the test suite actually exercises.

Using pyproject.toml Instead of tox.ini

Modern Python projects often consolidate all tool configuration into pyproject.toml. Tox 4 supports this natively — put your tox configuration in the [tool.tox] table and delete tox.ini:

# pyproject.toml — tox config inside the project file
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "mylib"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["requests>=2.28"]

[tool.tox]
legacy_tox_ini = """
[tox]
envlist = py39, py311, py312
skip_missing_interpreters = true

[testenv]
deps =
    pytest
    pytest-cov
commands = pytest tests/ --cov=mylib --cov-report=term-missing

[testenv:lint]
package = skip
deps = ruff
commands = ruff check mylib/ tests/
"""

The legacy_tox_ini key holds an INI string — the same syntax as a standalone tox.ini — inside the TOML file. Tox reads it transparently. There is also a native TOML-based configuration format (available in tox 4.2+) that avoids the embedded string, but legacy_tox_ini is the most compatible approach for projects that also need to support tox 3 users.

Environment Variables and Secrets

Test environments often need credentials or configuration that should not be committed to source control. Tox provides setenv for constants and passenv for forwarding values from your shell:

# tox.ini — handling secrets and config
[testenv]
deps = pytest

# Set constants needed by tests
setenv =
    APP_ENV = testing
    DATABASE_URL = sqlite:///test.db
    LOG_LEVEL = WARNING

# Pass secrets from the shell environment
passenv =
    AWS_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY
    GITHUB_TOKEN
    CI
    CODECOV_TOKEN

commands = pytest tests/
# In your shell before running tox:
export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
tox -e py311
# tests/test_api.py — accessing the passed variable
import os

def test_token_available():
    token = os.environ.get("GITHUB_TOKEN")
    # In CI this will be the real token; locally it must be set
    assert token is not None, "GITHUB_TOKEN not set in environment"

Tox deliberately strips most environment variables from the test environment by default. This prevents hidden dependencies on your local shell configuration — the same isolation that makes tox results trustworthy also means you must explicitly declare every environment variable your tests need. Use passenv = * only as a last resort during debugging; it defeats tox’s isolation guarantee.

Running tox in GitHub Actions CI

The real payoff of a tox configuration is running it automatically on every push. GitHub Actions has first-class support for matrix builds across Python versions:

# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.9", "3.10", "3.11", "3.12"]

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install tox
        run: pip install tox tox-gh-actions

      - name: Run tox
        run: tox
# tox.ini — gh-actions section maps matrix Python to tox env
[tox]
envlist = py39, py310, py311, py312, lint

[gh-actions]
python =
    3.9: py39
    3.10: py310
    3.11: py311
    3.12: py312

[testenv]
deps = pytest
commands = pytest tests/

[testenv:lint]
package = skip
deps = ruff
commands = ruff check mylib/

The tox-gh-actions plugin reads the GITHUB_ACTIONS and PYTHON_VERSION environment variables set by GitHub’s matrix runner and automatically selects the matching tox environment. When the matrix job runs Python 3.11, tox automatically runs only the py311 environment rather than all of them. This is more efficient than running the full envlist on every matrix node.

Real-Life Example: Testing a String Utilities Library

# string_utils_project/
# ├── strutils/
# │   ├── __init__.py
# │   ├── transform.py
# │   └── validate.py
# ├── tests/
# │   ├── test_transform.py
# │   └── test_validate.py
# ├── pyproject.toml
# └── tox.ini
# strutils/transform.py
def slugify(text: str) -> str:
    """Convert a string to a URL-friendly slug."""
    import re
    text = text.lower().strip()
    text = re.sub(r'[^\w\s-]', '', text)
    text = re.sub(r'[\s_-]+', '-', text)
    text = re.sub(r'^-+|-+$', '', text)
    return text

def truncate(text: str, max_length: int, suffix: str = "...") -> str:
    """Truncate text to max_length characters."""
    if len(text) <= max_length:
        return text
    return text[:max_length - len(suffix)] + suffix
# strutils/validate.py
import re

def is_valid_email(email: str) -> bool:
    """Basic email format validation."""
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.match(pattern, email))

def is_strong_password(password: str) -> bool:
    """Check password has 8+ chars, upper, lower, digit, special."""
    if len(password) < 8:
        return False
    has_upper = any(c.isupper() for c in password)
    has_lower = any(c.islower() for c in password)
    has_digit = any(c.isdigit() for c in password)
    has_special = any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?' for c in password)
    return all([has_upper, has_lower, has_digit, has_special])
# tests/test_transform.py
import pytest
from strutils.transform import slugify, truncate

@pytest.mark.parametrize("text,expected", [
    ("Hello World", "hello-world"),
    ("  Python 3.11  ", "python-311"),
    ("café & bistro!", "caf-bistro"),
])
def test_slugify(text, expected):
    assert slugify(text) == expected

def test_truncate_short():
    assert truncate("hello", 10) == "hello"

def test_truncate_long():
    assert truncate("hello world", 8) == "hello..."

def test_truncate_custom_suffix():
    assert truncate("hello world", 8, suffix="…") == "hello w…"
# tests/test_validate.py
import pytest
from strutils.validate import is_valid_email, is_strong_password

@pytest.mark.parametrize("email,valid", [
    ("user@example.com", True),
    ("bad-email", False),
    ("missing@tld.", False),
    ("ok+tag@sub.domain.org", True),
])
def test_is_valid_email(email, valid):
    assert is_valid_email(email) == valid

def test_strong_password():
    assert is_strong_password("Secure@123") is True
    assert is_strong_password("weakpass") is False
    assert is_strong_password("NoSpecial1") is False
# tox.ini — full production config for strutils
[tox]
envlist = py39, py310, py311, py312, lint, typecheck, coverage-report
skip_missing_interpreters = true

[gh-actions]
python =
    3.9: py39
    3.10: py310
    3.11: py311
    3.12: py312

[testenv]
deps =
    pytest>=8.0
    pytest-cov
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage.{envname}
commands =
    pytest tests/ --cov=strutils --cov-report=

[testenv:lint]
package = skip
deps = ruff
commands = ruff check strutils/ tests/

[testenv:typecheck]
package = skip
deps = mypy
commands = mypy strutils/ --strict

[testenv:coverage-report]
package = skip
deps = coverage
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage
commands =
    coverage combine
    coverage report --show-missing --fail-under=90
$ tox -e py311,lint,coverage-report
py311 create: .tox/py311
py311 run-test: pytest tests/ --cov=strutils --cov-report=
============================= test session starts ==============================
collected 11 items
tests/test_transform.py ....                                             [100%]
tests/test_validate.py .......                                           [100%]
============================== 11 passed in 0.18s ==============================

lint run-test: ruff check strutils/ tests/
All checks passed!

coverage-report run-test: coverage combine && coverage report --show-missing --fail-under=90
Name                      Stmts   Miss  Cover   Missing
-------------------------------------------------------
strutils/transform.py        12      0   100%
strutils/validate.py         10      0   100%
-------------------------------------------------------
TOTAL                        22      0   100%
___________________________________ summary ___________________________________
  py311: commands succeeded
  lint: commands succeeded
  coverage-report: commands succeeded
  congratulations :)

This configuration gives you a complete quality gate: unit tests across Python versions, linting with ruff, strict type checking with mypy, and a combined coverage report with a minimum threshold. Add this to GitHub Actions with the matrix config shown earlier and every pull request will automatically validate against all supported Python versions before merging.

Frequently Asked Questions

What happens if a Python version isn't installed?

With skip_missing_interpreters = true, tox prints a warning and marks that environment as skipped rather than failing. The final summary shows SKIPPED for missing interpreters and only fails if an installed environment's tests actually fail. Without that setting, tox exits with an error if any interpreter in envlist cannot be found. On developer machines, use skip_missing_interpreters = true; in CI, use false to enforce that all required versions are present.

When does tox recreate environments?

Tox caches virtual environments in the .tox/ directory and reuses them across runs for speed. It recreates an environment only when deps change, the Python interpreter changes, or you pass --recreate (or -r). If your tests behave strangely after a dependency upgrade, run tox -r to force a clean rebuild. You can also delete the entire .tox/ directory — tox will rebuild everything from scratch on the next run.

How do I run a single test with tox?

Use the {posargs} placeholder in your commands and pass arguments after -- on the command line. For example, tox -e py311 -- tests/test_transform.py::test_slugify -v runs only that one test with verbose output. The -- separator tells tox everything after it should be forwarded as {posargs} rather than interpreted as tox options. This is the cleanest way to do rapid test-driven development while keeping tox's isolation.

Should I pin dependency versions in tox.ini?

For library projects, leave deps unpinned (e.g., pytest>=8.0) so tox installs the latest compatible versions — this surfaces breakage from upstream changes early. For application projects where you want reproducible builds, pin exact versions (e.g., pytest==8.2.1) or use a requirements-test.txt file referenced with deps = -r requirements-test.txt. The -r syntax in tox deps works the same way as pip install -r.

What changed between tox 3 and tox 4?

Tox 4 dropped Python 2 support and changed several defaults: isolated_build = true is now the default, meaning tox uses PEP 517/518 build systems instead of python setup.py install. The [gh-actions] section syntax changed slightly. The package option replaces skip_install. Most importantly, tox 4 requires a valid pyproject.toml or setup.py — if your project has neither, add a minimal pyproject.toml with [build-system]. Check the official upgrade guide when migrating.

Conclusion

Tox turns multi-environment testing from a manual, error-prone process into a single tox command. You learned how to write a tox.ini with envlist, deps, and commands; create per-environment overrides for linting, type checking, and version-specific dependencies; integrate pytest coverage across Python versions; use setenv and passenv for environment variables; configure tox-gh-actions for CI matrix builds; and build a complete test pipeline for a real utility library.

Extend the string utilities example by adding a docs environment that builds Sphinx documentation, a security environment that runs bandit, or a benchmark environment that runs pytest-benchmark. Each new environment is just a few lines in tox.ini. Official documentation: tox.wiki.

Further Reading: For more details, see the Python importlib documentation.

Frequently Asked Questions

What is a plugin architecture in Python?

A plugin architecture allows you to extend an application’s functionality by loading external code modules at runtime without modifying the core application. It promotes loose coupling, making your software more flexible and maintainable.

How does PyPlugs work?

PyPlugs provides a simple decorator-based system for registering and discovering plugins. You decorate functions or classes with PyPlugs decorators, and the framework automatically discovers and loads them from specified packages or directories.

What are alternatives to PyPlugs for plugin systems in Python?

Alternatives include pluggy (used by pytest), stevedore (uses setuptools entry points), yapsy, and Python’s built-in importlib for manual plugin loading. Each has different tradeoffs in complexity and features.

When should I use a plugin architecture?

Use a plugin architecture when you need extensibility without modifying core code, when third parties should be able to add features, or when different deployments need different feature sets. Common examples include text editors, web frameworks, and data processing pipelines.

Can I create a simple plugin system without external libraries?

Yes. Use Python’s importlib.import_module() to dynamically load modules from a plugins directory, combined with a registration pattern using decorators or base classes. This gives you a basic but functional plugin system with no dependencies.

How To Detect If A Date Is A Public Holiday In Python 3

How To Detect If A Date Is A Public Holiday In Python 3

Beginner

Determining the current date is a public holiday can be tricky when holidays change and it of course changes from country to country. From system time of servers & machines running to timestamps for tracking the transactions and events in e-commerce platforms, the date and time play a major role. There are a variety of use cases related to manipulating date and time that can be solved using the inbuilt datetime module in Python3, such as

  • Finding if a given year is a leap year or an ordinary year
  • Finding the number of days between the two mentioned dates
  • Convert between different date or time formats

What if you were to check if a given date is a public holiday? There isn’t any specific formula or logic to determine that, do we? Holidays can be pre-defined or uncalled for.

Here, we will be exploring the two ways to detect if a date is a holiday or not.

Checking For Public Holiday With Holidays Module

Although Python3 doesn’t provide any modules to detect if a date is a holiday or not, there are some of the external modules that help in detecting this. One of those modules is Holidays.

In your terminal, type in the following to get the module installed.

sudo pip3 install holidays
Module Installation Demo – holidays

Now that our module is ready, let’s understand a bit about what the module and what it is capable of. Have a look at the following code snippet.

'''
Snippet to check if a given date is a holiday
'''

from datetime import date                                           # Step 1
import holidays

us_holidays = holidays.UnitedStates()                               # Step 2

input_date = input("Enter the date as YYYY-MM-DD: ")                # Step 3

holiday_name = us_holidays .get(input_date)                         # Step 4

if holiday_name != None: 
    output = "{} is a US Holiday - It's {}".format(input_date, holiday_name)
else:
    output = "{} is not a US Holiday".format(input_date)
                                                                    # Step 5
print (output)

In the above snippet,

  • Step 1: Imports the required modules
  • Step 2: Initializes the us_holidays object, so that the corresponding get function can be invoked at step 3
  • Step 3: Gets dateinput from the user
  • Step 4: Invokes the get function of the holidays module. This returns the name of the holiday if the date is a holiday or returns None in case if it isn’t. This gets assigned to the variable – holiday_name.
  • Step 5: Based on the variable – holiday_name, using the if clause the string formatting is done. Can you make this if clause even leaner? Read this article to know about the One line if else statements.

Here’s what the output looks like.

Checking For Holidays With API Call to Calendarific

The above method is suitable for simple projects; however, it can never be used to provide an enterprise-grade solution. Let’s say, you are building a web application for a holiday and travel startup, building an enterprise-grade application requires an enterprise-grade solution. If you haven’t noticed, the holidays module is pretty simple and if you consider state-wise or newly announced holidays, then this solution doesn’t simply cut for a large-scale application.

Enterprise requirements such as these can be satisfied by using external APIs such as Calendarific which provides the API as a service for such applications to consume. They keep updating the holidays of states and countries constantly, and the applications may consume these APIs. Of course, enterprise solutions don’t always come free, but the developer account has a limit of 1000API requests per month.

Locate to https://calendarific.com/ on your favorite browser and follow the steps as shown in the following images to get yourself a free account and an API key for this exercise.

Step 1: Open Calendarific on your favorite browser
Step 2: Signup for a free account
Step 3: Login to your account
Step 4: Copy the API Key

Understanding the Calendarific REST API

Before we could dive into using the API KEY, get yourself a REST API client – Insomnia or Postman. We are about to test our API key if we are able to retrieve the holiday information. Plugin the following URL by replacing [APIKEY] text with your API KEY received from above on your REST client.

https://calendarific.com/api/v2/holidays?api_key=[APIKEY]&country=us-ny&type=national&year=2020&month=1&day=1

In the above URL:

  • https://calendarific.com/api/v2 is the API Base URL
  • /holidays is the API route
  • api_key, country, type, year, month, day are URL Parameters
  • Each parameter has a value allocated to it with an = (equal sign)
  • Each parameter and value pair is split by an & (ampersand)

For the above API call, the following response will be received; the value corresponding to the code key under the meta tag as ‘200’ corresponds to a successful response.

{
  "meta": {
    "code": 200
  },
  "response": {
    "holidays": [
      {
        "name": "New Year's Day",
        "description": "New Year's Day is the first day of the Gregorian calendar, which is widely used in many countries such as the USA.",
        "country": {
          "id": "us",
          "name": "United States"
        },
        "date": {
          "iso": "2020-01-01",
          "datetime": {
            "year": 2020,
            "month": 1,
            "day": 1
          }
        },
        "type": [
          "National holiday"
        ],
        "locations": "All",
        "states": "All"
      }
    ]
  }
}

The REST API call has returned some useful info about the National holiday on the 1st of January. Let’s see if it’s able to detect for the 2nd of January. Plugin the following URL again by replacing the text [APIKEY] with your API Key.

https://calendarific.com/api/v2/holidays?api_key=[APIKEY]&country=us-ny&type=national&year=2020&month=1&day=2

The above URL should be returning a response similar to below.

{
   "meta": {
     "code": 200
   },
   "response": {
     "holidays": []
   }
 }

Indeed, the 2nd of January is not a public holiday and hence, the holidays list inside the response nested JSON key turns out to be an empty list.

Now we know that our API works very well, it is now time to incorporate Calendarific REST API into our Python code. We will be using the requests module in order to make this happen. Here’s how it is done.

'''
Snippet to check if a given date is a holiday using an external API - Calendarific
'''

import requests                                                     # Step 1

api_key   = '[APIKEY]'                                              # Step 2
base_url  = 'https://calendarific.com/api/v2'
api_route = '/holidays'

location  = input("Enter Country & State code - E.g.: us-ny: ")
date_inpt = input("Enter the date as YYYY-MM-DD: ")                 # Step 3
y, m, d = date_inpt.split('-')
 
full_url = '{}{}?api_key={}&country={}&type=national&year={}&month={}&day={}'\
                .format(base_url, api_route, api_key, location, str(int(y)), str(int(m)), str(int(d)))                                           # Step 4

response = requests.get(full_url).json()                            # Step 5

if response['response']['holidays'] != []:
    print ("{} is a holiday - {}".format(date_inpt, response['response']['holidays'][0]['name']))
else:                                                               # Step 6
    print ("{} is not a holiday".format(date_inpt))

In the above snippet,

  • Step 1: Import requests module – you will be needing this module to invoke the REST API.
  • Step 2: Replace ‘[APIKEY]’ with your own API key from Calendarific
  • Step 3: The user inputs the corresponding location and date for which the holiday needs to be detected
  • Step 4: String formatting in order to frame the URL
  • Step 5: Invoke the API and convert the response to a JSON; i.e.) a dictionary
  • Step 6: If clause checks for the presence of an empty list or with a returned response.

Here’s what the output looks like.

And there you have it, a working example for detecting if a given date is a holiday using an external API.

Summary

From an overall perspective, there could be multiple ways to solve a given problem, and here, we have portrayed two of those ways in detecting if a given date is a holiday or not. One is a straight forward out-of-the-box solution and the other one is an enterprise-ready solution, which one would you choose?

Subscribe to our newsletter

How To Use Python tox for Multi-Environment Test Automation

How To Use Python tox for Multi-Environment Test Automation

Intermediate

You have a Python library that works perfectly on your machine running Python 3.11. Then a user files an issue — it crashes on 3.9. Another user is on 3.12. You fix the 3.9 bug and accidentally break 3.11 compatibility. Sound familiar? Testing across multiple Python versions manually means juggling virtual environments, remembering which one to activate, and running your test suite in each — a process so tedious it simply doesn’t happen. Bugs slip through. Users get hurt.

tox solves this by automating multi-environment testing in a single command. It reads a configuration file that lists which Python versions and dependencies to test against, creates isolated virtual environments for each one, installs your package into them, and runs your test suite in every environment — reporting failures per environment. One command, all Python versions, zero manual juggling.

In this article you will learn how to install and configure tox, write a tox.ini file from scratch, run tests across Python 3.9 through 3.12, pass environment variables and extra dependencies, run only a subset of environments, integrate tox with pytest and coverage, use the modern pyproject.toml configuration style, and build a real-world tox setup for a small utility library. By the end you will have a test automation setup that works identically on your laptop and in CI.

Running tox: Quick Example

Before diving into configuration details, here is a minimal tox setup that runs pytest across two Python versions. Create a small project directory, add a module, a test, and a tox.ini file, then run tox.

# project layout
# mylib/
# ├── mylib/
# │   └── utils.py
# ├── tests/
# │   └── test_utils.py
# ├── tox.ini
# └── setup.py (or pyproject.toml)

# mylib/utils.py
def add(a, b):
    return a + b

def greet(name):
    return f"Hello, {name}!"
# tests/test_utils.py
from mylib.utils import add, greet

def test_add():
    assert add(2, 3) == 5

def test_greet():
    assert greet("Alice") == "Hello, Alice!"
# tox.ini
[tox]
envlist = py39, py311

[testenv]
deps = pytest
commands = pytest tests/
# Run from the project root
$ tox
py39 create: /home/user/mylib/.tox/py39
py39 installdeps: pytest
py39 inst: /home/user/mylib/.tox/.tmp/package/1/mylib-0.1.0.tar.gz
py39 run-test: pytest tests/
============================= test session starts ==============================
platform linux -- Python 3.9.18, pytest-8.2.0
collected 2 items
tests/test_utils.py ..                                                   [100%]
============================== 2 passed in 0.12s ===============================
py311 create: /home/user/mylib/.tox/py311
py311 installdeps: pytest
py311 inst: /home/user/mylib/.tox/.tmp/package/1/mylib-0.1.0.tar.gz
py311 run-test: pytest tests/
============================= test session starts ==============================
platform linux -- Python 3.11.8, pytest-8.2.0
collected 2 items
tests/test_utils.py ..                                                   [100%]
============================== 2 passed in 0.12s ===============================
___________________________________ summary ___________________________________
  py39: commands succeeded
  py311: commands succeeded
  congratulations :)

Tox created two completely separate virtual environments — one for Python 3.9, one for 3.11 — installed your package and pytest into each, ran the test suite, and reported results. The key parts are envlist (which environments to run), deps (what to install), and commands (what to execute). Everything below digs into these and more.

What Is tox and Why Use It?

Tox is a generic virtualenv management and test command-line tool. At its core it does three things: creates isolated virtual environments, installs specified dependencies into each, and runs your commands inside them. It was originally designed for testing Python packages across multiple interpreter versions, but it handles any environment-based task — linting, type checking, building docs, running formatters.

The key insight is that tox installs your package into each environment from a source distribution, the same way a user would install it with pip install mylib. This means your tests run against the installed package, not the raw source files. If you forget to list a dependency in your setup.py or pyproject.toml, tox will catch it — the test environment simply won’t have that import available.

ApproachWhat it solvesWhat it misses
Manually activate venvsIsolationRepeatability, automation
pytest onlyTest runningMulti-version, missing deps detection
toxMulti-version + isolation + automationRequires Python versions to be installed
tox + pyenvEverythingSlightly more setup upfront

Install tox into your system Python or a dedicated virtual environment — do not install it inside the project venv you are testing, as this creates circular dependency problems:

# install_tox.sh
pip install tox --break-system-packages
# or into a dedicated tools venv
python -m venv ~/.venvs/tox && ~/.venvs/tox/bin/pip install tox
tox --version
tox 4.15.0 from /home/user/.local/lib/python3.11/site-packages/tox/__init__.py

Tox 4 (released 2022) changed several configuration defaults from tox 3. This article uses tox 4 conventions throughout. The most important difference: tox 4 no longer requires a setup.py — it works with pyproject.toml out of the box.

Debug Dee at futuristic control panel configuring test environments
tox spins up a fresh venv for each envlist entry. No shared state, no surprises.

The tox.ini Configuration File

The tox.ini file lives in your project root alongside setup.py or pyproject.toml. It uses INI syntax with sections that map to environments. Understanding the full set of options unlocks tox’s real power.

[tox] — Global Settings

# tox.ini — full global section
[tox]
# Environments to run when you type just "tox"
envlist = py39, py310, py311, py312

# Minimum tox version required
minversion = 4.0

# Skip missing Python interpreters instead of failing
skip_missing_interpreters = true

# Where to store environment data (default: {toxinidir}/.tox)
toxworkdir = {toxinidir}/.tox

skip_missing_interpreters = true is extremely useful in CI: if you have only Python 3.11 and 3.12 installed, tox skips the 3.9 and 3.10 environments with a warning rather than failing the build. On a developer laptop you might have only one or two Python versions — this setting keeps things friendly. Set it to false in CI if you want strict enforcement.

[testenv] — The Base Environment

The [testenv] section defines defaults inherited by all environments. Any specific environment like [testenv:py39] inherits everything from [testenv] and can override individual values.

# tox.ini — complete testenv section
[tox]
envlist = py39, py311, py312

[testenv]
# Dependencies to install (separate from your package's requirements)
deps =
    pytest>=8.0
    pytest-cov
    requests-mock

# The command to run
commands =
    pytest {posargs:tests/} --cov=mylib --cov-report=term-missing

# Environment variables to pass through or set
setenv =
    PYTHONPATH = {toxinidir}/src
    APP_ENV = testing

# Pass these variables from your shell into the environment
passenv =
    HOME
    CI
    GITHUB_*

# Install the package itself (default: true)
# Set to false for environments that don't need it (e.g., linting)
package = wheel
# Run tox and pass extra pytest args via posargs
$ tox -- -k test_add -v
py39 run-test: pytest tests/ -k test_add -v --cov=mylib --cov-report=term-missing
...
PASSED tests/test_utils.py::test_add

The {posargs} placeholder is how you forward extra arguments to the underlying command. Everything after -- on the tox command line becomes {posargs}. This lets you run a single test or pass -x to stop on first failure without changing tox.ini.

Per-Environment Overrides

Sometimes a specific Python version needs different dependencies or commands. Define a named environment section to override just those values:

# tox.ini — per-environment overrides
[tox]
envlist = py39, py311, py312, lint, typecheck

[testenv]
deps =
    pytest
    pytest-cov
commands = pytest tests/ --cov=mylib

# py39 needs a backport not required on 3.11+
[testenv:py39]
deps =
    pytest
    pytest-cov
    importlib-metadata>=4.0

# Linting environment — no package install needed
[testenv:lint]
package = skip
deps =
    ruff
    black
commands =
    ruff check mylib/ tests/
    black --check mylib/ tests/

# Type checking
[testenv:typecheck]
package = skip
deps = mypy
commands = mypy mylib/ --strict
# Run only the lint environment
$ tox -e lint

# Run multiple specific environments
$ tox -e py311,typecheck

# List all configured environments
$ tox list
default environments:
py39        -> [no description]
py311       -> [no description]
py312       -> [no description]
lint        -> [no description]
typecheck   -> [no description]

The package = skip setting tells tox not to build and install your package for that environment. This speeds up linting and type checking runs significantly since they only need the source files, not a full package installation.

API Alice with clipboard celebrating all tests passing
tox.ini: four lines to test against Python 3.9, 3.10, 3.11, and 3.12 simultaneously.

Integrating pytest and Coverage

Tox and pytest work seamlessly together. The most useful addition is coverage reporting — knowing not just that your tests pass, but that they actually exercise your code.

# tox.ini — pytest + coverage setup
[tox]
envlist = py311, py312

[testenv]
deps =
    pytest>=8.0
    pytest-cov

commands =
    pytest tests/ \
        --cov=mylib \
        --cov-report=term-missing \
        --cov-report=html:htmlcov \
        --cov-fail-under=80

# Separate environment to combine coverage from all Python versions
[testenv:coverage-report]
package = skip
deps = coverage[toml]
commands =
    coverage combine
    coverage report --fail-under=80
    coverage html

To combine coverage data across all Python version environments, add --cov-append to your pytest command and ensure all environments write to the same .coverage file:

# tox.ini — combined coverage across Python versions
[testenv]
deps =
    pytest
    pytest-cov

setenv =
    COVERAGE_FILE = {toxinidir}/.coverage.{envname}

commands =
    pytest tests/ --cov=mylib --cov-report= --cov-append

[testenv:coverage-report]
package = skip
deps = coverage
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage

commands =
    coverage combine .coverage.py311 .coverage.py312
    coverage report --show-missing
$ tox -e py311,py312,coverage-report
Name                 Stmts   Miss  Cover   Missing
--------------------------------------------------
mylib/utils.py          12      1    92%   45
mylib/parser.py         30      4    87%   22-25
--------------------------------------------------
TOTAL                   42      5    88%

The combined coverage report aggregates line hit data from every Python version. A line that only executes under Python 3.9’s sys.version_info branch will now be properly credited, giving you a truer picture of what the test suite actually exercises.

Using pyproject.toml Instead of tox.ini

Modern Python projects often consolidate all tool configuration into pyproject.toml. Tox 4 supports this natively — put your tox configuration in the [tool.tox] table and delete tox.ini:

# pyproject.toml — tox config inside the project file
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "mylib"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["requests>=2.28"]

[tool.tox]
legacy_tox_ini = """
[tox]
envlist = py39, py311, py312
skip_missing_interpreters = true

[testenv]
deps =
    pytest
    pytest-cov
commands = pytest tests/ --cov=mylib --cov-report=term-missing

[testenv:lint]
package = skip
deps = ruff
commands = ruff check mylib/ tests/
"""

The legacy_tox_ini key holds an INI string — the same syntax as a standalone tox.ini — inside the TOML file. Tox reads it transparently. There is also a native TOML-based configuration format (available in tox 4.2+) that avoids the embedded string, but legacy_tox_ini is the most compatible approach for projects that also need to support tox 3 users.

Environment Variables and Secrets

Test environments often need credentials or configuration that should not be committed to source control. Tox provides setenv for constants and passenv for forwarding values from your shell:

# tox.ini — handling secrets and config
[testenv]
deps = pytest

# Set constants needed by tests
setenv =
    APP_ENV = testing
    DATABASE_URL = sqlite:///test.db
    LOG_LEVEL = WARNING

# Pass secrets from the shell environment
passenv =
    AWS_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY
    GITHUB_TOKEN
    CI
    CODECOV_TOKEN

commands = pytest tests/
# In your shell before running tox:
export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
tox -e py311
# tests/test_api.py — accessing the passed variable
import os

def test_token_available():
    token = os.environ.get("GITHUB_TOKEN")
    # In CI this will be the real token; locally it must be set
    assert token is not None, "GITHUB_TOKEN not set in environment"

Tox deliberately strips most environment variables from the test environment by default. This prevents hidden dependencies on your local shell configuration — the same isolation that makes tox results trustworthy also means you must explicitly declare every environment variable your tests need. Use passenv = * only as a last resort during debugging; it defeats tox’s isolation guarantee.

Running tox in GitHub Actions CI

The real payoff of a tox configuration is running it automatically on every push. GitHub Actions has first-class support for matrix builds across Python versions:

# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.9", "3.10", "3.11", "3.12"]

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install tox
        run: pip install tox tox-gh-actions

      - name: Run tox
        run: tox
# tox.ini — gh-actions section maps matrix Python to tox env
[tox]
envlist = py39, py310, py311, py312, lint

[gh-actions]
python =
    3.9: py39
    3.10: py310
    3.11: py311
    3.12: py312

[testenv]
deps = pytest
commands = pytest tests/

[testenv:lint]
package = skip
deps = ruff
commands = ruff check mylib/

The tox-gh-actions plugin reads the GITHUB_ACTIONS and PYTHON_VERSION environment variables set by GitHub’s matrix runner and automatically selects the matching tox environment. When the matrix job runs Python 3.11, tox automatically runs only the py311 environment rather than all of them. This is more efficient than running the full envlist on every matrix node.

Real-Life Example: Testing a String Utilities Library

# string_utils_project/
# ├── strutils/
# │   ├── __init__.py
# │   ├── transform.py
# │   └── validate.py
# ├── tests/
# │   ├── test_transform.py
# │   └── test_validate.py
# ├── pyproject.toml
# └── tox.ini
# strutils/transform.py
def slugify(text: str) -> str:
    """Convert a string to a URL-friendly slug."""
    import re
    text = text.lower().strip()
    text = re.sub(r'[^\w\s-]', '', text)
    text = re.sub(r'[\s_-]+', '-', text)
    text = re.sub(r'^-+|-+$', '', text)
    return text

def truncate(text: str, max_length: int, suffix: str = "...") -> str:
    """Truncate text to max_length characters."""
    if len(text) <= max_length:
        return text
    return text[:max_length - len(suffix)] + suffix
# strutils/validate.py
import re

def is_valid_email(email: str) -> bool:
    """Basic email format validation."""
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.match(pattern, email))

def is_strong_password(password: str) -> bool:
    """Check password has 8+ chars, upper, lower, digit, special."""
    if len(password) < 8:
        return False
    has_upper = any(c.isupper() for c in password)
    has_lower = any(c.islower() for c in password)
    has_digit = any(c.isdigit() for c in password)
    has_special = any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?' for c in password)
    return all([has_upper, has_lower, has_digit, has_special])
# tests/test_transform.py
import pytest
from strutils.transform import slugify, truncate

@pytest.mark.parametrize("text,expected", [
    ("Hello World", "hello-world"),
    ("  Python 3.11  ", "python-311"),
    ("café & bistro!", "caf-bistro"),
])
def test_slugify(text, expected):
    assert slugify(text) == expected

def test_truncate_short():
    assert truncate("hello", 10) == "hello"

def test_truncate_long():
    assert truncate("hello world", 8) == "hello..."

def test_truncate_custom_suffix():
    assert truncate("hello world", 8, suffix="…") == "hello w…"
# tests/test_validate.py
import pytest
from strutils.validate import is_valid_email, is_strong_password

@pytest.mark.parametrize("email,valid", [
    ("user@example.com", True),
    ("bad-email", False),
    ("missing@tld.", False),
    ("ok+tag@sub.domain.org", True),
])
def test_is_valid_email(email, valid):
    assert is_valid_email(email) == valid

def test_strong_password():
    assert is_strong_password("Secure@123") is True
    assert is_strong_password("weakpass") is False
    assert is_strong_password("NoSpecial1") is False
# tox.ini — full production config for strutils
[tox]
envlist = py39, py310, py311, py312, lint, typecheck, coverage-report
skip_missing_interpreters = true

[gh-actions]
python =
    3.9: py39
    3.10: py310
    3.11: py311
    3.12: py312

[testenv]
deps =
    pytest>=8.0
    pytest-cov
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage.{envname}
commands =
    pytest tests/ --cov=strutils --cov-report=

[testenv:lint]
package = skip
deps = ruff
commands = ruff check strutils/ tests/

[testenv:typecheck]
package = skip
deps = mypy
commands = mypy strutils/ --strict

[testenv:coverage-report]
package = skip
deps = coverage
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage
commands =
    coverage combine
    coverage report --show-missing --fail-under=90
$ tox -e py311,lint,coverage-report
py311 create: .tox/py311
py311 run-test: pytest tests/ --cov=strutils --cov-report=
============================= test session starts ==============================
collected 11 items
tests/test_transform.py ....                                             [100%]
tests/test_validate.py .......                                           [100%]
============================== 11 passed in 0.18s ==============================

lint run-test: ruff check strutils/ tests/
All checks passed!

coverage-report run-test: coverage combine && coverage report --show-missing --fail-under=90
Name                      Stmts   Miss  Cover   Missing
-------------------------------------------------------
strutils/transform.py        12      0   100%
strutils/validate.py         10      0   100%
-------------------------------------------------------
TOTAL                        22      0   100%
___________________________________ summary ___________________________________
  py311: commands succeeded
  lint: commands succeeded
  coverage-report: commands succeeded
  congratulations :)

This configuration gives you a complete quality gate: unit tests across Python versions, linting with ruff, strict type checking with mypy, and a combined coverage report with a minimum threshold. Add this to GitHub Actions with the matrix config shown earlier and every pull request will automatically validate against all supported Python versions before merging.

Frequently Asked Questions

What happens if a Python version isn't installed?

With skip_missing_interpreters = true, tox prints a warning and marks that environment as skipped rather than failing. The final summary shows SKIPPED for missing interpreters and only fails if an installed environment's tests actually fail. Without that setting, tox exits with an error if any interpreter in envlist cannot be found. On developer machines, use skip_missing_interpreters = true; in CI, use false to enforce that all required versions are present.

When does tox recreate environments?

Tox caches virtual environments in the .tox/ directory and reuses them across runs for speed. It recreates an environment only when deps change, the Python interpreter changes, or you pass --recreate (or -r). If your tests behave strangely after a dependency upgrade, run tox -r to force a clean rebuild. You can also delete the entire .tox/ directory — tox will rebuild everything from scratch on the next run.

How do I run a single test with tox?

Use the {posargs} placeholder in your commands and pass arguments after -- on the command line. For example, tox -e py311 -- tests/test_transform.py::test_slugify -v runs only that one test with verbose output. The -- separator tells tox everything after it should be forwarded as {posargs} rather than interpreted as tox options. This is the cleanest way to do rapid test-driven development while keeping tox's isolation.

Should I pin dependency versions in tox.ini?

For library projects, leave deps unpinned (e.g., pytest>=8.0) so tox installs the latest compatible versions — this surfaces breakage from upstream changes early. For application projects where you want reproducible builds, pin exact versions (e.g., pytest==8.2.1) or use a requirements-test.txt file referenced with deps = -r requirements-test.txt. The -r syntax in tox deps works the same way as pip install -r.

What changed between tox 3 and tox 4?

Tox 4 dropped Python 2 support and changed several defaults: isolated_build = true is now the default, meaning tox uses PEP 517/518 build systems instead of python setup.py install. The [gh-actions] section syntax changed slightly. The package option replaces skip_install. Most importantly, tox 4 requires a valid pyproject.toml or setup.py — if your project has neither, add a minimal pyproject.toml with [build-system]. Check the official upgrade guide when migrating.

Conclusion

Tox turns multi-environment testing from a manual, error-prone process into a single tox command. You learned how to write a tox.ini with envlist, deps, and commands; create per-environment overrides for linting, type checking, and version-specific dependencies; integrate pytest coverage across Python versions; use setenv and passenv for environment variables; configure tox-gh-actions for CI matrix builds; and build a complete test pipeline for a real utility library.

Extend the string utilities example by adding a docs environment that builds Sphinx documentation, a security environment that runs bandit, or a benchmark environment that runs pytest-benchmark. Each new environment is just a few lines in tox.ini. Official documentation: tox.wiki.

Further Reading: For more details, see the Python datetime module documentation.

Pro Tips for Working with Public Holidays in Python

1. Cache Holiday Data to Avoid Repeated API Calls

If you are using the Calendarific API, cache the results locally instead of calling the API every time you check a date. Holiday lists for a given country and year rarely change. Save the API response to a JSON file and only refresh it when the year changes. This reduces API usage and makes your application faster.

# cache_holidays.py
import json
import os
from datetime import date

CACHE_FILE = "holidays_cache.json"

def get_cached_holidays(country, year):
    if os.path.exists(CACHE_FILE):
        with open(CACHE_FILE, "r") as f:
            cache = json.load(f)
        key = f"{country}_{year}"
        if key in cache:
            print(f"Using cached holidays for {country} {year}")
            return cache[key]
    return None

def save_to_cache(country, year, holidays):
    cache = {}
    if os.path.exists(CACHE_FILE):
        with open(CACHE_FILE, "r") as f:
            cache = json.load(f)
    cache[f"{country}_{year}"] = holidays
    with open(CACHE_FILE, "w") as f:
        json.dump(cache, f, indent=2)
    print(f"Cached {len(holidays)} holidays for {country} {year}")

Output:

Cached 11 holidays for US 2026
Using cached holidays for US 2026

2. Calculate Business Days Excluding Holidays

One of the most common real-world uses of holiday detection is calculating business days. Combine the holidays library with Python’s datetime to count only working days between two dates, excluding weekends and public holidays. This is essential for shipping estimates, SLA calculations, and payroll processing.

# business_days.py
import holidays
from datetime import date, timedelta

def business_days_between(start, end, country="US"):
    us_holidays = holidays.country_holidays(country)
    count = 0
    current = start
    while current <= end:
        if current.weekday() < 5 and current not in us_holidays:
            count += 1
        current += timedelta(days=1)
    return count

start = date(2026, 12, 20)
end = date(2026, 12, 31)
days = business_days_between(start, end)
print(f"Business days from {start} to {end}: {days}")

Output:

Business days from 2026-12-20 to 2026-12-31: 7

3. Handle Multiple Countries for International Apps

If your application serves users in different countries, check holidays for each user's country rather than assuming a single country. The holidays library supports 100+ countries. Store each user's country code and pass it when checking holidays. Remember that some countries have regional holidays too -- for example, different states in Australia or provinces in Canada have different public holidays.

4. Build a Holiday-Aware Scheduler

Many applications need to skip processing on holidays. Instead of checking manually every time, create a decorator that wraps scheduled tasks and automatically skips execution on public holidays. This is useful for automated reports, email campaigns, and batch processing jobs that should only run on business days.

# holiday_aware_scheduler.py
import holidays
from datetime import date
from functools import wraps

def skip_on_holidays(country="US"):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            today = date.today()
            if today in holidays.country_holidays(country):
                name = holidays.country_holidays(country).get(today)
                print(f"Skipping {func.__name__}: today is {name}")
                return None
            return func(*args, **kwargs)
        return wrapper
    return decorator

@skip_on_holidays("US")
def send_daily_report():
    print("Sending daily report...")
    return "Report sent"

result = send_daily_report()
print(f"Result: {result}")

Output (on a regular business day):

Sending daily report...
Result: Report sent

5. Display Upcoming Holidays for Better UX

Show your users which holidays are coming up so they can plan ahead. This is valuable for project management tools, delivery estimate pages, and HR applications. Sort the holiday list by date and filter for upcoming dates only to give users a clear view of the next few holidays.

Frequently Asked Questions

How do I check if a date is a public holiday in Python?

Use the holidays library: install it with pip install holidays, then check with date in holidays.country_holidays('US'). It returns True if the date is a recognized public holiday for that country.

What countries does the Python holidays library support?

The holidays library supports over 100 countries and their subdivisions. Major countries include the US, UK, Canada, Australia, Germany, France, India, and many more. Use holidays.list_supported_countries() to see the complete list.

Can I add custom holidays to the holidays library?

Yes. Create a custom holiday class inheriting from the country class, or use the append() method to add individual dates. You can also create entirely custom holiday calendars for company-specific or regional holidays.

How do I get the name of a holiday for a specific date?

Access the holiday name with holidays.country_holidays('US').get(date), which returns the holiday name as a string, or None if it is not a holiday. You can also iterate over the holidays object to list all holidays in a year.

Is the holidays library useful for business day calculations?

Yes. Combine it with numpy.busday_count() or pandas.bdate_range() to calculate working days excluding public holidays. This is useful for project management, payroll calculations, and delivery date estimation.

How To Split And Organise Your Source Code Into Multiple Files in Python 3

How To Split And Organise Your Source Code Into Multiple Files in Python 3

Beginner/Intermediate

Every Python developer reaches a moment when a single file stops working. You’re 500 lines in, juggling functions from data processing, API calls, and database operations all in one main.py, and suddenly finding the function you need feels like a scavenger hunt. This is when organizing your code into multiple files transforms from a nice-to-have into a survival skill.

The good news? Python has a built-in system for this. You don’t need external tools or elaborate frameworks—the module and package system is already there, waiting for you to use it. Whether you’re building a command-line tool, a web application, or a data science project, splitting your code into logical, reusable pieces makes everything cleaner, faster to debug, and easier for others to understand.

In this article, we’ll explore how Python modules and packages work, walk through real examples from organizing a few scripts to building a complete project structure, and learn the best practices that professionals use every day. By the end, you’ll understand __init__.py files, import patterns, and how to structure projects that scale.

Splitting Python Code Into Multiple Files: Quick Example

Let’s start with the simplest possible example. Imagine you have a script that needs utility functions. Instead of writing everything in one file, split it into two:

# utils.py
def greet(name):
    return f"Hello, {name}!"

def add_numbers(a, b):
    return a + b

Now import and use those functions from a separate file:

# main.py
from utils import greet, add_numbers

print(greet("Alice"))
print(f"3 + 4 = {add_numbers(3, 4)}")

Output (when main.py runs):

Hello, Alice!
3 + 4 = 7

That’s it. One file defines functions, another imports and uses them. This simple pattern scales to complex projects. The rest of this article teaches you how to expand this concept into packages, subdirectories, and professional-grade structures.

What Are Modules and Packages?

Python uses two core concepts to organize code: modules and packages. Understanding the difference is crucial because they work together but serve different purposes.

A module is simply a Python file. When you create utils.py, you’ve created a module named utils. Inside it, you can define functions, classes, and variables. Other files import and use what you define. A module is the smallest unit of code organization.

A package is a directory that contains Python modules and a special __init__.py file. Packages let you organize related modules into a hierarchical structure. Think of a module as a single document and a package as a folder containing multiple documents (modules).

Here’s a quick comparison:

Concept What It Is Example How to Import
Module A single Python file utils.py import utils or from utils import func
Package A directory with __init__.py and modules mypackage/ directory import mypackage.module or from mypackage import module
Namespace Package A directory without __init__.py (Python 3.3+) mypackage/ (no __init__.py) import mypackage.module (if properly configured)

Think of it this way: a module is like a notebook, and a package is like a filing cabinet full of notebooks. When you want something from one notebook, you ask for it by name. When you want something from a notebook in the cabinet, you specify both the cabinet and the notebook.

One module per concern. Future debugging will thank you.
One module per concern. Future debugging will thank you.

Importing From Files in the Same Directory

The simplest form of code splitting happens when all your files live in the same directory. Let’s build on the earlier example and explore different import styles.

Start with a file structure like this:

# Project structure:
# project/
# ├── main.py
# └── utils.py

Now, let’s write a more realistic utility module:

# utils.py
"""Utility functions for text processing."""

def reverse_string(text):
    """Reverse a string."""
    return text[::-1]

def count_vowels(text):
    """Count vowels in a string."""
    vowels = "aeiouAEIOU"
    return sum(1 for char in text if char in vowels)

def format_title(text):
    """Format text as a title."""
    return text.title()

In your main script, you can import from utils several ways. Let’s start with importing the entire module:

# main.py - Import Approach 1: Import entire module
import utils

text = "hello world"
print(f"Original: {text}")
print(f"Reversed: {utils.reverse_string(text)}")
print(f"Vowels: {utils.count_vowels(text)}")

Output (Approach 1):

Original: hello world
Reversed: dlrow olleh
Vowels: 3

Or use selective imports:

# main.py - Import Approach 2: Import specific functions
from utils import reverse_string, count_vowels

text = "python programming"
print(f"Reversed: {reverse_string(text)}")
print(f"Vowels: {count_vowels(text)}")

Output (Approach 2):

Reversed: gnimmargorp nohtyp
Vowels: 7

When you import a module from the same directory, Python searches the current directory automatically. If your files are in different directories (like one folder for the main app and another for utilities), you’ll use packages instead.

Creating Packages With Directories

Real projects need structure. Instead of dumping all modules in one directory, you organize related modules into packages. A package is a directory with an __init__.py file inside it.

Here’s a typical project structure:

# Project structure:
# weather_app/
# ├── main.py
# ├── data/
# │   ├── __init__.py
# │   └── weather_data.py
# └── utils/
#     ├── __init__.py
#     └── formatters.py

The data and utils directories are packages because they contain __init__.py files. Now you can import from these packages:

# weather_data.py (inside data/ package)
def fetch_temperature(location):
    """Simulated weather data fetch."""
    return {"location": location, "temp_c": 22, "condition": "Sunny"}

Next, create the display formatter in the utils package. This module handles converting and presenting the raw data:

# formatters.py (inside utils/ package)
def celsius_to_fahrenheit(celsius):
    """Convert Celsius to Fahrenheit."""
    return (celsius * 9/5) + 32

def format_weather(data):
    """Format weather data for display."""
    fahrenheit = celsius_to_fahrenheit(data["temp_c"])
    return f"{data['location']}: {data['temp_c']}°C ({fahrenheit:.1f}°F), {data['condition']}"

Finally, the main script imports from both packages and ties everything together:

# main.py (at project root)
from data.weather_data import fetch_temperature
from utils.formatters import format_weather

location = "London"
weather = fetch_temperature(location)
print(format_weather(weather))

Output (when main.py runs):

London: 22°C (71.6°F), Sunny

When Python sees from data.weather_data import fetch_temperature, it looks for a directory named data with an __init__.py file, then finds the weather_data module inside it. Without the __init__.py file, Python won’t recognize data as a package, and the import will fail.

Everything in one file is everything in one bug.
Everything in one file is everything in one bug.

The __init__.py File Explained

The __init__.py file is how Python knows a directory is a package. Even if the file is empty, its presence tells Python “this directory should be treated as a package.” But __init__.py can do much more than just mark a directory.

An empty __init__.py file does nothing visible, but it still serves a purpose:

# utils/__init__.py (empty)
# This file exists, but is completely empty.
# Python still recognizes utils/ as a package.

However, you can use __init__.py to control what gets imported when someone imports your package. This is called the package’s public interface:

# math_tools/__init__.py
"""Math tools package."""

from .calculations import add, subtract, multiply
from .conversions import celsius_to_fahrenheit

__all__ = ["add", "subtract", "multiply", "celsius_to_fahrenheit"]

Purpose of this __init__.py: When someone does from math_tools import add, Python looks in __init__.py first. This file imports add from the submodule and makes it directly available.

Now users can write simpler code:

# Instead of:
from math_tools.calculations import add

# They can write:
from math_tools import add

When is __init__.py executed? The __init__.py file runs once when the package is first imported. If you put print statements or initialization code there, they execute at import time.

In Python 3.3+, you can also create namespace packages—directories without __init__.py files. However, for clarity and compatibility, most projects use __init__.py files explicitly.

Import Styles and Best Practices

Python gives you multiple ways to import code. Choosing the right style matters for readability and avoiding bugs.

Style 1: Import the entire module

import utils
result = utils.add(5, 3)

Pro: Clear where add comes from. Con: Requires the module prefix every time.

Style 2: Import specific items

from utils import add, subtract
result = add(5, 3)

Pro: Cleaner syntax, less typing. Con: Can be unclear where add comes from if not paying attention.

Style 3: Import with aliases

import numpy as np
import pandas as pd
from utils import add as add_numbers
result = add_numbers(5, 3)

Pro: Useful for long module names or preventing naming conflicts. Con: Requires that all code uses the alias.

Style 4: Import everything (AVOID THIS)

from utils import *
result = add(5, 3)  # Where does add come from? No idea!

Pro: Minimal typing. Con: Creates ambiguity, can cause naming conflicts, makes code hard to maintain.

Here’s a comparison table of common patterns:

Pattern Use Case Readability Recommendation
import module Simple modules you use throughout the code Excellent Preferred
from module import func Using a few specific items frequently Good Preferred
import module as alias Long module names, preventing conflicts Good Use with care
from module import * Interactive sessions only Poor Avoid in production

Best Practice: Use absolute imports (like from utils import func) over relative imports (like from . import func) in most cases. Absolute imports are clearer about where code comes from.

Modules click together. That's the whole point.
Modules click together. That’s the whole point.

Understanding __name__ and __main__

One of the most useful but confusing features in Python is the __name__ variable. Every Python file has a special variable called __name__ that Python sets automatically.

How __name__ works: When a file runs directly (not imported), __name__ is set to "__main__". When the file is imported as a module, __name__ is set to the module’s name.

Let’s see this in action:

# demo.py
print(f"Module name: {__name__}")

def greet():
    return "Hello from demo.py"

if __name__ == "__main__":
    print("This code only runs when demo.py is executed directly.")
    print(greet())
else:
    print("This code runs when demo.py is imported as a module.")

Output (when you run demo.py directly):

Module name: __main__
This code only runs when demo.py is executed directly.
Hello from demo.py

Output (when you import it from another file):

# another_file.py
import demo

# This prints:
# Module name: demo
# This code runs when demo.py is imported as a module.

This pattern is invaluable. It lets your module do two things: define functions for others to use AND include tests or example code that only runs when you execute the file directly.

# calculations.py
def add(a, b):
    """Add two numbers."""
    return a + b

def subtract(a, b):
    """Subtract two numbers."""
    return a - b

if __name__ == "__main__":
    # Test the functions
    print(f"5 + 3 = {add(5, 3)}")
    print(f"5 - 3 = {subtract(5, 3)}")

Output (when calculations.py runs directly):

5 + 3 = 8
5 - 3 = 2

Now, calculations.py can be imported by other files (the tests won’t run), or executed directly to test itself. This is why professional Python code always includes the if __name__ == "__main__": guard.

Understanding __file__

Another special variable is __file__, which contains the path to the current Python file. This is surprisingly useful for finding files relative to your module.

When you need to load a data file or configuration that lives next to your module, __file__ helps you find it:

# data_loader.py
import os
import json

def load_config():
    """Load configuration from a JSON file next to this module."""
    current_dir = os.path.dirname(__file__)
    config_path = os.path.join(current_dir, "config.json")

    with open(config_path) as f:
        return json.load(f)

if __name__ == "__main__":
    config = load_config()
    print(f"Loaded config: {config}")

Output (with config.json in the same directory):

Loaded config: {'api_key': 'secret123', 'timeout': 30}

Without __file__, finding relative paths becomes a nightmare. Different working directories would break your code. With __file__, your module is portable—it finds files relative to itself, not to wherever the user ran the script.

Common Pitfalls and How to Avoid Them

Even experienced developers hit these snags. Understanding them saves hours of debugging.

Pitfall 1: Circular Imports occur when Module A imports Module B, and Module B imports Module A. Python can’t resolve this circular dependency:

# module_a.py
from module_b import function_b

def function_a():
    return function_b()

And then module_b.py tries to import from module_a in return:

# module_b.py
from module_a import function_a  # Circular!

def function_b():
    return function_a()

Solution: Restructure your code so dependencies flow in one direction. Move shared code to a third module that both can import from, or delay the import until it’s actually needed inside a function.

Pitfall 2: Shadowing Built-in Modules happens when your module name matches Python’s built-in modules:

# DON'T create a file named "string.py" or "json.py" in your project.
# Python will import YOUR file instead of the built-in module.

# string.py (your file - BAD IDEA)
def process():
    return "My string module"

Solution: Use descriptive names that won’t conflict. Instead of string.py, use string_utils.py.

Pitfall 3: Confusion Between Relative and Absolute Imports happens when working with packages:

# Inside mypackage/module_a.py - RELATIVE IMPORT
from . import module_b  # Import from the same package

# Inside mypackage/module_a.py - ABSOLUTE IMPORT
from mypackage import module_b  # Full path from root

Guideline: Use absolute imports in most cases; they’re clearer and more portable. Use relative imports sparingly, only when you have a good reason.

Pitfall 4: Importing From a Directory Not in sys.path happens when Python can’t find your module:

# This fails if utils/ isn't in Python's search path
from utils import helper  # ModuleNotFoundError!

Solution: Use proper package structure with __init__.py files, or add directories to sys.path if needed (though this is a code smell).

Real-Life Example: Building a Modular Weather Dashboard

Let’s put everything together. Here’s a complete project that demonstrates proper organization:

# Project structure:
# weather_dashboard/
# ├── main.py
# ├── data/
# │   ├── __init__.py
# │   └── sources.py
# └── display/
#     ├── __init__.py
#     └── formatters.py

Let’s build each file. First, the data source module that fetches weather information:

# data/sources.py
"""Weather data sources."""

import random
from datetime import datetime

def fetch_weather(location):
    """Simulate fetching weather data."""
    return {
        "location": location,
        "temperature_c": random.randint(10, 30),
        "humidity": random.randint(40, 90),
        "condition": random.choice(["Sunny", "Cloudy", "Rainy"]),
        "timestamp": datetime.now().isoformat()
    }

The data/sources.py module handles fetching weather data (simulated here with random values). In a real project, this would call a weather API. Now let’s create the display formatter:

# display/formatters.py
"""Format weather data for display."""

def celsius_to_fahrenheit(celsius):
    """Convert temperature."""
    return (celsius * 9/5) + 32

def format_weather_report(data):
    """Create a formatted weather report."""
    fahrenheit = celsius_to_fahrenheit(data["temperature_c"])
    report = f"""
    ╔════════════════════════════════════╗
    ║  Weather Report for {data['location']:<18} ║
    ╠════════════════════════════════════╣
    ║ Temperature: {data['temperature_c']}°C ({fahrenheit:.1f}°F)          ║
    ║ Humidity: {data['humidity']}%                        ║
    ║ Condition: {data['condition']:<25} ║
    ║ Updated: {data['timestamp']:<23} ║
    ╚════════════════════════════════════╝
    """
    return report

The formatter converts temperatures and builds a clean text-based report. Next, set up the __init__.py files to control each package's public interface:

# data/__init__.py
"""Data package for weather sources."""

from .sources import fetch_weather

__all__ = ["fetch_weather"]

The data/__init__.py re-exports fetch_weather so users can import directly from the package. Do the same for the display package:

# display/__init__.py
"""Display package for formatting weather information."""

from .formatters import format_weather_report

__all__ = ["format_weather_report"]

With the __init__.py files in place, the main script can use clean, simple imports from each package:

# main.py
"""Weather Dashboard - Main Entry Point."""

from data import fetch_weather
from display import format_weather_report

def main():
    """Run the weather dashboard."""
    locations = ["London", "New York", "Tokyo", "Sydney"]

    for location in locations:
        weather = fetch_weather(location)
        report = format_weather_report(weather)
        print(report)

if __name__ == "__main__":
    main()

Output (when you run main.py):

    ╔════════════════════════════════════╗
    ║  Weather Report for London         ║
    ╠════════════════════════════════════╣
    ║ Temperature: 22°C (71.6°F)          ║
    ║ Humidity: 65%                        ║
    ║ Condition: Sunny                     ║
    ║ Updated: 2026-03-14T14:32:18.123456 ║
    ╚════════════════════════════════════╝

    ╔════════════════════════════════════╗
    ║  Weather Report for New York       ║
    ╠════════════════════════════════════╣
    ║ Temperature: 18°C (64.4°F)          ║
    ║ Humidity: 72%                        ║
    ║ Condition: Cloudy                   ║
    ║ Updated: 2026-03-14T14:32:18.234567 ║
    ╚════════════════════════════════════╝

(Output continues for Tokyo and Sydney with randomized values...)

This example demonstrates several key concepts: modules organized into packages, __init__.py files controlling the public interface, absolute imports throughout, and a clear separation of concerns (data fetching vs. display formatting). When your dashboard grows to 50 functions, this organization keeps everything manageable.

Frequently Asked Questions

Should I organize my imports in any particular order?

Yes, PEP 8 (Python's style guide) recommends grouping imports: standard library first, third-party packages second, and local modules last, with blank lines between groups. Example:

import os
import sys

import requests
import numpy as np

from myproject import utils
from myproject.data import loader

When should I use relative imports like from . import module?

Use relative imports only within packages when you have a good reason (like avoiding name collisions). For most projects, absolute imports are clearer. Relative imports can break if your package structure changes or if someone runs the module in unexpected ways.

Can I leave __init__.py completely empty?

Yes, an empty __init__.py is perfectly valid and commonly used. Python just needs the file to exist to recognize a directory as a package. However, it's often helpful to put documentation, import statements, or initialization code there.

Are modules imported multiple times if I import them in multiple files?

No. Python caches imported modules in sys.modules. The first import runs the module's code, but subsequent imports return the cached version. This is efficient and prevents re-execution.

I have circular dependencies; how do I really fix them?

The best solution is restructuring: move shared code to a separate module that both modules import. If that's not feasible, delay the import until inside the function that needs it (import at the bottom of the function, not at the top). Example:

def my_function():
    from another_module import some_func  # Import only when needed
    return some_func()

How do I properly import my modules when running tests from a different directory?

Use absolute imports with your project as the root. If you have a project structure with packages, install your project in development mode using pip install -e . (with a setup.py), or ensure your test runner is aware of the project root.

Conclusion

Splitting Python code into multiple files is not about complexity—it's about clarity. A well-organized project with modules and packages is easier to understand, test, extend, and collaborate on. The patterns you've learned here—modules, packages, __init__.py, import styles, and the __name__ variable—are the foundation of every professional Python project, from small scripts to massive frameworks.

Start with simple modules in the same directory. As your project grows, organize them into packages. Use __init__.py to control your public interface. Follow import best practices. Your future self (and your teammates) will thank you. For more details on Python's module system, check the official Python documentation on modules and packages.

How To Use ConfigParser For Configuration Files In Python 3

How To Use ConfigParser For Configuration Files In Python 3

Intermediate

Putting parameters in configuration files can take some extra effort at the start, but then can save you a lot of time and heartache in the future. We are all tempted to simply hardcode parameters directly into our code as we save precious time when we write code, but then doing this properly can take extra effort. Some of us at least create constants or store parameters in a variable, while others store them in a class variable to keep this even cleaner. Arguably the best option is store these in a configuration file. In this article you’ll learn the steps compulsory to use configuration files in python 3. It will be strictly according to the official documentation of python 3.

ConfigParser is the class used to implement configuration files in python 3. The main function of using these files is to write python programs which can easily be modified by end users easily. The main aspect of this article is to know about the complete implementation of configuration files. We will cover the three main aspects in this article which are Setup, File format and Basic API.

Introduction to Python 3 Configuration Files

Configuration files can play a vital role in any program and its management. One of the popular approaches to separate code from configuration is to store these files in YAML, JSON or INI and not in .py format. One reason that .py files are not used is that Python 3 can be slower when it comes to reloading. You would need to restart the whole program if you stored your config in a python .py file. Also, the end user can modify the code at will if it is in .py format. Configuration files make it easier to modify or change the code. The data stored in configuration is to have separation so that the programmer can focus on code development and ensure that is clean as possible and the user only needs to touch the configuration file.

Setup of Python 3 ConfigParser

The class used to create configuration files is ConfigParser. This is a part of the standard python 3 library so no need to do any pip installation. We have to import it: “import configparser” to use it or there is another way of using it, it will work in both python2 and python 3, which is: 

    import configparser

File Format of configuration file

One convention that is used for the file format is to use the extension .ini (short for initial or initiation) but you can use the configuration based on your own or on clients preferences. There are different parts of configuration files. 

  • A configuration file consists of one or more sections. 
  • The section names are written in these delimiters [section name]. 
  • The concept is similar to mapping. It consists of key-value pairs meaning there is a name of the configuration item (“key”) and the other the actual value of the configuration (“value”)
  • Two operators are used to initialize or separate key-value pair assignment operator (=) or colon operator (:).
  • You can even put in a comment using the # or ; prefix.

Example: 

[default]

host = 192.168.1.1
port = 31

username = admin

password = admin

[database]

#database related configuration files
port = 22
forwardx11 = no

name = db_test

In the above configuration file example, we have two sections first is [default]  and second is [database]. Each section has its own key-value pairs/entries like username = admin and name = db_test. So all of the key-value pairs belong to a given section, so it is easier to organise your configuration files. Finally the sentence with a prefix of # is for commenting

Reading the configuration file from python code

Now, we will talk about the method to read from the config file. As mentioned earlier, ConfigParser is the module/class used to create configuration files. First, ConfigParser object has to be initialized: config = configparser.ConfigParser(); The following are functions:

Initialization of ConfigParser

You can can initiate the configuration file with the following syntax. Here the variable “config” will contain all the values

config = configparser.ConfigParser()

Write to a Configuration file with ConfigParser

Although normally you normally edit to a configuration file in a text editor by hand, there are times where you want to programmatically write to a config file. For example, this could be to create a default config file which a user can then use as a basis to change or edit. You may also want to over-ride a config entry (after confirming with the user) that is erroneous.

Once the object is initialised, we can now write in it. There are ways through which we can initialize the section to write in the config file. We are going use the example mentioned above in file format. Let’s initialize the default section using dictionary.

Example: 

config['default'] = {
 "host" : "192.168.1.1",
 "port" : "22",
 "username" : "username",
 "password" : "password"
 }

Here, “default” is the name of the section (the part in the actual configuration file that had the square “[” and “]” brackets) and curly braces denote the start and end of a dictionary. Inside the dictionary are key-value pairs i.e. “host” is the key and “192.168.1.1” is the value separated by colon “:”

Now, let’s initialize the database section using empty dictionary and add the key-value pairs line by line. 

Example:

 config['database'] = {}
 config['database']['port'] = "22"
 config['database']['forwardx11'] = "no"
 config['database']['name'] = "db_test"

Here, “database” is the name of the section and curly braces denote the same start and end of a dictionary. In this case, the dictionary is empty. Key-value pairs i.e. “port” is the key and “22” is the value separated by colon “=.” This method provides a lot more flexibility. 

Here’s the full code so far:

import configparser

config = configparser.ConfigParser()
config['default'] = {
 "host" : "192.168.1.1",
 "port" : "22",
 "username" : "username",
 "password" : "password"
 }

config['database'] = {}
config['database']['port'] = "22"
config['database']['forwardx11'] = "no"
config['database']['name'] = "db_test"

with open('test.ini', 'w') as configfile:
      config.write(configfile);

After initializing the sections in config, you can now write it to a config file:

with open('test.ini', 'w') as configfile:
      config.write(configfile);

Now, you will be able to see the file named test.ini created.

Read config from the config file using ConfigParser

The next step is to read the file which you just have created.

  • The config file can be read by using read() method: config.read(‘test.ini’). This will read the test.ini file which you just created.
  • If you want to print just the sections available in configuration file, method sections() can be used: config.sections().
  • Next is getting the value of any key stored in the section. config[‘database’][‘name’] 

This will give you the value which is “db_test” of the key called “name” stored in data_base section. 

The following code will print out all the values stored against the keys in the default section using a for loop.

for key in config['default']:
    print(config['default'][key])

Code:

Output:

Changing the datatype of the configuration value from ConfigParser

The datatype of the object of ConfigParser is string by default. This is fine for most situations, but then suppose you want to get a true/false value instead, or a number value to do maths operations. For this the string default may not work. We can typecast/covert the datatype of the object of configparser or the datatype of keys of section into any other type such as integer, float etc. In order to change the datatype of object, you have to covert it manually or by using getter methods. The best and the preferred way is to use getter methods.

There are three getter methods:

  • getint();
  • getfloat();
  • getboolean();

Example: config['default'].getint('port')

getint() will covert the datatype of port key of section “default” into “integer”. If you use the typeof(); method on port then it will show integer type now.

There is another way of doing it:

Example: config.getboolean('data_base', 'forwardx11')

In this way, config file is invoking the getboolean() method and its takin two parameters as argument. The first is the name of the section and the other is the key whole value’s type will be changed.

What to do if a value is not available from a configfile

A fallback result can also be obtained. Fallback is the result obtained when the key or section we want to get isn’t available.

Example: config.get('default', 'database', fallback='not_database')  

In this case, not_database will be returned if the “database” key isn’t available or the section default is not found.

Conclusion

We come to know about the setup i.e. importing the ConfigParser first to create configuration files. Next section was about the file format. There you can check about the basic syntax of creating a configuration file. It consists of sections and key-value pairs.

We played with the data types of keys in default and data_base sections. We can change datatypes using getter methods. Last but not the least, we studied about the basic api like write, read and about fallback.

Using configuration files is not difficult and can save a lot of time. So in your next coding work, take the extra few minutes to create a configuration file instead of hardcoding.

Full Code: ConfigParser Example Code

import configparser

config = configparser.ConfigParser() 

#Set up default item for hosts using dictionary
config['default'] = {"host" : "192.168.1.1",
                     "port" : "22",
                     "username" : "username",
                     "password" : "password" }

#setup config item bytes
config['database'] = {}
config['database']['port'] = "22"
config['database']['forwardx11'] = "no"
config['database']['name'] = "db_test"

#Write default file
with open('test.ini', 'w') as configfile:
	config.write(configfile)

#Open the file again to try to read it
config.read('test.ini')

#Print the sections
print(config.sections())

print( config['database']['name'] )

#Print each key pair
for key in config['default']:
	print(config['default'][key])

#print the type of integer value
print (type (config['default'].getint('port')))

print( config.getboolean('database', 'forwardx11') )

#Print default value
print( config.get('default', 'databaseabc', fallback='not_database') )

Output:

Reference

https://docs.python.org/3/library/configparser.html

Want to see more useful tips?

How To Use Python tox for Multi-Environment Test Automation

How To Use Python tox for Multi-Environment Test Automation

Intermediate

You have a Python library that works perfectly on your machine running Python 3.11. Then a user files an issue — it crashes on 3.9. Another user is on 3.12. You fix the 3.9 bug and accidentally break 3.11 compatibility. Sound familiar? Testing across multiple Python versions manually means juggling virtual environments, remembering which one to activate, and running your test suite in each — a process so tedious it simply doesn’t happen. Bugs slip through. Users get hurt.

tox solves this by automating multi-environment testing in a single command. It reads a configuration file that lists which Python versions and dependencies to test against, creates isolated virtual environments for each one, installs your package into them, and runs your test suite in every environment — reporting failures per environment. One command, all Python versions, zero manual juggling.

In this article you will learn how to install and configure tox, write a tox.ini file from scratch, run tests across Python 3.9 through 3.12, pass environment variables and extra dependencies, run only a subset of environments, integrate tox with pytest and coverage, use the modern pyproject.toml configuration style, and build a real-world tox setup for a small utility library. By the end you will have a test automation setup that works identically on your laptop and in CI.

Running tox: Quick Example

Before diving into configuration details, here is a minimal tox setup that runs pytest across two Python versions. Create a small project directory, add a module, a test, and a tox.ini file, then run tox.

# project layout
# mylib/
# ├── mylib/
# │   └── utils.py
# ├── tests/
# │   └── test_utils.py
# ├── tox.ini
# └── setup.py (or pyproject.toml)

# mylib/utils.py
def add(a, b):
    return a + b

def greet(name):
    return f"Hello, {name}!"
# tests/test_utils.py
from mylib.utils import add, greet

def test_add():
    assert add(2, 3) == 5

def test_greet():
    assert greet("Alice") == "Hello, Alice!"
# tox.ini
[tox]
envlist = py39, py311

[testenv]
deps = pytest
commands = pytest tests/
# Run from the project root
$ tox
py39 create: /home/user/mylib/.tox/py39
py39 installdeps: pytest
py39 inst: /home/user/mylib/.tox/.tmp/package/1/mylib-0.1.0.tar.gz
py39 run-test: pytest tests/
============================= test session starts ==============================
platform linux -- Python 3.9.18, pytest-8.2.0
collected 2 items
tests/test_utils.py ..                                                   [100%]
============================== 2 passed in 0.12s ===============================
py311 create: /home/user/mylib/.tox/py311
py311 installdeps: pytest
py311 inst: /home/user/mylib/.tox/.tmp/package/1/mylib-0.1.0.tar.gz
py311 run-test: pytest tests/
============================= test session starts ==============================
platform linux -- Python 3.11.8, pytest-8.2.0
collected 2 items
tests/test_utils.py ..                                                   [100%]
============================== 2 passed in 0.12s ===============================
___________________________________ summary ___________________________________
  py39: commands succeeded
  py311: commands succeeded
  congratulations :)

Tox created two completely separate virtual environments — one for Python 3.9, one for 3.11 — installed your package and pytest into each, ran the test suite, and reported results. The key parts are envlist (which environments to run), deps (what to install), and commands (what to execute). Everything below digs into these and more.

What Is tox and Why Use It?

Tox is a generic virtualenv management and test command-line tool. At its core it does three things: creates isolated virtual environments, installs specified dependencies into each, and runs your commands inside them. It was originally designed for testing Python packages across multiple interpreter versions, but it handles any environment-based task — linting, type checking, building docs, running formatters.

The key insight is that tox installs your package into each environment from a source distribution, the same way a user would install it with pip install mylib. This means your tests run against the installed package, not the raw source files. If you forget to list a dependency in your setup.py or pyproject.toml, tox will catch it — the test environment simply won’t have that import available.

ApproachWhat it solvesWhat it misses
Manually activate venvsIsolationRepeatability, automation
pytest onlyTest runningMulti-version, missing deps detection
toxMulti-version + isolation + automationRequires Python versions to be installed
tox + pyenvEverythingSlightly more setup upfront

Install tox into your system Python or a dedicated virtual environment — do not install it inside the project venv you are testing, as this creates circular dependency problems:

# install_tox.sh
pip install tox --break-system-packages
# or into a dedicated tools venv
python -m venv ~/.venvs/tox && ~/.venvs/tox/bin/pip install tox
tox --version
tox 4.15.0 from /home/user/.local/lib/python3.11/site-packages/tox/__init__.py

Tox 4 (released 2022) changed several configuration defaults from tox 3. This article uses tox 4 conventions throughout. The most important difference: tox 4 no longer requires a setup.py — it works with pyproject.toml out of the box.

Debug Dee at futuristic control panel configuring test environments
tox spins up a fresh venv for each envlist entry. No shared state, no surprises.

The tox.ini Configuration File

The tox.ini file lives in your project root alongside setup.py or pyproject.toml. It uses INI syntax with sections that map to environments. Understanding the full set of options unlocks tox’s real power.

[tox] — Global Settings

# tox.ini — full global section
[tox]
# Environments to run when you type just "tox"
envlist = py39, py310, py311, py312

# Minimum tox version required
minversion = 4.0

# Skip missing Python interpreters instead of failing
skip_missing_interpreters = true

# Where to store environment data (default: {toxinidir}/.tox)
toxworkdir = {toxinidir}/.tox

skip_missing_interpreters = true is extremely useful in CI: if you have only Python 3.11 and 3.12 installed, tox skips the 3.9 and 3.10 environments with a warning rather than failing the build. On a developer laptop you might have only one or two Python versions — this setting keeps things friendly. Set it to false in CI if you want strict enforcement.

[testenv] — The Base Environment

The [testenv] section defines defaults inherited by all environments. Any specific environment like [testenv:py39] inherits everything from [testenv] and can override individual values.

# tox.ini — complete testenv section
[tox]
envlist = py39, py311, py312

[testenv]
# Dependencies to install (separate from your package's requirements)
deps =
    pytest>=8.0
    pytest-cov
    requests-mock

# The command to run
commands =
    pytest {posargs:tests/} --cov=mylib --cov-report=term-missing

# Environment variables to pass through or set
setenv =
    PYTHONPATH = {toxinidir}/src
    APP_ENV = testing

# Pass these variables from your shell into the environment
passenv =
    HOME
    CI
    GITHUB_*

# Install the package itself (default: true)
# Set to false for environments that don't need it (e.g., linting)
package = wheel
# Run tox and pass extra pytest args via posargs
$ tox -- -k test_add -v
py39 run-test: pytest tests/ -k test_add -v --cov=mylib --cov-report=term-missing
...
PASSED tests/test_utils.py::test_add

The {posargs} placeholder is how you forward extra arguments to the underlying command. Everything after -- on the tox command line becomes {posargs}. This lets you run a single test or pass -x to stop on first failure without changing tox.ini.

Per-Environment Overrides

Sometimes a specific Python version needs different dependencies or commands. Define a named environment section to override just those values:

# tox.ini — per-environment overrides
[tox]
envlist = py39, py311, py312, lint, typecheck

[testenv]
deps =
    pytest
    pytest-cov
commands = pytest tests/ --cov=mylib

# py39 needs a backport not required on 3.11+
[testenv:py39]
deps =
    pytest
    pytest-cov
    importlib-metadata>=4.0

# Linting environment — no package install needed
[testenv:lint]
package = skip
deps =
    ruff
    black
commands =
    ruff check mylib/ tests/
    black --check mylib/ tests/

# Type checking
[testenv:typecheck]
package = skip
deps = mypy
commands = mypy mylib/ --strict
# Run only the lint environment
$ tox -e lint

# Run multiple specific environments
$ tox -e py311,typecheck

# List all configured environments
$ tox list
default environments:
py39        -> [no description]
py311       -> [no description]
py312       -> [no description]
lint        -> [no description]
typecheck   -> [no description]

The package = skip setting tells tox not to build and install your package for that environment. This speeds up linting and type checking runs significantly since they only need the source files, not a full package installation.

API Alice with clipboard celebrating all tests passing
tox.ini: four lines to test against Python 3.9, 3.10, 3.11, and 3.12 simultaneously.

Integrating pytest and Coverage

Tox and pytest work seamlessly together. The most useful addition is coverage reporting — knowing not just that your tests pass, but that they actually exercise your code.

# tox.ini — pytest + coverage setup
[tox]
envlist = py311, py312

[testenv]
deps =
    pytest>=8.0
    pytest-cov

commands =
    pytest tests/ \
        --cov=mylib \
        --cov-report=term-missing \
        --cov-report=html:htmlcov \
        --cov-fail-under=80

# Separate environment to combine coverage from all Python versions
[testenv:coverage-report]
package = skip
deps = coverage[toml]
commands =
    coverage combine
    coverage report --fail-under=80
    coverage html

To combine coverage data across all Python version environments, add --cov-append to your pytest command and ensure all environments write to the same .coverage file:

# tox.ini — combined coverage across Python versions
[testenv]
deps =
    pytest
    pytest-cov

setenv =
    COVERAGE_FILE = {toxinidir}/.coverage.{envname}

commands =
    pytest tests/ --cov=mylib --cov-report= --cov-append

[testenv:coverage-report]
package = skip
deps = coverage
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage

commands =
    coverage combine .coverage.py311 .coverage.py312
    coverage report --show-missing
$ tox -e py311,py312,coverage-report
Name                 Stmts   Miss  Cover   Missing
--------------------------------------------------
mylib/utils.py          12      1    92%   45
mylib/parser.py         30      4    87%   22-25
--------------------------------------------------
TOTAL                   42      5    88%

The combined coverage report aggregates line hit data from every Python version. A line that only executes under Python 3.9’s sys.version_info branch will now be properly credited, giving you a truer picture of what the test suite actually exercises.

Using pyproject.toml Instead of tox.ini

Modern Python projects often consolidate all tool configuration into pyproject.toml. Tox 4 supports this natively — put your tox configuration in the [tool.tox] table and delete tox.ini:

# pyproject.toml — tox config inside the project file
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "mylib"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["requests>=2.28"]

[tool.tox]
legacy_tox_ini = """
[tox]
envlist = py39, py311, py312
skip_missing_interpreters = true

[testenv]
deps =
    pytest
    pytest-cov
commands = pytest tests/ --cov=mylib --cov-report=term-missing

[testenv:lint]
package = skip
deps = ruff
commands = ruff check mylib/ tests/
"""

The legacy_tox_ini key holds an INI string — the same syntax as a standalone tox.ini — inside the TOML file. Tox reads it transparently. There is also a native TOML-based configuration format (available in tox 4.2+) that avoids the embedded string, but legacy_tox_ini is the most compatible approach for projects that also need to support tox 3 users.

Environment Variables and Secrets

Test environments often need credentials or configuration that should not be committed to source control. Tox provides setenv for constants and passenv for forwarding values from your shell:

# tox.ini — handling secrets and config
[testenv]
deps = pytest

# Set constants needed by tests
setenv =
    APP_ENV = testing
    DATABASE_URL = sqlite:///test.db
    LOG_LEVEL = WARNING

# Pass secrets from the shell environment
passenv =
    AWS_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY
    GITHUB_TOKEN
    CI
    CODECOV_TOKEN

commands = pytest tests/
# In your shell before running tox:
export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
tox -e py311
# tests/test_api.py — accessing the passed variable
import os

def test_token_available():
    token = os.environ.get("GITHUB_TOKEN")
    # In CI this will be the real token; locally it must be set
    assert token is not None, "GITHUB_TOKEN not set in environment"

Tox deliberately strips most environment variables from the test environment by default. This prevents hidden dependencies on your local shell configuration — the same isolation that makes tox results trustworthy also means you must explicitly declare every environment variable your tests need. Use passenv = * only as a last resort during debugging; it defeats tox’s isolation guarantee.

Running tox in GitHub Actions CI

The real payoff of a tox configuration is running it automatically on every push. GitHub Actions has first-class support for matrix builds across Python versions:

# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.9", "3.10", "3.11", "3.12"]

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install tox
        run: pip install tox tox-gh-actions

      - name: Run tox
        run: tox
# tox.ini — gh-actions section maps matrix Python to tox env
[tox]
envlist = py39, py310, py311, py312, lint

[gh-actions]
python =
    3.9: py39
    3.10: py310
    3.11: py311
    3.12: py312

[testenv]
deps = pytest
commands = pytest tests/

[testenv:lint]
package = skip
deps = ruff
commands = ruff check mylib/

The tox-gh-actions plugin reads the GITHUB_ACTIONS and PYTHON_VERSION environment variables set by GitHub’s matrix runner and automatically selects the matching tox environment. When the matrix job runs Python 3.11, tox automatically runs only the py311 environment rather than all of them. This is more efficient than running the full envlist on every matrix node.

Real-Life Example: Testing a String Utilities Library

# string_utils_project/
# ├── strutils/
# │   ├── __init__.py
# │   ├── transform.py
# │   └── validate.py
# ├── tests/
# │   ├── test_transform.py
# │   └── test_validate.py
# ├── pyproject.toml
# └── tox.ini
# strutils/transform.py
def slugify(text: str) -> str:
    """Convert a string to a URL-friendly slug."""
    import re
    text = text.lower().strip()
    text = re.sub(r'[^\w\s-]', '', text)
    text = re.sub(r'[\s_-]+', '-', text)
    text = re.sub(r'^-+|-+$', '', text)
    return text

def truncate(text: str, max_length: int, suffix: str = "...") -> str:
    """Truncate text to max_length characters."""
    if len(text) <= max_length:
        return text
    return text[:max_length - len(suffix)] + suffix
# strutils/validate.py
import re

def is_valid_email(email: str) -> bool:
    """Basic email format validation."""
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.match(pattern, email))

def is_strong_password(password: str) -> bool:
    """Check password has 8+ chars, upper, lower, digit, special."""
    if len(password) < 8:
        return False
    has_upper = any(c.isupper() for c in password)
    has_lower = any(c.islower() for c in password)
    has_digit = any(c.isdigit() for c in password)
    has_special = any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?' for c in password)
    return all([has_upper, has_lower, has_digit, has_special])
# tests/test_transform.py
import pytest
from strutils.transform import slugify, truncate

@pytest.mark.parametrize("text,expected", [
    ("Hello World", "hello-world"),
    ("  Python 3.11  ", "python-311"),
    ("café & bistro!", "caf-bistro"),
])
def test_slugify(text, expected):
    assert slugify(text) == expected

def test_truncate_short():
    assert truncate("hello", 10) == "hello"

def test_truncate_long():
    assert truncate("hello world", 8) == "hello..."

def test_truncate_custom_suffix():
    assert truncate("hello world", 8, suffix="…") == "hello w…"
# tests/test_validate.py
import pytest
from strutils.validate import is_valid_email, is_strong_password

@pytest.mark.parametrize("email,valid", [
    ("user@example.com", True),
    ("bad-email", False),
    ("missing@tld.", False),
    ("ok+tag@sub.domain.org", True),
])
def test_is_valid_email(email, valid):
    assert is_valid_email(email) == valid

def test_strong_password():
    assert is_strong_password("Secure@123") is True
    assert is_strong_password("weakpass") is False
    assert is_strong_password("NoSpecial1") is False
# tox.ini — full production config for strutils
[tox]
envlist = py39, py310, py311, py312, lint, typecheck, coverage-report
skip_missing_interpreters = true

[gh-actions]
python =
    3.9: py39
    3.10: py310
    3.11: py311
    3.12: py312

[testenv]
deps =
    pytest>=8.0
    pytest-cov
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage.{envname}
commands =
    pytest tests/ --cov=strutils --cov-report=

[testenv:lint]
package = skip
deps = ruff
commands = ruff check strutils/ tests/

[testenv:typecheck]
package = skip
deps = mypy
commands = mypy strutils/ --strict

[testenv:coverage-report]
package = skip
deps = coverage
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage
commands =
    coverage combine
    coverage report --show-missing --fail-under=90
$ tox -e py311,lint,coverage-report
py311 create: .tox/py311
py311 run-test: pytest tests/ --cov=strutils --cov-report=
============================= test session starts ==============================
collected 11 items
tests/test_transform.py ....                                             [100%]
tests/test_validate.py .......                                           [100%]
============================== 11 passed in 0.18s ==============================

lint run-test: ruff check strutils/ tests/
All checks passed!

coverage-report run-test: coverage combine && coverage report --show-missing --fail-under=90
Name                      Stmts   Miss  Cover   Missing
-------------------------------------------------------
strutils/transform.py        12      0   100%
strutils/validate.py         10      0   100%
-------------------------------------------------------
TOTAL                        22      0   100%
___________________________________ summary ___________________________________
  py311: commands succeeded
  lint: commands succeeded
  coverage-report: commands succeeded
  congratulations :)

This configuration gives you a complete quality gate: unit tests across Python versions, linting with ruff, strict type checking with mypy, and a combined coverage report with a minimum threshold. Add this to GitHub Actions with the matrix config shown earlier and every pull request will automatically validate against all supported Python versions before merging.

Frequently Asked Questions

What happens if a Python version isn't installed?

With skip_missing_interpreters = true, tox prints a warning and marks that environment as skipped rather than failing. The final summary shows SKIPPED for missing interpreters and only fails if an installed environment's tests actually fail. Without that setting, tox exits with an error if any interpreter in envlist cannot be found. On developer machines, use skip_missing_interpreters = true; in CI, use false to enforce that all required versions are present.

When does tox recreate environments?

Tox caches virtual environments in the .tox/ directory and reuses them across runs for speed. It recreates an environment only when deps change, the Python interpreter changes, or you pass --recreate (or -r). If your tests behave strangely after a dependency upgrade, run tox -r to force a clean rebuild. You can also delete the entire .tox/ directory — tox will rebuild everything from scratch on the next run.

How do I run a single test with tox?

Use the {posargs} placeholder in your commands and pass arguments after -- on the command line. For example, tox -e py311 -- tests/test_transform.py::test_slugify -v runs only that one test with verbose output. The -- separator tells tox everything after it should be forwarded as {posargs} rather than interpreted as tox options. This is the cleanest way to do rapid test-driven development while keeping tox's isolation.

Should I pin dependency versions in tox.ini?

For library projects, leave deps unpinned (e.g., pytest>=8.0) so tox installs the latest compatible versions — this surfaces breakage from upstream changes early. For application projects where you want reproducible builds, pin exact versions (e.g., pytest==8.2.1) or use a requirements-test.txt file referenced with deps = -r requirements-test.txt. The -r syntax in tox deps works the same way as pip install -r.

What changed between tox 3 and tox 4?

Tox 4 dropped Python 2 support and changed several defaults: isolated_build = true is now the default, meaning tox uses PEP 517/518 build systems instead of python setup.py install. The [gh-actions] section syntax changed slightly. The package option replaces skip_install. Most importantly, tox 4 requires a valid pyproject.toml or setup.py — if your project has neither, add a minimal pyproject.toml with [build-system]. Check the official upgrade guide when migrating.

Conclusion

Tox turns multi-environment testing from a manual, error-prone process into a single tox command. You learned how to write a tox.ini with envlist, deps, and commands; create per-environment overrides for linting, type checking, and version-specific dependencies; integrate pytest coverage across Python versions; use setenv and passenv for environment variables; configure tox-gh-actions for CI matrix builds; and build a complete test pipeline for a real utility library.

Extend the string utilities example by adding a docs environment that builds Sphinx documentation, a security environment that runs bandit, or a benchmark environment that runs pytest-benchmark. Each new environment is just a few lines in tox.ini. Official documentation: tox.wiki.

Frequently Asked Questions

What is ConfigParser used for in Python?

ConfigParser is a built-in Python module for reading and writing configuration files in INI format. It handles settings organized into sections with key-value pairs, making it easy to store and retrieve application configuration without hardcoding values.

What format does ConfigParser use?

ConfigParser uses the INI file format with sections in square brackets ([section]), followed by key-value pairs using = or : as delimiters. Comments start with # or ;. There is always a [DEFAULT] section for fallback values.

How do I read a config file with ConfigParser?

Create a ConfigParser() instance, call config.read('filename.ini'), then access values with config['section']['key'] or config.get('section', 'key'). Use getint(), getfloat(), or getboolean() for type conversion.

Can ConfigParser handle nested sections?

No, ConfigParser does not support nested sections natively. For nested configuration structures, consider using TOML (tomllib in Python 3.11+), YAML (PyYAML), or JSON configuration files instead.

What is the difference between ConfigParser and JSON for configuration?

ConfigParser uses human-friendly INI format with sections and is ideal for simple settings. JSON supports nested structures and lists but lacks comments. ConfigParser has built-in type conversion methods and a DEFAULT section for fallback values, while JSON requires manual type handling.

Simple Guide To Markov Chain Text Generator in Python 3

Simple Guide To Markov Chain Text Generator in Python 3

Advanced

Making computer generated text mimic human speech is fascinating and actually not that difficult for an effect that is sometimes convincing, but certainly entertaining. Markov Chain’s is one way to do this. It works by generating new text based on historical texts where the original sequencing of neighboring words (or groups of words) is used to generate meaningful sentences. Read the below guide on how to code a Markov Chain text generator (code example in python) including explanation of the concept.

What’s really interesting, is that you can take historical texts of a person, then generate new sentences which can sound similar to the way that person speaks. Alternatively, you can combine texts from two different people and get a mixed “voice”.

I played around this with texts of speeches from two great presidents:

Image of courtesy of screentv.com

What my Markov Chain generated which was “trained” using the combination of texts from Obama speeches and Bartlet scripts, is as follows:

  • ‘Can I burn my mother in North Carolina for giving us a great night planned.’
  • ‘And so going forward, I believe that we can build a bomb into their church.’
  • ‘’Charlie, my father had grown up in the Situation Room every time I came in.’’
  • ‘This campaign must be ballistic.’,

What is a Markov Chain in the context of a text generation?

For a more technical explanation, I think you can find plenty of resources out there. In simple terms, it is an algorithm which is used to generate a new outcome from a weighted list of words based on historical texts. Now that’s rather abstract. In more practical terms, in the scenario for text generation, it is a way to use historical texts, chop it up into individual words (or sets of words), and then randomly chose a given word then randomly chose the next likely words based on historical sequences. For example:

An example that shows the original text (A), the dictionary that gets generated of the words (B), and a sample text (C ) that was generated from randomly selecting words with selected words highlighted in red. The numbers in brackets indicates the occurrences of that word in the original word.

This doesn’t just apply in text as well (although one of the most popular applications is in your smart phone where there’s predictive text), it can be used for any scenario where you use historical information to define next steps for a given state. For example, you could codify a given stock market pattern (such as the % daily changes for the last 30 days), then use that to see historically what was the likely next day outcome (example only.. I’m very doubtful how effective it would be).

Why are Markov Chain Text Generators so fun?

I’ve always wanted to build a text generator as it’s just an awesome way to see how you could mimic intelligence using a very cheap shortcut. You’ll see the algorithm below, and it is super simple. The other fact is that, like above example, you can use it to mix the ‘voice’ from two different persons and see the outcome.

How does the Markov Chain Text Generator work?

There are two phases for text generation with Markov Chains. There’s first the ‘dictionary build phase’ which involves gathering the historical texts, and then generating a dictionary with the key being a given word in a sentence, and then having the resultant being the natural follow-up words.

Here you can see the original sentences were broken down into words and the included the subsequent words with a counter to indicate number of occurrences. Note that full-stops are also included.

The second is the execution, where you start from a given word, then use that word to see what the next word would be in a probabilistic way. For example:

Traversing the dictionary to generate text

Now, there are some tricks which you need to be mindful of ( I found this out the hard way):

  • You can’t start from any random word — if you do, then you’ll get sentences like this: “ate the cat.” . You have to keep track of “starting words” to keep things simple — hence you can have: “John ate the cat”.
  • Don’t ignore punctuation— if you do remove punctuation, you’ll get sentence like this: “The dog barked at John cat”. Instead keep them there so that you can have a better chance to have a more realistic sentence — i.e. “The dog barked at John’s ca
  • End on a full-stop word. When you go through and start from a word, then find the next word, then find the next word and so on, you can continue until you reach a specified length, but then you’ll end up stopping in mid-sentence such as this: “The cat ate John’s”. Instead, simply end when you have a word that has a full stop (another reason not to remove the punctuation) — i.e. “The cat ate John’s boots.

Markov Chain Example Code Source texts

I played around with different texts including: Eddie Murphy stand-up routines, Donald Trump tweets, Obama speeches, and Jed Bartlet dialogue. You can find the the markov chain example source text here. It’s great to use one source and then generate the dictionary, but then you can mix and match and use two sources (e.g. Obama and Bartlet) and then create the one dictionary file. Then when you traverse the dictionary you get the both voices.

It is important to make sure that you can balance the text — e.g. if you had a 8000 text from Obama and only 1000 text from Eddie Murphy, it’s likely that you would see more of the Obama words. Of course, when you build the dictionary, you can also add some artificial weighting towards the lighter text source to balance things out.

Markov Chain Summary

The Markov Chain text generator is not perfect — you’ll see when you create your own, that some text is just gibberish. The more text that you have the better. Secondly, using single words is not helpful in the dictionary — you should use groups of 2–3 words. The actual number depends on how much historical text you have.

You can find all the python code, source texts and Markov Chain python example code here. Good luck!

Subscribe to our newsletter

How To Use Python tox for Multi-Environment Test Automation

How To Use Python tox for Multi-Environment Test Automation

Intermediate

You have a Python library that works perfectly on your machine running Python 3.11. Then a user files an issue — it crashes on 3.9. Another user is on 3.12. You fix the 3.9 bug and accidentally break 3.11 compatibility. Sound familiar? Testing across multiple Python versions manually means juggling virtual environments, remembering which one to activate, and running your test suite in each — a process so tedious it simply doesn’t happen. Bugs slip through. Users get hurt.

tox solves this by automating multi-environment testing in a single command. It reads a configuration file that lists which Python versions and dependencies to test against, creates isolated virtual environments for each one, installs your package into them, and runs your test suite in every environment — reporting failures per environment. One command, all Python versions, zero manual juggling.

In this article you will learn how to install and configure tox, write a tox.ini file from scratch, run tests across Python 3.9 through 3.12, pass environment variables and extra dependencies, run only a subset of environments, integrate tox with pytest and coverage, use the modern pyproject.toml configuration style, and build a real-world tox setup for a small utility library. By the end you will have a test automation setup that works identically on your laptop and in CI.

Running tox: Quick Example

Before diving into configuration details, here is a minimal tox setup that runs pytest across two Python versions. Create a small project directory, add a module, a test, and a tox.ini file, then run tox.

# project layout
# mylib/
# ├── mylib/
# │   └── utils.py
# ├── tests/
# │   └── test_utils.py
# ├── tox.ini
# └── setup.py (or pyproject.toml)

# mylib/utils.py
def add(a, b):
    return a + b

def greet(name):
    return f"Hello, {name}!"
# tests/test_utils.py
from mylib.utils import add, greet

def test_add():
    assert add(2, 3) == 5

def test_greet():
    assert greet("Alice") == "Hello, Alice!"
# tox.ini
[tox]
envlist = py39, py311

[testenv]
deps = pytest
commands = pytest tests/
# Run from the project root
$ tox
py39 create: /home/user/mylib/.tox/py39
py39 installdeps: pytest
py39 inst: /home/user/mylib/.tox/.tmp/package/1/mylib-0.1.0.tar.gz
py39 run-test: pytest tests/
============================= test session starts ==============================
platform linux -- Python 3.9.18, pytest-8.2.0
collected 2 items
tests/test_utils.py ..                                                   [100%]
============================== 2 passed in 0.12s ===============================
py311 create: /home/user/mylib/.tox/py311
py311 installdeps: pytest
py311 inst: /home/user/mylib/.tox/.tmp/package/1/mylib-0.1.0.tar.gz
py311 run-test: pytest tests/
============================= test session starts ==============================
platform linux -- Python 3.11.8, pytest-8.2.0
collected 2 items
tests/test_utils.py ..                                                   [100%]
============================== 2 passed in 0.12s ===============================
___________________________________ summary ___________________________________
  py39: commands succeeded
  py311: commands succeeded
  congratulations :)

Tox created two completely separate virtual environments — one for Python 3.9, one for 3.11 — installed your package and pytest into each, ran the test suite, and reported results. The key parts are envlist (which environments to run), deps (what to install), and commands (what to execute). Everything below digs into these and more.

What Is tox and Why Use It?

Tox is a generic virtualenv management and test command-line tool. At its core it does three things: creates isolated virtual environments, installs specified dependencies into each, and runs your commands inside them. It was originally designed for testing Python packages across multiple interpreter versions, but it handles any environment-based task — linting, type checking, building docs, running formatters.

The key insight is that tox installs your package into each environment from a source distribution, the same way a user would install it with pip install mylib. This means your tests run against the installed package, not the raw source files. If you forget to list a dependency in your setup.py or pyproject.toml, tox will catch it — the test environment simply won’t have that import available.

ApproachWhat it solvesWhat it misses
Manually activate venvsIsolationRepeatability, automation
pytest onlyTest runningMulti-version, missing deps detection
toxMulti-version + isolation + automationRequires Python versions to be installed
tox + pyenvEverythingSlightly more setup upfront

Install tox into your system Python or a dedicated virtual environment — do not install it inside the project venv you are testing, as this creates circular dependency problems:

# install_tox.sh
pip install tox --break-system-packages
# or into a dedicated tools venv
python -m venv ~/.venvs/tox && ~/.venvs/tox/bin/pip install tox
tox --version
tox 4.15.0 from /home/user/.local/lib/python3.11/site-packages/tox/__init__.py

Tox 4 (released 2022) changed several configuration defaults from tox 3. This article uses tox 4 conventions throughout. The most important difference: tox 4 no longer requires a setup.py — it works with pyproject.toml out of the box.

Debug Dee at futuristic control panel configuring test environments
tox spins up a fresh venv for each envlist entry. No shared state, no surprises.

The tox.ini Configuration File

The tox.ini file lives in your project root alongside setup.py or pyproject.toml. It uses INI syntax with sections that map to environments. Understanding the full set of options unlocks tox’s real power.

[tox] — Global Settings

# tox.ini — full global section
[tox]
# Environments to run when you type just "tox"
envlist = py39, py310, py311, py312

# Minimum tox version required
minversion = 4.0

# Skip missing Python interpreters instead of failing
skip_missing_interpreters = true

# Where to store environment data (default: {toxinidir}/.tox)
toxworkdir = {toxinidir}/.tox

skip_missing_interpreters = true is extremely useful in CI: if you have only Python 3.11 and 3.12 installed, tox skips the 3.9 and 3.10 environments with a warning rather than failing the build. On a developer laptop you might have only one or two Python versions — this setting keeps things friendly. Set it to false in CI if you want strict enforcement.

[testenv] — The Base Environment

The [testenv] section defines defaults inherited by all environments. Any specific environment like [testenv:py39] inherits everything from [testenv] and can override individual values.

# tox.ini — complete testenv section
[tox]
envlist = py39, py311, py312

[testenv]
# Dependencies to install (separate from your package's requirements)
deps =
    pytest>=8.0
    pytest-cov
    requests-mock

# The command to run
commands =
    pytest {posargs:tests/} --cov=mylib --cov-report=term-missing

# Environment variables to pass through or set
setenv =
    PYTHONPATH = {toxinidir}/src
    APP_ENV = testing

# Pass these variables from your shell into the environment
passenv =
    HOME
    CI
    GITHUB_*

# Install the package itself (default: true)
# Set to false for environments that don't need it (e.g., linting)
package = wheel
# Run tox and pass extra pytest args via posargs
$ tox -- -k test_add -v
py39 run-test: pytest tests/ -k test_add -v --cov=mylib --cov-report=term-missing
...
PASSED tests/test_utils.py::test_add

The {posargs} placeholder is how you forward extra arguments to the underlying command. Everything after -- on the tox command line becomes {posargs}. This lets you run a single test or pass -x to stop on first failure without changing tox.ini.

Per-Environment Overrides

Sometimes a specific Python version needs different dependencies or commands. Define a named environment section to override just those values:

# tox.ini — per-environment overrides
[tox]
envlist = py39, py311, py312, lint, typecheck

[testenv]
deps =
    pytest
    pytest-cov
commands = pytest tests/ --cov=mylib

# py39 needs a backport not required on 3.11+
[testenv:py39]
deps =
    pytest
    pytest-cov
    importlib-metadata>=4.0

# Linting environment — no package install needed
[testenv:lint]
package = skip
deps =
    ruff
    black
commands =
    ruff check mylib/ tests/
    black --check mylib/ tests/

# Type checking
[testenv:typecheck]
package = skip
deps = mypy
commands = mypy mylib/ --strict
# Run only the lint environment
$ tox -e lint

# Run multiple specific environments
$ tox -e py311,typecheck

# List all configured environments
$ tox list
default environments:
py39        -> [no description]
py311       -> [no description]
py312       -> [no description]
lint        -> [no description]
typecheck   -> [no description]

The package = skip setting tells tox not to build and install your package for that environment. This speeds up linting and type checking runs significantly since they only need the source files, not a full package installation.

API Alice with clipboard celebrating all tests passing
tox.ini: four lines to test against Python 3.9, 3.10, 3.11, and 3.12 simultaneously.

Integrating pytest and Coverage

Tox and pytest work seamlessly together. The most useful addition is coverage reporting — knowing not just that your tests pass, but that they actually exercise your code.

# tox.ini — pytest + coverage setup
[tox]
envlist = py311, py312

[testenv]
deps =
    pytest>=8.0
    pytest-cov

commands =
    pytest tests/ \
        --cov=mylib \
        --cov-report=term-missing \
        --cov-report=html:htmlcov \
        --cov-fail-under=80

# Separate environment to combine coverage from all Python versions
[testenv:coverage-report]
package = skip
deps = coverage[toml]
commands =
    coverage combine
    coverage report --fail-under=80
    coverage html

To combine coverage data across all Python version environments, add --cov-append to your pytest command and ensure all environments write to the same .coverage file:

# tox.ini — combined coverage across Python versions
[testenv]
deps =
    pytest
    pytest-cov

setenv =
    COVERAGE_FILE = {toxinidir}/.coverage.{envname}

commands =
    pytest tests/ --cov=mylib --cov-report= --cov-append

[testenv:coverage-report]
package = skip
deps = coverage
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage

commands =
    coverage combine .coverage.py311 .coverage.py312
    coverage report --show-missing
$ tox -e py311,py312,coverage-report
Name                 Stmts   Miss  Cover   Missing
--------------------------------------------------
mylib/utils.py          12      1    92%   45
mylib/parser.py         30      4    87%   22-25
--------------------------------------------------
TOTAL                   42      5    88%

The combined coverage report aggregates line hit data from every Python version. A line that only executes under Python 3.9’s sys.version_info branch will now be properly credited, giving you a truer picture of what the test suite actually exercises.

Using pyproject.toml Instead of tox.ini

Modern Python projects often consolidate all tool configuration into pyproject.toml. Tox 4 supports this natively — put your tox configuration in the [tool.tox] table and delete tox.ini:

# pyproject.toml — tox config inside the project file
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "mylib"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["requests>=2.28"]

[tool.tox]
legacy_tox_ini = """
[tox]
envlist = py39, py311, py312
skip_missing_interpreters = true

[testenv]
deps =
    pytest
    pytest-cov
commands = pytest tests/ --cov=mylib --cov-report=term-missing

[testenv:lint]
package = skip
deps = ruff
commands = ruff check mylib/ tests/
"""

The legacy_tox_ini key holds an INI string — the same syntax as a standalone tox.ini — inside the TOML file. Tox reads it transparently. There is also a native TOML-based configuration format (available in tox 4.2+) that avoids the embedded string, but legacy_tox_ini is the most compatible approach for projects that also need to support tox 3 users.

Environment Variables and Secrets

Test environments often need credentials or configuration that should not be committed to source control. Tox provides setenv for constants and passenv for forwarding values from your shell:

# tox.ini — handling secrets and config
[testenv]
deps = pytest

# Set constants needed by tests
setenv =
    APP_ENV = testing
    DATABASE_URL = sqlite:///test.db
    LOG_LEVEL = WARNING

# Pass secrets from the shell environment
passenv =
    AWS_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY
    GITHUB_TOKEN
    CI
    CODECOV_TOKEN

commands = pytest tests/
# In your shell before running tox:
export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
tox -e py311
# tests/test_api.py — accessing the passed variable
import os

def test_token_available():
    token = os.environ.get("GITHUB_TOKEN")
    # In CI this will be the real token; locally it must be set
    assert token is not None, "GITHUB_TOKEN not set in environment"

Tox deliberately strips most environment variables from the test environment by default. This prevents hidden dependencies on your local shell configuration — the same isolation that makes tox results trustworthy also means you must explicitly declare every environment variable your tests need. Use passenv = * only as a last resort during debugging; it defeats tox’s isolation guarantee.

Running tox in GitHub Actions CI

The real payoff of a tox configuration is running it automatically on every push. GitHub Actions has first-class support for matrix builds across Python versions:

# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.9", "3.10", "3.11", "3.12"]

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install tox
        run: pip install tox tox-gh-actions

      - name: Run tox
        run: tox
# tox.ini — gh-actions section maps matrix Python to tox env
[tox]
envlist = py39, py310, py311, py312, lint

[gh-actions]
python =
    3.9: py39
    3.10: py310
    3.11: py311
    3.12: py312

[testenv]
deps = pytest
commands = pytest tests/

[testenv:lint]
package = skip
deps = ruff
commands = ruff check mylib/

The tox-gh-actions plugin reads the GITHUB_ACTIONS and PYTHON_VERSION environment variables set by GitHub’s matrix runner and automatically selects the matching tox environment. When the matrix job runs Python 3.11, tox automatically runs only the py311 environment rather than all of them. This is more efficient than running the full envlist on every matrix node.

Real-Life Example: Testing a String Utilities Library

# string_utils_project/
# ├── strutils/
# │   ├── __init__.py
# │   ├── transform.py
# │   └── validate.py
# ├── tests/
# │   ├── test_transform.py
# │   └── test_validate.py
# ├── pyproject.toml
# └── tox.ini
# strutils/transform.py
def slugify(text: str) -> str:
    """Convert a string to a URL-friendly slug."""
    import re
    text = text.lower().strip()
    text = re.sub(r'[^\w\s-]', '', text)
    text = re.sub(r'[\s_-]+', '-', text)
    text = re.sub(r'^-+|-+$', '', text)
    return text

def truncate(text: str, max_length: int, suffix: str = "...") -> str:
    """Truncate text to max_length characters."""
    if len(text) <= max_length:
        return text
    return text[:max_length - len(suffix)] + suffix
# strutils/validate.py
import re

def is_valid_email(email: str) -> bool:
    """Basic email format validation."""
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.match(pattern, email))

def is_strong_password(password: str) -> bool:
    """Check password has 8+ chars, upper, lower, digit, special."""
    if len(password) < 8:
        return False
    has_upper = any(c.isupper() for c in password)
    has_lower = any(c.islower() for c in password)
    has_digit = any(c.isdigit() for c in password)
    has_special = any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?' for c in password)
    return all([has_upper, has_lower, has_digit, has_special])
# tests/test_transform.py
import pytest
from strutils.transform import slugify, truncate

@pytest.mark.parametrize("text,expected", [
    ("Hello World", "hello-world"),
    ("  Python 3.11  ", "python-311"),
    ("café & bistro!", "caf-bistro"),
])
def test_slugify(text, expected):
    assert slugify(text) == expected

def test_truncate_short():
    assert truncate("hello", 10) == "hello"

def test_truncate_long():
    assert truncate("hello world", 8) == "hello..."

def test_truncate_custom_suffix():
    assert truncate("hello world", 8, suffix="…") == "hello w…"
# tests/test_validate.py
import pytest
from strutils.validate import is_valid_email, is_strong_password

@pytest.mark.parametrize("email,valid", [
    ("user@example.com", True),
    ("bad-email", False),
    ("missing@tld.", False),
    ("ok+tag@sub.domain.org", True),
])
def test_is_valid_email(email, valid):
    assert is_valid_email(email) == valid

def test_strong_password():
    assert is_strong_password("Secure@123") is True
    assert is_strong_password("weakpass") is False
    assert is_strong_password("NoSpecial1") is False
# tox.ini — full production config for strutils
[tox]
envlist = py39, py310, py311, py312, lint, typecheck, coverage-report
skip_missing_interpreters = true

[gh-actions]
python =
    3.9: py39
    3.10: py310
    3.11: py311
    3.12: py312

[testenv]
deps =
    pytest>=8.0
    pytest-cov
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage.{envname}
commands =
    pytest tests/ --cov=strutils --cov-report=

[testenv:lint]
package = skip
deps = ruff
commands = ruff check strutils/ tests/

[testenv:typecheck]
package = skip
deps = mypy
commands = mypy strutils/ --strict

[testenv:coverage-report]
package = skip
deps = coverage
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage
commands =
    coverage combine
    coverage report --show-missing --fail-under=90
$ tox -e py311,lint,coverage-report
py311 create: .tox/py311
py311 run-test: pytest tests/ --cov=strutils --cov-report=
============================= test session starts ==============================
collected 11 items
tests/test_transform.py ....                                             [100%]
tests/test_validate.py .......                                           [100%]
============================== 11 passed in 0.18s ==============================

lint run-test: ruff check strutils/ tests/
All checks passed!

coverage-report run-test: coverage combine && coverage report --show-missing --fail-under=90
Name                      Stmts   Miss  Cover   Missing
-------------------------------------------------------
strutils/transform.py        12      0   100%
strutils/validate.py         10      0   100%
-------------------------------------------------------
TOTAL                        22      0   100%
___________________________________ summary ___________________________________
  py311: commands succeeded
  lint: commands succeeded
  coverage-report: commands succeeded
  congratulations :)

This configuration gives you a complete quality gate: unit tests across Python versions, linting with ruff, strict type checking with mypy, and a combined coverage report with a minimum threshold. Add this to GitHub Actions with the matrix config shown earlier and every pull request will automatically validate against all supported Python versions before merging.

Frequently Asked Questions

What happens if a Python version isn't installed?

With skip_missing_interpreters = true, tox prints a warning and marks that environment as skipped rather than failing. The final summary shows SKIPPED for missing interpreters and only fails if an installed environment's tests actually fail. Without that setting, tox exits with an error if any interpreter in envlist cannot be found. On developer machines, use skip_missing_interpreters = true; in CI, use false to enforce that all required versions are present.

When does tox recreate environments?

Tox caches virtual environments in the .tox/ directory and reuses them across runs for speed. It recreates an environment only when deps change, the Python interpreter changes, or you pass --recreate (or -r). If your tests behave strangely after a dependency upgrade, run tox -r to force a clean rebuild. You can also delete the entire .tox/ directory — tox will rebuild everything from scratch on the next run.

How do I run a single test with tox?

Use the {posargs} placeholder in your commands and pass arguments after -- on the command line. For example, tox -e py311 -- tests/test_transform.py::test_slugify -v runs only that one test with verbose output. The -- separator tells tox everything after it should be forwarded as {posargs} rather than interpreted as tox options. This is the cleanest way to do rapid test-driven development while keeping tox's isolation.

Should I pin dependency versions in tox.ini?

For library projects, leave deps unpinned (e.g., pytest>=8.0) so tox installs the latest compatible versions — this surfaces breakage from upstream changes early. For application projects where you want reproducible builds, pin exact versions (e.g., pytest==8.2.1) or use a requirements-test.txt file referenced with deps = -r requirements-test.txt. The -r syntax in tox deps works the same way as pip install -r.

What changed between tox 3 and tox 4?

Tox 4 dropped Python 2 support and changed several defaults: isolated_build = true is now the default, meaning tox uses PEP 517/518 build systems instead of python setup.py install. The [gh-actions] section syntax changed slightly. The package option replaces skip_install. Most importantly, tox 4 requires a valid pyproject.toml or setup.py — if your project has neither, add a minimal pyproject.toml with [build-system]. Check the official upgrade guide when migrating.

Conclusion

Tox turns multi-environment testing from a manual, error-prone process into a single tox command. You learned how to write a tox.ini with envlist, deps, and commands; create per-environment overrides for linting, type checking, and version-specific dependencies; integrate pytest coverage across Python versions; use setenv and passenv for environment variables; configure tox-gh-actions for CI matrix builds; and build a complete test pipeline for a real utility library.

Extend the string utilities example by adding a docs environment that builds Sphinx documentation, a security environment that runs bandit, or a benchmark environment that runs pytest-benchmark. Each new environment is just a few lines in tox.ini. Official documentation: tox.wiki.

Further Reading: For more details, see the Python random module documentation.

Frequently Asked Questions

What is a Markov chain in simple terms?

A Markov chain is a mathematical model where the next state depends only on the current state, not on the sequence of events that preceded it. In text generation, this means the next word is predicted based only on the current word or phrase.

How does a Markov chain text generator work in Python?

A Python Markov chain text generator builds a dictionary of word transitions from training text. Each word maps to a list of words that follow it. The generator then randomly selects next words based on these observed probabilities to create new text.

What are the limitations of Markov chain text generation?

Markov chains produce text that can be grammatically inconsistent over long passages because they only consider local context (the previous few words). They lack understanding of meaning, coherence, and long-range dependencies that modern language models handle better.

Can I use Markov chains for purposes other than text generation?

Yes. Markov chains are used in weather prediction, stock market modeling, DNA sequence analysis, game AI, PageRank algorithms, and many simulation scenarios. Any system where transitions between states follow probabilistic rules can be modeled with Markov chains.

How do I improve the quality of Markov chain generated text?

Increase the chain order (use pairs or triples of words instead of single words as keys), use larger and higher-quality training data, add post-processing to fix grammar, and filter out nonsensical outputs. Higher-order chains produce more coherent text but require more training data.

Printing Text, Newlines, Format, Exceptions With Examples

Printing Text, Newlines, Format, Exceptions With Examples

Beginner

The need to print when you program is of course one of the most important, and probably the very first things you ever did! This is your full guide on how to print for both python 2 and python 3).

The quickest and simplest scenario on how to print is to simply write the following:

print("Hello World")

However, there are many other variations of printing that comes up when you are coding in python. These could be printing json files, printing without a new line, printing to a log file, printing formatted text, and many more. Find below what you’re looking for in this one stop guide to printing!

Printing without a new line

When you normally use the print(“abc”) construct it still adds a new line character. In order to print without a new line use the end parameter.

print("Hello World", end="")

Normally when printing:

See example with the print parameter:

Printing Text together

When printing items, there are often times you need to print text write next to each other, or you need concatenate text together. Concatenating text in Python is simple and can be done in several ways.

Note that in the below approach that for the method 2, there is no space between the text which is why method 3 helps to solve this problem.

text1 = 'shoe'
text2 = 'laces'
print("Method 1:", text1,  text2)
print("Method 2:", text1 +  text2)
print("Method 3:", text1 + ' ' + text2)
print("Method 4:", "%s %s" % ( text1, text2) )

Formatting numeric output when printing

When printing, often it’s needed to format the print output. Here’s a list of formatting scenarios.

Printing a number with a string

When printing a number, it is typically simple to do with the following statement:

counter = 5
print(counter) 

The problem arises when you want to print text along the same line. You will typically get this error TypeError: unsupported operand type(s) for +: ‘int’ and ‘str’ . For example for the following:

The trick is that you can always concatenate two strings together. Hence, you simply need to convert the int (short for integer, or whole number) into a str (string).

counter = 5
print( str(counter) + ' apples'  )

Padding zeros when printing numbers

When printing numbers, often you need to pad with zeros. There are multiple ways to do this, but one of the easier ways is to first convert the number to a string, and then use the zfill function of the string where you can specify how long the number should be.

#this prints the number 10 with up to 8 padded zeros
counter = 10
str(counter).zfill(8)

A more advanced example follows where we’re printing 7 numbers. Notice that for the last number where the number is more than 8 digits, that there are no padded zeros.

counter = 2
for x in range(1, 7):
     print(  str(counter).zfill(8) )   
        counter = counter * counter

Another method to pad zeros is the following method to use the format function where a zero is placed in front of the number of digits. Here the “08” refers to padding zeros for 8 digits

counter = 2
for x in range(1, 7):
    print( format(counter, '08'))  
    counter = counter * counter

Printing with text alignment

The following can be used when you want to print a table of contents where the structure “{:>nn”.format(‘text to format’) is used. nn is the number of letters to pad.

'{:>15}'.format('text')

Without any alignment:

With alignment:

Printing complex data structures in readable format

One of the great things about python is that you can put together complex data structures fairly easily. This could be a dictionary where each dictionary item is a list. However, to print this out normally is quite difficult to read. This is where pretty print comes in. Suppose you have the following structure:

Within python, this is represented as a dictionary where the main items “furniture” and “appliances” have the sub-items. So if the data structure is “listitems”, then the data coudl be represented as follows:

listitems ={ 'furniture':[ 'desk', 'chair', 'sofa'], 'appliances':['tv', 'lamp', 'hifi']}

With this in mind, then printing of this data would be as follows:

listitems ={ 'furniture':[ 'desk', 'chair', 'sofa'], 'appliances':['tv', 'lamp', 'hifi']}
print(listitems)

This is where the import library pprint comes in. You can simply use this to print out the output in a more readable fashion. There are two important parameters though. You should use the indent parameter to specify how much space there is per element, and then width to ensure that limited items are put on a single two. If you put a width of 1 character, then that’ll ensure only show one element at most (so if a element has more than 1 character it’s ok, but you cannot include a second element in there as you’re already the 1 width limit).

import pprint; 
listitems ={ 'furniture':[ 'desk', 'chair', 'sofa'], 'appliances':['tv', 'lamp', 'hifi']}
pprint.pprint(listitems,  indent=1, width=1)

Printing time

Printing time is another important item that you tend to do often in case you want to monitor performance or perhaps to give an update that your long operation is still running.

Print the time

First lets simply print the current date and time

import datetime
print(datetime.datetime.now())

This date time can be easily formatted using the special function from the date object “strftime”. With strftime you can convert the format of the time quite easily to a specified format of hours, mins, seconds and date, with or without the timezone information

import datetime

currentTime = datetime.datetime.now()

print(currentTime.strftime("%Y-%m-%d"))
print(currentTime.strftime("%Y-%m-%d %H:%M:%S"))
print(currentTime.strftime("%Y-%m-%d %H:%M:%S %Z%z"))

As you can guess the Y=year, m = month, d = year, H = hour, M=minutes, S = seconds. Z = timezone. You’ll notice that for the 3rd print item the timezone is blank. We’ll address that in the next section.

Print the time in the correct timezone

However, if you are using a remote machine, or a virtual machine where your local timezone is not set, you may want to chose your own timezone. Also, if you are running services which are across different machines, it is important to make sure you use the right timezone. One simple way is to use universal time (UTC), or to simply to set a single timezone. You can then convert as required.

import datetime
import pytz   #include the timezone module

currentTime = datetime.datetime.now( pytz.timezone('UTC') ) 

print(currentTime.strftime("%Y-%m-%d"))
print(currentTime.strftime("%Y-%m-%d %H:%M:%S"))
print(currentTime.strftime("%Y-%m-%d %H:%M:%S %Z%z"))

Please note in the above example that when the timezone format was shown it showed that the timezone was set to UTC+0000 unlike the previous example. This means that the timezone information was present there.

In the following code, we will first get the time in the UTC timezone, and then convert the time to Hong Kong timezone.

import datetime
import pytz
currentTime = datetime.datetime.now( pytz.timezone('UTC') ) 
print("Time 1 (UTC time):", currentTime.strftime("%Y-%m-%d %H:%M:%S %Z%z"))
now_local = currentTime.astimezone(pytz.timezone('Asia/Hong_Kong'))
print("Time 2a(HK time) :", now_local.strftime("%Y-%m-%d %H:%M:%S %Z%z"))
print("Time 2b(HK time) :", now_local.strftime("%Y-%m-%d %H:%M:%S  "))

Please note that in the “Time 2a” output, you can see the Hong Kong time as 2am with the timezone indicator at the end of +8 hours. The final “Time 2b” is the same time without the timezone included.

Finally, you can get a list of all the timezones available with a quick check on the pytz module and checking “all_timezones”.

import pytz
for tz in pytz.all_timezones:
     print(tz)

How to print an exception

Things will go wrong in your code all the time – especially things that you don’t expect. This is where exceptions come in where the try exception blocks fit in quite nicely. The tricky part is that you need to make sure you output what the exception is in order for you to understand what’s going on.

Firstly a quick example of where a try /except can be helpful. Suppose you had the following code where after the definition of the function, the function was called.

def badFunction():
     print(a)     #print an undefined function

badFunction()#call the functionprint("have a nice day")

In here, as the variable “a” was not defined, then the program terminated and the final line “have a nice day” was never printed.

This is where try/except blocks can come in where you can catch errors from uncertain actions. So you can wrap the “badfunction” in a try block. See following example:

def badFunction():
     print(a)
#Try the unsafe code
 try:
     badFunction()
 except NameError:
     print("Variable x is not defined")
 except:
   print("Something else went wrong")

 print("have a nice day")

Here, the program continued to run gracefully and it caught the exception with the error “Variable x is not defined”. The reason it was caught was due to “NameError” exception object being defined.

In this next example, we have put a different error. Now the variable is defined as a number but there will be an exception as the number will be concatenated to a string.

def badFunction():
     a = 1
     print(a + ' join str')  #this will fail as joining a string with a number

#try the unsafe code
try:
     badFunction()
except NameError:
     print("Variable x is not defined")
except:
   print("Something else went wrong")
print("have a nice day")

Here another exception was caught but with the generic message of “Something else went wrong”. This is where printing the actual exception is really important. This is where you can define the exception object and print out the error.

 ef badFunction():
     a = 1
     print(a + ' join str')
 try:
     badFunction()
 except NameError:
     print("Variable x is not defined")
 except Exception as e:  
   print(e)   #print the exception object print("have a nice day")

Here you can see that the reason for the failure was included, and the program continued to run.

There’s a final improvement we can make which is to include where the problem occurred. This is really important where you have logging defined and you can see where the issue was caused.

import traceback

def badFunction():
     a = 1
     print(a + ' join str')

#run the unsafe code
try:
     badFunction()
except NameError:
     print("Variable x is not defined")
except Exception as e:
   print(e)
   traceback.print_tb(e.__traceback__)  #show the call list

print("have a nice day")

Here you can see the error description “Unsupported operand type(s) for +”, and then also where the error occurred from the initial call on line 8 with the call to “badFunction()” and the actual offending line of line 5.

Many more printing on python

There’s many more ways to print outputs within python, however this was intended to be a simple resource for some of the common printing challenges that come up, how you can use them, and with a simple example to get you up to speed very quickly with usable code. More to come!

Subscribe to our newsletter

How To Use Python tox for Multi-Environment Test Automation

How To Use Python tox for Multi-Environment Test Automation

Intermediate

You have a Python library that works perfectly on your machine running Python 3.11. Then a user files an issue — it crashes on 3.9. Another user is on 3.12. You fix the 3.9 bug and accidentally break 3.11 compatibility. Sound familiar? Testing across multiple Python versions manually means juggling virtual environments, remembering which one to activate, and running your test suite in each — a process so tedious it simply doesn’t happen. Bugs slip through. Users get hurt.

tox solves this by automating multi-environment testing in a single command. It reads a configuration file that lists which Python versions and dependencies to test against, creates isolated virtual environments for each one, installs your package into them, and runs your test suite in every environment — reporting failures per environment. One command, all Python versions, zero manual juggling.

In this article you will learn how to install and configure tox, write a tox.ini file from scratch, run tests across Python 3.9 through 3.12, pass environment variables and extra dependencies, run only a subset of environments, integrate tox with pytest and coverage, use the modern pyproject.toml configuration style, and build a real-world tox setup for a small utility library. By the end you will have a test automation setup that works identically on your laptop and in CI.

Running tox: Quick Example

Before diving into configuration details, here is a minimal tox setup that runs pytest across two Python versions. Create a small project directory, add a module, a test, and a tox.ini file, then run tox.

# project layout
# mylib/
# ├── mylib/
# │   └── utils.py
# ├── tests/
# │   └── test_utils.py
# ├── tox.ini
# └── setup.py (or pyproject.toml)

# mylib/utils.py
def add(a, b):
    return a + b

def greet(name):
    return f"Hello, {name}!"
# tests/test_utils.py
from mylib.utils import add, greet

def test_add():
    assert add(2, 3) == 5

def test_greet():
    assert greet("Alice") == "Hello, Alice!"
# tox.ini
[tox]
envlist = py39, py311

[testenv]
deps = pytest
commands = pytest tests/
# Run from the project root
$ tox
py39 create: /home/user/mylib/.tox/py39
py39 installdeps: pytest
py39 inst: /home/user/mylib/.tox/.tmp/package/1/mylib-0.1.0.tar.gz
py39 run-test: pytest tests/
============================= test session starts ==============================
platform linux -- Python 3.9.18, pytest-8.2.0
collected 2 items
tests/test_utils.py ..                                                   [100%]
============================== 2 passed in 0.12s ===============================
py311 create: /home/user/mylib/.tox/py311
py311 installdeps: pytest
py311 inst: /home/user/mylib/.tox/.tmp/package/1/mylib-0.1.0.tar.gz
py311 run-test: pytest tests/
============================= test session starts ==============================
platform linux -- Python 3.11.8, pytest-8.2.0
collected 2 items
tests/test_utils.py ..                                                   [100%]
============================== 2 passed in 0.12s ===============================
___________________________________ summary ___________________________________
  py39: commands succeeded
  py311: commands succeeded
  congratulations :)

Tox created two completely separate virtual environments — one for Python 3.9, one for 3.11 — installed your package and pytest into each, ran the test suite, and reported results. The key parts are envlist (which environments to run), deps (what to install), and commands (what to execute). Everything below digs into these and more.

What Is tox and Why Use It?

Tox is a generic virtualenv management and test command-line tool. At its core it does three things: creates isolated virtual environments, installs specified dependencies into each, and runs your commands inside them. It was originally designed for testing Python packages across multiple interpreter versions, but it handles any environment-based task — linting, type checking, building docs, running formatters.

The key insight is that tox installs your package into each environment from a source distribution, the same way a user would install it with pip install mylib. This means your tests run against the installed package, not the raw source files. If you forget to list a dependency in your setup.py or pyproject.toml, tox will catch it — the test environment simply won’t have that import available.

ApproachWhat it solvesWhat it misses
Manually activate venvsIsolationRepeatability, automation
pytest onlyTest runningMulti-version, missing deps detection
toxMulti-version + isolation + automationRequires Python versions to be installed
tox + pyenvEverythingSlightly more setup upfront

Install tox into your system Python or a dedicated virtual environment — do not install it inside the project venv you are testing, as this creates circular dependency problems:

# install_tox.sh
pip install tox --break-system-packages
# or into a dedicated tools venv
python -m venv ~/.venvs/tox && ~/.venvs/tox/bin/pip install tox
tox --version
tox 4.15.0 from /home/user/.local/lib/python3.11/site-packages/tox/__init__.py

Tox 4 (released 2022) changed several configuration defaults from tox 3. This article uses tox 4 conventions throughout. The most important difference: tox 4 no longer requires a setup.py — it works with pyproject.toml out of the box.

Debug Dee at futuristic control panel configuring test environments
tox spins up a fresh venv for each envlist entry. No shared state, no surprises.

The tox.ini Configuration File

The tox.ini file lives in your project root alongside setup.py or pyproject.toml. It uses INI syntax with sections that map to environments. Understanding the full set of options unlocks tox’s real power.

[tox] — Global Settings

# tox.ini — full global section
[tox]
# Environments to run when you type just "tox"
envlist = py39, py310, py311, py312

# Minimum tox version required
minversion = 4.0

# Skip missing Python interpreters instead of failing
skip_missing_interpreters = true

# Where to store environment data (default: {toxinidir}/.tox)
toxworkdir = {toxinidir}/.tox

skip_missing_interpreters = true is extremely useful in CI: if you have only Python 3.11 and 3.12 installed, tox skips the 3.9 and 3.10 environments with a warning rather than failing the build. On a developer laptop you might have only one or two Python versions — this setting keeps things friendly. Set it to false in CI if you want strict enforcement.

[testenv] — The Base Environment

The [testenv] section defines defaults inherited by all environments. Any specific environment like [testenv:py39] inherits everything from [testenv] and can override individual values.

# tox.ini — complete testenv section
[tox]
envlist = py39, py311, py312

[testenv]
# Dependencies to install (separate from your package's requirements)
deps =
    pytest>=8.0
    pytest-cov
    requests-mock

# The command to run
commands =
    pytest {posargs:tests/} --cov=mylib --cov-report=term-missing

# Environment variables to pass through or set
setenv =
    PYTHONPATH = {toxinidir}/src
    APP_ENV = testing

# Pass these variables from your shell into the environment
passenv =
    HOME
    CI
    GITHUB_*

# Install the package itself (default: true)
# Set to false for environments that don't need it (e.g., linting)
package = wheel
# Run tox and pass extra pytest args via posargs
$ tox -- -k test_add -v
py39 run-test: pytest tests/ -k test_add -v --cov=mylib --cov-report=term-missing
...
PASSED tests/test_utils.py::test_add

The {posargs} placeholder is how you forward extra arguments to the underlying command. Everything after -- on the tox command line becomes {posargs}. This lets you run a single test or pass -x to stop on first failure without changing tox.ini.

Per-Environment Overrides

Sometimes a specific Python version needs different dependencies or commands. Define a named environment section to override just those values:

# tox.ini — per-environment overrides
[tox]
envlist = py39, py311, py312, lint, typecheck

[testenv]
deps =
    pytest
    pytest-cov
commands = pytest tests/ --cov=mylib

# py39 needs a backport not required on 3.11+
[testenv:py39]
deps =
    pytest
    pytest-cov
    importlib-metadata>=4.0

# Linting environment — no package install needed
[testenv:lint]
package = skip
deps =
    ruff
    black
commands =
    ruff check mylib/ tests/
    black --check mylib/ tests/

# Type checking
[testenv:typecheck]
package = skip
deps = mypy
commands = mypy mylib/ --strict
# Run only the lint environment
$ tox -e lint

# Run multiple specific environments
$ tox -e py311,typecheck

# List all configured environments
$ tox list
default environments:
py39        -> [no description]
py311       -> [no description]
py312       -> [no description]
lint        -> [no description]
typecheck   -> [no description]

The package = skip setting tells tox not to build and install your package for that environment. This speeds up linting and type checking runs significantly since they only need the source files, not a full package installation.

API Alice with clipboard celebrating all tests passing
tox.ini: four lines to test against Python 3.9, 3.10, 3.11, and 3.12 simultaneously.

Integrating pytest and Coverage

Tox and pytest work seamlessly together. The most useful addition is coverage reporting — knowing not just that your tests pass, but that they actually exercise your code.

# tox.ini — pytest + coverage setup
[tox]
envlist = py311, py312

[testenv]
deps =
    pytest>=8.0
    pytest-cov

commands =
    pytest tests/ \
        --cov=mylib \
        --cov-report=term-missing \
        --cov-report=html:htmlcov \
        --cov-fail-under=80

# Separate environment to combine coverage from all Python versions
[testenv:coverage-report]
package = skip
deps = coverage[toml]
commands =
    coverage combine
    coverage report --fail-under=80
    coverage html

To combine coverage data across all Python version environments, add --cov-append to your pytest command and ensure all environments write to the same .coverage file:

# tox.ini — combined coverage across Python versions
[testenv]
deps =
    pytest
    pytest-cov

setenv =
    COVERAGE_FILE = {toxinidir}/.coverage.{envname}

commands =
    pytest tests/ --cov=mylib --cov-report= --cov-append

[testenv:coverage-report]
package = skip
deps = coverage
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage

commands =
    coverage combine .coverage.py311 .coverage.py312
    coverage report --show-missing
$ tox -e py311,py312,coverage-report
Name                 Stmts   Miss  Cover   Missing
--------------------------------------------------
mylib/utils.py          12      1    92%   45
mylib/parser.py         30      4    87%   22-25
--------------------------------------------------
TOTAL                   42      5    88%

The combined coverage report aggregates line hit data from every Python version. A line that only executes under Python 3.9’s sys.version_info branch will now be properly credited, giving you a truer picture of what the test suite actually exercises.

Using pyproject.toml Instead of tox.ini

Modern Python projects often consolidate all tool configuration into pyproject.toml. Tox 4 supports this natively — put your tox configuration in the [tool.tox] table and delete tox.ini:

# pyproject.toml — tox config inside the project file
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "mylib"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["requests>=2.28"]

[tool.tox]
legacy_tox_ini = """
[tox]
envlist = py39, py311, py312
skip_missing_interpreters = true

[testenv]
deps =
    pytest
    pytest-cov
commands = pytest tests/ --cov=mylib --cov-report=term-missing

[testenv:lint]
package = skip
deps = ruff
commands = ruff check mylib/ tests/
"""

The legacy_tox_ini key holds an INI string — the same syntax as a standalone tox.ini — inside the TOML file. Tox reads it transparently. There is also a native TOML-based configuration format (available in tox 4.2+) that avoids the embedded string, but legacy_tox_ini is the most compatible approach for projects that also need to support tox 3 users.

Environment Variables and Secrets

Test environments often need credentials or configuration that should not be committed to source control. Tox provides setenv for constants and passenv for forwarding values from your shell:

# tox.ini — handling secrets and config
[testenv]
deps = pytest

# Set constants needed by tests
setenv =
    APP_ENV = testing
    DATABASE_URL = sqlite:///test.db
    LOG_LEVEL = WARNING

# Pass secrets from the shell environment
passenv =
    AWS_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY
    GITHUB_TOKEN
    CI
    CODECOV_TOKEN

commands = pytest tests/
# In your shell before running tox:
export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
tox -e py311
# tests/test_api.py — accessing the passed variable
import os

def test_token_available():
    token = os.environ.get("GITHUB_TOKEN")
    # In CI this will be the real token; locally it must be set
    assert token is not None, "GITHUB_TOKEN not set in environment"

Tox deliberately strips most environment variables from the test environment by default. This prevents hidden dependencies on your local shell configuration — the same isolation that makes tox results trustworthy also means you must explicitly declare every environment variable your tests need. Use passenv = * only as a last resort during debugging; it defeats tox’s isolation guarantee.

Running tox in GitHub Actions CI

The real payoff of a tox configuration is running it automatically on every push. GitHub Actions has first-class support for matrix builds across Python versions:

# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.9", "3.10", "3.11", "3.12"]

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install tox
        run: pip install tox tox-gh-actions

      - name: Run tox
        run: tox
# tox.ini — gh-actions section maps matrix Python to tox env
[tox]
envlist = py39, py310, py311, py312, lint

[gh-actions]
python =
    3.9: py39
    3.10: py310
    3.11: py311
    3.12: py312

[testenv]
deps = pytest
commands = pytest tests/

[testenv:lint]
package = skip
deps = ruff
commands = ruff check mylib/

The tox-gh-actions plugin reads the GITHUB_ACTIONS and PYTHON_VERSION environment variables set by GitHub’s matrix runner and automatically selects the matching tox environment. When the matrix job runs Python 3.11, tox automatically runs only the py311 environment rather than all of them. This is more efficient than running the full envlist on every matrix node.

Real-Life Example: Testing a String Utilities Library

# string_utils_project/
# ├── strutils/
# │   ├── __init__.py
# │   ├── transform.py
# │   └── validate.py
# ├── tests/
# │   ├── test_transform.py
# │   └── test_validate.py
# ├── pyproject.toml
# └── tox.ini
# strutils/transform.py
def slugify(text: str) -> str:
    """Convert a string to a URL-friendly slug."""
    import re
    text = text.lower().strip()
    text = re.sub(r'[^\w\s-]', '', text)
    text = re.sub(r'[\s_-]+', '-', text)
    text = re.sub(r'^-+|-+$', '', text)
    return text

def truncate(text: str, max_length: int, suffix: str = "...") -> str:
    """Truncate text to max_length characters."""
    if len(text) <= max_length:
        return text
    return text[:max_length - len(suffix)] + suffix
# strutils/validate.py
import re

def is_valid_email(email: str) -> bool:
    """Basic email format validation."""
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.match(pattern, email))

def is_strong_password(password: str) -> bool:
    """Check password has 8+ chars, upper, lower, digit, special."""
    if len(password) < 8:
        return False
    has_upper = any(c.isupper() for c in password)
    has_lower = any(c.islower() for c in password)
    has_digit = any(c.isdigit() for c in password)
    has_special = any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?' for c in password)
    return all([has_upper, has_lower, has_digit, has_special])
# tests/test_transform.py
import pytest
from strutils.transform import slugify, truncate

@pytest.mark.parametrize("text,expected", [
    ("Hello World", "hello-world"),
    ("  Python 3.11  ", "python-311"),
    ("café & bistro!", "caf-bistro"),
])
def test_slugify(text, expected):
    assert slugify(text) == expected

def test_truncate_short():
    assert truncate("hello", 10) == "hello"

def test_truncate_long():
    assert truncate("hello world", 8) == "hello..."

def test_truncate_custom_suffix():
    assert truncate("hello world", 8, suffix="…") == "hello w…"
# tests/test_validate.py
import pytest
from strutils.validate import is_valid_email, is_strong_password

@pytest.mark.parametrize("email,valid", [
    ("user@example.com", True),
    ("bad-email", False),
    ("missing@tld.", False),
    ("ok+tag@sub.domain.org", True),
])
def test_is_valid_email(email, valid):
    assert is_valid_email(email) == valid

def test_strong_password():
    assert is_strong_password("Secure@123") is True
    assert is_strong_password("weakpass") is False
    assert is_strong_password("NoSpecial1") is False
# tox.ini — full production config for strutils
[tox]
envlist = py39, py310, py311, py312, lint, typecheck, coverage-report
skip_missing_interpreters = true

[gh-actions]
python =
    3.9: py39
    3.10: py310
    3.11: py311
    3.12: py312

[testenv]
deps =
    pytest>=8.0
    pytest-cov
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage.{envname}
commands =
    pytest tests/ --cov=strutils --cov-report=

[testenv:lint]
package = skip
deps = ruff
commands = ruff check strutils/ tests/

[testenv:typecheck]
package = skip
deps = mypy
commands = mypy strutils/ --strict

[testenv:coverage-report]
package = skip
deps = coverage
setenv =
    COVERAGE_FILE = {toxinidir}/.coverage
commands =
    coverage combine
    coverage report --show-missing --fail-under=90
$ tox -e py311,lint,coverage-report
py311 create: .tox/py311
py311 run-test: pytest tests/ --cov=strutils --cov-report=
============================= test session starts ==============================
collected 11 items
tests/test_transform.py ....                                             [100%]
tests/test_validate.py .......                                           [100%]
============================== 11 passed in 0.18s ==============================

lint run-test: ruff check strutils/ tests/
All checks passed!

coverage-report run-test: coverage combine && coverage report --show-missing --fail-under=90
Name                      Stmts   Miss  Cover   Missing
-------------------------------------------------------
strutils/transform.py        12      0   100%
strutils/validate.py         10      0   100%
-------------------------------------------------------
TOTAL                        22      0   100%
___________________________________ summary ___________________________________
  py311: commands succeeded
  lint: commands succeeded
  coverage-report: commands succeeded
  congratulations :)

This configuration gives you a complete quality gate: unit tests across Python versions, linting with ruff, strict type checking with mypy, and a combined coverage report with a minimum threshold. Add this to GitHub Actions with the matrix config shown earlier and every pull request will automatically validate against all supported Python versions before merging.

Frequently Asked Questions

What happens if a Python version isn't installed?

With skip_missing_interpreters = true, tox prints a warning and marks that environment as skipped rather than failing. The final summary shows SKIPPED for missing interpreters and only fails if an installed environment's tests actually fail. Without that setting, tox exits with an error if any interpreter in envlist cannot be found. On developer machines, use skip_missing_interpreters = true; in CI, use false to enforce that all required versions are present.

When does tox recreate environments?

Tox caches virtual environments in the .tox/ directory and reuses them across runs for speed. It recreates an environment only when deps change, the Python interpreter changes, or you pass --recreate (or -r). If your tests behave strangely after a dependency upgrade, run tox -r to force a clean rebuild. You can also delete the entire .tox/ directory — tox will rebuild everything from scratch on the next run.

How do I run a single test with tox?

Use the {posargs} placeholder in your commands and pass arguments after -- on the command line. For example, tox -e py311 -- tests/test_transform.py::test_slugify -v runs only that one test with verbose output. The -- separator tells tox everything after it should be forwarded as {posargs} rather than interpreted as tox options. This is the cleanest way to do rapid test-driven development while keeping tox's isolation.

Should I pin dependency versions in tox.ini?

For library projects, leave deps unpinned (e.g., pytest>=8.0) so tox installs the latest compatible versions — this surfaces breakage from upstream changes early. For application projects where you want reproducible builds, pin exact versions (e.g., pytest==8.2.1) or use a requirements-test.txt file referenced with deps = -r requirements-test.txt. The -r syntax in tox deps works the same way as pip install -r.

What changed between tox 3 and tox 4?

Tox 4 dropped Python 2 support and changed several defaults: isolated_build = true is now the default, meaning tox uses PEP 517/518 build systems instead of python setup.py install. The [gh-actions] section syntax changed slightly. The package option replaces skip_install. Most importantly, tox 4 requires a valid pyproject.toml or setup.py — if your project has neither, add a minimal pyproject.toml with [build-system]. Check the official upgrade guide when migrating.

Conclusion

Tox turns multi-environment testing from a manual, error-prone process into a single tox command. You learned how to write a tox.ini with envlist, deps, and commands; create per-environment overrides for linting, type checking, and version-specific dependencies; integrate pytest coverage across Python versions; use setenv and passenv for environment variables; configure tox-gh-actions for CI matrix builds; and build a complete test pipeline for a real utility library.

Extend the string utilities example by adding a docs environment that builds Sphinx documentation, a security environment that runs bandit, or a benchmark environment that runs pytest-benchmark. Each new environment is just a few lines in tox.ini. Official documentation: tox.wiki.

Further Reading: For more details, see the Python print() function documentation.

Frequently Asked Questions

What does \n do in Python print statements?

The \n escape sequence creates a newline character, causing text after it to appear on the next line. For example, print('Hello\nWorld') outputs ‘Hello’ and ‘World’ on separate lines.

How do I print multiple lines without using \n?

You can use triple-quoted strings (''' or """) to write multi-line text directly, or call print() multiple times. The textwrap.dedent() function also helps format multi-line strings cleanly.

What is a format exception in Python?

A format exception (typically a ValueError) occurs when a format string and its arguments do not match. For example, using the wrong number of placeholders in str.format() or mismatched types in f-strings.

How do I use f-strings for text formatting in Python?

F-strings (formatted string literals) use the syntax f'text {variable}' and were introduced in Python 3.6. They allow you to embed expressions directly inside string literals for readable, efficient formatting.

What is the difference between print() and sys.stdout.write()?

print() adds a newline by default and accepts multiple arguments with separators. sys.stdout.write() writes raw text without any automatic newline, giving you more control over output formatting.