From 0450b4dd5eac423799c44e1bbd424fe196f80d88 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Fri, 5 Dec 2025 10:11:20 +0000 Subject: [PATCH 1/7] support array encode in serialization and deserialization --- .../pygen/codegen/templates/model_base.py.jinja2 | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 index e374e0e4887..421328c2a8c 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 @@ -179,6 +179,17 @@ _VALID_RFC7231 = re.compile( r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s\d{4}\s\d{2}:\d{2}:\d{2}\sGMT" ) +_ARRAY_ENCODE_MAPPING = { + "pipeDelimited": "|", + "spaceDelimited": " ", + "commaDelimited": ",", + "newlineDelimited": "\n", +} + +def _deserialize_array_encoded(delimit: str, attr): + if isinstance(attr, str): + return attr.split(delimit) + return attr def _deserialize_datetime(attr: typing.Union[str, datetime]) -> datetime: """Deserialize ISO-8601 formatted string into Datetime object. @@ -323,6 +334,8 @@ _DESERIALIZE_MAPPING_WITHFORMAT = { def get_deserializer(annotation: typing.Any, rf: typing.Optional["_RestField"] = None): if annotation is int and rf and rf._format == "str": return _deserialize_int_as_str + if annotation is list and rf and rf._format in _ARRAY_ENCODE_MAPPING: + return functools.partial(_deserialize_array_encoded, _ARRAY_ENCODE_MAPPING[rf._format]) if rf and rf._format: return _DESERIALIZE_MAPPING_WITHFORMAT.get(rf._format) {% if code_model.has_external_type %} @@ -497,6 +510,8 @@ def _is_model(obj: typing.Any) -> bool: def _serialize(o, format: typing.Optional[str] = None): # pylint: disable=too-many-return-statements if isinstance(o, list): + if format in _ARRAY_ENCODE_MAPPING and all(isinstance(x, str) for x in o): + return _ARRAY_ENCODE_MAPPING[format].join(o) return [_serialize(x, format) for x in o] if isinstance(o, dict): return {k: _serialize(v, format) for k, v in o.items()} From 4897fac103f5739dcdcd0a7a159a370df0fa9d37 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Fri, 5 Dec 2025 10:12:22 +0000 Subject: [PATCH 2/7] add changelog --- .chronus/changes/python-array-encode-2025-11-5-10-12-9.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/python-array-encode-2025-11-5-10-12-9.md diff --git a/.chronus/changes/python-array-encode-2025-11-5-10-12-9.md b/.chronus/changes/python-array-encode-2025-11-5-10-12-9.md new file mode 100644 index 00000000000..af788a49a8f --- /dev/null +++ b/.chronus/changes/python-array-encode-2025-11-5-10-12-9.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http-client-python" +--- + +Support encode for array of string in serialization and deserialization \ No newline at end of file From c7912502a86d1291cc57ed6bdbb4a69c4f9bb009 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Fri, 5 Dec 2025 10:27:04 +0000 Subject: [PATCH 3/7] add encode info --- packages/http-client-python/emitter/src/types.ts | 1 + .../generator/pygen/codegen/models/property.py | 1 + .../generator/pygen/codegen/serializers/model_serializer.py | 2 ++ 3 files changed, 4 insertions(+) diff --git a/packages/http-client-python/emitter/src/types.ts b/packages/http-client-python/emitter/src/types.ts index ee7a373ed8f..2abffa47f12 100644 --- a/packages/http-client-python/emitter/src/types.ts +++ b/packages/http-client-python/emitter/src/types.ts @@ -246,6 +246,7 @@ function emitProperty( flatten: property.flatten, isMultipartFileInput: isMultipartFileInput, xmlMetadata: getXmlMetadata(property), + encode: property.encode, }; } diff --git a/packages/http-client-python/generator/pygen/codegen/models/property.py b/packages/http-client-python/generator/pygen/codegen/models/property.py index dbdf01aa7d0..ca18aba8ddf 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/property.py +++ b/packages/http-client-python/generator/pygen/codegen/models/property.py @@ -40,6 +40,7 @@ def __init__( self.is_multipart_file_input: bool = yaml_data.get("isMultipartFileInput", False) self.flatten = self.yaml_data.get("flatten", False) and not getattr(self.type, "flattened_property", False) self.original_tsp_name: Optional[str] = self.yaml_data.get("originalTspName") + self.encode: Optional[str] = self.yaml_data.get("encode") def pylint_disable(self) -> str: retval: 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 b533c65f3e8..cf17f26e1fd 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 @@ -329,6 +329,8 @@ def declare_property(self, prop: Property) -> str: args.append("is_multipart_file_input=True") elif hasattr(prop.type, "encode") and prop.type.encode: # type: ignore args.append(f'format="{prop.type.encode}"') # type: ignore + elif prop.encode: + args.append(f'format="{prop.encode}"') if prop.xml_metadata: args.append(f"xml={prop.xml_metadata}") From cf7f6b518c0cc0d0c74de0218c8a5368f2e82202 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Thu, 11 Dec 2025 09:05:41 +0000 Subject: [PATCH 4/7] add test case --- .../codegen/templates/model_base.py.jinja2 | 15 ++++++- .../asynctests/test_encode_array_async.py | 43 +++++++++++++++++++ .../test_encode_array.py | 38 ++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 packages/http-client-python/generator/test/generic_mock_api_tests/asynctests/test_encode_array_async.py create mode 100644 packages/http-client-python/generator/test/generic_mock_api_tests/test_encode_array.py diff --git a/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 index 421328c2a8c..5445a2fc09b 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 @@ -334,7 +334,7 @@ _DESERIALIZE_MAPPING_WITHFORMAT = { def get_deserializer(annotation: typing.Any, rf: typing.Optional["_RestField"] = None): if annotation is int and rf and rf._format == "str": return _deserialize_int_as_str - if annotation is list and rf and rf._format in _ARRAY_ENCODE_MAPPING: + if annotation is str and rf and rf._format in _ARRAY_ENCODE_MAPPING: return functools.partial(_deserialize_array_encoded, _ARRAY_ENCODE_MAPPING[rf._format]) if rf and rf._format: return _DESERIALIZE_MAPPING_WITHFORMAT.get(rf._format) @@ -824,6 +824,19 @@ def _deserialize_sequence( return obj if isinstance(obj, ET.Element): obj = list(obj) + if isinstance(obj, str): + return deserializer(obj) + try: + if ( + isinstance(obj, str) + and isinstance(deserializer, functools.partial) + and isinstance(deserializer.args[0], functools.partial) + and deserializer.args[0].func == _deserialize_array_encoded + ): + # encoded string may be deserialized to sequence + return deserializer(obj) + except: # pylint: disable=bare-except + pass return type(obj)(_deserialize(deserializer, entry, module) for entry in obj) diff --git a/packages/http-client-python/generator/test/generic_mock_api_tests/asynctests/test_encode_array_async.py b/packages/http-client-python/generator/test/generic_mock_api_tests/asynctests/test_encode_array_async.py new file mode 100644 index 00000000000..27cae3caa09 --- /dev/null +++ b/packages/http-client-python/generator/test/generic_mock_api_tests/asynctests/test_encode_array_async.py @@ -0,0 +1,43 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import pytest +from encode.array.aio import ArrayClient +from encode.array import models + + +@pytest.fixture +async def client(): + async with ArrayClient() as client: + yield client + + +@pytest.mark.asyncio +async def test_comma_delimited(client: ArrayClient): + body = models.CommaDelimitedArrayProperty(value=["blue", "red", "green"]) + result = await client.property.comma_delimited(body) + assert result.value == ["blue", "red", "green"] + + +@pytest.mark.asyncio +async def test_space_delimited(client: ArrayClient): + body = models.SpaceDelimitedArrayProperty(value=["blue", "red", "green"]) + result = await client.property.space_delimited(body) + assert result.value == ["blue", "red", "green"] + + +@pytest.mark.asyncio +async def test_pipe_delimited(client: ArrayClient): + body = models.PipeDelimitedArrayProperty(value=["blue", "red", "green"]) + result = await client.property.pipe_delimited(body) + assert result.value == ["blue", "red", "green"] + + +@pytest.mark.asyncio +async def test_newline_delimited(client: ArrayClient): + body = models.NewlineDelimitedArrayProperty(value=["blue", "red", "green"]) + result = await client.property.newline_delimited(body) + assert result.value == ["blue", "red", "green"] diff --git a/packages/http-client-python/generator/test/generic_mock_api_tests/test_encode_array.py b/packages/http-client-python/generator/test/generic_mock_api_tests/test_encode_array.py new file mode 100644 index 00000000000..3e1b48c908e --- /dev/null +++ b/packages/http-client-python/generator/test/generic_mock_api_tests/test_encode_array.py @@ -0,0 +1,38 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import pytest +from encode.array import ArrayClient, models + + +@pytest.fixture +def client(): + with ArrayClient() as client: + yield client + + +def test_comma_delimited(client: ArrayClient): + body = models.CommaDelimitedArrayProperty(value=["blue", "red", "green"]) + result = client.property.comma_delimited(body) + assert result.value == ["blue", "red", "green"] + + +def test_space_delimited(client: ArrayClient): + body = models.SpaceDelimitedArrayProperty(value=["blue", "red", "green"]) + result = client.property.space_delimited(body) + assert result.value == ["blue", "red", "green"] + + +def test_pipe_delimited(client: ArrayClient): + body = models.PipeDelimitedArrayProperty(value=["blue", "red", "green"]) + result = client.property.pipe_delimited(body) + assert result.value == ["blue", "red", "green"] + + +def test_newline_delimited(client: ArrayClient): + body = models.NewlineDelimitedArrayProperty(value=["blue", "red", "green"]) + result = client.property.newline_delimited(body) + assert result.value == ["blue", "red", "green"] From aeea07559de8d2edd885f8c828f307686cac50f8 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Thu, 11 Dec 2025 11:25:36 +0000 Subject: [PATCH 5/7] fix --- .../generator/pygen/codegen/templates/model_base.py.jinja2 | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 index 5445a2fc09b..08cc2bd9274 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 @@ -824,8 +824,6 @@ def _deserialize_sequence( return obj if isinstance(obj, ET.Element): obj = list(obj) - if isinstance(obj, str): - return deserializer(obj) try: if ( isinstance(obj, str) From 61fd62a7a5f36eab9527aa395dd6afda07895a63 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Thu, 11 Dec 2025 12:49:21 +0000 Subject: [PATCH 6/7] fix ci --- .../generator/pygen/codegen/templates/model_base.py.jinja2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 index 08cc2bd9274..db9ca646d99 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 @@ -829,7 +829,7 @@ def _deserialize_sequence( isinstance(obj, str) and isinstance(deserializer, functools.partial) and isinstance(deserializer.args[0], functools.partial) - and deserializer.args[0].func == _deserialize_array_encoded + and deserializer.args[0].func == _deserialize_array_encoded # pylint: disable=comparison-with-callable ): # encoded string may be deserialized to sequence return deserializer(obj) From e46bc0105b2790b7948f8cfa46b5aad044a34f04 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Fri, 12 Dec 2025 07:58:14 +0000 Subject: [PATCH 7/7] add unit test --- .../codegen/templates/model_base.py.jinja2 | 2 + .../test_model_base_serialization.py | 264 ++++++++++++++++++ 2 files changed, 266 insertions(+) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 index db9ca646d99..4211882d35d 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/model_base.py.jinja2 @@ -188,6 +188,8 @@ _ARRAY_ENCODE_MAPPING = { def _deserialize_array_encoded(delimit: str, attr): if isinstance(attr, str): + if attr == "": + return [] return attr.split(delimit) return attr diff --git a/packages/http-client-python/generator/test/unittests/test_model_base_serialization.py b/packages/http-client-python/generator/test/unittests/test_model_base_serialization.py index f971e59836f..4f45174a7aa 100644 --- a/packages/http-client-python/generator/test/unittests/test_model_base_serialization.py +++ b/packages/http-client-python/generator/test/unittests/test_model_base_serialization.py @@ -4108,3 +4108,267 @@ def test_multi_layer_discriminator(): assert AnotherPet(name="Buddy", trained=True) == model_pet assert AnotherDog(name="Rex", trained=True, breed="German Shepherd") == model_dog + + +def test_array_encode_comma_delimited(): + """Test commaDelimited format for array of strings""" + + class CommaDelimitedModel(Model): + colors: list[str] = rest_field(format="commaDelimited") + + @overload + def __init__(self, *, colors: list[str]): ... + + @overload + def __init__(self, mapping: Mapping[str, Any], /): ... + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Test serialization: list[str] -> comma-delimited string + model = CommaDelimitedModel(colors=["blue", "red", "green"]) + assert model.colors == ["blue", "red", "green"] + assert model["colors"] == "blue,red,green" + + # Test deserialization: comma-delimited string -> list[str] + model = CommaDelimitedModel({"colors": "blue,red,green"}) + assert model.colors == ["blue", "red", "green"] + assert model["colors"] == "blue,red,green" + + # Test with empty list + model = CommaDelimitedModel(colors=[]) + assert model.colors == [] + assert model["colors"] == "" + + # Test with single item + model = CommaDelimitedModel(colors=["blue"]) + assert model.colors == ["blue"] + assert model["colors"] == "blue" + + +def test_array_encode_pipe_delimited(): + """Test pipeDelimited format for array of strings""" + + class PipeDelimitedModel(Model): + colors: list[str] = rest_field(format="pipeDelimited") + + @overload + def __init__(self, *, colors: list[str]): ... + + @overload + def __init__(self, mapping: Mapping[str, Any], /): ... + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Test serialization: list[str] -> pipe-delimited string + model = PipeDelimitedModel(colors=["blue", "red", "green"]) + assert model.colors == ["blue", "red", "green"] + assert model["colors"] == "blue|red|green" + + # Test deserialization: pipe-delimited string -> list[str] + model = PipeDelimitedModel({"colors": "blue|red|green"}) + assert model.colors == ["blue", "red", "green"] + assert model["colors"] == "blue|red|green" + + # Test with empty list + model = PipeDelimitedModel(colors=[]) + assert model.colors == [] + assert model["colors"] == "" + + +def test_array_encode_space_delimited(): + """Test spaceDelimited format for array of strings""" + + class SpaceDelimitedModel(Model): + colors: list[str] = rest_field(format="spaceDelimited") + + @overload + def __init__(self, *, colors: list[str]): ... + + @overload + def __init__(self, mapping: Mapping[str, Any], /): ... + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Test serialization: list[str] -> space-delimited string + model = SpaceDelimitedModel(colors=["blue", "red", "green"]) + assert model.colors == ["blue", "red", "green"] + assert model["colors"] == "blue red green" + + # Test deserialization: space-delimited string -> list[str] + model = SpaceDelimitedModel({"colors": "blue red green"}) + assert model.colors == ["blue", "red", "green"] + assert model["colors"] == "blue red green" + + # Test with empty list + model = SpaceDelimitedModel(colors=[]) + assert model.colors == [] + assert model["colors"] == "" + + +def test_array_encode_newline_delimited(): + """Test newlineDelimited format for array of strings""" + + class NewlineDelimitedModel(Model): + colors: list[str] = rest_field(format="newlineDelimited") + + @overload + def __init__(self, *, colors: list[str]): ... + + @overload + def __init__(self, mapping: Mapping[str, Any], /): ... + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Test serialization: list[str] -> newline-delimited string + model = NewlineDelimitedModel(colors=["blue", "red", "green"]) + assert model.colors == ["blue", "red", "green"] + assert model["colors"] == "blue\nred\ngreen" + + # Test deserialization: newline-delimited string -> list[str] + model = NewlineDelimitedModel({"colors": "blue\nred\ngreen"}) + assert model.colors == ["blue", "red", "green"] + assert model["colors"] == "blue\nred\ngreen" + + # Test with empty list + model = NewlineDelimitedModel(colors=[]) + assert model.colors == [] + assert model["colors"] == "" + + +def test_array_encode_optional(): + """Test array encoding with optional fields""" + + class OptionalEncodedModel(Model): + colors: Optional[list[str]] = rest_field(default=None, format="commaDelimited") + + @overload + def __init__(self, *, colors: Optional[list[str]] = None): ... + + @overload + def __init__(self, mapping: Mapping[str, Any], /): ... + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Test with None + model = OptionalEncodedModel(colors=None) + assert model.colors is None + assert model["colors"] is None + + # Test with value + model = OptionalEncodedModel(colors=["blue", "red"]) + assert model.colors == ["blue", "red"] + assert model["colors"] == "blue,red" + + # Test deserialization with None + model = OptionalEncodedModel({"colors": None}) + assert model.colors is None + + +def test_array_encode_modification(): + """Test modifying array-encoded fields""" + + class ModifiableModel(Model): + colors: list[str] = rest_field(format="commaDelimited") + + @overload + def __init__(self, *, colors: list[str]): ... + + @overload + def __init__(self, mapping: Mapping[str, Any], /): ... + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + model = ModifiableModel(colors=["blue", "red"]) + assert model.colors == ["blue", "red"] + assert model["colors"] == "blue,red" + + # Modify through property + model.colors = ["green", "yellow", "purple"] + assert model.colors == ["green", "yellow", "purple"] + assert model["colors"] == "green,yellow,purple" + + # Modify through dict access + model["colors"] = "orange,pink" + assert model.colors == ["orange", "pink"] + assert model["colors"] == "orange,pink" + + +def test_array_encode_json_roundtrip(): + """Test JSON serialization and deserialization with array encoding""" + + class JsonModel(Model): + pipe_colors: list[str] = rest_field(name="pipeColors", format="pipeDelimited") + comma_colors: list[str] = rest_field(name="commaColors", format="commaDelimited") + + @overload + def __init__( + self, + *, + pipe_colors: list[str], + comma_colors: list[str], + ): ... + + @overload + def __init__(self, mapping: Mapping[str, Any], /): ... + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + model = JsonModel( + pipe_colors=["blue", "red", "green"], + comma_colors=["small", "medium", "large"], + ) + + # Serialize to JSON + json_str = json.dumps(dict(model), cls=SdkJSONEncoder) + assert json.loads(json_str) == { + "pipeColors": "blue|red|green", + "commaColors": "small,medium,large", + } + + # Deserialize from JSON + deserialized = JsonModel(json.loads(json_str)) + assert deserialized.pipe_colors == ["blue", "red", "green"] + assert deserialized.comma_colors == ["small", "medium", "large"] + + +def test_array_encode_with_special_characters(): + """Test array encoding with strings containing special characters""" + + class SpecialCharsModel(Model): + comma_values: list[str] = rest_field(name="commaValues", format="commaDelimited") + pipe_values: list[str] = rest_field(name="pipeValues", format="pipeDelimited") + + @overload + def __init__( + self, + *, + comma_values: list[str], + pipe_values: list[str], + ): ... + + @overload + def __init__(self, mapping: Mapping[str, Any], /): ... + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Test with strings that might contain delimiters + # Note: In real usage, the strings should not contain the delimiter character + # This test documents current behavior + model = SpecialCharsModel( + comma_values=["value with spaces", "another-value", "value_3"], + pipe_values=["path/to/file", "another-path", "final.path"], + ) + + assert model.comma_values == ["value with spaces", "another-value", "value_3"] + assert model["commaValues"] == "value with spaces,another-value,value_3" + + assert model.pipe_values == ["path/to/file", "another-path", "final.path"] + assert model["pipeValues"] == "path/to/file|another-path|final.path"