From 81659137b2d21c5926f726046a17970e6acfe12f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E5=B1=B1?= Date: Fri, 31 Jul 2026 16:34:57 +0800 Subject: [PATCH] Add Python keep-property-names option --- .../src/language/Python/PythonRenderer.ts | 19 ++++++-- .../src/language/Python/language.ts | 5 ++ .../src/language/Python/utils.ts | 26 ++++++++++ test/unit/python-property-names.test.ts | 48 +++++++++++++++++++ 4 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 test/unit/python-property-names.test.ts diff --git a/packages/quicktype-core/src/language/Python/PythonRenderer.ts b/packages/quicktype-core/src/language/Python/PythonRenderer.ts index 1a5a35ed25..44dc86ce1b 100644 --- a/packages/quicktype-core/src/language/Python/PythonRenderer.ts +++ b/packages/quicktype-core/src/language/Python/PythonRenderer.ts @@ -32,7 +32,11 @@ import { matchType, removeNullFromUnion } from "../../Type/TypeUtils.js"; import { forbiddenPropertyNames, forbiddenTypeNames } from "./constants.js"; import type { pythonOptions } from "./language.js"; -import { classNameStyle, snakeNameStyle } from "./utils.js"; +import { + classNameStyle, + isLegalPythonIdentifier, + snakeNameStyle, +} from "./utils.js"; export class PythonRenderer extends ConvenienceRenderer { private readonly imports: Map> = new Map(); @@ -68,9 +72,16 @@ export class PythonRenderer extends ConvenienceRenderer { } protected namerForObjectProperty(): Namer { - return funPrefixNamer("property", (s) => - snakeNameStyle(s, false, this.pyOptions.nicePropertyNames), - ); + return funPrefixNamer("property", (s) => { + if ( + this.pyOptions.keepPropertyNames && + isLegalPythonIdentifier(s) + ) { + return s; + } + + return snakeNameStyle(s, false, this.pyOptions.nicePropertyNames); + }); } protected makeUnionMemberNamer(): null { diff --git a/packages/quicktype-core/src/language/Python/language.ts b/packages/quicktype-core/src/language/Python/language.ts index d3e1ed48c9..552ffaf338 100644 --- a/packages/quicktype-core/src/language/Python/language.ts +++ b/packages/quicktype-core/src/language/Python/language.ts @@ -80,6 +80,11 @@ export const pythonOptions = { "Transform property names to be Pythonic", true, ), + keepPropertyNames: new BooleanOption( + "keep-property-names", + "Keep original property names when they are valid Python identifiers", + false, + ), pydanticBaseModel: new BooleanOption( "pydantic-base-model", "Uses pydantic BaseModel", diff --git a/packages/quicktype-core/src/language/Python/utils.ts b/packages/quicktype-core/src/language/Python/utils.ts index db3c6dae86..7d8b7d73fe 100644 --- a/packages/quicktype-core/src/language/Python/utils.ts +++ b/packages/quicktype-core/src/language/Python/utils.ts @@ -44,6 +44,32 @@ function isPartCharacter3(utf16Unit: number): boolean { return true; } +function isPythonIdentifierStart(utf16Unit: number): boolean { + return utf16Unit === 0x5f || isNormalizedStartCharacter3(utf16Unit); +} + +function isPythonIdentifierPart(utf16Unit: number): boolean { + return ( + isPythonIdentifierStart(utf16Unit) || + isNormalizedPartCharacter3(utf16Unit) + ); +} + +export function isLegalPythonIdentifier(original: string): boolean { + if ( + original.length === 0 || + !isPythonIdentifierStart(original.charCodeAt(0)) + ) { + return false; + } + + for (let i = 1; i < original.length; i++) { + if (!isPythonIdentifierPart(original.charCodeAt(i))) return false; + } + + return true; +} + const legalizeName3 = utf16LegalizeCharacters(isPartCharacter3); export function classNameStyle(original: string): string { diff --git a/test/unit/python-property-names.test.ts b/test/unit/python-property-names.test.ts new file mode 100644 index 0000000000..048933b023 --- /dev/null +++ b/test/unit/python-property-names.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "vitest"; + +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; + +const schema = JSON.stringify({ + type: "object", + properties: { + source_m3u8: { type: "string" }, + "has-dash": { type: "string" }, + }, +}); + +async function pythonFor(keepPropertyNames: boolean): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ name: "TopLevel", schema }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "python", + rendererOptions: { + "keep-property-names": keepPropertyNames, + }, + }); + + return result.lines.join("\n"); +} + +test("Python keeps the default naming behavior", async () => { + const output = await pythonFor(false); + + expect(output).toContain("source_m3_u8: str"); + expect(output).toContain("has_dash: str"); +}); + +test("Python can keep valid original property names", async () => { + const output = await pythonFor(true); + + expect(output).toContain("source_m3u8: str"); + expect(output).toContain("has_dash: str"); + expect(output).toContain('obj.get("source_m3u8")'); +});