Skip to content

Commit 8e7f0eb

Browse files
committed
Add FStringDetector for self-doc and PEP 701 f-string detection
self-doc is 3.8+ PEP 701 f-string is 3.12+
1 parent dbbd5f9 commit 8e7f0eb

4 files changed

Lines changed: 878 additions & 1 deletion

File tree

runtests.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"config",
1818
"arguments",
1919
"lang",
20+
"fstring_detector",
2021
"module",
2122
"builtin_classes",
2223
"class",

tests/fstring_detector.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import ast
2+
3+
from vermin.fstring_detector import FStringDetector
4+
5+
from .testutils import VerminTest
6+
7+
8+
def joined_strs(source):
9+
tree = ast.parse(source)
10+
return [n for n in ast.walk(tree) if isinstance(n, ast.JoinedStr)]
11+
12+
13+
class FStringDetectorTests(VerminTest):
14+
def assert_self_doc(self, source, expected):
15+
detector = FStringDetector(source)
16+
nodes = joined_strs(source)
17+
self.assertTrue(nodes, "no f-string in: " + source)
18+
self.assertEqual(expected, any(detector.is_self_doc(n) for n in nodes), source)
19+
20+
def assert_pep701(self, source, expected):
21+
"""Assert the source's f-strings trigger the named PEP 701 violation. `None` expectation means
22+
"no violation", not "no f-string", which is asserted separately.
23+
"""
24+
detector = FStringDetector(source)
25+
nodes = joined_strs(source)
26+
self.assertTrue(nodes, "no f-string in: " + source)
27+
if expected is None:
28+
self.assertTrue(all(detector.pep701_violation(n) is None for n in nodes), source)
29+
else:
30+
self.assertTrue(any(detector.pep701_violation(n) == expected for n in nodes), source)
31+
32+
def test_none_source(self):
33+
detector = FStringDetector(None)
34+
node = joined_strs('f"{x}"')[0]
35+
self.assertFalse(detector.is_self_doc(node))
36+
self.assertIsNone(detector.pep701_violation(node))
37+
# pylint: disable=W0212
38+
self.assertIsNone(detector._read_fstring_token(node, None))
39+
40+
def test_input_guards(self):
41+
detector = FStringDetector('f"{x}"')
42+
bare = ast.JoinedStr()
43+
const = ast.Constant(value="a")
44+
self.assertIsNone(detector.pep701_violation(bare))
45+
self.assertFalse(detector.is_self_doc(const))
46+
self.assertIsNone(detector.pep701_violation(const))
47+
# pylint: disable=W0212
48+
self.assertIsNone(detector._read_fstring_token(bare, 'f"{x}"'.splitlines()))
49+
self.assertIsNone(detector._read_fstring_token(bare, []))
50+
51+
@VerminTest.skipUnlessVersion(3, 8)
52+
@VerminTest.parameterized_args([
53+
("f'{a=}'", True),
54+
("f'hello {name=}'", True),
55+
("f'{b=}={a}'", True),
56+
("f'{a =}'", True),
57+
("f'{ a=}'", True),
58+
("f'{a= }'", True),
59+
("f'{ a = }'", True),
60+
("f'{1+1=}'", True),
61+
("f'{a+b=}'", True),
62+
("f'{-5=}'", True),
63+
("f'{(1,2,3)=}'", True),
64+
("f'{ {1,2,3}=}'", True),
65+
("f'{[x for x in [1,2,3]]=}'", True),
66+
("f'{0==1=}'", True),
67+
("f'{3.14=:10.10}'", True),
68+
("f'{3.14=!s:10.10}'", True),
69+
("f'{x=!r}'", True),
70+
("f'{x=:.2f}'", True),
71+
("f'{f\"{3.1415=:.1f}\":*^20}'", True),
72+
("f'{{literal}}{a=}'", True),
73+
("f'''{\n3\n=}'''", True),
74+
("f'{f(a=4)=}'", True),
75+
("f\"\\N{EXCLAMATION MARK}{a=}\"", True),
76+
77+
# CRLFs must not shift the byte offsets of nodes on later lines.
78+
("x = 1\r\ns = f\"{x=}\"\r\n", True),
79+
("x = 1\r\ns = f\"{x=!r}\"\r\n", True),
80+
81+
# Bare `\r` endings and parser-only separators, like `\v` and `\f`, in string literals must not
82+
# shift offsets either.
83+
("x = 1\rs = f\"{x=}\"\r", True),
84+
("y = \"\v\f\"\ns = f\"{x=}\"\n", True),
85+
86+
("a = 1\nf'a={a}'", False),
87+
("a = 1\nf'={a}'", False),
88+
("f'{a != b}'", False),
89+
("f'{a == b}'", False),
90+
("f'{a >= b}'", False),
91+
("f'{a <= b}'", False),
92+
("f'{x:=10}'", False),
93+
("f'hello {name!r}'", False),
94+
("f'{x!r}'", False),
95+
("f'{x!s}'", False),
96+
("f'{x!a}'", False),
97+
("f'{x:.2f}'", False),
98+
("f'val={val:.2f}'", False),
99+
("f'{(a+b)}={x!r}'", False),
100+
("f'{{x={a}}}'", False),
101+
("f'{x}'", False),
102+
("f'{x:{width}}'", False),
103+
("f'={cos(radians(theta)):.3f}'", False),
104+
("x = 1\r\ns = f\"{x+1}\"\r\n", False),
105+
("x = 1\rs = f\"{x+1}\"\r", False),
106+
])
107+
def test_self_doc(self, source, expected):
108+
self.assert_self_doc(source, expected)
109+
110+
@VerminTest.skipUnlessVersion(3, 12)
111+
@VerminTest.parameterized_args([
112+
('f"{\n1+2\n}"', "multi_line"),
113+
('f"{\n x +\n y\n}"', "multi_line"),
114+
("f\"{\n'''a'''\n}\"", "multi_line"),
115+
("'''lit''' f\"{x\n+y}\"", "multi_line"),
116+
('f"{a \\\n}"', "multi_line"),
117+
118+
('f"{x}"', None),
119+
('f"{x:10}"', None),
120+
("f'''{\n3\n}'''", None),
121+
('f"\\N{EXCLAMATION MARK}{x}"', None),
122+
('s = (f"got {x}: "\n f"done")', None),
123+
('s = f"{a}" f"{b}"', None),
124+
('s = f"lit {a}" f"""{b\n+ c}"""', None),
125+
('s = (f"got {x}: "\n f"""done\n{p\n+ q}""")', None),
126+
])
127+
def test_pep701_multi_line(self, source, expected):
128+
self.assert_pep701(source, expected)
129+
130+
@VerminTest.skipUnlessVersion(3, 12)
131+
@VerminTest.parameterized_args([
132+
('f"outer {f"inner"}"', "nested_same_quote"),
133+
("f'outer {f'inner'}'", "nested_same_quote"),
134+
('f"""outer {f"""inner"""}"""', "nested_same_quote"),
135+
('f"outer {f"""inner"""}"', "nested_same_quote"),
136+
('f"{x:{f".2f"}}"', "nested_same_quote"),
137+
('f"1 {f"2 {f"3"}"}"', "nested_same_quote"),
138+
139+
('f"""outer {f"inner"}"""', None),
140+
('f"outer {f\'inner\'}"', None),
141+
("f'outer {f\"inner\"}'", None),
142+
])
143+
def test_pep701_nested_same_quote(self, source, expected):
144+
self.assert_pep701(source, expected)
145+
146+
@VerminTest.skipUnlessVersion(3, 12)
147+
@VerminTest.parameterized_args([
148+
("'lit' f\"{f\"{x}\"}\"", "nested_same_quote"),
149+
('"""lit""" f"{f"{x}"}"', "nested_same_quote"),
150+
("'''lit''' f\"{f\"{x}\"}\"", "nested_same_quote"),
151+
("f'{\"lit\" f'{x}'}'", "nested_same_quote"),
152+
('f"""{rf"""{x}"""}"""', "nested_same_quote"),
153+
('f"{fr"""{x}"""}"', "nested_same_quote"),
154+
("f'outer {rf'''{x}'''}'", "nested_same_quote"),
155+
('f"{F"e{fr"""{x}"""}config"}"', "nested_same_quote"),
156+
("('lit'\n# comment between\n f\"{f\"{x}\"}\")", "nested_same_quote"),
157+
158+
("'pre' f\"{f'{x}'}\"", None),
159+
("'''pre''' f\"{f'{x}'}\"", None),
160+
161+
# Format-spec nested f-strings are valid pre-3.12 even when same-quoted.
162+
("f\"{x:{f'{y}'}}\"", None),
163+
('f"""{x:{f"{y}"}}"""', None),
164+
])
165+
def test_pep701_nested_same_quote_implicit_concat(self, source, expected):
166+
self.assert_pep701(source, expected)
167+
168+
@VerminTest.skipUnlessVersion(3, 12)
169+
@VerminTest.parameterized_args([
170+
("f\"{'\\n'.join(x)}\"", "backslash"),
171+
("f\"{r'\\n'}\"", "backslash"),
172+
173+
("f\"{chr(10)}\"", None),
174+
("f'hello\\nworld'", None),
175+
])
176+
def test_pep701_backslash(self, source, expected):
177+
self.assert_pep701(source, expected)
178+
179+
@VerminTest.skipUnlessVersion(3, 12)
180+
def test_pep701_backslash_line_continuation(self):
181+
source = 'f"{a \\\n}"'
182+
node = joined_strs(source)[0].values[0]
183+
detector = FStringDetector(source)
184+
# pylint: disable=W0212
185+
self.assertTrue(detector._has_pep701_backslash(node, source.splitlines()))
186+
187+
@VerminTest.skipUnlessVersion(3, 12)
188+
@VerminTest.parameterized_args([
189+
('f"""{x # comment\n}"""', "comment"),
190+
('f"""{x\n# comment\n}"""', "comment"),
191+
192+
("f\"{'#notacomment'}\"", None),
193+
('f"{x}"', None),
194+
195+
# `#` in a format spec is spec filler text, valid since 3.6.
196+
('f"""{x:>3 # c\n}"""', None),
197+
('f"""{x:{w} # c\n}"""', None),
198+
('f"""{x!r:>3 # c\n}"""', None),
199+
200+
# A comment before the format spec is a PEP 701 comment.
201+
('f"""{x # c\n:>3}"""', "comment"),
202+
])
203+
def test_pep701_comment(self, source, expected):
204+
self.assert_pep701(source, expected)
205+
206+
@VerminTest.skipUnlessVersion(3, 12)
207+
@VerminTest.parameterized_args([
208+
("('lit' f\"{x}\")", ('"', False)),
209+
("('''lit''' f'{x}')", ("'", False)),
210+
('(f"lit {a}" f"""{b\n+ c}""")', ('"', False)),
211+
('f"{f"{x}"}"', ('"', False)),
212+
('f"""{rf"""{x}"""}"""', ('"', True)),
213+
('rf"raw {x}"', ('"', False)),
214+
("(f'lit' f\"{x}\")", ("'", False)),
215+
])
216+
def test_read_fstring_token(self, source, expected):
217+
detector = FStringDetector(source)
218+
nodes = joined_strs(source)
219+
self.assertTrue(nodes, "no f-string in: " + source)
220+
# pylint: disable=W0212
221+
got = detector._read_fstring_token(nodes[0], source.splitlines())
222+
self.assertEqual(expected, got, source)
223+
224+
@VerminTest.skipUnlessVersion(3, 12)
225+
@VerminTest.parameterized_args([
226+
('f"""{x:>3 # c\n}"""', False),
227+
('f"""{x:{w} # c\n}"""', False),
228+
('f"""{x!r:>3 # c\n}"""', False),
229+
('f"""{x # c\n:>3}"""', True),
230+
('f"""{x\n# comment\n}"""', True),
231+
("f\"{'#notacomment'}\"", False),
232+
])
233+
def test_pep701_comment_region(self, source, expected):
234+
detector = FStringDetector(source)
235+
node = joined_strs(source)[0].values[0]
236+
# pylint: disable=W0212
237+
got = detector._has_pep701_comment(node, source.splitlines())
238+
self.assertEqual(expected, got, source)
239+
240+
@VerminTest.skipUnlessVersion(3, 12)
241+
@VerminTest.parameterized_args([
242+
("x = 1\r\ns = f\"{f\"{x}\"}\"\r\n", "nested_same_quote"),
243+
("x = 1\r\ns = f\"{\r\nx\r\n}\"\r\n", "multi_line"),
244+
("x = 1\r\ns = f\"{x # c\r\n}\"\r\n", "multi_line"),
245+
("x = 1\r\ns = f\"\"\"{x # c\r\n}\"\"\"\r\n", "comment"),
246+
("x = 1\r\n'''lit''' f\"{x\n+y}\"\r\n", "multi_line"),
247+
("x = 1\r\ns = f\"{x!r}\"\r\n", None),
248+
])
249+
def test_pep701_crlf(self, source, expected):
250+
self.assert_pep701(source, expected)
251+
252+
@VerminTest.skipUnlessVersion(3, 12)
253+
@VerminTest.parameterized_args([
254+
# Bare `\r` endings and parser-only separators, like `\v` and `\f`, in string literals must not
255+
# shift offsets of nodes on later lines.
256+
("x = 1\rs = f\"{f\"{x}\"}\"\r", "nested_same_quote"),
257+
("y = \"\v\f\"\ns = f\"{f\"{x}\"}\"\n", "nested_same_quote"),
258+
("x = 1\rs = f\"{x}\"\r", None),
259+
("y = \"\v\f\"\ns = f\"{x}\"\n", None),
260+
])
261+
def test_pep701_parser_line_breaks(self, source, expected):
262+
self.assert_pep701(source, expected)

tests/general.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ def test_probably_python_file(self):
238238

239239
def test_detect_paths(self):
240240
paths = detect_paths([abspath("vermin")], config=self.config)
241-
self.assertEqual(20, len(paths))
241+
self.assertEqual(21, len(paths))
242242

243243
def test_detect_hidden_paths(self):
244244
tmp_fld = mkdtemp()

0 commit comments

Comments
 (0)