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.
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
conda env create --name myenvname conda activate env myenvname conda install pip
pip install --upgrade -r requirements.txt pip install --upgrade -r requirements-dev.txt
pip install -e .
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.
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.shFor more about GitHub Actions, check out the GitHub Actions Quickstart.