Skip to content

Add invalid-lazy-import for PEP 810 imports Python rejects - #11209

Open
Pierre-Sassoulas wants to merge 2 commits into
pylint-dev:mainfrom
Pierre-Sassoulas:invalid-lazy-import
Open

Add invalid-lazy-import for PEP 810 imports Python rejects#11209
Pierre-Sassoulas wants to merge 2 commits into
pylint-dev:mainfrom
Pierre-Sassoulas:invalid-lazy-import

Conversation

@Pierre-Sassoulas

@Pierre-Sassoulas Pierre-Sassoulas commented Jul 30, 2026

Copy link
Copy Markdown
Member

Type of Changes

Type
✨ New feature

Description

Adds invalid-lazy-import (E0403) and using-lazy-import-in-unsupported-version
(W2608), split out of #10983 so that PR stays limited to making pylint work on
Python 3.15 rather than growing new checks.

The version-guard work that was here — and that @DanielNoord reviewed — moved to
its own PR, #11293: it is about is_sys_guard, not about PEP 810.

invalid-lazy-import

PEP 810 only allows a lazy import at module level, outside of any try
statement, and never as a wildcard import. Python rejects the other positions
when it compiles the module, but ast.parse accepts them all, so astroid
builds a perfectly ordinary module and pylint had nothing to say about code that
cannot run.

All seven positions CPython rejects are reported:

Code Message
lazy import inside a function or async function Lazy import is not allowed inside a function
lazy import inside a class body Lazy import is not allowed inside a class body
lazy import in a try body, except, else or finally, including except* Lazy import is not allowed inside a try statement
lazy from x import * Lazy import is not allowed as a wildcard import

None of the legal positions are flagged: if, for, while, with and match
at module level are all accepted. I checked the classification against CPython
3.15.0b1 by compiling each snippet, and ruff's parser independently agrees on the
same set — it rejects lazy import json inside try with the same reasoning.

The message carries minversion=(3, 15), so it cannot fire on interpreters where
the syntax does not exist.

astroid 4.3.0 renamed Import.lazy to Import.is_lazy and pylint accepts
astroid>=4.2.0b5,<=4.3, so an is_lazy_import helper asks for either spelling.
Reading one of them directly raises an AttributeError on every import statement
for half of the supported range, and the test suite pins 4.2.0b5, so CI cannot
see it. The helper carries a TODO to read node.is_lazy directly once the pin
requires 4.3, which #11234 does.

using-lazy-import-in-unsupported-version

invalid-lazy-import says where Python forbids a lazy import; it says nothing
about whether the interpreter the code targets knows the syntax at all. That is
what unsupported_version is for, next to f-strings, the walrus operator,
except* and PEP 695. W2608 reports lazy import module when py-version still
includes a version that cannot compile it.

Documentation examples on 3.15-only syntax

The examples for a message about 3.15 syntax cannot be parsed by an older
interpreter, and doc/test_messages_documentation.py lints every example with
whatever interpreter is running — CI uses DEFAULT_PYTHON. An example directory
may now declare a min_pyver file and be skipped below that version.

That gate is deliberately about parsing, not about whether the message applies.
A message that merely does not apply to every py-version already has a way to
say so: pin py-version in the example's own pylintrc, the way
boolean-datetime does. My first attempt keyed the skip on the message's own
minversion/maxversion metadata instead, which was wrong — pylint compares
those against the configured py-version, not the interpreter, and it silently
skipped boolean-datetime and return-arg-in-generator, which had been passing.

The examples are also excluded from ruff-doc, which targets py310 and cannot
parse them. black 26.5.1 handles PEP 810 natively, so black-doc needs nothing.

Testing

tests/ and doc/test_messages_documentation.py are green on 3.15.0b1 and on
3.13, where the two new examples skip, at each commit. One failure on 3.15 is
pre-existing on main and fixed by #10983, not by this PR: the
unspecified-encoding example raises nothing on a 3.15 interpreter because
PEP 686 gives that message maxversion=(3, 15).

Refs #10982

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.41%. Comparing base (43c7d8a) to head (5a68efa).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main   #11209   +/-   ##
=======================================
  Coverage   96.41%   96.41%           
=======================================
  Files         178      178           
  Lines       20068    20099   +31     
=======================================
+ Hits        19348    19379   +31     
  Misses        720      720           
Files with missing lines Coverage Δ
pylint/checkers/imports.py 95.14% <100.00%> (+0.18%) ⬆️
pylint/checkers/unsupported_version.py 100.00% <100.00%> (ø)
pylint/checkers/utils.py 96.04% <100.00%> (+<0.01%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

This comment has been minimized.

@Pierre-Sassoulas
Pierre-Sassoulas force-pushed the invalid-lazy-import branch 3 times, most recently from d684d05 to c125862 Compare August 1, 2026 08:01
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@Pierre-Sassoulas Pierre-Sassoulas added this to the 4.1.0 milestone Aug 12, 2026
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pylint that referenced this pull request Aug 12, 2026
The false positives fixed in the previous commits are not py-version
dependent: the element of a comprehension is not an assignment target, and
'dict([*pairs for pairs in nested])' is not equivalent to a key/value dict
comprehension, whichever version the code targets. What does depend on
py-version is whether the syntax exists at all, and 'unsupported_version'
is where pylint says so, next to f-strings, the walrus operator, 'except*'
and PEP 695.

'using-comprehension-unpacking-in-unsupported-version' (W2607) reports
'[*element for element in elements]' and '{**element for element in
elements}' when py-version still includes a version that cannot compile
them.

The documentation example cannot be parsed before 3.15, so
'doc/test_messages_documentation.py' learns to skip an example declaring
the version it needs in a 'min_pyver' file, the example asks for the newest
grammar in 'doc/data/ruff.toml', and black is excluded from it because it
cannot check that it preserved the meaning of code the interpreter it runs
on cannot parse. pylint-dev#11209 adds the same 'min_pyver' support for its own
example, so whichever of the two lands second drops the duplicate.

Refs pylint-dev#10982
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

DanielNoord pushed a commit that referenced this pull request Aug 17, 2026
The false positives fixed in the previous commits are not py-version
dependent: the element of a comprehension is not an assignment target, and
'dict([*pairs for pairs in nested])' is not equivalent to a key/value dict
comprehension, whichever version the code targets. What does depend on
py-version is whether the syntax exists at all, and 'unsupported_version'
is where pylint says so, next to f-strings, the walrus operator, 'except*'
and PEP 695.

'using-comprehension-unpacking-in-unsupported-version' (W2607) reports
'[*element for element in elements]' and '{**element for element in
elements}' when py-version still includes a version that cannot compile
them.

The documentation example cannot be parsed before 3.15, so
'doc/test_messages_documentation.py' learns to skip an example declaring
the version it needs in a 'min_pyver' file, the example asks for the newest
grammar in 'doc/data/ruff.toml', and black is excluded from it because it
cannot check that it preserved the meaning of code the interpreter it runs
on cannot parse. #11209 adds the same 'min_pyver' support for its own
example, so whichever of the two lands second drops the duplicate.

Refs #10982

@DanielNoord DanielNoord left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Couple of questions, but already looks pretty good. Needs a rebase though

Comment thread tests/checkers/unittest_utils.py Outdated
assert isinstance(guard, nodes.If)
assert utils.zealous_is_sys_guard(guard) is True, guard.as_string()

for not_a_guard in code[10:]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Probably better to split this test up at the 10th index, that makes it a lot easier to see what is being tested.

Comment thread pylint/checkers/utils.py
return modname


def is_lazy_import(node: nodes.Import | nodes.ImportFrom) -> bool:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we now fix this?

Comment thread pylint/checkers/utils.py Outdated
return False


def zealous_is_sys_guard(node: nodes.If) -> bool:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this really that much slower that we can't always call this improved version?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, I was wondering that too. Plus we need corectness more than we need pylint being 1% faster.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We have a lot of those compromise with corectness against performance, for numpy in particular if it's not imported as np. or numpy we sometime don't bother. Or for aliases. Going all in on handling this might be what differentiate pylint tbh.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would vote to just always be zealous in this particular case

A lazy import is only valid at module level, outside any try statement,
and never as a wildcard import. Python raises a SyntaxError for the other
positions, but 'ast' parses them, so pylint sees a normal module and has
to report them itself.

'astroid' 4.3.0 renamed 'Import.lazy' to 'Import.is_lazy' and pylint
accepts 'astroid>=4.2.0b5,<=4.3', so the new 'is_lazy_import' helper asks
for either spelling. Reading one of them directly would raise an
'AttributeError' on every import statement for half of the supported
range, and the test suite pins 4.2.0b5, so CI could not see it.

The documentation examples for the new message can only be parsed by
Python 3.15, so the message documentation test now skips an example that
declares a 'min_pyver'.
'invalid-lazy-import' says where Python forbids a lazy import; it says
nothing about whether the interpreter the code targets knows the syntax at
all. That is what 'unsupported_version' is for, next to f-strings, the
walrus operator, 'except*' and PEP 695.

'using-lazy-import-in-unsupported-version' (W2608) reports
'lazy import module' when py-version still includes a version that cannot
compile it. The documentation example reuses the 'min_pyver' skip added for
'invalid-lazy-import', and asks for the newest grammar in
'doc/data/ruff.toml' like its 'good.py' does.

W2607 is already taken by
'using-comprehension-unpacking-in-unsupported-version', which reports the
PEP 798 unpacking the same way.

Refs pylint-dev#10982
@github-actions

This comment has been minimized.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 According to the primer, this change has no effect on the checked open source code. 🤖🎉

This comment was generated for commit 5a68efa

@Pierre-Sassoulas

Copy link
Copy Markdown
Member Author

Need to merge #11293 first.

@Pierre-Sassoulas Pierre-Sassoulas added the Blocked 🚧 Blocked by a particular issue label Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Blocked 🚧 Blocked by a particular issue python 3.15

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants