Skip to content

Handle deferred evaluation of annotations in Python 3.14 #10381

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions doc/whatsnew/fragments/10149.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Handle deferred evaluation of annotations in Python 3.14.

Closes #10149
18 changes: 13 additions & 5 deletions pylint/checkers/typecheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -975,8 +975,15 @@ class TypeChecker(BaseChecker):
def open(self) -> None:
py_version = self.linter.config.py_version
self._py310_plus = py_version >= (3, 10)
self._py314_plus = py_version >= (3, 14)
self._postponed_evaluation_enabled = False
self._mixin_class_rgx = self.linter.config.mixin_class_rgx

def visit_module(self, node: nodes.Module) -> None:
self._postponed_evaluation_enabled = (
self._py314_plus or is_postponed_evaluation_enabled(node)
)

@cached_property
def _compiled_generated_members(self) -> tuple[Pattern[str], ...]:
# do this lazily since config not fully initialized in __init__
Expand Down Expand Up @@ -1065,7 +1072,7 @@ def visit_attribute(
):
return

if is_postponed_evaluation_enabled(node) and is_node_in_type_annotation_context(
if self._postponed_evaluation_enabled and is_node_in_type_annotation_context(
node
):
return
Expand Down Expand Up @@ -1949,9 +1956,10 @@ def _detect_unsupported_alternative_union_syntax(self, node: nodes.BinOp) -> Non
if self._py310_plus: # 310+ supports the new syntax
return

if isinstance(
node.parent, TYPE_ANNOTATION_NODES_TYPES
) and not is_postponed_evaluation_enabled(node):
if (
isinstance(node.parent, TYPE_ANNOTATION_NODES_TYPES)
and not self._postponed_evaluation_enabled
):
# Use in type annotations only allowed if
# postponed evaluation is enabled.
self._check_unsupported_alternative_union_syntax(node)
Expand All @@ -1973,7 +1981,7 @@ def _detect_unsupported_alternative_union_syntax(self, node: nodes.BinOp) -> Non
# Make sure to filter context if postponed evaluation is enabled
# and parent is allowed node type.
allowed_nested_syntax = False
if is_postponed_evaluation_enabled(node):
if self._postponed_evaluation_enabled:
parent_node = node.parent
while True:
if isinstance(parent_node, TYPE_ANNOTATION_NODES_TYPES):
Expand Down
12 changes: 9 additions & 3 deletions pylint/checkers/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,10 @@ def __init__(self, linter: PyLinter) -> None:
] = {}
self._postponed_evaluation_enabled = False

def open(self) -> None:
py_version = self.linter.config.py_version
self._py314_plus = py_version >= (3, 14)

@utils.only_required_for_messages(
"unbalanced-dict-unpacking",
)
Expand Down Expand Up @@ -1375,7 +1379,9 @@ def visit_module(self, node: nodes.Module) -> None:
checks globals doesn't overrides builtins.
"""
self._to_consume = [NamesConsumer(node, "module")]
self._postponed_evaluation_enabled = is_postponed_evaluation_enabled(node)
self._postponed_evaluation_enabled = (
self._py314_plus or is_postponed_evaluation_enabled(node)
)

for name, stmts in node.locals.items():
if utils.is_builtin(name):
Expand Down Expand Up @@ -2501,8 +2507,8 @@ def _is_only_type_assignment(
parent = parent_scope.parent
return True

@staticmethod
def _is_first_level_self_reference(
self,
node: nodes.Name,
defstmt: nodes.ClassDef,
found_nodes: list[nodes.NodeNG],
Expand All @@ -2514,7 +2520,7 @@ def _is_first_level_self_reference(
# Check if used as type annotation
# Break if postponed evaluation is enabled
if utils.is_node_in_type_annotation_context(node):
if not utils.is_postponed_evaluation_enabled(node):
if not self._postponed_evaluation_enabled:
return (VariableVisitConsumerAction.CONTINUE, None)
return (VariableVisitConsumerAction.RETURN, None)
# Check if used as default value by calling the class
Expand Down
11 changes: 9 additions & 2 deletions pylint/extensions/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ def open(self) -> None:
self._py39_plus = py_version >= (3, 9)
self._py310_plus = py_version >= (3, 10)
self._py313_plus = py_version >= (3, 13)
self._py314_plus = py_version >= (3, 14)
self._postponed_evaluation_enabled = False

self._should_check_typing_alias = self._py39_plus or (
self._py37_plus and self.linter.config.runtime_typing is False
Expand All @@ -197,6 +199,11 @@ def open(self) -> None:
self._should_check_noreturn = py_version < (3, 7, 2)
self._should_check_callable = py_version < (3, 9, 2)

def visit_module(self, node: nodes.Module) -> None:
self._postponed_evaluation_enabled = (
self._py314_plus or is_postponed_evaluation_enabled(node)
)

def _msg_postponed_eval_hint(self, node: nodes.NodeNG) -> str:
"""Message hint if postponed evaluation isn't enabled."""
if self._py310_plus or "annotations" in node.root().future_imports:
Expand Down Expand Up @@ -474,7 +481,7 @@ def _check_broken_noreturn(self, node: nodes.Name | nodes.Attribute) -> None:
return

if in_type_checking_block(node) or (
is_postponed_evaluation_enabled(node)
self._postponed_evaluation_enabled
and is_node_in_type_annotation_context(node)
):
return
Expand Down Expand Up @@ -511,7 +518,7 @@ def _check_broken_callable(self, node: nodes.Name | nodes.Attribute) -> None:
def _broken_callable_location(self, node: nodes.Name | nodes.Attribute) -> bool:
"""Check if node would be a broken location for collections.abc.Callable."""
if in_type_checking_block(node) or (
is_postponed_evaluation_enabled(node)
self._postponed_evaluation_enabled
and is_node_in_type_annotation_context(node)
):
return False
Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@

class Class:
@classmethod
def from_string(cls, source) -> Class: # [undefined-variable]
def from_string(cls, source) -> Class: # <3.14:[undefined-variable]
...

def validate_b(self, obj: OtherClass) -> bool: # [used-before-assignment]
def validate_b(self, obj: OtherClass) -> bool: # <3.14:[used-before-assignment]
...


Expand Down
35 changes: 35 additions & 0 deletions tests/functional/u/undefined/undefined_variable.314.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
undefined-variable:12:19:12:26::Undefined variable 'unknown':UNDEFINED
undefined-variable:18:10:18:21:in_method:Undefined variable 'nomoreknown':UNDEFINED
undefined-variable:21:19:21:31::Undefined variable '__revision__':UNDEFINED
undefined-variable:23:8:23:20::Undefined variable '__revision__':UNDEFINED
undefined-variable:27:29:27:37:bad_default:Undefined variable 'unknown2':UNDEFINED
undefined-variable:30:10:30:14:bad_default:Undefined variable 'xxxx':UNDEFINED
undefined-variable:31:4:31:10:bad_default:Undefined variable 'augvar':UNDEFINED
undefined-variable:32:8:32:14:bad_default:Undefined variable 'vardel':UNDEFINED
undefined-variable:34:19:34:31:<lambda>:Undefined variable 'doesnotexist':UNDEFINED
undefined-variable:35:23:35:24:<lambda>:Undefined variable 'z':UNDEFINED
used-before-assignment:38:4:38:9::Using variable 'POUET' before assignment:CONTROL_FLOW
used-before-assignment:43:4:43:10::Using variable 'POUETT' before assignment:CONTROL_FLOW
used-before-assignment:48:4:48:11::Using variable 'POUETTT' before assignment:CONTROL_FLOW
used-before-assignment:56:4:56:9::Using variable 'PLOUF' before assignment:CONTROL_FLOW
used-before-assignment:65:11:65:14:if_branch_test:Using variable 'xxx' before assignment:HIGH
used-before-assignment:91:23:91:32:test_arguments:Using variable 'TestClass' before assignment:HIGH
used-before-assignment:95:16:95:24:TestClass:Using variable 'Ancestor' before assignment:HIGH
used-before-assignment:98:26:98:35:TestClass.MissingAncestor:Using variable 'Ancestor1' before assignment:HIGH
used-before-assignment:105:36:105:41:TestClass.test1.UsingBeforeDefinition:Using variable 'Empty' before assignment:HIGH
undefined-variable:119:10:119:14:Self:Undefined variable 'Self':UNDEFINED
undefined-variable:135:7:135:10::Undefined variable 'BAT':UNDEFINED
undefined-variable:136:4:136:7::Undefined variable 'BAT':UNDEFINED
used-before-assignment:146:31:146:38:KeywordArgument.test1:Using variable 'enabled' before assignment:HIGH
undefined-variable:149:32:149:40:KeywordArgument.test2:Undefined variable 'disabled':UNDEFINED
undefined-variable:154:22:154:25:KeywordArgument.<lambda>:Undefined variable 'arg':UNDEFINED
undefined-variable:166:4:166:13::Undefined variable 'unicode_2':UNDEFINED
undefined-variable:171:4:171:13::Undefined variable 'unicode_3':UNDEFINED
undefined-variable:226:25:226:37:LambdaClass4.<lambda>:Undefined variable 'LambdaClass4':UNDEFINED
undefined-variable:234:25:234:37:LambdaClass5.<lambda>:Undefined variable 'LambdaClass5':UNDEFINED
undefined-variable:291:18:291:24:not_using_loop_variable_accordingly:Undefined variable 'iteree':UNDEFINED
undefined-variable:308:27:308:28:undefined_annotation:Undefined variable 'x':UNDEFINED
used-before-assignment:309:7:309:8:undefined_annotation:Using variable 'x' before assignment:HIGH
undefined-variable:339:11:339:12:decorated3:Undefined variable 'x':UNDEFINED
undefined-variable:344:19:344:20:decorated4:Undefined variable 'y':UNDEFINED
undefined-variable:365:10:365:20:global_var_mixed_assignment:Undefined variable 'GLOBAL_VAR':HIGH
10 changes: 5 additions & 5 deletions tests/functional/u/undefined/undefined_variable.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ def onclick(event):
from datetime import datetime


def func_should_fail(_dt: datetime): # [used-before-assignment]
def func_should_fail(_dt: datetime): # <3.14:[used-before-assignment]
pass


Expand Down Expand Up @@ -374,13 +374,13 @@ def global_var_mixed_assignment():


class RepeatedReturnAnnotations:
def x(self, o: RepeatedReturnAnnotations) -> bool: # [undefined-variable]
def x(self, o: RepeatedReturnAnnotations) -> bool: # <3.14:[undefined-variable]
pass
def y(self) -> RepeatedReturnAnnotations: # [undefined-variable]
def y(self) -> RepeatedReturnAnnotations: # <3.14:[undefined-variable]
pass
def z(self) -> RepeatedReturnAnnotations: # [undefined-variable]
def z(self) -> RepeatedReturnAnnotations: # <3.14:[undefined-variable]
pass

class A:
def say_hello(self) -> __module__: # [undefined-variable]
def say_hello(self) -> __module__: # <3.14:[undefined-variable]
...
9 changes: 9 additions & 0 deletions tests/functional/u/undefined/undefined_variable_py30.314.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
undefined-variable:33:34:33:39:Undefined1.InnerScope.test_undefined:Undefined variable 'Undef':UNDEFINED
undefined-variable:36:25:36:28:Undefined1.InnerScope.test1:Undefined variable 'ABC':UNDEFINED
undefined-variable:51:28:51:32:FalsePositive342.test_bad:Undefined variable 'trop':UNDEFINED
undefined-variable:54:31:54:36:FalsePositive342.test_bad1:Undefined variable 'trop1':UNDEFINED
undefined-variable:57:31:57:36:FalsePositive342.test_bad2:Undefined variable 'trop2':UNDEFINED
undefined-variable:63:0:63:9:Bad:Undefined variable 'ABCMet':UNDEFINED
undefined-variable:66:0:66:15:SecondBad:Undefined variable 'ab':UNDEFINED
undefined-variable:97:53:97:61:InheritingClass:Undefined variable 'variable':UNDEFINED
undefined-variable:103:0:103:15:Inheritor:Undefined variable 'DefinedTooLate':UNDEFINED
2 changes: 1 addition & 1 deletion tests/functional/u/undefined/undefined_variable_py30.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
class Undefined:
""" test various annotation problems. """

def test(self)->Undefined: # [undefined-variable]
def test(self)->Undefined: # <3.14:[undefined-variable]
""" used Undefined, which is Undefined in this scope. """

Undefined = True
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
used-before-assignment:17:14:17:17:test_fail.wrap:Using variable 'cnt' before assignment:HIGH
used-before-assignment:26:14:26:17:test_fail2.wrap:Using variable 'cnt' before assignment:HIGH
used-before-assignment:90:10:90:18:type_annotation_never_gets_value_despite_nonlocal:Using variable 'some_num' before assignment:HIGH
used-before-assignment:96:14:96:18:inner_function_lacks_access_to_outer_args.inner:Using variable 'args' before assignment:HIGH
used-before-assignment:117:18:117:21:nonlocal_in_outer_frame_fail.outer.inner:Using variable 'num' before assignment:HIGH
possibly-used-before-assignment:149:20:149:28:nonlocal_in_distant_outer_frame_fail.outer.intermediate.inner:Possibly using variable 'callback' before assignment:CONTROL_FLOW
used-before-assignment:163:14:163:17:nonlocal_after_bad_usage_fail.inner:Using variable 'num' before assignment:HIGH
6 changes: 3 additions & 3 deletions tests/functional/u/used/used_before_assignment_nonlocal.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,17 @@ def wrap():
cnt = cnt + 1 # [used-before-assignment]
wrap()

def test_fail3(arg: test_fail4): # [used-before-assignment]
def test_fail3(arg: test_fail4): # <3.14:[used-before-assignment]
""" Depends on `test_fail4`, in argument annotation. """
return arg
# +1: [used-before-assignment, used-before-assignment]
# +1:<3.14: [used-before-assignment, used-before-assignment]
def test_fail4(*args: test_fail5, **kwargs: undefined):
""" Depends on `test_fail5` and `undefined` in
variable and named arguments annotations.
"""
return args, kwargs

def test_fail5()->undefined1: # [used-before-assignment]
def test_fail5()->undefined1: # <3.14:[used-before-assignment]
""" Depends on `undefined1` in function return annotation. """

def undefined():
Expand Down
15 changes: 15 additions & 0 deletions tests/functional/u/used/used_before_assignment_typing.314.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
undefined-variable:79:20:79:27:MyClass.incorrect_default_method:Undefined variable 'MyClass':UNDEFINED
possibly-used-before-assignment:171:14:171:20:TypeCheckingMultiBranch.defined_in_elif_branch:Possibly using variable 'bisect' before assignment:INFERENCE
possibly-used-before-assignment:172:15:172:23:TypeCheckingMultiBranch.defined_in_elif_branch:Possibly using variable 'calendar' before assignment:INFERENCE
used-before-assignment:175:14:175:22:TypeCheckingMultiBranch.defined_in_else_branch:Using variable 'zoneinfo' before assignment:INFERENCE
used-before-assignment:176:14:176:20:TypeCheckingMultiBranch.defined_in_else_branch:Using variable 'pprint' before assignment:INFERENCE
used-before-assignment:177:14:177:25:TypeCheckingMultiBranch.defined_in_else_branch:Using variable 'collections' before assignment:INFERENCE
possibly-used-before-assignment:181:14:181:19:TypeCheckingMultiBranch.defined_in_nested_if_else:Possibly using variable 'heapq' before assignment:INFERENCE
used-before-assignment:185:14:185:19:TypeCheckingMultiBranch.defined_in_try_except:Using variable 'types' before assignment:INFERENCE
used-before-assignment:186:14:186:18:TypeCheckingMultiBranch.defined_in_try_except:Using variable 'copy' before assignment:INFERENCE
used-before-assignment:187:14:187:21:TypeCheckingMultiBranch.defined_in_try_except:Using variable 'numbers' before assignment:INFERENCE
used-before-assignment:188:15:188:20:TypeCheckingMultiBranch.defined_in_try_except:Using variable 'array' before assignment:INFERENCE
used-before-assignment:191:14:191:19:TypeCheckingMultiBranch.defined_in_loops:Using variable 'email' before assignment:INFERENCE
used-before-assignment:192:14:192:21:TypeCheckingMultiBranch.defined_in_loops:Using variable 'mailbox' before assignment:INFERENCE
used-before-assignment:193:14:193:23:TypeCheckingMultiBranch.defined_in_loops:Using variable 'mimetypes' before assignment:INFERENCE
used-before-assignment:197:14:197:22:TypeCheckingMultiBranch.defined_in_with:Using variable 'binascii' before assignment:INFERENCE
26 changes: 13 additions & 13 deletions tests/functional/u/used/used_before_assignment_typing.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for used-before-assignment for typing related issues"""
# pylint: disable=missing-function-docstring,ungrouped-imports,invalid-name

# pylint: disable=line-too-long

from typing import List, NamedTuple, Optional, TYPE_CHECKING

Expand Down Expand Up @@ -66,12 +66,12 @@ class MyClass:
"""Type annotation or default values for first level methods can't refer to their own class"""

def incorrect_typing_method(
self, other: MyClass # [undefined-variable]
self, other: MyClass # <3.14:[undefined-variable]
) -> bool:
return self == other

def incorrect_nested_typing_method(
self, other: List[MyClass] # [undefined-variable]
self, other: List[MyClass] # <3.14:[undefined-variable]
) -> bool:
return self == other[0]

Expand Down Expand Up @@ -137,7 +137,7 @@ class MyFourthClass: # pylint: disable=too-few-public-methods
"""Class to test conditional imports guarded by TYPE_CHECKING two levels
up then used in function annotation. See https://github.com/pylint-dev/pylint/issues/7539"""

def is_close(self, comparator: math.isclose, first, second): # [used-before-assignment]
def is_close(self, comparator: math.isclose, first, second): # <3.14:[used-before-assignment]
"""Conditional imports guarded are only valid for variable annotations."""
comparator(first, second)

Expand All @@ -150,7 +150,7 @@ class VariableAnnotationsGuardedByTypeChecking: # pylint: disable=too-few-publi
and https://github.com/pylint-dev/pylint/issues/7882
"""

still_an_error: datetime.date # [used-before-assignment]
still_an_error: datetime.date # <3.14:[used-before-assignment]

def print_date(self, date) -> None:
date: datetime.date = date
Expand All @@ -167,33 +167,33 @@ class ConditionalImportGuardedWhenUsed: # pylint: disable=too-few-public-method

class TypeCheckingMultiBranch: # pylint: disable=too-few-public-methods,unused-variable
"""Test for defines in TYPE_CHECKING if/elif/else branching"""
def defined_in_elif_branch(self) -> calendar.Calendar: # [possibly-used-before-assignment]
def defined_in_elif_branch(self) -> calendar.Calendar: # <3.14:[possibly-used-before-assignment]
print(bisect) # [possibly-used-before-assignment]
return calendar.Calendar()
return calendar.Calendar() # >=3.14:[possibly-used-before-assignment]

def defined_in_else_branch(self) -> urlopen:
print(zoneinfo) # [used-before-assignment]
print(pprint()) # [used-before-assignment]
print(collections()) # [used-before-assignment]
return urlopen

def defined_in_nested_if_else(self) -> heapq: # [possibly-used-before-assignment]
print(heapq)
def defined_in_nested_if_else(self) -> heapq: # <3.14:[possibly-used-before-assignment]
print(heapq) # >=3.14:[possibly-used-before-assignment]
return heapq

def defined_in_try_except(self) -> array: # [used-before-assignment]
def defined_in_try_except(self) -> array: # <3.14:[used-before-assignment]
print(types) # [used-before-assignment]
print(copy) # [used-before-assignment]
print(numbers) # [used-before-assignment]
return array
return array # >=3.14:[used-before-assignment]

def defined_in_loops(self) -> json: # [used-before-assignment]
def defined_in_loops(self) -> json: # <3.14:[used-before-assignment]
print(email) # [used-before-assignment]
print(mailbox) # [used-before-assignment]
print(mimetypes) # [used-before-assignment]
return json

def defined_in_with(self) -> base64: # [used-before-assignment]
def defined_in_with(self) -> base64: # <3.14:[used-before-assignment]
print(binascii) # [used-before-assignment]
return base64

Expand Down
Loading