Skip to content

Commit 3a74764

Browse files
committed
WPS237: forbid complex f-string format specifiers
1 parent 419238d commit 3a74764

4 files changed

Lines changed: 146 additions & 11 deletions

File tree

CHANGELOG.md

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

2728
### Bugfixes
2829

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

Lines changed: 87 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,22 @@
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+
# Allowed format specifiers
38+
f_format_str = "f'{value!s}'"
39+
f_format_repr = "f'{value!r}'"
40+
f_format_code = "f'{value!a}'"
41+
f_format_hex_lower_short = "f'{value:x}'"
42+
f_format_hex_upper_short = "f'{value:X}'"
43+
f_format_hex_lower_long = "f'{value:#x}'"
44+
f_format_hex_upper_long = "f'{value:#X}'"
45+
f_format_char = "f'{value:c}'"
46+
f_format_rounded = "f'{value:.123456f}'"
47+
f_format_scientific = "f'{value:.456789e}'"
48+
f_format_var_single = "f'{value:{fmt}}'"
49+
f_format_var_single2 = "f'{value1:{fmt1}} {value2:{fmt2}}'"
50+
f_format_var_single_index = "f'{value:{fmt[0]}}'"
51+
f_format_var_single_call = "f'{value:{fmt()}}'"
52+
f_format_convertions = "f'{value1!r} {value2!s} {value3!a}'"
3653

3754
# Disallowed
3855
f_string = "f'x + y = {2 + 2}'"
@@ -53,6 +70,32 @@
5370
f_single_chained_functions = "f'{f1().f2()}'"
5471
f_function_with_four_args = "f'{func(arg1, arg2, arg3, arg4)}'"
5572
f_method_with_four_args = "f'{obj.meth(arg1, arg2, arg3, arg4)}-post'"
73+
f_nested_string = "f'{f\"{value}\"}'"
74+
# Disallowed format specifiers
75+
f_format_var_multi = "f'pre {value:{fmt1}{fmt2}}'"
76+
f_format_var_chain = "f'{value:{fmt.attr.attr}}'"
77+
f_format_var_before1 = "f'{value:{fmt}10}'"
78+
f_format_var_before2 = "f'{value:{fmt}.4f}'"
79+
f_format_var_after1 = "f'{value:_{fmt}}'"
80+
f_format_var_after2 = "f'{value:_^{fmt}}'"
81+
f_format_var_between1 = "f'{value:_{fmt}10}'"
82+
f_format_var_between2 = "f'{value:.{precision}f}'"
83+
f_format_var_around = "f'{value:{fmt1}^{fmt2}}'"
84+
f_format_str_const = "f'{value!s:10}'"
85+
f_format_repr_const = "f'{value!r:10}'"
86+
f_format_code_const = "f'{value!a:10}'"
87+
f_format_str_var = "f'{value!s:{fmt}}'"
88+
f_format_repr_var = "f'{value!r:{fmt}}'"
89+
f_format_code_var = "f'{value!a:{fmt}}'"
90+
f_format_hex_lower_short_const = "f'{value:10x}'"
91+
f_format_hex_upper_short_const = "f'{value:10X}'"
92+
f_format_hex_lower_long_const = "f'{value:#10x}'"
93+
f_format_hex_upper_long_const = "f'{value:#10X}'"
94+
f_format_char_const = "f'{value:10c}'"
95+
f_format_round_const = "f'{value:10.4f}'"
96+
f_format_scientific_const = "f'{value:10.7e}'"
97+
f_format_useless1 = "f'{value:_}'"
98+
f_format_useless2 = "f'{value:_<}'"
5699

57100
# regression 1921
58101
f_string_comma_format = 'f"Count={count:,}"'
@@ -103,6 +146,33 @@ def test_string_normal(
103146
f_calling_returned_function,
104147
f_single_chained_functions,
105148
f_function_with_four_args,
149+
f_method_with_four_args,
150+
f_nested_string,
151+
# format specifiers
152+
f_format_var_multi,
153+
f_format_var_chain,
154+
f_format_var_before1,
155+
f_format_var_before2,
156+
f_format_var_after1,
157+
f_format_var_after2,
158+
f_format_var_between1,
159+
f_format_var_between2,
160+
f_format_var_around,
161+
f_format_str_const,
162+
f_format_repr_const,
163+
f_format_code_const,
164+
f_format_str_var,
165+
f_format_repr_var,
166+
f_format_code_var,
167+
f_format_hex_lower_short_const,
168+
f_format_hex_upper_short_const,
169+
f_format_hex_lower_long_const,
170+
f_format_hex_upper_long_const,
171+
f_format_char_const,
172+
f_format_round_const,
173+
f_format_scientific_const,
174+
f_format_useless1,
175+
f_format_useless2,
106176
],
107177
)
108178
def test_complex_f_string(assert_errors, parse_ast_tree, code, default_options):
@@ -125,6 +195,7 @@ def test_complex_f_string(assert_errors, parse_ast_tree, code, default_options):
125195
f_function_empty_args,
126196
f_list_index_lookup,
127197
f_variable_lookup,
198+
f_multi_variable_lookup,
128199
f_single_chained_attr,
129200
f_attr_on_function,
130201
f_true_index,
@@ -135,6 +206,22 @@ def test_complex_f_string(assert_errors, parse_ast_tree, code, default_options):
135206
f_function_with_single_arg,
136207
f_function_with_three_args,
137208
f_method_with_three_args,
209+
# format specifiers
210+
f_format_str,
211+
f_format_repr,
212+
f_format_code,
213+
f_format_hex_lower_short,
214+
f_format_hex_upper_short,
215+
f_format_hex_lower_long,
216+
f_format_hex_upper_long,
217+
f_format_char,
218+
f_format_rounded,
219+
f_format_scientific,
220+
f_format_var_single,
221+
f_format_var_single2,
222+
f_format_var_single_index,
223+
f_format_var_single_call,
224+
f_format_convertions,
138225
],
139226
)
140227
def test_simple_f_string(assert_errors, parse_ast_tree, code, default_options):

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: 50 additions & 11 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,7 +9,7 @@
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
)
@@ -81,7 +82,7 @@ def _check_is_alphabet(
8182

8283

8384
@final
84-
class WrongFormatStringVisitor(base.BaseNodeVisitor):
85+
class WrongFormatStringVisitor(base.BaseNodeVisitor): # noqa: WPS214
8586
"""Restricts usage of ``f`` strings."""
8687

8788
_valid_format_index: ClassVar[AnyNodes] = (
@@ -101,24 +102,62 @@ class WrongFormatStringVisitor(base.BaseNodeVisitor):
101102

102103
def visit_JoinedStr(self, node: ast.JoinedStr) -> None:
103104
"""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)
105+
self._check_complex_formatted_string(node)
108106
self.generic_visit(node)
109107

110108
def _check_complex_formatted_string(self, node: ast.JoinedStr) -> None:
111109
"""Allows all simple uses of `f` strings."""
110+
parent = walk.get_closest_parent(node, ast.JoinedStr)
112111
for string_component in node.values:
112+
# check component complexity
113+
is_component_complex = False
113114
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),
115+
is_component_complex = not self._is_valid_formatted_value(
116+
string_component.value
117+
)
118+
# check format complexity for nested JoinedStr
119+
is_format_complex = parent and (
120+
self._has_conversion(string_component)
121+
or len(node.values) > 1
122+
or not self._is_const_format_valid(string_component, parent)
123+
)
124+
125+
if is_component_complex or is_format_complex:
126+
self.add_violation(
127+
complexity.TooComplexFormattedStringViolation(
128+
parent or node
129+
),
119130
)
120131
return
121132

133+
def _is_const_format_valid(
134+
self,
135+
string_component: ast.AST,
136+
parent: ast.AST | None,
137+
) -> bool:
138+
if (
139+
parent
140+
and isinstance(string_component, ast.Constant)
141+
and isinstance(string_component.value, str)
142+
):
143+
# check if a string component contains a valid format pattern
144+
matched = re.match(
145+
r'^((?:\.\d+(?:f|e))|#?[x|X]|[c,])$',
146+
string_component.value,
147+
)
148+
return bool(matched)
149+
return True
150+
151+
def _has_conversion(self, string_component: ast.AST) -> bool:
152+
formatted_component = (
153+
walk.get_closest_parent(string_component, ast.FormattedValue)
154+
or string_component
155+
)
156+
return (
157+
isinstance(formatted_component, ast.FormattedValue)
158+
and formatted_component.conversion != -1
159+
)
160+
122161
def _is_valid_formatted_value(self, format_value: ast.AST) -> bool:
123162
if isinstance(
124163
format_value,

0 commit comments

Comments
 (0)