Skip to content
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
25 changes: 0 additions & 25 deletions .eslintrc.js

This file was deleted.

18 changes: 18 additions & 0 deletions eslint.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { defineConfig } from "eslint/config";
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';

export default defineConfig([
{ files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.node } },
tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
},
},
},
eslintPluginPrettierRecommended,
]);
22 changes: 13 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,34 @@
"dependencies": {
"@commander-js/extra-typings": "^12.1.0",
"commander": "^12.1.0",
"typescript": "^5.3.3"
"typescript": "^6.0.3"
},
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/node": "^7.22.19",
"@babel/preset-env": "^7.23.7",
"@babel/preset-typescript": "^7.28.5",
"@ts-morph/bootstrap": "^0.22.0",
"@eslint/js": "^10.0.1",
"@ts-morph/bootstrap": "^0.29.0",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"@types/node": "^25.6.0",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.2",
"globals": "^17.5.0",
"jest": "^29.7.0",
"prettier": "^3.1.1"
"jiti": "^2.6.1",
"prettier": "^3.1.1",
"typescript-eslint": "^8.59.0"
},
"scripts": {
"watch": "babel-node --watch -x .ts --",
"single": "babel-node -x .ts",
"develop": "yarn watch src/index.ts",
"lint": "eslint src/**/*.ts",
"lint": "eslint src",
"build": "babel --extensions .ts --ignore '**/*.test.ts' ./src -d dist --source-maps",
"test": "jest",
"typecheck": "tsc -p . --noEmit",
Expand Down
7 changes: 5 additions & 2 deletions src/ParserState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export type ParserState = {
config: Ts2PyConfig;
};

export const createNewParserState = (typechecker: ts.TypeChecker, config: Ts2PyConfig): ParserState => {
export const createNewParserState = (
typechecker: ts.TypeChecker,
config: Ts2PyConfig,
): ParserState => {
const knownTypes = new Map<ts.Type, string>();
knownTypes.set(typechecker.getVoidType(), "None");
knownTypes.set(typechecker.getNullType(), "None");
Expand All @@ -29,4 +32,4 @@ export const createNewParserState = (typechecker: ts.TypeChecker, config: Ts2PyC
canonicalTypeNames: new Map(),
config,
};
}
};
26 changes: 13 additions & 13 deletions src/canonicalTypeName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@ 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.
* 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;
}
}
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;
}
};
5 changes: 2 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

export type Ts2PyConfig = {
nullableOptionals?: boolean;
}
nullableOptionals?: boolean;
};
24 changes: 16 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import path from "path";
import { program } from "@commander-js/extra-typings";
import { typeScriptToPython } from "./typeScriptToPython";
import { Ts2PyConfig } from "./config";
import { readFileSync } from "fs";

const compile = (fileNames: string[], config: Ts2PyConfig & {strict?: boolean}) => {
const compile = (
fileNames: string[],
config: Ts2PyConfig & { strict?: boolean },
) => {
const program = ts.createProgram(fileNames, {
noEmit: true,
allowJs: true,
Expand All @@ -23,18 +25,24 @@ const compile = (fileNames: string[], config: Ts2PyConfig & {strict?: boolean})
.map((fn) => path.relative(fn, f.fileName) === "")
.reduce((a, b) => a || b),
);
const transpiled = typeScriptToPython(program.getTypeChecker(), relevantSourceFiles, config)
const transpiled = typeScriptToPython(
program.getTypeChecker(),
relevantSourceFiles,
config,
);
console.log(transpiled);
}
};

program
.name("typescript2python")
.description("A program that converts TypeScript type definitions to Python")
.option("--nullable-optionals", "if set, optional entries in dictionaries will be nullable, e.g. `NotRequired[Optional[T]]`")
.option(
"--nullable-optionals",
"if set, optional entries in dictionaries will be nullable, e.g. `NotRequired[Optional[T]]`",
)
.option("--strict", "Enable all strict type-checking options.")
.arguments("<input...>")
.action((args, options) => {
compile(args, options)
compile(args, options);
})
.parse(process.argv)

.parse(process.argv);
13 changes: 9 additions & 4 deletions src/newHelperTypeName.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { ParserState } from "./ParserState";
import ts from "typescript";
import { createHash } from "node:crypto"
import { createHash } from "node:crypto";
import { getCanonicalTypeName } from "./canonicalTypeName";

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));
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) {
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();
} else {
state.helperTypeNames.set(shortHash, type);
Expand Down
1 change: 0 additions & 1 deletion src/parseExports.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import ts from "typescript";
import { ParserState } from "./ParserState";
import { parseTypeDefinition } from "./parseTypeDefinition";
import { Ts2PyConfig } from "./config";

export function parseExports(state: ParserState, sourceFile: ts.SourceFile) {
for (const statement of sourceFile.statements) {
Expand Down
19 changes: 14 additions & 5 deletions src/parseInlineType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,25 @@ export const tryToParseInlineType = (
globalScope?: boolean,
): string | undefined => {
const known = state.knownTypes.get(type);

if (known !== undefined) {
return known;
} else if (type === state.typechecker.getTrueType()) {
state.imports.add("Literal");
return "Literal[True]"
return "Literal[True]";
} else if (type === state.typechecker.getFalseType()) {
state.imports.add("Literal");
return "Literal[False]"
} else if (type === state.typechecker.getAnyType() || ((type.flags & TypeFlags.Unknown) !== 0)) {
return "Literal[False]";
} else if (
type === state.typechecker.getAnyType() ||
(type.flags & TypeFlags.Unknown) !== 0
) {
state.imports.add("Any");
return "Any";
} else if (type.getFlags() & (ts.TypeFlags.TypeParameter | ts.TypeFlags.TypeVariable)) {
} else if (
type.getFlags() &
(ts.TypeFlags.TypeParameter | ts.TypeFlags.TypeVariable)
) {
// we don't support types with generic type parameters
return `object`;
} else if (type.isLiteral()) {
Expand Down Expand Up @@ -66,6 +72,9 @@ export const tryToParseInlineType = (
// there is no way to represent template literals in Python,
// so we fallback to string
return "str";
} else if (type.flags & TypeFlags.ESSymbol) {
state.imports.add("Any");
return "Any";
} else {
// assume interface or object, we need to create a helper type
if (!globalScope) {
Expand Down
46 changes: 26 additions & 20 deletions src/parseProperty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ export const parseProperty = (state: ParserState, symbol: ts.Symbol) => {
const documentationSuffix = documentation
? `\n """\n ${documentation.replaceAll("\n", "\n ")}\n """`
: "";

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 (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}]]${documentationSuffix}`;
Expand All @@ -38,12 +40,14 @@ export const parseProperty = (state: ParserState, symbol: ts.Symbol) => {
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)),
);
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}]]`;
Expand All @@ -60,18 +64,20 @@ export const parsePropertyForDict = (state: ParserState, symbol: ts.Symbol) => {
}
};

export const getDocumentationStringForDict = (state: ParserState, symbol: ts.Symbol) => {
export const getDocumentationStringForDict = (
state: ParserState,
symbol: ts.Symbol,
) => {
const name = symbol.getName();
const documentation = symbol
.getDocumentationComment(state.typechecker)
.map((v) => v.text)
.filter(v => !!v)
.filter((v) => !!v)
.join(" \n");

if (documentation.length > 0) {
return `${JSON.stringify(name)}: ${documentation}`
return `\`${JSON.stringify(name)}\`: ${documentation}`;
} else {
return undefined;
}
}

};
Loading