diff --git a/README.md b/README.md index f8c3a06..029541a 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,14 @@ export type TupleType = [string, Foo | Bar, any[]] ### TypeScript2Python ```python -from typing_extensions import Any, Dict, List, Literal, NotRequired, Optional, Tuple, TypedDict, Union +from typing_extensions import Any, Dict, List, Literal, NotRequired, Tuple, TypedDict, Union class Foo(TypedDict): type: Literal["foo"] foo: List[float] optional: NotRequired[str] -class Ts2PyHelperType1(TypedDict): +class Ts2Py_tliGTOBrDv(TypedDict): foo: Foo class Bar(TypedDict): @@ -56,7 +56,7 @@ class Bar(TypedDict): """ type: Literal["bar"] bar: str - nested: Ts2PyHelperType1 + nested: Ts2Py_tliGTOBrDv """ nested objects need extra declarations in Python """ diff --git a/src/ParserState.ts b/src/ParserState.ts index fccbad4..eedb654 100644 --- a/src/ParserState.ts +++ b/src/ParserState.ts @@ -2,10 +2,11 @@ import ts from "typescript"; import { Ts2PyConfig } from "./config"; export type ParserState = { - helperCount: number; statements: string[]; typechecker: ts.TypeChecker; knownTypes: Map; + helperTypeNames: Map; + canonicalTypeNames: Map; imports: Set; config: Ts2PyConfig; }; @@ -20,10 +21,11 @@ export const createNewParserState = (typechecker: ts.TypeChecker, config: Ts2PyC return { statements: [], - helperCount: 0, typechecker, knownTypes, imports: new Set(), + helperTypeNames: new Map(), + canonicalTypeNames: new Map(), config, }; } diff --git a/src/canonicalTypeName.ts b/src/canonicalTypeName.ts new file mode 100644 index 0000000..cae36a3 --- /dev/null +++ b/src/canonicalTypeName.ts @@ -0,0 +1,22 @@ +import { createNewParserState, ParserState } from "./ParserState"; +import ts from "typescript"; +import { parseTypeDefinition } from "./parseTypeDefinition"; + +/** + * A function that creates a unique string for a given interface or object type, + * from a fresh parser state. This should return the same string for two semantically + * identically types, allowing us to re-use existing helper types if the generated + * strings match. + **/ +export const getCanonicalTypeName = (state: ParserState, type: ts.Type) => { + const cachedName = state.canonicalTypeNames.get(type); + if (cachedName) { + return cachedName; + } else { + const tmpState = createNewParserState(state.typechecker, state.config); + parseTypeDefinition(tmpState, "TS2PyTmpType", type); + const result = tmpState.statements.join("\n"); + state.canonicalTypeNames.set(type, result); + return result; + } +} diff --git a/src/newHelperTypeName.ts b/src/newHelperTypeName.ts index 3d7d87b..2fc5670 100644 --- a/src/newHelperTypeName.ts +++ b/src/newHelperTypeName.ts @@ -1,7 +1,18 @@ import { ParserState } from "./ParserState"; import ts from "typescript"; +import { createHash } from "node:crypto" +import { getCanonicalTypeName } from "./canonicalTypeName"; -export const newHelperTypeName = (state: ParserState, type?: ts.Type) => { - const typeName = type?.aliasSymbol?.getName() ?? ""; - return `Ts2Py${typeName}HelperType${++state.helperCount}`; +export const newHelperTypeName = (state: ParserState, type: ts.Type) => { + // to keep helper type names predictable and not dependent on the order of definition, + // we use the first 10 characters of a sha256 hash of the type. If there is an unexpected + // collision, we fallback to using an incrementing counter. + const fullHash = createHash("sha256").update(getCanonicalTypeName(state, type)); + // for the short hash, we remove all non-alphanumeric characters from the hash and take the + // first 10 characters. + let shortHash = fullHash.digest("base64").replace(/\W/g, '').substring(0, 10); + if (state.helperTypeNames.has(shortHash) && state.helperTypeNames.get(shortHash) !== type) { + shortHash = "HelperType" + state.helperTypeNames.size.toString(); + } + return `Ts2Py_${shortHash}`; }; diff --git a/src/parseInlineType.ts b/src/parseInlineType.ts index d74383f..7c4be52 100644 --- a/src/parseInlineType.ts +++ b/src/parseInlineType.ts @@ -1,7 +1,8 @@ import ts, { TypeFlags } from "typescript"; -import { ParserState, createNewParserState } from "./ParserState"; +import { ParserState } from "./ParserState"; import { newHelperTypeName } from "./newHelperTypeName"; import { parseTypeDefinition } from "./parseTypeDefinition"; +import { getCanonicalTypeName } from "./canonicalTypeName"; export const parseInlineType = (state: ParserState, type: ts.Type) => { const result = tryToParseInlineType(state, type); @@ -12,18 +13,6 @@ export const parseInlineType = (state: ParserState, type: ts.Type) => { } }; -/** - * A function that creates a unique string for a given interface or object type, - * from a fresh parser state. This should return the same string for two semantically - * identically types, allowing us to re-use existing helper types if the generated - * strings match. - **/ -const getCanonicalTypeName = (state: ParserState, type: ts.Type) => { - const tmpState = createNewParserState(state.typechecker, state.config); - parseTypeDefinition(tmpState, "TS2PyTmpType", type); - return tmpState.statements.join("\n") -} - export const tryToParseInlineType = ( state: ParserState, type: ts.Type, diff --git a/src/testing/basic.test.ts b/src/testing/basic.test.ts index 34acdb3..73c1757 100644 --- a/src/testing/basic.test.ts +++ b/src/testing/basic.test.ts @@ -73,5 +73,3 @@ describe("transpiling basic types", () => { expect(result).toContain("Exported = float"); }); }); - -export type T = Record; diff --git a/src/testing/dicts.test.ts b/src/testing/dicts.test.ts index 1676c0c..73279b3 100644 --- a/src/testing/dicts.test.ts +++ b/src/testing/dicts.test.ts @@ -43,11 +43,11 @@ describe("transpiling dictionaries types", () => { extra: number, }`); expect(result).toContain( - `class Ts2PyHelperType1(TypedDict): + `class Ts2Py_FOZhdT9ykh(TypedDict): inner: str class A(TypedDict): - outer: Ts2PyHelperType1 + outer: Ts2Py_FOZhdT9ykh extra: float`, ); }); diff --git a/src/testing/helperTypes.test.ts b/src/testing/helperTypes.test.ts index 0d2c2ca..7e44025 100644 --- a/src/testing/helperTypes.test.ts +++ b/src/testing/helperTypes.test.ts @@ -9,14 +9,14 @@ describe("creating helper types", () => { expect(result).toEqual( `from typing_extensions import TypedDict -class Ts2PyHelperType2(TypedDict): +class Ts2Py_rTIa1O0osy(TypedDict): bar: str -class Ts2PyHelperType1(TypedDict): - foo: Ts2PyHelperType2 +class Ts2Py_v6EwABEDVq(TypedDict): + foo: Ts2Py_rTIa1O0osy class T(TypedDict): - inner: Ts2PyHelperType1`, + inner: Ts2Py_v6EwABEDVq`, ); }); @@ -29,19 +29,56 @@ class T(TypedDict): expect(result).toEqual(`from typing_extensions import TypedDict -class Ts2PyHelperType2(TypedDict): +class Ts2Py_rTIa1O0osy(TypedDict): bar: str -class Ts2PyHelperType1(TypedDict): - foo: Ts2PyHelperType2 +class Ts2Py_v6EwABEDVq(TypedDict): + foo: Ts2Py_rTIa1O0osy class A(TypedDict): - a: Ts2PyHelperType1 + a: Ts2Py_v6EwABEDVq class B(TypedDict): - b: Ts2PyHelperType1 + b: Ts2Py_v6EwABEDVq class C(TypedDict): - foo: Ts2PyHelperType2`); + foo: Ts2Py_rTIa1O0osy`); + }); + + it("always uses the same helper type names", async () => { + const result1 = await transpileString(` + export type A = { a: { foo: { bar: string } } } + `); + const result2 = await transpileString(` + export type B = { b: { bar: { foo: string } } } + export type A = { a: { foo: { bar: string } } } + `); + + // the type hashes will be the same, no matter if we define other helper types before + const expectedAType = `class Ts2Py_rTIa1O0osy(TypedDict): + bar: str + +class Ts2Py_v6EwABEDVq(TypedDict): + foo: Ts2Py_rTIa1O0osy + +class A(TypedDict): + a: Ts2Py_v6EwABEDVq`; + + expect(result1).toEqual(`from typing_extensions import TypedDict + +${expectedAType}`); + + expect(result2).toEqual(`from typing_extensions import TypedDict + +class Ts2Py_9ZFaik8GRM(TypedDict): + foo: str + +class Ts2Py_g2bOy1R1LY(TypedDict): + bar: Ts2Py_9ZFaik8GRM + +class B(TypedDict): + b: Ts2Py_g2bOy1R1LY + +${expectedAType}`); }); }); diff --git a/src/testing/imports.test.ts b/src/testing/imports.test.ts index d7c697c..6ef03ef 100644 --- a/src/testing/imports.test.ts +++ b/src/testing/imports.test.ts @@ -30,11 +30,56 @@ describe("transpiling referenced types", () => { expect(transpiled).toEqual( `from typing_extensions import TypedDict -class Ts2PyFooHelperType1(TypedDict): +class Ts2Py_vxK3pg8Yk2(TypedDict): foo: float class Bar(TypedDict): - foo: Ts2PyFooHelperType1`, + foo: Ts2Py_vxK3pg8Yk2`, ); }); + + it("doesn't get confused if imported types have the same name", async () => { + const project = await createProject(); + + project.createSourceFile( + "foo.ts", + ` + type Content = { publicFoo: "foo" }; + export type Foo = { foo: Content } + `, + ); + project.createSourceFile( + "bar.ts", + ` + type Content = { publicBar: "bar" }; + export type Bar = { bar: Content } + `, + ); + const commonSource = project.createSourceFile( + "common.ts", + ` + import {Foo} from './foo'; + import {Bar} from './bar'; + export type FooBar = { foo: Foo, bar: Bar } + `, + ); + const program = project.createProgram(); + const diagnostics = ts.getPreEmitDiagnostics(program); + + if (diagnostics.length > 0) { + throw new Error( + `code compiled with errors: ${project.formatDiagnosticsWithColorAndContext( + diagnostics, + )}`, + ); + } + + const transpiled = typeScriptToPython( + program.getTypeChecker(), + [commonSource], + {}, + ); + expect(transpiled).toContain(`publicFoo: Literal["foo"]`); + expect(transpiled).toContain(`publicBar: Literal["bar"]`); + }); }); diff --git a/src/testing/readme.test.ts b/src/testing/readme.test.ts new file mode 100644 index 0000000..92e282e --- /dev/null +++ b/src/testing/readme.test.ts @@ -0,0 +1,56 @@ +import { transpileString } from "./utils"; + +describe("readme", () => { + it("transpiles the readme example", async () => { + const result = await transpileString(` +export type Foo = { + type: "foo" + foo: number[] + optional?: string +} + +/** DocStrings are supported! */ +export type Bar = { + type: "bar" + bar: string + /** nested objects need extra declarations in Python */ + nested: { + foo: Foo + } +} + +export type FooBarMap = { + [key: string]: Foo | Bar +} + +export type TupleType = [string, Foo | Bar, any[]] + `); + + // note: if this needs to be updated, be sure to update the readme as well + expect(result) + .toEqual(`from typing_extensions import Any, Dict, List, Literal, NotRequired, Tuple, TypedDict, Union + +class Foo(TypedDict): + type: Literal["foo"] + foo: List[float] + optional: NotRequired[str] + +class Ts2Py_tliGTOBrDv(TypedDict): + foo: Foo + +class Bar(TypedDict): + """ + DocStrings are supported! + """ + type: Literal["bar"] + bar: str + nested: Ts2Py_tliGTOBrDv + """ + nested objects need extra declarations in Python + """ + +FooBarMap = Dict[str,Union[Foo,Bar]] + +TupleType = Tuple[str,Union[Foo,Bar],List[Any]]`); + }); +}); diff --git a/src/testing/reference.test.ts b/src/testing/reference.test.ts index b3d9859..c177aaf 100644 --- a/src/testing/reference.test.ts +++ b/src/testing/reference.test.ts @@ -10,15 +10,15 @@ describe("transpiling referenced types", () => { expect(result).toEqual( `from typing_extensions import Dict, TypedDict, Union -class Ts2PyAHelperType1(TypedDict): +class Ts2Py_vxK3pg8Yk2(TypedDict): foo: float -class Ts2PyHelperType2(TypedDict): +class Ts2Py_rTIa1O0osy(TypedDict): bar: str class C(TypedDict): flat: float - outer: Union[Ts2PyAHelperType1,Dict[str,bool],Ts2PyHelperType2]`, + outer: Union[Ts2Py_vxK3pg8Yk2,Dict[str,bool],Ts2Py_rTIa1O0osy]`, ); }); });