Intro

The goal of this tutorial is to describe Python development ecosystem. It can be helpful for someone coming to Python from another programming language.

They say that you should stick to algorithms and data structures, that you can learn a new language in just a couple of weeks, that it’s just a new syntax. I completely agree that algorithms and data structures are extremely important but when it comes to language it’s slightly more than just syntax. There is an entire infrastructure of tools and best practices around it. For someone coming from a different background, it can be overwhelming to keep up with all this stuff, especially taking into consideration that sometimes information should be found in different places.

This is my very opinionated attempt to compile some of the best practices on setting up a new Python environment for local development. There are also advice of integration these tools with Visual Studio Code, however, it’s not necessary to use this particular editor. I’m going to update this page as there are some changes with the underlying tools. I also plan to use it myself as a boilerplate for starting a new Python project. The tutorial is long because I explain in detail purpose and usage of the tools, however, the end result is quick set up of the new project environment that can be achieved in just a couple of minutes. See Fast Track section.

How to manage Python versions with pyenv?

Why use pyenv?

Many tutorials start with the same thing: go to python.org and download the latest version of the language for you platform. Don’t listen to them. There is a better way. And here is why.

There are different versions of Python and you would need switching between these versions while working on different projects.

There is probably some version of Python already coming with your operation system. For Mac it’s 2.7, some Linux distributions already switched to version 3. Even more, there is another Python installed as a part of Anaconda package. The bottom line is: you never know for sure which Python is going to be run as you type python in the command line.

At some point, there’s going to be a mess of different Python executables on your machine, and you will need some way of managing it. If only there was a tool for that.

And there is. It’s called pyenv - https://github.com/pyenv/pyenv

How to install pyenv?

Install for Mac:

brew update
brew install pyenv

For Linux you’d probably better off with pyenv installer - https://github.com/pyenv/pyenv-installer:

curl [https://pyenv.run](https://pyenv.run/) | bash

For Windows, there is pyenv for Windows - https://github.com/pyenv-win/pyenv-win.

But you’d probably better off with Windows Subsystem for Linux (WSL), then installing it the Linux way.

How to use pyenv?

First, we can see a list of all Python executables, if any, that are installed on your machine (at least the ones pyenv was able to find):

pyenv versions
* system (set by /Users/alex/.python-version)

Above shown the output for my machine. Here asterisk indicates the current active version of Python. So if I run

python -V
Python 2.7.16

we can see that MacOS is still shipped with Python 2.7 as a system executable.

Now let’s see all the available Python versions:

pyenv install --list

This will output a long list of different Python versions. You will be surprised how many Pythons there are. CPython implementations default to versions like 3.8.5, other have prefixes like pypy3.6-7.3.1.

If you want to see only CPython version, you can run something like this:

pyenv install --list | grep " 3\."

If you are using pyenv for quite some time, and there is no new version in the list, you should update pyenv itself:

brew upgrade pyenv

or

pyenv update

Versions with suffix -dev are currently in active development.

Let’s install the most new and stable Python version to the moment of writing this post

pyenv install 3.8.5

This downloads the source code and compiles it on your machine:

python-build: use [email protected] from homebrew
python-build: use readline from homebrew
Downloading Python-3.8.5.tar.xz...
-> https://www.python.org/ftp/python/3.8.5/Python-3.8.5.tar.xz
Installing Python-3.8.5...
python-build: use readline from homebrew
python-build: use zlib from xcode sdk
Installed Python-3.8.5 to /Users/alex/.pyenv/versions/3.8.5

If we run pyenv versions again, we see our new version in the list but it’s still not active

* system (set by /Users/alex/.python-version)
  3.8.5

We can verify it with python -V. Still

Python 2.7.16

pyenv allows us to set up any version of Python as a global Python interpreter but we are not going to do that. There can be some scripts or other programs that rely on the default interpreter, so we don’t want to mess that up. Instead, we are going to set up Python per project. So let’s create a project in the next section.

Alternative tool: asdf: https://asdf-vm.com/

Dependency management for Python with Poetry

Why use Poetry?

By default, Python packages are installed with pip install. In reality nobody uses it this way. It installs all your dependencies into one version of Python interpreter which messes up dependencies.

It’s a good practice to install dependencies per project. So each project only contains dependencies that are required for it, and nothing more. This also prevents conflicts of versions of different packages that are required for different projects.

To solve this problem, there is a concept of virtual environments. So each project has it’s own virtual environment with fixed Python version and fixed dependencies specific for this project.

Virtual environments evolved from venv, virtualenv, virtualenvwrapper to pipenv, and poetry. pipenv is very popular and was a good choice for a long time. There was some controversy about pipenv that is greatly covered by this blog post: Pipenv: promises a lot, delivers very little by Chris Warrick. One of the biggest concerns was delay in releases - there was no new release since 2018. Although, mid 2020 pipenv became active again with several new updates, lots of developers made up their mind already. Poetry gained some traction, in addition it claims to better resolve dependencies where pipenv fails. Also, Poetry can be helpful for open source projects as it helps publishing the package.

Poetry official website: https://python-poetry.org/

How to install Poetry?

So, let’s get it started. It is recommended to install Poetry on a system level.

For MacOS, Linux, and WSL:

curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python

If you use Powershell with Windows, there is a script for that on the official website but I personally suggest just using WSL.

To apply changes for your current shell session, run

source $HOME/.poetry/env

You may add this to the auto-run shell script like .bashrc or .zshrc if Poetry doesn’t appear in a new shell session:

export PATH="$HOME/.poetry/bin:$PATH"

You can also enable tab completions for your shell. The process is described here.

How to create new project with Poetry?

Poetry creates a folder structure for you, so make sure to change your current directory to one that is supposed to be a parent directory for the new project and then run:

poetry new my-project

where my-project is the name of the project. Put a name of your project instead.

Now, let’s see what Poetry created for us:

cd my-project; ls

How to set Python version per project?

We were still using old Python 2.7, remember? For our new project, we want to use modern version of Python, so we are back to pyenv tool. As we are still in the project directory, set Python version locally for this directory:

pyenv local 3.8.5

If we run pyenv versions now, we can see that 3.8.5 is marked with asterisk, so it’s active for this directory. Note that outside of this directory Python version remains the same as before.

system
* 3.8.5 (set by /Users/alex/iCloud/dev/projects/my-project/.python-version)

We can double check it with python -V.

Python 3.8.5

Now we make Poetry to pick up current Python version:

poetry env use python
Creating virtualenv my-project-PSaGJAu6-py3.8 in /Users/alex/Library/Caches/pypoetry/virtualenvs
Using virtualenv: /Users/alex/Library/Caches/pypoetry/virtualenvs/my-project-PSaGJAu6-py3.8

As the last step of setting up Python version, let’s update pyproject.toml. [tool.poetry.dependencies] should reflect that supported version of Python is 3.8 or higher:

[tool.poetry]
name = "my-project"
version = "0.1.0"
description = ""
authors = ["Alex"]

[tool.poetry.dependencies]
python = "^3.8"

[tool.poetry.dev-dependencies]
pytest = "^5.2"

[build-system]
requires = ["poetry-core>=1.0.0a5"]
build-backend = "poetry.core.masonry.api"

How to use Poetry?

On the next step, you probably want to install your first dependency: some library or framework on top of which you plan to build the project. For example:

poetry add aiohttp

where aiohttp is the framework we install.

If you pull an existing project and want to install its dependencies for local development, simply run

poetry install

To bump all the dependencies to the latest versions:

poetry update

How to use Poetry with VS Code?

VS Code doesn’t detect Poetry automatically. Good news though, there is a simple way to make VS Code detect virtual environment created by Poetry.

First, we have to activate virtual environment:

poetry shell
Spawning shell within /Users/alex/Library/Caches/pypoetry/virtualenvs/my-project-PSaGJAu6-py3.8
➜  my-project . /Users/alex/Library/Caches/pypoetry/virtualenvs/my-project-PSaGJAu6-py3.8/bin/activate
(my-project-PSaGJAu6-py3.8) ➜  my-project

So now, as we are inside said virtual environment, we can call VS Code from it:

code .

Install Pylance - Python language server for VS Code.

As you open any Python file, VS Code immediately asks us to choose a Python interpreter

images/python-best-practice/Untitled.png

So let’s do exactly what we were asked. Because we called VS Code from within the virtual environment, the right interpreter will be presented as an option and we can choose it.

images/python-best-practice/Untitled%201.png

Notice that there are two options for 3.8.5, we should select the one that sits under virtual environment (see file path, it should contain virtualenv).

In general, you can like this Github issue to add support for Poetry to VS Code: https://github.com/microsoft/vscode-python/issues/8372 to keep track of the progress. As Brett Cannon said, VS Code team is reworking environment discovery code, so eventually Poetry will be fully supported by VS Code.

How to upgrade Poetry?

Simply running poetry self update will bump to the most recent version of Poetry.

If you run into error

ImportError: No module named cleo

you’d need to reinstall Poetry by removing it first:

curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py > get-poetry.py
python get-poetry.py --uninstall
python get-poetry.py
rm get-poetry.py

How to upgrade Python version with Poetry and pyenv?

Time has passed and the new Python version was released. There are new features and bugfixes, so we want to bump Python version in our project. This is a pretty straightforward task for pyenv and Poetry.

First, we download, compile and set up new the Python interpreter. Make sure to run this command not from virtual environment but from the project root folder. In my case, I’m going to upgrade to CPython 3.9 beta which is available at the time of writing this text but it can be any other version of CPython interpreter.

pyenv install 3.9.0b5
pyenv local 3.9.0b5
pyenv versions
my-project pyenv versions
  system
  3.8.5
* 3.9.0b5 (set by /Users/alex/iCloud/dev/projects/my-project/.python-version)

Next, we make Poetry use this interpreter for virtual environments.

poetry env use python
Creating virtualenv my-project-PSaGJAu6-py3.9 in /Users/alex.mitelman/Library/Caches/pypoetry/virtualenvs
Using virtualenv: /Users/alex/Library/Caches/pypoetry/virtualenvs/my-project-PSaGJAu6-py3.9

Notice that the project hash remains the same as before but the Python version is now 3.9.

As we have a new virtual environment, we have to reinstall all the dependencies for it:

poetry install

On the next step, we just activate virtual environment and double check that the right version of Python was picked up. We also start VS Code from within the virtual environment, so it is able to discover new environment (make sure to close VS Code before that).

poetry shell
python -V
code .

Finally, at the bottom left corner click on Python and choose the updated version from the list. Make sure that you pick virtual environment version.

images/python-best-practice/Untitled%202.png

images/python-best-practice/Untitled%203.png

Don’t forget to update [tool.poetry.dependencies] section in pyproject.toml file to reflect the Python version support.

VS Code will prompt you to choose linter, code formatter, etc. Please ignore those prompts for now. We will set them up later on in this tutorial.

Alternative: venv - built in with Python.

How to test with pytest?

Why use pytest?

Tests are important, so first thing we do after creating the project is taking care of tests.

pytest is a popular framework that received a broad adoption. In fact, it’s so popular, that it comes as a default testing framework for Poetry. So if we open [tool.poetry.dev-dependencies] section in pyproject.toml we can see that pytest is already listed there as a development dependency.

More than that, Poetry created tests folder structure for us.

cd tests; ls
__init__.py        __pycache__        test_my_project.py

How to use pytest with VS Code?

As an intentionally oversimplified example, let’s create and test function that multiplies two numbers. According to TDD, we create test first.

Let’s open test_my_project.py and add import of the function and very simple test:

from my_project.math import multiply_two_numbers

def test_multiply_two_numbers():
    result = multiply_two_numbers(2, 3)
    assert result == 6

Instead of running our tests from the terminal, let’s take advantage of code editor. ⇧⌘P - start typing “Python: Discover Tests” and select it from the dropdown. (Keybord shortcuts here and further are for macOS)

images/python-best-practice/Untitled%204.png

Test framework selection appears in the bottom right corner. Click the button:

images/python-best-practice/Untitled%205.png

and select pytest in the dropdown:

images/python-best-practice/Untitled%206.png

As the last step, it asks you to provide directory that contains all your tests:

images/python-best-practice/Untitled%207.png

VS Code discovered our tests, so now it adds fancy buttons to run or debug particular test.

images/python-best-practice/Untitled%208.png

As we run tests with VS Code, it obviously marks them red as we didn’t implement the function yet.

images/python-best-practice/Untitled%209.png

So let’s create [math.py](http://math.py) file in my_project directory and then implement our simple function:

def multiply_two_numbers(a, b):
    return a * b

As we run tests again, they are marked green now

images/python-best-practice/Untitled%2010.png

Alternatively, you can run tests from the command line with

poetry run pytest

or from the activated virtual environment just

pytest

Alternative: unittest - included with default Python distribution. Disadvantages: non-Pythonic camelcase API, slightly harder syntax.

How to measure tests coverage with pytest-cov?

Where tests, there’s coverage. We can install pytest-cov plugin for pytest to measure tests coverage:

poetry add --dev pytest-cov

Now running tests with an additional parameter will generate a coverage report:

pytest --cov=my_project tests/
================================================= test session starts =============
platform darwin -- Python 3.9.0b5, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
rootdir: /Users/alex/iCloud/dev/projects/my-project
plugins: cov-2.10.1
collected 2 items                                                                                                     

tests/test_my_project.py ..                                                  [100%]

----------- coverage: platform darwin, python 3.9.0-beta-5 -----------
Name                     Stmts   Miss  Cover
--------------------------------------------
my_project/__init__.py       1      0   100%
my_project/math.py           4      0   100%
--------------------------------------------
TOTAL                        5      0   100%

================================================== 2 passed in 0.08s ===============

This also generates .coverage file which we don’t want in our version control, so let’s not forget to add it .gitignore:

echo '.coverage' > .gitignore

How to run checks before committing changes with pre-commit?

Use Git

We didn’t talk about version control yet, and we should. Obviously, we are using Git in 2021. Install or update to the latest version. For macOS:

brew install git

If there is no repository for the project yet, we should create it. Github creates new repositories with main default branch now, so let’s create it this way too:

git init -b main

First thing first, we should have .gitignore file, so we don’t commit some temporary or binary files to the repo. We can manually copy-paste it from here https://github.com/github/gitignore/blob/master/Python.gitignore or simply run following command which will create .gitignore and download content of the above link into it. You must be in your project root directory.

curl -s https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore >> .gitignore

Now let’s add everything we have so far to version control:

git add .

Why run checks before commit?

As you can see (and will see further down in the tutorial), the boilerplate for the project is big. It’s easy to miss out on something or forget to apply some checks before committing your code to the remote repo. As a consequence, the code will not pass CI automation, your colleagues or maintainers may ask you to make some changes to the code during the code review. All this because of a small nitpick like absence of a newline character at the end of a file. Having newline there is a POSIX standard. Even if you are well aware of that, it’s very easy to forget or miss out on this. All this back and forth is just a waste of time which could be easily avoided if there was some automation before we commit a piece of code. And there is.

Please welcome [pre-commit](https://pre-commit.com/). It’s a tool to run automatic checks on every git commit. It’s easy to use and it doesn’t require root access. By the way, pre-commit is written in Python but can be used for projects in various programming languages.

How to install and use pre-commit?

pre-commit can be installed on a system level but we don’t want to do that exactly for the reasons we started using pyenv - we are going to use different Python versions and our dependencies should be in order. That’s why we install pre-commit as a development dependency:

poetry add pre-commit --dev

Going forward we will put all our linters and other stuff into the pre-commit hook. For the time being, we are going to create a simple config for small stuff like end of file newline that we discussed as an example.

There is a command for pre-commit to conveniently create a sample file for us. Executed from our project root:

pre-commit sample-config > .pre-commit-config.yaml

it creates a config file with the following content:

# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v2.4.0
    hooks:
    -   id: trailing-whitespace
    -   id: end-of-file-fixer
    -   id: check-yaml
    -   id: check-added-large-files

We can see that pre-commit already created some handy hooks for us. Let’s see how it works. To run checks manually:

pre-commit run --all-files

After running pre-commit, it already found that even file that was created by VS Code didn’t contain newline at the end!

Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Failed
- hook id: end-of-file-fixer
- exit code: 1
- files were modified by this hook

Fixing my_project/math.py
Fixing .vscode/settings.json
Fixing tests/test_my_project.py

Check Yaml...............................................................Passed
Check for added large files..............................................Passed

pre-commit actually fixed that for us, so if we run the same command again, all checks are passed OK.

Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Check for added large files..............................................Passed

The most important part is to make Git aware of these hooks and to run them before each commit.

pre-commit install
pre-commit installed at .git/hooks/pre-commit

Now, we can commit changes:

git commit -m 'Initial commit'
Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Check for added large files..............................................Passed
[master (root-commit) 7dc335f] Initial commit
 11 files changed, 559 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 .pre-commit-config.yaml
 create mode 100644 .python-version
 create mode 100644 .vscode/settings.json
 create mode 100644 README.rst
 create mode 100644 my_project/__init__.py
 create mode 100644 my_project/math.py
 create mode 100644 poetry.lock
 create mode 100644 pyproject.toml
 create mode 100644 tests/__init__.py
 create mode 100644 tests/test_my_project.py

As we can see, hooks were run before the commit. Just as planned.

One last thing, we can ask pre-commit to keep config updated to the latest version of tools:

pre-commit autoupdate

It immediately updates hook for me:

Updating https://github.com/pre-commit/pre-commit-hooks ... updating v2.4.0 -> v3.2.0.

Code analysis with Flake8 linter

Why use linter for Python project?

Linters are on the front line of the fight with errors and bugs. They signal you even before you run the code. Obviously, IDEs support linters, so you don’t even have to run the manually. IDE with the help of linter can flag wrong code right in the moment you actually write it.

In Python world, there are lots of linters but two major ones are Flake8 and Pylint.

Pylint is a very strict and nit-picky linter. Google uses it for their Python projects internally according to their guidelines. Because of it’s nature, you’ll probably spend a lot of time fighting or configuring it. Which is maybe not bad, by the way. Outcome of such strictness can be a safer code, however, as a consequence - longer development time.

Most popular open source projects use Flake8. But before we start, there is another hero in the Python world we should talk about.

Python Style Guide - PEP8

Python Style Guide, more famous by it’s Python Enhancement Proposal number- PEP8. Every Python developer should get themselves familiar with PEP8, along with Zen of Python (PEP 20).

After reading through PEP8, you may wonder if there is a way to automatically check and enforce these guidelines? Flake8 does exactly this, and a bit more. It works out of the box, and can be configured in case you want to change some specific settings.

While it’s not as strict as Pylint, it still does a great job on a first line of defense.

That’s why I recommend sticking to Flake8. You can still use Pylint as a second linter but Flake8 is a bare minimum.

How to install Flake8?

VS Code will prompt you to select linter for the project. You can also press ⇧⌘P, start typing “linter” and choose “Python: Select Linter”

images/python-best-practice/Untitled%2011.png

Then choose “flake8”

images/python-best-practice/Untitled%2012.png

VS Code should pick up your virtual environment and install Flake8 as a development dependency.

If it doesn’t happen or if you want to install it from the command line, here is a way:

poetry add flake8 --dev

How to use Flake8?

Getting back to a small portion of the code we wrote in a previous section. VS Code marks red some code that linter found problem with. By setting a pointer to that part we can see an error message, error number (that we can search on the internet), and the linter that flagged the error.

images/python-best-practice/Untitled%2013.png

We can also run Flake8 manually to see the same result:

flake8 .
./tests/test_my_project.py:4:1: E302 expected 2 blank lines, found 1
./tests/test_my_project.py:7:1: E302 expected 2 blank lines, found 1

How to add Flake8 to git hooks?

Red marks in IDE is easy to ignore sometimes. It’s also easy to forget running flake8 command before submitting our code. That’s why we have git hooks. We can add Flake8 to the hooks list, so it will check our code with linter automatically.

To do this, open .pre-commit-config.yaml and add following:

-   repo: https://gitlab.com/pycqa/flake8
    rev: 3.8.3
    hooks:
    -   id: flake8

Pay extra attention to rev line. I’ve added version myself here. To figure out version of Flake8 we currently use, I opened pyproject.toml, found version of Flake8, and put it in the pre-commit config file as stated above.

Remember, as we asked pre-commit to keep versions up to date, it would update Flake8 to the most recent version. But as we already use the newest version of the linter, nothing extra will be done here. pre-commit will download this version of Flake8 because it runs its checks in the separate environment. This means that we basically have two versions of Flake8 now, and we should make sure that they are actually the same version. Usually updating to the latest version shouldn’t be an issue but if it is, it’s recommended to turn auto update off for pre-commit.

As we change config file, let’s stage it:

git add .pre-commit-config.yaml

And commit:

git commit -m 'Add Flake8 to git hooks'
Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Check for added large files..............................................Passed
flake8...............................................(no files to check)Skipped
[master 1d25c9f] Add Flake8 to git hooks
 1 file changed, 4 insertions(+)

Let’s try running pre-commit manually again to see how it picked up Flake8:

pre-commit run --all-files
Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Check for added large files..............................................Passed
flake8...................................................................Failed
- hook id: flake8
- exit code: 1

tests/test_my_project.py:4:1: E302 expected 2 blank lines, found 1
tests/test_my_project.py:7:1: E302 expected 2 blank lines, found 1

Don’t hurry fixing this error to satisfy the linter. Computer already found the error. If it’s already of it, wouldn’t it be great to fix this error automatically?

Formatting code with Black

Why to format code with Black?

Every person writes code in their own style. Even on Python which forces you pretty much to indent blocks of code, it is very much possible to write the same thing in different ways.

Sometimes, just by looking at some part of code, you could say what person wrote it. What if different people touched the same code and wrote different parts of it in their own style? It can look a bit ugly.

How can we prevent it? We can argue on code review of course. This can significantly slow down merging such pull request. Also, arguing about style is pretty much a matter of taste. At the end of the day, people will argue about the looks instead of paying attention to what the code actually does.

In addition, wouldn’t it be nice if code looked the same across an entire project, just like it was written by one person?

There is a solution for it, and it’s called Black.

Black formats the code in its own style, so it looks consistent. There is very little that you can customize in Black, so even here there is no room for the argument. Just take it and use it as it is. After all, things like PEP8 and Black were created to agree on one style and just stop arguing about it.

Ironically, Black violates PEP8’s line length rule. Here is a thing, PEP8 told us that the maximum line length should be no more than 79 characters. It goes deep into history of IBM punch cards and UNIX terminals. PEP8 was written in 2001, things changed since then. People started questioning this rule. “I don’t read the code from a UNIX terminal”, - they say. “I have 27” monitor for a reason", - they say. There is a problem with that though. Some people work with code from 13" laptops. And viewing diff of two files of 79 char per line max becomes very convenient. Otherwise, you have to scroll horizontally, which is not very nice for working with code. That’s why I still think that 79 chars rule should be there.

And yes, while PEP8 was created in 2001, it’s received some updates since then but 79 char per line rule was never changed.

Thankfully, this is an option that Black allows us to adjust.

How to install Black?

In VS Code, open some Python file in our project. In my case, I have test_my_project.py that Flake8 was complaining about. ⇧⌘P and start typing “format”, then choose “Format Document”.

images/python-best-practice/Untitled%2014.png

It will ask you which formatter would you like to use.

images/python-best-practice/Untitled%2015.png

Click “Use black”.

VS Code will detect that we use Poetry and will install Black for us automatically.

images/python-best-practice/Untitled%2016.png

Optionally, you can install it manually with:

poetry add --dev black --allow-prereleases

--allow-prereleases option is here because Black is actually still in Beta in 2021. Initially, 2019 releases were planned to be the last ones in this status but it is what it is. Many production and open source projects already use it by default to format the code, so it’s pretty safe to assume that Black is relatively stable.

To configure Black, let’s open pyproject.toml and add following section:

[tool.black]
line-length = 79
target-version = ['py38']
include = '\.pyi?$'
exclude = '''

(
  /(
      \.eggs         # exclude a few common directories in the
    | \.git          # root of the project
    | \.hg
    | \.mypy_cache
    | \.tox
    | \.venv
    | _build
    | buck-out
    | build
    | dist
  )/
  | foo.py           # also separately exclude a file named foo.py in
                     # the root of the project
)
'''

The most important part here is that we set max line length to 79.

Also, if you use some other version of Python, make sure to update but double check that it’s supported by Black here.

We also have to suppress some error at Flake8 to make it work with Black. For this, we have to create setup.cfg file, which is a config file for Flake8, and put following in there:

[flake8]
extend-ignore = E203

Use to use Black?

Black usage is fairly simple. Just run it, and it formats the code:

black .
reformatted /projects/my-project/my_project/__init__.py
reformatted /projects/my-project/tests/test_my_project.py
All done! ✨ 🍰 ✨
2 files reformatted, 2 files left unchanged.

Remember Flake8 was complaining about some absent new lines? If we run out Git hook now, everything is OK.

pre-commit run --all-files
Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Check for added large files..............................................Passed
flake8...................................................................Passed

How to add Black to git hooks?

Same as with Flake8, it would be great to automate running Black, so we don’t have to bother. Let’s add following to .pre-commit-config.yaml

-   repo: https://github.com/psf/black
    rev: 20.8b1
    hooks:
      - id: black

Let’s run pre-commit run --all-files again. We can see that it downloads Black and uses it:

[INFO] Initializing environment for https://github.com/psf/black.
[INFO] Installing environment for https://github.com/psf/black.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Check for added large files..............................................Passed
flake8...................................................................Passed
black....................................................................Passed

I will not describe saving changes to git. The flow is the same as for Flake8.

Alternative: yapf - https://github.com/google/yapf

Bonus

You can add ruler to VS Code so there is a vertical line showing you the edge of 79 characters.

Open settings.json in .vscode directory and add following:

"[python]": {
        "editor.rulers": [72, 79]
    }

This will add rulers only for Python. 72 chars is for docstrings.

Static typing with Mypy

Why use static type checker with Python?

Type wars started in 70s. IBM with Smalltalk against Sun with Java. As we all know, strongly typed Java won, although Smalltalk being dynamically typed language was considered as a competitive advantage by some companies due to rapid development. Of course, type reduces amount of bugs and runtime errors which can be also solved by having 100% test coverage (as Uncle Bob claims, that’s one of the reasons of Python’s success). However, let’s not forget that not all projects have 100% coverage.

But can we have best of two worlds? Can we have type safety with rapid software development? I think, with Mypy we can. Mypy is a static code analysis tool that makes sure that the code is type safe. The project became so valuable that Python creator Guido van Rossum added type annotations to Python and joined Mypy development. Using type annotations also helps IDE providing a better code completion. As a bonus, type annotations make code more readable for humans too.

How to install mypy

In VS Code, you can edit settings.json file in .vscode directory. Set "python.linting.mypyEnabled": true. After that, open any Python file in the project, for example math.py. VS Code will detect that Mypy is not installed yet.

images/python-best-practice/Untitled%2017.png

Click install, and it will automatically install it with Poetry.

Alternatively, you can install Mypy manually:

poetry add --dev mypy

How to use mypy?

Now we can try running this tool on our small project:

mypy .
Success: no issues found in 4 source files

So far so good, we didn’t even have to make any changes to our code, Mypy automatically detects types to run checks.

But as we mentioned previously, having type annotations helps us better understand code, and makes IDE provide better code completions, so let’s take advantage of that.

Open setup.cfg file and add following:

[mypy]
follow_imports = silent
strict_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
disallow_any_generics = True
check_untyped_defs = True
no_implicit_reexport = True
disallow_untyped_defs = True
ignore_missing_imports = True

The most important line here is disallow_untyped_defs = True. It forces you to define functions with types. For existing legacy projects, you’d probably disable it but as we create a new project, it’s would be beneficial to make sure we never forget to add type annotations.

For better compatibility, there are various plugins for Mypy. For example, if you plan to use pydantic for data validation and serialization, config file will look like this:

[mypy]
plugins = pydantic.mypy

follow_imports = silent
strict_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
disallow_any_generics = True
check_untyped_defs = True
no_implicit_reexport = True
disallow_untyped_defs = True
ignore_missing_imports = True

[pydantic-mypy]
init_forbid_extra = True
init_typed = True
warn_required_dynamic_aliases = True
warn_untyped_fields = True

Here is the output after we added new config:

my_project/math.py:1: error: Function is missing a type annotation
tests/test_my_project.py:5: error: Function is missing a return type annotation
tests/test_my_project.py:5: note: Use "-> None" if function does not return a value
tests/test_my_project.py:9: error: Function is missing a return type annotation
tests/test_my_project.py:9: note: Use "-> None" if function does not return a value
Found 3 errors in 2 files (checked 4 source files)

In addition, VS Code uses Mypy as a linter and marks incorrect parts:

images/python-best-practice/Untitled%2018.png

To fix it, let’s add type annotation to our function:

from numbers import Real
from typing import Union

def multiply_two_numbers(a: Union[int, Real], b: Union[int, Real]) -> Union[int, Real]:
    return a * b

As we can see, function definition got too long. By pressing ⇧⌥F , VS Code formats code with Black to make it fit into 79 characters per line:

from numbers import Real
from typing import Union

def multiply_two_numbers(
    a: Union[int, Real], b: Union[int, Real]
) -> Union[int, Real]:
    return a * b

In test file, just add -> None as a return type:

def test_multiply_two_numbers() -> None:
    result = multiply_two_numbers(2, 3)
    assert result == 6

Commit changes:

git commit -m 'Add Mypy'

How to add mypy to git hooks?

Obviously, we want make sure that Mypy runs before committing the code. Add following to .pre-commit-config.yaml:

-   repo: https://github.com/pre-commit/mirrors-mypy
    rev: v0.782
    hooks:
        - id: mypy
          additional_dependencies: [pydantic]  # add if use pydantic

As we run pre-commit run --all-files it installs Mypy for pre-commits and runs checks:

[INFO] Installing environment for https://github.com/pre-commit/mirrors-mypy.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Check for added large files..............................................Passed
flake8...................................................................Passed
black....................................................................Passed
mypy.....................................................................Passed

Sorting imports with isort

One last thing: imports.

Why sort imports isort?

PEP8 specifies that imports should be sorted in the following order: standard library, third party, local. In addition we want imports to be beautiful and human friendly.

And there is a tool for that. Meet isort which stands for “import sort”. Here is an example from official site to get a sense of it.

Before:

from my_lib import Object

import os

from my_lib import Object3

from my_lib import Object2

import sys

from third_party import lib15, lib1, lib2, lib3, lib4, lib5, lib6, lib7, lib8, lib9, lib10, lib11, lib12, lib13, lib14

import sys

from __future__ import absolute_import

from third_party import lib3

After:

from __future__ import absolute_import

import os
import sys

from third_party import (lib1, lib2, lib3, lib4, lib5, lib6, lib7, lib8,
                         lib9, lib10, lib11, lib12, lib13, lib14, lib15)

from my_lib import Object, Object2, Object3

Way better, isn’t it?

How to install, use, and add isort to git hooks?

VS Code (with Python extension) uses isort internally, so there is no additional configuration required. If you don’t plan to use it from the command line, there is even no need to install it separately because pre-commit installs all dependencies to a its own separate environment.

But if you plan to use isort apart from VS Code and pre-commit, here is how to install it:

poetry add --dev isort

To make it work with Black correctly, we should add following to pyproject.toml:

[tool.isort]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
line_length = 79

In VS Code, ⇧⌘P, then start typing “sort imports”. VS Code will show:

images/python-best-practice/Untitled%2019.png

Alternatively, if you installed it, run in the project root:

isort .
Skipped 2 files

As a final step, let’s add it to the hooks list in .pre-commit-config.yaml:

-   repo: https://github.com/PyCQA/isort
    rev: 5.4.2
    hooks:
    -   id: isort

See it works with pre-commit run --all-files:

Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check Yaml...............................................................Passed
Check for added large files..............................................Passed
flake8...................................................................Passed
black....................................................................Passed
mypy.....................................................................Passed
isort....................................................................Passed

Fast track

Here is how you can create a fully configured new project in a just a couple of minutes (assuming you have pyenv and poetry installed already).

poetry new my-project; cd my-project; ls
pyenv local 3.8.5
poetry env use python
poetry add --dev pytest-cov pre-commit flake8 mypy isort
poetry add --dev --allow-prereleases black
poetry shell
code .

Add config to pyproject.toml:

[tool.isort]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
line_length = 79

[tool.black]
line-length = 79
target-version = ['py38']
include = '\.pyi?$'
exclude = '''

(
  /(
      \.eggs         # exclude a few common directories in the
    | \.git          # root of the project
    | \.hg
    | \.mypy_cache
    | \.tox
    | \.venv
    | _build
    | buck-out
    | build
    | dist
  )/
  | foo.py           # also separately exclude a file named foo.py in
                     # the root of the project
)
'''

Create setup.cfg:

[flake8]
extend-ignore = E203

[mypy]
follow_imports = silent
strict_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
disallow_any_generics = True
check_untyped_defs = True
no_implicit_reexport = True
disallow_untyped_defs = True
ignore_missing_imports = True

Create .pre-commit-config.yaml.

repos:
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v3.2.0
    hooks:
    -   id: trailing-whitespace
    -   id: end-of-file-fixer
    -   id: check-yaml
    -   id: check-added-large-files
-   repo: https://gitlab.com/pycqa/flake8
    rev: 3.8.3
    hooks:
    -   id: flake8
-   repo: https://github.com/psf/black
    rev: 20.8b1
    hooks:
      - id: black
-   repo: https://github.com/pre-commit/mirrors-mypy
    rev: v0.782
    hooks:
        - id: mypy
          additional_dependencies: [pydantic]  # add if use pydantic
-   repo: https://github.com/PyCQA/isort
    rev: 5.5.4
    hooks:
    -   id: isort
echo '.coverage' > .gitignore
curl -s https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore >> .gitignore
git init -b main
git add .
git commit -m 'Initial commit'
pre-commit install
pre-commit autoupdate
pre-commit run --all-files
pre-commit run --all-files