For the past 3 years, I’ve been working in the Podimo AI team. During that time, I’ve worked with Python code a lot. That wasn’t the plan, actually, as I’m a huge fan of statically compiled languages with robust type systems. Alas, most of the code I write will be maintained by data scientists, and it would be a huge effort for the rest of our small team to become productive in my preferred languages.
Therefore, I committed to writing Python in all my projects, and doing it as well as I could. The theme here, as you might guess, is having your feedback loop be as tight as possible.
What does that mean?
It means going from writing code, to validating code as fast as possible.
Tooling
- Editor: Use an editor. Make sure it supports the language server protocol, and make sure it’s configured to pick up the virtual environments. It’s a huge waste of time to develop Python code without proper autocompletion, formatting, and refactorings.
- Project Management: Use uv, there’s no competition. Manage your virtual environment, dependencies, lockfiles and
pyproject.toml
with the same tool. This is hands down the best project management tool for Python out there. - Version Control: Just use it. It will save you a world of pain. And if you can, use
pre-commit
or some equivalent for your VCS.
Development Dependencies
- Ruff: Linting and formatting.
- Deptry: Dependency lints.
- Pytest: For testing, specifically I recommend the following list, depending in your needs:
pytest
,pytest-cov
,pytest-benchmark
,pytest-profiling
. For pytest, make sure you enable doctests, which are a fantastic way to develop code.
Recommended Dependencies
- Models: I recommend
msgspec
, althoughpydantic
is a strong contender as well. But either way, make sure you have some framework for validating the shape of your data. Don’t just type everything asdict
orlist
, and don’t avoid using types either. - APIs: The landscape in Python changes quickly. My current bet is Robyn or Litestar. Both are performant APIs which support
msgspec
.
Writing Productive Tests
I discovered doctests in Python not too long ago, and I’ve started the following:
- When developing a new feature, launch a file watcher which runs unit tests whenever there are changes in the directories I’m coding in. Something like the following works:
watchexec -c -w src -- uv run pytest --doctest-modules
. This command watches for changes in thesrc
directory, and runs pytest every time it detects them. - I begin writing functions. Before I implement the function, I write a doctest for how it ought to be used.
Let me give you an example. Say you want to write a simple function that adds two numbers.
def add(a: int, b: int) -> int:
"""Adds two numbers
>>> add(1,2)
3
"""
What happens here?
If watchexec
is running, it will run pytest
, and the tests will fail.
The doctests look similar to your interpreter input/output, with >>>
denoting the start of a new statement, and ...
the continuation of a statement.
After such lines, you add the expected output as it would be displayed by your interpreter.
If the output in the doctest does not match the output as evaluated by pytest
, you get a failing test.
By having the unit tests so close to the functions themselves, we really lower the amount of friction in our feedback loop. If your tests are running automatically whenever you update your code, it’s as close to instant as it gets.
In test-driven development, the idea is to:
- Write a failing test
- Make the test pass
- Refactor
I’d like to argue that doctests in Python are the ideal way to do this at the start of a project. As your code matures, grows in complexity and needs more thorough testing, you can migrate some of your doctest logic over to unit tests, add fixtures, and so forth.