Normally I do Python development in work, where everything is already set up for easy development and testing. It took considerable research and setup to get a similarly good environment for Python development at home, so I decided to document it for myself and others :)
Install all the packages Link to heading
Mac OS Link to heading
The list of Python modules you need is:
black lxml mutmut mypy pudb pyfakefs pylint pytest pytest-cov
See Upgrading packages installed with pip3 is troublesome for
how I install and upgrade packages; I recommend using
virtualenv
as described there rather than installing globally.
Linux Link to heading
These package names are correct for Debian.
sudo apt install black pylint3 python3-mypy python3-pudb python3-pyfakefs \
python3-pytest python3-pytest-cov
mutmut
isn’t packaged for Debian, so you might want to install it with Pip.
Testing Link to heading
I use pytest for running tests and pytest-cov to get test coverage so I can figure out which parts of my code still need to be tested.
cd test-directory
pytest
Test coverage Link to heading
To enable test coverage with pytest
, create pytest.ini
in the directory
containing your tests, with contents like
https://github.com/tobinjt/bin/blob/master/python/pytest.ini. Every time your
tests are run successfully coverage will be generated and a message will be
printed showing the coverage percentage.
I found that test coverage needed quite a bit of configuration through the file
.coveragerc
in the directory containing your tests; my final contents are
https://github.com/tobinjt/bin/blob/master/python/.coveragerc. I highly
recommend configuring testing to fail if there is insufficient coverage. You can
also configure a HTML output directory so that you can easily see which lines of
code you haven’t tested; configuring a portable output directory might be
difficult, I haven’t needed to do so yet.
Integration tests Link to heading
I’m a fan of integration tests, where instead of testing individual functions you test large swathes of code at a time. I take the approach of picking a piece of functionality that should be supported, then writing a test to exercise that functionality end to end.
https://github.com/tobinjt/bin/blob/ad5b57afa03d650ac657249c886300d581a8c60f/python/linkdirs_test.py#L39 is a good example of this, where I test progressively more complex use cases and scenarios by:
- Populating a fake filesystem (
pyfakefs
is great for this) with the scenario to deal with. - Calling
main()
with the right arguments. - Checking that the resulting filesystem is correct.
This was particularly reassuring when I added deletion to linkdirs.py
:)
Automatically running tests Link to heading
I like having tests run every time I save the source or test file because it saves me from doing it manually. See Automatically running tests, linters, or anything for an easy way to do this.
Mutation testing Link to heading
Mutation testing is where minor changes are made to source code (e.g.
initialising a variable to True
instead of False
) and then the tests are run
with the changed source. If the tests still pass despite the change it might
indicate a gap in testing, or it might indicate that the default value is simply
a placeholder that is always overwritten, or the change might not be meaningful
(e.g. .rstrip('\n')
changing to .rstrip('XX\nXX')
), or other reasons. There
isn’t a one-size-fits-all approach that you can take to addressing mutations
that don’t cause test failures - sometimes you will add tests, sometimes you
will change the source, and sometimes you will mark the line so it’s not mutated
in future.
I use mutmut for mutation testing.
I have made some changes to my
code in response to mutmut
warnings, and thus far I think the most significant change was to add tests for
the output from --help
to ensure that I don’t accidentally make it confusing
(e.g. changing the description so it no longer matches the options).
The first run of mutmut
for any file will be slow, but for subsequent runs
mutmut
will only check mutations that previously failed, so it speeds up as
you make fixes. Sadly there have been a couple of occasions where it stayed
reporting problems despite me fixing them, and the only way to mitigate that
situation was rm .mutmut-cache
and rerun from scratch :( mutmut
runs your
tests once without mutations to get a timing baseline so it can detect mutations
that cause tests to run for too long, but this means that you cannot switch to
doing anything else while testing mutations, because if the extra system load
from doing something else slows down the testing enough you will have false
positives.
Beware: mutmut
only runs your tests with unmodified source the first time
you run it. If you break your tests while addressing mutmut
warnings, on the
next run it will make all the mutations, run the tests, and declare that you
have no problems to fix because the tests fail - but not because of the
mutations :(
I found running mutmut
was awkward - it doesn’t understand that tests for
foo.py
are in foo_test.py
, so I wrote a wrapper for mutmut
run that first runs
tests with unmodified sources, then runs mutmut
with the correct arguments,
and fixes permissions afterwards (because mutmut
replaces the source files it
operates on and doesn’t set permissions correctly).
I use mutmut
like this:
Run mutation testing:
$ mutmut_run colx.py - Mutation testing starting - These are the steps: 1. A full test suite run will be made to make sure we can run the tests successfully and we know how long it takes (to detect infinite loops for example) 2. Mutants will be generated and checked Results are stored in .mutmut-cache. Print found mutants with `mutmut results`. Legend for output: ๐ Killed mutants. The goal is for everything to end up in this bucket. โฐ Timeout. Test suite took 10 times as long as the baseline so were killed. ๐ค Suspicious. Tests took a long time, but not long enough to be fatal. ๐ Survived. This means your tests needs to be expanded. ๐ Skipped. Skipped. 1. Using cached time for baseline tests, to run baseline again delete the cache file 2. Checking mutants โ 88/88 ๐ 62 โฐ 1 ๐ค 0 ๐ 25 ๐ 0 real 4m38.098s user 4m17.888s sys 0m17.081s
Show results:
$ mutmut show colx.py To apply a mutant on disk: mutmut apply <id> To show a mutant: mutmut show <id> Timed out โฐ (1) ---- colx.py (1) ---- # mutant 229 --- colx.py +++ colx.py @@ -121,7 +121,7 @@ # Strip leading and trailing empty fields. first_index = 0 while len(split_columns) > first_index and not split_columns[first_index]: - first_index += 1 + first_index = 1 last_index = len(split_columns) - 1 while last_index > first_index and not split_columns[last_index]: last_index -= 1 Survived ๐ (25) ---- colx.py (25) ---- # mutant 161 --- colx.py +++ colx.py @@ -39,7 +39,7 @@ Returns: argparse.Namespace, with attributes set based on the arguments. """ - description = '\n'.join(__doc__.split('\n')[1:]) + description = 'XX\nXX'.join(__doc__.split('\n')[1:]) usage = __doc__.split('\n')[0] argv_parser = argparse.ArgumentParser(
Investigate and fix some of the reported mutants.
- False positives (e.g. changing a constant used consistently throughout
the codebase) can be disabled with
# pragma: no mutate
. - Tests can be added or expanded, e.g. changing regexes to be anchored, or adding edge cases so that boundary conditions are tested more tightly.
- Source changes may be appropriate, but I haven’t encountered any good examples yet.
- False positives (e.g. changing a constant used consistently throughout
the codebase) can be disabled with
GOTO 1
.
I’ve found that I can fix a lot of the mutations pretty quickly, then my progress slows down as I pick off more and more of the low hanging fruit. My aim is to reach 0 mutations that pass tests, and sometimes the pragmatic way to deal with the last few is to just disable them. I aim for 0 so that future runs give me a better signal, and I’m not left wondering “is that something I decided to ignore in the past?” because I know I didn’t decide to ignore anything. I’ve also found that I’m getting faster with each subsequent file, because I’m learning patterns and I can reuse tests and fixes from earlier files, which encourages me to continue.
Linting Link to heading
I use pylint for linting. I have configured
Syntastic and Vim to run pylint
automatically on saving, or see Automatically running tests, linters, or
anything
for an easy way to run pylint
every time you save a file.
pylint *.py
https://github.com/tobinjt/dotfiles/blob/master/.pylintrc is my
$HOME/.pylintrc
.
Automatic formatting Link to heading
I use Black for autoformatting my code. I run this manually and as a pre-commit hook but will probably make it automatic the next time I write some Python. I used to use YAPF but Google is now developing a fork of Black named Pyink, so I switched to Black.
black *.py
Type checking Link to heading
I use mypy for checking type
annotations, which gives me more confidence that I’m passing the right types to
functions, and helps document my code - particularly when I use type aliases to
give meaningful names to parameter types. I have configured Syntastic and
Vim to run mypy
automatically on saving,
or see Automatically running tests, linters, or
anything
for an easy way to run mypy
every time you save a file.
mypy *.py
https://github.com/tobinjt/dotfiles/blob/master/.mypy.ini is my
$HOME/.mypy.ini
.
Debugging Link to heading
I’ve started using pudb for
debugging because it presents so much more information at once. I recently
debugged a failing test where displaying all the local variables made the
problem obvious, but without that display (e.g. using pdb
or printing debug
information on each run) the debugging process would have been far slower.
To trigger debugging at a specific point in your code:
import pudb
pudb.set_trace()
Stop generating .pyc
files
Link to heading
By default Python will write compiled bytecode for foo.py
to foo.pyc
, which
I found annoying because it clutters up your source directory. I disabled that
by setting the environment variable PYTHONDONTWRITEBYTECODE
, e.g.:
export PYTHONDONTWRITEBYTECODE="No .pyc files please"
Note that you’ll probably want to unset this when installing Python modules so that you get the benefit of loading bytecode; to do that I use this wrapper function:
pip3() {
(unset PYTHONDONTWRITEBYTECODE; command pip3 "$@")
}
Upgrading packages installed with pip3
is troublesome
Link to heading
pip3
doesn’t track manually installed packages vs auto-installed packages, and
doesn’t have a way to upgrade all packages. Upgrading all packages (manually
installed and auto-installed) can be done with a shell one-liner, except that
it doesn’t take dependencies into account, so you might upgrade a dependency to
a version that breaks a package you manually installed :(
If you keep track of the packages you’ve installed, you can upgrade them with
pip3 install --upgrade pkg1 pkg2 ...
i, but I found that wasn’t reliable -
sometimes dependencies weren’t updated properly. For a long time I used a nuke
it from orbit approach: remove the site-packages
directory, reinstall Python,
and reinstall the packages I need. This approach was probably overkill, but
after debugged a failed upgrade a couple of times I found this was easier. I
used this approach for a couple of years until Homebrew started packaging Python
modules (specifically pre-commit
depends on six
); this complicated the
restoration process enough that I figured sooner or later I’d break a package,
so I changed approach.
My current approach is to use virtualenv to install all the modules in a separate directory, which I can then delete when I want to update them. I add this directory to the end of my PATH (so that the python binary in it isn’t used in preference to the system python binary) and everything Just Works. A simplified version would be:
install_dir="${HOME}/tmp/virtualenv"
mkdir -p "${install_dir}"
# Run virtualenv from the destination directory just in case.
cd "${install_dir}"
virtualenv --no-periodic-update "${install_dir}"
# virtualenv sometimes deletes the destination directory so cd there again.
cd "${install_dir}"
source bin/activate
pip3 install black lxml mypy pudb pyfakefs pylint pytest pytest-cov \
pytest-flakefinder pyyaml
I do this once a month because frequent updates make it far easier to figure out breakages, so I’ve written https://github.com/tobinjt/bin/blob/master/update-python-modules to make it a single command.
Vim configuration Link to heading
I don’t have much configuration for Python:
Make sure that syntastic uses Python 3 so type annotations can be parsed:
let g:syntastic_python_python_exec = 'python3'
Configure syntastic to use
mypy
too:let g:syntastic_python_checkers = ['python', 'mypy', 'pylint']
I chose the order
python
,mypy
,pylint
to fail fast and surface errors from most severe to least severe - syntax errors will be caught bypython
, type errors bymypy
, and lint errors bypylint
.