Skip to content

Consolidate formatting: replace black and isort with ruff #9066

Description

@hjmjohnson

Proposal

Fold black and isort into ruff, using ruff format and ruff's I rules, continuing the consolidation already carried out for pyupgrade (#8606), yesqa (#8692), flake8 (#8694) and pycln (#9061). After those, black and isort are the last non-ruff tools in the formatting and linting path.

This issue exists to hold that discussion in one place. It is a proposal with a real cost attached, not a request to merge something — the caveats below are as important as the benefit, and at least one of them is a governance question rather than a technical one.

Related: #8683 (parity between local pre-commit and CI, where @Borda first raised pruning duplicated tools and @aymuos15 prototyped a ruff-only branch) and #9058 (CI Updates 2026).

Why

@Borda's rationale on #8606 applies unchanged here — "simplify the linting and reduce the number of used tools to increase consistency without any formatting loss". Concretely, one lint dependency instead of three, one configuration table instead of three, and one tool to pin, upgrade and explain to contributors.

Measured on dev, 1353 Python files, from a black/isort-clean tree. CPU seconds, because wall-clock depends on how many cores black gets to parallelise across:

CPU
black . + isort . 68.9 s
ruff format . + ruff check --select I --fix . 1.20 s

Roughly 57×. This is the least interesting part of the case and should not drive the decision.

The costs

It changes the formatting oracle. Ruff's formatter deliberately tracks black's style but is not contractually byte-identical to it, and can trail black's newer stable style by a release. Preferring a spec-frozen formatter as the reference and using ruff for linting only is a defensible position. This is the real question in this issue.

It is a one-time 283-file reformat, +268 / -651. It splits cleanly, which is what makes it reviewable:

  • 239 files differ by exactly one rule — the blank line between class Foo: and its first member, which black 26 preserves and ruff strips. 376 deleted blank lines and, verified, nothing else in those files. There is no flag that restores it, on 0.16.4 stable or --preview.
  • 44 files have substantive differences: joined implicit string concatenations, different quote normalisation where escapes are involved, different line-splitting decisions in both directions, and import-order tie-breaks.

One behavioural risk. Ruff honours # isort: off, on, skip and split, but not # isort: dont-add-import. monai/networks/nets/dynunet.py carries that directive to keep from __future__ import annotations out of the file, near-certainly for TorchScript. Without a per-file-ignores entry for I002, ruff check --fix adds the import. That is a behaviour change, not a cosmetic one, and would need TorchScript smoke-testing. It is the only such case in the tree today.

Smaller items. docs/source/conf.py would have its imports sorted for the first time, since [tool.isort] skip excluded it. Ruff ≥0.16 formats Python blocks inside Markdown, which is a scope expansion — one file, one line, here. Line length is a wash: lines over 120 characters go from 515 to 516, and E501 is ignored anyway.

Timing. A 283-file reformat conflicts with every open PR. #9058 is an active modernisation push with merge-queue work (#8991) in flight, so when matters as much as whether.

/black chatops. .github/workflows/chatops.yml dispatches /black to project-monai/monai-code-formatter, a separate repository. If this lands and that is not repointed or retired, /black will silently reformat PRs to a style the repo no longer uses.

Open questions for maintainers

  1. Is trading black-as-oracle for ruff-as-oracle acceptable, given ruff offers no byte-identity guarantee?
  2. Do downstream repositories — MONAI Deploy, the model bundles, the tutorials — share this configuration in a way that a style flip would propagate to? Nobody has checked.
  3. Who owns project-monai/monai-code-formatter, and does this mean retiring /black or repointing it?
  4. If the answer is yes, when is the right window relative to the merge-queue work?

Prerequisite

Doing this cleanly first requires each tool to have a single definition, otherwise the swap has to be made in pyproject.toml, .pre-commit-config.yaml and runtests.sh simultaneously and kept in sync. That groundwork is separable and is proposed on its own.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions