Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,89 @@ RequiredBase()
bar.value # error: [lazy-import-immediately-resolved]
OtherBase() # error: [lazy-import-immediately-resolved]
```

## Module declarations

```toml
target-version = "py315"

[lint]
preview = true
select = ["TID255"]
```

### From imports

A declaration makes members imported from the listed module lazy.

```py
__lazy_modules__ = ["json"]
from json import loads

loads("{}") # error: [lazy-import-immediately-resolved]
```

### Mixed imports

Only the listed module is lazy, even when the same statement imports an eager module. No fix is
offered for imports governed by `__lazy_modules__`.

```py
__lazy_modules__ = ["json"]
import json, pathlib

json.dumps({}) # snapshot: lazy-import-immediately-resolved
pathlib.Path(".")
```

```snapshot
error[TID255]: Lazy import `json` is resolved immediately
--> src/mdtest_snippet.py:4:1
|
4 | json.dumps({}) # snapshot: lazy-import-immediately-resolved
| ^^^^
```

### Reassigned declarations

Clearing the declaration does not make an earlier import eager.

```py
__lazy_modules__ = ["json"]
import json

__lazy_modules__ = []

json.dumps({}) # error: [lazy-import-immediately-resolved]
```

### Declarations after imports

A declaration does not retroactively make an earlier import lazy.

```py
import json
__lazy_modules__ = ["json"]
Comment on lines +122 to +123

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this an idea for another rule that warns about earlier imports that are later marked as lazy (same when using the lazy import syntax?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Could be!


json.dumps({})
```

### Older target versions

A declaration expresses intended lazy imports even when the target version predates the `lazy`
keyword.

```toml
target-version = "py314"

[lint]
preview = true
select = ["TID255"]
```

```py
__lazy_modules__ = ["json"]
import json

json.dumps({}) # error: [lazy-import-immediately-resolved]
```
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# `lazy-import-mismatch` (`TID254`)

## Explicit lazy imports

```toml
target-version = "py315"

Expand All @@ -12,7 +14,7 @@ require-lazy = ["typing", "package.deferred"]
ban-lazy = ["this", "package.eager"]
```

## Multiple imports
### Multiple imports

When names in one statement have conflicting policies, we report the mismatch without a fix.
Changing the entire statement would violate the other name's policy and cause a fix loop.
Expand All @@ -34,7 +36,7 @@ error[TID254]: `typing` should be imported lazily
help: Convert to a lazy import
```

## Multiple imported members
### Multiple imported members

The same restriction applies to importing multiple members from one module, whether the statement
is eager or lazy.
Expand All @@ -55,3 +57,139 @@ error[TID254]: `package.deferred` should be imported lazily
| ^^^^^^^^
help: Convert to a lazy import
```

## Module declarations

```toml
target-version = "py314"

[lint]
preview = true
select = ["TID254"]

[lint.flake8-tidy-imports]
require-lazy = "all"
```

### Literal declarations

[PEP 810](https://peps.python.org/pep-0810/#semantics) allows a module-level `__lazy_modules__`
collection to identify lazy imports. Ruff recognizes literal lists, tuples, and sets, including
annotated assignments.

```py
__lazy_modules__ = ["json"]
import json

__lazy_modules__ = ("pathlib",)
import pathlib

__lazy_modules__: set[str] = {"collections"}
import collections

import math # error: [lazy-import-mismatch]
```

### Exact module matching

Entries match module names independently of local aliases. A `from` import checks its containing
module, and listing a package does not include its submodules.

```py
__lazy_modules__ = ["json", "xml"]
import json as renamed
from json import dumps, loads
import xml.dom # error: [lazy-import-mismatch]
```

### Empty declarations on older Python versions

An empty declaration enables lazy-import policies even when the target version predates the `lazy`
keyword.

```py
__lazy_modules__ = []
import json # error: [lazy-import-mismatch]
```
Comment on lines +105 to +113

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's possible that we don't want to do this, but it made sense to me to consider a __lazy_modules__ declaration (plus enabling a lazy-import rule) as enough opt-in to flag these even for a configured Python version before 3.15.

Similarly, I added the "In the future..." note below in analogy to #18502.


### Older Python versions without a declaration

Without a declaration, the policy is skipped on target versions that do not support `lazy` syntax.

In the future, the rule could create a `__lazy_modules__` declaration to offer fixes on older targets
without requiring an existing declaration.

```py
import json
```

### Reassigned declarations

Each import uses the declaration present when the checker reaches it. An annotation without a value
does not replace the declaration.

```py
__lazy_modules__ = ["json"]
import json
__lazy_modules__: list[str]
import json as still_lazy

__lazy_modules__ = []
import json as eager # error: [lazy-import-mismatch]
```

### Unknown declarations

An unsupported declaration value does not establish whether the import violates the policy, so no
diagnostic is emitted.

```py
__lazy_modules__ = configured_modules()
import json
```
Comment on lines +141 to +149

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

One of the relevant sections from the PEP is here:

It is worth noting that the implementation performs membership checks by calling __contains__ on the __lazy_modules__ object. Consequently, users requiring wildcard behavior may provide a custom object implementing __contains__ to return True for all queries or other desired patterns.

We treat this as an unknown laziness, different from certain laziness or certain eagerness.


### Per-alias policies

Each alias is checked independently. Here `json` must become eager and `pathlib` must become lazy.

```toml
target-version = "py315"

[lint]
preview = true
select = ["TID254"]

[lint.flake8-tidy-imports]
require-lazy = ["pathlib"]
ban-lazy = ["json"]
```

```py
__lazy_modules__ = ["json"]
# error: [lazy-import-mismatch]
# error: [lazy-import-mismatch]
Comment thread
ntBre marked this conversation as resolved.
Outdated
import json, pathlib
```

### From-import member policies

A declaration makes every member imported from its module lazy, but policies still apply to
individual members.

```toml
target-version = "py314"

[lint]
preview = true
select = ["TID254"]

[lint.flake8-tidy-imports]
require-lazy = ["pkg.First"]
ban-lazy = ["pkg.Second"]
```

```py
__lazy_modules__ = ["pkg"]
from pkg import First
from pkg import Second # error: [lazy-import-mismatch]
```
139 changes: 139 additions & 0 deletions crates/ruff_linter/resources/mdtest/pylint/manual-from-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# `manual-from-import` (`PLR0402`)

```toml
target-version = "py315"

[lint]
select = ["PLR0402"]
```

## Both modules listed

When `__lazy_modules__` contains both the package and its submodule, the fix preserves laziness.

```py
__lazy_modules__ = ["foo", "foo.bar"]
import foo.bar as bar # snapshot: manual-from-import
```

```snapshot
error[PLR0402]: Use `from foo import bar` in lieu of alias
--> src/mdtest_snippet.py:2:8
|
2 | import foo.bar as bar # snapshot: manual-from-import
| ^^^^^^^^^^^^^^
help: Replace with `from foo import bar`
|
1 | __lazy_modules__ = ["foo", "foo.bar"]
- import foo.bar as bar # snapshot: manual-from-import
2 + from foo import bar # snapshot: manual-from-import
|
```

## Only the submodule listed

A `from` import checks its containing module for membership in `__lazy_modules__`. Rewriting this
import would make it eager, so the diagnostic has no fix.

```py
__lazy_modules__ = ["foo.bar"]
import foo.bar as bar # snapshot: manual-from-import
```

```snapshot
error[PLR0402]: Use `from foo import bar` in lieu of alias
--> src/mdtest_snippet.py:2:8
|
2 | import foo.bar as bar # snapshot: manual-from-import
| ^^^^^^^^^^^^^^
help: Replace with `from foo import bar`
```

## Only the package listed

Listing a package does not make imports of its submodules lazy. Rewriting this import would make it
lazy, so the diagnostic has no fix.

```py
__lazy_modules__ = ["foo"]
import foo.bar as bar # snapshot: manual-from-import
```

```snapshot
error[PLR0402]: Use `from foo import bar` in lieu of alias
--> src/mdtest_snippet.py:2:8
|
2 | import foo.bar as bar # snapshot: manual-from-import
| ^^^^^^^^^^^^^^
help: Replace with `from foo import bar`
```

## Explicit lazy imports

The fix preserves the `lazy` keyword, so the import remains lazy even when the package is absent
from `__lazy_modules__`.

```py
__lazy_modules__ = ["foo.bar"]
lazy import foo.bar as bar # snapshot: manual-from-import
```

```snapshot
error[PLR0402]: Use `from foo import bar` in lieu of alias
--> src/mdtest_snippet.py:2:13
|
2 | lazy import foo.bar as bar # snapshot: manual-from-import
| ^^^^^^^^^^^^^^
help: Replace with `from foo import bar`
|
1 | __lazy_modules__ = ["foo.bar"]
- lazy import foo.bar as bar # snapshot: manual-from-import
2 + lazy from foo import bar # snapshot: manual-from-import
|
```

## Unknown declarations

When the declaration's contents are unknown, the rule cannot establish whether the fix preserves
laziness, so no fix is offered.

```py
__lazy_modules__ = configured_lazy_modules
import foo.bar as bar # snapshot: manual-from-import
```

```snapshot
error[PLR0402]: Use `from foo import bar` in lieu of alias
--> src/mdtest_snippet.py:2:8
|
2 | import foo.bar as bar # snapshot: manual-from-import
| ^^^^^^^^^^^^^^
help: Replace with `from foo import bar`
```

## Imports inside functions

Imports inside functions are eager regardless of `__lazy_modules__`, so an unknown declaration does
not prevent a fix.

```py
__lazy_modules__ = configured_lazy_modules


def import_in_function():
import foo.bar as bar # snapshot: manual-from-import
```

```snapshot
error[PLR0402]: Use `from foo import bar` in lieu of alias
--> src/mdtest_snippet.py:5:12
|
5 | import foo.bar as bar # snapshot: manual-from-import
| ^^^^^^^^^^^^^^
help: Replace with `from foo import bar`
|
4 | def import_in_function():
- import foo.bar as bar # snapshot: manual-from-import
5 + from foo import bar # snapshot: manual-from-import
|
```
Loading
Loading