From b1c2c661a5763acd962d8f829f2cb713ecfb9e78 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Thu, 19 Feb 2026 11:08:05 -0800 Subject: [PATCH 1/4] Add support for t-strings. Co-authored-by: Brian Schubert Co-authored-by: Claude --- mypy/checkexpr.py | 19 ++++++++ mypy/evalexpr.py | 3 ++ mypy/fastparse.py | 27 ++++++------ mypy/literals.py | 4 ++ mypy/nodes.py | 23 ++++++++++ mypy/semanal.py | 10 +++++ mypy/server/subexpr.py | 5 +++ mypy/strconv.py | 9 ++++ mypy/stubgen.py | 4 ++ mypy/traverser.py | 15 +++++++ mypy/treetransform.py | 13 ++++++ mypy/visitor.py | 4 ++ mypyc/irbuild/visitor.py | 4 ++ mypyc/test-data/run-python314.test | 16 +++++++ mypyc/test/test_run.py | 2 + test-data/unit/check-python314.test | 35 +++++++++++++-- test-data/unit/parse-python314.test | 67 ++++++++++++++++++++++++++++- 17 files changed, 243 insertions(+), 17 deletions(-) create mode 100644 mypyc/test-data/run-python314.test diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 6df02870d1042..3a5ea8a321519 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -30,6 +30,7 @@ from mypy.exprtotype import TypeTranslationError, expr_to_unanalyzed_type from mypy.infer import ArgumentInferContext, infer_function_type_arguments, infer_type_arguments from mypy.literals import literal +from mypy.lookup import lookup_fully_qualified from mypy.maptype import map_instance_to_supertype from mypy.meet import is_overlapping_types, narrow_declared_type from mypy.message_registry import ErrorMessage @@ -92,6 +93,7 @@ SuperExpr, SymbolNode, SymbolTableNode, + TemplateStrExpr, TempNode, TupleExpr, TypeAlias, @@ -5487,6 +5489,23 @@ def find_typeddict_context( # No TypedDict type in context. return [], False + def visit_template_str_expr(self, e: TemplateStrExpr) -> Type: + """Type check a template string expression (t-string). + + Type-checks all interpolated expressions but the result is always + string.templatelib.Template. + """ + for item in e.items: + if isinstance(item, tuple): + value_expr, _source, _conversion, format_spec = item + self.accept(value_expr) + if format_spec is not None: + self.accept(format_spec) + sym = lookup_fully_qualified("string.templatelib.Template", self.chk.modules) + if sym is not None and isinstance(sym.node, TypeInfo): + return Instance(sym.node, []) + return AnyType(TypeOfAny.from_error) + def visit_lambda_expr(self, e: LambdaExpr) -> Type: """Type check lambda expression.""" old_in_lambda = self.in_lambda_expr diff --git a/mypy/evalexpr.py b/mypy/evalexpr.py index 218d50e37ec36..f46e5c23c3e43 100644 --- a/mypy/evalexpr.py +++ b/mypy/evalexpr.py @@ -192,6 +192,9 @@ def visit__promote_expr(self, o: mypy.nodes.PromoteExpr) -> object: def visit_await_expr(self, o: mypy.nodes.AwaitExpr) -> object: return UNKNOWN + def visit_template_str_expr(self, o: mypy.nodes.TemplateStrExpr) -> object: + return UNKNOWN + def visit_temp_node(self, o: mypy.nodes.TempNode) -> object: return UNKNOWN diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 701e449f8f338..18e1b049ca03e 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -78,6 +78,7 @@ Statement, StrExpr, SuperExpr, + TemplateStrExpr, TempNode, TryStmt, TupleExpr, @@ -1685,20 +1686,22 @@ 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: - self.fail( - ErrorMessage("PEP 750 template strings are not yet supported"), - n.lineno, - n.col_offset, - blocker=False, - ) - e = TempNode(AnyType(TypeOfAny.from_error)) + def visit_TemplateStr(self, n: ast_TemplateStr) -> TemplateStrExpr: + items: list[Expression | tuple[Expression, str, str | None, Expression | None]] = [] + for value in n.values: + if isinstance(value, ast_Interpolation): + val_expr = self.visit(value.value) + val_expr.set_line(value.lineno, value.col_offset) + conversion = None if value.conversion < 0 else chr(value.conversion) + format_spec = ( + self.visit(value.format_spec) if value.format_spec is not None else None + ) + items.append((val_expr, value.str, conversion, format_spec)) + else: + items.append(self.visit(value)) + e = TemplateStrExpr(items) return self.set_line(e, n) - # Interpolation(expr value, constant str, int conversion, expr? format_spec) - def visit_Interpolation(self, n: ast_Interpolation) -> Expression: - assert False, "Unreachable" - # Attribute(expr value, identifier attr, expr_context ctx) def visit_Attribute(self, n: Attribute) -> MemberExpr | SuperExpr: value = n.value diff --git a/mypy/literals.py b/mypy/literals.py index 50720da35548c..f572a6f9c624f 100644 --- a/mypy/literals.py +++ b/mypy/literals.py @@ -42,6 +42,7 @@ StarExpr, StrExpr, SuperExpr, + TemplateStrExpr, TempNode, TupleExpr, TypeAliasExpr, @@ -316,6 +317,9 @@ def visit__promote_expr(self, e: PromoteExpr) -> None: def visit_await_expr(self, e: AwaitExpr) -> None: return None + def visit_template_str_expr(self, e: TemplateStrExpr) -> None: + return None + def visit_temp_node(self, e: TempNode) -> None: return None diff --git a/mypy/nodes.py b/mypy/nodes.py index 4c238592f9927..4dbed8beb8e33 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -2765,6 +2765,29 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T: return visitor.visit_dict_expr(self) +class TemplateStrExpr(Expression): + """Template string expression t'...'.""" + + __slots__ = ("items",) + __match_args__ = ("items",) + + # Each item is either: + # - a StrExpr (literal string segment), or + # - a tuple (value_expr, source_text, conversion, format_spec_expr) + # where conversion is str | None ("r", "s", "a", or None) + # and format_spec_expr is Expression | None + items: list[Expression | tuple[Expression, str, str | None, Expression | None]] + + def __init__( + self, items: list[Expression | tuple[Expression, str, str | None, Expression | None]] + ) -> None: + super().__init__() + self.items = items + + def accept(self, visitor: ExpressionVisitor[T]) -> T: + return visitor.visit_template_str_expr(self) + + class TupleExpr(Expression): """Tuple literal expression (..., ...) diff --git a/mypy/semanal.py b/mypy/semanal.py index 219459c92e3ce..e2c073fd4dea8 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -166,6 +166,7 @@ SymbolNode, SymbolTable, SymbolTableNode, + TemplateStrExpr, TempNode, TryStmt, TupleExpr, @@ -5850,6 +5851,15 @@ def visit_dict_expr(self, expr: DictExpr) -> None: key.accept(self) value.accept(self) + def visit_template_str_expr(self, expr: TemplateStrExpr) -> None: + for item in expr.items: + if isinstance(item, tuple): + item[0].accept(self) + if item[3] is not None: + item[3].accept(self) + else: + item.accept(self) + def visit_star_expr(self, expr: StarExpr) -> None: if not expr.valid: self.fail("can't use starred expression here", expr, blocker=True) diff --git a/mypy/server/subexpr.py b/mypy/server/subexpr.py index 013b936e8b7c9..12aae33eaaf59 100644 --- a/mypy/server/subexpr.py +++ b/mypy/server/subexpr.py @@ -26,6 +26,7 @@ SetExpr, SliceExpr, StarExpr, + TemplateStrExpr, TupleExpr, TypeApplication, TypeFormExpr, @@ -155,6 +156,10 @@ def visit_dict_expr(self, e: DictExpr) -> None: self.add(e) super().visit_dict_expr(e) + def visit_template_str_expr(self, e: TemplateStrExpr) -> None: + self.add(e) + super().visit_template_str_expr(e) + def visit_set_expr(self, e: SetExpr) -> None: self.add(e) super().visit_set_expr(e) diff --git a/mypy/strconv.py b/mypy/strconv.py index 168a8bcffdc7e..e16060f3f5817 100644 --- a/mypy/strconv.py +++ b/mypy/strconv.py @@ -497,6 +497,15 @@ def visit_list_expr(self, o: mypy.nodes.ListExpr) -> str: def visit_dict_expr(self, o: mypy.nodes.DictExpr) -> str: return self.dump([[k, v] for k, v in o.items], o) + def visit_template_str_expr(self, o: mypy.nodes.TemplateStrExpr) -> str: + items_repr: list[object] = [] + for item in o.items: + if isinstance(item, tuple): + items_repr.append(item[0]) # value expression + else: + items_repr.append(item) + return self.dump(items_repr, o) + def visit_set_expr(self, o: mypy.nodes.SetExpr) -> str: return self.dump(o.items, o) diff --git a/mypy/stubgen.py b/mypy/stubgen.py index 5d6149b97507a..ce6335e9e34f9 100755 --- a/mypy/stubgen.py +++ b/mypy/stubgen.py @@ -113,6 +113,7 @@ StarExpr, Statement, StrExpr, + TemplateStrExpr, TempNode, TupleExpr, TypeAliasStmt, @@ -373,6 +374,9 @@ def visit_dict_expr(self, o: DictExpr) -> str: dict_items.append(f"{key.accept(self)}: {value.accept(self)}") return f"{{{', '.join(dict_items)}}}" + def visit_template_str_expr(self, o: TemplateStrExpr) -> str: + return self.stubgen.add_name("_typeshed.Incomplete") + def visit_ellipsis(self, node: EllipsisExpr) -> str: return "..." diff --git a/mypy/traverser.py b/mypy/traverser.py index ad5e805d31ab5..6fdb54298f85c 100644 --- a/mypy/traverser.py +++ b/mypy/traverser.py @@ -68,6 +68,7 @@ StarExpr, StrExpr, SuperExpr, + TemplateStrExpr, TempNode, TryStmt, TupleExpr, @@ -329,6 +330,15 @@ def visit_dict_expr(self, o: DictExpr, /) -> None: k.accept(self) v.accept(self) + def visit_template_str_expr(self, o: TemplateStrExpr, /) -> None: + for item in o.items: + if isinstance(item, tuple): + item[0].accept(self) + if item[3] is not None: + item[3].accept(self) + else: + item.accept(self) + def visit_set_expr(self, o: SetExpr, /) -> None: for item in o.items: item.accept(self) @@ -785,6 +795,11 @@ def visit_dict_expr(self, o: DictExpr, /) -> None: return super().visit_dict_expr(o) + def visit_template_str_expr(self, o: TemplateStrExpr, /) -> None: + if not self.visit(o): + return + super().visit_template_str_expr(o) + def visit_tuple_expr(self, o: TupleExpr, /) -> None: if not self.visit(o): return diff --git a/mypy/treetransform.py b/mypy/treetransform.py index 1a76a50a2d94e..2c66d4b5f3659 100644 --- a/mypy/treetransform.py +++ b/mypy/treetransform.py @@ -77,6 +77,7 @@ StrExpr, SuperExpr, SymbolTable, + TemplateStrExpr, TempNode, TryStmt, TupleExpr, @@ -578,6 +579,18 @@ def visit_dict_expr(self, node: DictExpr) -> DictExpr: [(self.expr(key) if key else None, self.expr(value)) for key, value in node.items] ) + def visit_template_str_expr(self, node: TemplateStrExpr) -> TemplateStrExpr: + items: list[Expression | tuple[Expression, str, str | None, Expression | None]] = [] + for item in node.items: + if isinstance(item, tuple): + value, source, conversion, format_spec = item + items.append( + (self.expr(value), source, conversion, self.optional_expr(format_spec)) + ) + else: + items.append(self.expr(item)) + return TemplateStrExpr(items) + def visit_tuple_expr(self, node: TupleExpr) -> TupleExpr: return TupleExpr(self.expressions(node.items)) diff --git a/mypy/visitor.py b/mypy/visitor.py index e150788ec3c16..2b6d957316830 100644 --- a/mypy/visitor.py +++ b/mypy/visitor.py @@ -111,6 +111,10 @@ def visit_list_expr(self, o: mypy.nodes.ListExpr, /) -> T: def visit_dict_expr(self, o: mypy.nodes.DictExpr, /) -> T: pass + @abstractmethod + def visit_template_str_expr(self, o: mypy.nodes.TemplateStrExpr, /) -> T: + pass + @abstractmethod def visit_tuple_expr(self, o: mypy.nodes.TupleExpr, /) -> T: pass diff --git a/mypyc/irbuild/visitor.py b/mypyc/irbuild/visitor.py index dc81e95a2980e..705513171e3d1 100644 --- a/mypyc/irbuild/visitor.py +++ b/mypyc/irbuild/visitor.py @@ -66,6 +66,7 @@ StarExpr, StrExpr, SuperExpr, + TemplateStrExpr, TempNode, TryStmt, TupleExpr, @@ -311,6 +312,9 @@ def visit_tuple_expr(self, expr: TupleExpr) -> Value: def visit_dict_expr(self, expr: DictExpr) -> Value: return transform_dict_expr(self.builder, expr) + def visit_template_str_expr(self, expr: TemplateStrExpr) -> Value: + assert False, "TemplateStrExpr should have been handled already" + def visit_set_expr(self, expr: SetExpr) -> Value: return transform_set_expr(self.builder, expr) diff --git a/mypyc/test-data/run-python314.test b/mypyc/test-data/run-python314.test new file mode 100644 index 0000000000000..16b6142a34fd9 --- /dev/null +++ b/mypyc/test-data/run-python314.test @@ -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, "") diff --git a/mypyc/test/test_run.py b/mypyc/test/test_run.py index 7368bb93a1e5c..49995988da30c 100644 --- a/mypyc/test/test_run.py +++ b/mypyc/test/test_run.py @@ -91,6 +91,8 @@ 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 diff --git a/test-data/unit/check-python314.test b/test-data/unit/check-python314.test index f1043aab860ae..b7440bcc406b2 100644 --- a/test-data/unit/check-python314.test +++ b/test-data/unit/check-python314.test @@ -1,3 +1,32 @@ -[case testTemplateString] -reveal_type(t"mypy") # E: PEP 750 template strings are not yet supported \ - # N: Revealed type is "Any" +[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] diff --git a/test-data/unit/parse-python314.test b/test-data/unit/parse-python314.test index 34fe753084f67..c319d5d8eebb1 100644 --- a/test-data/unit/parse-python314.test +++ b/test-data/unit/parse-python314.test @@ -1,5 +1,68 @@ -[case testTemplateString] +[case testTemplateStringSimple] x = 'mypy' t'Hello {x}' [out] -main:2: error: PEP 750 template strings are not yet supported +MypyFile:1( + AssignmentStmt:1( + NameExpr(x) + StrExpr(mypy)) + ExpressionStmt:2( + TemplateStrExpr:2( + StrExpr(Hello ) + NameExpr(x)))) + +[case testTemplateStringWithConversion] +x = 'mypy' +T'Hello {x!r}' +[out] +MypyFile:1( + AssignmentStmt:1( + NameExpr(x) + StrExpr(mypy)) + ExpressionStmt:2( + TemplateStrExpr:2( + StrExpr(Hello ) + NameExpr(x)))) + +[case testTemplateStringWithOnlyFormatSpecifier] +x = 'mypy' +t'Hello {x:<30}' +[out] +MypyFile:1( + AssignmentStmt:1( + NameExpr(x) + StrExpr(mypy)) + ExpressionStmt:2( + TemplateStrExpr:2( + StrExpr(Hello ) + NameExpr(x)))) + +[case testTemplateStringWithFormatSpecifierAndConversion] +x = 'mypy' +t'Hello {x!s:<30}' +[out] +MypyFile:1( + AssignmentStmt:1( + NameExpr(x) + StrExpr(mypy)) + ExpressionStmt:2( + TemplateStrExpr:2( + StrExpr(Hello ) + NameExpr(x)))) + +[case testTemplateStringWithFormatSpecifierExpression] +x = 'mypy' +y = 30 +t'Hello {x!s:<{y+y}}' +[out] +MypyFile:1( + AssignmentStmt:1( + NameExpr(x) + StrExpr(mypy)) + AssignmentStmt:2( + NameExpr(y) + IntExpr(30)) + ExpressionStmt:3( + TemplateStrExpr:3( + StrExpr(Hello ) + NameExpr(x)))) From 5f20ab1272c6dd34113064910861effeb74a96a1 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Thu, 19 Feb 2026 13:51:04 -0800 Subject: [PATCH 2/4] Add non-abstract visit_template_str_expr() method. --- mypy/visitor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mypy/visitor.py b/mypy/visitor.py index 2b6d957316830..de754c408f97d 100644 --- a/mypy/visitor.py +++ b/mypy/visitor.py @@ -543,6 +543,9 @@ def visit_list_expr(self, o: mypy.nodes.ListExpr, /) -> T: def visit_dict_expr(self, o: mypy.nodes.DictExpr, /) -> T: raise NotImplementedError() + def visit_template_str_expr(self, o: mypy.nodes.TemplateStrExpr, /) -> T: + raise NotImplementedError() + def visit_tuple_expr(self, o: mypy.nodes.TupleExpr, /) -> T: raise NotImplementedError() From fee0a5b6d08d61c35ecd40e80483447085cdde8b Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Thu, 19 Feb 2026 13:57:19 -0800 Subject: [PATCH 3/4] Remove unneeded test_template_string_basic() test. --- mypyc/test-data/run-python314.test | 16 ---------------- mypyc/test/test_run.py | 2 -- 2 files changed, 18 deletions(-) delete mode 100644 mypyc/test-data/run-python314.test diff --git a/mypyc/test-data/run-python314.test b/mypyc/test-data/run-python314.test deleted file mode 100644 index 16b6142a34fd9..0000000000000 --- a/mypyc/test-data/run-python314.test +++ /dev/null @@ -1,16 +0,0 @@ --- 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, "") diff --git a/mypyc/test/test_run.py b/mypyc/test/test_run.py index 49995988da30c..7368bb93a1e5c 100644 --- a/mypyc/test/test_run.py +++ b/mypyc/test/test_run.py @@ -91,8 +91,6 @@ 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 From 88cc20e4ec87a653c988bb25762cb68ec74dd123 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Thu, 19 Feb 2026 15:15:50 -0800 Subject: [PATCH 4/4] Fix error with older Python versions. On Python < 3.14, ast_Interpolation is Any. Avoid typing error by using "type: ignore". This matches what is done for `ast_TypeVarTuple`. --- mypy/fastparse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 18e1b049ca03e..68826ecadb7b7 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -1689,7 +1689,7 @@ def visit_FormattedValue(self, n: ast3.FormattedValue) -> Expression: def visit_TemplateStr(self, n: ast_TemplateStr) -> TemplateStrExpr: items: list[Expression | tuple[Expression, str, str | None, Expression | None]] = [] for value in n.values: - if isinstance(value, ast_Interpolation): + if isinstance(value, ast_Interpolation): # type: ignore[misc] val_expr = self.visit(value.value) val_expr.set_line(value.lineno, value.col_offset) conversion = None if value.conversion < 0 else chr(value.conversion)