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..41a5fdb0cab --- /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" +--- + +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 dd462d1d5bd..5415e70df48 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,20 @@ 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 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. + """ + if "@" in name: + return f"``{name}``" + return name + + 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..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 @@ -23,7 +23,7 @@ ) 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 @@ -80,6 +80,9 @@ def _documentation_string( doc_name = ( prop.wire_name if kwargs.get("serialize_namespace_type") == NamespaceType.TYPES_FILE else prop.client_name ) + # 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("\\", "\\\\") retval.append(f"{sphinx_prefix} {description}" if description else sphinx_prefix) diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py index 69a2e4b745a..9437a10be98 100644 --- a/packages/http-client-python/tests/unit/test_typeddict.py +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -405,6 +405,39 @@ 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 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( + 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 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 + + 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")