diff --git a/.chronus/changes/fix-pprint-name-shell-quote-leak-2026-7-6-14-56-0.md b/.chronus/changes/fix-pprint-name-shell-quote-leak-2026-7-6-14-56-0.md new file mode 100644 index 00000000000..d63c8a80df2 --- /dev/null +++ b/.chronus/changes/fix-pprint-name-shell-quote-leak-2026-7-6-14-56-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +Fix Python SDK generation failure when `package-pprint-name` contains spaces. The shell-escaping quotes were baked into the option value and leaked into the Pyodide runtime, producing an invalid `setup.py` (e.g. `PACKAGE_PPRINT_NAME = ""Azure Web PubSub Chat Service""`) that `black` could not parse. Quoting is now applied only when building the native Python shell command. diff --git a/packages/http-client-python/emitter/src/emitter.ts b/packages/http-client-python/emitter/src/emitter.ts index ef58cecb204..982096c91b3 100644 --- a/packages/http-client-python/emitter/src/emitter.ts +++ b/packages/http-client-python/emitter/src/emitter.ts @@ -42,18 +42,6 @@ function addDefaultOptions(sdkContext: PythonSdkContext) { // Explicitly set unbranded flavor when not azure (options as any).flavor = "unbranded"; } - - if ( - options["package-pprint-name"] !== undefined && - !options["package-pprint-name"].startsWith('"') - ) { - // Only add quotes for shell compatibility when NOT using emit-yaml-only mode - // (emit-yaml-only passes options via JSON config files, not shell) - const needsShellQuoting = !options["use-pyodide"] && !options["emit-yaml-only"]; - options["package-pprint-name"] = needsShellQuoting - ? `"${options["package-pprint-name"]}"` - : `${options["package-pprint-name"]}`; - } } async function createPythonSdkContext( diff --git a/packages/http-client-python/emitter/src/node-runner.ts b/packages/http-client-python/emitter/src/node-runner.ts index 47af1553517..1d75b7c4c59 100644 --- a/packages/http-client-python/emitter/src/node-runner.ts +++ b/packages/http-client-python/emitter/src/node-runner.ts @@ -16,6 +16,7 @@ import { blackExcludeDirs, PYGEN_WHEEL_FILENAME } from "./constants.js"; import { saveCodeModelAsYaml } from "./external-process.js"; import { PythonEmitterOptions, reportDiagnostic } from "./lib.js"; import { runPython3 } from "./run-python3.js"; +import { quoteShellArg } from "./utils.js"; export interface RunNodeEmitArgs { context: EmitContext; @@ -106,7 +107,7 @@ export async function runNodeEmit({ commandArgs["output-folder"] = outputDir; commandArgs["tsp-file"] = yamlPath; const commandFlags = Object.entries(commandArgs) - .map(([key, value]) => `--${key}=${value}`) + .map(([key, value]) => `--${key}=${quoteShellArg(String(value))}`) .join(" "); const command = `${venvPath} ${root}/eng/scripts/setup/run_tsp.py ${commandFlags}`; execSync(command); diff --git a/packages/http-client-python/emitter/src/utils.ts b/packages/http-client-python/emitter/src/utils.ts index 31d819015ed..2f4c22ee57e 100644 --- a/packages/http-client-python/emitter/src/utils.ts +++ b/packages/http-client-python/emitter/src/utils.ts @@ -268,6 +268,19 @@ export function capitalize(name: string): string { return name[0].toUpperCase() + name.slice(1); } +/** + * Quotes a value so it can be safely embedded in a shell command line. + * + * The value is wrapped in double quotes (handling spaces and other separators) + * and any embedded double quotes are escaped. This quoting must only be applied + * when the value is passed through a shell (e.g. `execSync`); it must never be + * baked into the option value itself, otherwise the quotes leak into non-shell + * consumers such as the Pyodide runtime and end up in generated files. + */ +export function quoteShellArg(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + // Library namespaces that should not be used as client namespaces const LIB_NAMESPACE = [ "azure.core", diff --git a/packages/http-client-python/emitter/test/utils.test.ts b/packages/http-client-python/emitter/test/utils.test.ts index 733f5258a04..332bf597e76 100644 --- a/packages/http-client-python/emitter/test/utils.test.ts +++ b/packages/http-client-python/emitter/test/utils.test.ts @@ -1,6 +1,6 @@ import { strictEqual } from "assert"; import { describe, it } from "vitest"; -import { camelToSnakeCase, md2Rst } from "../src/utils.js"; +import { camelToSnakeCase, md2Rst, quoteShellArg } from "../src/utils.js"; describe("typespec-python: utils", () => { it("camelToSnakeCase", async () => { @@ -27,4 +27,15 @@ describe("typespec-python: utils", () => { const des = "Format: .."; strictEqual(md2Rst(des), "Format: .."); }); + + it("quoteShellArg wraps values in double quotes without doubling existing content", async () => { + // Regression test for https://github.com/microsoft/typespec Python SDK generation failure + // where a package-pprint-name with spaces (e.g. "Azure Web PubSub Chat Service") got quotes + // baked into the option value and then leaked into the generated setup.py as doubled quotes. + strictEqual(quoteShellArg("Azure Web PubSub Chat Service"), '"Azure Web PubSub Chat Service"'); + strictEqual(quoteShellArg("simple"), '"simple"'); + strictEqual(quoteShellArg('has "quotes"'), '"has \\"quotes\\""'); + strictEqual(quoteShellArg("trailing\\"), '"trailing\\\\"'); + strictEqual(quoteShellArg('back\\slash "q"'), '"back\\\\slash \\"q\\""'); + }); }); diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 6961d890a2d..8441a03bd77 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -420,7 +420,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: