Skip to content

Commit 9f94c87

Browse files
committed
fix: properly render double braces in f-strings. #1980
1 parent 1958c3f commit 9f94c87

File tree

6 files changed

+49
-4
lines changed

6 files changed

+49
-4
lines changed

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,16 @@ Unreleased
2727
which was previously only available through the COVERAGE_CORE environment
2828
variable. Finishes `issue 1746`_.
2929

30+
- Fixed incorrect rendering of f-strings with doubled braces, closing `issue
31+
1980`_.
32+
3033
- The C extension module now conforms to `PEP 489`_, closing `issue 1977`_.
3134
Thanks, `Adam Turner <pull 1978_>`_.
3235

3336
.. _issue 1746: https://github.com/nedbat/coveragepy/issues/1746
3437
.. _issue 1977: https://github.com/nedbat/coveragepy/issues/1977
3538
.. _pull 1978: https://github.com/nedbat/coveragepy/pull/1978
39+
.. _issue 1980: https://github.com/nedbat/coveragepy/issues/1980
3640
.. _PEP 489: https://peps.python.org/pep-0489
3741

3842

coverage/env.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ class PYBEHAVIOR:
147147
# Some words are keywords in some places, identifiers in other places.
148148
soft_keywords = (PYVERSION >= (3, 10))
149149

150+
# f-strings are parsed as code, pep 701
151+
fstring_syntax = (PYVERSION >= (3, 12))
152+
150153
# PEP669 Low Impact Monitoring: https://peps.python.org/pep-0669/
151154
pep669: Final[bool] = bool(getattr(sys, "monitoring", None))
152155

coverage/htmlfiles/style.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,7 @@ $border-indicator-width: .2em;
501501
font-weight: bold;
502502
line-height: 1px;
503503
}
504-
.str {
504+
.str, .fst {
505505
color: $light-token-str;
506506
@include color-dark($dark-token-str);
507507
}

coverage/phystokens.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def _phys_tokens(toks: TokenInfos) -> TokenInfos:
7070
# It's a multi-line string and the first line ends with
7171
# a backslash, so we don't need to inject another.
7272
inject_backslash = False
73-
elif sys.version_info >= (3, 12) and ttype == token.FSTRING_MIDDLE:
73+
elif env.PYBEHAVIOR.fstring_syntax and ttype == token.FSTRING_MIDDLE:
7474
inject_backslash = False
7575
if inject_backslash:
7676
# Figure out what column the backslash is in.
@@ -144,6 +144,9 @@ def source_token_lines(source: str) -> TSourceTokenLines:
144144
elif ttype in ws_tokens:
145145
mark_end = False
146146
else:
147+
if env.PYBEHAVIOR.fstring_syntax and ttype == token.FSTRING_MIDDLE:
148+
part = part.replace("{", "{{").replace("}", "}}")
149+
ecol = scol + len(part)
147150
if mark_start and scol > col:
148151
line.append(("ws", " " * (scol - col)))
149152
mark_start = False

tests/test_html.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,21 @@ def test_bug_1836(self, leader: str) -> None:
12241224
"7" + "'''",
12251225
]
12261226

1227+
def test_bug_1980(self) -> None:
1228+
self.make_file("fstring_middle.py", """\
1229+
x = 1
1230+
f'Look: {x} {{x}}!'
1231+
""")
1232+
1233+
cov = coverage.Coverage()
1234+
the_mod = self.start_import_stop(cov, "fstring_middle")
1235+
cov.html_report(the_mod)
1236+
1237+
assert self.get_html_report_text_lines("fstring_middle.py") == [
1238+
"1" + "x = 1",
1239+
"2" + "f'Look: {x} {{x}}!'",
1240+
]
1241+
12271242
def test_unicode(self) -> None:
12281243
surrogate = "\U000e0100"
12291244

tests/test_phystokens.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,27 @@ def test_stress(self, fname: str) -> None:
131131
with open(stress, encoding="utf-8") as fstress:
132132
assert re.search(r"(?m) $", fstress.read()), f"{stress} needs a trailing space."
133133

134+
def test_fstring_middle(self) -> None:
135+
tokens = list(source_token_lines(textwrap.dedent("""\
136+
f'Look: {x} {{x}}!'
137+
""")))
138+
if env.PYBEHAVIOR.fstring_syntax:
139+
assert tokens == [
140+
[
141+
("fst", "f'"),
142+
("fst", "Look: "),
143+
("op", "{"),
144+
("nam", "x"),
145+
("op", "}"),
146+
("fst", " {{"),
147+
("fst", "x}}"),
148+
("fst", "!"),
149+
("fst", "'"),
150+
],
151+
]
152+
else:
153+
assert tokens == [[("str", "f'Look: {x} {{x}}!'")]]
154+
134155

135156
@pytest.mark.skipif(not env.PYBEHAVIOR.soft_keywords, reason="Soft keywords are new in Python 3.10")
136157
class SoftKeywordTest(CoverageTest):
@@ -154,7 +175,6 @@ def match():
154175
global case
155176
""")
156177
tokens = list(source_token_lines(source))
157-
print(tokens)
158178
assert tokens[0][0] == ("key", "match")
159179
assert tokens[0][4] == ("nam", "match")
160180
assert tokens[1][1] == ("key", "case")
@@ -168,7 +188,7 @@ def match():
168188
assert tokens[10][2] == ("nam", "match")
169189
assert tokens[11][3] == ("nam", "case")
170190

171-
@pytest.mark.skipif(sys.version_info < (3, 12), reason="type is a soft keyword in 3.12")
191+
@pytest.mark.skipif(sys.version_info < (3, 12), reason="type isn't a soft keyword until 3.12")
172192
def test_soft_keyword_type(self) -> None:
173193
source = textwrap.dedent("""\
174194
type Point = tuple[float, float]

0 commit comments

Comments
 (0)