Skip to content

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 UserService and from app.users.services import _user (when _user is 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

pyproject.toml
[tool.inwards]
layers = [{ name = "store", modules = ["store"] }]

[tool.inwards.rules]
extend-select = ["INW021"]
store/users/services/_user.py
class UserService:
    def name(self, user_id: int) -> str:
        return f"user {user_id}"
store/users/services/__init__.py
"""User services: other packages import what this module re-exports."""
store/orders/api.py
from store.users.services._user import UserService


def greet(user_id: int) -> str:
    return UserService().name(user_id)
inwards check store
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.

store/users/services/__init__.py
from ._user import UserService

__all__ = ["UserService"]
store/orders/api.py
from store.users.services import UserService


def greet(user_id: int) -> str:
    return UserService().name(user_id)
inwards check store

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

  1. Import the name from the package (from app.users.services import UserService), not from its private module.
  2. If the package'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 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):

pyproject.toml
[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: true makes 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 _helper is 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__.py has 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 = true it 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