Skip to content

Use hashed type names instead of indices #15

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -56,7 +56,7 @@ class Bar(TypedDict):
"""
type: Literal["bar"]
bar: str
nested: Ts2PyHelperType1
nested: Ts2Py_tliGTOBrDv
"""
nested objects need extra declarations in Python
"""
Expand Down
6 changes: 4 additions & 2 deletions src/ParserState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import ts from "typescript";
import { Ts2PyConfig } from "./config";

export type ParserState = {
helperCount: number;
statements: string[];
typechecker: ts.TypeChecker;
knownTypes: Map<ts.Type | string, string>;
helperTypeNames: Map<string, ts.Type>;
canonicalTypeNames: Map<ts.Type, string>;
imports: Set<string>;
config: Ts2PyConfig;
};
Expand All @@ -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,
};
}
22 changes: 22 additions & 0 deletions src/canonicalTypeName.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
17 changes: 14 additions & 3 deletions src/newHelperTypeName.ts
Original file line number Diff line number Diff line change
@@ -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}`;
};
15 changes: 2 additions & 13 deletions src/parseInlineType.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions src/testing/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,3 @@ describe("transpiling basic types", () => {
expect(result).toContain("Exported = float");
});
});

export type T = Record<string, string>;
4 changes: 2 additions & 2 deletions src/testing/dicts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
});
Expand Down
57 changes: 47 additions & 10 deletions src/testing/helperTypes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
});

Expand All @@ -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}`);
});
});
49 changes: 47 additions & 2 deletions src/testing/imports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]`);
});
});
56 changes: 56 additions & 0 deletions src/testing/readme.test.ts
Original file line number Diff line number Diff line change
@@ -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]]`);
});
});
6 changes: 3 additions & 3 deletions src/testing/reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]`,
);
});
});
Loading