diff --git a/packages/griffelib/src/griffe/_internal/expressions.py b/packages/griffelib/src/griffe/_internal/expressions.py index df959bee..e00bfe16 100644 --- a/packages/griffelib/src/griffe/_internal/expressions.py +++ b/packages/griffelib/src/griffe/_internal/expressions.py @@ -563,6 +563,43 @@ def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]: yield "}" +_FSTRING_ALL_QUOTES = ("'", '"', "'''", '"""') +_FSTRING_MULTI_QUOTES = ('"""', "'''") + + +def _fstring_choose_quote(values: Sequence[str | Expr]) -> tuple[str, list[str]]: + # Follows ast.unparse's visit_JoinedStr quote-selection algorithm. + # Pre-render all parts: literals get brace-doubled, expressions get flattened. + fstring_parts: list[tuple[str, bool]] = [] + for value in values: + if isinstance(value, str): + fstring_parts.append((value.replace("{", "{{").replace("}", "}}"), True)) + else: + fstring_parts.append((str(value), False)) + + quote_types: list[str] = list(_FSTRING_ALL_QUOTES) + escaped_parts: list[str] = [] + + for raw, is_constant in fstring_parts: + if is_constant: + escaped = ( + raw.replace("\\", "\\\\") + .replace("\r", "\\r") + .replace("\0", "\\x00") + .replace("\n", "\\n") + .replace("\t", "\\t") + ) + quote_types = [q for q in quote_types if q not in escaped] or quote_types + escaped_parts.append(escaped) + else: + if "\n" in raw: + quote_types = [q for q in quote_types if q in _FSTRING_MULTI_QUOTES] or quote_types + quote_types = [q for q in quote_types if q not in raw] or quote_types + escaped_parts.append(raw) + + return quote_types[0], escaped_parts + + @dataclass(eq=True, slots=True) class ExprJoinedStr(Expr): """Joined strings like `f"a {b} c"`.""" @@ -571,9 +608,14 @@ class ExprJoinedStr(Expr): """Joined values.""" def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]: - yield "f'" - yield from _join(self.values, "", flat=flat) - yield "'" + quote, escaped_parts = _fstring_choose_quote(self.values) + yield f"f{quote}" + for value, escaped in zip(self.values, escaped_parts, strict=True): + if isinstance(value, str): + yield escaped + else: + yield from _yield(value, flat=flat, outer_precedence=_OperatorPrecedence.NONE) + yield quote @dataclass(eq=True, slots=True) @@ -938,9 +980,14 @@ class ExprTemplateStr(Expr): """Joined values.""" def iterate(self, *, flat: bool = True) -> Iterator[str | Expr]: - yield "t'" - yield from _join(self.values, "", flat=flat) - yield "'" + quote, escaped_parts = _fstring_choose_quote(self.values) + yield f"t{quote}" + for value, escaped in zip(self.values, escaped_parts, strict=True): + if isinstance(value, str): + yield escaped + else: + yield from _yield(value, flat=flat, outer_precedence=_OperatorPrecedence.NONE) + yield quote @dataclass(eq=True, slots=True) @@ -1176,7 +1223,7 @@ def _build_constant( if in_joined_str and not in_formatted_str: # We're in a f-string, not in a formatted value, don't keep quotes. return node.value - if parse_strings and not literal_strings: + if parse_strings and not literal_strings and not in_formatted_str: # We're in a place where a string could be a type annotation # (and not in a Literal[...] type annotation). # We parse the string and build from the resulting nodes again. diff --git a/packages/griffelib/tests/test_expressions.py b/packages/griffelib/tests/test_expressions.py index 3fcd831b..dab89b81 100644 --- a/packages/griffelib/tests/test_expressions.py +++ b/packages/griffelib/tests/test_expressions.py @@ -233,6 +233,57 @@ def func[Z](arg1: T, arg2: Y): pass assert module["C.func"].parameters["arg2"].annotation.canonical_path == "Y" +@pytest.mark.parametrize( + ("code", "expected"), + [ + # Single quotes only in literal parts → double-quote delimiter. + ('f"it\'s {x}"', 'f"it\'s {x}"'), + ("f\"don't {x} won't {y}\"", "f\"don't {x} won't {y}\""), + # Double quotes only in literal parts → single-quote delimiter. + ("f'say \"hello\" to {x}'", "f'say \"hello\" to {x}'"), + ('f\'"open" and "close" around {x}\'', 'f\'"open" and "close" around {x}\''), + # Both quote types in literal parts → triple-single-quote delimiter. + (r"""f'it\'s "complicated" {x}'""", "f'''it's \"complicated\" {x}'''"), + (r"""f'she said "it\'s fine" to {x}'""", "f'''she said \"it's fine\" to {x}'''"), + (r"""f'can\'t stop, won\'t stop: "the {x} motto"'""", "f'''can't stop, won't stop: \"the {x} motto\"'''"), + # Literal braces must be doubled ({{/}}) in the output. + ("f'{a} {{b}}'", "f'{a} {{b}}'"), + ("f'{{opening}} {x} {{closing}}'", "f'{{opening}} {x} {{closing}}'"), + # String literals inside f-string expressions are never type annotations. + # The expression content drives delimiter choice (single → use double outer). + ("f'{print(\"1\")}'", "f\"{print('1')}\""), + ("f'{x + \"hello\"}'", "f\"{x + 'hello'}\""), + ], +) +def test_fstring_quote_selection(code: str, expected: str) -> None: + """ExprJoinedStr produces valid Python source for f-strings with tricky quote content. + + Regression test for https://github.com/mkdocstrings/griffe/issues/444. + """ + top_node = compile(code, filename="<>", mode="exec", flags=ast.PyCF_ONLY_AST, optimize=2) + expression = get_expression(top_node.body[0].value, parent=Module("module")) # ty:ignore[unresolved-attribute] + assert str(expression) == expected + + +@pytest.mark.skipif(sys.version_info < (3, 14), reason="t-strings require Python 3.14+") +@pytest.mark.parametrize( + ("code", "expected"), + [ + ('t"it\'s {x}"', 't"it\'s {x}"'), + ("t'say \"hello\" to {x}'", "t'say \"hello\" to {x}'"), + (r"""t'it\'s "complicated" {x}'""", "t'''it's \"complicated\" {x}'''"), + ], +) +def test_tstring_quote_selection(code: str, expected: str) -> None: + """ExprTemplateStr produces valid Python source for t-strings with tricky quote content. + + Regression test for https://github.com/mkdocstrings/griffe/issues/444. + """ + top_node = compile(code, filename="<>", mode="exec", flags=ast.PyCF_ONLY_AST, optimize=2) + expression = get_expression(top_node.body[0].value, parent=Module("module")) # ty:ignore[unresolved-attribute] + assert str(expression) == expected + + def test_render_dict_comprehension() -> None: """Assert dict comprehensions are rendered correctly.""" with temporary_visited_module( diff --git a/packages/griffelib/tests/test_nodes.py b/packages/griffelib/tests/test_nodes.py index 4e95d739..46d8d7b9 100644 --- a/packages/griffelib/tests/test_nodes.py +++ b/packages/griffelib/tests/test_nodes.py @@ -52,8 +52,20 @@ "call(something=something)", # Strings. "f'a {round(key, 2)} {z}'", - # YORE: EOL 3.13: Replace line with `"t'a {round(key, 2)} {z}'",`. + 'f"it\'s {x}"', # ' only -> " delimiter + "f\"don't {x} won't {y}\"", # multiple ' -> " delimiter + "f'say \"hello\" to {x}'", # " only -> ' delimiter + 'f\'"quoted" and "re-quoted" {x}\'', # multiple " -> ' delimiter + "f'''it's \"complicated\" {x}'''", # both -> triple-' delimiter + "f'''she said \"it's fine\" to {x}'''", # both, different parts -> triple-' + # YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line. *(["t'a {round(key, 2)} {z}'"] if sys.version_info >= (3, 14) else []), + # YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line. + *(['t"it\'s {x}"'] if sys.version_info >= (3, 14) else []), + # YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line. + *(["t'say \"hello\" to {x}'"] if sys.version_info >= (3, 14) else []), + # YORE: EOL 3.13: Regex-replace `\*\(\[(.+)\].+\),` with `\1,` within line. + *(["t'''it's \"complicated\" {x}'''"] if sys.version_info >= (3, 14) else []), # Slices. "o[x]", "o[x, y]",