Welcome! We're happy to have you here. Thank you in advance for your contribution to Ruff.
Ruff welcomes contributions in the form of Pull Requests.
For small changes (e.g., bug fixes), feel free to submit a PR.
For larger changes (e.g., new lint rules, new functionality, new configuration options), consider creating an issue outlining your proposed change. You can also join us on Discord to discuss your idea with the community. We've labeled beginner-friendly tasks in the issue tracker, along with bugs and improvements that are ready for contributions.
If you're looking for a place to start, we recommend implementing a new lint rule (see: Adding a new lint rule, which will allow you to learn from and pattern-match against the examples in the existing codebase. Many lint rules are inspired by existing Python plugins, which can be used as a reference implementation.
As a concrete example: consider taking on one of the rules from the flake8-pyi
plugin, and looking to the originating Python source for
guidance.
If you have suggestions on how we might improve the contributing documentation, let us know!
Ruff is written in Rust. You'll need to install the Rust toolchain for development.
You'll also need Insta to update snapshot tests:
cargo install cargo-insta
and pre-commit to run some validation checks:
pipx install pre-commit # or `pip install pre-commit` if you have a virtualenv
You can optionally install pre-commit hooks to automatically run the validation checks when making a commit:
pre-commit install
After cloning the repository, run Ruff locally from the repository root with:
cargo run -p ruff_cli -- check /path/to/file.py --no-cache
Prior to opening a pull request, ensure that your code has been auto-formatted, and that it passes both the lint and test validation checks:
cargo clippy --workspace --all-targets --all-features -- -D warnings # Rust linting
RUFF_UPDATE_SCHEMA=1 cargo test # Rust testing and updating ruff.schema.json
pre-commit run --all-files --show-diff-on-failure # Rust and Python formatting, Markdown and Python linting, etc.
These checks will run on GitHub Actions when you open your Pull Request, but running them locally will save you time and expedite the merge process.
Note that many code changes also require updating the snapshot tests, which is done interactively
after running cargo test
like so:
cargo insta review
Your Pull Request will be reviewed by a maintainer, which may involve a few rounds of iteration prior to merging.
Ruff is structured as a monorepo with a flat crate structure,
such that all crates are contained in a flat crates
directory.
The vast majority of the code, including all lint rules, lives in the ruff
crate (located at
crates/ruff
). As a contributor, that's the crate that'll be most relevant to you.
At time of writing, the repository includes the following crates:
crates/ruff
: library crate containing all lint rules and the core logic for running them. If you're working on a rule, this is the crate for you.crates/ruff_benchmark
: binary crate for running micro-benchmarks.crates/ruff_cache
: library crate for caching lint results.crates/ruff_cli
: binary crate containing Ruff's command-line interface.crates/ruff_dev
: binary crate containing utilities used in the development of Ruff itself (e.g.,cargo dev generate-all
), see thecargo dev
section below.crates/ruff_diagnostics
: library crate for the rule-independent abstractions in the lint diagnostics APIs.crates/ruff_formatter
: library crate for language agnostic code formatting logic based on an intermediate representation. The backend forruff_python_formatter
.crates/ruff_index
: library crate inspired byrustc_index
.crates/ruff_macros
: proc macro crate containing macros used by Ruff.crates/ruff_notebook
: library crate for parsing and manipulating Jupyter notebooks.crates/ruff_python_ast
: library crate containing Python-specific AST types and utilities.crates/ruff_python_codegen
: library crate containing utilities for generating Python source code.crates/ruff_python_formatter
: library crate implementing the Python formatter. Emits an intermediate representation for each node, whichruff_formatter
prints based on the configured line length.crates/ruff_python_semantic
: library crate containing Python-specific semantic analysis logic, including Ruff's semantic model. Used to resolve queries like "What import does this variable refer to?"crates/ruff_python_stdlib
: library crate containing Python-specific standard library data, e.g. the names of all built-in exceptions and which standard library types are immutable.crates/ruff_python_trivia
: library crate containing Python-specific trivia utilities (e.g., for analyzing indentation, newlines, etc.).crates/ruff_python_parser
: library crate containing the Python parser.crates/ruff_wasm
: library crate for exposing Ruff as a WebAssembly module. Powers the Ruff Playground.
At a high level, the steps involved in adding a new lint rule are as follows:
-
Determine a name for the new rule as per our rule naming convention (e.g.,
AssertFalse
, as in, "allowassert False
"). -
Create a file for your rule (e.g.,
crates/ruff/src/rules/flake8_bugbear/rules/assert_false.rs
). -
In that file, define a violation struct (e.g.,
pub struct AssertFalse
). You can grep for#[violation]
to see examples. -
In that file, define a function that adds the violation to the diagnostic list as appropriate (e.g.,
pub(crate) fn assert_false
) based on whatever inputs are required for the rule (e.g., anast::StmtAssert
node). -
Define the logic for invoking the diagnostic in
crates/ruff/src/checkers/ast/analyze
(for AST-based rules),crates/ruff/src/checkers/tokens.rs
(for token-based rules),crates/ruff/src/checkers/physical_lines.rs
(for text-based rules),crates/ruff/src/checkers/filesystem.rs
(for filesystem-based rules), etc. For AST-based rules, you'll likely want to modifyanalyze/statement.rs
(if your rule is based on analyzing statements, like imports) oranalyze/expression.rs
(if your rule is based on analyzing expressions, like function calls). -
Map the violation struct to a rule code in
crates/ruff/src/codes.rs
(e.g.,B011
). -
Add proper testing for your rule.
-
Update the generated files (documentation and generated code).
To trigger the violation, you'll likely want to augment the logic in crates/ruff/src/checkers/ast.rs
to call your new function at the appropriate time and with the appropriate inputs. The Checker
defined therein is a Python AST visitor, which iterates over the AST, building up a semantic model,
and calling out to lint rule analyzer functions as it goes.
If you need to inspect the AST, you can run cargo dev print-ast
with a Python file. Grep
for the Diagnostic::new
invocations to understand how other, similar rules are implemented.
Once you're satisfied with your code, add tests for your rule. See rule testing for more details.
Finally, regenerate the documentation and other generated assets (like our JSON Schema) with:
cargo dev generate-all
.
Like Clippy, Ruff's rule names should make grammatical and logical sense when read as "allow ${rule}" or "allow ${rule} items", as in the context of suppression comments.
For example, AssertFalse
fits this convention: it flags assert False
statements, and so a
suppression comment would be framed as "allow assert False
".
As such, rule names should...
-
Highlight the pattern that is being linted against, rather than the preferred alternative. For example,
AssertFalse
guards againstassert False
statements. -
Not contain instructions on how to fix the violation, which instead belong in the rule documentation and the
autofix_title
. -
Not contain a redundant prefix, like
Disallow
orBanned
, which are already implied by the convention.
When re-implementing rules from other linters, we prioritize adhering to this convention over preserving the original rule name.
To test rules, Ruff uses snapshots of Ruff's output for a given file (fixture). Generally, there
will be one file per rule (e.g., E402.py
), and each file will contain all necessary examples of
both violations and non-violations. cargo insta review
will generate a snapshot file containing
Ruff's output for each fixture, which you can then commit alongside your changes.
Once you've completed the code for the rule itself, you can define tests with the following steps:
-
Add a Python file to
crates/ruff/resources/test/fixtures/[linter]
that contains the code you want to test. The file name should match the rule name (e.g.,E402.py
), and it should include examples of both violations and non-violations. -
Run Ruff locally against your file and verify the output is as expected. Once you're satisfied with the output (you see the violations you expect, and no others), proceed to the next step. For example, if you're adding a new rule named
E402
, you would run:cargo run -p ruff_cli -- check crates/ruff/resources/test/fixtures/pycodestyle/E402.py --no-cache --select E402
Note: Only a subset of rules are enabled by default. When testing a new rule, ensure that you activate it by adding
--select ${rule_code}
to the command. -
Add the test to the relevant
crates/ruff/src/rules/[linter]/mod.rs
file. If you're contributing a rule to a pre-existing set, you should be able to find a similar example to pattern-match against. If you're adding a new linter, you'll need to create a newmod.rs
file (see, e.g.,crates/ruff/src/rules/flake8_bugbear/mod.rs
) -
Run
cargo test
. Your test will fail, but you'll be prompted to follow-up withcargo insta review
. Runcargo insta review
, review and accept the generated snapshot, then commit the snapshot file alongside the rest of your changes. -
Run
cargo test
again to ensure that your test passes.
Ruff's user-facing settings live in a few different places.
First, the command-line options are defined via the Cli
struct in crates/ruff/src/cli.rs
.
Second, the pyproject.toml
options are defined in crates/ruff/src/settings/options.rs
(via the
Options
struct), crates/ruff/src/settings/configuration.rs
(via the Configuration
struct), and
crates/ruff/src/settings/mod.rs
(via the Settings
struct). These represent, respectively: the
schema used to parse the pyproject.toml
file; an internal, intermediate representation; and the
final, internal representation used to power Ruff.
To add a new configuration option, you'll likely want to modify these latter few files (along with
cli.rs
, if appropriate). If you want to pattern-match against an existing example, grep for
dummy_variable_rgx
, which defines a regular expression to match against acceptable unused
variables (e.g., _
).
Note that plugin-specific configuration options are defined in their own modules (e.g.,
crates/ruff/src/flake8_unused_arguments/settings.rs
).
You may also want to add the new configuration option to the flake8-to-ruff
tool, which is
responsible for converting flake8
configuration files to Ruff's TOML format. This logic
lives in crates/ruff/src/flake8_to_ruff/converter.rs
.
Finally, regenerate the documentation and generated code with cargo dev generate-all
.
To preview any changes to the documentation locally:
-
Install the Rust toolchain.
-
Install MkDocs and Material for MkDocs with:
pip install -r docs/requirements.txt
-
Generate the MkDocs site with:
python scripts/generate_mkdocs.py
-
Run the development server with:
# For contributors. mkdocs serve -f mkdocs.generated.yml # For members of the Astral org, which has access to MkDocs Insiders via sponsorship. mkdocs serve -f mkdocs.insiders.yml
The documentation should then be available locally at http://127.0.0.1:8000/docs/.
As of now, Ruff has an ad hoc release process: releases are cut with high frequency via GitHub Actions, which automatically generates the appropriate wheels across architectures and publishes them to PyPI.
Ruff follows the semver versioning standard. However, as pre-1.0 software, even patch releases may contain non-backwards-compatible changes.
- Update the version with
rg 0.0.269 --files-with-matches | xargs sed -i 's/0.0.269/0.0.270/g'
- Update
BREAKING_CHANGES.md
- Create a PR with the version and
BREAKING_CHANGES.md
updated - Merge the PR
- Run the release workflow with the version number (without starting
v
) as input. Make sure main has your merged PR as last commit - The release workflow will do the following:
- Build all the assets. If this fails (even though we tested in step 4), we haven't tagged or uploaded anything, you can restart after pushing a fix.
- Upload to PyPI.
- Create and push the Git tag (as extracted from
pyproject.toml
). We create the Git tag only after building the wheels and uploading to PyPI, since we can't delete or modify the tag (#4468). - Attach artifacts to draft GitHub release
- Trigger downstream repositories. This can fail non-catastrophically, as we can run any downstream jobs manually if needed.
- Create release notes in GitHub UI and promote from draft.
- If needed, update the schemastore
- If needed, update the
ruff-lsp
andruff-vscode
repositories.
GitHub Actions will run your changes against a number of real-world projects from GitHub and report on any diagnostic differences. You can also run those checks locally via:
python scripts/check_ecosystem.py path/to/your/ruff path/to/older/ruff
You can also run the Ecosystem CI check in a Docker container across a larger set of projects by
downloading the known-github-tomls.json
as github_search.jsonl
and following the instructions in scripts/Dockerfile.ecosystem.
Note that this check will take a while to run.
We have several ways of benchmarking and profiling Ruff:
- Our main performance benchmark comparing Ruff with other tools on the CPython codebase
- Microbenchmarks which the linter or the formatter on individual files. There run on pull requests.
- Profiling the linter on either the microbenchmarks or entire projects
First, clone CPython. It's a large and diverse Python codebase, which makes it a good target for benchmarking.
git clone --branch 3.10 https://github.com/python/cpython.git crates/ruff/resources/test/cpython
To benchmark the release build:
cargo build --release && hyperfine --warmup 10 \
"./target/release/ruff ./crates/ruff/resources/test/cpython/ --no-cache -e" \
"./target/release/ruff ./crates/ruff/resources/test/cpython/ -e"
Benchmark 1: ./target/release/ruff ./crates/ruff/resources/test/cpython/ --no-cache
Time (mean ± σ): 293.8 ms ± 3.2 ms [User: 2384.6 ms, System: 90.3 ms]
Range (min … max): 289.9 ms … 301.6 ms 10 runs
Benchmark 2: ./target/release/ruff ./crates/ruff/resources/test/cpython/
Time (mean ± σ): 48.0 ms ± 3.1 ms [User: 65.2 ms, System: 124.7 ms]
Range (min … max): 45.0 ms … 66.7 ms 62 runs
Summary
'./target/release/ruff ./crates/ruff/resources/test/cpython/' ran
6.12 ± 0.41 times faster than './target/release/ruff ./crates/ruff/resources/test/cpython/ --no-cache'
To benchmark against the ecosystem's existing tools:
hyperfine --ignore-failure --warmup 5 \
"./target/release/ruff ./crates/ruff/resources/test/cpython/ --no-cache" \
"pyflakes crates/ruff/resources/test/cpython" \
"autoflake --recursive --expand-star-imports --remove-all-unused-imports --remove-unused-variables --remove-duplicate-keys resources/test/cpython" \
"pycodestyle crates/ruff/resources/test/cpython" \
"flake8 crates/ruff/resources/test/cpython"
Benchmark 1: ./target/release/ruff ./crates/ruff/resources/test/cpython/ --no-cache
Time (mean ± σ): 294.3 ms ± 3.3 ms [User: 2467.5 ms, System: 89.6 ms]
Range (min … max): 291.1 ms … 302.8 ms 10 runs
Warning: Ignoring non-zero exit code.
Benchmark 2: pyflakes crates/ruff/resources/test/cpython
Time (mean ± σ): 15.786 s ± 0.143 s [User: 15.560 s, System: 0.214 s]
Range (min … max): 15.640 s … 16.157 s 10 runs
Warning: Ignoring non-zero exit code.
Benchmark 3: autoflake --recursive --expand-star-imports --remove-all-unused-imports --remove-unused-variables --remove-duplicate-keys resources/test/cpython
Time (mean ± σ): 6.175 s ± 0.169 s [User: 54.102 s, System: 1.057 s]
Range (min … max): 5.950 s … 6.391 s 10 runs
Benchmark 4: pycodestyle crates/ruff/resources/test/cpython
Time (mean ± σ): 46.921 s ± 0.508 s [User: 46.699 s, System: 0.202 s]
Range (min … max): 46.171 s … 47.863 s 10 runs
Warning: Ignoring non-zero exit code.
Benchmark 5: flake8 crates/ruff/resources/test/cpython
Time (mean ± σ): 12.260 s ± 0.321 s [User: 102.934 s, System: 1.230 s]
Range (min … max): 11.848 s … 12.933 s 10 runs
Warning: Ignoring non-zero exit code.
Summary
'./target/release/ruff ./crates/ruff/resources/test/cpython/ --no-cache' ran
20.98 ± 0.62 times faster than 'autoflake --recursive --expand-star-imports --remove-all-unused-imports --remove-unused-variables --remove-duplicate-keys resources/test/cpython'
41.66 ± 1.18 times faster than 'flake8 crates/ruff/resources/test/cpython'
53.64 ± 0.77 times faster than 'pyflakes crates/ruff/resources/test/cpython'
159.43 ± 2.48 times faster than 'pycodestyle crates/ruff/resources/test/cpython'
To benchmark a subset of rules, e.g. LineTooLong
and DocLineTooLong
:
cargo build --release && hyperfine --warmup 10 \
"./target/release/ruff ./crates/ruff/resources/test/cpython/ --no-cache -e --select W505,E501"
You can run poetry install
from ./scripts/benchmarks
to create a working environment for the
above. All reported benchmarks were computed using the versions specified by
./scripts/benchmarks/pyproject.toml
on Python 3.11.
To benchmark Pylint, remove the following files from the CPython repository:
rm Lib/test/bad_coding.py \
Lib/test/bad_coding2.py \
Lib/test/bad_getattr.py \
Lib/test/bad_getattr2.py \
Lib/test/bad_getattr3.py \
Lib/test/badcert.pem \
Lib/test/badkey.pem \
Lib/test/badsyntax_3131.py \
Lib/test/badsyntax_future10.py \
Lib/test/badsyntax_future3.py \
Lib/test/badsyntax_future4.py \
Lib/test/badsyntax_future5.py \
Lib/test/badsyntax_future6.py \
Lib/test/badsyntax_future7.py \
Lib/test/badsyntax_future8.py \
Lib/test/badsyntax_future9.py \
Lib/test/badsyntax_pep3120.py \
Lib/test/test_asyncio/test_runners.py \
Lib/test/test_copy.py \
Lib/test/test_inspect.py \
Lib/test/test_typing.py
Then, from crates/ruff/resources/test/cpython
, run: time pylint -j 0 -E $(git ls-files '*.py')
. This
will execute Pylint with maximum parallelism and only report errors.
To benchmark Pyupgrade, run the following from crates/ruff/resources/test/cpython
:
hyperfine --ignore-failure --warmup 5 --prepare "git reset --hard HEAD" \
"find . -type f -name \"*.py\" | xargs -P 0 pyupgrade --py311-plus"
Benchmark 1: find . -type f -name "*.py" | xargs -P 0 pyupgrade --py311-plus
Time (mean ± σ): 30.119 s ± 0.195 s [User: 28.638 s, System: 0.390 s]
Range (min … max): 29.813 s … 30.356 s 10 runs
The ruff_benchmark
crate benchmarks the linter and the formatter on individual files.
You can run the benchmarks with
cargo benchmark
Ruff uses Criterion.rs for benchmarks. You can use
--save-baseline=<name>
to store an initial baseline benchmark (e.g. on main
) and then use
--benchmark=<name>
to compare against that benchmark. Criterion will print a message telling you
if the benchmark improved/regressed compared to that baseline.
# Run once on your "baseline" code
cargo benchmark --save-baseline=main
# Then iterate with
cargo benchmark --baseline=main
You can use --save-baseline
and critcmp
to get a pretty comparison between two recordings.
This is useful to illustrate the improvements of a PR.
# On main
cargo benchmark --save-baseline=main
# After applying your changes
cargo benchmark --save-baseline=pr
critcmp main pr
You must install critcmp
for the comparison.
cargo install critcmp
- Use
cargo benchmark <filter>
to only run specific benchmarks. For example:cargo benchmark linter/pydantic
to only run the pydantic tests. - Use
cargo benchmark --quiet
for a more cleaned up output (without statistical relevance) - Use
cargo benchmark --quick
to get faster results (more prone to noise)
You can either use the microbenchmarks from above or a project directory for benchmarking. There are a lot of profiling tools out there, The Rust Performance Book lists some examples.
Install perf
and build ruff_benchmark
with the release-debug
profile and then run it with perf
cargo bench -p ruff_benchmark --no-run --profile=release-debug && perf record --call-graph dwarf -F 9999 cargo bench -p ruff_benchmark --profile=release-debug -- --profile-time=1
You can also use the ruff_dev
launcher to run ruff check
multiple times on a repository to
gather enough samples for a good flamegraph (change the 999, the sample rate, and the 30, the number
of checks, to your liking)
cargo build --bin ruff_dev --profile=release-debug
perf record -g -F 999 target/release-debug/ruff_dev repeat --repeat 30 --exit-zero --no-cache path/to/cpython > /dev/null
Then convert the recorded profile
perf script -F +pid > /tmp/test.perf
You can now view the converted file with firefox profiler, with a more in-depth guide here
An alternative is to convert the perf data to flamegraph.svg
using
flamegraph (cargo install flamegraph
):
flamegraph --perfdata perf.data --no-inline
Install cargo-instruments
:
cargo install cargo-instruments
Then run the profiler with
cargo instruments -t time --bench linter --profile release-debug -p ruff_benchmark -- --profile-time=1
-t
: Specifies what to profile. Useful options aretime
to profile the wall time andalloc
for profiling the allocations.- You may want to pass an additional filter to run a single test file
Otherwise, follow the instructions from the linux section.
cargo dev
is a shortcut for cargo run --package ruff_dev --bin ruff_dev
. You can run some useful
utils with it:
cargo dev print-ast <file>
: Print the AST of a python file using the RustPython parser that is mainly used in Ruff. Forif True: pass # comment
, you can see the syntax tree, the byte offsets for start and stop of each node and also how the:
token, the comment and whitespace are not represented anymore:
[
If(
StmtIf {
range: 0..13,
test: Constant(
ExprConstant {
range: 3..7,
value: Bool(
true,
),
kind: None,
},
),
body: [
Pass(
StmtPass {
range: 9..13,
},
),
],
orelse: [],
},
),
]
cargo dev print-tokens <file>
: Print the tokens that the AST is built upon. Again forif True: pass # comment
:
0 If 2
3 True 7
7 Colon 8
9 Pass 13
14 Comment(
"# comment",
) 23
23 Newline 24
cargo dev print-cst <file>
: Print the CST of a python file using LibCST, which is used in addition to the RustPython parser in Ruff. E.g. forif True: pass # comment
everything including the whitespace is represented:
Module {
body: [
Compound(
If(
If {
test: Name(
Name {
value: "True",
lpar: [],
rpar: [],
},
),
body: SimpleStatementSuite(
SimpleStatementSuite {
body: [
Pass(
Pass {
semicolon: None,
},
),
],
leading_whitespace: SimpleWhitespace(
" ",
),
trailing_whitespace: TrailingWhitespace {
whitespace: SimpleWhitespace(
" ",
),
comment: Some(
Comment(
"# comment",
),
),
newline: Newline(
None,
Real,
),
},
},
),
orelse: None,
leading_lines: [],
whitespace_before_test: SimpleWhitespace(
" ",
),
whitespace_after_test: SimpleWhitespace(
"",
),
is_elif: false,
},
),
),
],
header: [],
footer: [],
default_indent: " ",
default_newline: "\n",
has_trailing_newline: true,
encoding: "utf-8",
}
cargo dev generate-all
: Updateruff.schema.json
,docs/configuration.md
anddocs/rules
. You can also setRUFF_UPDATE_SCHEMA=1
to updateruff.schema.json
duringcargo test
.cargo dev generate-cli-help
,cargo dev generate-docs
andcargo dev generate-json-schema
: Update justdocs/configuration.md
,docs/rules
andruff.schema.json
respectively.cargo dev generate-options
: Generate a markdown-compatible table of allpyproject.toml
options. Used for https://beta.ruff.rs/docs/settings/cargo dev generate-rules-table
: Generate a markdown-compatible table of all rules. Used for https://beta.ruff.rs/docs/rules/cargo dev round-trip <python file or jupyter notebook>
: Read a Python file or Jupyter Notebook, parse it, serialize the parsed representation and write it back. Used to check how good our representation is so that fixes don't rewrite irrelevant parts of a file.cargo dev format_dev
: See ruff_python_formatter README.md
If we view Ruff as a compiler, in which the inputs are paths to Python files and the outputs are diagnostics, then our current compilation pipeline proceeds as follows:
-
File discovery: Given paths like
foo/
, locate all Python files in any specified subdirectories, taking into account our hierarchical settings system and anyexclude
options. -
Package resolution: Determine the "package root" for every file by traversing over its parent directories and looking for
__init__.py
files. -
Cache initialization: For every "package root", initialize an empty cache.
-
Analysis: For every file, in parallel:
-
Cache read: If the file is cached (i.e., its modification timestamp hasn't changed since it was last analyzed), short-circuit, and return the cached diagnostics.
-
Tokenization: Run the lexer over the file to generate a token stream.
-
Indexing: Extract metadata from the token stream, such as: comment ranges,
# noqa
locations,# isort: off
locations, "doc lines", etc. -
Token-based rule evaluation: Run any lint rules that are based on the contents of the token stream (e.g., commented-out code).
-
Filesystem-based rule evaluation: Run any lint rules that are based on the contents of the filesystem (e.g., lack of
__init__.py
file in a package). -
Logical line-based rule evaluation: Run any lint rules that are based on logical lines (e.g., stylistic rules).
-
Parsing: Run the parser over the token stream to produce an AST. (This consumes the token stream, so anything that relies on the token stream needs to happen before parsing.)
-
AST-based rule evaluation: Run any lint rules that are based on the AST. This includes the vast majority of lint rules. As part of this step, we also build the semantic model for the current file as we traverse over the AST. Some lint rules are evaluated eagerly, as we iterate over the AST, while others are evaluated in a deferred manner (e.g., unused imports, since we can't determine whether an import is unused until we've finished analyzing the entire file), after we've finished the initial traversal.
-
Import-based rule evaluation: Run any lint rules that are based on the module's imports (e.g., import sorting). These could, in theory, be included in the AST-based rule evaluation phase — they're just separated for simplicity.
-
Physical line-based rule evaluation: Run any lint rules that are based on physical lines (e.g., line-length).
-
Suppression enforcement: Remove any violations that are suppressed via
# noqa
directives orper-file-ignores
. -
Cache write: Write the generated diagnostics to the package cache using the file as a key.
-
-
Reporting: Print diagnostics in the specified format (text, JSON, etc.), to the specified output channel (stdout, a file, etc.).
To understand Ruff's import categorization system, we first need to define two concepts:
- "Project root": The directory containing the
pyproject.toml
,ruff.toml
, or.ruff.toml
file, discovered by identifying the "closest" such directory for each Python file. (If you're running viaruff --config /path/to/pyproject.toml
, then the current working directory is used as the "project root".) - "Package root": The top-most directory defining the Python package that includes a given Python
file. To find the package root for a given Python file, traverse up its parent directories until
you reach a parent directory that doesn't contain an
__init__.py
file (and isn't marked as a namespace package); take the directory just before that, i.e., the first directory in the package.
For example, given:
my_project
├── pyproject.toml
└── src
└── foo
├── __init__.py
└── bar
├── __init__.py
└── baz.py
Then when analyzing baz.py
, the project root would be the top-level directory (./my_project
),
and the package root would be ./my_project/src/foo
.
The project root does not have a significant impact beyond that all relative paths within the loaded configuration file are resolved relative to the project root.
For example, to indicate that bar
above is a namespace package (it isn't, but let's run with it),
the pyproject.toml
would list namespace-packages = ["./src/bar"]
, which would resolve
to my_project/src/bar
.
The same logic applies when providing a configuration file via --config
. In that case, the
current working directory is used as the project root, and so all paths in that configuration file
are resolved relative to the current working directory. (As a general rule, we want to avoid relying
on the current working directory as much as possible, to ensure that Ruff exhibits the same behavior
regardless of where and how you invoke it — but that's hard to avoid in this case.)
Additionally, if a pyproject.toml
file extends another configuration file, Ruff will still use
the directory containing that pyproject.toml
file as the project root. For example, if
./my_project/pyproject.toml
contains:
[tool.ruff]
extend = "/path/to/pyproject.toml"
Then Ruff will use ./my_project
as the project root, even though the configuration file extends
/path/to/pyproject.toml
. As such, if the configuration file at /path/to/pyproject.toml
contains
any relative paths, they will be resolved relative to ./my_project
.
If a project uses nested configuration files, then Ruff would detect multiple project roots, one for each configuration file.
The package root is used to determine a file's "module path". Consider, again, baz.py
. In that
case, ./my_project/src/foo
was identified as the package root, so the module path for baz.py
would resolve to foo.bar.baz
— as computed by taking the relative path from the package root
(inclusive of the root itself). The module path can be thought of as "the path you would use to
import the module" (e.g., import foo.bar.baz
).
The package root and module path are used to, e.g., convert relative to absolute imports, and for import categorization, as described below.
When sorting and formatting import blocks, Ruff categorizes every import into one of five categories:
- "Future": the import is a
__future__
import. That's easy: just look at the name of the imported module! - "Standard library": the import comes from the Python standard library (e.g.,
import os
). This is easy too: we include a list of all known standard library modules in Ruff itself, so it's a simple lookup. - "Local folder": the import is a relative import (e.g.,
from .foo import bar
). This is easy too: just check if the import includes alevel
(i.e., a dot-prefix). - "First party": the import is part of the current project. (More on this below.)
- "Third party": everything else.
The real challenge lies in determining whether an import is first-party — everything else is either trivial, or (as in the case of third-party) merely defined as "not first-party".
There are three ways in which an import can be categorized as "first-party":
- Explicit settings: the import is marked as such via the
known-first-party
setting. (This should generally be seen as an escape hatch.) - Same-package: the imported module is in the same package as the current file. This gets back
to the importance of the "package root" and the file's "module path". Imagine that we're
analyzing
baz.py
above. Ifbaz.py
contains any imports that appear to come from thefoo
package (e.g.,from foo import bar
orimport foo.bar
), they'll be classified as first-party automatically. This check is as simple as comparing the first segment of the current file's module path to the first segment of the import. - Source roots: Ruff supports a
[src](https://beta.ruff.rs/docs/settings/#src)
setting, which sets the directories to scan when identifying first-party imports. The algorithm is straightforward: given an import, likeimport foo
, iterate over the directories enumerated in thesrc
setting and, for each directory, check for the existence of a subdirectoryfoo
or a filefoo.py
.
By default, src
is set to the project root. In the above example, we'd want to set
src = ["./src"]
to ensure that we locate ./my_project/src/foo
and thus categorize import foo
as first-party in baz.py
. In practice, for this limited example, setting src = ["./src"]
is
unnecessary, as all imports within ./my_project/src/foo
would be categorized as first-party via
the same-package heuristic; but your project contains multiple packages, you'll want to set src
explicitly.