From a41c72884f331bfd2ca21b610babd1d8f4637aed Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 11 Aug 2026 14:05:56 -0700 Subject: [PATCH 1/3] fix(http-client-python): escape leading @ in docstring field targets Wire names such as @search.facets are emitted as the target of Sphinx info fields (:ivar/:vartype/:keyword/:paramtype/:param/:type). A leading @ is interpreted by Sphinx and breaks the rendered docstring, so escape it via a shared escape_sphinx_field_name helper applied across model, TypedDict, operation, and client docstring generation. The raw @ is preserved everywhere else (e.g. TypedDict keys, wire names). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b38539c-8877-4fdc-8b9d-bc400a50d325 --- ...scape-at-sign-docstring-field-2026-8-11.md | 7 ++++ .../generator/pygen/codegen/models/utils.py | 10 ++++++ .../codegen/serializers/builder_serializer.py | 9 +++-- .../codegen/serializers/client_serializer.py | 19 +++++++---- .../codegen/serializers/model_serializer.py | 17 ++++++++-- .../tests/unit/test_typeddict.py | 34 +++++++++++++++++++ 6 files changed, 82 insertions(+), 14 deletions(-) create mode 100644 .chronus/changes/escape-at-sign-docstring-field-2026-8-11.md diff --git a/.chronus/changes/escape-at-sign-docstring-field-2026-8-11.md b/.chronus/changes/escape-at-sign-docstring-field-2026-8-11.md new file mode 100644 index 00000000000..f8092b9ba1e --- /dev/null +++ b/.chronus/changes/escape-at-sign-docstring-field-2026-8-11.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +Escape a leading `@` in wire names used as Sphinx docstring field targets (e.g. `:vartype @search.facets:`) so the generated docstrings for models, TypedDicts, operations, and clients render correctly. diff --git a/packages/http-client-python/generator/pygen/codegen/models/utils.py b/packages/http-client-python/generator/pygen/codegen/models/utils.py index dd462d1d5bd..57f33c821c7 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/utils.py +++ b/packages/http-client-python/generator/pygen/codegen/models/utils.py @@ -34,6 +34,16 @@ def add_to_pylint_disable(curr_str: str, entry: str) -> str: return f" # pylint: disable={entry}" +def escape_sphinx_field_name(name: str) -> str: + """Escape a name used as the target of a Sphinx info field (``:ivar``, ``:vartype``, + ``:keyword``, ``:paramtype``, ``:param``, ``:type``). + + A leading ``@`` (e.g. a wire name such as ``@search.facets``) is otherwise interpreted + by Sphinx/docutils and breaks the rendered docstring, so it must be escaped. + """ + return name.replace("@", "\\@") + + class NamespaceType(str, Enum): """Special signal for impports""" diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py index 152b840d260..fe7649a3b89 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py @@ -32,7 +32,7 @@ ParameterListType, ByteArraySchema, ) -from ..models.utils import NamespaceType +from ..models.utils import NamespaceType, escape_sphinx_field_name from .parameter_serializer import ParameterSerializer, PopKwargType, check_body_optional from ..models.parameter_list import ParameterType from . import utils @@ -311,16 +311,15 @@ def param_description(self, builder: BuilderType) -> list[str]: or param.method_location == ParameterMethodLocation.KWARG ): continue + escaped_name = escape_sphinx_field_name(param.client_name) description_list.extend( - f":{param.description_keyword} {param.client_name}: {param.description}".replace("\n", "\n ").split( - "\n" - ) + f":{param.description_keyword} {escaped_name}: {param.description}".replace("\n", "\n ").split("\n") ) docstring_type = param.docstring_type( async_mode=self.async_mode, serialize_namespace=self.serialize_namespace, ) - description_list.append(f":{param.docstring_type_keyword} {param.client_name}: {docstring_type}") + description_list.append(f":{param.docstring_type_keyword} {escaped_name}: {docstring_type}") return description_list def param_description_and_response_docstring(self, builder: BuilderType) -> list[str]: diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/client_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/client_serializer.py index cf8d8dba96d..89bda92ae25 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/client_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/client_serializer.py @@ -8,6 +8,7 @@ from . import utils from ..models import Client, ParameterMethodLocation, Parameter, ParameterLocation from .parameter_serializer import ParameterSerializer, PopKwargType +from ..models.utils import escape_sphinx_field_name from ...utils import build_policies @@ -61,13 +62,16 @@ def property_descriptions(self, async_mode: bool) -> list[str]: retval: list[str] = [] operations_folder = ".aio.operations." if async_mode else ".operations." for og in [og for og in self.client.operation_groups if not og.is_mixin]: - retval.append(f":ivar {og.property_name}: {og.class_name} operations") + retval.append(f":ivar {escape_sphinx_field_name(og.property_name)}: {og.class_name} operations") property_type = f"{self.client.code_model.namespace}{operations_folder}{og.class_name}" - retval.append(f":vartype {og.property_name}: {property_type}") + retval.append(f":vartype {escape_sphinx_field_name(og.property_name)}: {property_type}") for param in self.client.parameters.method: - retval.append(f":{param.description_keyword} {param.client_name}: {param.description}") retval.append( - f":{param.docstring_type_keyword} {param.client_name}: {param.docstring_type(async_mode=async_mode)}" + f":{param.description_keyword} {escape_sphinx_field_name(param.client_name)}: {param.description}" + ) + retval.append( + f":{param.docstring_type_keyword} {escape_sphinx_field_name(param.client_name)}: " + f"{param.docstring_type(async_mode=async_mode)}" ) if self.client.has_public_lro_operations: retval.append( @@ -315,7 +319,10 @@ def check_required_parameters(self) -> list[str]: def property_descriptions(self, async_mode: bool) -> list[str]: retval: list[str] = [] for p in self.client.config.parameters.method: - retval.append(f":{p.description_keyword} {p.client_name}: {p.description}") - retval.append(f":{p.docstring_type_keyword} {p.client_name}: {p.docstring_type(async_mode=async_mode)}") + retval.append(f":{p.description_keyword} {escape_sphinx_field_name(p.client_name)}: {p.description}") + retval.append( + f":{p.docstring_type_keyword} {escape_sphinx_field_name(p.client_name)}: " + f"{p.docstring_type(async_mode=async_mode)}" + ) retval.append('"""') return retval diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py index 61a4744fa56..a7971f8f3d4 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py @@ -23,10 +23,12 @@ ) from .import_serializer import FileImportSerializer from .base_serializer import BaseSerializer -from ..models.utils import NamespaceType, add_to_pylint_disable +from ..models.utils import NamespaceType, add_to_pylint_disable, escape_sphinx_field_name -def _get_xml_deserializer_name(prop: Property) -> Optional[str]: # pylint: disable=too-many-return-statements +def _get_xml_deserializer_name( + prop: Property, +) -> Optional[str]: # pylint: disable=too-many-return-statements """Return the _xml_deser_* function name for a scalar XML property, or None.""" prop_type = prop.type # Unwrap ConstantType to get the underlying value type @@ -80,6 +82,9 @@ def _documentation_string( doc_name = ( prop.wire_name if kwargs.get("serialize_namespace_type") == NamespaceType.TYPES_FILE else prop.client_name ) + # Escape characters that Sphinx would otherwise interpret in the info field target + # (e.g. a leading "@" in a wire name such as "@search.facets"). + doc_name = escape_sphinx_field_name(doc_name) sphinx_prefix = f":{description_keyword} {doc_name}:" description = prop.description(is_operation_file=False).replace("\\", "\\\\") retval.append(f"{sphinx_prefix} {description}" if description else sphinx_prefix) @@ -94,7 +99,13 @@ def _documentation_string( class _ModelSerializer(BaseSerializer, ABC): def __init__( - self, code_model, env, async_mode=False, *, models: list[ModelType], client_namespace: Optional[str] = None + self, + code_model, + env, + async_mode=False, + *, + models: list[ModelType], + client_namespace: Optional[str] = None, ): super().__init__(code_model, env, async_mode, client_namespace=client_namespace) self.models = models diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py index 69a2e4b745a..af0e8ed590f 100644 --- a/packages/http-client-python/tests/unit/test_typeddict.py +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -405,6 +405,40 @@ def test_models_mode_typeddict_docstring_uses_wire_name(): assert ":vartype param_name:" not in output +def test_models_mode_typeddict_docstring_escapes_at_sign(): + """A leading "@" in a wire name must be escaped in the Sphinx info field, but the + TypedDict key itself must keep the raw "@".""" + code_model = _make_code_model(models_mode="typeddict") + string_type = build_type({"type": "string"}, code_model) + model = TypedDictModelType( + yaml_data={"name": "Foo", "type": "model", "snakeCaseName": "foo", "usage": 2}, + code_model=code_model, + properties=[ + Property( + yaml_data={ + "wireName": "@search.facets", + "clientName": "search_facets", + "optional": True, + }, + code_model=code_model, + type=string_type, + ) + ], + ) + code_model.model_types = [model] + + env = _make_env() + output = TypesSerializer(code_model=code_model, env=env, models=[model]).serialize() + + # Docstring field target is escaped + assert ":ivar \\@search.facets:" in output + assert ":vartype \\@search.facets: str" in output + assert ":ivar @search.facets:" not in output + assert ":vartype @search.facets:" not in output + # The actual TypedDict key keeps the raw "@" (must NOT be escaped in code) + assert '"@search.facets":' in output + + def test_types_file_has_no_named_unions(): """Serialized types.py should not contain named union definitions.""" code_model = _make_code_model(models_mode="dpg") From 04107b966eb7c1be558d4405bd675b7df2fd3a55 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 11 Aug 2026 14:21:12 -0700 Subject: [PATCH 2/3] fix(http-client-python): wrap @ wire names in backticks for docstring fields A leading @ in a Sphinx info-field target is fine in stock autodoc, but escaping it as \@ (the previous approach) emits an invalid Python escape sequence into the generated SDK (SyntaxWarning / SyntaxError under -W error). Instead wrap names containing @ in double backticks (inline literal), which renders correctly across :ivar/:vartype/:keyword/:paramtype/:param/:type with no backslash and no invalid escape. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b38539c-8877-4fdc-8b9d-bc400a50d325 --- .../escape-at-sign-docstring-field-2026-8-11.md | 2 +- .../generator/pygen/codegen/models/utils.py | 10 +++++++--- .../pygen/codegen/serializers/model_serializer.py | 4 ++-- .../http-client-python/tests/unit/test_typeddict.py | 13 ++++++------- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.chronus/changes/escape-at-sign-docstring-field-2026-8-11.md b/.chronus/changes/escape-at-sign-docstring-field-2026-8-11.md index f8092b9ba1e..41a5fdb0cab 100644 --- a/.chronus/changes/escape-at-sign-docstring-field-2026-8-11.md +++ b/.chronus/changes/escape-at-sign-docstring-field-2026-8-11.md @@ -4,4 +4,4 @@ packages: - "@typespec/http-client-python" --- -Escape a leading `@` in wire names used as Sphinx docstring field targets (e.g. `:vartype @search.facets:`) so the generated docstrings for models, TypedDicts, operations, and clients render correctly. +Wrap wire names containing `@` (e.g. `@search.facets`) in double backticks when they are used as Sphinx docstring field targets (`:ivar`/`:vartype`/`:keyword`/`:paramtype`/`:param`/`:type`) across models, TypedDicts, operations, and clients, so the generated docstrings render correctly without introducing an invalid escape sequence in the generated code. diff --git a/packages/http-client-python/generator/pygen/codegen/models/utils.py b/packages/http-client-python/generator/pygen/codegen/models/utils.py index 57f33c821c7..5415e70df48 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/utils.py +++ b/packages/http-client-python/generator/pygen/codegen/models/utils.py @@ -38,10 +38,14 @@ def escape_sphinx_field_name(name: str) -> str: """Escape a name used as the target of a Sphinx info field (``:ivar``, ``:vartype``, ``:keyword``, ``:paramtype``, ``:param``, ``:type``). - A leading ``@`` (e.g. a wire name such as ``@search.facets``) is otherwise interpreted - by Sphinx/docutils and breaks the rendered docstring, so it must be escaped. + A name containing ``@`` (e.g. a wire name such as ``@search.facets``) is wrapped in + double backticks so it is treated as an inline literal. This renders cleanly in Sphinx + without a leading ``@`` being misinterpreted, and — unlike a ``\\@`` escape — introduces + no invalid escape sequence into the generated Python docstring. """ - return name.replace("@", "\\@") + if "@" in name: + return f"``{name}``" + return name class NamespaceType(str, Enum): diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py index a7971f8f3d4..81e9c4078f4 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py @@ -82,8 +82,8 @@ def _documentation_string( doc_name = ( prop.wire_name if kwargs.get("serialize_namespace_type") == NamespaceType.TYPES_FILE else prop.client_name ) - # Escape characters that Sphinx would otherwise interpret in the info field target - # (e.g. a leading "@" in a wire name such as "@search.facets"). + # Escape names that Sphinx would otherwise misinterpret in the info field target + # (e.g. a wire name with a leading "@" such as "@search.facets"). doc_name = escape_sphinx_field_name(doc_name) sphinx_prefix = f":{description_keyword} {doc_name}:" description = prop.description(is_operation_file=False).replace("\\", "\\\\") diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py index af0e8ed590f..9437a10be98 100644 --- a/packages/http-client-python/tests/unit/test_typeddict.py +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -406,8 +406,8 @@ def test_models_mode_typeddict_docstring_uses_wire_name(): def test_models_mode_typeddict_docstring_escapes_at_sign(): - """A leading "@" in a wire name must be escaped in the Sphinx info field, but the - TypedDict key itself must keep the raw "@".""" + """A wire name containing "@" is wrapped in double backticks in the Sphinx info field, + but the TypedDict key itself must keep the raw "@".""" code_model = _make_code_model(models_mode="typeddict") string_type = build_type({"type": "string"}, code_model) model = TypedDictModelType( @@ -430,11 +430,10 @@ def test_models_mode_typeddict_docstring_escapes_at_sign(): env = _make_env() output = TypesSerializer(code_model=code_model, env=env, models=[model]).serialize() - # Docstring field target is escaped - assert ":ivar \\@search.facets:" in output - assert ":vartype \\@search.facets: str" in output - assert ":ivar @search.facets:" not in output - assert ":vartype @search.facets:" not in output + # Docstring field target is wrapped in double backticks (no backslash escape) + assert ":ivar ``@search.facets``:" in output + assert ":vartype ``@search.facets``: str" in output + assert "\\@search.facets" not in output # The actual TypedDict key keeps the raw "@" (must NOT be escaped in code) assert '"@search.facets":' in output From d32f5be0bf9ae750cb521431440d2bdb1466a0db Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Wed, 12 Aug 2026 08:40:15 -0700 Subject: [PATCH 3/3] fix(http-client-python): restore single-line signatures split by formatter An earlier accidental black run at the wrong line length split two function signatures in model_serializer.py across multiple lines. That moved the '# pylint: disable=too-many-return-statements' comment off the def line, breaking the suppression (R0911 + useless-suppression). Restore both signatures to match main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b38539c-8877-4fdc-8b9d-bc400a50d325 --- .../pygen/codegen/serializers/model_serializer.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py index 81e9c4078f4..14ab92efc37 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py @@ -26,9 +26,7 @@ from ..models.utils import NamespaceType, add_to_pylint_disable, escape_sphinx_field_name -def _get_xml_deserializer_name( - prop: Property, -) -> Optional[str]: # pylint: disable=too-many-return-statements +def _get_xml_deserializer_name(prop: Property) -> Optional[str]: # pylint: disable=too-many-return-statements """Return the _xml_deser_* function name for a scalar XML property, or None.""" prop_type = prop.type # Unwrap ConstantType to get the underlying value type @@ -99,13 +97,7 @@ def _documentation_string( class _ModelSerializer(BaseSerializer, ABC): def __init__( - self, - code_model, - env, - async_mode=False, - *, - models: list[ModelType], - client_namespace: Optional[str] = None, + self, code_model, env, async_mode=False, *, models: list[ModelType], client_namespace: Optional[str] = None ): super().__init__(code_model, env, async_mode, client_namespace=client_namespace) self.models = models