From 405aee99b7ae192a8a5eaf7475a49ac7c2fb19a7 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Thu, 22 Oct 2026 13:09:41 +0800 Subject: [PATCH] fix(http-client-python): escape discriminator string literals Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ecfba725-b27d-416c-95a6-bc48a498ca8f --- ...iscriminator-string-literals-2026-10-22.md | 7 + .../pygen/codegen/models/model_type.py | 2 +- .../pygen/codegen/models/primitive_types.py | 4 +- .../codegen/serializers/model_serializer.py | 4 +- .../tests/unit/test_string_literals.py | 176 ++++++++++++++++++ .../tests/unit/test_typeddict.py | 4 +- 6 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 .chronus/changes/python-discriminator-string-literals-2026-10-22.md create mode 100644 packages/http-client-python/tests/unit/test_string_literals.py diff --git a/.chronus/changes/python-discriminator-string-literals-2026-10-22.md b/.chronus/changes/python-discriminator-string-literals-2026-10-22.md new file mode 100644 index 00000000000..aa918207e7f --- /dev/null +++ b/.chronus/changes/python-discriminator-string-literals-2026-10-22.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +Escape string literals in generated Python discriminator declarations, initializers, and templates to preserve specification values as data. diff --git a/packages/http-client-python/generator/pygen/codegen/models/model_type.py b/packages/http-client-python/generator/pygen/codegen/models/model_type.py index f7d4a9c6e7a..2309b981f23 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/model_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/model_type.py @@ -172,7 +172,7 @@ def get_json_template_representation( ] } if self.discriminator and self.discriminator_value: - representation[f'"{self.discriminator.wire_name}"'] = f'"{self.discriminator_value}"' + representation[f'"{self.discriminator.wire_name}"'] = repr(self.discriminator_value) # once we've finished, we want to reset created_json_template_representation to false # so we can call it again diff --git a/packages/http-client-python/generator/pygen/codegen/models/primitive_types.py b/packages/http-client-python/generator/pygen/codegen/models/primitive_types.py index e8a1d28c1f9..487f38ad11d 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/primitive_types.py +++ b/packages/http-client-python/generator/pygen/codegen/models/primitive_types.py @@ -332,8 +332,8 @@ def validation(self) -> Optional[dict[str, Union[bool, int, str]]]: validation["pattern"] = RawString(self.pattern) # type: ignore return validation or None - def get_declaration(self, value) -> str: - return f"'{value}'" if value == '"' else f'"{value}"' + def get_declaration(self, value: str) -> str: + return repr(value) def serialization_type(self, **kwargs: Any) -> str: return "str" 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 14ab92efc37..76beee778a9 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 @@ -137,7 +137,7 @@ def super_call(self, model: ModelType) -> list[str]: @staticmethod def initialize_discriminator_property(model: ModelType, prop: Property) -> str: - discriminator_value = f"'{model.discriminator_value}'" if model.discriminator_value else None + discriminator_value = repr(model.discriminator_value) if model.discriminator_value else None if not discriminator_value: typing = "Optional[str]" else: @@ -422,7 +422,7 @@ def declare_model(self, model: ModelType) -> str: if model.parents: basename = ", ".join([m.name for m in model.parents]) if model.discriminator_value: - basename += f", discriminator='{model.discriminator_value}'" + basename += f", discriminator={model.discriminator_value!r}" return f"class {model.name}({basename}):{self.class_pylint_disable(model)}" def class_pylint_disable(self, model: ModelType) -> str: diff --git a/packages/http-client-python/tests/unit/test_string_literals.py b/packages/http-client-python/tests/unit/test_string_literals.py new file mode 100644 index 00000000000..eed58b043bb --- /dev/null +++ b/packages/http-client-python/tests/unit/test_string_literals.py @@ -0,0 +1,176 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +"""Specification strings must remain literal data in generated Python.""" + +import ast +from types import SimpleNamespace +from typing import Literal, get_args + +import pytest +from jinja2 import Environment, PackageLoader + +from pygen import OptionsDict +from pygen.codegen.models import CodeModel, build_type +from pygen.codegen.models.primitive_types import StringType +from pygen.codegen.serializers.enum_serializer import EnumSerializer +from pygen.codegen.serializers.model_serializer import DpgModelSerializer + +VALUES = [ + "ordinary", + "", + "'", + '"', + "Both 'single' and \"double\" quotes", + r"C:\new\test\\", + "first\nsecond\r\t\x00\x1f", + "\u96ea\U0001f600", + 'prefix" + marker() + "suffix', + "prefix' + marker() + 'suffix", +] + + +@pytest.fixture(scope="session", autouse=True) +def testserver(): + yield + + +@pytest.fixture +def code_model(): + return CodeModel( + { + "namespace": "sample", + "clients": [ + { + "name": "client", + "namespace": "sample", + "moduleName": "sample", + "parameters": [], + "url": "", + "operationGroups": [], + } + ], + }, + OptionsDict({"models-mode": "dpg", "flavor": "unbranded", "tsp_file": True}), + ) + + +@pytest.fixture +def env(): + return Environment(loader=PackageLoader("pygen.codegen", "templates"), trim_blocks=True, lstrip_blocks=True) + + +@pytest.mark.parametrize("value", VALUES) +def test_string_declarations_are_literals(code_model, value): + declaration = StringType({"type": "string"}, code_model).get_declaration(value) + assert ast.literal_eval(declaration) == value + + +@pytest.mark.parametrize("value", VALUES) +def test_discriminator_generation_preserves_values(code_model, env, value): + model = build_type( + { + "type": "model", + "base": "dpg", + "name": "Child", + "snakeCaseName": "child", + "discriminatorValue": value, + "properties": [ + { + "wireName": "kind", + "clientName": "kind", + "optional": False, + "isDiscriminator": True, + "type": {"type": "constant", "value": value, "valueType": {"type": "string"}}, + } + ], + }, + code_model, + ) + serializer = DpgModelSerializer(code_model, env, models=[model]) + model.parents = [SimpleNamespace(name="Base", properties=[])] + prop = model.properties[0] + if value: + representation = model.get_json_template_representation() + assert {ast.literal_eval(k): ast.literal_eval(v) for k, v in representation.items()} == {"kind": value} + calls = [] + + def marker(): + calls.append(True) + return "unexpected" + + source = "\n".join( + [ + "class Base(_Model):", + " __mapping__ = {}", + ' kind: str = rest_discriminator(name="kind")', + serializer.declare_model(model), + " " + serializer.declare_property(prop), + " def __init__(self, *args, **kwargs):", + *[" " + line for line in serializer.super_call(model)], + ] + ) + module = ast.parse(source) + annotation = module.body[1].body[0].annotation + assert ast.literal_eval(annotation.slice) == value + if value: + assert ast.literal_eval(module.body[1].keywords[0].value) == value + + runtime = env.get_template("model_base.py.jinja2").render(code_model=code_model) + namespace = {"__name__": "string_literal_test"} + exec(compile(runtime, "", "exec"), namespace) + namespace.update( + _Model=namespace["Model"], + Literal=Literal, + marker=marker, + ) + exec(compile(source, "", "exec"), namespace) + child = namespace["Child"] + assert get_args(child.__annotations__["kind"]) == (value,) + for _ in range(2): + instance = child() + assert instance.kind == value + assert instance.as_dict() == {"kind": value} + if value: + selected = namespace["Base"]._deserialize({"kind": value}, []) + assert isinstance(selected, child) + assert selected.as_dict() == {"kind": value} + assert calls == [] + + +@pytest.mark.parametrize("value", VALUES[0:1] + VALUES[2:]) +def test_discriminator_initializer_is_literal(value): + model = SimpleNamespace(discriminator_value=value) + prop = SimpleNamespace(client_name="kind") + statement = DpgModelSerializer.initialize_discriminator_property(model, prop) + assignment = ast.parse(statement).body[0] + assert ast.literal_eval(assignment.value) == value + + +@pytest.mark.parametrize("value", VALUES) +def test_enum_values_are_literals(code_model, env, value): + enum_yaml = { + "type": "enum", + "name": "Kind", + "valueType": {"type": "string"}, + "values": [], + } + enum = build_type(enum_yaml, code_model) + enum.values.append( + build_type( + { + "type": "enumvalue", + "name": "VALUE", + "value": value, + "enumType": enum_yaml, + "valueType": {"type": "string"}, + }, + code_model, + ) + ) + source = EnumSerializer(code_model, env, enums=[enum]).serialize() + assignments = [node for node in ast.walk(ast.parse(source)) if isinstance(node, ast.Assign)] + assignment = next(node for node in assignments if any(getattr(t, "id", "") == "VALUE" for t in node.targets)) + assert ast.literal_eval(assignment.value) == value diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py index 8f53e30e659..db8f4621322 100644 --- a/packages/http-client-python/tests/unit/test_typeddict.py +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -738,7 +738,7 @@ def test_enum_value_typeddict_annotation_uses_literal_value(): """ code_model = _make_code_model(models_mode="typeddict") enum_value = _make_enum_value(code_model) - assert enum_value.type_annotation() == 'Literal["red"]' + assert enum_value.type_annotation() == "Literal['red']" def test_enum_value_typeddict_does_not_import_enums_module(): @@ -894,7 +894,7 @@ def test_literal_value_false_positive_no_builtins_import_and_value_preserved(): env = _make_env() output = TypesSerializer(code_model=code_model, env=env, models=[model]).serialize() - assert 'Literal["type"]' in output + assert "Literal['type']" in output assert "builtins.type" not in output assert "import builtins" not in output