From ca3eb8751958ca81483010cd92061a62611a1400 Mon Sep 17 00:00:00 2001 From: RKS Date: Wed, 9 Sep 2026 12:54:21 -0400 Subject: [PATCH 1/2] fix(python): escape quotes in enum docstrings --- .../changes/escape-python-enum-docstrings.md | 7 +++ .../pygen/codegen/templates/enum.py.jinja2 | 4 +- .../codegen/templates/operation_tools.jinja2 | 5 ++ .../tests/unit/test_enum_docstrings.py | 56 +++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 .chronus/changes/escape-python-enum-docstrings.md create mode 100644 packages/http-client-python/tests/unit/test_enum_docstrings.py diff --git a/.chronus/changes/escape-python-enum-docstrings.md b/.chronus/changes/escape-python-enum-docstrings.md new file mode 100644 index 00000000000..1cbcf72eb6b --- /dev/null +++ b/.chronus/changes/escape-python-enum-docstrings.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +Escape quotes in enum and enum member documentation so embedded Python string delimiters remain documentation in generated code. diff --git a/packages/http-client-python/generator/pygen/codegen/templates/enum.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/enum.py.jinja2 index dbdab9a5f2a..0f74d76ae93 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/enum.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/enum.py.jinja2 @@ -3,13 +3,13 @@ class {{ enum.name }}({{enum.pylint_disable()}} {{ enum.value_type.type_annotation(is_operation_file=False) }}, Enum, metaclass=CaseInsensitiveEnumMeta ): {% if enum.yaml_data.get("description") %} - """{{ op_tools.wrap_string(enum.yaml_data["description"], "\n ") }} + """{{ op_tools.wrap_docstring(enum.yaml_data["description"], "\n ") }} """ {% endif %} {% for value in enum.values %} {{ value.name }} = {{ enum.value_type.get_declaration(value.value) }} {% if value.description(is_operation_file=False) %} - """{{ op_tools.wrap_string(value.description(is_operation_file=False), "\n ") }}""" + """{{ op_tools.wrap_docstring(value.description(is_operation_file=False), "\n ") }}""" {% endif %} {% endfor %} diff --git a/packages/http-client-python/generator/pygen/codegen/templates/operation_tools.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/operation_tools.jinja2 index 19f4c668cb6..434662f4a21 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/operation_tools.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/operation_tools.jinja2 @@ -31,6 +31,11 @@ {%- endif -%} {{ normalized_string | replace("\\", "\\\\") | wordwrap(width=width, break_long_words=False, break_on_hyphens=False, wrapstring=wrapstring)}}{% endmacro %} +{# Escape quotes after wrapping so wrapping cannot split an escape sequence. #} +{% macro wrap_docstring(string, wrapstring, width=95) -%} +{{ wrap_string(string, wrapstring, width) | replace('"', '\\"') }} +{%- endmacro %} + {% macro description(builder, serializer) %} {% set example_template = serializer.example_template(builder) %} {% set param_description_and_response_docstring = serializer.param_description_and_response_docstring(builder) %} diff --git a/packages/http-client-python/tests/unit/test_enum_docstrings.py b/packages/http-client-python/tests/unit/test_enum_docstrings.py new file mode 100644 index 00000000000..1555c5adf4e --- /dev/null +++ b/packages/http-client-python/tests/unit/test_enum_docstrings.py @@ -0,0 +1,56 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +"""Enum documentation must remain string literals in generated Python.""" + +import ast +import inspect +from pathlib import Path +from types import SimpleNamespace + +import black +import pytest +from jinja2 import Environment, FileSystemLoader + + +@pytest.mark.parametrize( + "description", + [ + "Ordinary documentation.", + 'A"""; print("This should remain documentation"); """B.', + 'Quotes: " and "" and """.', + 'Ends with a quote"', + r'Quotes """ with a regex \W and a path C:\new\test.', + 'First paragraph.\n\nSecond paragraph with """ quotes.', + ], +) +@pytest.mark.parametrize("location", ["enum", "member"]) +def test_enum_docstrings_preserve_documentation(description, location): + templates = Path(__file__).parents[2] / "generator/pygen/codegen/templates" + env = Environment(loader=FileSystemLoader(templates), trim_blocks=True, lstrip_blocks=True) + value = SimpleNamespace( + name="FAST", + value="fast", + description=lambda **kwargs: description if location == "member" else "", + ) + enum = SimpleNamespace( + name="WidgetMode", + yaml_data={"description": description if location == "enum" else ""}, + values=[value], + pylint_disable=lambda: "", + value_type=SimpleNamespace(type_annotation=lambda **kwargs: "str", get_declaration=repr), + ) + source = env.from_string('{% import "operation_tools.jinja2" as op_tools %}{% include "enum.py.jinja2" %}').render( + enum=enum + ) + # Formatting success alone does not prove that documentation stayed inside a string. + source = black.format_str(source, mode=black.Mode()) + body = ast.parse(source).body[0].body + assert len(body) == 2 + doc, assignment = body if location == "enum" else reversed(body) + assert isinstance(assignment, ast.Assign) + assert isinstance(doc, ast.Expr) + assert isinstance(doc.value, ast.Constant) + assert inspect.cleandoc(doc.value.value).strip() == description From b89dbcac63d654dc569f2479cdd76efb441c63b8 Mon Sep 17 00:00:00 2001 From: RKS Date: Wed, 9 Sep 2026 15:16:03 -0400 Subject: [PATCH 2/2] fix(python): escape literal enum documentation in TypedDict output Cover Literal enum rendering with the same AST and documentation-preservation checks as class and member docstrings. Assisted-by: OpenAI Codex (model: GPT-6 Astra, autonomous) --- .../pygen/codegen/templates/types.py.jinja2 | 2 +- .../tests/unit/test_enum_docstrings.py | 23 ++++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 index 88dd962c5b9..00cefed3045 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 @@ -10,7 +10,7 @@ {{ serializer.declare_literal_enum(enum) }} {% if enum.yaml_data.get("description") %} -"""{{ op_tools.wrap_string(enum.yaml_data["description"], "\n") }}""" +"""{{ op_tools.wrap_docstring(enum.yaml_data["description"], "\n") }}""" {% endif %} {% endfor %} {% for model in models %} diff --git a/packages/http-client-python/tests/unit/test_enum_docstrings.py b/packages/http-client-python/tests/unit/test_enum_docstrings.py index 1555c5adf4e..3b2a9a8273b 100644 --- a/packages/http-client-python/tests/unit/test_enum_docstrings.py +++ b/packages/http-client-python/tests/unit/test_enum_docstrings.py @@ -26,7 +26,7 @@ 'First paragraph.\n\nSecond paragraph with """ quotes.', ], ) -@pytest.mark.parametrize("location", ["enum", "member"]) +@pytest.mark.parametrize("location", ["enum", "member", "literal"]) def test_enum_docstrings_preserve_documentation(description, location): templates = Path(__file__).parents[2] / "generator/pygen/codegen/templates" env = Environment(loader=FileSystemLoader(templates), trim_blocks=True, lstrip_blocks=True) @@ -37,17 +37,28 @@ def test_enum_docstrings_preserve_documentation(description, location): ) enum = SimpleNamespace( name="WidgetMode", - yaml_data={"description": description if location == "enum" else ""}, + yaml_data={"description": description if location in ("enum", "literal") else ""}, values=[value], pylint_disable=lambda: "", value_type=SimpleNamespace(type_annotation=lambda **kwargs: "str", get_declaration=repr), ) - source = env.from_string('{% import "operation_tools.jinja2" as op_tools %}{% include "enum.py.jinja2" %}').render( - enum=enum - ) + if location == "literal": + source = env.get_template("types.py.jinja2").render( + code_model=SimpleNamespace(license_header=""), + imports="", + literal_enums=[enum], + models=[], + discriminated_bases=[], + serializer=SimpleNamespace(declare_literal_enum=lambda enum: f'{enum.name} = Literal["fast"]'), + ) + else: + source = env.from_string( + '{% import "operation_tools.jinja2" as op_tools %}{% include "enum.py.jinja2" %}' + ).render(enum=enum) # Formatting success alone does not prove that documentation stayed inside a string. source = black.format_str(source, mode=black.Mode()) - body = ast.parse(source).body[0].body + module = ast.parse(source) + body = module.body if location == "literal" else module.body[0].body assert len(body) == 2 doc, assignment = body if location == "enum" else reversed(body) assert isinstance(assignment, ast.Assign)