diff --git a/mypy/fastparse.py b/mypy/fastparse.py index e2af2198cdfd..c4f0f779c065 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -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, @@ -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: @@ -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 @@ -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 diff --git a/mypy/test/testcheck.py b/mypy/test/testcheck.py index fb2eb3a75b9b..0645e5a27b01 100644 --- a/mypy/test/testcheck.py +++ b/mypy/test/testcheck.py @@ -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"): diff --git a/mypy/test/testparse.py b/mypy/test/testparse.py index 027ca4dd2887..c8bcb5c0d807 100644 --- a/mypy/test/testparse.py +++ b/mypy/test/testparse.py @@ -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) @@ -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 diff --git a/mypy/typeshed/stdlib/ast.pyi b/mypy/typeshed/stdlib/ast.pyi index af9d20d086b3..1c483523b5dd 100644 --- a/mypy/typeshed/stdlib/ast.pyi +++ b/mypy/typeshed/stdlib/ast.pyi @@ -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__( @@ -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: ... diff --git a/mypyc/test-data/run-python314.test b/mypyc/test-data/run-python314.test new file mode 100644 index 000000000000..16b6142a34fd --- /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 b96c4241f30d..b7d98844cd03 100644 --- a/mypyc/test/test_run.py +++ b/mypyc/test/test_run.py @@ -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 diff --git a/test-data/unit/check-python314.test b/test-data/unit/check-python314.test new file mode 100644 index 000000000000..b7440bcc406b --- /dev/null +++ b/test-data/unit/check-python314.test @@ -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] diff --git a/test-data/unit/parse-python314.test b/test-data/unit/parse-python314.test new file mode 100644 index 000000000000..ad92ae5a981b --- /dev/null +++ b/test-data/unit/parse-python314.test @@ -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())))))))))))