Skip to content

Conversation

@ntBre
Copy link
Contributor

@ntBre ntBre commented Dec 22, 2025

Summary

This PR adds a new rule, non-empty-init-module, which restricts the kind of
code that can be included in an __init__.py file. By default, docstrings,
imports, and assignments to __all__ are allowed. When the new configuration
option lint.ruff.strictly-empty-init-modules is enabled, no code at all is
allowed.

This closes #9848, where these two variants correspond to different rules in the
flake8-empty-init-modules
linter. The upstream rules are EIM001, which bans all code, and EIM002, which
bans non-import/docstring/__all__ code. Since we discussed folding these into
one rule on Discord, I just added the rule to the RUF group instead of
adding a new EIM plugin.

I'm not really sure we need to flag docstrings even when the strict setting is
enabled, but I just followed upstream for now. Similarly, as I noted in a TODO
comment, we could also allow more statements involving __all__, such as
__all__.append(...) or __all__.extend(...). The current version only allows
assignments, like upstream, as well as annotated and augmented assignments,
unlike upstream.

I think when we discussed this previously, we considered flagging the module
itself as containing code, but for now I followed the upstream implementation of
flagging each statement in the module that breaks the rule (actually the
upstream linter flags each line, including comments). This will obviously be a
bit noisier, emitting many diagnostics for the same module. But this also seems
preferable because it flags every statement that needs to be fixed up front
instead of only emitting one diagnostic for the whole file that persists as you
keep removing more lines. It was also easy to implement in analyze::statement
without a separate visitor.

The first commit adds the rule and baseline tests, the second commit adds the
option and a diff test showing the additional diagnostics when the setting is
enabled.

I noticed a small (~2%) performance regression on our largest benchmark, so I also added a cached Checker::in_init_module field and method instead of the Checker::path method. This was almost the only reason for the Checker::path field at all, but there's one remaining reference in a warn_user! call.

Test Plan

New tests adapted from the upstream linter

Ecosystem Report

I've spot-checked the ecosystem report, and the results look "correct." This is obviously a very noisy rule if you do include code in __init__.py files. We could make it less noisy by adding more exceptions (e.g. allowing if TYPE_CHECKING blocks, allowing __getattr__ functions, allowing imports from importlib assignments), but I'm sort of inclined just to start simple and see what users need.

@ntBre ntBre added rule Implementing or modifying a lint rule preview Related to preview mode features labels Dec 22, 2025
@astral-sh-bot
Copy link

astral-sh-bot bot commented Dec 22, 2025

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

ℹ️ ecosystem check detected linter changes. (+2016 -0 violations, +0 -0 fixes in 24 projects; 31 projects unchanged)

DisnakeDev/disnake (+28 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview

+ disnake/__init__.py:13:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ disnake/__init__.py:15:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ disnake/__init__.py:16:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ disnake/__init__.py:83:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ disnake/ext/mypy_plugin/__init__.py:11:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ disnake/ext/mypy_plugin/__init__.py:7:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ disnake/ext/tasks/__init__.py:38:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ disnake/ext/tasks/__init__.py:39:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ disnake/ext/tasks/__init__.py:40:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ disnake/ext/tasks/__init__.py:41:1: RUF067 `__init__` module should only contain docstrings and re-exports
... 18 additional changes omitted for project

RasaHQ/rasa (+12 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview

+ rasa/__init__.py:10:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ rasa/cli/__init__.py:5:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ rasa/core/__init__.py:5:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ rasa/core/channels/__init__.py:28:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ rasa/core/channels/__init__.py:47:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ rasa/core/training/__init__.py:10:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ rasa/core/training/__init__.py:33:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ rasa/nlu/__init__.py:5:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ rasa/nlu/classifiers/__init__.py:3:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ rasa/nlu/utils/__init__.py:11:1: RUF067 `__init__` module should only contain docstrings and re-exports
... 2 additional changes omitted for project

apache/airflow (+389 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview --select ALL

+ airflow-core/src/airflow/__init__.py:130:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ airflow-core/src/airflow/__init__.py:137:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ airflow-core/src/airflow/__init__.py:38:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ airflow-core/src/airflow/__init__.py:46:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ airflow-core/src/airflow/__init__.py:80:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ airflow-core/src/airflow/__init__.py:84:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ airflow-core/src/airflow/api/client/__init__.py:25:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ airflow-core/src/airflow/api_fastapi/core_api/routes/public/__init__.py:53:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ airflow-core/src/airflow/api_fastapi/core_api/routes/public/__init__.py:56:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ airflow-core/src/airflow/api_fastapi/core_api/routes/public/__init__.py:61:1: RUF067 `__init__` module should only contain docstrings and re-exports
... 379 additional changes omitted for project

apache/superset (+70 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview --select ALL

+ superset-core/src/superset_core/mcp/__init__.py:100:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ superset-core/src/superset_core/mcp/__init__.py:41:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ superset-core/src/superset_core/mcp/__init__.py:44:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ superset/__init__.py:37:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ superset/__init__.py:38:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ superset/__init__.py:39:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ superset/__init__.py:40:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ superset/__init__.py:41:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ superset/__init__.py:42:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ superset/__init__.py:45:1: RUF067 `__init__` module should only contain docstrings and re-exports
... 60 additional changes omitted for project

bokeh/bokeh (+56 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview --select ALL

+ src/bokeh/__init__.py:101:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/bokeh/__init__.py:102:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/bokeh/__init__.py:107:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/bokeh/__init__.py:109:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/bokeh/__init__.py:110:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/bokeh/__init__.py:111:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/bokeh/__init__.py:37:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/bokeh/__init__.py:62:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/bokeh/__init__.py:85:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/bokeh/__init__.py:92:1: RUF067 `__init__` module should only contain docstrings and re-exports
... 46 additional changes omitted for project

ibis-project/ibis (+142 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview

+ ibis/__init__.py:31:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ ibis/__init__.py:42:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ ibis/backends/__init__.py:1517:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ ibis/backends/__init__.py:1530:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ ibis/backends/__init__.py:1550:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ ibis/backends/__init__.py:1557:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ ibis/backends/__init__.py:1567:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ ibis/backends/__init__.py:1601:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ ibis/backends/__init__.py:1648:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ ibis/backends/__init__.py:1673:1: RUF067 `__init__` module should only contain docstrings and re-exports
... 132 additional changes omitted for project

langchain-ai/langchain (+274 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview

+ libs/cli/tests/unit_tests/migrate/cli_runner/cases/__init__.py:14:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ libs/cli/tests/unit_tests/migrate/cli_runner/cases/__init__.py:15:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ libs/cli/tests/unit_tests/migrate/cli_runner/cases/__init__.py:6:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ libs/core/langchain_core/__init__.py:19:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ libs/core/langchain_core/__init__.py:20:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ libs/core/langchain_core/_api/__init__.py:45:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ libs/core/langchain_core/callbacks/__init__.py:86:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ libs/core/langchain_core/document_loaders/__init__.py:21:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ libs/core/langchain_core/documents/__init__.py:39:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ libs/core/langchain_core/embeddings/__init__.py:16:1: RUF067 `__init__` module should only contain docstrings and re-exports
... 264 additional changes omitted for project

latchbio/latch (+52 -0 violations, +0 -0 fixes)

ruff check --no-cache --exit-zero --no-fix --output-format concise --preview

+ src/latch_cli/docker_utils/__init__.py:19:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/latch_cli/docker_utils/__init__.py:27:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/latch_cli/docker_utils/__init__.py:38:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/latch_cli/docker_utils/__init__.py:401:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/latch_cli/docker_utils/__init__.py:427:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/latch_cli/services/init/assemble_and_sort/__init__.py:13:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/latch_cli/services/init/assemble_and_sort/__init__.py:14:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/latch_cli/services/init/assemble_and_sort/__init__.py:58:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/latch_cli/services/init/assemble_and_sort/__init__.py:81:1: RUF067 `__init__` module should only contain docstrings and re-exports
+ src/latch_cli/services/init/assemble_and_sort/__init__.py:86:1: RUF067 `__init__` module should only contain docstrings and re-exports
... 42 additional changes omitted for project

... Truncated remaining completed project reports due to GitHub comment length restrictions

Changes by rule (1 rules affected)

code total + violation - violation + fix - fix
RUF067 2016 2016 0 0 0

impl Violation for NonEmptyInitModule {
#[derive_message_formats]
fn message(&self) -> String {
"Code detected".to_string()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely open to suggestions here, this feels a bit too short. Adding in an __init__.py file seems redundant with the filename in the output, though. The upstream linter at least customizes the message based on the strictness (e.g. Non-import code detected for the default).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the message is a bit surprising. It's like, yeah, this is code, what's wrong with it?

Using separate messages based on strictness probably makes sense. For strict mode, maybe init module must be empty (although it doesn't really say what's wrong)?

@ntBre ntBre marked this pull request as ready for review December 22, 2025 18:59
@ntBre ntBre requested a review from MichaReiser December 22, 2025 18:59
@ntBre
Copy link
Contributor Author

ntBre commented Dec 23, 2025

Based on the ecosystem results, and my conversation with Micha today, I added a few more allowed statement types in non-strict mode:

  • Module-level __getattr__ functions
  • TYPE_CHECKING blocks
  • Assignments to __path__ (to support legacy namespace packages)

I also fixed my top-level statement check, which was previously only checking the scope and thus emitting diagnostics for statements within if bodies, as just one example.

That will clear out 10/17 of the airflow diagnostics rendered in the comment, so I expect it to drop the total number of ecosystem hits substantially too.

@ntBre
Copy link
Contributor Author

ntBre commented Dec 23, 2025

A few remaining common patterns in the ecosystem:

  • Initializing loggers
  • Setting __version__
  • __dir__ functions

I think I'll add support for the last two at least since __dir__ was added in the same PEP as __getattr__ and often appears with it, and it seems safe enough to allow __version__ too.

I'm less convinced about the loggers since they can be hard to detect and also seem a bit less common.

ntBre added 10 commits December 23, 2025 15:35
Summary
--

This PR adds a new rule, `non-empty-init-module`, which restricts the kind of
code that can be included in an `__init__.py` file. By default, docstrings,
imports, and assignments to `__all__` are allowed. When the new configuration
option `lint.ruff.strictly-empty-init-modules` is enabled, no code at all is
allowed.

This closes #9848, where these two variants correspond to different rules in the
[`flake8-empty-init-modules`](https://github.com/samueljsb/flake8-empty-init-modules/)
linter. The upstream rules are EIM001, which bans all code, and EIM002, which
bans non-import/docstring/`__all__` code. Since we discussed folding these into
one rule on [Discord], I just added the rule to the `RUF` group instead of
adding a new `EIM` plugin.

I'm not really sure we need to flag docstrings even when the strict setting is
enabled, but I just followed upstream for now. Similarly, as I noted in a TODO
comment, we could also allow more statements involving `__all__`, such as
`__all__.append(...)` or `__all__.extend(...)`. The current version only allows
assignments, like upstream, as well as annotated and augmented assignments,
unlike upstream.

I think when we discussed this previously, we considered flagging the module
itself as containing code, but for now I followed the upstream implementation of
flagging each statement in the module that breaks the rule (actually the
upstream linter flags each _line_, including comments). This will obviously be a
bit noisier, emitting many diagnostics for the same module. But this also seems
preferable because it flags every statement that needs to be fixed up front
instead of only emitting one diagnostic for the whole file that persists as you
keep removing more lines. It was also easy to implement in `analyze::statement`
without a separate visitor.

The first commit adds the rule and baseline tests, the second commit adds the
option and a diff test showing the additional diagnostics when the setting is
enabled.

Test Plan
--

New tests adapted from the upstream linter

[Discord]: https://discord.com/channels/1039017663004942429/1082324250112823306/1440086001035771985
this is actually the only reason Checker::path() was used and nearly the only
reason the field Checker::path exists. there's only one remaining reference to
the field in a debug logging call
@MichaReiser
Copy link
Member

Haven't started reviewing but we should probably allow all listed here https://peps.python.org/pep-0008/#module-level-dunder-names

Copy link
Member

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. This is great. The bigest open question for me is whether this rule should also apply to __init__.pyi files

(Ruff, "064") => rules::ruff::rules::NonOctalPermissions,
(Ruff, "065") => rules::ruff::rules::LoggingEagerConversion,
(Ruff, "066") => rules::ruff::rules::PropertyWithoutReturn,
(Ruff, "070") => rules::ruff::rules::NonEmptyInitModule,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I prefer avoiding "holes" and instead assign rule ids on the rule that comes first

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just felt bad because another rule currently using RUF067 is sitting in my review queue (along with RUF069), but I'll update this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I do is that I change the rule code myself if I end up approving a contributor PR, so that we don't need to feel bad about it

impl Violation for NonEmptyInitModule {
#[derive_message_formats]
fn message(&self) -> String {
"Code detected".to_string()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the message is a bit surprising. It's like, yeah, this is code, what's wrong with it?

Using separate messages based on strictness probably makes sense. For strict mode, maybe init module must be empty (although it doesn't really say what's wrong)?

@ntBre
Copy link
Contributor Author

ntBre commented Dec 26, 2025

Haven't started reviewing but we should probably allow all listed here peps.python.org/pep-0008#module-level-dunder-names

I'm not sure that this is an exhaustive list, and from when I searched around last week, even __version__ doesn't strictly seem to be a standard (one reference). But I've added __author__, which I think was the only one missing from that section.

@ntBre ntBre changed the title [ruff] Add non-empty-init-module (RUF070) [ruff] Add non-empty-init-module (RUF067) Dec 29, 2025
@dscorbett
Copy link

Part of the rationale is currently that “Including code in __init__.py files can cause surprising slowdowns because the code is run at import time.” If it is slow to define a class in __init__.py, it is just as slow (or marginally slower) to define a class elsewhere and import it in __init__.py. Moving code to a different module that __init__.py imports doesn’t stop that code from being run at import time. So that part of the rationale does not make sense.

@ntBre
Copy link
Contributor Author

ntBre commented Dec 30, 2025

Good catch, thanks! That rationale works for the strict form of the rule, but I agree that it doesn't make much sense next to the first example I have here. I'll try to come up with something better.

@ntBre ntBre merged commit c483b59 into main Dec 30, 2025
40 checks passed
@ntBre ntBre deleted the brent/eim branch December 30, 2025 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

preview Related to preview mode features rule Implementing or modifying a lint rule

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New rule: disallow code in __init__ modules

4 participants