Skip to content

Commit 314c389

Browse files
authored
Support arbitrary keys in objects by falling back to the functional typed dict definition (#21)
* update gitignore for mac * use the workspace ts version * support arbitrary keys in dicts by falling back to a functional typed dict definition * improve readability of functional dicts
1 parent b060ff9 commit 314c389

5 files changed

Lines changed: 114 additions & 14 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
/build
33
/yarn-error.log
44
/dist
5+
.DS_Store

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"js/ts.tsdk.path": "node_modules/typescript/lib"
3+
}

src/parseProperty.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,42 @@ export const parseProperty = (state: ParserState, symbol: ts.Symbol) => {
3434
return `${name}: ${definition}${documentationSuffix}`;
3535
}
3636
};
37+
38+
export const parsePropertyForDict = (state: ParserState, symbol: ts.Symbol) => {
39+
const name = JSON.stringify(symbol.getName());
40+
if (symbol.flags & ts.SymbolFlags.Optional) {
41+
state.imports.add("NotRequired");
42+
const definition = parseInlineType(
43+
state,
44+
// since the entry is already options, the inner type can be non-nullable
45+
state.typechecker.getNonNullableType(state.typechecker.getTypeOfSymbol(symbol)),
46+
);
47+
if (state.config.nullableOptionals) {
48+
state.imports.add("Optional");
49+
return `${name}: NotRequired[Optional[${definition}]]`;
50+
} else {
51+
return `${name}: NotRequired[${definition}]`;
52+
}
53+
} else {
54+
const definition = parseInlineType(
55+
state,
56+
// since the entry is already options, the inner type can be non-nullable
57+
state.typechecker.getTypeOfSymbol(symbol),
58+
);
59+
return `${name}: ${definition}`;
60+
}
61+
};
62+
63+
export const getDocumentationStringForDict = (state: ParserState, symbol: ts.Symbol) => {
64+
const name = symbol.getName();
65+
const documentation = symbol
66+
.getDocumentationComment(state.typechecker)
67+
.map((v) => v.text)
68+
.join(" \n");
69+
if (documentation.length > 0) {
70+
return `${JSON.stringify(name)}: ${documentation}`
71+
} else {
72+
return undefined;
73+
}
74+
}
75+

src/parseTypeDefinition.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import ts from "typescript";
22
import { ParserState } from "./ParserState";
3-
import { parseProperty } from "./parseProperty";
3+
import { getDocumentationStringForDict, parseProperty, parsePropertyForDict } from "./parseProperty";
44
import { getDocumentationStringForType } from "./getDocumentationStringForType";
55
import { tryToParseInlineType } from "./parseInlineType";
66
import { isValidPythonIdentifier } from "./isValidPythonIdentifier";
@@ -25,18 +25,40 @@ export const parseTypeDefinition = (
2525
state.statements.push(definition);
2626
} else {
2727
state.imports.add("TypedDict");
28-
29-
const properties = type
28+
29+
const allKeysAreValidPythonIdentifiers = type
3030
.getProperties()
31-
.filter((v) => isValidPythonIdentifier(v.getName()))
32-
.map((v) => parseProperty(state, v));
31+
.map((v) => isValidPythonIdentifier(v.getName()))
32+
.reduce((a,b) => a&&b, true);
3333

34-
const definition = `class ${name}(TypedDict):${
35-
documentation
36-
? `\n """\n ${documentation.replaceAll("\n", " \n")}\n """`
37-
: ""
38-
}\n ${properties.length > 0 ? properties.join(`\n `) : "pass"}`;
34+
if (allKeysAreValidPythonIdentifiers) {
35+
const properties = type
36+
.getProperties()
37+
.map((v) => parseProperty(state, v));
38+
39+
const definition = `class ${name}(TypedDict):${
40+
documentation
41+
? `\n """\n ${documentation.replaceAll("\n", " \n")}\n """`
42+
: ""
43+
}\n ${properties.length > 0 ? properties.join(`\n `) : "pass"}`;
44+
state.statements.push(definition);
45+
} else {
46+
const properties = type
47+
.getProperties()
48+
.map((v) => parsePropertyForDict(state, v));
49+
50+
const propertyDocumentation = type
51+
.getProperties()
52+
.map((v) => getDocumentationStringForDict(state, v))
53+
.filter(v => v !== undefined)
54+
.join("\n");
55+
56+
const innerDocstring = documentation?.replaceAll("\n", " \n") + (propertyDocumentation.length > 0 ? "\n## Entries\n" + propertyDocumentation : "");
57+
const docstring = innerDocstring.length > 0 ? `\n"""\n${innerDocstring}\n"""` : "";
58+
const definition = `${name} = TypedDict(${JSON.stringify(name)}, {\n ${properties.join(",\n ")}\n})${docstring}`;
59+
60+
state.statements.push(definition);
61+
}
3962

40-
state.statements.push(definition);
4163
}
4264
};

src/testing/dicts.test.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@ class A(TypedDict):
5959
expect(result).toContain(`class A(TypedDict):\n foo: str\n bar: float`);
6060
});
6161

62-
it("transpiles optional values as NotRequired[Optional[T]]", async () => {
62+
it("transpiles optional values as NotRequired[T]", async () => {
6363
const result = await transpileString(`export type A = { foo?: string }`);
6464
expect(result).toContain(`class A(TypedDict):\n foo: NotRequired[str]`);
6565
});
6666

67-
it("transpiles optional values as NotRequired[Optional[T]] in strict mode", async () => {
67+
it("transpiles optional values as NotRequired[T] in strict mode", async () => {
6868
const result = await transpileString(
6969
`export type A = { foo?: string }`,
7070
{},
@@ -73,7 +73,7 @@ class A(TypedDict):
7373
expect(result).toContain(`class A(TypedDict):\n foo: NotRequired[str]`);
7474
});
7575

76-
it("transpiles optional values with non-null optionals as NotRequired[T]", async () => {
76+
it("transpiles optional values with non-null optionals as NotRequired[Optional[T]]", async () => {
7777
const result = await transpileString(`export type A = { foo?: string }`, {
7878
nullableOptionals: true,
7979
});
@@ -88,4 +88,39 @@ class A(TypedDict):
8888
);
8989
expect(result).toContain(`class A(TypedDict):\n foo: float\n bar: float`);
9090
});
91+
92+
it("falls back to the functional syntax if keys are unsupported", async () => {
93+
const result = await transpileString(`export type A = {
94+
"foo.bar"?: string,
95+
}`);
96+
expect(result).toContain(
97+
`A = TypedDict("A", {
98+
"foo.bar": NotRequired[str]
99+
})`,
100+
);
101+
});
102+
103+
it("moves the key/value docstrings to the object docstring in the functional syntax", async () => {
104+
const result = await transpileString(`
105+
/** This is A */
106+
export type A = {
107+
/** this is foo.bar */
108+
"foo.bar": string,
109+
/** this is a/b */
110+
"a/b": number,
111+
"undocumented": string,
112+
}
113+
`);
114+
expect(result).toContain(`A = TypedDict("A", {
115+
"foo.bar": str,
116+
"a/b": float,
117+
"undocumented": str
118+
})
119+
"""
120+
This is A
121+
## Entries
122+
"foo.bar": this is foo.bar
123+
"a/b": this is a/b
124+
"""`);
125+
});
91126
});

0 commit comments

Comments
 (0)