Skip to content

Commit cdcedf5

Browse files
author
iscai-msft
committed
add e2e tests
1 parent 9572470 commit cdcedf5

8 files changed

Lines changed: 206 additions & 20 deletions

File tree

packages/http-client-python/eng/scripts/ci/regenerate.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,18 @@ const EMITTER_OPTIONS: Record<string, Record<string, string> | Record<string, st
307307
"clear-output-folder": "true",
308308
},
309309
],
310-
"type/model/usage": {
311-
"package-name": "typetest-model-usage",
312-
namespace: "typetest.model.usage",
313-
},
310+
"type/model/usage": [
311+
{
312+
"package-name": "typetest-model-usage",
313+
namespace: "typetest.model.usage",
314+
},
315+
{
316+
"package-name": "typetest-model-usage-typeddictonly",
317+
namespace: "typetest.model.usage.typeddictonly",
318+
"models-mode": "typeddict",
319+
"typed-dict-only-models": "InputRecord,OutputRecord,InputOutputRecord",
320+
},
321+
],
314322
"type/model/visibility": [
315323
{
316324
"package-name": "typetest-model-visibility",

packages/http-client-python/generator/pygen/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ def _validate_and_transform(self, key: str, value: Any) -> Any:
181181
raise ValueError(
182182
f"--package-mode can only be {' or '.join(TYPESPEC_PACKAGE_MODE)} or directory which contains template files" # pylint: disable=line-too-long
183183
)
184+
if key == "typed-dict-only-models" and isinstance(value, str):
185+
value = [v.strip() for v in value.split(",") if v.strip()]
184186
return value
185187

186188
def setdefault(self, key: str, default: Any, /) -> Any: # type: ignore # pylint: disable=arguments-differ

packages/http-client-python/generator/pygen/codegen/models/code_model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ def model_types(self, val: list[ModelType]) -> None:
349349

350350
@staticmethod
351351
def get_public_model_types(models: list[ModelType]) -> list[ModelType]:
352-
return [m for m in models if not m.internal and not m.base == "json"]
352+
return [m for m in models if not m.internal and not m.base == "json" and not m.is_typed_dict_only]
353353

354354
@property
355355
def public_model_types(self) -> list[ModelType]:

packages/http-client-python/generator/pygen/codegen/models/model_type.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ def __init__(
7777
self.cross_language_definition_id: Optional[str] = self.yaml_data.get("crossLanguageDefinitionId")
7878
self.usage: int = self.yaml_data.get("usage", UsageFlags.Input.value | UsageFlags.Output.value)
7979
self.client_namespace: str = self.yaml_data.get("clientNamespace", code_model.namespace)
80+
self.is_typed_dict_only: bool = (
81+
self.yaml_data.get("typedDictOnly", False)
82+
or self.name in code_model.options.get("typed-dict-only-models", [])
83+
)
8084

8185
@property
8286
def is_usage_output(self) -> bool:
@@ -352,6 +356,22 @@ def imports(self, **kwargs: Any) -> FileImport:
352356
class DPGModelType(GeneratedModelType):
353357
base = "dpg"
354358

359+
def type_annotation(self, **kwargs: Any) -> str:
360+
if self.is_typed_dict_only:
361+
is_operation_file = kwargs.pop("is_operation_file", False)
362+
skip_quote = kwargs.get("skip_quote", False)
363+
retval = f"types.{self.name}"
364+
return retval if is_operation_file or skip_quote else f'"{retval}"'
365+
return super().type_annotation(**kwargs)
366+
367+
def docstring_type(self, **kwargs: Any) -> str:
368+
if self.is_typed_dict_only:
369+
client_namespace = self.client_namespace
370+
if self.code_model.options.get("generation-subdir"):
371+
client_namespace += f".{self.code_model.options['generation-subdir']}"
372+
return f"~{client_namespace}.types.{self.name}"
373+
return super().docstring_type(**kwargs)
374+
355375
def serialization_type(self, **kwargs: Any) -> str:
356376
return (
357377
self.type_annotation(skip_quote=True, **kwargs)
@@ -364,6 +384,28 @@ def instance_check_template(self) -> str:
364384
return "isinstance({}, " + f"_models.{self.name})"
365385

366386
def imports(self, **kwargs: Any) -> FileImport:
387+
if self.is_typed_dict_only:
388+
file_import = FileImport(self.code_model)
389+
serialize_namespace_type = kwargs.get("serialize_namespace_type")
390+
serialize_namespace = kwargs.get("serialize_namespace", self.code_model.namespace)
391+
relative_path = self.code_model.get_relative_import_path(serialize_namespace, self.client_namespace)
392+
if serialize_namespace_type in [NamespaceType.OPERATION, NamespaceType.CLIENT]:
393+
file_import.add_submodule_import(
394+
relative_path,
395+
"types",
396+
ImportType.LOCAL,
397+
)
398+
elif serialize_namespace_type in [NamespaceType.TYPES_FILE, NamespaceType.UNIONS_FILE] or (
399+
serialize_namespace_type == NamespaceType.MODEL
400+
and kwargs.get("called_by_property", False)
401+
):
402+
file_import.add_submodule_import(
403+
relative_path,
404+
"types",
405+
ImportType.LOCAL,
406+
typing_section=TypingSection.TYPING,
407+
)
408+
return file_import
367409
file_import = super().imports(**kwargs)
368410
if self.flattened_property:
369411
file_import.add_submodule_import("typing", "Any", ImportType.STDLIB)

packages/http-client-python/generator/pygen/codegen/serializers/__init__.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,14 @@ def _serialize_and_write_models_folder(
304304
serializer = DpgModelSerializer
305305
else:
306306
serializer = MsrestModelSerializer
307-
if self.code_model.has_non_json_models(models):
307+
# Filter out typed-dict-only models — they only appear in types.py, not as model classes
308+
class_models = [m for m in models if not m.is_typed_dict_only]
309+
if self.code_model.has_non_json_models(class_models):
308310
self.write_file(
309311
models_path / Path(f"{self.code_model.models_filename}.py"),
310-
serializer(code_model=self.code_model, env=env, client_namespace=namespace, models=models).serialize(),
312+
serializer(
313+
code_model=self.code_model, env=env, client_namespace=namespace, models=class_models
314+
).serialize(),
311315
)
312316
if enums:
313317
self.write_file(
@@ -318,7 +322,7 @@ def _serialize_and_write_models_folder(
318322
)
319323
self.write_file(
320324
models_path / Path("__init__.py"),
321-
ModelInitSerializer(code_model=self.code_model, env=env, models=models, enums=enums).serialize(),
325+
ModelInitSerializer(code_model=self.code_model, env=env, models=class_models, enums=enums).serialize(),
322326
)
323327

324328
self._keep_patch_file(models_path / Path("_patch.py"), env)

packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,6 +1000,12 @@ def response_deserialization( # pylint: disable=too-many-statements
10001000
elif self.code_model.options["models-mode"] == "dpg":
10011001
if builder.has_stream_response:
10021002
deserialize_code.append("deserialized = response.content")
1003+
elif isinstance(response.type, ModelType) and response.type.is_typed_dict_only:
1004+
# Typed-dict-only models skip deserialization — return raw JSON
1005+
deserialize_code.append("if response.content:")
1006+
deserialize_code.append(" deserialized = response.json()")
1007+
deserialize_code.append("else:")
1008+
deserialize_code.append(" deserialized = None")
10031009
else:
10041010
format_filed = (
10051011
f', format="{response.type.encode}"'
@@ -1429,18 +1435,23 @@ def _extract_data_callback( # pylint: disable=too-many-statements,too-many-bran
14291435
)
14301436
pylint_disable = ""
14311437
if self.code_model.options["models-mode"] == "dpg":
1432-
item_type = builder.item_type.type_annotation(
1433-
is_operation_file=True, serialize_namespace=self.serialize_namespace
1434-
)
1435-
pylint_disable = (
1436-
" # pylint: disable=protected-access" if getattr(builder.item_type, "internal", False) else ""
1437-
)
1438-
list_of_elem_deserialized = [
1439-
"_deserialize(",
1440-
f"{item_type},{pylint_disable}",
1441-
f"deserialized{access},",
1442-
")",
1443-
]
1438+
is_item_typed_dict_only = isinstance(builder.item_type, ModelType) and builder.item_type.is_typed_dict_only
1439+
if is_item_typed_dict_only:
1440+
# Typed-dict-only models skip deserialization — return raw JSON items
1441+
list_of_elem_deserialized = [f"deserialized{access}"]
1442+
else:
1443+
item_type = builder.item_type.type_annotation(
1444+
is_operation_file=True, serialize_namespace=self.serialize_namespace
1445+
)
1446+
pylint_disable = (
1447+
" # pylint: disable=protected-access" if getattr(builder.item_type, "internal", False) else ""
1448+
)
1449+
list_of_elem_deserialized = [
1450+
"_deserialize(",
1451+
f"{item_type},{pylint_disable}",
1452+
f"deserialized{access},",
1453+
")",
1454+
]
14441455
else:
14451456
list_of_elem_deserialized = [f"deserialized{access}"]
14461457
list_of_elem_deserialized_str = "\n ".join(list_of_elem_deserialized)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# --------------------------------------------------------------------------
6+
import pytest
7+
from typetest.model.usage.typeddictonly import UsageClient
8+
from typetest.model.usage.typeddictonly.types import InputRecord, OutputRecord, InputOutputRecord
9+
10+
11+
@pytest.fixture
12+
def client():
13+
with UsageClient() as client:
14+
yield client
15+
16+
17+
def test_input(client: UsageClient):
18+
# TypedDict-only: pass a plain dict matching the TypedDict schema
19+
result = client.input({"requiredProp": "example-value"})
20+
assert result is None
21+
22+
23+
def test_output(client: UsageClient):
24+
# TypedDict-only: output should be a plain dict (no model deserialization)
25+
output = client.output()
26+
assert isinstance(output, dict)
27+
assert output["requiredProp"] == "example-value"
28+
29+
30+
def test_input_and_output(client: UsageClient):
31+
# TypedDict-only: input a dict, get a dict back
32+
result = client.input_and_output({"requiredProp": "example-value"})
33+
assert isinstance(result, dict)
34+
assert result["requiredProp"] == "example-value"
35+
36+
37+
def test_no_model_classes():
38+
"""Verify that typed-dict-only models don't generate model classes."""
39+
from typetest.model.usage.typeddictonly import models
40+
41+
# models.__all__ should be empty — no model classes exported
42+
assert models.__all__ == []
43+
# The TypedDicts should only exist in the types module
44+
assert hasattr(InputRecord, "__required_keys__") or hasattr(InputRecord, "__annotations__")

packages/http-client-python/tests/unit/test_typeddict.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,78 @@ def test_unions_serializer_no_unions():
169169
output = us.serialize()
170170
assert "TypedDict" not in output
171171
assert "Union" not in output
172+
173+
174+
# ---------- typed-dict-only ----------
175+
176+
177+
def _make_typed_dict_only_model(code_model, name, **extra_yaml):
178+
"""Create a TypedDictModelType with typedDictOnly=True."""
179+
yaml_data = {
180+
"name": name,
181+
"type": "model",
182+
"snakeCaseName": name.lower(),
183+
"usage": 2,
184+
"typedDictOnly": True,
185+
**extra_yaml,
186+
}
187+
return TypedDictModelType(
188+
yaml_data=yaml_data,
189+
code_model=code_model,
190+
properties=[],
191+
)
192+
193+
194+
def test_typed_dict_only_property():
195+
"""is_typed_dict_only should be True when yaml_data has typedDictOnly=True."""
196+
code_model = _make_code_model(models_mode="typeddict")
197+
model = _make_typed_dict_only_model(code_model, "Foo")
198+
assert model.is_typed_dict_only is True
199+
200+
normal_model = _make_model(code_model, "Bar", model_cls=TypedDictModelType)
201+
assert normal_model.is_typed_dict_only is False
202+
203+
204+
def test_typed_dict_only_excluded_from_public_model_types():
205+
"""Typed-dict-only models should not appear in public_model_types."""
206+
code_model = _make_code_model(models_mode="typeddict")
207+
normal = _make_model(code_model, "Normal", model_cls=TypedDictModelType)
208+
td_only = _make_typed_dict_only_model(code_model, "TdOnly")
209+
code_model.model_types = [normal, td_only]
210+
211+
public = code_model.public_model_types
212+
assert normal in public
213+
assert td_only not in public
214+
215+
216+
def test_typed_dict_only_still_in_types_file():
217+
"""Typed-dict-only models should still appear in types.py as TypedDicts."""
218+
code_model = _make_code_model(models_mode="typeddict")
219+
td_only = _make_typed_dict_only_model(code_model, "MyModel")
220+
code_model.model_types = [td_only]
221+
222+
env = _make_env()
223+
ts = TypesSerializer(code_model=code_model, env=env, models=[td_only])
224+
output = ts.serialize()
225+
assert "class MyModel(TypedDict, total=False):" in output
226+
227+
228+
def test_typed_dict_only_type_annotation():
229+
"""Typed-dict-only models should use types.Name, not _models.Name."""
230+
code_model = _make_code_model(models_mode="typeddict")
231+
model = _make_typed_dict_only_model(code_model, "Foo")
232+
233+
# In operation files, should be types.Name
234+
annotation = model.type_annotation(is_operation_file=True)
235+
assert annotation == "types.Foo"
236+
assert "_models" not in annotation
237+
238+
239+
def test_typed_dict_only_docstring_type():
240+
"""Typed-dict-only models should reference types module, not models."""
241+
code_model = _make_code_model(models_mode="typeddict")
242+
model = _make_typed_dict_only_model(code_model, "Foo")
243+
244+
docstring = model.docstring_type()
245+
assert "types.Foo" in docstring
246+
assert "models.Foo" not in docstring

0 commit comments

Comments
 (0)