Migrating to ruff
from black
and flake8
When it comes to linting and formatting Python code, ruff
is the ultimate swiss army
knife. So next time you venture out into the wilderness of enterprise code bases leave your black
,
flake8
, isort
and pydocstyle
at home.
You can even forget all those plugin attachments for your flake8
. Yes, ruff
really does do it
all. There may be a few gaps but they're being plugged faster than you can list all the linting and
formatting tools you had to have before.
And did I mention it was built in rust? This means it's pretty damn fast, even on large monorepo code bases. So this coupled with fewer dependencies being installed on each CI pipeline run could potentially save a lot of time over the course of a long project.
But is it painful to switch over? That's what I endeavored to find out by having a go at migrating
to ruff
in the dynaconf
code base. Why? dynaconf
wants
a way to standardise docstrings which ruff
offers - it seemed like a good opportunity to introduce
it for its other linting and formatting capabilities before switching on the docstring checks.
The aim was to keep the formatting as close as possible to the previous configuration, at least as a start. Here's the resulting PR that was merged (success! 🎉), so let me talk you through what I learned.
Tip
Add ruff
to your dev dependencies group so it can be installed and run with your CI pipeline.
But if you want to avoid installing it in every single one of your virtual environments, you can
use pipx
. Installing with pipx
will put ruff
in an isolated
virtual environment and add the binaries into a folder that pipx
has appended to your path.
This means that you can use CLI tools like ruff
anywhere, even in your virtual environments,
with a single install.
Migration Notes
Settings file
dynaconf
doesn't have a pyproject.toml
file in the code base yet, so I decided to implement
ruff
in its own config file ruff.toml
. You can see the difference between the two options
here.
Line length
Line length in the dynaconf
code base was set to 79, applying PEP8 more strictly than black
's
more forgiving default of 88. I wanted to keep this consistent, and this was easily set at the top
level of the config file.
There were a couple of changes though which needed a bit of explaining:
- This change where a line with an emoji was moved onto a single line whereas
previously it was broken across 3. This is because
ruff
looks at the Unicode width rather than the character width. I think this means that the unicode width of this emoji 🎛️ is 1, whereas the character length may be the length of this::control_knobs:
. - This change where a line with a pragma comment (
#type
,# noqa
, etc.) is moved is explained by this reasoning. Basicallyruff
doesn't move pragma comments around as this can change their behaviour.
Use extend-select
rather than select
Using select
overwrites the default rules(1) with your specified rules rather than extending them.
Therefore, it's better to use extend-select
rather than repeating the defaults followed by your
specific rules in select
.
- From ruff docs:
By default, Ruff enables Flake8's F rules, along with a subset of the E rules, (
["E4", "E7", "E9", "F"]
) omitting any stylistic rules that overlap with the use of a formatter, like ruff format or Black.
I used this to specify the various plugins and additional tools that were being used in dynaconf
-
so ruff
has replaced 6 additional packages on top of black
and flake8
!
[lint]
extend-select =[
"T100", # https://docs.astral.sh/ruff/rules/#flake8-debugger-t10
"T2", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
"TD", # https://docs.astral.sh/ruff/rules/#flake8-todos-td
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"I", # https://docs.astral.sh/ruff/rules/#isort-i
"N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n
]
Use per-file-ignores
rather than a blanket ignore
You also want to avoid nesting config files in sub-directories to implement this -
per-file-ignores
is the way to go. This feature was available in flake8
and it's great to see it
ported over. Using this allowed us to be less strict in some areas in the test sub-directories, and
to also to ignore some rules in __init__.py
files that we would want to apply elsewhere.
[lint.per-file-ignores]
"*/__init__.py" = [
"F401", # (not used import)
"F403", # (star import `from foo import *`)
]
"tests/*" = [
"N806", # Lowercase variables
"N802", # Lowercase function names
]
"tests_functional/*" = [
"T2", # flake8-print
"TD", # flake8-todos
"N806", # flake8-todos
"E402", # (Imports not on top)
"F401", # (not used import)
]
Note
I think just __init__.py
works in the same way as */__init__.py
here.
Initially I implemented the nested config files. If you want to see the difference, you can see what I removed on this commit.
Mimicking current import sort behaviour in dynaconf
I added a few configurations to the [lint.isort]
section:
[lint.isort]
force-single-line = true
order-by-type = false
required-imports = ["from __future__ import annotations"]
known-first-party = ["dynaconf"]
force-single-line = true
because this is whatdyanconf
had already. It basically forces each individual import onto its own line rather than bundling them together. Although this results in more lines, it can result in clearer diffs if imports are added or removed in the future (although GitHub is quite good now at showing which part of the line has changed).order-by-type = false
to force pure alphabetical sorting rather than grouping functions, classes and constants and sorting alphabetically within each group. This was just what was done before.required-imports = ["from __future__ import annotations"]
will add this import to the top of every file.dynaconf
had another tool doing this before.known-first-party = ["dynaconf"]
means that when importing parts of the packages own API in the test scripts, these imports are sorted as first party rather than being bundled in with the third party libraries which happens when it's not set.
Sorting imports currently needs another pre-commit hook
This part of the ruff
docs tells you the options for setting up
pre-commit
hooks. However, the ruff
formatter currently doesn't sort imports
out of the box - you have to use this command: ruff check --select I --fix
. It does say that a
unified command for both linting and formatting is in the works though.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.3.0
hooks:
# Sort imports.
- id: ruff # (2)!
name: ruff-sort-imports # (1)!
args: [--select, I, --fix]
# Run the linter.
- id: ruff
# Run the formatter.
- id: ruff-format
- This will differentiate it from the following call when the hooks are run. I actually forgot to add this in the PR!
- Runs
ruff check
.
Quite a few hooks were removed in dynaconf
's .pre-commit-config.yaml
to be replaced with ruff
.
You can view the full changes here.
Vertical whitespace
ruff
seems to favour adding an extra blank line after module level docstrings which seems to be a
bit of a deviation from black
.
Other deviations
There is a good section in the docs highlighting all known deviations from black
.
And they also have a list of unintentional deviations in their issue
tracker, including this one which came up in the
dynaconf
changes.
Future
Switching on the docstring checks! ruff
supports multiple docstring conventions.
Most projects I see these days prefer the Google convention, as do I, and here's the simplest setup:
[tool.ruff.lint]
# Enable all `pydocstyle` rules, limiting to those that adhere to the
# Google convention via `convention = "google"`, below.
extend-select = ["D"]
[lint.pydocstyle]
convention = "google"
I'm planning to sweep over the Dynaconf
code base at some point to apply these rules. I'll put a
link to this commit once I've tackled it.
What I'm watching out for
This issue was raised to ask for one call per-line with chained method calls. I think this looks a lot better than the current rules, where something like this would be valid:
some_query = (
select(whatever.id, x.something)
.join(x, x.y == whatever.y)
.where(x > 12)
.order_by(whatever.id)
)
Wrap Up
So that's the story of my first ruff
migration - it took a bit of documentation diving to try and
keep the formatting similar to what it was before but it was worthwhile and I uncovered some great
features and learnt what ruff
is capable of.
Tip
A final tip (for VS Code users) is to install the VS Code extension for ruff
,
make this the default formatter for Python and enable "Format on Save". This is great for
formatting your code on the go and results in your pre-commit checks passing first time much
more often.
Have you implemented ruff
in any of your projects yet? How are you finding it and are there any
other features that you're finding invaluable? Please let me know in the comments!