Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/escape-at-sign-docstring-field-2026-8-11.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}")
Comment thread
l0lawrence marked this conversation as resolved.
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions packages/http-client-python/tests/unit/test_typeddict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading