Skip to content

Commit 57dc40e

Browse files
WPS237: forbid complex f-string format specifiers (#3608)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 651d3e4 commit 57dc40e

5 files changed

Lines changed: 176 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Semantic versioning in our case means:
2525
- Allows walrus operator in `WPS332`, #3505
2626
- Forbids symmetric bitwise operations in `WPS345`, #3593
2727
- Adds `WPS366`: forbid meaningless boolean operations, #3593
28+
- Forbids complex f-string format specifiers in `WPS237`, #3491
2829

2930
### Bugfixes
3031

tests/test_visitors/test_ast/test_builtins/test_strings/test_formatted_string.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
# Allowed
2323
f_single_chained_attr = "f'{attr1.attr2}'"
2424
f_variable_lookup = "f'smth {value}'"
25+
f_multi_variable_lookup = "f'smth {value1} {value2} {value3}'"
2526
f_dict_lookup_str_key = 'f\'smth {dict_value["key"]}\''
2627
f_list_index_lookup = "f'smth {list_value[0]}'"
2728
f_function_empty_args = "f'smth {user.get_full_name()}'"
@@ -33,6 +34,32 @@
3334
f_function_with_single_arg = "f'smth {func(arg)}'"
3435
f_function_with_three_args = "f'{func(arg1, arg2, arg3)}'"
3536
f_method_with_three_args = "f'{obj.method(arg1, arg2, arg3)}'"
37+
f_assign = "f'{value=}'"
38+
f_assign_attr = "f'{value.attr=}'"
39+
f_assign_call = "f'{value()=}'"
40+
41+
# Allowed format specifiers
42+
f_format_aligned = "f'{value:<5}'"
43+
f_format_str = "f'{value!s}'"
44+
f_format_repr = "f'{value!r}'"
45+
f_format_code = "f'{value!a}'"
46+
f_format_hex_lower_short = "f'{value:x}'"
47+
f_format_hex_upper_short = "f'{value:X}'"
48+
f_format_hex_lower_long = "f'{value:#x}'"
49+
f_format_hex_upper_long = "f'{value:#X}'"
50+
f_format_char = "f'{value:c}'"
51+
f_format_rounded = "f'{value:.123456f}'"
52+
f_format_scientific = "f'{value:.456789e}'"
53+
f_format_var_single = "f'{value:{fmt}}'"
54+
f_format_var_single2 = "f'{value1:{fmt1}} {value2:{fmt2}}'"
55+
f_format_var_single_index = "f'{value:{fmt[0]}}'"
56+
f_format_var_single_call = "f'{value:{fmt()}}'"
57+
f_format_convertions = "f'{value1!r} {value2!s} {value3!a}'"
58+
f_format_assign = "f'{value=:<8}'"
59+
f_format_assign_conversion = "f'{value=!r}'"
60+
f_format_assign_attr = "f'{value.attr=:.456e}'"
61+
f_format_assign_var_single = "f'{value=:{fmt}}'"
62+
f_format_assign_var_single2 = "f'{value1=:{fmt1}} {value2=:{fmt2}}'"
3663

3764
# Disallowed
3865
f_string = "f'x + y = {2 + 2}'"
@@ -53,6 +80,36 @@
5380
f_single_chained_functions = "f'{f1().f2()}'"
5481
f_function_with_four_args = "f'{func(arg1, arg2, arg3, arg4)}'"
5582
f_method_with_four_args = "f'{obj.meth(arg1, arg2, arg3, arg4)}-post'"
83+
f_nested_string = 'f\'{f"{value}"}\''
84+
# Disallowed format specifiers
85+
f_format_var_multi = "f'pre {value:{fmt1}{fmt2}}'"
86+
f_format_var_chain = "f'{value:{fmt.attr.attr}}'"
87+
f_format_var_before1 = "f'{value:{fmt}10}'"
88+
f_format_var_before2 = "f'{value:{fmt}.4f}'"
89+
f_format_var_after1 = "f'{value:_{fmt}}'"
90+
f_format_var_after2 = "f'{value:_^{fmt}}'"
91+
f_format_var_between1 = "f'{value:_{fmt}10}'"
92+
f_format_var_between2 = "f'{value:.{precision}f}'"
93+
f_format_var_around = "f'{value:{fmt1}^{fmt2}}'"
94+
f_format_str_const = "f'{value!s:10}'"
95+
f_format_repr_const = "f'{value!r:10}'"
96+
f_format_code_const = "f'{value!a:10}'"
97+
f_format_str_var = "f'{value!s:{fmt}}'"
98+
f_format_repr_var = "f'{value!r:{fmt}}'"
99+
f_format_code_var = "f'{value!a:{fmt}}'"
100+
f_format_hex_lower_short_const = "f'{value:10x}'"
101+
f_format_hex_upper_short_const = "f'{value:10X}'"
102+
f_format_hex_lower_long_const = "f'{value:#10x}'"
103+
f_format_hex_upper_long_const = "f'{value:#10X}'"
104+
f_format_char_const = "f'{value:10c}'"
105+
f_format_round_const = "f'{value:10.4f}'"
106+
f_format_scientific_const = "f'{value:10.7e}'"
107+
f_format_useless1 = "f'{value:_}'"
108+
f_format_useless2 = "f'{value:_<}'"
109+
f_format_assign_const = "f'{value=:_^8.2f}'"
110+
f_format_assign_conversion_const = "f'{value=!r:_>11}'"
111+
f_format_assign_var_chain = "f'{value=:{fmt.attr.attr}}'"
112+
f_format_assign_var_multi = "f'{value=:{fmt1}{fmt2}}'"
56113

57114
# regression 1921
58115
f_string_comma_format = 'f"Count={count:,}"'
@@ -103,6 +160,37 @@ def test_string_normal(
103160
f_calling_returned_function,
104161
f_single_chained_functions,
105162
f_function_with_four_args,
163+
f_method_with_four_args,
164+
f_nested_string,
165+
# format specifiers
166+
f_format_var_multi,
167+
f_format_var_chain,
168+
f_format_var_before1,
169+
f_format_var_before2,
170+
f_format_var_after1,
171+
f_format_var_after2,
172+
f_format_var_between1,
173+
f_format_var_between2,
174+
f_format_var_around,
175+
f_format_str_const,
176+
f_format_repr_const,
177+
f_format_code_const,
178+
f_format_str_var,
179+
f_format_repr_var,
180+
f_format_code_var,
181+
f_format_hex_lower_short_const,
182+
f_format_hex_upper_short_const,
183+
f_format_hex_lower_long_const,
184+
f_format_hex_upper_long_const,
185+
f_format_char_const,
186+
f_format_round_const,
187+
f_format_scientific_const,
188+
f_format_useless1,
189+
f_format_useless2,
190+
f_format_assign_const,
191+
f_format_assign_conversion_const,
192+
f_format_assign_var_chain,
193+
f_format_assign_var_multi,
106194
],
107195
)
108196
def test_complex_f_string(assert_errors, parse_ast_tree, code, default_options):
@@ -125,6 +213,7 @@ def test_complex_f_string(assert_errors, parse_ast_tree, code, default_options):
125213
f_function_empty_args,
126214
f_list_index_lookup,
127215
f_variable_lookup,
216+
f_multi_variable_lookup,
128217
f_single_chained_attr,
129218
f_attr_on_function,
130219
f_true_index,
@@ -135,6 +224,31 @@ def test_complex_f_string(assert_errors, parse_ast_tree, code, default_options):
135224
f_function_with_single_arg,
136225
f_function_with_three_args,
137226
f_method_with_three_args,
227+
f_assign,
228+
f_assign_attr,
229+
f_assign_call,
230+
# format specifiers
231+
f_format_aligned,
232+
f_format_str,
233+
f_format_repr,
234+
f_format_code,
235+
f_format_hex_lower_short,
236+
f_format_hex_upper_short,
237+
f_format_hex_lower_long,
238+
f_format_hex_upper_long,
239+
f_format_char,
240+
f_format_rounded,
241+
f_format_scientific,
242+
f_format_var_single,
243+
f_format_var_single2,
244+
f_format_var_single_index,
245+
f_format_var_single_call,
246+
f_format_convertions,
247+
f_format_assign,
248+
f_format_assign_conversion,
249+
f_format_assign_attr,
250+
f_format_assign_var_single,
251+
f_format_assign_var_single2,
138252
],
139253
)
140254
def test_simple_f_string(assert_errors, parse_ast_tree, code, default_options):

wemake_python_styleguide/logic/tree/strings.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import ast
22

3+
from wemake_python_styleguide.logic.walk import get_closest_parent
4+
35

46
def is_doc_string(node: ast.AST) -> bool:
57
"""
@@ -14,3 +16,14 @@ def is_doc_string(node: ast.AST) -> bool:
1416
node.value.value,
1517
str,
1618
)
19+
20+
21+
def has_fstring_conversion(component: ast.AST) -> bool:
22+
"""Checks whether f-string with the component has a conversion specifier."""
23+
formatted_component = (
24+
get_closest_parent(component, ast.FormattedValue) or component
25+
)
26+
return (
27+
isinstance(formatted_component, ast.FormattedValue)
28+
and formatted_component.conversion != -1
29+
)

wemake_python_styleguide/violations/complexity.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,6 +1149,9 @@ class TooComplexFormattedStringViolation(ASTViolation):
11491149
string as the key
11501150
- the return value of a function / method call with 3 arguments maximum
11511151
1152+
Using format specifiers also increases complexity, so you should avoid
1153+
using more than one specifier for a single value.
1154+
11521155
Reasoning:
11531156
Complex ``f`` strings are often difficult to understand,
11541157
making the code less readable. Generally we don't allow
@@ -1164,12 +1167,17 @@ class TooComplexFormattedStringViolation(ASTViolation):
11641167
# Correct:
11651168
f'smth {user.get_full_name()}'
11661169
f'smth {math_func(1, 2, 3)}'
1170+
f'smth {value=:.2f}
1171+
f'smth {value=:{formatspec}}
11671172
11681173
# Wrong:
11691174
f'{reverse("url-name")}?{"&".join("user=" + uid for uid in user_ids)}'
11701175
f'smth {math_func(1, 2, 3, 4)}'
1176+
f'smth {value=:{filler}^{padding}.{precision}f}'
11711177
11721178
.. versionadded:: 0.15.0
1179+
.. versionchanged:: 1.6.0
1180+
Complex format specifiers are forbidden.
11731181
11741182
"""
11751183

wemake_python_styleguide/visitors/ast/builtins.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ast
2+
import re
23
import string
34
from collections.abc import Sequence
45
from typing import ClassVar, Final, TypeAlias, final
@@ -8,13 +9,14 @@
89
AssignNodesWithWalrus,
910
FunctionNodes,
1011
)
11-
from wemake_python_styleguide.logic import nodes, source, walk
12+
from wemake_python_styleguide.logic import source, walk
1213
from wemake_python_styleguide.logic.complexity.annotations import (
1314
check_is_node_in_specific_annotation,
1415
)
1516
from wemake_python_styleguide.logic.tree import (
1617
attributes,
1718
operators,
19+
strings,
1820
variables,
1921
)
2022
from wemake_python_styleguide.types import (
@@ -101,24 +103,52 @@ class WrongFormatStringVisitor(base.BaseNodeVisitor):
101103

102104
def visit_JoinedStr(self, node: ast.JoinedStr) -> None:
103105
"""Forbids use of ``f`` strings and too complex ``f`` strings."""
104-
if not isinstance(nodes.get_parent(node), ast.FormattedValue):
105-
# We need this condition to make sure that this
106-
# is not a part of complex string format like `f"Count={count:,}"`:
107-
self._check_complex_formatted_string(node)
106+
self._check_complex_formatted_string(node)
108107
self.generic_visit(node)
109108

110109
def _check_complex_formatted_string(self, node: ast.JoinedStr) -> None:
111110
"""Allows all simple uses of `f` strings."""
111+
parent = walk.get_closest_parent(node, ast.JoinedStr)
112112
for string_component in node.values:
113+
# check component complexity
114+
is_component_complex = False
113115
if isinstance(string_component, ast.FormattedValue):
114-
# Test if possible chaining is invalid
115-
if self._is_valid_formatted_value(string_component.value):
116-
continue
117-
self.add_violation( # Everything else is too complex:
118-
complexity.TooComplexFormattedStringViolation(node),
116+
is_component_complex = not self._is_valid_formatted_value(
117+
string_component.value
118+
)
119+
# check format complexity for nested JoinedStr
120+
is_format_complex = parent and (
121+
strings.has_fstring_conversion(string_component)
122+
or len(node.values) > 1
123+
or not self._is_const_format_valid(string_component, parent)
124+
)
125+
126+
if is_component_complex or is_format_complex:
127+
self.add_violation(
128+
complexity.TooComplexFormattedStringViolation(
129+
parent or node
130+
),
119131
)
120132
return
121133

134+
def _is_const_format_valid(
135+
self,
136+
string_component: ast.AST,
137+
parent: ast.AST | None,
138+
) -> bool:
139+
if (
140+
parent
141+
and isinstance(string_component, ast.Constant)
142+
and isinstance(string_component.value, str)
143+
):
144+
# check if a string component contains a valid format pattern
145+
matched = re.match(
146+
r'^(\.\d+(?:f|e)|[<^>]\d+|#?[x|X]|[c,])$',
147+
string_component.value,
148+
)
149+
return bool(matched)
150+
return True
151+
122152
def _is_valid_formatted_value(self, format_value: ast.AST) -> bool:
123153
if isinstance(
124154
format_value,

0 commit comments

Comments
 (0)