From 327c6f786d8c8f7d1cec5c68269617acecc7e11e Mon Sep 17 00:00:00 2001 From: Lars Melchior Date: Wed, 22 Apr 2026 13:54:48 +0200 Subject: [PATCH 1/4] update gitignore for mac --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 76e33e1..5347700 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /build /yarn-error.log /dist +.DS_Store From 96d59094aeb9987c1f320ab6b4cf2e5072c7db7e Mon Sep 17 00:00:00 2001 From: Lars Melchior Date: Wed, 22 Apr 2026 13:55:24 +0200 Subject: [PATCH 2/4] use the workspace ts version --- .vscode/settings.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3c358ef --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "js/ts.tsdk.path": "node_modules/typescript/lib" +} \ No newline at end of file From 5dad165e1462e41c14acf989ddef1f857c801211 Mon Sep 17 00:00:00 2001 From: Lars Melchior Date: Wed, 22 Apr 2026 14:23:36 +0200 Subject: [PATCH 3/4] support arbitrary keys in dicts by falling back to a functional typed dict definition --- src/parseProperty.ts | 39 +++++++++++++++++++++++++++++++++ src/parseTypeDefinition.ts | 44 ++++++++++++++++++++++++++++---------- src/testing/dicts.test.ts | 36 ++++++++++++++++++++++++++++--- 3 files changed, 105 insertions(+), 14 deletions(-) diff --git a/src/parseProperty.ts b/src/parseProperty.ts index 431e72f..38492cf 100644 --- a/src/parseProperty.ts +++ b/src/parseProperty.ts @@ -34,3 +34,42 @@ export const parseProperty = (state: ParserState, symbol: ts.Symbol) => { return `${name}: ${definition}${documentationSuffix}`; } }; + +export const parsePropertyForDict = (state: ParserState, symbol: ts.Symbol) => { + const name = JSON.stringify(symbol.getName()); + if (symbol.flags & ts.SymbolFlags.Optional) { + state.imports.add("NotRequired"); + const definition = parseInlineType( + state, + // since the entry is already options, the inner type can be non-nullable + state.typechecker.getNonNullableType(state.typechecker.getTypeOfSymbol(symbol)), + ); + if (state.config.nullableOptionals) { + state.imports.add("Optional"); + return `${name}: NotRequired[Optional[${definition}]]`; + } else { + return `${name}: NotRequired[${definition}]`; + } + } else { + const definition = parseInlineType( + state, + // since the entry is already options, the inner type can be non-nullable + state.typechecker.getTypeOfSymbol(symbol), + ); + return `${name}: ${definition}`; + } +}; + +export const getDocumentationStringForDict = (state: ParserState, symbol: ts.Symbol) => { + const name = symbol.getName(); + const documentation = symbol + .getDocumentationComment(state.typechecker) + .map((v) => v.text) + .join(" \n"); + if (documentation.length > 0) { + return `${JSON.stringify(name)}: ${documentation}` + } else { + return undefined; + } +} + diff --git a/src/parseTypeDefinition.ts b/src/parseTypeDefinition.ts index 5d97bef..4ffcd75 100644 --- a/src/parseTypeDefinition.ts +++ b/src/parseTypeDefinition.ts @@ -1,6 +1,6 @@ import ts from "typescript"; import { ParserState } from "./ParserState"; -import { parseProperty } from "./parseProperty"; +import { getDocumentationStringForDict, parseProperty, parsePropertyForDict } from "./parseProperty"; import { getDocumentationStringForType } from "./getDocumentationStringForType"; import { tryToParseInlineType } from "./parseInlineType"; import { isValidPythonIdentifier } from "./isValidPythonIdentifier"; @@ -25,18 +25,40 @@ export const parseTypeDefinition = ( state.statements.push(definition); } else { state.imports.add("TypedDict"); - - const properties = type + + const allKeysAreValidPythonIdentifiers = type .getProperties() - .filter((v) => isValidPythonIdentifier(v.getName())) - .map((v) => parseProperty(state, v)); + .map((v) => isValidPythonIdentifier(v.getName())) + .reduce((a,b) => a&&b); - const definition = `class ${name}(TypedDict):${ - documentation - ? `\n """\n ${documentation.replaceAll("\n", " \n")}\n """` - : "" - }\n ${properties.length > 0 ? properties.join(`\n `) : "pass"}`; + if (allKeysAreValidPythonIdentifiers) { + const properties = type + .getProperties() + .map((v) => parseProperty(state, v)); + + const definition = `class ${name}(TypedDict):${ + documentation + ? `\n """\n ${documentation.replaceAll("\n", " \n")}\n """` + : "" + }\n ${properties.length > 0 ? properties.join(`\n `) : "pass"}`; + state.statements.push(definition); + } else { + const properties = type + .getProperties() + .map((v) => parsePropertyForDict(state, v)); + + const propertyDocumentation = type + .getProperties() + .map((v) => getDocumentationStringForDict(state, v)) + .filter(v => v !== undefined) + .join("\n"); + + const innerDocstring = documentation?.replaceAll("\n", " \n") + (propertyDocumentation.length > 0 ? "\n## Entries\n" + propertyDocumentation : ""); + const docstring = innerDocstring.length > 0 ? `\n"""\n${innerDocstring}\n"""` : ""; + const definition = `${name} = TypedDict(${JSON.stringify(name)}, { ${properties.join(", ")} })${docstring}`; + + state.statements.push(definition); + } - state.statements.push(definition); } }; diff --git a/src/testing/dicts.test.ts b/src/testing/dicts.test.ts index 4f810b9..1b3214d 100644 --- a/src/testing/dicts.test.ts +++ b/src/testing/dicts.test.ts @@ -59,12 +59,12 @@ class A(TypedDict): expect(result).toContain(`class A(TypedDict):\n foo: str\n bar: float`); }); - it("transpiles optional values as NotRequired[Optional[T]]", async () => { + it("transpiles optional values as NotRequired[T]", async () => { const result = await transpileString(`export type A = { foo?: string }`); expect(result).toContain(`class A(TypedDict):\n foo: NotRequired[str]`); }); - it("transpiles optional values as NotRequired[Optional[T]] in strict mode", async () => { + it("transpiles optional values as NotRequired[T] in strict mode", async () => { const result = await transpileString( `export type A = { foo?: string }`, {}, @@ -73,7 +73,7 @@ class A(TypedDict): expect(result).toContain(`class A(TypedDict):\n foo: NotRequired[str]`); }); - it("transpiles optional values with non-null optionals as NotRequired[T]", async () => { + it("transpiles optional values with non-null optionals as NotRequired[Optional[T]]", async () => { const result = await transpileString(`export type A = { foo?: string }`, { nullableOptionals: true, }); @@ -88,4 +88,34 @@ class A(TypedDict): ); expect(result).toContain(`class A(TypedDict):\n foo: float\n bar: float`); }); + + it("falls back to the functional syntax if keys are unsupported", async () => { + const result = await transpileString(`export type A = { + "foo.bar"?: string, + }`); + expect(result).toContain( + `A = TypedDict("A", { "foo.bar": NotRequired[str] })`, + ); + }); + + it("moves the key/value docstrings to the object docstring in the functional syntax", async () => { + const result = await transpileString(` + /** This is A */ + export type A = { + /** this is foo.bar */ + "foo.bar": string, + /** this is a/b */ + "a/b": number, + "undocumented": string, + } + `); + expect(result) + .toContain(`A = TypedDict("A", { "foo.bar": str, "a/b": float, "undocumented": str }) +""" +This is A +## Entries +"foo.bar": this is foo.bar +"a/b": this is a/b +"""`); + }); }); From a486efeb1962585a557412c50692b3a4244352b0 Mon Sep 17 00:00:00 2001 From: Lars Melchior Date: Wed, 22 Apr 2026 14:28:12 +0200 Subject: [PATCH 4/4] improve readability of functional dicts --- src/parseTypeDefinition.ts | 4 ++-- src/testing/dicts.test.ts | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/parseTypeDefinition.ts b/src/parseTypeDefinition.ts index 4ffcd75..f60e023 100644 --- a/src/parseTypeDefinition.ts +++ b/src/parseTypeDefinition.ts @@ -29,7 +29,7 @@ export const parseTypeDefinition = ( const allKeysAreValidPythonIdentifiers = type .getProperties() .map((v) => isValidPythonIdentifier(v.getName())) - .reduce((a,b) => a&&b); + .reduce((a,b) => a&&b, true); if (allKeysAreValidPythonIdentifiers) { const properties = type @@ -55,7 +55,7 @@ export const parseTypeDefinition = ( const innerDocstring = documentation?.replaceAll("\n", " \n") + (propertyDocumentation.length > 0 ? "\n## Entries\n" + propertyDocumentation : ""); const docstring = innerDocstring.length > 0 ? `\n"""\n${innerDocstring}\n"""` : ""; - const definition = `${name} = TypedDict(${JSON.stringify(name)}, { ${properties.join(", ")} })${docstring}`; + const definition = `${name} = TypedDict(${JSON.stringify(name)}, {\n ${properties.join(",\n ")}\n})${docstring}`; state.statements.push(definition); } diff --git a/src/testing/dicts.test.ts b/src/testing/dicts.test.ts index 1b3214d..0729825 100644 --- a/src/testing/dicts.test.ts +++ b/src/testing/dicts.test.ts @@ -94,7 +94,9 @@ class A(TypedDict): "foo.bar"?: string, }`); expect(result).toContain( - `A = TypedDict("A", { "foo.bar": NotRequired[str] })`, + `A = TypedDict("A", { + "foo.bar": NotRequired[str] +})`, ); }); @@ -109,8 +111,11 @@ class A(TypedDict): "undocumented": string, } `); - expect(result) - .toContain(`A = TypedDict("A", { "foo.bar": str, "a/b": float, "undocumented": str }) + expect(result).toContain(`A = TypedDict("A", { + "foo.bar": str, + "a/b": float, + "undocumented": str +}) """ This is A ## Entries