From 46c9f0f87a60a11e81b021e13b2409a1750a6815 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Fri, 3 Jul 2026 16:25:52 +0800 Subject: [PATCH 1/5] [python] Fix enum member names corrupted by YAML 1.1/1.2 boundary Date-like TypeSpec labels (e.g. 2020-01-01) produce snake-cased enum member names such as 2020_01_01. js-yaml (YAML 1.2) dumps these unquoted, but the Python generator parses with PyYAML (YAML 1.1), which reads 2020_01_01 back as the integer 20200101, corrupting the name. Fix the root cause in the emitter by force-quoting string scalars when serializing the code model so names round-trip as strings, and keep pygen robust by coercing enum value names to str. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-python-non-string-description-2026-7-2.md | 7 +++++ .../http-client-python/emitter/src/emitter.ts | 4 +-- .../emitter/src/external-process.ts | 19 +++++++++++- .../emitter/test/external-process.test.ts | 31 +++++++++++++++++++ .../generator/pygen/preprocess/__init__.py | 19 ++++++++++-- .../tests/unit/test_add_to_description.py | 4 +++ .../tests/unit/test_name_converter.py | 29 +++++++++++++++++ 7 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 .chronus/changes/http-client-python-non-string-description-2026-7-2.md create mode 100644 packages/http-client-python/emitter/test/external-process.test.ts diff --git a/.chronus/changes/http-client-python-non-string-description-2026-7-2.md b/.chronus/changes/http-client-python-non-string-description-2026-7-2.md new file mode 100644 index 00000000000..6610558ab44 --- /dev/null +++ b/.chronus/changes/http-client-python-non-string-description-2026-7-2.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +Fix enum member names derived from date-like TypeSpec labels (e.g. `` `2020-01-01` ``) being corrupted by the js-yaml (YAML 1.2) to PyYAML (YAML 1.1) boundary. String scalars are now force-quoted when serializing the code model so names such as `2020_01_01` round-trip as strings instead of being read back as integers. diff --git a/packages/http-client-python/emitter/src/emitter.ts b/packages/http-client-python/emitter/src/emitter.ts index ef58cecb204..c5d935e52cd 100644 --- a/packages/http-client-python/emitter/src/emitter.ts +++ b/packages/http-client-python/emitter/src/emitter.ts @@ -1,8 +1,8 @@ import { createSdkContext } from "@azure-tools/typespec-client-generator-core"; import { EmitContext, emitFile, joinPaths, NoTarget } from "@typespec/compiler"; -import jsyaml from "js-yaml"; import pkgJson from "../../package.json" with { type: "json" }; import { emitCodeModel } from "./code-model.js"; +import { dumpCodeModelToYaml } from "./external-process.js"; import { BLOB_STORAGE_BASE_URL, PACKAGE_NAME, @@ -250,7 +250,7 @@ async function onEmitMain(context: EmitContext) { pyodide.FS.mkdirTree("/yaml"); pyodide.FS.mkdirTree("/output"); clearMemfsDirectory(pyodide, "/output"); - pyodide.FS.writeFile(yamlFilePath, jsyaml.dump(parsedYamlMap)); + pyodide.FS.writeFile(yamlFilePath, dumpCodeModelToYaml(parsedYamlMap)); await runPyodideGeneration(pyodide, "/output", yamlFilePath, commandArgs); await copyPyodideOutputToHost(context, pyodide, "/output"); diff --git a/packages/http-client-python/emitter/src/external-process.ts b/packages/http-client-python/emitter/src/external-process.ts index 97a9125b44a..8c3b0486286 100644 --- a/packages/http-client-python/emitter/src/external-process.ts +++ b/packages/http-client-python/emitter/src/external-process.ts @@ -11,6 +11,23 @@ export function createTempPath(extension: string, prefix: string = "") { return joinPaths(tspCodeGenTempDir, prefix + randomUUID() + extension); } +/** + * Serialize the given codemodel to a YAML string. + * + * The generated YAML is consumed by the Python generator, which parses it with + * PyYAML (YAML 1.1). js-yaml, on the other hand, dumps using YAML 1.2 rules, so + * plain scalars such as `2020_01_01` (a snake-cased enum member name) are left + * unquoted because YAML 1.2 does not treat underscores as integer separators. + * PyYAML would then read `2020_01_01` back as the integer `20200101`, corrupting + * string values (e.g. enum member names, descriptions). Forcing every string + * scalar to be quoted guarantees that PyYAML round-trips them as strings. + * @param codemodel Codemodel to serialize + * @return the YAML representation of the codemodel. + */ +export function dumpCodeModelToYaml(codemodel: unknown): string { + return jsyaml.dump(codemodel, { forceQuotes: true, quotingType: '"' }); +} + /** * Save the given codemodel in a yaml file. * @param name Name of the codemodel. To give a guide to the temp file name. @@ -20,7 +37,7 @@ export function createTempPath(extension: string, prefix: string = "") { export async function saveCodeModelAsYaml(name: string, codemodel: unknown): Promise { await mkdir(tspCodeGenTempDir, { recursive: true }); const filename = createTempPath(".yaml", name); - const yamlStr = jsyaml.dump(codemodel); + const yamlStr = dumpCodeModelToYaml(codemodel); await writeFile(filename, yamlStr); return filename; } diff --git a/packages/http-client-python/emitter/test/external-process.test.ts b/packages/http-client-python/emitter/test/external-process.test.ts new file mode 100644 index 00000000000..e0b6ca92bf2 --- /dev/null +++ b/packages/http-client-python/emitter/test/external-process.test.ts @@ -0,0 +1,31 @@ +import { load } from "js-yaml"; +import { ok, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { dumpCodeModelToYaml } from "../src/external-process.js"; + +describe("typespec-python: external-process", () => { + // The Python generator parses the emitted YAML with PyYAML (YAML 1.1), where a plain + // scalar such as `2020_01_01` is interpreted as the integer `20200101`. js-yaml dumps + // using YAML 1.2 rules and would otherwise leave such string scalars unquoted, so we + // must force-quote strings to keep enum member names (and other string values) intact. + it("force-quotes string scalars that YAML 1.1 would misinterpret", () => { + const yaml = dumpCodeModelToYaml({ name: "2020_01_01" }); + // The scalar must be quoted, otherwise PyYAML reads it back as the integer 20200101. + ok( + yaml.includes('"2020_01_01"'), + `expected the underscore scalar to be quoted, got: ${yaml}`, + ); + ok( + !/name:\s*2020_01_01\s*$/m.test(yaml), + `expected no unquoted underscore scalar, got: ${yaml}`, + ); + }); + + it("keeps string values as strings after a round-trip", () => { + const codeModel = { name: "2020_01_01", value: "2020-01-01", plain: "hello" }; + const roundTripped = load(dumpCodeModelToYaml(codeModel)) as Record; + strictEqual(roundTripped.name, "2020_01_01"); + strictEqual(roundTripped.value, "2020-01-01"); + strictEqual(roundTripped.plain, "hello"); + }); +}); diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 6961d890a2d..88e7ba95e62 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -107,10 +107,11 @@ def add_overloads_for_body_param(yaml_data: dict[str, Any]) -> None: content_type_param["optional"] = True -def update_description(description: Optional[str], default_description: str = "") -> str: +def update_description(description: Any, default_description: str = "") -> str: + """Normalize YAML descriptions; numeric and other scalar values are converted to strings.""" if not description: description = default_description - description.rstrip(" ") + description = str(description).rstrip(" ") # Don't append a trailing period when the description ends with a code block: the # period would land inside the rendered literal block (e.g. "]." ) and break Sphinx. if description and description[-1] != "." and not description_ends_with_code_block(description): @@ -118,6 +119,17 @@ def update_description(description: Optional[str], default_description: str = "" return description +def update_enum_value_name(name: Any) -> str: + """Coerce an enum value name to a string. + + The emitter force-quotes string scalars so names round-trip correctly, but a name may + still arrive as a non-string (e.g. from an older emitter that emitted `2020_01_01`, + which PyYAML reads as the integer 20200101). Coercing to ``str`` keeps the downstream + casing logic (``.upper()``, ``ENUM_`` prefixing) robust. + """ + return str(name) + + def update_operation_group_class_name(prefix: str, class_name: str) -> str: if class_name == "": return prefix + "OperationsMixin" @@ -420,7 +432,6 @@ def add_body_param_type( code_model["types"].append(body_parameter["type"]) - def pad_reserved_words(self, name: str, pad_type: PadType, yaml_type: dict[str, Any]) -> str: # we want to pad hidden variables as well if not name: @@ -452,6 +463,7 @@ def update_types(self, yaml_data: list[dict[str, Any]]) -> None: ) add_redefined_builtin_info(property["clientName"], property) if type.get("name"): + type["name"] = update_enum_value_name(type["name"]) pad_type = PadType.MODEL if type["type"] == "model" else PadType.ENUM_CLASS if type["type"] != "enumvalue": name = self.pad_reserved_words(type["name"], pad_type, type) @@ -463,6 +475,7 @@ def update_types(self, yaml_data: list[dict[str, Any]]) -> None: for value in type["values"]: if value.get("isExactName", False): continue + value["name"] = update_enum_value_name(value["name"]) upper_name = value["name"].upper() if upper_name[0] in "0123456789": upper_name = "ENUM_" + upper_name diff --git a/packages/http-client-python/tests/unit/test_add_to_description.py b/packages/http-client-python/tests/unit/test_add_to_description.py index 70e2408badf..e7e9cfcd735 100644 --- a/packages/http-client-python/tests/unit/test_add_to_description.py +++ b/packages/http-client-python/tests/unit/test_add_to_description.py @@ -126,6 +126,10 @@ def test_update_description_still_adds_period_for_plain_text() -> None: assert update_description("The tools.") == "The tools." +def test_update_description_accepts_non_string_description() -> None: + assert update_description(1) == "1." + + def test_inline_code_block_mention_is_not_treated_as_trailing_block() -> None: # ".. code-block::" mentioned inline in a sentence is not a real directive, so the # description does not "end" with a code block: the trailing period is still added and diff --git a/packages/http-client-python/tests/unit/test_name_converter.py b/packages/http-client-python/tests/unit/test_name_converter.py index 88a10a20409..5c8086e9b14 100644 --- a/packages/http-client-python/tests/unit/test_name_converter.py +++ b/packages/http-client-python/tests/unit/test_name_converter.py @@ -33,3 +33,32 @@ def test_escaped_reserved_words(): } for name in expected_conversion_parameter: assert pad_reserved_words(name, pad_type=PadType.PARAMETER) == expected_conversion_parameter[name] + + +def test_update_types_preserves_string_enum_value_names(): + # With the emitter force-quoting string scalars, snake-cased date-like names arrive as + # strings (e.g. "2020_01_01") and must keep their separators through the ENUM_ prefixing. + standalone_enum_value = {"name": "2020_01_01", "isExactName": False, "type": "enumvalue"} + nested_enum_value = {"name": "2021_01_01", "isExactName": False, "type": "enumvalue", "value": "2021-01-01"} + enum_type = {"name": "ApiVersion", "type": "enum", "values": [nested_enum_value]} + + PreProcessPlugin(output_folder="").update_types([standalone_enum_value, enum_type]) + + assert standalone_enum_value["name"] == "2020_01_01" + assert standalone_enum_value["snakeCaseName"] == "2020_01_01" + assert nested_enum_value["name"] == "ENUM_2021_01_01" + + +def test_update_types_coerces_numeric_enum_value_names(): + # Defensive fallback: if a name still arrives as a non-string (e.g. from an older emitter + # that let PyYAML read "2020_01_01" as the integer 20200101), coerce it to a string so the + # casing/prefixing logic does not crash. + standalone_enum_value = {"name": 20200101, "isExactName": False, "type": "enumvalue"} + nested_enum_value = {"name": 20210101, "isExactName": False, "type": "enumvalue", "value": "2021-01-01"} + enum_type = {"name": "ApiVersion", "type": "enum", "values": [nested_enum_value]} + + PreProcessPlugin(output_folder="").update_types([standalone_enum_value, enum_type]) + + assert standalone_enum_value["description"] == "20200101." + assert standalone_enum_value["snakeCaseName"] == "20200101" + assert nested_enum_value["name"] == "ENUM_20210101" From b096260035b82a4739c2922658dd0efd160a2c31 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Fri, 3 Jul 2026 16:32:12 +0800 Subject: [PATCH 2/5] Remove now-unnecessary update_enum_value_name pygen workaround With the emitter force-quoting string scalars, enum member names always round-trip as strings, so the defensive name coercion (and its tests) are no longer needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generator/pygen/preprocess/__init__.py | 14 +-------- .../tests/unit/test_name_converter.py | 29 ------------------- 2 files changed, 1 insertion(+), 42 deletions(-) diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 88e7ba95e62..6bb251ec230 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -119,17 +119,6 @@ def update_description(description: Any, default_description: str = "") -> str: return description -def update_enum_value_name(name: Any) -> str: - """Coerce an enum value name to a string. - - The emitter force-quotes string scalars so names round-trip correctly, but a name may - still arrive as a non-string (e.g. from an older emitter that emitted `2020_01_01`, - which PyYAML reads as the integer 20200101). Coercing to ``str`` keeps the downstream - casing logic (``.upper()``, ``ENUM_`` prefixing) robust. - """ - return str(name) - - def update_operation_group_class_name(prefix: str, class_name: str) -> str: if class_name == "": return prefix + "OperationsMixin" @@ -432,6 +421,7 @@ def add_body_param_type( code_model["types"].append(body_parameter["type"]) + def pad_reserved_words(self, name: str, pad_type: PadType, yaml_type: dict[str, Any]) -> str: # we want to pad hidden variables as well if not name: @@ -463,7 +453,6 @@ def update_types(self, yaml_data: list[dict[str, Any]]) -> None: ) add_redefined_builtin_info(property["clientName"], property) if type.get("name"): - type["name"] = update_enum_value_name(type["name"]) pad_type = PadType.MODEL if type["type"] == "model" else PadType.ENUM_CLASS if type["type"] != "enumvalue": name = self.pad_reserved_words(type["name"], pad_type, type) @@ -475,7 +464,6 @@ def update_types(self, yaml_data: list[dict[str, Any]]) -> None: for value in type["values"]: if value.get("isExactName", False): continue - value["name"] = update_enum_value_name(value["name"]) upper_name = value["name"].upper() if upper_name[0] in "0123456789": upper_name = "ENUM_" + upper_name diff --git a/packages/http-client-python/tests/unit/test_name_converter.py b/packages/http-client-python/tests/unit/test_name_converter.py index 5c8086e9b14..88a10a20409 100644 --- a/packages/http-client-python/tests/unit/test_name_converter.py +++ b/packages/http-client-python/tests/unit/test_name_converter.py @@ -33,32 +33,3 @@ def test_escaped_reserved_words(): } for name in expected_conversion_parameter: assert pad_reserved_words(name, pad_type=PadType.PARAMETER) == expected_conversion_parameter[name] - - -def test_update_types_preserves_string_enum_value_names(): - # With the emitter force-quoting string scalars, snake-cased date-like names arrive as - # strings (e.g. "2020_01_01") and must keep their separators through the ENUM_ prefixing. - standalone_enum_value = {"name": "2020_01_01", "isExactName": False, "type": "enumvalue"} - nested_enum_value = {"name": "2021_01_01", "isExactName": False, "type": "enumvalue", "value": "2021-01-01"} - enum_type = {"name": "ApiVersion", "type": "enum", "values": [nested_enum_value]} - - PreProcessPlugin(output_folder="").update_types([standalone_enum_value, enum_type]) - - assert standalone_enum_value["name"] == "2020_01_01" - assert standalone_enum_value["snakeCaseName"] == "2020_01_01" - assert nested_enum_value["name"] == "ENUM_2021_01_01" - - -def test_update_types_coerces_numeric_enum_value_names(): - # Defensive fallback: if a name still arrives as a non-string (e.g. from an older emitter - # that let PyYAML read "2020_01_01" as the integer 20200101), coerce it to a string so the - # casing/prefixing logic does not crash. - standalone_enum_value = {"name": 20200101, "isExactName": False, "type": "enumvalue"} - nested_enum_value = {"name": 20210101, "isExactName": False, "type": "enumvalue", "value": "2021-01-01"} - enum_type = {"name": "ApiVersion", "type": "enum", "values": [nested_enum_value]} - - PreProcessPlugin(output_folder="").update_types([standalone_enum_value, enum_type]) - - assert standalone_enum_value["description"] == "20200101." - assert standalone_enum_value["snakeCaseName"] == "20200101" - assert nested_enum_value["name"] == "ENUM_20210101" From 9c8ebe0095dfc649dac5c8e37b4e033008be06ca Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Fri, 3 Jul 2026 16:45:47 +0800 Subject: [PATCH 3/5] Revert pygen defensive workarounds; keep emitter root-cause fix only The emitter force-quoting fix makes the non-string description/enum-name handling from #11143 unnecessary, so revert those pygen changes and their tests, leaving a focused emitter-only fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generator/pygen/preprocess/__init__.py | 5 ++--- .../http-client-python/tests/unit/test_add_to_description.py | 4 ---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 6bb251ec230..6961d890a2d 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -107,11 +107,10 @@ def add_overloads_for_body_param(yaml_data: dict[str, Any]) -> None: content_type_param["optional"] = True -def update_description(description: Any, default_description: str = "") -> str: - """Normalize YAML descriptions; numeric and other scalar values are converted to strings.""" +def update_description(description: Optional[str], default_description: str = "") -> str: if not description: description = default_description - description = str(description).rstrip(" ") + description.rstrip(" ") # Don't append a trailing period when the description ends with a code block: the # period would land inside the rendered literal block (e.g. "]." ) and break Sphinx. if description and description[-1] != "." and not description_ends_with_code_block(description): diff --git a/packages/http-client-python/tests/unit/test_add_to_description.py b/packages/http-client-python/tests/unit/test_add_to_description.py index e7e9cfcd735..70e2408badf 100644 --- a/packages/http-client-python/tests/unit/test_add_to_description.py +++ b/packages/http-client-python/tests/unit/test_add_to_description.py @@ -126,10 +126,6 @@ def test_update_description_still_adds_period_for_plain_text() -> None: assert update_description("The tools.") == "The tools." -def test_update_description_accepts_non_string_description() -> None: - assert update_description(1) == "1." - - def test_inline_code_block_mention_is_not_treated_as_trailing_block() -> None: # ".. code-block::" mentioned inline in a sentence is not a real directive, so the # description does not "end" with a code block: the trailing period is still added and From 517b59d1a5f3a2c7efa5d3a1709b7addb5d4b358 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Fri, 3 Jul 2026 16:48:52 +0800 Subject: [PATCH 4/5] Fix prettier formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/http-client-python/emitter/src/emitter.ts | 2 +- .../emitter/test/external-process.test.ts | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/http-client-python/emitter/src/emitter.ts b/packages/http-client-python/emitter/src/emitter.ts index c5d935e52cd..24ae9f97f46 100644 --- a/packages/http-client-python/emitter/src/emitter.ts +++ b/packages/http-client-python/emitter/src/emitter.ts @@ -2,13 +2,13 @@ import { createSdkContext } from "@azure-tools/typespec-client-generator-core"; import { EmitContext, emitFile, joinPaths, NoTarget } from "@typespec/compiler"; import pkgJson from "../../package.json" with { type: "json" }; import { emitCodeModel } from "./code-model.js"; -import { dumpCodeModelToYaml } from "./external-process.js"; import { BLOB_STORAGE_BASE_URL, PACKAGE_NAME, PYGEN_WHEEL_FILENAME, PYODIDE_VERSION, } from "./constants.js"; +import { dumpCodeModelToYaml } from "./external-process.js"; import { PythonEmitterOptions, PythonSdkContext, reportDiagnostic } from "./lib.js"; import { runNodeEmit } from "./node-runner.js"; import { loadPyodide, PyodideInterface } from "./pyodide-loader.js"; diff --git a/packages/http-client-python/emitter/test/external-process.test.ts b/packages/http-client-python/emitter/test/external-process.test.ts index e0b6ca92bf2..fd6a77135c3 100644 --- a/packages/http-client-python/emitter/test/external-process.test.ts +++ b/packages/http-client-python/emitter/test/external-process.test.ts @@ -1,5 +1,5 @@ -import { load } from "js-yaml"; import { ok, strictEqual } from "assert"; +import { load } from "js-yaml"; import { describe, it } from "vitest"; import { dumpCodeModelToYaml } from "../src/external-process.js"; @@ -11,10 +11,7 @@ describe("typespec-python: external-process", () => { it("force-quotes string scalars that YAML 1.1 would misinterpret", () => { const yaml = dumpCodeModelToYaml({ name: "2020_01_01" }); // The scalar must be quoted, otherwise PyYAML reads it back as the integer 20200101. - ok( - yaml.includes('"2020_01_01"'), - `expected the underscore scalar to be quoted, got: ${yaml}`, - ); + ok(yaml.includes('"2020_01_01"'), `expected the underscore scalar to be quoted, got: ${yaml}`); ok( !/name:\s*2020_01_01\s*$/m.test(yaml), `expected no unquoted underscore scalar, got: ${yaml}`, From 6665a321932cc6c62cb295224888a2aa7f8cd3f0 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Fri, 3 Jul 2026 16:52:27 +0800 Subject: [PATCH 5/5] Update http-client-python-non-string-description-2026-7-2.md --- .../http-client-python-non-string-description-2026-7-2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.chronus/changes/http-client-python-non-string-description-2026-7-2.md b/.chronus/changes/http-client-python-non-string-description-2026-7-2.md index 6610558ab44..d4f98f7dc45 100644 --- a/.chronus/changes/http-client-python-non-string-description-2026-7-2.md +++ b/.chronus/changes/http-client-python-non-string-description-2026-7-2.md @@ -4,4 +4,4 @@ packages: - "@typespec/http-client-python" --- -Fix enum member names derived from date-like TypeSpec labels (e.g. `` `2020-01-01` ``) being corrupted by the js-yaml (YAML 1.2) to PyYAML (YAML 1.1) boundary. String scalars are now force-quoted when serializing the code model so names such as `2020_01_01` round-trip as strings instead of being read back as integers. +Fix enum member names derived from date-like TypeSpec labels (e.g. `` `2020-01-01` ``) being corrupted by the js-yaml (YAML 1.2) to PyYAML (YAML 1.1) boundary. String scalars are now force-quoted when serializing the code model so names such as `2020_01_01` round-trip as strings instead of being read back as integers