Skip to content

Commit 90e28d4

Browse files
authored
fix: Render f-strings and t-strings with correct quote delimiters
`ExprJoinedStr.iterate()` and ExprTemplateStr.iterate() previously hardcoded a single-quote delimiter, producing invalid Python source when the literal parts of the f-string contained apostrophes. Port `ast.unparse`'s `visit_JoinedStr` quote-selection algorithm: pre-render all parts (literals get brace-doubled and control-char-escaped, expression parts get flattened to string), then pick the first quote type from `("'", '"', "'''", '"""')` that doesn't conflict with any part. Also guard `_build_constant`'s `parse_strings` path with `not in_formatted_str` so string literals inside f-string expressions (`{...}`) are never mistakenly compiled as type annotations. Issue-444: #444 PR-455: #455
1 parent 9c10a2b commit 90e28d4

3 files changed

Lines changed: 118 additions & 8 deletions

File tree

packages/griffelib/src/griffe/_internal/expressions.py

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,43 @@ def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
566566
yield "}"
567567

568568

569+
_FSTRING_ALL_QUOTES = ("'", '"', "'''", '"""')
570+
_FSTRING_MULTI_QUOTES = ('"""', "'''")
571+
572+
573+
def _fstring_choose_quote(values: Sequence[str | Expr]) -> tuple[str, list[str]]:
574+
# Follows ast.unparse's visit_JoinedStr quote-selection algorithm.
575+
# Pre-render all parts: literals get brace-doubled, expressions get flattened.
576+
fstring_parts: list[tuple[str, bool]] = []
577+
for value in values:
578+
if isinstance(value, str):
579+
fstring_parts.append((value.replace("{", "{{").replace("}", "}}"), True))
580+
else:
581+
fstring_parts.append((str(value), False))
582+
583+
quote_types: list[str] = list(_FSTRING_ALL_QUOTES)
584+
escaped_parts: list[str] = []
585+
586+
for raw, is_constant in fstring_parts:
587+
if is_constant:
588+
escaped = (
589+
raw.replace("\\", "\\\\")
590+
.replace("\r", "\\r")
591+
.replace("\0", "\\x00")
592+
.replace("\n", "\\n")
593+
.replace("\t", "\\t")
594+
)
595+
quote_types = [q for q in quote_types if q not in escaped] or quote_types
596+
escaped_parts.append(escaped)
597+
else:
598+
if "\n" in raw:
599+
quote_types = [q for q in quote_types if q in _FSTRING_MULTI_QUOTES] or quote_types
600+
quote_types = [q for q in quote_types if q not in raw] or quote_types
601+
escaped_parts.append(raw)
602+
603+
return quote_types[0], escaped_parts
604+
605+
569606
@dataclass(eq=True, slots=True)
570607
class ExprJoinedStr(Expr):
571608
"""Joined strings like `f"a {b} c"`."""
@@ -574,9 +611,14 @@ class ExprJoinedStr(Expr):
574611
"""Joined values."""
575612

576613
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
577-
yield "f'"
578-
yield from _join(self.values, "", flat=flat)
579-
yield "'"
614+
quote, escaped_parts = _fstring_choose_quote(self.values)
615+
yield f"f{quote}"
616+
for value, escaped in zip(self.values, escaped_parts, strict=True):
617+
if isinstance(value, str):
618+
yield escaped
619+
else:
620+
yield from _yield(value, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
621+
yield quote
580622

581623

582624
@dataclass(eq=True, slots=True)
@@ -941,9 +983,14 @@ class ExprTemplateStr(Expr):
941983
"""Joined values."""
942984

943985
def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]:
944-
yield "t'"
945-
yield from _join(self.values, "", flat=flat)
946-
yield "'"
986+
quote, escaped_parts = _fstring_choose_quote(self.values)
987+
yield f"t{quote}"
988+
for value, escaped in zip(self.values, escaped_parts, strict=True):
989+
if isinstance(value, str):
990+
yield escaped
991+
else:
992+
yield from _yield(value, flat=flat, outer_precedence=_OperatorPrecedence.NONE)
993+
yield quote
947994

948995

949996
@dataclass(eq=True, slots=True)
@@ -1179,7 +1226,7 @@ def _build_constant(
11791226
if in_joined_str and not in_formatted_str:
11801227
# We're in a f-string, not in a formatted value, don't keep quotes.
11811228
return node.value
1182-
if parse_strings and not literal_strings:
1229+
if parse_strings and not literal_strings and not in_formatted_str:
11831230
# We're in a place where a string could be a type annotation
11841231
# (and not in a Literal[...] type annotation).
11851232
# We parse the string and build from the resulting nodes again.

packages/griffelib/tests/test_expressions.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,57 @@ def func[Z](arg1: T, arg2: Y): pass
233233
assert module["C.func"].parameters["arg2"].annotation.canonical_path == "Y"
234234

235235

236+
@pytest.mark.parametrize(
237+
("code", "expected"),
238+
[
239+
# Single quotes only in literal parts → double-quote delimiter.
240+
('f"it\'s {x}"', 'f"it\'s {x}"'),
241+
("f\"don't {x} won't {y}\"", "f\"don't {x} won't {y}\""),
242+
# Double quotes only in literal parts → single-quote delimiter.
243+
("f'say \"hello\" to {x}'", "f'say \"hello\" to {x}'"),
244+
('f\'"open" and "close" around {x}\'', 'f\'"open" and "close" around {x}\''),
245+
# Both quote types in literal parts → triple-single-quote delimiter.
246+
(r"""f'it\'s "complicated" {x}'""", "f'''it's \"complicated\" {x}'''"),
247+
(r"""f'she said "it\'s fine" to {x}'""", "f'''she said \"it's fine\" to {x}'''"),
248+
(r"""f'can\'t stop, won\'t stop: "the {x} motto"'""", "f'''can't stop, won't stop: \"the {x} motto\"'''"),
249+
# Literal braces must be doubled ({{/}}) in the output.
250+
("f'{a} {{b}}'", "f'{a} {{b}}'"),
251+
("f'{{opening}} {x} {{closing}}'", "f'{{opening}} {x} {{closing}}'"),
252+
# String literals inside f-string expressions are never type annotations.
253+
# The expression content drives delimiter choice (single → use double outer).
254+
("f'{print(\"1\")}'", "f\"{print('1')}\""),
255+
("f'{x + \"hello\"}'", "f\"{x + 'hello'}\""),
256+
],
257+
)
258+
def test_fstring_quote_selection(code: str, expected: str) -> None:
259+
"""ExprJoinedStr produces valid Python source for f-strings with tricky quote content.
260+
261+
Regression test for https://github.com/mkdocstrings/griffe/issues/444.
262+
"""
263+
top_node = compile(code, filename="<>", mode="exec", flags=ast.PyCF_ONLY_AST, optimize=2)
264+
expression = get_expression(top_node.body[0].value, parent=Module("module")) # ty:ignore[unresolved-attribute]
265+
assert str(expression) == expected
266+
267+
268+
@pytest.mark.skipif(sys.version_info < (3, 14), reason="t-strings require Python 3.14+")
269+
@pytest.mark.parametrize(
270+
("code", "expected"),
271+
[
272+
('t"it\'s {x}"', 't"it\'s {x}"'),
273+
("t'say \"hello\" to {x}'", "t'say \"hello\" to {x}'"),
274+
(r"""t'it\'s "complicated" {x}'""", "t'''it's \"complicated\" {x}'''"),
275+
],
276+
)
277+
def test_tstring_quote_selection(code: str, expected: str) -> None:
278+
"""ExprTemplateStr produces valid Python source for t-strings with tricky quote content.
279+
280+
Regression test for https://github.com/mkdocstrings/griffe/issues/444.
281+
"""
282+
top_node = compile(code, filename="<>", mode="exec", flags=ast.PyCF_ONLY_AST, optimize=2)
283+
expression = get_expression(top_node.body[0].value, parent=Module("module")) # ty:ignore[unresolved-attribute]
284+
assert str(expression) == expected
285+
286+
236287
def test_render_dict_comprehension() -> None:
237288
"""Assert dict comprehensions are rendered correctly."""
238289
with temporary_visited_module(

packages/griffelib/tests/test_nodes.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,20 @@
5252
"call(something=something)",
5353
# Strings.
5454
"f'a {round(key, 2)} {z}'",
55-
# YORE: EOL 3.13: Replace line with `"t'a {round(key, 2)} {z}'",`.
55+
'f"it\'s {x}"', # ' only -> " delimiter
56+
"f\"don't {x} won't {y}\"", # multiple ' -> " delimiter
57+
"f'say \"hello\" to {x}'", # " only -> ' delimiter
58+
'f\'"quoted" and "re-quoted" {x}\'', # multiple " -> ' delimiter
59+
"f'''it's \"complicated\" {x}'''", # both -> triple-' delimiter
60+
"f'''she said \"it's fine\" to {x}'''", # both, different parts -> triple-'
61+
# YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line.
5662
*(["t'a {round(key, 2)} {z}'"] if sys.version_info >= (3, 14) else []),
63+
# YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line.
64+
*(['t"it\'s {x}"'] if sys.version_info >= (3, 14) else []),
65+
# YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line.
66+
*(["t'say \"hello\" to {x}'"] if sys.version_info >= (3, 14) else []),
67+
# YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line.
68+
*(["t'''it's \"complicated\" {x}'''"] if sys.version_info >= (3, 14) else []),
5769
# Slices.
5870
"o[x]",
5971
"o[x, y]",

0 commit comments

Comments
 (0)