Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
176 changes: 176 additions & 0 deletions packages/http-client-python/tests/unit/test_string_literals.py
Original file line number Diff line number Diff line change
@@ -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, "<model_base>", "exec"), namespace)
namespace.update(
_Model=namespace["Model"],
Literal=Literal,
marker=marker,
)
exec(compile(source, "<models>", "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
4 changes: 2 additions & 2 deletions packages/http-client-python/tests/unit/test_typeddict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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

Expand Down
Loading