Skip to content

Commit f4276d3

Browse files
authored
Use hashed type names instead of indices (#15)
* use hashes for helper type names instead of indices * update readme and add readme test case
1 parent 291f0ca commit f4276d3

11 files changed

+200
-40
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@ export type TupleType = [string, Foo | Bar, any[]]
4040
### TypeScript2Python
4141
4242
```python
43-
from typing_extensions import Any, Dict, List, Literal, NotRequired, Optional, Tuple, TypedDict, Union
43+
from typing_extensions import Any, Dict, List, Literal, NotRequired, Tuple, TypedDict, Union
4444

4545
class Foo(TypedDict):
4646
type: Literal["foo"]
4747
foo: List[float]
4848
optional: NotRequired[str]
4949

50-
class Ts2PyHelperType1(TypedDict):
50+
class Ts2Py_tliGTOBrDv(TypedDict):
5151
foo: Foo
5252

5353
class Bar(TypedDict):
@@ -56,7 +56,7 @@ class Bar(TypedDict):
5656
"""
5757
type: Literal["bar"]
5858
bar: str
59-
nested: Ts2PyHelperType1
59+
nested: Ts2Py_tliGTOBrDv
6060
"""
6161
nested objects need extra declarations in Python
6262
"""

src/ParserState.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import ts from "typescript";
22
import { Ts2PyConfig } from "./config";
33

44
export type ParserState = {
5-
helperCount: number;
65
statements: string[];
76
typechecker: ts.TypeChecker;
87
knownTypes: Map<ts.Type | string, string>;
8+
helperTypeNames: Map<string, ts.Type>;
9+
canonicalTypeNames: Map<ts.Type, string>;
910
imports: Set<string>;
1011
config: Ts2PyConfig;
1112
};
@@ -20,10 +21,11 @@ export const createNewParserState = (typechecker: ts.TypeChecker, config: Ts2PyC
2021

2122
return {
2223
statements: [],
23-
helperCount: 0,
2424
typechecker,
2525
knownTypes,
2626
imports: new Set(),
27+
helperTypeNames: new Map(),
28+
canonicalTypeNames: new Map(),
2729
config,
2830
};
2931
}

src/canonicalTypeName.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { createNewParserState, ParserState } from "./ParserState";
2+
import ts from "typescript";
3+
import { parseTypeDefinition } from "./parseTypeDefinition";
4+
5+
/**
6+
* A function that creates a unique string for a given interface or object type,
7+
* from a fresh parser state. This should return the same string for two semantically
8+
* identically types, allowing us to re-use existing helper types if the generated
9+
* strings match.
10+
**/
11+
export const getCanonicalTypeName = (state: ParserState, type: ts.Type) => {
12+
const cachedName = state.canonicalTypeNames.get(type);
13+
if (cachedName) {
14+
return cachedName;
15+
} else {
16+
const tmpState = createNewParserState(state.typechecker, state.config);
17+
parseTypeDefinition(tmpState, "TS2PyTmpType", type);
18+
const result = tmpState.statements.join("\n");
19+
state.canonicalTypeNames.set(type, result);
20+
return result;
21+
}
22+
}

src/newHelperTypeName.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import { ParserState } from "./ParserState";
22
import ts from "typescript";
3+
import { createHash } from "node:crypto"
4+
import { getCanonicalTypeName } from "./canonicalTypeName";
35

4-
export const newHelperTypeName = (state: ParserState, type?: ts.Type) => {
5-
const typeName = type?.aliasSymbol?.getName() ?? "";
6-
return `Ts2Py${typeName}HelperType${++state.helperCount}`;
6+
export const newHelperTypeName = (state: ParserState, type: ts.Type) => {
7+
// to keep helper type names predictable and not dependent on the order of definition,
8+
// we use the first 10 characters of a sha256 hash of the type. If there is an unexpected
9+
// collision, we fallback to using an incrementing counter.
10+
const fullHash = createHash("sha256").update(getCanonicalTypeName(state, type));
11+
// for the short hash, we remove all non-alphanumeric characters from the hash and take the
12+
// first 10 characters.
13+
let shortHash = fullHash.digest("base64").replace(/\W/g, '').substring(0, 10);
14+
if (state.helperTypeNames.has(shortHash) && state.helperTypeNames.get(shortHash) !== type) {
15+
shortHash = "HelperType" + state.helperTypeNames.size.toString();
16+
}
17+
return `Ts2Py_${shortHash}`;
718
};

src/parseInlineType.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import ts, { TypeFlags } from "typescript";
2-
import { ParserState, createNewParserState } from "./ParserState";
2+
import { ParserState } from "./ParserState";
33
import { newHelperTypeName } from "./newHelperTypeName";
44
import { parseTypeDefinition } from "./parseTypeDefinition";
5+
import { getCanonicalTypeName } from "./canonicalTypeName";
56

67
export const parseInlineType = (state: ParserState, type: ts.Type) => {
78
const result = tryToParseInlineType(state, type);
@@ -12,18 +13,6 @@ export const parseInlineType = (state: ParserState, type: ts.Type) => {
1213
}
1314
};
1415

15-
/**
16-
* A function that creates a unique string for a given interface or object type,
17-
* from a fresh parser state. This should return the same string for two semantically
18-
* identically types, allowing us to re-use existing helper types if the generated
19-
* strings match.
20-
**/
21-
const getCanonicalTypeName = (state: ParserState, type: ts.Type) => {
22-
const tmpState = createNewParserState(state.typechecker, state.config);
23-
parseTypeDefinition(tmpState, "TS2PyTmpType", type);
24-
return tmpState.statements.join("\n")
25-
}
26-
2716
export const tryToParseInlineType = (
2817
state: ParserState,
2918
type: ts.Type,

src/testing/basic.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,3 @@ describe("transpiling basic types", () => {
7373
expect(result).toContain("Exported = float");
7474
});
7575
});
76-
77-
export type T = Record<string, string>;

src/testing/dicts.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,11 @@ describe("transpiling dictionaries types", () => {
4343
extra: number,
4444
}`);
4545
expect(result).toContain(
46-
`class Ts2PyHelperType1(TypedDict):
46+
`class Ts2Py_FOZhdT9ykh(TypedDict):
4747
inner: str
4848
4949
class A(TypedDict):
50-
outer: Ts2PyHelperType1
50+
outer: Ts2Py_FOZhdT9ykh
5151
extra: float`,
5252
);
5353
});

src/testing/helperTypes.test.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ describe("creating helper types", () => {
99
expect(result).toEqual(
1010
`from typing_extensions import TypedDict
1111
12-
class Ts2PyHelperType2(TypedDict):
12+
class Ts2Py_rTIa1O0osy(TypedDict):
1313
bar: str
1414
15-
class Ts2PyHelperType1(TypedDict):
16-
foo: Ts2PyHelperType2
15+
class Ts2Py_v6EwABEDVq(TypedDict):
16+
foo: Ts2Py_rTIa1O0osy
1717
1818
class T(TypedDict):
19-
inner: Ts2PyHelperType1`,
19+
inner: Ts2Py_v6EwABEDVq`,
2020
);
2121
});
2222

@@ -29,19 +29,56 @@ class T(TypedDict):
2929

3030
expect(result).toEqual(`from typing_extensions import TypedDict
3131
32-
class Ts2PyHelperType2(TypedDict):
32+
class Ts2Py_rTIa1O0osy(TypedDict):
3333
bar: str
3434
35-
class Ts2PyHelperType1(TypedDict):
36-
foo: Ts2PyHelperType2
35+
class Ts2Py_v6EwABEDVq(TypedDict):
36+
foo: Ts2Py_rTIa1O0osy
3737
3838
class A(TypedDict):
39-
a: Ts2PyHelperType1
39+
a: Ts2Py_v6EwABEDVq
4040
4141
class B(TypedDict):
42-
b: Ts2PyHelperType1
42+
b: Ts2Py_v6EwABEDVq
4343
4444
class C(TypedDict):
45-
foo: Ts2PyHelperType2`);
45+
foo: Ts2Py_rTIa1O0osy`);
46+
});
47+
48+
it("always uses the same helper type names", async () => {
49+
const result1 = await transpileString(`
50+
export type A = { a: { foo: { bar: string } } }
51+
`);
52+
const result2 = await transpileString(`
53+
export type B = { b: { bar: { foo: string } } }
54+
export type A = { a: { foo: { bar: string } } }
55+
`);
56+
57+
// the type hashes will be the same, no matter if we define other helper types before
58+
const expectedAType = `class Ts2Py_rTIa1O0osy(TypedDict):
59+
bar: str
60+
61+
class Ts2Py_v6EwABEDVq(TypedDict):
62+
foo: Ts2Py_rTIa1O0osy
63+
64+
class A(TypedDict):
65+
a: Ts2Py_v6EwABEDVq`;
66+
67+
expect(result1).toEqual(`from typing_extensions import TypedDict
68+
69+
${expectedAType}`);
70+
71+
expect(result2).toEqual(`from typing_extensions import TypedDict
72+
73+
class Ts2Py_9ZFaik8GRM(TypedDict):
74+
foo: str
75+
76+
class Ts2Py_g2bOy1R1LY(TypedDict):
77+
bar: Ts2Py_9ZFaik8GRM
78+
79+
class B(TypedDict):
80+
b: Ts2Py_g2bOy1R1LY
81+
82+
${expectedAType}`);
4683
});
4784
});

src/testing/imports.test.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,56 @@ describe("transpiling referenced types", () => {
3030
expect(transpiled).toEqual(
3131
`from typing_extensions import TypedDict
3232
33-
class Ts2PyFooHelperType1(TypedDict):
33+
class Ts2Py_vxK3pg8Yk2(TypedDict):
3434
foo: float
3535
3636
class Bar(TypedDict):
37-
foo: Ts2PyFooHelperType1`,
37+
foo: Ts2Py_vxK3pg8Yk2`,
3838
);
3939
});
40+
41+
it("doesn't get confused if imported types have the same name", async () => {
42+
const project = await createProject();
43+
44+
project.createSourceFile(
45+
"foo.ts",
46+
`
47+
type Content = { publicFoo: "foo" };
48+
export type Foo = { foo: Content }
49+
`,
50+
);
51+
project.createSourceFile(
52+
"bar.ts",
53+
`
54+
type Content = { publicBar: "bar" };
55+
export type Bar = { bar: Content }
56+
`,
57+
);
58+
const commonSource = project.createSourceFile(
59+
"common.ts",
60+
`
61+
import {Foo} from './foo';
62+
import {Bar} from './bar';
63+
export type FooBar = { foo: Foo, bar: Bar }
64+
`,
65+
);
66+
const program = project.createProgram();
67+
const diagnostics = ts.getPreEmitDiagnostics(program);
68+
69+
if (diagnostics.length > 0) {
70+
throw new Error(
71+
`code compiled with errors: ${project.formatDiagnosticsWithColorAndContext(
72+
diagnostics,
73+
)}`,
74+
);
75+
}
76+
77+
const transpiled = typeScriptToPython(
78+
program.getTypeChecker(),
79+
[commonSource],
80+
{},
81+
);
82+
expect(transpiled).toContain(`publicFoo: Literal["foo"]`);
83+
expect(transpiled).toContain(`publicBar: Literal["bar"]`);
84+
});
4085
});

src/testing/readme.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { transpileString } from "./utils";
2+
3+
describe("readme", () => {
4+
it("transpiles the readme example", async () => {
5+
const result = await transpileString(`
6+
export type Foo = {
7+
type: "foo"
8+
foo: number[]
9+
optional?: string
10+
}
11+
12+
/** DocStrings are supported! */
13+
export type Bar = {
14+
type: "bar"
15+
bar: string
16+
/** nested objects need extra declarations in Python */
17+
nested: {
18+
foo: Foo
19+
}
20+
}
21+
22+
export type FooBarMap = {
23+
[key: string]: Foo | Bar
24+
}
25+
26+
export type TupleType = [string, Foo | Bar, any[]]
27+
`);
28+
29+
// note: if this needs to be updated, be sure to update the readme as well
30+
expect(result)
31+
.toEqual(`from typing_extensions import Any, Dict, List, Literal, NotRequired, Tuple, TypedDict, Union
32+
33+
class Foo(TypedDict):
34+
type: Literal["foo"]
35+
foo: List[float]
36+
optional: NotRequired[str]
37+
38+
class Ts2Py_tliGTOBrDv(TypedDict):
39+
foo: Foo
40+
41+
class Bar(TypedDict):
42+
"""
43+
DocStrings are supported!
44+
"""
45+
type: Literal["bar"]
46+
bar: str
47+
nested: Ts2Py_tliGTOBrDv
48+
"""
49+
nested objects need extra declarations in Python
50+
"""
51+
52+
FooBarMap = Dict[str,Union[Foo,Bar]]
53+
54+
TupleType = Tuple[str,Union[Foo,Bar],List[Any]]`);
55+
});
56+
});

0 commit comments

Comments
 (0)