All Articles

A modern set-up for Python package development

A few months ago, I started developing my first ever PyPI-published python package, called pylambdarest.

This post is not about pylambdarest itself (I’ll let you have a look at the documentation if you are interested). Rather, I will tell you about the journey I had, setting-up my development tooling, from dependency management to quality assurance, documentation, and CI/CD.

We will cover the following topics:

What we will build: Time Series Generator

Let’s say you want to develop a package that allows you to quickly generate time series of different shapes. We will name it tsgen for Time Series Generator. Here is tsgen in action:

The code for this toy package is quite simple. It implements a new TimeSerie class and some generator functions that rely heavily on pandas and numpy.

Here is our initial project structure:

tsgen
├── tsgen
│   ├── __init__.py
│   ├── time_serie.py
│   └── generators.py
└── README.md

The class TimeSerie is defined in time_serie.py and implements methods such as addition, subtraction, and basic plotting:

import pandas as pd
import numpy as np
class TimeSerie:
def __init__(self, index, y):
if not isinstance(index, pd.DatetimeIndex):
raise TypeError("index should be a pandas.DatetimeIndex")
self.index = index
self.y = np.array(y)
if len(index) != len(y):
raise ValueError("index and y's shapes do not match")
def to_frame(self):
return pd.DataFrame({"y": self.y}, index=self.index)
def plot(self):
self.to_frame().y.plot()
def __len__(self):
return len(self.index)
def __str__(self):
return str(self.to_frame())
def __repr__(self):
return self.__str__()
def __eq__(self, other):
if not isinstance(other, TimeSerie):
return False
return (self.index == other.index).all() and (self.y == other.y).all()
def __add__(self, other):
if (
(not isinstance(other, TimeSerie))
and (not isinstance(other, int))
and (not isinstance(other, float))
):
raise TypeError("Wrong values")
if (
isinstance(other, TimeSerie)
and not (self.index == other.index).all()
):
raise ValueError("Indexes do not match")
if isinstance(other, TimeSerie):
return TimeSerie(index=self.index, y=(self.y + other.y))
return TimeSerie(index=self.index, y=(self.y + other))
def __sub__(self, other):
if isinstance(other, TimeSerie):
negative_other = TimeSerie(index=self.index, y=(-1 * other.y))
else:
negative_other = -1 * other
return self + negative_other
view raw tsgen_time_serie.py hosted with ❤ by GitHub

In the file generators.py, we define functions that generate TimeSerie objects, such as sine or affine from the previous notebook:

import math
import numpy as np
import pandas as pd
from tsgen.time_serie import TimeSerie
def affine(start, end, freq, start_y, end_y):
index = pd.date_range(start=start, end=end, freq=freq)
return TimeSerie(index=index, y=np.linspace(start_y, end_y, len(index)))
def constant(start, end, freq, value):
return affine(start, end, freq, value, value)
def cosine(start, end, freq, amp=1, n_periods=1):
index = pd.date_range(start=start, end=end, freq=freq)
return TimeSerie(
index=index,
y=amp
* np.cos(np.linspace(0, 2 * math.pi * n_periods, num=len(index))),
)
def sine(start, end, freq, n_periods=1):
index = pd.date_range(start=start, end=end, freq=freq)
return TimeSerie(
index=index,
y=np.sin(np.linspace(0, 2 * math.pi * n_periods, num=len(index))),
)
view raw tsgen_generators.py hosted with ❤ by GitHub

And __init__.py:

from tsgen.time_serie import TimeSerie
import tsgen.generators as generators
__all__ = ["TimeSerie", "generators"]
view raw tsgen___init__.py hosted with ❤ by GitHub

Now that we have in mind how the code works and what it should do, we will discuss how we can turn these three files into a quality PyPi-published python package.

Dependency management and package publishing with Poetry

As stated earlier, tsgen will depend on pandas and numpy for the core features. It will also rely on several other development dependencies for documentation, linting, testing, etc.

There are plenty of ways to install those dependencies in a dedicated virtual environment. You could use the good old pip and virtualenv with some requirements.txt files.

But there are modern tools that manage all of that for you and much more. And one of them is Poetry.

When you are at the root of your project, you can initialize Poetry with just one command: poetry init

Poetry will guide you through the initialization process, asking you about the project’s name, its dependencies, and development dependencies. Finally, it will generate a pyproject.toml file containing all the important information about your project:

[tool.poetry]
name = "tsgen"
version = "0.1.0"
description = "Time Series Generator"
authors = ["Marwan Debbiche <marwan.debbiche@example.com>"]
license = "MIT"
[tool.poetry.dependencies]
python = "^3.8"
pandas = "^1.1.3"
numpy = "^1.19.2"
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

To install your dependencies in the project’s virtual environment, you can just run poetry install.

If you want to add a new dependency, it’s as simple as :

poetry add newdependency
poetry add -D pytest # -D for development dependency

To run a command within your virtual environment you can do:

poetry run pytest

For the version management of Python itself, Poetry plays nicely with pyenv, which lets you switch easily between different versions:

pyenv install 3.8.5
pyenv local 3.8.5  # Activate Python 3.8 for the current project
poetry install

But Poetry is not just a dependency management tool. As stated in the documentation:

Poetry is a tool for dependency management and packaging in Python. It allows you to declare the libraries your project depends on and it will manage (install/update) them for you.

It can package your library and even publish it to PyPI:

poetry publish -u PYPI_USERNAME -p PYPI_PASSWORD --build

And just like that, tsgen is hosted on PyPI.

Quality Assurance

Thanks to Poetry, it’s quite simple to build and publish your package to PyPI. But hosting it on PyPI doesn’t make it a good package.

Sure, we tested it for a bit in a notebook, and it seems to do what it should. But how can we be sure that there are no bugs? Or at least how can we minimize the risk?

For example, here is a bug I noticed after a while:

I just forgot to add a __radd__ method in the TimeSerie class. This is a trivial bug that could have been detected a lot quicker. This is where Quality Assurance comes into play.

Testing with pytest

Quality Assurance in software engineering implies many things. But in my opinion, testing is the cornerstone of QA.

Several python packages exist for testing such as pytest, unittest, or nose. Find the one you like best. Personally, I like pytest. Let’s see how we can use it for tsgen.

First, we need to add a /test directory at the root of our project, containing test files for each of our package source file:

tsgen
├── tsgen
│   ├── __init__.py
│   ├── time_serie.py
│   └── generators.py
│
├── tests
│   ├── test_time_serie.py
│   └── test_generators.py
│
├── pyproject.toml
└── README.md

Let’s write a few test cases in test_time_serie.py:

import pandas as pd
import numpy as np
import pytest
from tsgen import TimeSerie
@pytest.fixture
def ts_monthly_constant():
start = "2020"
end = "2021"
freq = "1M"
index = pd.date_range(start=start, end=end, freq=freq)
return TimeSerie(index=index, y=np.linspace(1, 1, len(index)))
@pytest.fixture
def ts_monthly_1():
start = "2020"
end = "2021"
freq = "1M"
index = pd.date_range(start=start, end=end, freq=freq)
return TimeSerie(
index=index,
y=np.random.randn(len(index))
)
@pytest.fixture
def ts_monthly_2():
start = "2020"
end = "2021"
freq = "1M"
index = pd.date_range(start=start, end=end, freq=freq)
return TimeSerie(
index=index,
y=np.random.randn(len(index))
)
@pytest.fixture
def ts_daily():
start = "2020"
end = "2021"
freq = "1D"
index = pd.date_range(start=start, end=end, freq=freq)
return TimeSerie(
index=index,
y=np.random.randn(len(index))
)
def test_len(ts_monthly_1, ts_daily):
assert len(ts_monthly_1) == 12
# 2020 is a leap year and 2021-01-01 is included
assert len(ts_daily) == 367
def test_addition(ts_monthly_1, ts_monthly_2, ts_monthly_constant):
assert (ts_monthly_1 + ts_monthly_2) == (ts_monthly_2 + ts_monthly_1)
assert (ts_monthly_1 + ts_monthly_constant) == (ts_monthly_1 + 1)
# this line fails if we don't implement a __radd__ method:
assert (1 + ts_monthly_1) == (ts_monthly_1 + 1)

We define fixtures that return TimeSerie objects, and then use them in two test functions.

As you can see, the last assertion will fail if __radd__ is not implemented:

After implementing __radd__ :

All clear!

Coverage with Coverage.py

Now that our first tests passed, should we stop? How much is enough?

We can figure it out using Coverage.py to analyze the test coverage, which is the percentage of your source code’s statements that are being tested.

poetry add -D coverage
poetry run coverage run -m --source=tsgen pytest tests
poetry run coverage report

The goal, of course, is to achieve 100% coverage. But you should also write a test for each feature of your package.

Linting

What is it? From Wikipedia:

lint, or a linter, is a static code analysis tool used to flag programming errors, bugs, stylistic errors, and suspicious constructs

There are plenty of python linters: pylint, pycodestyle, flake8, wemake-python-styleguide

Even if they often overlap, they usually have small differences in the types of errors they flag, so you might consider using several linters for the same project.

Here is an example of pylint output:

As you can see, there are a lot of missing docstrings, and maybe the y attribute in TimeSerie isn’t well named (too short). Maybe y_values would be better?

Overall pylint gave tsgen a grade of 8.09/10. Not too bad.

If you have a lot of styling errors (not conforming to PEP8), you can use a formatter like black to automatically format your code.

Type checking with mypy

Python is dynamically typed, meaning that the variables’ types are inferred at runtime, and not defined beforehand. This makes python very easy to learn, but also has some pitfalls. Mainly, it is error-prone.

Luckily, type hints were introduced in version 3.5 of python and allow you to declare the types of your variables, function arguments, and return values:

def say_hello_n_times(name: str, n: int) -> None:
    hello_string: str = "Hello {} !".format(name)
    for _ in range(n):
        print(hello_string)

Of course, type hints are just hints and don’t change the fact that Python is dynamically typed. So the following code is completely valid and will not throw any error.

a: str = dict(name="John Doe")

However, a type checker like mypy can detect these kinds of inconsistencies, allowing you to find sneaky bugs before they occur.

To check types with mypy you can run:

poetry add -D mypy
poetry run mypy tsgen

But you will probably get the following output:

This is because pandas and numpy have no type hints, so mypy can’t analyze them.

You can either install data-science-types, which provides type information for matplotlib, numpy, and pandas or just add # type: ignore at the end of each associated import statements. I chose the latter because I found that the data-science-types package is not mature yet, and a lot of type definitions are missing:

import math
import numpy as np # type: ignore
import pandas as pd # type: ignore
from tsgen.time_serie import TimeSerie
def affine(start, end, freq, start_y, end_y) -> TimeSerie:
"""
Generate a linear TimeSerie.
"""
index = pd.date_range(start=start, end=end, freq=freq)
return TimeSerie(
index=index, y_values=np.linspace(start_y, end_y, len(index))
)
def constant(start, end, freq, value) -> TimeSerie:
"""
Generate a constant TimeSerie.
"""
return affine(start, end, freq, value, value)
def cosine(start, end, freq, amp=1, n_periods=1) -> TimeSerie:
"""
Generate a cosine TimeSerie.
"""
index = pd.date_range(start=start, end=end, freq=freq)
return TimeSerie(
index=index,
y_values=amp
* np.cos(np.linspace(0, 2 * math.pi * n_periods, num=len(index))),
)
def sine(start, end, freq, n_periods=1) -> TimeSerie:
"""
Generate a sine TimeSerie.
"""
index = pd.date_range(start=start, end=end, freq=freq)
return TimeSerie(
index=index,
y_values=np.sin(
np.linspace(0, 2 * math.pi * n_periods, num=len(index))
),
)
def randn(start, end, freq, mean=0, std=1) -> TimeSerie:
"""
Generate a random normally distributed TimeSerie.
"""
index = pd.date_range(start=start, end=end, freq=freq)
return TimeSerie(
index=index, y_values=(std * np.random.randn(len(index)) + mean)
)

And mypy checks now pass successfully:

Pre-commit

Now that we have installed and run our quality assurance tools, how can we make sure that they are actually used and that we are not committing or publishing any poor quality code or package?

The first way to do that is to use pre-commit. pre-commit lets you define and install git hooks, which are scripts that are run just before a commit.

Here is a pre-commit config file to run standard checks such as end-of-file or trailing-whitespace, pylint, and mypy:

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
hooks:
- id: check-yaml
- id: debug-statements
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: local
hooks:
- id: pylint
name: pylint
entry: pylint
exclude: ^(tests/)
language: system
types: [python]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.790
hooks:
- id: mypy
exclude: ^(tests/)

Let’s install the hooks:

poetry add -D pre-commit
poetry run pre-commit install

Then if we have an invalid file, pre-commit will prevent us from committing:

In this case, it will even fix the invalid file by removing the trailing whitespace. You can then stage those changes and commit again:

You can still commit files with errors using the--no-verify argument in git commit . For example, if you need to share some work in progress on a development branch with coworkers.

CI/CD with Github Actions

Now that we have installed all our QA tools locally, how can we make sure that everyone uses them? What if your teammate doesn’t want to install pre-commit hooks, and push shoddy code in your VCS? How can you make sure to detect it before it is published?

To do so, you need to have an efficient CI/CD (Continuous Integration / Continuous Delivery) pipeline that will automatically run linting and tests for you every time a new commit is pushed. It should also publish your package automatically on PyPI when you create a new release, but only if the QA steps have passed.

There are plenty of CI/CD tools (Travis-ci, CircleCI, Azure Pipelines, …) but I like having my code and my CI/CD pipelines in one place so I started using Github Actions.

To create a pipeline, you will only need to add a YAML file in a .github/workflows. Here is tsgen’s .github/workflows/pipeline.yml:

name: CI/CD # pipeline's name that will appear in Github Actions
on: # events that trigger our pipeline: push on any branch and release creation
push:
release:
types: [created]
jobs: # jobs. We will have two jobs (test and publish) with multiple steps.
test:
# Our test job will run on ubuntu.
# We define matrix strategy for python-version so that
# our tests are run on multiple python versions:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Run image # install poetry
uses: abatilo/actions-poetry@v2.0.0
with:
poetry-version: 1.0.10
- name: Install dependencies # install all dependencies
run: poetry install
- name: Pylint # Run pylint static analysis
run: |
poetry run pylint tsgen
- name: mypy # Run mypy static analysis
run: |
poetry run mypy -p tsgen
- name: Pytest # Run pytest
run: |
poetry run coverage run -m --source=tsgen pytest tests
poetry run coverage report
- name: Coveralls # Send coverage metrics to coveralls.io
run: poetry run coveralls
env:
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
publish:
# Our publish job will only run on release creation events,
# and only if the test job has passed
if: github.event_name == 'release' && github.event.action == 'created'
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: "3.8"
- name: Run image
uses: abatilo/actions-poetry@v2.0.0
with:
poetry-version: 1.0.10
- name: Build and publish # publish tsgen to PyPI
env:
PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }}
PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: poetry publish -u $PYPI_USERNAME -p $PYPI_PASSWORD --build
view raw tsgen_pipeline.yml hosted with ❤ by GitHub

I think the syntax is fairly easy to understand. You define jobs that can depend on each other, and each job is a combination of standard (actions/setup-python@v2) or custom (run commands) steps.

You can define environment variables to use in each step, which will often use GitHub secrets to pass sensitive information such as passwords or tokens.

If you pay attention, you will notice that I added a step at the end of the test job to send coverage data to coverall.io, which allows you to keep track of your test coverage.

Documentation

I think it is now safe to say that we have put in place many things to ensure continuous integration and delivery of a quality PyPI package. However, something crucial is missing if we want to create adoption for tsgen. And that’s documentation.

How can a new user know how to use tsgen? Which functions are defined, and which parameters are expected?

Documentation generation with Sphinx

From Sphinx documentation:

Sphinx is a tool that makes it easy to create intelligent and beautiful documentation.

And that’s just what we want to do!

To initialize your sphinx doc:

poetry add -D sphinx
mkdir docs
cd docs
poetry run sphinx-quickstart

Sphinx will create all the directories you need to start your documentation. You can build your documentation with:

poetry run make html

And you can now open the built index.html in your browser:

To actually provide some information you should:

  • Write a few general things about your package in the index.rst file, like what it does and how you can install it, and maybe some simple “Getting Started” explanations.
  • Create an API reference page that will automatically import information about your package’s modules, classes, and functions from docstrings using the autodoc extension.
  • You can also create a Tutorial page.

Sphinx is highly customizable, so you can easily change the theme of your version (I personally like Read the Docs theme).

I won’t go into detail here, you can always have a look at the docs folder of tsgen’s GitHub repository. But the final result will look like this:

Pretty neat, right?

Documentation hosting with Read the Docs

Now that we are satisfied with our documentation, we will need to host it somewhere on the internet so that new users can view it.

Read the Docs automates the process of building and hosting of your documentation. Once you have created an account, you will need to create a .readthedocs.yml and import your git repository:

# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/source/conf.py
python:
version: 3.8
install:
- method: pip # install tsgen. You will need this for autodoc to work
path: .

Once it’s done, every time you push to master or create a new release, Read the Docs will automatically build and host your “latest” or “stable” documentation.

You can have a look at tsgen documentation on Read the Docs.

Conclusion

What I learned developing pylambdarest as well as tsgen is that in the beginning, you will spend a lot more time setting up all your development tools than actually coding. But it’s totally worth it. Once you are all set, you can focus on developing new features for your package, instead of spending time finding or fixing bugs, or deploying your package.

Enforcing style conventions with linters will also improve your code readability and by doing so, you will increase your team’s velocity.

Also, keep in mind that the particular tools I presented often have many viable alternatives. Choose the ones that work best for you and your team. But make sure you do choose some, or you won’t be able to ensure high quality for your software and development process.