February 22, 2021

My Python project setup basics (with tests and linting)

I have this problem that when I go to start a new Python project with all tools I want (e.g. linting and testing run automatically with GitHub Actions, nicely specified dependencies, etc.), I often realize that I've... forgotten some basic things about how to do that. And while with practice I'm getting better at remembering those things, I'm also having more occasion to explain to someone else how I like to set up a project.

So, I put together this post for the dual purpose of 1) replacing (or at least complementing!) the digging around in an old project for templates for myself and trying to remember things I totally used to know how to do and 2) serving as a resource or explainer that I can share with others.

Note that this setup might be a little overkill if you're just hacking together a bit of code for yourself; it's meant to be useful especially if you're building something that's likely to be distributed as package for others.

Here's how the project tree is organized. Click an element for more information and/or an example file.
 myproject
    setup.py
    README.md 
    requirements.txt
    requirements-dev.txt 
    myproject
        __init__.py
        example_code.py
    test
        test_example_code.py
    scripts
        lint.sh
        lint_and_test.sh
        test.sh
        

This file contains information and instructions that help your project be installed as a package, for example with pip. If you don't expect to distribute your project as a package and don't need it to be easily installable in this way, you likely don't need this file.

The readme gives basic information about the project, including information about installation, usage, how to contribute, and licensing.

Lists that packages users will need to run the project. You can pin your requirements to specific releases of those libraries as well, by specifying the version as in the example below.

Lists packages that developers on the project (but not users) would need, such as linting and testing packages.

You used to need an __init__.py file to mark a directory as a module. But now you don't need it for that. However, you can still use __init__.py to add things to the namespace. Note that my example setup.py file looks for a version number within __init__.py.

Probably you'll have more than just this one file with code, unless it's a very small project. In this example, the line __all__ = ["greet", "say_farewell"] controls what gets imported in an import * statement.

Test(s).

This folder can hold scripts related to the project that you and other developers might like to use. For example, it could include scripts for linting and testing (as in this example), or building documentation, and/or for building a release.

This example runs formatting with black, type checking with mypy, and style checking with pylint. You might like these this setup or not. For example, the formatter black calls itself "uncompromising" — you might like that it takes formatting decisions (and differences of opinion about formatting) out of the equation for you and your collaborators, or you might find you'd prefer not to use it because you'd like a little compromise thank-you-very-much.

This script just runs the lint and test scripts in sequence.

This script runs tests with pytest.

import codecs
import os
import re

import setuptools
from setuptools import setup, find_packages
from setuptools.command.install import install
from setuptools.command.develop import develop


PROJECT_ROOT = os.path.dirname(os.path.realpath(__file__))
REQUIREMENTS_FILE = os.path.join(PROJECT_ROOT, "requirements.txt")
README_FILE = os.path.join(PROJECT_ROOT, "README.md")
VERSION_FILE = os.path.join(PROJECT_ROOT, "myproject", "__init__.py")


def get_requirements():
    with codecs.open(REQUIREMENTS_FILE) as buff:
        return buff.read().splitlines()


def get_long_description():
    with codecs.open(README_FILE, "rt") as buff:
        return buff.read()


def get_version():
    lines = open(VERSION_FILE, "rt").readlines()
    version_regex = r"^__version__ = ['\"]([^'\"]*)['\"]"
    for line in lines:
        mo = re.search(version_regex, line, re.M)
        if mo:
            return mo.group(1)
    raise RuntimeError("Unable to find version in %s." % (VERSION_FILE,))


setup(
    name="myproject",
    version=get_version(),
    description="",
    author="Me",
    url="https://github.com/karink520/myproject",
    packages=find_packages(),
    install_requires=get_requirements(),
    long_description=get_long_description(),
    long_description_content_type="text/markdown",
    include_package_data=True,
)    
        
scipy
numpy
pymc3==3.9.3
black
mypy
pytest
pytest-cov
pylint
            
"""My project that does a thing"""
__version__ = "0.0.1"
from .example_code import *
            
"""An example code file"""

__all__ = ["greet", "say_farewell"]


def greet(person):
    greeting = "hello, " + person + "!"


def say_farewell():
    return "goodbye for now!"
            
"""Tests for example_code.py"""
from myproject.example_code import *


def test_greet():
    assert greet("world") == "hello, world!"


def test_say_farewell():
    assert say_farewell() == "goodbye for now!"
            
#! /bin/bash

set -ex # fail on first error, print commands

SRC_DIR=${SRC_DIR:-$(pwd)}

echo "Checking code style with black..."
python -m black --line-length 100 --check "${SRC_DIR}"
echo "Success!"

echo "Type checking with mypy..."
mypy --ignore-missing-imports myproject
echo "Success!"

echo "Checking code style with pylint..."
python -m pylint "${SRC_DIR}"/myproject/ "${SRC_DIR}"/test/*.py
echo "Success!"
            
#! /bin/bash

set -ex # fail on first error, print commands

SRC_DIR=${SRC_DIR:-$(pwd)}

./scripts/lint.sh
./scripts/test.sh
            
#! /bin/bash

set -ex # fail on first error, print commands

SRC_DIR=${SRC_DIR:-$(pwd)}

pytest -vx --cov myproject
            
If you haven't yet created your conda environment for development, you can create it, activate it, and install pip in it:
conda env create --name myenvname 
conda activate env myenvname
conda install pip
Next, with that conda environment activated, install and/or update libraries that your library and development depend on:
pip install --upgrade -r requirements.txt
pip install --upgrade -r requirements-dev.txt 
Make sure you have your own module installed in the conda environment, by navigating to the directory containing setup.py and entering:
pip install -e . 
Note that here the . is here as a path to where pip should look for the relevant setup.py file (i.e. in the same directory you are already in).
If you've set up scripts to run linting and testing as in the example file tree above, you can run them from within your top-level directory with:
bash scripts/lint_and_test.sh
  • Formatting with black In the example lint.sh file above, the formatter black is run with the --check flag, which means that it will just check the code for violations of black's formatting standards, rather than actually modifying the files. If you want to actually make the changes, you can locally run e.g.
    python -m black --line-length 100 myproject
    python -m black --line-length 100 test

    to reformat files in the myproject and test directories (of course, you can also modify the line-length to suit you).
  • Customizing pylint I find that using pylint and listening to its complaints helps me learn to write better and more readable code. But, sometimes it's helpful to change its settings to disable certain checks. There are two ways to do so. First, you can add a .pylintrc file to the top-level directory (here's an example). By editing this file, you can disable certain checks, or do more detailed tweaks like allowing certain variable names that pylint might otherwise disallow. A second approach (that can be used in combination with the first), is to disable pylint checks just on a certain line, by adding a specifically formatted comment to your code, that looks roughly like:
    offending_python_code_here # pylint: disable=name-of-check-to-disable

1. Make sure you have pytest and pytest-cov installed in your conda environment (you can install them one by one, but to stay organized and make things easier for your collaborators to use the same tools, just make sure they're listed inside requirements-dev.txt as in the example above.)

2. Run

pytest -vx --cov-report=html --cov myproject

3. Once the tests have run, notice that a new directory htmlcov has been created. Open the index.html file within this new directory in a browser to get a nice html-based report on your coverage.

For this step, assuming you have a repository set up on GitHub, you'll add a file to myproject/.github/workflows. For example, the following file, which we might name python-package.yaml, would install the necessary python dependencies and run the lint and test scripts lint.sh and test.sh, located in the scripts directory as in the example file tree above, any time a contributor pushes or makes a pull request.
name: Python package

on: [push, pull_request]

jobs:
    build:
    runs-on: ubuntu-latest
    strategy:
        matrix:
        python-version: [3.8, 3.9]

    steps:
        - uses: actions/checkout@v2
        - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v2
        with:
            python-version: ${{ matrix.python-version }}
        - name: Install dependencies
        run: |
            python -m pip install --upgrade pip
            pip install -e .
            pip install -r requirements-dev.txt
        - name: Run linters
        run: |
            ./scripts/lint.sh
        - name: Test with pytest
        run: |
            ./scripts/test.sh
For more about GitHub Actions, check out the GitHub Actions Quickstart.
Much gratitude to Colin Carroll, who taught me a whole bunch about how to set up a Python project in a reasonable way, and answered many questions.