@typra/emitter 0.2.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.
Files changed (82) hide show
  1. package/dist/src/cleanup/generated-file.d.ts +6 -0
  2. package/dist/src/cleanup/generated-file.js +61 -0
  3. package/dist/src/cli.d.ts +2 -0
  4. package/dist/src/cli.js +110 -0
  5. package/dist/src/decorators.d.ts +56 -0
  6. package/dist/src/decorators.js +177 -0
  7. package/dist/src/emitter.d.ts +13 -0
  8. package/dist/src/emitter.js +137 -0
  9. package/dist/src/generate.d.ts +86 -0
  10. package/dist/src/generate.js +104 -0
  11. package/dist/src/index.d.ts +4 -0
  12. package/dist/src/index.js +5 -0
  13. package/dist/src/ir/ast.d.ts +235 -0
  14. package/dist/src/ir/ast.js +589 -0
  15. package/dist/src/ir/declarations.d.ts +364 -0
  16. package/dist/src/ir/declarations.js +23 -0
  17. package/dist/src/ir/expansion.d.ts +140 -0
  18. package/dist/src/ir/expansion.js +407 -0
  19. package/dist/src/ir/lower.d.ts +53 -0
  20. package/dist/src/ir/lower.js +480 -0
  21. package/dist/src/ir/utilities.d.ts +12 -0
  22. package/dist/src/ir/utilities.js +39 -0
  23. package/dist/src/ir/visitor.d.ts +29 -0
  24. package/dist/src/ir/visitor.js +48 -0
  25. package/dist/src/languages/csharp/driver.d.ts +5 -0
  26. package/dist/src/languages/csharp/driver.js +315 -0
  27. package/dist/src/languages/csharp/emitter.d.ts +33 -0
  28. package/dist/src/languages/csharp/emitter.js +1140 -0
  29. package/dist/src/languages/csharp/scaffolding.d.ts +18 -0
  30. package/dist/src/languages/csharp/scaffolding.js +591 -0
  31. package/dist/src/languages/csharp/test-emitter.d.ts +43 -0
  32. package/dist/src/languages/csharp/test-emitter.js +274 -0
  33. package/dist/src/languages/csharp/visitor.d.ts +14 -0
  34. package/dist/src/languages/csharp/visitor.js +79 -0
  35. package/dist/src/languages/go/driver.d.ts +12 -0
  36. package/dist/src/languages/go/driver.js +128 -0
  37. package/dist/src/languages/go/emitter.d.ts +33 -0
  38. package/dist/src/languages/go/emitter.js +879 -0
  39. package/dist/src/languages/go/scaffolding.d.ts +18 -0
  40. package/dist/src/languages/go/scaffolding.js +53 -0
  41. package/dist/src/languages/go/test-emitter.d.ts +20 -0
  42. package/dist/src/languages/go/test-emitter.js +300 -0
  43. package/dist/src/languages/go/visitor.d.ts +14 -0
  44. package/dist/src/languages/go/visitor.js +78 -0
  45. package/dist/src/languages/markdown/driver.d.ts +19 -0
  46. package/dist/src/languages/markdown/driver.js +408 -0
  47. package/dist/src/languages/python/driver.d.ts +14 -0
  48. package/dist/src/languages/python/driver.js +372 -0
  49. package/dist/src/languages/python/emitter.d.ts +31 -0
  50. package/dist/src/languages/python/emitter.js +856 -0
  51. package/dist/src/languages/python/scaffolding.d.ts +33 -0
  52. package/dist/src/languages/python/scaffolding.js +279 -0
  53. package/dist/src/languages/python/test-emitter.d.ts +29 -0
  54. package/dist/src/languages/python/test-emitter.js +388 -0
  55. package/dist/src/languages/python/visitor.d.ts +14 -0
  56. package/dist/src/languages/python/visitor.js +65 -0
  57. package/dist/src/languages/rust/driver.d.ts +13 -0
  58. package/dist/src/languages/rust/driver.js +624 -0
  59. package/dist/src/languages/rust/emitter.d.ts +45 -0
  60. package/dist/src/languages/rust/emitter.js +1596 -0
  61. package/dist/src/languages/rust/visitor.d.ts +25 -0
  62. package/dist/src/languages/rust/visitor.js +153 -0
  63. package/dist/src/languages/typescript/driver.d.ts +8 -0
  64. package/dist/src/languages/typescript/driver.js +209 -0
  65. package/dist/src/languages/typescript/emitter.d.ts +42 -0
  66. package/dist/src/languages/typescript/emitter.js +904 -0
  67. package/dist/src/languages/typescript/scaffolding.d.ts +32 -0
  68. package/dist/src/languages/typescript/scaffolding.js +303 -0
  69. package/dist/src/languages/typescript/test-emitter.d.ts +23 -0
  70. package/dist/src/languages/typescript/test-emitter.js +204 -0
  71. package/dist/src/languages/typescript/visitor.d.ts +14 -0
  72. package/dist/src/languages/typescript/visitor.js +64 -0
  73. package/dist/src/lib.d.ts +33 -0
  74. package/dist/src/lib.js +101 -0
  75. package/dist/src/testing/index.d.ts +2 -0
  76. package/dist/src/testing/index.js +8 -0
  77. package/dist/src/testing/test-context.d.ts +63 -0
  78. package/dist/src/testing/test-context.js +355 -0
  79. package/fixtures/shapes/main.tsp +43 -0
  80. package/fixtures/tspconfig.yaml +13 -0
  81. package/package.json +76 -0
  82. package/src/lib/main.tsp +110 -0
@@ -0,0 +1,372 @@
1
+ import { resolvePath } from "@typespec/compiler";
2
+ import { execFileSync } from "child_process";
3
+ import { existsSync } from "fs";
4
+ import { dirname, resolve } from "path";
5
+ import { enumerateTypes } from "../../ir/ast.js";
6
+ import { resolveFactoryExpr, resolveCoerceExpr, TypeRegistry, collectExprTypeRefs } from "../../ir/expansion.js";
7
+ import { renderObjectLiteral } from "../../ir/visitor.js";
8
+ import { PythonExprVisitor } from "./visitor.js";
9
+ import { filterNodes } from "../../emitter.js";
10
+ import { toSnakeCase } from "../../ir/utilities.js";
11
+ import { buildBaseTestContext, pythonTestOptions } from "../../testing/test-context.js";
12
+ import { lowerFile, collectPolymorphicTypeNames } from "../../ir/lower.js";
13
+ import { emitPythonFile as emitPythonFileDecl } from "./emitter.js";
14
+ import { emitPythonContext, emitPythonInit, emitPythonGroupInit } from "./scaffolding.js";
15
+ import { emitPythonTest, emitPythonTestContext } from "./test-emitter.js";
16
+ import { emitGeneratedFile } from "../../cleanup/generated-file.js";
17
+ /**
18
+ * Type mapping from TypeSpec scalar types to Python types.
19
+ * This is passed as data to templates, not used for inline rendering.
20
+ */
21
+ export const pythonTypeMapper = {
22
+ "string": "str",
23
+ "number": "float",
24
+ "array": "list",
25
+ "object": "dict",
26
+ "boolean": "bool",
27
+ "int64": "int",
28
+ "int32": "int",
29
+ "float64": "float",
30
+ "float32": "float",
31
+ "integer": "int",
32
+ "float": "float",
33
+ "numeric": "float",
34
+ "any": "Any",
35
+ "dictionary": "dict[str, Any]",
36
+ };
37
+ /**
38
+ * Stale-file deletion is intentionally disabled until manifest cleanup is enabled.
39
+ */
40
+ function cleanupFlatTypeFiles(relDir, isTypeFile) {
41
+ void relDir;
42
+ void isTypeFile;
43
+ return;
44
+ }
45
+ /**
46
+ * Main entry point for Python code generation.
47
+ * Prepares data contexts and delegates rendering to inline emitter functions.
48
+ */
49
+ export const generatePython = async (context, node, emitTarget, options) => {
50
+ const allTypes = Array.from(enumerateTypes(node));
51
+ const nodes = filterNodes(allTypes, options);
52
+ // Build the expression IR infrastructure
53
+ const registry = TypeRegistry.fromTypeGraph(allTypes);
54
+ const visitor = new PythonExprVisitor(registry);
55
+ // Determine package name from root node namespace (e.g., "Typra" -> "typra")
56
+ const packageName = node.typeName.namespace.toLowerCase();
57
+ // Import path for test files — defaults to packageName, can be overridden via import-path config
58
+ const importPath = emitTarget["import-path"] || packageName;
59
+ // Stale flat-file cleanup is disabled in this slice.
60
+ cleanupFlatTypeFiles(emitTarget["output-dir"], name => /^_.+\.py$/.test(name) && name !== "_context.py");
61
+ cleanupFlatTypeFiles(emitTarget["test-dir"], name => /^test_.+\.py$/.test(name) && name !== "test_context.py");
62
+ // Emit py.typed marker for PEP 561 compliance
63
+ await emitPythonFile(context, 'py.typed', '', emitTarget["output-dir"]);
64
+ // Render LoadContext file
65
+ const contextContext = buildLoadContextContext();
66
+ const contextContent = emitPythonContext(contextContext.header);
67
+ await emitPythonFile(context, '_context.py', contextContent, emitTarget["output-dir"]);
68
+ // Render LoadContext tests
69
+ if (emitTarget["test-dir"]) {
70
+ const testContextContext = buildLoadContextContext(importPath);
71
+ const testContextContent = emitPythonTestContext(testContextContext.header, importPath);
72
+ await emitPythonFile(context, 'test_context.py', testContextContent, emitTarget["test-dir"]);
73
+ }
74
+ // Render init file — group-aware, imports from {group} subpackages
75
+ const initContext = buildInitContext(nodes);
76
+ const initContent = emitPythonInit(initContext.baseTypes, initContext.types);
77
+ await emitPythonFile(context, '__init__.py', initContent, emitTarget["output-dir"]);
78
+ // Collect polymorphic type names once for the full type graph
79
+ const polymorphicTypeNames = new Set();
80
+ for (const n of allTypes) {
81
+ for (const name of collectPolymorphicTypeNames(n, registry)) {
82
+ polymorphicTypeNames.add(name);
83
+ }
84
+ }
85
+ // Group nodes by their semantic group folder
86
+ const groupMap = new Map();
87
+ for (const n of nodes) {
88
+ if (!n.base) {
89
+ const g = n.group || "";
90
+ if (!groupMap.has(g))
91
+ groupMap.set(g, []);
92
+ groupMap.get(g).push(n);
93
+ }
94
+ }
95
+ // Render each base type and its children as a single file, into group subfolder
96
+ for (const n of nodes) {
97
+ // Skip child types - they're rendered with their parent
98
+ if (!n.base) {
99
+ const group = n.group || "";
100
+ const fileDecl = lowerFile(n, registry, polymorphicTypeNames);
101
+ const fileContent = emitPythonFileDecl(fileDecl, visitor, group);
102
+ const outDir = group ? `${emitTarget["output-dir"]}/${group}` : emitTarget["output-dir"];
103
+ await emitPythonFile(context, `_${n.typeName.name}.py`, fileContent, outDir);
104
+ }
105
+ // Render test file for each type (skip protocols — they have no data to test)
106
+ if (emitTarget["test-dir"] && !n.isProtocol) {
107
+ const testDir = n.group ? `${emitTarget["test-dir"]}/${n.group}` : emitTarget["test-dir"];
108
+ const testContext = buildTestContext(n, importPath);
109
+ const testContent = emitPythonTest(testContext);
110
+ await emitPythonFile(context, `test_${toSnakeCase(n.typeName.name)}.py`, testContent, testDir);
111
+ }
112
+ }
113
+ // Emit group-level __init__.py for each group
114
+ for (const [group, groupNodes] of groupMap) {
115
+ if (!group)
116
+ continue; // Root-level types (if any) are covered by the root __init__.py
117
+ const groupInitContent = emitPythonGroupInit(group, groupNodes);
118
+ await emitPythonFile(context, '__init__.py', groupInitContent, `${emitTarget["output-dir"]}/${group}`);
119
+ }
120
+ // Format emitted files if format option is enabled (default: true)
121
+ if (emitTarget.format !== false) {
122
+ // Resolve output paths relative to current working directory (where tsp compile was run)
123
+ const outputDir = emitTarget["output-dir"]
124
+ ? resolve(process.cwd(), emitTarget["output-dir"])
125
+ : context.emitterOutputDir;
126
+ const testDir = emitTarget["test-dir"]
127
+ ? resolve(process.cwd(), emitTarget["test-dir"])
128
+ : undefined;
129
+ formatPythonFiles(outputDir, testDir);
130
+ }
131
+ };
132
+ /**
133
+ * Format Python files using ruff linter and formatter.
134
+ * Runs formatters via uv from the Python project root (where pyproject.toml is located).
135
+ * CI enforces `ruff check` and `ruff format --check`, so both must pass.
136
+ */
137
+ function formatPythonFiles(outputDir, testDir) {
138
+ // Find the Python project root by looking for pyproject.toml
139
+ const projectRoot = findPythonProjectRoot(outputDir);
140
+ if (!projectRoot) {
141
+ console.warn(`Warning: Could not find pyproject.toml. Skipping formatting.`);
142
+ return;
143
+ }
144
+ const dirs = [outputDir, ...(testDir ? [testDir] : [])];
145
+ for (const dir of dirs) {
146
+ // Run ruff check with auto-fix (linting)
147
+ try {
148
+ execFileSync("uv", ["run", "ruff", "check", "--fix", dir], {
149
+ cwd: projectRoot,
150
+ stdio: 'pipe',
151
+ encoding: 'utf-8'
152
+ });
153
+ }
154
+ catch (error) {
155
+ console.warn(`Warning: ruff check failed for ${dir}. You may need to install ruff or run it manually.`);
156
+ }
157
+ // Run ruff format (formatting — matches CI's `ruff format --check`)
158
+ try {
159
+ execFileSync("uv", ["run", "ruff", "format", dir], {
160
+ cwd: projectRoot,
161
+ stdio: 'pipe',
162
+ encoding: 'utf-8'
163
+ });
164
+ }
165
+ catch (error) {
166
+ console.warn(`Warning: ruff format failed for ${dir}. You may need to install ruff or run it manually.`);
167
+ }
168
+ }
169
+ }
170
+ /**
171
+ * Find the Python project root by traversing up from the output directory
172
+ * looking for pyproject.toml.
173
+ */
174
+ function findPythonProjectRoot(startDir) {
175
+ let currentDir = resolve(startDir);
176
+ const root = resolve('/');
177
+ // On Windows, also check for drive root (e.g., "C:\")
178
+ while (currentDir !== root && currentDir !== dirname(currentDir)) {
179
+ const pyprojectPath = resolve(currentDir, 'pyproject.toml');
180
+ if (existsSync(pyprojectPath)) {
181
+ return currentDir;
182
+ }
183
+ currentDir = dirname(currentDir);
184
+ }
185
+ return undefined;
186
+ }
187
+ /**
188
+ * Build context for rendering a single Python class.
189
+ * Resolves factories and coercions via the expression IR when registry/visitor provided.
190
+ */
191
+ function buildClassContext(node, registry, visitor) {
192
+ // Pre-compute safe factory method names to avoid field/classmethod collisions.
193
+ const fieldNames = new Set(node.properties.map(p => toSnakeCase(p.name)));
194
+ const factoryNameMap = {};
195
+ for (const factory of node.factories) {
196
+ const snakeName = toSnakeCase(factory.name);
197
+ factoryNameMap[factory.name] = fieldNames.has(snakeName) ? `create_${snakeName}` : snakeName;
198
+ }
199
+ // Resolve factories via expression IR (when registry+visitor available)
200
+ const factoryTypeRefs = [];
201
+ const renderedFactories = (registry && visitor) ? (node.factories || []).map(f => {
202
+ const expr = resolveFactoryExpr(f.sets, f.params, node, registry);
203
+ for (const ref of collectExprTypeRefs(expr)) {
204
+ factoryTypeRefs.push(ref.name);
205
+ }
206
+ return {
207
+ name: f.name,
208
+ safeName: factoryNameMap[f.name],
209
+ params: f.params,
210
+ body: visitor.visitExpr(expr),
211
+ };
212
+ }) : [];
213
+ // Resolve coercions via expression IR
214
+ const renderedCoercions = (registry && visitor) ? (node.coercions || []).map(c => {
215
+ const expr = resolveCoerceExpr(c.expansion, c.scalar, node, registry, "data");
216
+ return {
217
+ scalar: pythonTypeMapper[c.scalar] || c.scalar,
218
+ expression: renderObjectLiteral(expr, visitor, "py"),
219
+ };
220
+ }) : [];
221
+ // Keep factory-referenced types for file-level import resolution
222
+ // Don't merge into class imports — the file template handles imports
223
+ const baseImports = getUniqueImportTypes(node);
224
+ return {
225
+ node,
226
+ typeMapper: pythonTypeMapper,
227
+ coercions: prepareCoercions(node),
228
+ polymorphicTypes: node.retrievePolymorphicTypes(),
229
+ imports: baseImports,
230
+ collectionTypes: getCollectionTypes(node),
231
+ coercionProperty: getCoercionProperty(node),
232
+ factoryNameMap,
233
+ renderedFactories,
234
+ renderedCoercions,
235
+ factoryTypeRefs,
236
+ };
237
+ }
238
+ /**
239
+ * Build context for rendering a Python file with a base type and its children.
240
+ */
241
+ function buildFileContext(node, registry, visitor) {
242
+ const classes = [
243
+ buildClassContext(node, registry, visitor),
244
+ ...node.childTypes.map(ct => buildClassContext(ct, registry, visitor))
245
+ ];
246
+ // Build grouped imports: module → set of type names to import from that module
247
+ // This handles both base types (module == type) and child types (module == parent type)
248
+ const childTypeNames = new Set([node.typeName.name, ...node.childTypes.map(ct => ct.typeName.name)]);
249
+ const importMap = new Map();
250
+ const addImport = (typeName) => {
251
+ if (childTypeNames.has(typeName))
252
+ return; // Skip types defined in this file
253
+ // Find which module this type lives in
254
+ const refNode = registry.get(typeName);
255
+ const module = refNode?.base ? refNode.base.name : typeName;
256
+ if (!importMap.has(module))
257
+ importMap.set(module, new Set());
258
+ importMap.get(module).add(typeName);
259
+ };
260
+ // Property-based imports (base types referenced by properties)
261
+ for (const name of getUniqueImportTypes(node)) {
262
+ addImport(name);
263
+ }
264
+ // Factory-referenced imports (may include child types like TextPart)
265
+ for (const cls of classes) {
266
+ for (const ref of cls.factoryTypeRefs) {
267
+ addImport(ref);
268
+ }
269
+ }
270
+ const imports = Array.from(importMap.entries())
271
+ .map(([module, names]) => ({ module, names: Array.from(names).sort() }))
272
+ .sort((a, b) => a.module.localeCompare(b.module));
273
+ return {
274
+ containsAbstract: node.isAbstract || node.childTypes.some(c => c.isAbstract),
275
+ typings: ["Any", "Callable", "Optional"],
276
+ imports,
277
+ classes,
278
+ typeMapper: pythonTypeMapper,
279
+ };
280
+ }
281
+ /**
282
+ * Build context for rendering the __init__.py file.
283
+ */
284
+ function buildInitContext(nodes) {
285
+ return {
286
+ baseTypes: nodes.filter(n => !n.base),
287
+ types: nodes,
288
+ };
289
+ }
290
+ /**
291
+ * Build context for rendering a test file using the standardized shared helper.
292
+ */
293
+ function buildTestContext(node, packageName) {
294
+ const base = buildBaseTestContext(node, packageName, pythonTestOptions);
295
+ const classCtx = buildClassContext(node);
296
+ return { ...base, classCtx };
297
+ }
298
+ /**
299
+ * Build context for rendering the LoadContext file.
300
+ */
301
+ function buildLoadContextContext(packageName) {
302
+ return {
303
+ header: "Typra LoadContext",
304
+ package: packageName,
305
+ };
306
+ }
307
+ /**
308
+ * Prepare coercion representations for template rendering.
309
+ * Converts coercions to Python-specific format with JSON stringification.
310
+ */
311
+ function prepareCoercions(node) {
312
+ if (!node.coercions || node.coercions.length === 0) {
313
+ return [];
314
+ }
315
+ return node.coercions.map(alt => ({
316
+ scalar: pythonTypeMapper[alt.scalar],
317
+ alternate: JSON.stringify(alt.expansion, null, '')
318
+ .replaceAll('\n', '')
319
+ .replaceAll('"{value}"', ' data'),
320
+ }));
321
+ }
322
+ /**
323
+ * Get the coercion property name from coercions.
324
+ * The coercion property is the one that receives "{value}" in the expansion.
325
+ */
326
+ function getCoercionProperty(node) {
327
+ if (!node.coercions || node.coercions.length === 0) {
328
+ return null;
329
+ }
330
+ // Look for a property that has "{value}" as its expansion value
331
+ for (const alt of node.coercions) {
332
+ for (const [key, value] of Object.entries(alt.expansion)) {
333
+ if (value === "{value}") {
334
+ return key;
335
+ }
336
+ }
337
+ }
338
+ return null;
339
+ }
340
+ /**
341
+ * Get collection properties with their nested type info for load_* methods.
342
+ */
343
+ function getCollectionTypes(node) {
344
+ return node.properties
345
+ .filter(p => p.isCollection && !p.isScalar && !p.isDict)
346
+ .map(p => ({
347
+ prop: p,
348
+ type: p.type?.properties.filter(t => t.name !== "name").map(t => t.name) || [],
349
+ hasNameProperty: p.type?.properties.some(t => t.name === "name") || false,
350
+ }));
351
+ }
352
+ /**
353
+ * Get unique import types needed from other modules.
354
+ * Excludes self-references and parent types.
355
+ */
356
+ function getUniqueImportTypes(node) {
357
+ const imports = [
358
+ node.properties.filter(p => !p.isScalar && !p.isDict).map(p => p.typeName.name),
359
+ ...node.childTypes.flatMap(c => c.properties.filter(p => !p.isScalar && !p.isDict).map(p => p.typeName.name))
360
+ ].flat().filter(n => n !== node.typeName.name && node.base?.name !== n);
361
+ // Remove duplicates and sort
362
+ return Array.from(new Set(imports)).sort();
363
+ }
364
+ /**
365
+ * Write generated Python content to file using TypeSpec's emitFile API.
366
+ */
367
+ async function emitPythonFile(context, filename, content, outputDir) {
368
+ outputDir = outputDir || `${context.emitterOutputDir}/python`;
369
+ const filePath = resolvePath(outputDir, filename);
370
+ await emitGeneratedFile(context, filePath, content);
371
+ }
372
+ //# sourceMappingURL=driver.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Python code emitter — Declaration IR → Python source code.
3
+ *
4
+ * Replaces `file.py.njk` + `_macros.njk` (~619 lines of Nunjucks templates)
5
+ * with a typed TypeScript function that walks the FileDecl tree.
6
+ *
7
+ * The emitter produces correctly-formatted Python 3.11+ code using modern
8
+ * `X | None` union syntax. Output should be byte-identical to the template
9
+ * output after ruff+black formatting.
10
+ *
11
+ * Structural blocks emitted (in order):
12
+ * 1. Header comment (auto-generated warning)
13
+ * 2. Imports (abc, dataclasses, typing, context, local types)
14
+ * 3. For each type in the file:
15
+ * a. @dataclass class definition with docstring
16
+ * b. _shorthand_property ClassVar
17
+ * c. Field declarations with type annotations + defaults
18
+ * d. load() static method
19
+ * e. Collection helpers (load_X / save_X)
20
+ * f. Polymorphic dispatch (load_<discriminator>)
21
+ * g. save() instance method
22
+ * h. to_yaml() and to_json() methods
23
+ * i. Factory classmethods
24
+ * j. Method stubs (as comments)
25
+ */
26
+ import { FileDecl } from "../../ir/declarations.js";
27
+ import { ExprVisitor } from "../../ir/visitor.js";
28
+ /**
29
+ * Emit a complete Python file from a FileDecl.
30
+ */
31
+ export declare function emitPythonFile(decl: FileDecl, visitor: ExprVisitor, group?: string): string;