From ee8a4eda9dd61dad67b4810e98c5d2a2073c9f7f Mon Sep 17 00:00:00 2001 From: Niels Buwen Date: Tue, 3 Sep 2024 12:11:28 +0200 Subject: [PATCH 1/5] Inline semi dynamic format specs to enable f-string checking --- mypy/checkstrformat.py | 46 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/mypy/checkstrformat.py b/mypy/checkstrformat.py index c63210a96c44..61f20af96108 100644 --- a/mypy/checkstrformat.py +++ b/mypy/checkstrformat.py @@ -13,7 +13,8 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Callable, Dict, Final, Match, Pattern, Tuple, Union, cast +from itertools import chain +from typing import TYPE_CHECKING, Callable, Dict, Final, Match, Pattern, Tuple, Union, cast, Sequence, Iterator from typing_extensions import TypeAlias as _TypeAlias import mypy.errorcodes as codes @@ -343,6 +344,47 @@ def check_str_format_call(self, call: CallExpr, format_value: str) -> None: return self.check_specs_in_format_call(call, conv_specs, format_value) + def _spec_expression_with_peek(self, specs: list[ConversionSpecifier], expressions: list[Expression]) -> Iterator[tuple[ConversionSpecifier, Expression, Expression | None]]: + """ + Basically zip specs with expressions and the next expression + """ + optional_expression: Sequence[Expression | None] = expressions + expression_it = chain(optional_expression, [None]) + next(expression_it) + return zip(specs, expressions, expression_it, ) + + def inline_semi_dynamic_specs(self, call: CallExpr, specs: list[ConversionSpecifier], expressions: list[Expression]) -> tuple[list[ConversionSpecifier], list[Expression]]: + """ + Try to inline literal expressions into "dynamic" format specifiers + + e.g. "{:{}}".format(123, "foo") becomes "{:foo}.format(123)" + + This works by checking if a spec if a simple dynamic specifier and if the next expression is a literal string. + If so, the literal string is inlined into the spec and the next spec-expression pair is dropped + + This is useful for f-strings + """ + inlined_specs = [] + inlined_expressions = [] + + spec_with_pairwise_expression = self._spec_expression_with_peek(specs, expressions) + for spec, expression, next_expression in spec_with_pairwise_expression: + if spec.format_spec == ':{}': # most simple dynamic case + assert expression is not None # dynamic spec cannot be last, this should have been detected earlier + + if isinstance(next_expression, StrExpr): # now inline the literal + parsed = parse_format_value(f'{{:{next_expression.value}}}', call, self.msg) + if parsed is None or len(parsed) != 1: + continue + spec = parsed[0] + next(spec_with_pairwise_expression) + + inlined_specs.append(spec) + inlined_expressions.append(expression) + + self.auto_generate_keys(inlined_specs, call) + return inlined_specs, inlined_expressions + def check_specs_in_format_call( self, call: CallExpr, specs: list[ConversionSpecifier], format_value: str ) -> None: @@ -350,9 +392,11 @@ def check_specs_in_format_call( The core logic for format checking is implemented in this method. """ + #raise RuntimeError assert all(s.key for s in specs), "Keys must be auto-generated first!" replacements = self.find_replacements_in_call(call, [cast(str, s.key) for s in specs]) assert len(replacements) == len(specs) + specs, replacements = self.inline_semi_dynamic_specs(call, specs, replacements) for spec, repl in zip(specs, replacements): repl = self.apply_field_accessors(spec, repl, ctx=call) actual_type = repl.type if isinstance(repl, TempNode) else self.chk.lookup_type(repl) From ba0a46dc6955531661121f63666905eff81bd30a Mon Sep 17 00:00:00 2001 From: Niels Buwen Date: Tue, 3 Sep 2024 12:48:20 +0200 Subject: [PATCH 2/5] Add tests for f-string check --- test-data/unit/check-string-format.test | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 test-data/unit/check-string-format.test diff --git a/test-data/unit/check-string-format.test b/test-data/unit/check-string-format.test new file mode 100644 index 000000000000..1282a0948323 --- /dev/null +++ b/test-data/unit/check-string-format.test @@ -0,0 +1,20 @@ +[case acceptFstringWithoutSpecs] +[builtins fixtures/f_string.pyi] +reveal_type(f"{123} {True} {1 + 2} {'foo'}") # N: Revealed type is "builtins.str" + +[case acceptFstringWithValidSpecs] +[builtins fixtures/f_string.pyi] +f"{123:+04} {True!r} {1 + 2:+} {'foo':3<}" + +[case denyFstringsWithInvalidSpecs] +[builtins fixtures/f_string.pyi] +f"{'hi':+}" # E: Numeric flags are only allowed for numeric types +f"{123:foo}" # E: Unrecognized format specification "foo" + +class MyClass: + pass + +f"{MyClass:abc}" # E: Unrecognized format specification "abc" + + +f"{1} {2} {3:x} {4:y} {5:z}" # E: Unsupported format character "y" # E: Unsupported format character "z" From c5944405394ec9b835174bcd12295dca66182e1a Mon Sep 17 00:00:00 2001 From: Niels Buwen Date: Tue, 3 Sep 2024 12:52:45 +0200 Subject: [PATCH 3/5] Remove commented code --- mypy/checkstrformat.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/mypy/checkstrformat.py b/mypy/checkstrformat.py index 61f20af96108..02602982d2e1 100644 --- a/mypy/checkstrformat.py +++ b/mypy/checkstrformat.py @@ -14,7 +14,19 @@ import re from itertools import chain -from typing import TYPE_CHECKING, Callable, Dict, Final, Match, Pattern, Tuple, Union, cast, Sequence, Iterator +from typing import ( + TYPE_CHECKING, + Callable, + Dict, + Final, + Iterator, + Match, + Pattern, + Sequence, + Tuple, + Union, + cast, +) from typing_extensions import TypeAlias as _TypeAlias import mypy.errorcodes as codes @@ -392,7 +404,6 @@ def check_specs_in_format_call( The core logic for format checking is implemented in this method. """ - #raise RuntimeError assert all(s.key for s in specs), "Keys must be auto-generated first!" replacements = self.find_replacements_in_call(call, [cast(str, s.key) for s in specs]) assert len(replacements) == len(specs) From 1fa153c5889e3297c775f11828d9c5149161fd8f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Sep 2024 10:52:22 +0000 Subject: [PATCH 4/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/checkstrformat.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/mypy/checkstrformat.py b/mypy/checkstrformat.py index 02602982d2e1..de3f3380a88a 100644 --- a/mypy/checkstrformat.py +++ b/mypy/checkstrformat.py @@ -356,16 +356,20 @@ def check_str_format_call(self, call: CallExpr, format_value: str) -> None: return self.check_specs_in_format_call(call, conv_specs, format_value) - def _spec_expression_with_peek(self, specs: list[ConversionSpecifier], expressions: list[Expression]) -> Iterator[tuple[ConversionSpecifier, Expression, Expression | None]]: + def _spec_expression_with_peek( + self, specs: list[ConversionSpecifier], expressions: list[Expression] + ) -> Iterator[tuple[ConversionSpecifier, Expression, Expression | None]]: """ Basically zip specs with expressions and the next expression """ optional_expression: Sequence[Expression | None] = expressions expression_it = chain(optional_expression, [None]) next(expression_it) - return zip(specs, expressions, expression_it, ) + return zip(specs, expressions, expression_it) - def inline_semi_dynamic_specs(self, call: CallExpr, specs: list[ConversionSpecifier], expressions: list[Expression]) -> tuple[list[ConversionSpecifier], list[Expression]]: + def inline_semi_dynamic_specs( + self, call: CallExpr, specs: list[ConversionSpecifier], expressions: list[Expression] + ) -> tuple[list[ConversionSpecifier], list[Expression]]: """ Try to inline literal expressions into "dynamic" format specifiers @@ -381,11 +385,13 @@ def inline_semi_dynamic_specs(self, call: CallExpr, specs: list[ConversionSpecif spec_with_pairwise_expression = self._spec_expression_with_peek(specs, expressions) for spec, expression, next_expression in spec_with_pairwise_expression: - if spec.format_spec == ':{}': # most simple dynamic case - assert expression is not None # dynamic spec cannot be last, this should have been detected earlier + if spec.format_spec == ":{}": # most simple dynamic case + assert ( + expression is not None + ) # dynamic spec cannot be last, this should have been detected earlier if isinstance(next_expression, StrExpr): # now inline the literal - parsed = parse_format_value(f'{{:{next_expression.value}}}', call, self.msg) + parsed = parse_format_value(f"{{:{next_expression.value}}}", call, self.msg) if parsed is None or len(parsed) != 1: continue spec = parsed[0] From bda3dc1dff6b2d2be39346b7a832540232a5b561 Mon Sep 17 00:00:00 2001 From: Niels Buwen Date: Tue, 3 Sep 2024 13:14:05 +0200 Subject: [PATCH 5/5] Preserve format string conversion when inlining --- mypy/checkstrformat.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mypy/checkstrformat.py b/mypy/checkstrformat.py index de3f3380a88a..65cf3441fb80 100644 --- a/mypy/checkstrformat.py +++ b/mypy/checkstrformat.py @@ -391,7 +391,8 @@ def inline_semi_dynamic_specs( ) # dynamic spec cannot be last, this should have been detected earlier if isinstance(next_expression, StrExpr): # now inline the literal - parsed = parse_format_value(f"{{:{next_expression.value}}}", call, self.msg) + new_format_string = f"{{{spec.conversion or ''}:{next_expression.value}}}" + parsed = parse_format_value(new_format_string, call, self.msg) if parsed is None or len(parsed) != 1: continue spec = parsed[0]