trpc-gen-python 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # trpc-gen-python
2
+
3
+ Generate type-safe Python clients from tRPC routers. Automatically converts your tRPC endpoints into Pydantic models and httpx-based client code.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npx trpc-gen-python generate
9
+ ```
10
+
11
+ Or install globally:
12
+
13
+ ```bash
14
+ npm install -g trpc-gen-python
15
+ trpc-gen-python generate
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ 1. Create a `trpc-gen-python.toml` configuration file in your project root:
21
+
22
+ ```toml
23
+ # Which endpoints to generate (supports glob patterns)
24
+ endpoints = ["user.*", "post.list"]
25
+
26
+ [router]
27
+ # Path to your tRPC router file
28
+ path = "./src/server/router.ts"
29
+ # Export name of your router
30
+ export = "appRouter"
31
+ # Base URL for your tRPC API
32
+ base_url = "http://localhost:3000/api/trpc"
33
+
34
+ [output]
35
+ # Output directory for generated Python code
36
+ dir = "./generated"
37
+ # Name of the generated Python package
38
+ package_name = "trpc_client"
39
+ ```
40
+
41
+ 2. Run the generator:
42
+
43
+ ```bash
44
+ npx trpc-gen-python generate
45
+ ```
46
+
47
+ 3. Use the generated Python client:
48
+
49
+ ```python
50
+ from generated.trpc_client import TRPCClient
51
+
52
+ client = TRPCClient()
53
+
54
+ # Call your tRPC procedures
55
+ user = client.user.get(id=123)
56
+ posts = client.post.list(limit=10)
57
+ ```
58
+
59
+ ## CLI Options
60
+
61
+ ```bash
62
+ trpc-gen-python generate [options]
63
+
64
+ Options:
65
+ -c, --config <path> Path to config file (default: trpc-gen-python.toml)
66
+ -h, --help Display help
67
+ -V, --version Display version
68
+ ```
69
+
70
+
71
+ ## Python Dependencies
72
+
73
+ The generated client requires:
74
+ ```bash
75
+ pip install pydantic httpx
76
+ ```
77
+
78
+ ## License
79
+
80
+ MIT
81
+
82
+ ## Contributing
83
+
84
+ Issues and pull requests welcome!
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { loadConfig } from "./config.js";
4
+ import { introspectRouter } from "./introspect.js";
5
+ import { generatePythonClient } from "./codegen.js";
6
+ const program = new Command();
7
+ program
8
+ .name("trpc-gen-python")
9
+ .description("Generate Python clients from tRPC routers")
10
+ .version("0.1.0");
11
+ program
12
+ .command("generate")
13
+ .description("Generate Python Pydantic models and httpx client from a tRPC router")
14
+ .option("-c, --config <path>", "Path to config file (default: trpc-gen-python.toml)")
15
+ .action(async (opts) => {
16
+ try {
17
+ console.log("Loading config...");
18
+ const config = loadConfig(opts.config);
19
+ console.log(`Introspecting router at ${config.router.path}...`);
20
+ const procedures = await introspectRouter(config);
21
+ console.log(`Found ${procedures.length} procedure(s), generating Python client...`);
22
+ generatePythonClient(config, procedures);
23
+ console.log("Done!");
24
+ }
25
+ catch (err) {
26
+ const message = err instanceof Error ? err.message : String(err);
27
+ console.error(`Error: ${message}`);
28
+ process.exit(1);
29
+ }
30
+ });
31
+ program.parse();
@@ -0,0 +1,5 @@
1
+ import type { Config, ProcedureInfo } from "./types.js";
2
+ /**
3
+ * Main codegen function — writes the generated Python package to disk.
4
+ */
5
+ export declare function generatePythonClient(config: Config, procedures: ProcedureInfo[]): void;
@@ -0,0 +1,340 @@
1
+ import { mkdirSync, writeFileSync } from "fs";
2
+ import { resolve, join } from "path";
3
+ /**
4
+ * Convert a tRPC procedure name like "user.getById" to a PascalCase class name
5
+ * like "UserGetById"
6
+ */
7
+ function toPascalCase(procedureName) {
8
+ return procedureName
9
+ .split(".")
10
+ .map((segment) => segment
11
+ .split(/[-_]/)
12
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
13
+ .join(""))
14
+ .join("");
15
+ }
16
+ /**
17
+ * Convert a tRPC procedure name like "user.getById" to a snake_case method name
18
+ * like "user_get_by_id"
19
+ */
20
+ function toSnakeCase(procedureName) {
21
+ return procedureName
22
+ .split(".")
23
+ .map((segment) => segment
24
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
25
+ .replace(/[-]/g, "_")
26
+ .toLowerCase())
27
+ .join("_");
28
+ }
29
+ /**
30
+ * Map a JSON Schema type to a Python type string.
31
+ */
32
+ function jsonSchemaTypeToPython(schema, modelPrefix, models) {
33
+ if (schema.anyOf) {
34
+ const types = schema.anyOf.map((s) => jsonSchemaTypeToPython(s, modelPrefix, models));
35
+ // If one of the types is None, it's Optional
36
+ const nonNone = types.filter((t) => t !== "None");
37
+ const hasNone = types.some((t) => t === "None");
38
+ if (hasNone && nonNone.length === 1) {
39
+ return `Optional[${nonNone[0]}]`;
40
+ }
41
+ return `Union[${types.join(", ")}]`;
42
+ }
43
+ if (schema.enum) {
44
+ // Generate a string literal union — for simplicity, use str
45
+ return "str";
46
+ }
47
+ const type = schema.type;
48
+ if (type === "string")
49
+ return "str";
50
+ if (type === "number")
51
+ return "float";
52
+ if (type === "integer")
53
+ return "int";
54
+ if (type === "boolean")
55
+ return "bool";
56
+ if (type === "null")
57
+ return "None";
58
+ if (type === "array") {
59
+ if (schema.items) {
60
+ const itemType = jsonSchemaTypeToPython(schema.items, modelPrefix, models);
61
+ return `List[${itemType}]`;
62
+ }
63
+ return "List[Any]";
64
+ }
65
+ if (type === "object") {
66
+ if (schema.properties) {
67
+ // Generate a nested model
68
+ const nestedName = modelPrefix;
69
+ const nestedCode = generateModelClass(nestedName, schema, models);
70
+ models.push({ name: nestedName, code: nestedCode });
71
+ return nestedName;
72
+ }
73
+ return "Dict[str, Any]";
74
+ }
75
+ return "Any";
76
+ }
77
+ /**
78
+ * Generate a Pydantic model class from a JSON Schema object.
79
+ */
80
+ function generateModelClass(className, schema, models) {
81
+ const lines = [];
82
+ lines.push(`class ${className}(BaseModel):`);
83
+ const properties = schema.properties;
84
+ if (!properties || Object.keys(properties).length === 0) {
85
+ lines.push(" pass");
86
+ return lines.join("\n");
87
+ }
88
+ const required = new Set(schema.required || []);
89
+ for (const [propName, propSchema] of Object.entries(properties)) {
90
+ const nestedPrefix = `${className}${propName.charAt(0).toUpperCase() + propName.slice(1)}`;
91
+ let pyType = jsonSchemaTypeToPython(propSchema, nestedPrefix, models);
92
+ const isRequired = required.has(propName);
93
+ if (!isRequired && !pyType.startsWith("Optional[")) {
94
+ pyType = `Optional[${pyType}]`;
95
+ }
96
+ const safeName = pythonSafeName(propName);
97
+ const alias = safeName !== propName ? `, alias="${propName}"` : "";
98
+ if (!isRequired) {
99
+ lines.push(` ${safeName}: ${pyType} = Field(None${alias})`);
100
+ }
101
+ else if (alias) {
102
+ lines.push(` ${safeName}: ${pyType} = Field(...${alias})`);
103
+ }
104
+ else {
105
+ lines.push(` ${safeName}: ${pyType}`);
106
+ }
107
+ }
108
+ return lines.join("\n");
109
+ }
110
+ /**
111
+ * Ensure a property name is a valid Python identifier.
112
+ */
113
+ function pythonSafeName(name) {
114
+ // Replace hyphens/dots with underscores
115
+ let safe = name.replace(/[-.\s]/g, "_");
116
+ // If it starts with a digit, prefix with underscore
117
+ if (/^\d/.test(safe)) {
118
+ safe = "_" + safe;
119
+ }
120
+ // Reserved words
121
+ const reserved = new Set([
122
+ "class",
123
+ "def",
124
+ "return",
125
+ "import",
126
+ "from",
127
+ "if",
128
+ "else",
129
+ "for",
130
+ "while",
131
+ "type",
132
+ "input",
133
+ "list",
134
+ "dict",
135
+ "set",
136
+ "str",
137
+ "int",
138
+ "float",
139
+ "bool",
140
+ "None",
141
+ "True",
142
+ "False",
143
+ "and",
144
+ "or",
145
+ "not",
146
+ "in",
147
+ "is",
148
+ "lambda",
149
+ "with",
150
+ "as",
151
+ "try",
152
+ "except",
153
+ "finally",
154
+ "raise",
155
+ "pass",
156
+ "break",
157
+ "continue",
158
+ "global",
159
+ "nonlocal",
160
+ "del",
161
+ "yield",
162
+ "assert",
163
+ "async",
164
+ "await",
165
+ ]);
166
+ if (reserved.has(safe)) {
167
+ safe = safe + "_";
168
+ }
169
+ return safe;
170
+ }
171
+ /**
172
+ * Check if a JSON Schema represents an object type (i.e. should become a Pydantic model).
173
+ */
174
+ function isObjectSchema(schema) {
175
+ const core = extractCoreSchema(schema);
176
+ return core.type === "object" && !!core.properties;
177
+ }
178
+ /**
179
+ * Get the Python type for a non-object (primitive/array) JSON Schema.
180
+ */
181
+ function primitiveSchemaToPythonType(schema, models) {
182
+ const core = extractCoreSchema(schema);
183
+ return jsonSchemaTypeToPython(core, "Anon", models);
184
+ }
185
+ /**
186
+ * Generate the models.py content, and return type info for each procedure.
187
+ */
188
+ function generateModelsFile(procedures) {
189
+ const lines = [
190
+ "from __future__ import annotations",
191
+ "",
192
+ "from pydantic import BaseModel, Field",
193
+ "from typing import Any, Dict, List, Optional, Union",
194
+ "",
195
+ "",
196
+ ];
197
+ const allModels = [];
198
+ const typeInfo = [];
199
+ for (const proc of procedures) {
200
+ const pascal = toPascalCase(proc.name);
201
+ const info = {
202
+ name: proc.name,
203
+ inputIsModel: false,
204
+ inputPyType: "",
205
+ outputIsModel: false,
206
+ outputPyType: "dict",
207
+ };
208
+ if (proc.inputSchema) {
209
+ if (isObjectSchema(proc.inputSchema)) {
210
+ const inputName = `${pascal}Input`;
211
+ const coreSchema = extractCoreSchema(proc.inputSchema);
212
+ const code = generateModelClass(inputName, coreSchema, allModels);
213
+ allModels.push({ name: inputName, code });
214
+ info.inputIsModel = true;
215
+ info.inputPyType = inputName;
216
+ }
217
+ else {
218
+ info.inputIsModel = false;
219
+ info.inputPyType = primitiveSchemaToPythonType(proc.inputSchema, allModels);
220
+ }
221
+ }
222
+ if (proc.outputSchema) {
223
+ if (isObjectSchema(proc.outputSchema)) {
224
+ const outputName = `${pascal}Output`;
225
+ const coreSchema = extractCoreSchema(proc.outputSchema);
226
+ const code = generateModelClass(outputName, coreSchema, allModels);
227
+ allModels.push({ name: outputName, code });
228
+ info.outputIsModel = true;
229
+ info.outputPyType = outputName;
230
+ }
231
+ else {
232
+ info.outputIsModel = false;
233
+ info.outputPyType = primitiveSchemaToPythonType(proc.outputSchema, allModels);
234
+ }
235
+ }
236
+ typeInfo.push(info);
237
+ }
238
+ for (const model of allModels) {
239
+ lines.push(model.code);
240
+ lines.push("");
241
+ lines.push("");
242
+ }
243
+ return { content: lines.join("\n").trimEnd() + "\n", typeInfo };
244
+ }
245
+ /**
246
+ * Extract the core schema from a zod-to-json-schema output,
247
+ * which may wrap the actual schema in definitions/$ref.
248
+ */
249
+ function extractCoreSchema(schema) {
250
+ if (schema.$ref && schema.definitions) {
251
+ const refName = schema.$ref.replace("#/definitions/", "");
252
+ return schema.definitions[refName] || schema;
253
+ }
254
+ return schema;
255
+ }
256
+ /**
257
+ * Generate the client.py content.
258
+ */
259
+ function generateClientFile(procedures, typeInfo, baseUrl) {
260
+ const lines = [
261
+ "import httpx",
262
+ "import json",
263
+ "from urllib.parse import quote",
264
+ "from .models import *",
265
+ "",
266
+ "",
267
+ "class TRPCClient:",
268
+ ' def __init__(self, base_url: str = "' + baseUrl + '"):',
269
+ ' self.base_url = base_url.rstrip("/")',
270
+ " self.http = httpx.Client()",
271
+ "",
272
+ ];
273
+ for (let i = 0; i < procedures.length; i++) {
274
+ const proc = procedures[i];
275
+ const info = typeInfo[i];
276
+ const snake = toSnakeCase(proc.name);
277
+ const hasInput = proc.inputSchema !== null;
278
+ const inputParam = hasInput ? `input: ${info.inputPyType}` : "";
279
+ const returnType = ` -> ${info.outputPyType}`;
280
+ lines.push(` def ${snake}(self, ${inputParam})${returnType}:`);
281
+ lines.push(` url = f"{self.base_url}/${proc.name}"`);
282
+ // Serialize input — models use .model_dump(), primitives are used directly
283
+ const inputExpr = hasInput
284
+ ? info.inputIsModel
285
+ ? "input.model_dump(by_alias=True)"
286
+ : "input"
287
+ : null;
288
+ if (proc.type === "query") {
289
+ if (inputExpr) {
290
+ lines.push(` encoded = quote(json.dumps(${inputExpr}))`);
291
+ lines.push(` resp = self.http.get(url, params={"input": encoded})`);
292
+ }
293
+ else {
294
+ lines.push(` resp = self.http.get(url)`);
295
+ }
296
+ }
297
+ else {
298
+ if (inputExpr) {
299
+ lines.push(` resp = self.http.post(url, json={"json": ${inputExpr}})`);
300
+ }
301
+ else {
302
+ lines.push(` resp = self.http.post(url)`);
303
+ }
304
+ }
305
+ lines.push(` resp.raise_for_status()`);
306
+ lines.push(` data = resp.json()["result"]["data"]`);
307
+ if (info.outputIsModel) {
308
+ lines.push(` return ${info.outputPyType}(**data)`);
309
+ }
310
+ else {
311
+ lines.push(` return data`);
312
+ }
313
+ lines.push("");
314
+ }
315
+ return lines.join("\n").trimEnd() + "\n";
316
+ }
317
+ /**
318
+ * Generate the __init__.py content.
319
+ */
320
+ function generateInitFile() {
321
+ return `from .models import *\nfrom .client import TRPCClient\n`;
322
+ }
323
+ /**
324
+ * Main codegen function — writes the generated Python package to disk.
325
+ */
326
+ export function generatePythonClient(config, procedures) {
327
+ const outputDir = resolve(process.cwd(), config.output.dir);
328
+ const packageDir = join(outputDir, config.output.package_name);
329
+ mkdirSync(packageDir, { recursive: true });
330
+ const { content: modelsContent, typeInfo } = generateModelsFile(procedures);
331
+ writeFileSync(join(packageDir, "models.py"), modelsContent);
332
+ const clientContent = generateClientFile(procedures, typeInfo, config.router.base_url);
333
+ writeFileSync(join(packageDir, "client.py"), clientContent);
334
+ const initContent = generateInitFile();
335
+ writeFileSync(join(packageDir, "__init__.py"), initContent);
336
+ console.log(`Generated Python client at ${packageDir}/`);
337
+ console.log(` - models.py (${procedures.length} procedure(s))`);
338
+ console.log(` - client.py`);
339
+ console.log(` - __init__.py`);
340
+ }
@@ -0,0 +1,2 @@
1
+ import type { Config } from "./types.js";
2
+ export declare function loadConfig(configPath?: string): Config;
package/dist/config.js ADDED
@@ -0,0 +1,31 @@
1
+ import { readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+ import TOML from "@iarna/toml";
4
+ export function loadConfig(configPath) {
5
+ const filePath = configPath
6
+ ? resolve(configPath)
7
+ : resolve(process.cwd(), "trpc-gen-python.toml");
8
+ let raw;
9
+ try {
10
+ raw = readFileSync(filePath, "utf-8");
11
+ }
12
+ catch {
13
+ throw new Error(`Config file not found: ${filePath}`);
14
+ }
15
+ const parsed = TOML.parse(raw);
16
+ if (!parsed.router?.path) {
17
+ throw new Error("Config missing required field: router.path");
18
+ }
19
+ if (!parsed.router?.base_url) {
20
+ throw new Error("Config missing required field: router.base_url");
21
+ }
22
+ if (!parsed.output?.dir) {
23
+ throw new Error("Config missing required field: output.dir");
24
+ }
25
+ if (!parsed.endpoints || parsed.endpoints.length === 0) {
26
+ throw new Error("Config must specify at least one endpoint pattern in endpoints = [...]");
27
+ }
28
+ parsed.router.export = parsed.router.export || "appRouter";
29
+ parsed.output.package_name = parsed.output.package_name || "trpc_client";
30
+ return parsed;
31
+ }
@@ -0,0 +1,10 @@
1
+ import type { JSONSchema } from "./types.js";
2
+ /**
3
+ * Use the TypeScript compiler to infer output types for procedures
4
+ * that don't have an explicit .output() Zod schema.
5
+ *
6
+ * Works by resolving `inferRouterOutputs<AppRouter>` and navigating
7
+ * the resulting type to extract each procedure's output type,
8
+ * then converting that TS type to JSON Schema.
9
+ */
10
+ export declare function inferOutputTypes(routerPath: string, routerExport: string, procedureNames: string[]): Map<string, JSONSchema>;
@@ -0,0 +1,165 @@
1
+ import { Project } from "ts-morph";
2
+ import { resolve, dirname } from "path";
3
+ import { accessSync } from "fs";
4
+ /**
5
+ * Use the TypeScript compiler to infer output types for procedures
6
+ * that don't have an explicit .output() Zod schema.
7
+ *
8
+ * Works by resolving `inferRouterOutputs<AppRouter>` and navigating
9
+ * the resulting type to extract each procedure's output type,
10
+ * then converting that TS type to JSON Schema.
11
+ */
12
+ export function inferOutputTypes(routerPath, routerExport, procedureNames) {
13
+ const absRouterPath = resolve(process.cwd(), routerPath);
14
+ // Find the user's tsconfig
15
+ const tsConfigPath = findTsConfig(dirname(absRouterPath));
16
+ const project = new Project({
17
+ tsConfigFilePath: tsConfigPath,
18
+ skipAddingFilesFromTsConfig: true,
19
+ });
20
+ // Add the router file so the project can resolve it
21
+ project.addSourceFileAtPath(absRouterPath);
22
+ // Create a virtual file that uses inferRouterOutputs
23
+ const virtualSource = project.createSourceFile("__trpc_gen_virtual__.ts", `
24
+ import type { inferRouterOutputs } from "@trpc/server";
25
+ import type { ${routerExport} } from "${absRouterPath.replace(/\.ts$/, "")}";
26
+ type _AppRouter = typeof ${routerExport};
27
+ type _Outputs = inferRouterOutputs<_AppRouter>;
28
+ `, { overwrite: true });
29
+ const outputsAlias = virtualSource.getTypeAliasOrThrow("_Outputs");
30
+ const outputsType = outputsAlias.getType();
31
+ const results = new Map();
32
+ for (const procName of procedureNames) {
33
+ // "calls.getById" → navigate type via ["calls"]["getById"]
34
+ const segments = procName.split(".");
35
+ let currentType = outputsType;
36
+ let resolved = true;
37
+ for (const segment of segments) {
38
+ const prop = currentType.getProperty(segment);
39
+ if (!prop) {
40
+ resolved = false;
41
+ break;
42
+ }
43
+ currentType = prop.getTypeAtLocation(virtualSource);
44
+ }
45
+ if (resolved) {
46
+ const schema = tsTypeToJsonSchema(currentType, virtualSource, new Set());
47
+ results.set(procName, schema);
48
+ }
49
+ }
50
+ return results;
51
+ }
52
+ /**
53
+ * Find the nearest tsconfig.json starting from a directory.
54
+ */
55
+ function findTsConfig(startDir) {
56
+ let dir = startDir;
57
+ while (true) {
58
+ const candidate = resolve(dir, "tsconfig.json");
59
+ try {
60
+ accessSync(candidate);
61
+ return candidate;
62
+ }
63
+ catch {
64
+ const parent = dirname(dir);
65
+ if (parent === dir) {
66
+ throw new Error(`Could not find tsconfig.json starting from ${startDir}`);
67
+ }
68
+ dir = parent;
69
+ }
70
+ }
71
+ }
72
+ /**
73
+ * Convert a TypeScript type to a JSON Schema representation.
74
+ */
75
+ function tsTypeToJsonSchema(type, location, seen) {
76
+ // Handle Promise<T> → unwrap to T
77
+ if (type.getSymbol()?.getName() === "Promise" || type.getText().startsWith("Promise<")) {
78
+ const typeArgs = type.getTypeArguments();
79
+ if (typeArgs.length > 0) {
80
+ return tsTypeToJsonSchema(typeArgs[0], location, seen);
81
+ }
82
+ }
83
+ // Primitives
84
+ if (type.isString() || type.isStringLiteral()) {
85
+ return { type: "string" };
86
+ }
87
+ if (type.isNumber() || type.isNumberLiteral()) {
88
+ return { type: "number" };
89
+ }
90
+ if (type.isBoolean() || type.isBooleanLiteral()) {
91
+ return { type: "boolean" };
92
+ }
93
+ if (type.isNull()) {
94
+ return { type: "null" };
95
+ }
96
+ if (type.isUndefined()) {
97
+ return { type: "null" };
98
+ }
99
+ // Union types (but not boolean which shows as true | false)
100
+ if (type.isUnion() && !type.isBoolean()) {
101
+ const unionTypes = type.getUnionTypes();
102
+ // Filter out undefined for optional handling
103
+ const nonUndefined = unionTypes.filter((t) => !t.isUndefined());
104
+ const hasUndefined = unionTypes.some((t) => t.isUndefined());
105
+ if (nonUndefined.length === 1) {
106
+ const inner = tsTypeToJsonSchema(nonUndefined[0], location, seen);
107
+ if (hasUndefined) {
108
+ return { anyOf: [inner, { type: "null" }] };
109
+ }
110
+ return inner;
111
+ }
112
+ const schemas = nonUndefined.map((t) => tsTypeToJsonSchema(t, location, seen));
113
+ return { anyOf: schemas };
114
+ }
115
+ // Arrays
116
+ if (type.isArray()) {
117
+ const elementType = type.getArrayElementTypeOrThrow();
118
+ return {
119
+ type: "array",
120
+ items: tsTypeToJsonSchema(elementType, location, seen),
121
+ };
122
+ }
123
+ // Date → string
124
+ if (type.getSymbol()?.getName() === "Date") {
125
+ return { type: "string" };
126
+ }
127
+ // Object / interface types
128
+ if (type.isObject() && !type.isArray()) {
129
+ const typeText = type.getText();
130
+ // Guard against infinite recursion for recursive types
131
+ if (seen.has(typeText)) {
132
+ return { type: "object" };
133
+ }
134
+ seen.add(typeText);
135
+ const properties = {};
136
+ const required = [];
137
+ for (const prop of type.getProperties()) {
138
+ const propName = prop.getName();
139
+ // Skip internal/private properties
140
+ if (propName.startsWith("_"))
141
+ continue;
142
+ const propType = prop.getTypeAtLocation(location);
143
+ const isOptional = prop.isOptional();
144
+ let propSchema = tsTypeToJsonSchema(propType, location, new Set(seen));
145
+ // If the prop type is a union with undefined (from optional), unwrap it
146
+ if (isOptional && propSchema.anyOf) {
147
+ const nonNull = propSchema.anyOf.filter((s) => s.type !== "null");
148
+ if (nonNull.length === 1) {
149
+ propSchema = nonNull[0];
150
+ }
151
+ }
152
+ properties[propName] = propSchema;
153
+ if (!isOptional) {
154
+ required.push(propName);
155
+ }
156
+ }
157
+ const schema = { type: "object", properties };
158
+ if (required.length > 0) {
159
+ schema.required = required;
160
+ }
161
+ return schema;
162
+ }
163
+ // Fallback
164
+ return {};
165
+ }
@@ -0,0 +1,2 @@
1
+ import type { Config, ProcedureInfo } from "./types.js";
2
+ export declare function introspectRouter(config: Config): Promise<ProcedureInfo[]>;
@@ -0,0 +1,146 @@
1
+ import { resolve, dirname } from "path";
2
+ import { zodToJsonSchema } from "zod-to-json-schema";
3
+ import { inferOutputTypes } from "./infer-outputs.js";
4
+ export async function introspectRouter(config) {
5
+ const routerPath = resolve(process.cwd(), config.router.path);
6
+ // Use tsx to register TypeScript loader before importing
7
+ const tsxPath = resolve(dirname(new URL(import.meta.url).pathname), "..", "node_modules", "tsx", "esm", "api.mjs");
8
+ // Register tsx so we can import .ts files
9
+ try {
10
+ const tsx = await import(tsxPath);
11
+ if (tsx.register) {
12
+ tsx.register();
13
+ }
14
+ }
15
+ catch {
16
+ // tsx may already be registered if running via tsx
17
+ }
18
+ const routerModule = await import(routerPath);
19
+ const router = routerModule[config.router.export];
20
+ if (!router) {
21
+ throw new Error(`Export "${config.router.export}" not found in ${config.router.path}`);
22
+ }
23
+ const procedures = router._def.procedures;
24
+ const allProcNames = Object.keys(procedures);
25
+ // Resolve glob patterns against the full procedure list
26
+ const matchedNames = resolveEndpointPatterns(config.endpoints, allProcNames);
27
+ if (matchedNames.length === 0) {
28
+ throw new Error(`No procedures matched the patterns: ${config.endpoints.join(", ")}. Available: ${allProcNames.join(", ")}`);
29
+ }
30
+ const results = [];
31
+ // First pass: extract runtime schemas and identify procedures missing output
32
+ const needsInference = [];
33
+ for (const name of matchedNames) {
34
+ const procedure = procedures[name];
35
+ const procDef = procedure._def;
36
+ const procedureType = procDef.type === "mutation" ? "mutation" : "query";
37
+ // Extract input schema (tRPC v11 uses inputs array)
38
+ let inputSchema = null;
39
+ const inputs = procDef.inputs;
40
+ if (inputs && inputs.length > 0) {
41
+ if (inputs.length === 1) {
42
+ inputSchema = zodToJsonSchema(inputs[0], {
43
+ target: "jsonSchema7",
44
+ });
45
+ }
46
+ else {
47
+ const schemas = inputs.map((i) => zodToJsonSchema(i, { target: "jsonSchema7" }));
48
+ inputSchema = mergeObjectSchemas(schemas);
49
+ }
50
+ }
51
+ // Extract output schema from explicit .output()
52
+ let outputSchema = null;
53
+ if (procDef.output) {
54
+ outputSchema = zodToJsonSchema(procDef.output, {
55
+ target: "jsonSchema7",
56
+ });
57
+ }
58
+ else {
59
+ needsInference.push(name);
60
+ }
61
+ results.push({
62
+ name,
63
+ type: procedureType,
64
+ inputSchema,
65
+ outputSchema,
66
+ });
67
+ }
68
+ // Second pass: use TS compiler to infer output types for procedures without .output()
69
+ if (needsInference.length > 0) {
70
+ console.log(`Inferring output types via TypeScript compiler for: ${needsInference.join(", ")}`);
71
+ const inferred = inferOutputTypes(config.router.path, config.router.export, needsInference);
72
+ for (const result of results) {
73
+ if (!result.outputSchema && inferred.has(result.name)) {
74
+ result.outputSchema = inferred.get(result.name);
75
+ }
76
+ }
77
+ }
78
+ return results;
79
+ }
80
+ /**
81
+ * Convert a glob-like endpoint pattern to a RegExp.
82
+ * Supports:
83
+ * - "*" matches any characters within a single segment (between dots)
84
+ * - "**" matches any number of segments (including zero)
85
+ * - Exact names match literally
86
+ *
87
+ * Examples:
88
+ * "user.*" → matches "user.getById", "user.create"
89
+ * "user.**" → matches "user.getById", "user.profile.update"
90
+ * "*.list" → matches "post.list", "comment.list"
91
+ * "**" → matches everything
92
+ * "user.getById" → matches exactly "user.getById"
93
+ */
94
+ function patternToRegex(pattern) {
95
+ const escaped = pattern
96
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex chars (dot included)
97
+ .replace(/\\\.\*\*/g, "(?:\\.[^.]+)*") // ".**" → zero or more .segments
98
+ .replace(/\*\*/g, ".*") // standalone "**" → anything
99
+ .replace(/\*/g, "[^.]+"); // "*" → one segment (no dots)
100
+ return new RegExp(`^${escaped}$`);
101
+ }
102
+ /**
103
+ * Resolve an array of glob patterns against the full list of procedure names.
104
+ * Returns a deduplicated list in stable order (matching the order procedures appear in the router).
105
+ */
106
+ function resolveEndpointPatterns(patterns, allNames) {
107
+ const matched = new Set();
108
+ for (const pattern of patterns) {
109
+ const regex = patternToRegex(pattern);
110
+ let found = false;
111
+ for (const name of allNames) {
112
+ if (regex.test(name)) {
113
+ matched.add(name);
114
+ found = true;
115
+ }
116
+ }
117
+ if (!found) {
118
+ console.warn(`Warning: pattern "${pattern}" did not match any procedures`);
119
+ }
120
+ }
121
+ // Return in the order they appear in the router
122
+ return allNames.filter((name) => matched.has(name));
123
+ }
124
+ /**
125
+ * Merge multiple JSON Schemas (all assumed to be objects) into one
126
+ * by combining their properties and required arrays.
127
+ */
128
+ function mergeObjectSchemas(schemas) {
129
+ const merged = {
130
+ type: "object",
131
+ properties: {},
132
+ required: [],
133
+ };
134
+ for (const schema of schemas) {
135
+ if (schema.properties) {
136
+ Object.assign(merged.properties, schema.properties);
137
+ }
138
+ if (schema.required) {
139
+ merged.required.push(...schema.required);
140
+ }
141
+ }
142
+ if (merged.required.length === 0) {
143
+ delete merged.required;
144
+ }
145
+ return merged;
146
+ }
@@ -0,0 +1,36 @@
1
+ export interface RouterConfig {
2
+ path: string;
3
+ export: string;
4
+ base_url: string;
5
+ }
6
+ export interface OutputConfig {
7
+ dir: string;
8
+ package_name: string;
9
+ }
10
+ export interface Config {
11
+ router: RouterConfig;
12
+ output: OutputConfig;
13
+ endpoints: string[];
14
+ }
15
+ export interface JSONSchema {
16
+ type?: string;
17
+ properties?: Record<string, JSONSchema>;
18
+ required?: string[];
19
+ items?: JSONSchema;
20
+ enum?: unknown[];
21
+ $ref?: string;
22
+ definitions?: Record<string, JSONSchema>;
23
+ anyOf?: JSONSchema[];
24
+ allOf?: JSONSchema[];
25
+ oneOf?: JSONSchema[];
26
+ default?: unknown;
27
+ description?: string;
28
+ additionalProperties?: boolean | JSONSchema;
29
+ nullable?: boolean;
30
+ }
31
+ export interface ProcedureInfo {
32
+ name: string;
33
+ type: "query" | "mutation";
34
+ inputSchema: JSONSchema | null;
35
+ outputSchema: JSONSchema | null;
36
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "trpc-gen-python",
3
+ "version": "0.1.0",
4
+ "description": "Generate Python (Pydantic + httpx) clients from tRPC routers",
5
+ "type": "module",
6
+ "main": "./dist/cli.js",
7
+ "bin": {
8
+ "trpc-gen-python": "./dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsx src/cli.ts",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "keywords": [
20
+ "trpc",
21
+ "python",
22
+ "codegen",
23
+ "pydantic",
24
+ "httpx",
25
+ "type-safe",
26
+ "api-client"
27
+ ],
28
+ "author": "Ishan Das Sharma",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/sad-pixel/trpc-gen-python.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/sad-pixel/trpc-gen-python/issues"
36
+ },
37
+ "homepage": "https://github.com/sad-pixel/trpc-gen-python#readme",
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ },
41
+ "dependencies": {
42
+ "@iarna/toml": "^2.2.5",
43
+ "commander": "^12.1.0",
44
+ "ts-morph": "^27.0.2",
45
+ "tsx": "^4.19.0",
46
+ "zod-to-json-schema": "^3.23.0"
47
+ },
48
+ "devDependencies": {
49
+ "@trpc/server": "^11.0.0",
50
+ "@types/node": "^22.0.0",
51
+ "typescript": "^5.6.0",
52
+ "zod": "^3.23.0"
53
+ },
54
+ "peerDependencies": {
55
+ "@trpc/server": "^11.0.0",
56
+ "zod": "^3.23.0"
57
+ }
58
+ }