# Licensed under the LGPL: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html
# For details: https://github.com/PyCQA/astroid/blob/main/LICENSE
# Copyright (c) https://github.com/PyCQA/astroid/blob/main/CONTRIBUTORS.txt
"""Astroid hooks for the Python standard library."""
from __future__ import annotations
import functools
import keyword
import sys
from collections.abc import Iterator
from textwrap import dedent
import astroid
from astroid import arguments, bases, inference_tip, nodes, util
from astroid.builder import AstroidBuilder, _extract_single_node, extract_node
from astroid.context import InferenceContext
from astroid.exceptions import (
AstroidTypeError,
AstroidValueError,
InferenceError,
MroError,
UseInferenceDefault,
)
from astroid.manager import AstroidManager
if sys.version_info >= (3, 8):
from typing import Final
else:
from typing_extensions import Final
ENUM_BASE_NAMES = {
"Enum",
"IntEnum",
"enum.Enum",
"enum.IntEnum",
"IntFlag",
"enum.IntFlag",
}
ENUM_QNAME: Final[str] = "enum.Enum"
TYPING_NAMEDTUPLE_QUALIFIED: Final = {
"typing.NamedTuple",
"typing_extensions.NamedTuple",
}
TYPING_NAMEDTUPLE_BASENAMES: Final = {
"NamedTuple",
"typing.NamedTuple",
"typing_extensions.NamedTuple",
}
def _infer_first(node, context):
if isinstance(node, util.UninferableBase):
raise UseInferenceDefault
try:
value = next(node.infer(context=context))
except StopIteration as exc:
raise InferenceError from exc
if isinstance(value, util.UninferableBase):
raise UseInferenceDefault()
return value
def _find_func_form_arguments(node, context):
def _extract_namedtuple_arg_or_keyword( # pylint: disable=inconsistent-return-statements
position, key_name=None
):
if len(args) > position:
return _infer_first(args[position], context)
if key_name and key_name in found_keywords:
return _infer_first(found_keywords[key_name], context)
args = node.args
keywords = node.keywords
found_keywords = (
{keyword.arg: keyword.value for keyword in keywords} if keywords else {}
)
name = _extract_namedtuple_arg_or_keyword(position=0, key_name="typename")
names = _extract_namedtuple_arg_or_keyword(position=1, key_name="field_names")
if name and names:
return name.value, names
raise UseInferenceDefault()
def infer_func_form(
node: nodes.Call,
base_type: list[nodes.NodeNG],
context: InferenceContext | None = None,
enum: bool = False,
) -> tuple[nodes.ClassDef, str, list[str]]:
"""Specific inference function for namedtuple or Python 3 enum."""
# node is a Call node, class name as first argument and generated class
# attributes as second argument
# namedtuple or enums list of attributes can be a list of strings or a
# whitespace-separate string
try:
name, names = _find_func_form_arguments(node, context)
try:
attributes: list[str] = names.value.replace(",", " ").split()
except AttributeError as exc:
# Handle attributes of NamedTuples
if not enum:
attributes = []
fields = _get_namedtuple_fields(node)
if fields:
fields_node = extract_node(fields)
attributes = [
_infer_first(const, context).value for const in fields_node.elts
]
# Handle attributes of Enums
else:
# Enums supports either iterator of (name, value) pairs
# or mappings.
if hasattr(names, "items") and isinstance(names.items, list):
attributes = [
_infer_first(const[0], context).value
for const in names.items
if isinstance(const[0], nodes.Const)
]
elif hasattr(names, "elts"):
# Enums can support either ["a", "b", "c"]
# or [("a", 1), ("b", 2), ...], but they can't
# be mixed.
if all(isinstance(const, nodes.Tuple) for const in names.elts):
attributes = [
_infer_first(const.elts[0], context).value
for const in names.elts
if isinstance(const, nodes.Tuple)
]
else:
attributes = [
_infer_first(const, context).value for const in names.elts
]
else:
raise AttributeError from exc
if not attributes:
raise AttributeError from exc
except (AttributeError, InferenceError) as exc:
raise UseInferenceDefault from exc
if not enum:
# namedtuple maps sys.intern(str()) over over field_names
attributes = [str(attr) for attr in attributes]
# XXX this should succeed *unless* __str__/__repr__ is incorrect or throws
# in which case we should not have inferred these values and raised earlier
attributes = [attr for attr in attributes if " " not in attr]
# If we can't infer the name of the class, don't crash, up to this point
# we know it is a namedtuple anyway.
name = name or "Uninferable"
# we want to return a Class node instance with proper attributes set
class_node = nodes.ClassDef(name)
# A typical ClassDef automatically adds its name to the parent scope,
# but doing so causes problems, so defer setting parent until after init
# see: https://github.com/PyCQA/pylint/issues/5982
class_node.parent = node.parent
class_node.postinit(
# set base class=tuple
bases=base_type,
body=[],
decorators=None,
)
# XXX add __init__(*attributes) method
for attr in attributes:
fake_node = nodes.EmptyNode()
fake_node.parent = class_node
fake_node.attrname = attr
class_node.instance_attrs[attr] = [fake_node]
return class_node, name, attributes
def _has_namedtuple_base(node):
"""Predicate for class inference tip.
:type node: ClassDef
:rtype: bool
"""
return set(node.basenames) & TYPING_NAMEDTUPLE_BASENAMES
def _looks_like(node, name) -> bool:
func = node.func
if isinstance(func, nodes.Attribute):
return func.attrname == name
if isinstance(func, nodes.Name):
return func.name == name
return False
_looks_like_namedtuple = functools.partial(_looks_like, name="namedtuple")
_looks_like_enum = functools.partial(_looks_like, name="Enum")
_looks_like_typing_namedtuple = functools.partial(_looks_like, name="NamedTuple")
def infer_named_tuple(
node: nodes.Call, context: InferenceContext | None = None
) -> Iterator[nodes.ClassDef]:
"""Specific inference function for namedtuple Call node."""
tuple_base_name: list[nodes.NodeNG] = [nodes.Name(name="tuple", parent=node.root())]
class_node, name, attributes = infer_func_form(
node, tuple_base_name, context=context
)
call_site = arguments.CallSite.from_call(node, context=context)
node = extract_node("import collections; collections.namedtuple")
try:
func = next(node.infer())
except StopIteration as e:
raise InferenceError(node=node) from e
try:
rename = next(call_site.infer_argument(func, "rename", context)).bool_value()
except (InferenceError, StopIteration):
rename = False
try:
attributes = _check_namedtuple_attributes(name, attributes, rename)
except AstroidTypeError as exc:
raise UseInferenceDefault("TypeError: " + str(exc)) from exc
except AstroidValueError as exc:
raise UseInferenceDefault("ValueError: " + str(exc)) from exc
replace_args = ", ".join(f"{arg}=None" for arg in attributes)
field_def = (
" {name} = property(lambda self: self[{index:d}], "
"doc='Alias for field number {index:d}')"
)
field_defs = "\n".join(
field_def.format(name=name, index=index)
for index, name in enumerate(attributes)
)
fake = AstroidBuilder(AstroidManager()).string_build(
f"""
class {name}(tuple):
__slots__ = ()
_fields = {attributes!r}
def _asdict(self):
return self.__dict__
@classmethod
def _make(cls, iterable, new=tuple.__new__, len=len):
return new(cls, iterable)
def _replace(self, {replace_args}):
return self
def __getnewargs__(self):
return tuple(self)
{field_defs}
"""
)
class_node.locals["_asdict"] = fake.body[0].locals["_asdict"]
class_node.locals["_make"] = fake.body[0].locals["_make"]
class_node.locals["_replace"] = fake.body[0].locals["_replace"]
class_node.locals["_fields"] = fake.body[0].locals["_fields"]
for attr in attributes:
class_node.locals[attr] = fake.body[0].locals[attr]
# we use UseInferenceDefault, we can't be a generator so return an iterator
return iter([class_node])
def _get_renamed_namedtuple_attributes(field_names):
names = list(field_names)
seen = set()
for i, name in enumerate(field_names):
if (
not all(c.isalnum() or c == "_" for c in name)
or keyword.iskeyword(name)
or not name
or name[0].isdigit()
or name.startswith("_")
or name in seen
):
names[i] = "_%d" % i
seen.add(name)
return tuple(names)
def _check_namedtuple_attributes(typename, attributes, rename=False):
attributes = tuple(attributes)
if rename:
attributes = _get_renamed_namedtuple_attributes(attributes)
# The following snippet is derived from the CPython Lib/collections/__init__.py sources
#