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..d4f98f7dc45 --- /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..24ae9f97f46 100644 --- a/packages/http-client-python/emitter/src/emitter.ts +++ b/packages/http-client-python/emitter/src/emitter.ts @@ -1,6 +1,5 @@ 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 { @@ -9,6 +8,7 @@ import { 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"; @@ -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..fd6a77135c3 --- /dev/null +++ b/packages/http-client-python/emitter/test/external-process.test.ts @@ -0,0 +1,28 @@ +import { ok, strictEqual } from "assert"; +import { load } from "js-yaml"; +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"); + }); +});