Skip to content
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
4 changes: 4 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ Release date: TBA

Closes #2672

* Add basic support for ``ast.TemplateStr`` and ``ast.Interpolation``added in Python 3.14.

refs #2789


What's New in astroid 3.3.11?
=============================
Expand Down
2 changes: 2 additions & 0 deletions astroid/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
IfExp,
Import,
ImportFrom,
Interpolation,
JoinedStr,
Keyword,
Lambda,
Expand Down Expand Up @@ -151,6 +152,7 @@
Slice,
Starred,
Subscript,
TemplateStr,
Try,
TryStar,
Tuple,
Expand Down
4 changes: 4 additions & 0 deletions astroid/nodes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
IfExp,
Import,
ImportFrom,
Interpolation,
JoinedStr,
Keyword,
List,
Expand All @@ -76,6 +77,7 @@
Slice,
Starred,
Subscript,
TemplateStr,
Try,
TryStar,
Tuple,
Expand Down Expand Up @@ -247,6 +249,7 @@
"IfExp",
"Import",
"ImportFrom",
"Interpolation",
"JoinedStr",
"Keyword",
"Lambda",
Expand Down Expand Up @@ -278,6 +281,7 @@
"Slice",
"Starred",
"Subscript",
"TemplateStr",
"Try",
"TryStar",
"Tuple",
Expand Down
28 changes: 28 additions & 0 deletions astroid/nodes/as_string.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from astroid import objects
from astroid.nodes import Const
from astroid.nodes.node_classes import (
Interpolation,
Match,
MatchAs,
MatchCase,
Expand All @@ -26,6 +27,7 @@
MatchSingleton,
MatchStar,
MatchValue,
TemplateStr,
Unknown,
)
from astroid.nodes.node_ng import NodeNG
Expand Down Expand Up @@ -672,6 +674,32 @@ def visit_matchor(self, node: MatchOr) -> str:
raise AssertionError(f"{node} does not have pattern nodes")
return " | ".join(p.accept(self) for p in node.patterns)

def visit_templatestr(self, node: TemplateStr) -> str:
"""Return an astroid.TemplateStr node as string."""
string = ""
for value in node.values:
match value:
case nodes.Interpolation():
string += "{" + value.accept(self) + "}"
case _:
string += value.accept(self)[1:-1]
for quote in ("'", '"', '"""', "'''"):
if quote not in string:
break
return "t" + quote + string + quote

def visit_interpolation(self, node: Interpolation) -> str:
"""Return an astroid.Interpolation node as string."""
result = f"{node.str}"
if node.conversion and node.conversion >= 0:
# e.g. if node.conversion == 114: result += "!r"
result += "!" + chr(node.conversion)
if node.format_spec:
# The format spec is itself a JoinedString, i.e. an f-string
# We strip the f and quotes of the ends
result += ":" + node.format_spec.accept(self)[2:-1]
return result

# These aren't for real AST nodes, but for inference objects.

def visit_frozenset(self, node: objects.FrozenSet) -> str:
Expand Down
108 changes: 108 additions & 0 deletions astroid/nodes/node_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5484,6 +5484,114 @@ def postinit(self, *, patterns: list[Pattern]) -> None:
self.patterns = patterns


class TemplateStr(NodeNG):
"""Class representing an :class:`ast.TemplateStr` node.

>>> import astroid
>>> node = astroid.extract_node('t"{name} finished {place!s}"')
>>> node
<TemplateStr l.1 at 0x103b7aa50>
"""

_astroid_fields = ("values",)

def __init__(
self,
lineno: int | None = None,
col_offset: int | None = None,
parent: NodeNG | None = None,
*,
end_lineno: int | None = None,
end_col_offset: int | None = None,
) -> None:
self.values: list[NodeNG]
super().__init__(
lineno=lineno,
col_offset=col_offset,
end_lineno=end_lineno,
end_col_offset=end_col_offset,
parent=parent,
)

def postinit(self, *, values: list[NodeNG]) -> None:
self.values = values

def get_children(self) -> Iterator[NodeNG]:
yield from self.values


class Interpolation(NodeNG):
"""Class representing an :class:`ast.Interpolation` node.

>>> import astroid
>>> node = astroid.extract_node('t"{name} finished {place!s}"')
>>> node
<TemplateStr l.1 at 0x103b7aa50>
>>> node.values[0]
<Interpolation l.1 at 0x103b7acf0>
>>> node.values[2]
<Interpolation l.1 at 0x10411e5d0>
"""

_astroid_fields = ("value", "format_spec")
_other_fields = ("str", "conversion")

def __init__(
self,
lineno: int | None = None,
col_offset: int | None = None,
parent: NodeNG | None = None,
*,
end_lineno: int | None = None,
end_col_offset: int | None = None,
) -> None:
self.value: NodeNG
"""Any expression node."""

self.str: str
"""Text of the interpolation expression."""

self.conversion: int
"""The type of formatting to be applied to the value.

.. seealso::
:class:`ast.Interpolation`
"""

self.format_spec: JoinedStr | None = None
"""The formatting to be applied to the value.

.. seealso::
:class:`ast.Interpolation`
"""

super().__init__(
lineno=lineno,
col_offset=col_offset,
end_lineno=end_lineno,
end_col_offset=end_col_offset,
parent=parent,
)

def postinit(
self,
*,
value: NodeNG,
str: str, # pylint: disable=redefined-builtin
Copy link
Member Author

Choose a reason for hiding this comment

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

@Pierre-Sassoulas Should we change the attribute name here? str is the one used by the ast node but it's a bit unfortunate in my opinion since it shadows the builtin str.

Not sure using an alias here would be better though.

https://docs.python.org/3.14/library/ast.html#ast.Interpolation

Copy link
Member

Choose a reason for hiding this comment

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

I agree, not ideal, but what should we use instead ? Any non adherence to ast's API is mental load on astroid's user and documentation burden on us.

Copy link
Member Author

Choose a reason for hiding this comment

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

Just wanted to discuss it first. I also think that str while not ideal is still the best option.

Copy link
Member

Choose a reason for hiding this comment

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

Before you merged I was writing: Let's wait to read other opinions. Some maintainers might still be ambitious enough to keep astroid a wrapper above the AST and its multiple interpreters :D

It's still possible to change this later if someone want, we're in alpha/betas atm. Being able to test this in pylint fast is good too.

Copy link
Member Author

Choose a reason for hiding this comment

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

Sorry about that. Guess I was too fast this time 😅

Yeah, we can still change it. Furthermore I think the biggest issue is actually in the postinit arg name as that's used on it's own. Accessing the attribute itself shouldn't really be an issue with node.str.

conversion: int = -1,
format_spec: JoinedStr | None = None,
) -> None:
self.value = value
self.str = str
self.conversion = conversion
self.format_spec = format_spec

def get_children(self) -> Iterator[NodeNG]:
yield self.value
if self.format_spec:
yield self.format_spec


# constants ##############################################################

# The _proxied attribute of all container types (List, Tuple, etc.)
Expand Down
47 changes: 47 additions & 0 deletions astroid/rebuilder.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,18 @@ def visit(self, node: ast.MatchOr, parent: nodes.NodeNG) -> nodes.MatchOr: ...
@overload
def visit(self, node: ast.pattern, parent: nodes.NodeNG) -> nodes.Pattern: ...

if sys.version_info >= (3, 14):

@overload
def visit(
self, node: ast.TemplateStr, parent: nodes.NodeNG
) -> nodes.TemplateStr: ...

@overload
def visit(
self, node: ast.Interpolation, parent: nodes.NodeNG
) -> nodes.Interpolation: ...

@overload
def visit(self, node: ast.AST, parent: nodes.NodeNG) -> nodes.NodeNG: ...

Expand Down Expand Up @@ -1929,3 +1941,38 @@ def visit_matchor(self, node: ast.MatchOr, parent: nodes.NodeNG) -> nodes.MatchO
patterns=[self.visit(pattern, newnode) for pattern in node.patterns]
)
return newnode

if sys.version_info >= (3, 14):

def visit_templatestr(
self, node: ast.TemplateStr, parent: nodes.NodeNG
) -> nodes.TemplateStr:
newnode = nodes.TemplateStr(
lineno=node.lineno,
col_offset=node.col_offset,
end_lineno=node.end_lineno,
end_col_offset=node.end_col_offset,
parent=parent,
)
newnode.postinit(
values=[self.visit(value, newnode) for value in node.values]
)
return newnode

def visit_interpolation(
self, node: ast.Interpolation, parent: nodes.NodeNG
) -> nodes.Interpolation:
newnode = nodes.Interpolation(
lineno=node.lineno,
col_offset=node.col_offset,
end_lineno=node.end_lineno,
end_col_offset=node.end_col_offset,
parent=parent,
)
newnode.postinit(
value=self.visit(node.value, parent),
str=node.str,
conversion=node.conversion,
format_spec=self.visit(node.format_spec, parent),
)
return newnode
35 changes: 35 additions & 0 deletions tests/test_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2173,6 +2173,41 @@ def return_from_match(x):
assert [inf.value for inf in inferred] == [10, -1]


@pytest.mark.skipif(not PY314_PLUS, reason="TemplateStr was added in PY314")
class TestTemplateString:
@staticmethod
def test_template_string_simple() -> None:
code = textwrap.dedent(
"""
name = "Foo"
place = 3
t"{name} finished {place!r:ordinal}" #@
"""
).strip()
node = builder.extract_node(code)
assert node.as_string() == "t'{name} finished {place!r:ordinal}'"
assert isinstance(node, nodes.TemplateStr)
assert len(node.values) == 3
value = node.values[0]
assert isinstance(value, nodes.Interpolation)
assert isinstance(value.value, nodes.Name)
assert value.value.name == "name"
assert value.str == "name"
assert value.conversion == -1
assert value.format_spec is None
value = node.values[1]
assert isinstance(value, nodes.Const)
assert value.pytype() == "builtins.str"
assert value.value == " finished "
value = node.values[2]
assert isinstance(value, nodes.Interpolation)
assert isinstance(value.value, nodes.Name)
assert value.value.name == "place"
assert value.str == "place"
assert value.conversion == ord("r")
assert isinstance(value.format_spec, nodes.JoinedStr)


@pytest.mark.parametrize(
"node",
[
Expand Down
Loading