diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e2e1fb85f6..6fec4e2f50 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -81,7 +81,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.8, 3.9, "3.10", "3.11"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12-dev"] outputs: python-key: ${{ steps.generate-python-key.outputs.key }} steps: @@ -138,7 +138,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.8, 3.9, "3.10", "3.11"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12-dev"] steps: - name: Set temp directory run: echo "TEMP=$env:USERPROFILE\AppData\Local\Temp" >> $env:GITHUB_ENV diff --git a/ChangeLog b/ChangeLog index af0c97ace6..b81d0b0f78 100644 --- a/ChangeLog +++ b/ChangeLog @@ -6,6 +6,10 @@ What's New in astroid 3.0.0? ============================= Release date: TBA +* Add support for Python 3.12, including PEP 695 type parameter syntax. + + Closes #2201 + * Remove support for Python 3.7. Refs #2137 diff --git a/astroid/brain/brain_datetime.py b/astroid/brain/brain_datetime.py new file mode 100644 index 0000000000..e52c05b854 --- /dev/null +++ b/astroid/brain/brain_datetime.py @@ -0,0 +1,31 @@ +# Licensed under the LGPL: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html +# For details: https://github.com/pylint-dev/astroid/blob/main/LICENSE +# Copyright (c) https://github.com/pylint-dev/astroid/blob/main/CONTRIBUTORS.txt + +import textwrap + +from astroid.brain.helpers import register_module_extender +from astroid.builder import AstroidBuilder +from astroid.const import PY312_PLUS +from astroid.manager import AstroidManager + + +def datetime_transform(): + """The datetime module was C-accelerated in Python 3.12, so we + lack a Python source.""" + return AstroidBuilder(AstroidManager()).string_build( + textwrap.dedent( + """ + class date: ... + class time: ... + class datetime(date): ... + class timedelta: ... + class tzinfo: ... + class timezone(tzinfo): ... + """ + ) + ) + + +if PY312_PLUS: + register_module_extender(AstroidManager(), "datetime", datetime_transform) diff --git a/astroid/brain/brain_typing.py b/astroid/brain/brain_typing.py index 924f0ac0f9..d087885a4d 100644 --- a/astroid/brain/brain_typing.py +++ b/astroid/brain/brain_typing.py @@ -6,14 +6,16 @@ from __future__ import annotations +import textwrap import typing from collections.abc import Iterator from functools import partial from typing import Final from astroid import context, extract_node, inference_tip -from astroid.builder import _extract_single_node -from astroid.const import PY39_PLUS +from astroid.brain.helpers import register_module_extender +from astroid.builder import AstroidBuilder, _extract_single_node +from astroid.const import PY39_PLUS, PY312_PLUS from astroid.exceptions import ( AttributeInferenceError, InferenceError, @@ -231,7 +233,8 @@ def _looks_like_typing_alias(node: Call) -> bool: """ return ( isinstance(node.func, Name) - and node.func.name == "_alias" + # TODO: remove _DeprecatedGenericAlias when Py3.14 min + and node.func.name in {"_alias", "_DeprecatedGenericAlias"} and ( # _alias function works also for builtins object such as list and dict isinstance(node.args[0], (Attribute, Name)) @@ -273,6 +276,8 @@ def infer_typing_alias( :param node: call node :param context: inference context + + # TODO: evaluate if still necessary when Py3.12 is minimum """ if ( not isinstance(node.parent, Assign) @@ -415,6 +420,29 @@ def infer_typing_cast( return node.args[1].infer(context=ctx) +def _typing_transform(): + return AstroidBuilder(AstroidManager()).string_build( + textwrap.dedent( + """ + class Generic: + @classmethod + def __class_getitem__(cls, item): return cls + class ParamSpec: ... + class ParamSpecArgs: ... + class ParamSpecKwargs: ... + class TypeAlias: ... + class Type: + @classmethod + def __class_getitem__(cls, item): return cls + class TypeVar: + @classmethod + def __class_getitem__(cls, item): return cls + class TypeVarTuple: ... + """ + ) + ) + + AstroidManager().register_transform( Call, inference_tip(infer_typing_typevar_or_newtype), @@ -442,3 +470,6 @@ def infer_typing_cast( AstroidManager().register_transform( Call, inference_tip(infer_special_alias), _looks_like_special_alias ) + +if PY312_PLUS: + register_module_extender(AstroidManager(), "typing", _typing_transform) diff --git a/astroid/const.py b/astroid/const.py index 95672ae57d..3cc82a6401 100644 --- a/astroid/const.py +++ b/astroid/const.py @@ -10,6 +10,7 @@ PY39_PLUS = sys.version_info >= (3, 9) PY310_PLUS = sys.version_info >= (3, 10) PY311_PLUS = sys.version_info >= (3, 11) +PY312_PLUS = sys.version_info >= (3, 12) WIN32 = sys.platform == "win32" diff --git a/astroid/inference.py b/astroid/inference.py index 6dcfa49f1b..08dce62a68 100644 --- a/astroid/inference.py +++ b/astroid/inference.py @@ -92,6 +92,10 @@ def infer_end( nodes.Lambda._infer = infer_end # type: ignore[assignment] nodes.Const._infer = infer_end # type: ignore[assignment] nodes.Slice._infer = infer_end # type: ignore[assignment] +nodes.TypeAlias._infer = infer_end # type: ignore[assignment] +nodes.TypeVar._infer = infer_end # type: ignore[assignment] +nodes.ParamSpec._infer = infer_end # type: ignore[assignment] +nodes.TypeVarTuple._infer = infer_end # type: ignore[assignment] def _infer_sequence_helper( diff --git a/astroid/nodes/__init__.py b/astroid/nodes/__init__.py index f677ff509b..84fcb521f2 100644 --- a/astroid/nodes/__init__.py +++ b/astroid/nodes/__init__.py @@ -71,6 +71,7 @@ NamedExpr, NodeNG, Nonlocal, + ParamSpec, Pass, Pattern, Raise, @@ -83,6 +84,9 @@ TryFinally, TryStar, Tuple, + TypeAlias, + TypeVar, + TypeVarTuple, UnaryOp, Unknown, While, @@ -180,6 +184,8 @@ NamedExpr, NodeNG, Nonlocal, + ParamSpec, + TypeVarTuple, Pass, Pattern, Raise, @@ -193,6 +199,8 @@ TryFinally, TryStar, Tuple, + TypeAlias, + TypeVar, UnaryOp, Unknown, While, @@ -271,6 +279,7 @@ "NamedExpr", "NodeNG", "Nonlocal", + "ParamSpec", "Pass", "Position", "Raise", @@ -285,6 +294,9 @@ "TryFinally", "TryStar", "Tuple", + "TypeAlias", + "TypeVar", + "TypeVarTuple", "UnaryOp", "Unknown", "unpack_infer", diff --git a/astroid/nodes/as_string.py b/astroid/nodes/as_string.py index 49ef1b77e3..826c1c9971 100644 --- a/astroid/nodes/as_string.py +++ b/astroid/nodes/as_string.py @@ -178,6 +178,7 @@ def visit_classdef(self, node) -> str: args += [n.accept(self) for n in node.keywords] args_str = f"({', '.join(args)})" if args else "" docs = self._docs_dedent(node.doc_node) + # TODO: handle type_params return "\n\n{}class {}{}:{}\n{}\n".format( decorate, node.name, args_str, docs, self._stmt_list(node.body) ) @@ -330,6 +331,7 @@ def handle_functiondef(self, node, keyword) -> str: if node.returns: return_annotation = " -> " + node.returns.as_string() trailer = return_annotation + ":" + # TODO: handle type_params def_format = "\n%s%s %s(%s)%s%s\n%s" return def_format % ( decorate, @@ -431,6 +433,10 @@ def visit_nonlocal(self, node) -> str: """return an astroid.Nonlocal node as string""" return f"nonlocal {', '.join(node.names)}" + def visit_paramspec(self, node: nodes.ParamSpec) -> str: + """return an astroid.ParamSpec node as string""" + return node.name.accept(self) + def visit_pass(self, node) -> str: """return an astroid.Pass node as string""" return "pass" @@ -517,6 +523,18 @@ def visit_tuple(self, node) -> str: return f"({node.elts[0].accept(self)}, )" return f"({', '.join(child.accept(self) for child in node.elts)})" + def visit_typealias(self, node: nodes.TypeAlias) -> str: + """return an astroid.TypeAlias node as string""" + return node.name.accept(self) if node.name else "_" + + def visit_typevar(self, node: nodes.TypeVar) -> str: + """return an astroid.TypeVar node as string""" + return node.name.accept(self) if node.name else "_" + + def visit_typevartuple(self, node: nodes.TypeVarTuple) -> str: + """return an astroid.TypeVarTuple node as string""" + return "*" + node.name.accept(self) if node.name else "" + def visit_unaryop(self, node) -> str: """return an astroid.UnaryOp node as string""" if node.op == "not": diff --git a/astroid/nodes/node_classes.py b/astroid/nodes/node_classes.py index 5afb36594c..a8d63e9a78 100644 --- a/astroid/nodes/node_classes.py +++ b/astroid/nodes/node_classes.py @@ -19,7 +19,6 @@ ClassVar, Literal, Optional, - TypeVar, Union, ) @@ -62,8 +61,8 @@ def _is_const(value) -> bool: return isinstance(value, tuple(CONST_CLS)) -_NodesT = TypeVar("_NodesT", bound=NodeNG) -_BadOpMessageT = TypeVar("_BadOpMessageT", bound=util.BadOperationMessage) +_NodesT = typing.TypeVar("_NodesT", bound=NodeNG) +_BadOpMessageT = typing.TypeVar("_BadOpMessageT", bound=util.BadOperationMessage) AssignedStmtsPossibleNode = Union["List", "Tuple", "AssignName", "AssignAttr", None] AssignedStmtsCall = Callable[ @@ -2696,6 +2695,37 @@ def _infer_name(self, frame, name): return name +class ParamSpec(_base_nodes.AssignTypeNode): + """Class representing a :class:`ast.ParamSpec` node. + + >>> import astroid + >>> node = astroid.extract_node('type Alias[**P] = Callable[P, int]') + >>> node.type_params[0] + + """ + + def __init__( + self, + lineno: int, + col_offset: int, + parent: NodeNG, + *, + end_lineno: int | None = None, + end_col_offset: int | None = None, + ) -> None: + self.name: AssignName | None + super().__init__( + lineno=lineno, + col_offset=col_offset, + end_lineno=end_lineno, + end_col_offset=end_col_offset, + parent=parent, + ) + + def postinit(self, *, name: AssignName | None) -> None: + self.name = name + + class Pass(_base_nodes.NoChildrenNode, _base_nodes.Statement): """Class representing an :class:`ast.Pass` node. @@ -3310,6 +3340,115 @@ def getitem(self, index, context: InferenceContext | None = None): return _container_getitem(self, self.elts, index, context=context) +class TypeAlias(_base_nodes.AssignTypeNode): + """Class representing a :class:`ast.TypeAlias` node. + + >>> import astroid + >>> node = astroid.extract_node('type Point = tuple[float, float]') + >>> node + + """ + + _astroid_fields = ("type_params", "value") + + def __init__( + self, + lineno: int, + col_offset: int, + parent: NodeNG, + *, + end_lineno: int | None = None, + end_col_offset: int | None = None, + ) -> None: + self.name: AssignName | None + self.type_params: list[TypeVar, ParamSpec, TypeVarTuple] + self.value: NodeNG + super().__init__( + lineno=lineno, + col_offset=col_offset, + end_lineno=end_lineno, + end_col_offset=end_col_offset, + parent=parent, + ) + + def postinit( + self, + *, + name: AssignName | None, + type_params: list[TypeVar, ParamSpec, TypeVarTuple], + value: NodeNG, + ) -> None: + self.name = name + self.type_params = type_params + self.value = value + + +class TypeVar(_base_nodes.AssignTypeNode): + """Class representing a :class:`ast.TypeVar` node. + + >>> import astroid + >>> node = astroid.extract_node('type Point[T] = tuple[float, float]') + >>> node.type_params[0] + + """ + + _astroid_fields = ("bound",) + + def __init__( + self, + lineno: int, + col_offset: int, + parent: NodeNG, + *, + end_lineno: int | None = None, + end_col_offset: int | None = None, + ) -> None: + self.name: AssignName | None + self.bound: NodeNG | None + super().__init__( + lineno=lineno, + col_offset=col_offset, + end_lineno=end_lineno, + end_col_offset=end_col_offset, + parent=parent, + ) + + def postinit(self, *, name: AssignName | None, bound: NodeNG | None) -> None: + self.name = name + self.bound = bound + + +class TypeVarTuple(_base_nodes.AssignTypeNode): + """Class representing a :class:`ast.TypeVarTuple` node. + + >>> import astroid + >>> node = astroid.extract_node('type Alias[*Ts] = tuple[*Ts]') + >>> node.type_params[0] + + """ + + def __init__( + self, + lineno: int, + col_offset: int, + parent: NodeNG, + *, + end_lineno: int | None = None, + end_col_offset: int | None = None, + ) -> None: + self.name: AssignName | None + super().__init__( + lineno=lineno, + col_offset=col_offset, + end_lineno=end_lineno, + end_col_offset=end_col_offset, + parent=parent, + ) + + def postinit(self, *, name: AssignName | None) -> None: + self.name = name + + class UnaryOp(NodeNG): """Class representing an :class:`ast.UnaryOp` node. diff --git a/astroid/nodes/scoped_nodes/scoped_nodes.py b/astroid/nodes/scoped_nodes/scoped_nodes.py index bfe1462fd3..c8ad2a3365 100644 --- a/astroid/nodes/scoped_nodes/scoped_nodes.py +++ b/astroid/nodes/scoped_nodes/scoped_nodes.py @@ -1055,7 +1055,14 @@ class FunctionDef( """ - _astroid_fields = ("decorators", "args", "returns", "doc_node", "body") + _astroid_fields = ( + "decorators", + "args", + "returns", + "type_params", + "doc_node", + "body", + ) _multi_line_block_fields = ("body",) returns = None @@ -1123,6 +1130,9 @@ def __init__( self.body: list[NodeNG] = [] """The contents of the function body.""" + self.type_params: list[nodes.TypeVar, nodes.ParamSpec, nodes.TypeVarTuple] = [] + """PEP 695 (Python 3.12+) type params, e.g. first 'T' in def func[T]() -> T: ...""" + self.instance_attrs: dict[str, list[NodeNG]] = {} super().__init__( @@ -1147,6 +1157,7 @@ def postinit( *, position: Position | None = None, doc_node: Const | None = None, + type_params: list[nodes.TypeVar] | None = None, ): """Do some setup after initialisation. @@ -1164,6 +1175,8 @@ def postinit( Position of function keyword(s) and name. :param doc_node: The doc node associated with this node. + :param type_params: + The type_params associated with this node. """ self.args = args self.body = body @@ -1173,6 +1186,7 @@ def postinit( self.type_comment_args = type_comment_args self.position = position self.doc_node = doc_node + self.type_params = type_params or [] @cached_property def extra_decorators(self) -> list[node_classes.Call]: @@ -1739,7 +1753,7 @@ def get_wrapping_class(node): return klass -class ClassDef( +class ClassDef( # pylint: disable=too-many-instance-attributes _base_nodes.FilterStmtsBaseNode, LocalsDictNodeNG, _base_nodes.Statement ): """Class representing an :class:`ast.ClassDef` node. @@ -1758,7 +1772,14 @@ def my_meth(self, arg): # by a raw factories # a dictionary of class instances attributes - _astroid_fields = ("decorators", "bases", "keywords", "doc_node", "body") # name + _astroid_fields = ( + "decorators", + "bases", + "keywords", + "doc_node", + "body", + "type_params", + ) # name decorators = None """The decorators that are applied to this class. @@ -1825,6 +1846,9 @@ def __init__( self.is_dataclass: bool = False """Whether this class is a dataclass.""" + self.type_params: list[nodes.TypeVar, nodes.ParamSpec, nodes.TypeVarTuple] = [] + """PEP 695 (Python 3.12+) type params, e.g. class MyClass[T]: ...""" + super().__init__( lineno=lineno, col_offset=col_offset, @@ -1866,6 +1890,7 @@ def postinit( *, position: Position | None = None, doc_node: Const | None = None, + type_params: list[nodes.TypeVar] | None = None, ) -> None: if keywords is not None: self.keywords = keywords @@ -1876,6 +1901,7 @@ def postinit( self._metaclass = metaclass self.position = position self.doc_node = doc_node + self.type_params = type_params or [] def _newstyle_impl(self, context: InferenceContext | None = None): if context is None: diff --git a/astroid/rebuilder.py b/astroid/rebuilder.py index 64c1c12362..b26d16f0dc 100644 --- a/astroid/rebuilder.py +++ b/astroid/rebuilder.py @@ -18,7 +18,7 @@ from astroid import nodes from astroid._ast import ParserModule, get_parser_module, parse_function_type_comment -from astroid.const import IS_PYPY, PY38, PY39_PLUS, Context +from astroid.const import IS_PYPY, PY38, PY39_PLUS, PY312_PLUS, Context from astroid.manager import AstroidManager from astroid.nodes import NodeNG from astroid.nodes.utils import Position @@ -384,6 +384,12 @@ def visit(self, node: ast.Nonlocal, parent: NodeNG) -> nodes.Nonlocal: def visit(self, node: ast.Constant, parent: NodeNG) -> nodes.Const: ... + if sys.version_info >= (3, 12): + + @overload + def visit(self, node: ast.ParamSpec, parent: NodeNG) -> nodes.ParamSpec: + ... + @overload def visit(self, node: ast.Pass, parent: NodeNG) -> nodes.Pass: ... @@ -432,6 +438,22 @@ def visit(self, node: ast.TryStar, parent: NodeNG) -> nodes.TryStar: def visit(self, node: ast.Tuple, parent: NodeNG) -> nodes.Tuple: ... + if sys.version_info >= (3, 12): + + @overload + def visit(self, node: ast.TypeAlias, parent: NodeNG) -> nodes.TypeAlias: + ... + + @overload + def visit(self, node: ast.TypeVar, parent: NodeNG) -> nodes.TypeVar: + ... + + @overload + def visit( + self, node: ast.TypeVarTuple, parent: NodeNG + ) -> nodes.TypeVarTuple: + ... + @overload def visit(self, node: ast.UnaryOp, parent: NodeNG) -> nodes.UnaryOp: ... @@ -870,6 +892,9 @@ def visit_classdef( ], position=self._get_position_info(node, newnode), doc_node=self.visit(doc_ast_node, newnode), + type_params=[self.visit(param, newnode) for param in node.type_params] + if PY312_PLUS + else [], ) return newnode @@ -1170,6 +1195,9 @@ def _visit_functiondef( type_comment_args=type_comment_args, position=self._get_position_info(node, newnode), doc_node=self.visit(doc_ast_node, newnode), + type_params=[self.visit(param, newnode) for param in node.type_params] + if PY312_PLUS + else [], ) self._global_names.pop() return newnode @@ -1477,6 +1505,20 @@ def visit_constant(self, node: ast.Constant, parent: NodeNG) -> nodes.Const: parent=parent, ) + def visit_paramspec(self, node: ast.ParamSpec, parent: NodeNG) -> nodes.ParamSpec: + """Visit a ParamSpec node by returning a fresh instance of it.""" + newnode = nodes.ParamSpec( + lineno=node.lineno, + col_offset=node.col_offset, + end_lineno=node.end_lineno, + end_col_offset=node.end_col_offset, + parent=parent, + ) + # Add AssignName node for 'node.name' + # https://bugs.python.org/issue43994 + newnode.postinit(name=self.visit_assignname(node, newnode, node.name)) + return newnode + def visit_pass(self, node: ast.Pass, parent: NodeNG) -> nodes.Pass: """Visit a Pass node by returning a fresh instance of it.""" return nodes.Pass( @@ -1669,6 +1711,55 @@ def visit_tuple(self, node: ast.Tuple, parent: NodeNG) -> nodes.Tuple: newnode.postinit([self.visit(child, newnode) for child in node.elts]) return newnode + def visit_typealias(self, node: ast.TypeAlias, parent: NodeNG) -> nodes.TypeAlias: + """Visit a TypeAlias node by returning a fresh instance of it.""" + newnode = nodes.TypeAlias( + lineno=node.lineno, + col_offset=node.col_offset, + end_lineno=node.end_lineno, + end_col_offset=node.end_col_offset, + parent=parent, + ) + newnode.postinit( + name=self.visit(node.name, newnode), + type_params=[self.visit(p, newnode) for p in node.type_params], + value=self.visit(node.value, newnode), + ) + return newnode + + def visit_typevar(self, node: ast.TypeVar, parent: NodeNG) -> nodes.TypeVar: + """Visit a TypeVar node by returning a fresh instance of it.""" + newnode = nodes.TypeVar( + lineno=node.lineno, + col_offset=node.col_offset, + end_lineno=node.end_lineno, + end_col_offset=node.end_col_offset, + parent=parent, + ) + # Add AssignName node for 'node.name' + # https://bugs.python.org/issue43994 + newnode.postinit( + name=self.visit_assignname(node, newnode, node.name), + bound=self.visit(node.bound, newnode), + ) + return newnode + + def visit_typevartuple( + self, node: ast.TypeVarTuple, parent: NodeNG + ) -> nodes.TypeVarTuple: + """Visit a TypeVarTuple node by returning a fresh instance of it.""" + newnode = nodes.TypeVarTuple( + lineno=node.lineno, + col_offset=node.col_offset, + end_lineno=node.end_lineno, + end_col_offset=node.end_col_offset, + parent=parent, + ) + # Add AssignName node for 'node.name' + # https://bugs.python.org/issue43994 + newnode.postinit(name=self.visit_assignname(node, newnode, node.name)) + return newnode + def visit_unaryop(self, node: ast.UnaryOp, parent: NodeNG) -> nodes.UnaryOp: """Visit a UnaryOp node by returning a fresh instance of it.""" newnode = nodes.UnaryOp( diff --git a/doc/api/astroid.nodes.rst b/doc/api/astroid.nodes.rst index 7783b45d3d..402002cc17 100644 --- a/doc/api/astroid.nodes.rst +++ b/doc/api/astroid.nodes.rst @@ -67,6 +67,7 @@ Nodes astroid.nodes.Module astroid.nodes.Name astroid.nodes.Nonlocal + astroid.nodes.ParamSpec astroid.nodes.Pass astroid.nodes.Raise astroid.nodes.Return @@ -79,6 +80,9 @@ Nodes astroid.nodes.TryFinally astroid.nodes.TryStar astroid.nodes.Tuple + astroid.nodes.TypeAlias + astroid.nodes.TypeVar + astroid.nodes.TypeVarTuple astroid.nodes.UnaryOp astroid.nodes.Unknown astroid.nodes.While @@ -202,6 +206,8 @@ Nodes .. autoclass:: astroid.nodes.Nonlocal +.. autoclass:: astroid.nodes.ParamSpec + .. autoclass:: astroid.nodes.Pass .. autoclass:: astroid.nodes.Raise @@ -226,6 +232,12 @@ Nodes .. autoclass:: astroid.nodes.Tuple +.. autoclass:: astroid.nodes.TypeAlias + +.. autoclass:: astroid.nodes.TypeVar + +.. autoclass:: astroid.nodes.TypeVarTuple + .. autoclass:: astroid.nodes.UnaryOp .. autoclass:: astroid.nodes.Unknown diff --git a/pyproject.toml b/pyproject.toml index 9b3d42723c..014adf28fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/tests/brain/test_brain.py b/tests/brain/test_brain.py index 632a93284e..de3dba2061 100644 --- a/tests/brain/test_brain.py +++ b/tests/brain/test_brain.py @@ -15,6 +15,7 @@ from astroid import MANAGER, builder, nodes, objects, test_utils, util from astroid.bases import Instance from astroid.brain.brain_namedtuple_enum import _get_namedtuple_fields +from astroid.const import PY312_PLUS from astroid.exceptions import ( AttributeInferenceError, InferenceError, @@ -186,9 +187,16 @@ def test_builtin_subscriptable(self): def check_metaclass_is_abc(node: nodes.ClassDef): - meta = node.metaclass() - assert isinstance(meta, nodes.ClassDef) - assert meta.name == "ABCMeta" + if PY312_PLUS and node.name == "ByteString": + # .metaclass() finds the first metaclass in the mro(), + # which, from 3.12, is _DeprecateByteStringMeta (unhelpful) + # until ByteString is removed in 3.14. + # Jump over the first two ByteString classes in the mro(). + check_metaclass_is_abc(node.mro()[2]) + else: + meta = node.metaclass() + assert isinstance(meta, nodes.ClassDef) + assert meta.name == "ABCMeta" class CollectionsBrain(unittest.TestCase): @@ -323,7 +331,7 @@ def test_collections_object_not_yet_subscriptable_2(self): @test_utils.require_version(minver="3.9") def test_collections_object_subscriptable_3(self): - """With Python 3.9 the ByteString class of the collections module is subscritable + """With Python 3.9 the ByteString class of the collections module is subscriptable (but not the same class from typing module)""" right_node = builder.extract_node( """ diff --git a/tests/brain/test_qt.py b/tests/brain/test_qt.py index 9f778355fb..c946a129a3 100644 --- a/tests/brain/test_qt.py +++ b/tests/brain/test_qt.py @@ -8,6 +8,7 @@ from astroid import Uninferable, extract_node from astroid.bases import UnboundMethod +from astroid.const import PY312_PLUS from astroid.manager import AstroidManager from astroid.nodes import FunctionDef @@ -15,6 +16,8 @@ @pytest.mark.skipif(HAS_PYQT6 is None, reason="These tests require the PyQt6 library.") +# TODO: enable for Python 3.12 as soon as PyQt6 release is compatible +@pytest.mark.skipif(PY312_PLUS, reason="This test was segfaulting with Python 3.12.") class TestBrainQt: AstroidManager.brain["extension_package_whitelist"] = {"PyQt6"} diff --git a/tests/test_inference.py b/tests/test_inference.py index 6760f9c91b..f0acbde2d6 100644 --- a/tests/test_inference.py +++ b/tests/test_inference.py @@ -6,6 +6,7 @@ from __future__ import annotations +import sys import textwrap import unittest from abc import ABCMeta @@ -32,7 +33,7 @@ from astroid.arguments import CallSite from astroid.bases import BoundMethod, Instance, UnboundMethod, UnionType from astroid.builder import AstroidBuilder, _extract_single_node, extract_node, parse -from astroid.const import IS_PYPY, PY39_PLUS, PY310_PLUS +from astroid.const import IS_PYPY, PY39_PLUS, PY310_PLUS, PY312_PLUS from astroid.context import CallContext, InferenceContext from astroid.exceptions import ( AstroidTypeError, @@ -988,7 +989,12 @@ def test_import_as(self) -> None: self.assertIsInstance(inferred[0], nodes.Module) self.assertEqual(inferred[0].name, "os.path") inferred = list(ast.igetattr("e")) - self.assertEqual(len(inferred), 1) + if PY312_PLUS and sys.platform.startswith("win"): + # There are two os.path.exists exported, likely due to + # https://github.com/python/cpython/pull/101324 + self.assertEqual(len(inferred), 2) + else: + self.assertEqual(len(inferred), 1) self.assertIsInstance(inferred[0], nodes.FunctionDef) self.assertEqual(inferred[0].name, "exists") diff --git a/tests/test_nodes.py b/tests/test_nodes.py index d5c017dfc4..f291009fc3 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -28,7 +28,7 @@ transforms, util, ) -from astroid.const import PY310_PLUS, Context +from astroid.const import PY310_PLUS, PY312_PLUS, Context from astroid.context import InferenceContext from astroid.exceptions import ( AstroidBuildingError, @@ -279,6 +279,33 @@ def test_as_string_unknown() -> None: assert nodes.Unknown(lineno=1, col_offset=0).as_string() == "Unknown.Unknown()" +@pytest.mark.skipif(not PY312_PLUS, reason="Uses 3.12 type param nodes") +class AsStringTypeParamNodes(unittest.TestCase): + @staticmethod + def test_as_string_type_alias() -> None: + ast = abuilder.string_build("type Point = tuple[float, float]") + type_alias = ast.body[0] + assert type_alias.as_string().strip() == "Point" + + @staticmethod + def test_as_string_type_var() -> None: + ast = abuilder.string_build("type Point[T] = tuple[float, float]") + type_var = ast.body[0].type_params[0] + assert type_var.as_string().strip() == "T" + + @staticmethod + def test_as_string_type_var_tuple() -> None: + ast = abuilder.string_build("type Alias[*Ts] = tuple[*Ts]") + type_var_tuple = ast.body[0].type_params[0] + assert type_var_tuple.as_string().strip() == "*Ts" + + @staticmethod + def test_as_string_param_spec() -> None: + ast = abuilder.string_build("type Alias[**P] = Callable[P, int]") + param_spec = ast.body[0].type_params[0] + assert param_spec.as_string().strip() == "P" + + class _NodeTest(unittest.TestCase): """Test transformation of If Node.""" diff --git a/tests/test_nodes_lineno.py b/tests/test_nodes_lineno.py index 126655df52..c0af6628bf 100644 --- a/tests/test_nodes_lineno.py +++ b/tests/test_nodes_lineno.py @@ -8,7 +8,7 @@ import astroid from astroid import builder, nodes -from astroid.const import IS_PYPY, PY38, PY39_PLUS, PY310_PLUS +from astroid.const import IS_PYPY, PY38, PY39_PLUS, PY310_PLUS, PY312_PLUS @pytest.mark.skipif( @@ -977,13 +977,24 @@ def test_end_lineno_string() -> None: assert isinstance(s1.values[0], nodes.Const) assert (s1.lineno, s1.col_offset) == (1, 0) assert (s1.end_lineno, s1.end_col_offset) == (1, 29) - assert (s1.values[0].lineno, s1.values[0].col_offset) == (1, 0) - assert (s1.values[0].end_lineno, s1.values[0].end_col_offset) == (1, 29) + if PY312_PLUS: + assert (s1.values[0].lineno, s1.values[0].col_offset) == (1, 2) + assert (s1.values[0].end_lineno, s1.values[0].end_col_offset) == (1, 15) + else: + # Bug in Python 3.11 + # https://github.com/python/cpython/issues/81639 + assert (s1.values[0].lineno, s1.values[0].col_offset) == (1, 0) + assert (s1.values[0].end_lineno, s1.values[0].end_col_offset) == (1, 29) s2 = s1.values[1] assert isinstance(s2, nodes.FormattedValue) - assert (s2.lineno, s2.col_offset) == (1, 0) - assert (s2.end_lineno, s2.end_col_offset) == (1, 29) + if PY312_PLUS: + assert (s2.lineno, s2.col_offset) == (1, 15) + assert (s2.end_lineno, s2.end_col_offset) == (1, 28) + else: + assert (s2.lineno, s2.col_offset) == (1, 0) + assert (s2.end_lineno, s2.end_col_offset) == (1, 29) + assert isinstance(s2.value, nodes.Const) # 42.1234 if PY39_PLUS: assert (s2.value.lineno, s2.value.col_offset) == (1, 16) @@ -993,22 +1004,35 @@ def test_end_lineno_string() -> None: # https://bugs.python.org/issue44885 assert (s2.value.lineno, s2.value.col_offset) == (1, 1) assert (s2.value.end_lineno, s2.value.end_col_offset) == (1, 8) - assert isinstance(s2.format_spec, nodes.JoinedStr) # '02d' - assert (s2.format_spec.lineno, s2.format_spec.col_offset) == (1, 0) - assert (s2.format_spec.end_lineno, s2.format_spec.end_col_offset) == (1, 29) + assert isinstance(s2.format_spec, nodes.JoinedStr) # ':02d' + if PY312_PLUS: + assert (s2.format_spec.lineno, s2.format_spec.col_offset) == (1, 23) + assert (s2.format_spec.end_lineno, s2.format_spec.end_col_offset) == (1, 27) + else: + assert (s2.format_spec.lineno, s2.format_spec.col_offset) == (1, 0) + assert (s2.format_spec.end_lineno, s2.format_spec.end_col_offset) == (1, 29) s3 = ast_nodes[1] assert isinstance(s3, nodes.JoinedStr) assert isinstance(s3.values[0], nodes.Const) assert (s3.lineno, s3.col_offset) == (2, 0) assert (s3.end_lineno, s3.end_col_offset) == (2, 17) - assert (s3.values[0].lineno, s3.values[0].col_offset) == (2, 0) - assert (s3.values[0].end_lineno, s3.values[0].end_col_offset) == (2, 17) + if PY312_PLUS: + assert (s3.values[0].lineno, s3.values[0].col_offset) == (2, 2) + assert (s3.values[0].end_lineno, s3.values[0].end_col_offset) == (2, 15) + else: + assert (s3.values[0].lineno, s3.values[0].col_offset) == (2, 0) + assert (s3.values[0].end_lineno, s3.values[0].end_col_offset) == (2, 17) s4 = s3.values[1] assert isinstance(s4, nodes.FormattedValue) - assert (s4.lineno, s4.col_offset) == (2, 0) - assert (s4.end_lineno, s4.end_col_offset) == (2, 17) + if PY312_PLUS: + assert (s4.lineno, s4.col_offset) == (2, 9) + assert (s4.end_lineno, s4.end_col_offset) == (2, 16) + else: + assert (s4.lineno, s4.col_offset) == (2, 0) + assert (s4.end_lineno, s4.end_col_offset) == (2, 17) + assert isinstance(s4.value, nodes.Name) # 'name' if PY39_PLUS: assert (s4.value.lineno, s4.value.col_offset) == (2, 10) diff --git a/tests/test_raw_building.py b/tests/test_raw_building.py index 093e003cc0..d206022b8f 100644 --- a/tests/test_raw_building.py +++ b/tests/test_raw_building.py @@ -24,7 +24,7 @@ import tests.testdata.python3.data.fake_module_with_broken_getattr as fm_getattr import tests.testdata.python3.data.fake_module_with_warnings as fm from astroid.builder import AstroidBuilder -from astroid.const import IS_PYPY +from astroid.const import IS_PYPY, PY312_PLUS from astroid.raw_building import ( attach_dummy_node, build_class, @@ -86,7 +86,7 @@ def test_build_from_import(self) -> None: @unittest.skipIf(IS_PYPY, "Only affects CPython") def test_io_is__io(self): - # _io module calls itself io. This leads + # _io module calls itself io before Python 3.12. This leads # to cyclic dependencies when astroid tries to resolve # what io.BufferedReader is. The code that handles this # is in astroid.raw_building.imported_member, which verifies @@ -94,7 +94,8 @@ def test_io_is__io(self): builder = AstroidBuilder() module = builder.inspect_build(_io) buffered_reader = module.getattr("BufferedReader")[0] - self.assertEqual(buffered_reader.root().name, "io") + expected = "_io" if PY312_PLUS else "io" + self.assertEqual(buffered_reader.root().name, expected) def test_build_function_deepinspect_deprecation(self) -> None: # Tests https://github.com/pylint-dev/astroid/issues/1717 diff --git a/tests/test_scoped_nodes.py b/tests/test_scoped_nodes.py index aee0450c54..5ad2cc3f43 100644 --- a/tests/test_scoped_nodes.py +++ b/tests/test_scoped_nodes.py @@ -8,7 +8,7 @@ from __future__ import annotations -import datetime +import difflib import os import sys import textwrap @@ -2141,8 +2141,8 @@ class ParentGetattr(Getattr): # Test that objects analyzed through the live introspection # aren't considered to have dynamic getattr implemented. astroid_builder = builder.AstroidBuilder() - module = astroid_builder.module_build(datetime) - self.assertFalse(module["timedelta"].has_dynamic_getattr()) + module = astroid_builder.module_build(difflib) + self.assertFalse(module["SequenceMatcher"].has_dynamic_getattr()) def test_duplicate_bases_namedtuple(self) -> None: module = builder.parse( diff --git a/tests/test_type_params.py b/tests/test_type_params.py new file mode 100644 index 0000000000..b5827010cd --- /dev/null +++ b/tests/test_type_params.py @@ -0,0 +1,68 @@ +# Licensed under the LGPL: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html +# For details: https://github.com/pylint-dev/astroid/blob/main/LICENSE +# Copyright (c) https://github.com/pylint-dev/astroid/blob/main/CONTRIBUTORS.txt + +import pytest + +from astroid import extract_node +from astroid.const import PY312_PLUS +from astroid.nodes import ( + AssignName, + ParamSpec, + Subscript, + TypeAlias, + TypeVar, + TypeVarTuple, +) + +if not PY312_PLUS: + pytest.skip("Requires Python 3.12 or higher", allow_module_level=True) + + +def test_type_alias() -> None: + node = extract_node("type Point[T] = list[float, float]") + assert isinstance(node, TypeAlias) + assert isinstance(node.type_params[0], TypeVar) + assert isinstance(node.type_params[0].name, AssignName) + assert node.type_params[0].name.name == "T" + assert node.type_params[0].bound is None + + assert isinstance(node.value, Subscript) + assert node.value.value.name == "list" + assert node.value.slice.name == "tuple" + assert all(elt.name == "float" for elt in node.value.slice.elts) + + assert node.inferred()[0] is node + assert node.type_params[0].inferred()[0] is node.type_params[0] + + +def test_type_param_spec() -> None: + node = extract_node("type Alias[**P] = Callable[P, int]") + params = node.type_params[0] + assert isinstance(params, ParamSpec) + assert isinstance(params.name, AssignName) + assert params.name.name == "P" + + assert node.inferred()[0] is node + + +def test_type_var_tuple() -> None: + node = extract_node("type Alias[*Ts] = tuple[*Ts]") + params = node.type_params[0] + assert isinstance(params, TypeVarTuple) + assert isinstance(params.name, AssignName) + assert params.name.name == "Ts" + + assert node.inferred()[0] is node + + +def test_type_param() -> None: + func_node = extract_node("def func[T]() -> T: ...") + assert isinstance(func_node.type_params[0], TypeVar) + assert func_node.type_params[0].name.name == "T" + assert func_node.type_params[0].bound is None + + class_node = extract_node("class MyClass[T]: ...") + assert isinstance(class_node.type_params[0], TypeVar) + assert class_node.type_params[0].name.name == "T" + assert class_node.type_params[0].bound is None