diff --git a/.chronus/changes/python-encode-boolean-test-2026-6-24-23-0-0.md b/.chronus/changes/python-encode-boolean-test-2026-6-24-23-0-0.md new file mode 100644 index 00000000000..957491f4c9e --- /dev/null +++ b/.chronus/changes/python-encode-boolean-test-2026-6-24-23-0-0.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http-client-python" +--- + +Add mock API test coverage for `@encode(string)` on boolean properties (`encode/boolean` Spector scenarios). Fix Python generator to correctly serialize and deserialize boolean values encoded as strings (case-insensitive `true`/`false`) diff --git a/packages/http-client-python/emitter/src/types.ts b/packages/http-client-python/emitter/src/types.ts index 3f1a629f257..97619c96b7b 100644 --- a/packages/http-client-python/emitter/src/types.ts +++ b/packages/http-client-python/emitter/src/types.ts @@ -16,7 +16,7 @@ import { SdkUnionType, UsageFlags, } from "@azure-tools/typespec-client-generator-core"; -import { Type } from "@typespec/compiler"; +import { getEncode, Type } from "@typespec/compiler"; import { HttpAuth, Visibility } from "@typespec/http"; import { dump } from "js-yaml"; import { PythonSdkContext } from "./lib.js"; @@ -234,6 +234,10 @@ function emitProperty( addDisableGenerationMap(context, property.type); } const isNullable = !isMultipartFileInput && sourceType.kind === "nullable"; + const booleanEncode = + property.type.kind === "boolean" && property.__raw + ? getEncode(context.program, property.__raw) + : undefined; return { clientName: getClientName(property), isExactName: property.isExactName, @@ -252,7 +256,7 @@ function emitProperty( flatten: property.flatten, isMultipartFileInput: isMultipartFileInput, xmlMetadata: getXmlMetadata(property), - encode: property.encode, + encode: property.encode ?? (booleanEncode?.type.name === "string" ? "str" : undefined), clientDefaultValue: property.clientDefaultValue, }; } diff --git a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts index 74c0c4a7457..630bee17c7c 100644 --- a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts +++ b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts @@ -184,6 +184,9 @@ export const AZURE_EMITTER_OPTIONS: Record< "client/overload": { namespace: "client.overload", }, + "encode/boolean": { + namespace: "encode.boolean", + }, "encode/duration": { namespace: "encode.duration", }, 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 6b4a4f8a444..e8a1d28c1f9 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 @@ -50,6 +50,11 @@ def default_template_representation_declaration(self) -> str: class BooleanType(PrimitiveType): + def __init__(self, yaml_data: dict[str, Any], code_model: "CodeModel") -> None: + super().__init__(yaml_data=yaml_data, code_model=code_model) + if yaml_data.get("encode") == "string": + self.encode = "str" + def serialization_type(self, **kwargs: Any) -> str: return "bool" 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 54821400d89..d6bb659a2ea 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 @@ -348,6 +348,12 @@ def _deserialize_int_as_str(attr): return int(attr) +def _deserialize_bool_as_str(attr): + if isinstance(attr, bool): + return attr + return attr.lower() == "true" + + _DESERIALIZE_MAPPING = { datetime: _deserialize_datetime, date: _deserialize_date, @@ -375,6 +381,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 bool and rf and rf._format == "str": + return _deserialize_bool_as_str 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: diff --git a/packages/http-client-python/tests/mock_api/azure/asynctests/test_encode_boolean_async.py b/packages/http-client-python/tests/mock_api/azure/asynctests/test_encode_boolean_async.py new file mode 100644 index 00000000000..3245fdb434e --- /dev/null +++ b/packages/http-client-python/tests/mock_api/azure/asynctests/test_encode_boolean_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 +import pytest_asyncio +from encode.boolean.aio import BooleanClient +from encode.boolean import models + + +@pytest_asyncio.fixture +async def client(): + async with BooleanClient() as client: + yield client + + +@pytest.mark.asyncio +async def test_property_true_lower(client: BooleanClient): + result = await client.property.true_lower(models.BoolAsStringProperty(value=True)) + assert result.value is True + assert result["value"] == "true" + + +@pytest.mark.asyncio +async def test_property_false_lower(client: BooleanClient): + result = await client.property.false_lower(models.BoolAsStringProperty(value=False)) + assert result.value is False + assert result["value"] == "false" + + +@pytest.mark.asyncio +async def test_property_true_upper(client: BooleanClient): + result = await client.property.true_upper(models.BoolAsStringProperty(value=True)) + assert result.value is True + assert result["value"] == "TRUE" + + +@pytest.mark.asyncio +async def test_property_false_mixed(client: BooleanClient): + result = await client.property.false_mixed(models.BoolAsStringProperty(value=False)) + assert result.value is False + assert result["value"] == "FaLsE" diff --git a/packages/http-client-python/tests/mock_api/azure/test_encode_boolean.py b/packages/http-client-python/tests/mock_api/azure/test_encode_boolean.py new file mode 100644 index 00000000000..28a89fed980 --- /dev/null +++ b/packages/http-client-python/tests/mock_api/azure/test_encode_boolean.py @@ -0,0 +1,37 @@ +# ------------------------------------------------------------------------- +# 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.boolean import BooleanClient, models + + +@pytest.fixture +def client(): + with BooleanClient() as client: + yield client + + +def test_property_true_lower(client: BooleanClient): + result = client.property.true_lower(models.BoolAsStringProperty(value=True)) + assert result.value is True + assert result["value"] == "true" + + +def test_property_false_lower(client: BooleanClient): + result = client.property.false_lower(models.BoolAsStringProperty(value=False)) + assert result.value is False + assert result["value"] == "false" + + +def test_property_true_upper(client: BooleanClient): + result = client.property.true_upper(models.BoolAsStringProperty(value=True)) + assert result.value is True + assert result["value"] == "TRUE" + + +def test_property_false_mixed(client: BooleanClient): + result = client.property.false_mixed(models.BoolAsStringProperty(value=False)) + assert result.value is False + assert result["value"] == "FaLsE" diff --git a/packages/http-client-python/tests/mock_api/unbranded/asynctests/test_encode_boolean_async.py b/packages/http-client-python/tests/mock_api/unbranded/asynctests/test_encode_boolean_async.py new file mode 100644 index 00000000000..7083622bb99 --- /dev/null +++ b/packages/http-client-python/tests/mock_api/unbranded/asynctests/test_encode_boolean_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 +import pytest_asyncio +from encode.boolean.aio import BooleanClient +from encode.boolean.property import models + + +@pytest_asyncio.fixture +async def client(): + async with BooleanClient() as client: + yield client + + +@pytest.mark.asyncio +async def test_property_true_lower(client: BooleanClient): + result = await client.property.true_lower(models.BoolAsStringProperty(value=True)) + assert result.value is True + assert result["value"] == "true" + + +@pytest.mark.asyncio +async def test_property_false_lower(client: BooleanClient): + result = await client.property.false_lower(models.BoolAsStringProperty(value=False)) + assert result.value is False + assert result["value"] == "false" + + +@pytest.mark.asyncio +async def test_property_true_upper(client: BooleanClient): + result = await client.property.true_upper(models.BoolAsStringProperty(value=True)) + assert result.value is True + assert result["value"] == "TRUE" + + +@pytest.mark.asyncio +async def test_property_false_mixed(client: BooleanClient): + result = await client.property.false_mixed(models.BoolAsStringProperty(value=False)) + assert result.value is False + assert result["value"] == "FaLsE" diff --git a/packages/http-client-python/tests/mock_api/unbranded/test_encode_boolean.py b/packages/http-client-python/tests/mock_api/unbranded/test_encode_boolean.py new file mode 100644 index 00000000000..0d98ed6416f --- /dev/null +++ b/packages/http-client-python/tests/mock_api/unbranded/test_encode_boolean.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.boolean import BooleanClient +from encode.boolean.property import models + + +@pytest.fixture +def client(): + with BooleanClient() as client: + yield client + + +def test_property_true_lower(client: BooleanClient): + result = client.property.true_lower(models.BoolAsStringProperty(value=True)) + assert result.value is True + assert result["value"] == "true" + + +def test_property_false_lower(client: BooleanClient): + result = client.property.false_lower(models.BoolAsStringProperty(value=False)) + assert result.value is False + assert result["value"] == "false" + + +def test_property_true_upper(client: BooleanClient): + result = client.property.true_upper(models.BoolAsStringProperty(value=True)) + assert result.value is True + assert result["value"] == "TRUE" + + +def test_property_false_mixed(client: BooleanClient): + result = client.property.false_mixed(models.BoolAsStringProperty(value=False)) + assert result.value is False + assert result["value"] == "FaLsE"