INW021 private-module¶
What it does¶
Opt-in, and an error when on. A first-party module whose name, below the top-level package, starts with one underscore is private to the package that holds it: app.users.services._user belongs to app.users.services, and everything under app.users._internal belongs to app.users. INW021 reports an import of such a module from any module outside that package. The package's __init__.py, its other modules and its subpackages may import it.
Dunder names (__init__, __main__) aren't private. The top-level package itself isn't looked at, so _vendor.x is out of scope.
These imports count, as for INW003:
import app.users.services._user,from app.users.services._user import UserServiceandfrom app.users.services import _user(when_useris a module);- relative and star imports, imports inside a function, and imports under
if TYPE_CHECKING:, which still tie the importer to the private path; - an
importlib.import_module("...")call with a literal name.
An import INW003 already reports on the same line isn't reported again.
Why is this bad¶
PEP 8 marks internal modules, like internal functions, with a single leading underscore. A package that keeps its implementation in _user.py and re-exports the public names from __init__.py can move, split or rename _user.py without telling anyone. Code that imports _user.py directly breaks that promise, and the next agent copies the import.
Ruff's PLC2701 import-private-name doesn't cover this case. It is a preview rule, and it skips any import whose top-level package is the importing file's own, so inside one project from app.users.services._user import X in app.orders is never reported.
Example¶
[tool.inwards]
layers = [{ name = "store", modules = ["store"] }]
[tool.inwards.rules]
extend-select = ["INW021"]
class UserService:
def name(self, user_id: int) -> str:
return f"user {user_id}"
"""User services: other packages import what this module re-exports."""
from store.users.services._user import UserService
def greet(user_id: int) -> str:
return UserService().name(user_id)
store/orders/api.py:1:40: INW021 `store.orders.api` imports `store.users.services._user`, a private module of `store.users.services`. A module whose name starts with `_` is for its own package; other code imports what the package's `__init__.py` re-exports.
fix: Import `UserService` from `store.users.services`, not from its private module `store.users.services._user`.
1. In `from store.users.services._user import UserService`, import `UserService` from `store.users.services` instead.
2. If `store.users.services`'s `__init__.py` doesn't re-export it yet, add `from ._user import UserService` there (and to `__all__` if the package keeps one).
3. If `store.users.services._user` is meant for other packages, ask the user whether to rename it without the leading underscore instead.
Fixed: the package re-exports the class, and the caller imports it from the package.
from store.users.services import UserService
def greet(user_id: int) -> str:
return UserService().name(user_id)
How it works¶
INW021 runs on every module the rule is on for (modules, and not a test module unless check-tests = true), whether a layer owns it or not, so scripts are checked too. It reads the same imports as the other import rules: the prescan's import lines, or the full parse when a file needs it. Each import is resolved to the first-party module it lands in, through the project's module index; an import of a module that doesn't exist is INW010's, and a third-party module is never looked at.
The package a module is private to is the parent of its deepest private segment: app._internal.services._user is private to app._internal.services, and a module inside that package is inside app as well.
How to fix¶
- Import the name from the package (
from app.users.services import UserService), not from its private module. - If the package's
__init__.pydoesn't re-export it yet, addfrom ._user import UserServicethere, and to__all__if the package keeps one. - If the module is meant for other packages, rename it without the underscore.
Fix safety¶
No automatic fix (autofix: false). Re-exporting a name changes another package's __init__.py, and renaming a module changes every import of it.
Configuration¶
Severity error. Opt-in: turn it on with extend-select. The vertical-slices preset of inwards init turns INW021 on as a warning, since its scaffold's commands/ and queries/ re-export private modules from their __init__.py. The options live in [tool.inwards.rules.private-module] (options tables):
[tool.inwards.rules]
extend-select = ["INW021"]
[tool.inwards.rules.private-module]
modules = ["app"] # optional: only report in these modules
check-tests = true # optional: report in test modules too
modules: as for every rule, the modules INW021 reports in, the importers, not the private modules.check-tests:truemakes INW021 report in test modules too. They are skipped by default, since tests often reach into the modules they test.
A single import can be suppressed with a reason on its line: # inwards: ignore[INW021] reason="...".
Known limitations¶
- Private names aren't covered.
from app.users.services import _helper, when_helperis a function, is Ruff's (PLC2701 reports it across top-level packages); Inwards doesn't read the target module's names. - Third-party private modules are Ruff's too.
- A private module in a namespace package without an
__init__.pyhas no place to re-export from; give the package an__init__.py, or rename the module. - Measured on the corpus (ADR-046): only polar uses private modules. It gets 10 findings with tests skipped (8 in its source, 2 in its scripts), one per import statement. With
check-tests = trueit gets 67: 57 in tests, many of them function-level imports of the worker's private modules, between 41 pairs of importer and private module. The other four repositories get none.
References¶
- #434: the rule; ADR-046 and #407: the decision.
- PEP 8: Public and internal interfaces.
- Ruff PLC2701
import-private-nameand its source.