Skip to content

Commit 48a61e4

Browse files
authored
Merge pull request #328 from jakkdl/suggest_r_format_specifier
Add B028 - suggest !r for quote-wrapped variables in f-strings
2 parents bba5ac4 + ef39e9a commit 48a61e4

File tree

4 files changed

+340
-0
lines changed

4 files changed

+340
-0
lines changed

Diff for: README.rst

+3
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ limitations make it difficult.
169169

170170
**B027**: Empty method in abstract base class, but has no abstract decorator. Consider adding @abstractmethod.
171171

172+
**B028**: Consider replacing ``f"'{foo}'"`` with ``f"{foo!r}"`` which is both easier to read and will escape quotes inside ``foo`` if that would appear. The check tries to filter out any format specs that are invalid together with ``!r``. If you're using other conversion flags then e.g. ``f"'{foo!a}'"`` can be replaced with ``f"{ascii(foo)!r}"``. Not currently implemented for python<3.8 or ``str.format()`` calls.
173+
172174
Opinionated warnings
173175
~~~~~~~~~~~~~~~~~~~~
174176

@@ -310,6 +312,7 @@ Future
310312
~~~~~~~~~
311313

312314
* Add B906: ``visit_`` function with no further calls to a ``visit`` function. (#313)
315+
* Add B028: Suggest ``!r`` when formatted value in f-string is surrounded by quotes. (#319)
313316

314317
22.12.6
315318
~~~~~~~~~

Diff for: bugbear.py

+121
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import math
66
import re
7+
import sys
78
from collections import namedtuple
89
from contextlib import suppress
910
from functools import lru_cache, partial
@@ -448,6 +449,10 @@ def visit_With(self, node):
448449
self.check_for_b022(node)
449450
self.generic_visit(node)
450451

452+
def visit_JoinedStr(self, node):
453+
self.check_for_b028(node)
454+
self.generic_visit(node)
455+
451456
def check_for_b005(self, node):
452457
if node.func.attr not in B005.methods:
453458
return # method name doesn't match
@@ -1009,6 +1014,116 @@ def check_for_b906(self, node: ast.FunctionDef):
10091014
else:
10101015
self.errors.append(B906(node.lineno, node.col_offset))
10111016

1017+
def check_for_b028(self, node: ast.JoinedStr): # noqa: C901
1018+
# AST structure of strings in f-strings in 3.7 is different enough this
1019+
# implementation doesn't work
1020+
if sys.version_info <= (3, 7):
1021+
return # pragma: no cover
1022+
1023+
def myunparse(node: ast.AST) -> str: # pragma: no cover
1024+
if sys.version_info >= (3, 9):
1025+
return ast.unparse(node)
1026+
if isinstance(node, ast.Name):
1027+
return node.id
1028+
if isinstance(node, ast.Attribute):
1029+
return myunparse(node.value) + "." + node.attr
1030+
if isinstance(node, ast.Constant):
1031+
return repr(node.value)
1032+
if isinstance(node, ast.Call):
1033+
return myunparse(node.func) + "()" # don't bother with arguments
1034+
1035+
# as a last resort, just give the type name
1036+
return type(node).__name__
1037+
1038+
quote_marks = "'\""
1039+
current_mark = None
1040+
variable = None
1041+
for value in node.values:
1042+
# check for quote mark after pre-marked variable
1043+
if (
1044+
current_mark is not None
1045+
and variable is not None
1046+
and isinstance(value, ast.Constant)
1047+
and isinstance(value.value, str)
1048+
and value.value[0] == current_mark
1049+
):
1050+
self.errors.append(
1051+
B028(
1052+
variable.lineno,
1053+
variable.col_offset,
1054+
vars=(myunparse(variable.value),),
1055+
)
1056+
)
1057+
current_mark = variable = None
1058+
# don't continue with length>1, so we can detect a new pre-mark
1059+
# in the same string as a post-mark, e.g. `"{foo}" "{bar}"`
1060+
if len(value.value) == 1:
1061+
continue
1062+
1063+
# detect pre-mark
1064+
if (
1065+
isinstance(value, ast.Constant)
1066+
and isinstance(value.value, str)
1067+
and value.value[-1] in quote_marks
1068+
):
1069+
current_mark = value.value[-1]
1070+
variable = None
1071+
continue
1072+
1073+
# detect variable, if there's a pre-mark
1074+
if (
1075+
current_mark is not None
1076+
and variable is None
1077+
and isinstance(value, ast.FormattedValue)
1078+
and value.conversion != ord("r")
1079+
):
1080+
# check if the format spec shows that this is numeric
1081+
# or otherwise hard/impossible to convert to `!r`
1082+
if (
1083+
isinstance(value.format_spec, ast.JoinedStr)
1084+
and value.format_spec.values # empty format spec is fine
1085+
):
1086+
if (
1087+
# if there's variables in the format_spec, skip
1088+
len(value.format_spec.values) > 1
1089+
or not isinstance(value.format_spec.values[0], ast.Constant)
1090+
):
1091+
current_mark = variable = None
1092+
continue
1093+
format_specifier = value.format_spec.values[0].value
1094+
1095+
# if second character is an align, first character is a fill
1096+
# char - strip it
1097+
if len(format_specifier) > 1 and format_specifier[1] in "<>=^":
1098+
format_specifier = format_specifier[1:]
1099+
1100+
# strip out precision digits, so the only remaining ones are
1101+
# width digits, which will misplace the quotes
1102+
format_specifier = re.sub(r"\.\d*", "", format_specifier)
1103+
1104+
# skip if any invalid characters in format spec
1105+
invalid_characters = "".join(
1106+
(
1107+
"=", # align character only valid for numerics
1108+
"+- ", # sign
1109+
"0123456789", # width digits
1110+
"z", # coerce negative zero floating point to positive
1111+
"#", # alternate form
1112+
"_,", # thousands grouping
1113+
"bcdeEfFgGnoxX%", # various number specifiers
1114+
)
1115+
)
1116+
if set(format_specifier).intersection(invalid_characters):
1117+
current_mark = variable = None
1118+
continue
1119+
1120+
# otherwise save value as variable
1121+
variable = value
1122+
continue
1123+
1124+
# if no pre-mark or variable detected, reset state
1125+
current_mark = variable = None
1126+
10121127

10131128
def compose_call_path(node):
10141129
if isinstance(node, ast.Attribute):
@@ -1370,6 +1485,12 @@ def visit_Lambda(self, node):
13701485
" decorator. Consider adding @abstractmethod."
13711486
)
13721487
)
1488+
B028 = Error(
1489+
message=(
1490+
"B028 {!r} is manually surrounded by quotes, consider using the `!r` conversion"
1491+
" flag."
1492+
)
1493+
)
13731494

13741495
# Warnings disabled by default.
13751496
B901 = Error(

Diff for: tests/b028.py

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
def foo():
2+
return "hello"
3+
4+
5+
var = var2 = "hello"
6+
7+
# warnings
8+
f"begin '{var}' end"
9+
f"'{var}' end"
10+
f"begin '{var}'"
11+
12+
f'begin "{var}" end'
13+
f'"{var}" end'
14+
f'begin "{var}"'
15+
16+
f'a "{"hello"}" b'
17+
f'a "{foo()}" b'
18+
19+
# fmt: off
20+
k = (f'"' # error emitted on this line since all values are assigned the same lineno
21+
f'{var}'
22+
f'"'
23+
f'"')
24+
25+
k = (f'"' # error emitted on this line
26+
f'{var}'
27+
'"'
28+
f'"')
29+
# fmt: on
30+
31+
f'{"hello"}"{var}"' # warn for var and not hello
32+
f'"{var}"{"hello"}' # warn for var and not hello
33+
f'"{var}" and {"hello"} and "{var2}"' # warn for var and var2
34+
f'"{var}" and "{var2}"' # warn for both
35+
f'"{var}""{var2}"' # warn for both
36+
37+
# check that pre-quote & variable is reset if no post-quote is found
38+
f'"{var}abc "{var2}"' # warn on var2
39+
40+
# check formatting on different contained types
41+
f'"{var}"'
42+
f'"{var.__str__}"'
43+
f'"{var.__str__.__repr__}"'
44+
f'"{3+5}"'
45+
f'"{foo()}"'
46+
f'"{None}"'
47+
f'"{...}"' # although f'"{...!r}"' == 'Ellipsis'
48+
f'"{True}"'
49+
50+
# alignment specifier
51+
f'"{var:<}"'
52+
f'"{var:>}"'
53+
f'"{var:^}"'
54+
f'"{var:5<}"'
55+
56+
# explicit string specifier
57+
f'"{var:s}"'
58+
59+
# empty format string
60+
f'"{var:}"'
61+
62+
# These all currently give warnings, but could be considered false alarms
63+
# multiple quote marks
64+
f'"""{var}"""'
65+
# str conversion specified
66+
f'"{var!s}"'
67+
# two variables fighting over the same quote mark
68+
f'"{var}"{var2}"' # currently gives warning on the first one
69+
70+
71+
# ***no warnings*** #
72+
73+
# padding inside quotes
74+
f'"{var:5}"'
75+
76+
# quote mark not immediately adjacent
77+
f'" {var} "'
78+
f'"{var} "'
79+
f'" {var}"'
80+
81+
# mixed quote marks
82+
f"'{var}\""
83+
84+
# repr specifier already given
85+
f'"{var!r}"'
86+
87+
# two variables in a row with no quote mark inbetween
88+
f'"{var}{var}"'
89+
90+
# don't crash on non-string constants
91+
f'5{var}"'
92+
f"\"{var}'"
93+
94+
# sign option (only valid for number types)
95+
f'"{var:+}"'
96+
97+
# integer presentation type specified
98+
f'"{var:b}"'
99+
f'"{var:x}"'
100+
101+
# float presentation type
102+
f'"{var:e%}"'
103+
104+
# alignment specifier invalid for strings
105+
f'"{var:=}"'
106+
107+
# other types and combinations are tested in test_b028_format_specifier_permutations
108+
109+
# don't attempt to parse complex format specs
110+
f'"{var:{var}}"'
111+
f'"{var:5{var}}"'
112+
113+
# even if explicit string type (not implemented)
114+
f'"{var:{var}s}"'

0 commit comments

Comments
 (0)