Skip to content

Add basic support for template strings (PEP 750) #19333

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 4 commits into
base: master
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
53 changes: 53 additions & 0 deletions mypy/fastparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,13 @@ def ast3_parse(
ast_TypeVar = Any
ast_TypeVarTuple = Any

if sys.version_info >= (3, 14):
ast_TemplateStr = ast3.TemplateStr
ast_Interpolation = ast3.Interpolation
else:
ast_TemplateStr = Any
ast_Interpolation = Any

N = TypeVar("N", bound=Node)

# There is no way to create reasonable fallbacks at this stage,
Expand Down Expand Up @@ -768,6 +775,17 @@ def fix_function_overloads(self, stmts: list[Statement]) -> list[Statement]:
ret.append(last_if_stmt)
return ret

def insert_synthetic_templatelib_import(self, stmts: list[Statement]) -> None:
# Only insert 'string.templatelib' import if a t-string was visited.
if hasattr(ast3, "TemplateStr") and ast3.TemplateStr in self.visitor_cache:
imp = ImportFrom(
"string.templatelib",
0,
[("Template", "__mypy-Template"), ("Interpolation", "__mypy-Interpolation")],
)
stmts.insert(0, imp)
self.imports.append(imp)

def _check_ifstmt_for_overloads(
self, stmt: IfStmt, current_overload_name: str | None = None
) -> str | None:
Expand Down Expand Up @@ -892,6 +910,7 @@ def visit_Module(self, mod: ast3.Module) -> MypyFile:
self.fail(message_registry.INVALID_TYPE_IGNORE, ti.lineno, -1, blocker=False)

body = self.fix_function_overloads(self.translate_stmt_list(mod.body, ismodule=True))
self.insert_synthetic_templatelib_import(body)

ret = MypyFile(body, self.imports, False, ignored_lines=self.type_ignores)
ret.is_stub = self.is_stub
Expand Down Expand Up @@ -1698,6 +1717,40 @@ def visit_FormattedValue(self, n: ast3.FormattedValue) -> Expression:
)
return self.set_line(result_expression, n)

# TemplateStr(expr* values)
def visit_TemplateStr(self, n: ast_TemplateStr) -> Expression:
template_cls = NameExpr("__mypy-Template")
template_cls.set_line(n.lineno, n.col_offset)
template_values = self.translate_expr_list(n.values)
e = CallExpr(
template_cls,
template_values,
[ARG_POS] * len(template_values),
[None] * len(template_values),
)
return self.set_line(e, n)

# Interpolation(expr value, constant str, int conversion, expr? format_spec)
def visit_Interpolation(self, n: ast_Interpolation) -> Expression:
interp_cls = NameExpr("__mypy-Interpolation")
interp_cls.set_line(n.lineno, n.col_offset)
val_expr = self.visit(n.value)
val_expr.set_line(interp_cls)
str_expr = StrExpr(n.str)
str_expr.set_line(interp_cls)
conv_expr = NameExpr("None") if n.conversion < 0 else StrExpr(chr(n.conversion))
conv_expr.set_line(interp_cls)
format_expr = self.visit(n.format_spec) if n.format_spec is not None else StrExpr("")
if format_expr.line == -1:
format_expr.set_line(interp_cls)
e = CallExpr(
interp_cls,
[val_expr, str_expr, conv_expr, format_expr],
[ARG_POS, ARG_POS, ARG_POS, ARG_POS],
[None, None, None, None],
)
return self.set_line(e, n)

# Attribute(expr value, identifier attr, expr_context ctx)
def visit_Attribute(self, n: Attribute) -> MemberExpr | SuperExpr:
value = n.value
Expand Down
2 changes: 2 additions & 0 deletions mypy/test/testcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
typecheck_files.remove("check-python312.test")
if sys.version_info < (3, 13):
typecheck_files.remove("check-python313.test")
if sys.version_info < (3, 14):
typecheck_files.remove("check-python314.test")

# Special tests for platforms with case-insensitive filesystems.
if sys.platform not in ("darwin", "win32"):
Expand Down
4 changes: 4 additions & 0 deletions mypy/test/testparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class ParserSuite(DataSuite):
files.remove("parse-python312.test")
if sys.version_info < (3, 13):
files.remove("parse-python313.test")
if sys.version_info < (3, 14):
files.remove("parse-python314.test")

def run_case(self, testcase: DataDrivenTestCase) -> None:
test_parser(testcase)
Expand All @@ -46,6 +48,8 @@ def test_parser(testcase: DataDrivenTestCase) -> None:
options.python_version = (3, 12)
elif testcase.file.endswith("python313.test"):
options.python_version = (3, 13)
elif testcase.file.endswith("python314.test"):
options.python_version = (3, 14)
else:
options.python_version = defaults.PYTHON3_VERSION

Expand Down
6 changes: 3 additions & 3 deletions mypy/typeshed/stdlib/ast.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -1076,13 +1076,13 @@ if sys.version_info >= (3, 14):
value: expr
str: builtins.str
conversion: int
format_spec: builtins.str | None = None
format_spec: expr | None = None
def __init__(
self,
value: expr = ...,
str: builtins.str = ...,
conversion: int = ...,
format_spec: builtins.str | None = ...,
format_spec: expr | None = ...,
**kwargs: Unpack[_Attributes],
) -> None: ...
def __replace__(
Expand All @@ -1091,7 +1091,7 @@ if sys.version_info >= (3, 14):
value: expr = ...,
str: builtins.str = ...,
conversion: int = ...,
format_spec: builtins.str | None = ...,
format_spec: expr | None = ...,
**kwargs: Unpack[_Attributes],
) -> Self: ...

Expand Down
16 changes: 16 additions & 0 deletions mypyc/test-data/run-python314.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Test cases for Python 3.14 features

[case testTemplateStringBasic]
from string.templatelib import Interpolation, Template

def test_template_string_basic() -> None:
name = "mypy"
t = t"Hello {name}"

assert type(t) is Template
assert t.values == ('mypy',)
assert t.strings == ('Hello ', '')
assert len(t.interpolations) == 1
i = t.interpolations[0]
assert type(i) is Interpolation
assert (i.value, i.expression, i.conversion, i.format_spec) == ("mypy", "name", None, "")
2 changes: 2 additions & 0 deletions mypyc/test/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
files.append("run-match.test")
if sys.version_info >= (3, 12):
files.append("run-python312.test")
if sys.version_info >= (3, 14):
files.append("run-python314.test")

setup_format = """\
from setuptools import setup
Expand Down
32 changes: 32 additions & 0 deletions test-data/unit/check-python314.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[case testTemplateStringBasics]
reveal_type(t"foobar") # N: Revealed type is "string.templatelib.Template"
t"{'foobar'}"
t"foo{'bar'}"
t".{1}."
t"{type(1)}"
t"{1!r}"
t"{1:03d}"
t"{1!r:03d}"

from string.templatelib import Template
a: Template
a = t"foobar"
a = t"{'foobar'}"
[builtins fixtures/f_string.pyi]

[case testTemplateStringExpressionsOk]
t".{1 + 1}."
t".{1 + 1}.{'foo' + 'bar'}"
[builtins fixtures/f_string.pyi]

[case testTemplateStringExpressionsErrors]
t"{1 + ''}" # E: Unsupported operand types for + ("int" and "str")
t".{1 + ''}" # E: Unsupported operand types for + ("int" and "str")
[builtins fixtures/f_string.pyi]

[case testTemplateStringParseFormatOptions]
value = 10.5142
width = 10
precision = 4
t"result: {value:{width}.{precision}}"
[builtins fixtures/f_string.pyi]
129 changes: 129 additions & 0 deletions test-data/unit/parse-python314.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
[case testTemplateStringSimple]
x = 'mypy'
t'Hello {x}'
[out]
MypyFile:1(
ImportFrom:-1(string.templatelib, [Template : __mypy-Template, Interpolation : __mypy-Interpolation])
AssignmentStmt:1(
NameExpr(x)
StrExpr(mypy))
ExpressionStmt:2(
CallExpr:2(
NameExpr(__mypy-Template)
Args(
StrExpr(Hello )
CallExpr:2(
NameExpr(__mypy-Interpolation)
Args(
NameExpr(x)
StrExpr(x)
NameExpr(None)
StrExpr()))))))

[case testTemplateStringWithConversion]
x = 'mypy'
T'Hello {x!r}'
[out]
MypyFile:1(
ImportFrom:-1(string.templatelib, [Template : __mypy-Template, Interpolation : __mypy-Interpolation])
AssignmentStmt:1(
NameExpr(x)
StrExpr(mypy))
ExpressionStmt:2(
CallExpr:2(
NameExpr(__mypy-Template)
Args(
StrExpr(Hello )
CallExpr:2(
NameExpr(__mypy-Interpolation)
Args(
NameExpr(x)
StrExpr(x)
StrExpr(r)
StrExpr()))))))

[case testTemplateStringWithOnlyFormatSpecifier]
x = 'mypy'
t'Hello {x:<30}'
[out]
MypyFile:1(
ImportFrom:-1(string.templatelib, [Template : __mypy-Template, Interpolation : __mypy-Interpolation])
AssignmentStmt:1(
NameExpr(x)
StrExpr(mypy))
ExpressionStmt:2(
CallExpr:2(
NameExpr(__mypy-Template)
Args(
StrExpr(Hello )
CallExpr:2(
NameExpr(__mypy-Interpolation)
Args(
NameExpr(x)
StrExpr(x)
NameExpr(None)
StrExpr(<30)))))))

[case testTemplateStringWithFormatSpecifierAndConversion]
x = 'mypy'
t'Hello {x!s:<30}'
[out]
MypyFile:1(
ImportFrom:-1(string.templatelib, [Template : __mypy-Template, Interpolation : __mypy-Interpolation])
AssignmentStmt:1(
NameExpr(x)
StrExpr(mypy))
ExpressionStmt:2(
CallExpr:2(
NameExpr(__mypy-Template)
Args(
StrExpr(Hello )
CallExpr:2(
NameExpr(__mypy-Interpolation)
Args(
NameExpr(x)
StrExpr(x)
StrExpr(s)
StrExpr(<30)))))))

[case testTemplateStringWithFormatSpecifierExpression]
x = 'mypy'
y = 30
t'Hello {x!s:<{y+y}}'
[out]
MypyFile:1(
ImportFrom:-1(string.templatelib, [Template : __mypy-Template, Interpolation : __mypy-Interpolation])
AssignmentStmt:1(
NameExpr(x)
StrExpr(mypy))
AssignmentStmt:2(
NameExpr(y)
IntExpr(30))
ExpressionStmt:3(
CallExpr:3(
NameExpr(__mypy-Template)
Args(
StrExpr(Hello )
CallExpr:3(
NameExpr(__mypy-Interpolation)
Args(
NameExpr(x)
StrExpr(x)
StrExpr(s)
CallExpr:3(
MemberExpr:3(
StrExpr()
join)
Args(
ListExpr:3(
StrExpr(<)
CallExpr:3(
MemberExpr:3(
StrExpr({:{}})
format)
Args(
OpExpr:3(
+
NameExpr(y)
NameExpr(y))
StrExpr())))))))))))