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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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 @@ -976,8 +976,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 @@ -1066,7 +1073,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 @@ -1950,9 +1957,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 @@ -1974,7 +1982,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 @@ -1300,6 +1300,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 @@ -1363,7 +1367,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 @@ -2489,8 +2495,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 @@ -2502,7 +2508,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
20 changes: 19 additions & 1 deletion pylint/testutils/functional/test_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from __future__ import annotations

import configparser
import os
import sys
from collections.abc import Callable
from os.path import basename, exists, join
from typing import TypedDict
Expand Down Expand Up @@ -99,7 +101,23 @@ def module(self) -> str:

@property
def expected_output(self) -> str:
return self._file_type(".txt", check_exists=False)
files = list(
filter(
lambda s: s.startswith(f"{self.base}.") and s.endswith(".txt"),
os.listdir(self._directory),
)
)
# pylint: disable-next=bad-builtin
current_version = int("".join(map(str, sys.version_info[:2])))
output_options = [
int(version)
for s in files
if s.count(".") == 2 and (version := s.rsplit(".", maxsplit=2)[1]).isalnum()
]
for opt in sorted(output_options, reverse=True):
if current_version >= opt:
return join(self._directory, f"{self.base}.{opt}.txt")
return join(self._directory, self.base + ".txt")

@property
def source(self) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import typing as t

a1: t.Generator[int, str, str]
a2: t.Generator[int, None, None]
a2: t.Generator[int, None, None] # >=3.13:[unnecessary-default-type-args]
a3: t.Generator[int]
b1: t.AsyncGenerator[int, str]
b2: t.AsyncGenerator[int, None]
b2: t.AsyncGenerator[int, None] # >=3.13:[unnecessary-default-type-args]
b3: t.AsyncGenerator[int]

c1: ca.Generator[int, str, str]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
[main]
py-version=3.10
load-plugins=pylint.extensions.typing

[testoptions]
min_pyver=3.10

This file was deleted.

This file was deleted.

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
Loading
Loading