@workos/oagen-emitters 0.2.0 → 0.3.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/.husky/pre-commit +1 -0
- package/.oxfmtrc.json +8 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11943 -2728
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +298 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-dotnet.ts +17 -3
- package/smoke/sdk-elixir.ts +17 -3
- package/smoke/sdk-go.ts +137 -46
- package/smoke/sdk-kotlin.ts +23 -4
- package/smoke/sdk-node.ts +15 -3
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/smoke/sdk-ruby.ts +17 -3
- package/smoke/sdk-rust.ts +16 -3
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +81 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +191 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +3 -0
- package/src/node/client.ts +167 -122
- package/src/node/enums.ts +13 -4
- package/src/node/errors.ts +42 -233
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +15 -5
- package/src/node/index.ts +65 -16
- package/src/node/models.ts +264 -96
- package/src/node/naming.ts +52 -25
- package/src/node/resources.ts +621 -172
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +71 -27
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +56 -64
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +171 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +298 -0
- package/src/php/resources.ts +561 -0
- package/src/php/tests.ts +533 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +151 -0
- package/src/python/client.ts +337 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +209 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +255 -0
- package/src/shared/naming-utils.ts +107 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +59 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/node/client.test.ts +199 -94
- package/test/node/enums.test.ts +75 -3
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +109 -20
- package/test/node/naming.test.ts +37 -4
- package/test/node/resources.test.ts +662 -30
- package/test/node/serializers.test.ts +36 -7
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +94 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +644 -0
- package/test/php/tests.test.ts +118 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -744
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import type { Model, Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { assignModelsToServices, collectFieldDependencies, planOperation, walkTypeRef } from '@workos/oagen';
|
|
3
|
+
import { mapTypeRef } from './type-map.js';
|
|
4
|
+
import { className, fieldName, fileName, buildMountDirMap, dirToModule } from './naming.js';
|
|
5
|
+
import { assignEnumsToServices, collectGeneratedEnumSymbolsByDir } from './enums.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate Python dataclass model files from IR Model definitions.
|
|
9
|
+
* Each model becomes a single .py file with a dataclass, from_dict, and to_dict.
|
|
10
|
+
*/
|
|
11
|
+
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
12
|
+
if (models.length === 0) return [];
|
|
13
|
+
|
|
14
|
+
const modelToService = assignModelsToServices(models, ctx.spec.services);
|
|
15
|
+
const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
|
|
16
|
+
const mountDirMap = buildMountDirMap(ctx);
|
|
17
|
+
const resolveDir = (irService: string | undefined) =>
|
|
18
|
+
irService ? (mountDirMap.get(irService) ?? 'common') : 'common';
|
|
19
|
+
const files: GeneratedFile[] = [];
|
|
20
|
+
const emittedModelSymbolsByDir = new Map<string, string[]>();
|
|
21
|
+
const modelUsage = collectModelUsage(ctx.spec);
|
|
22
|
+
|
|
23
|
+
// Build recursive structural hashes for deduplication.
|
|
24
|
+
// Model/enum references are resolved bottom-up so structurally-identical
|
|
25
|
+
// model trees (e.g. event context/actor sub-models) get the same hash.
|
|
26
|
+
const recursiveHashes = buildRecursiveHashMap(models, ctx.spec.enums);
|
|
27
|
+
const hashGroups = new Map<string, string[]>(); // hash -> model names
|
|
28
|
+
for (const model of models) {
|
|
29
|
+
if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
|
|
30
|
+
const hash = recursiveHashes.get(model.name) ?? '';
|
|
31
|
+
if (!hashGroups.has(hash)) hashGroups.set(hash, []);
|
|
32
|
+
hashGroups.get(hash)!.push(model.name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// For each group of identical models, pick canonical (alphabetically first)
|
|
36
|
+
const aliasOf = new Map<string, string>(); // alias name -> canonical name
|
|
37
|
+
for (const [, names] of hashGroups) {
|
|
38
|
+
if (names.length <= 1) continue;
|
|
39
|
+
const sorted = [...names].sort((a, b) => compareAliasPriority(a, b, modelUsage));
|
|
40
|
+
const canonical = sorted[0];
|
|
41
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
42
|
+
if (canAliasModels(canonical, sorted[i], modelUsage)) {
|
|
43
|
+
aliasOf.set(sorted[i], canonical);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const model of models) {
|
|
49
|
+
// Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
|
|
50
|
+
if (isListWrapperModel(model)) continue;
|
|
51
|
+
// Skip all list metadata models (e.g., ListMetadata, FooListListMetadata)
|
|
52
|
+
if (isListMetadataModel(model)) continue;
|
|
53
|
+
|
|
54
|
+
const service = modelToService.get(model.name);
|
|
55
|
+
const dirName = resolveDir(service);
|
|
56
|
+
const modelClassName = className(model.name);
|
|
57
|
+
|
|
58
|
+
// If this model is an alias for a canonical model, generate a type alias file
|
|
59
|
+
const canonicalName = aliasOf.get(model.name);
|
|
60
|
+
if (canonicalName) {
|
|
61
|
+
const canonicalService = modelToService.get(canonicalName);
|
|
62
|
+
const canonicalDir = resolveDir(canonicalService);
|
|
63
|
+
const canonicalClassName = className(canonicalName);
|
|
64
|
+
const lines: string[] = [];
|
|
65
|
+
lines.push('from typing_extensions import TypeAlias');
|
|
66
|
+
// Always use direct file import to avoid barrel dependency on the canonical
|
|
67
|
+
if (canonicalDir === dirName) {
|
|
68
|
+
lines.push(`from .${fileName(canonicalName)} import ${canonicalClassName}`);
|
|
69
|
+
} else {
|
|
70
|
+
lines.push(
|
|
71
|
+
`from ${ctx.namespace}.${dirToModule(canonicalDir)}.models.${fileName(canonicalName)} import ${canonicalClassName}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
lines.push('');
|
|
75
|
+
lines.push(`${modelClassName}: TypeAlias = ${canonicalClassName}`);
|
|
76
|
+
files.push({
|
|
77
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
|
|
78
|
+
content: lines.join('\n'),
|
|
79
|
+
integrateTarget: true,
|
|
80
|
+
overwriteExisting: true,
|
|
81
|
+
});
|
|
82
|
+
if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
|
|
83
|
+
emittedModelSymbolsByDir.get(dirName)!.push(model.name);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Deduplicate fields that map to the same snake_case name
|
|
88
|
+
const seenFieldNames = new Set<string>();
|
|
89
|
+
const deduplicatedFields = model.fields.filter((f) => {
|
|
90
|
+
const pyName = fieldName(f.name);
|
|
91
|
+
if (seenFieldNames.has(pyName)) return false;
|
|
92
|
+
seenFieldNames.add(pyName);
|
|
93
|
+
return true;
|
|
94
|
+
});
|
|
95
|
+
const dedupModel = { ...model, fields: deduplicatedFields };
|
|
96
|
+
const deps = collectFieldDependencies(dedupModel);
|
|
97
|
+
|
|
98
|
+
const lines: string[] = [];
|
|
99
|
+
|
|
100
|
+
// Collect typing imports
|
|
101
|
+
const typingImports = new Set<string>();
|
|
102
|
+
typingImports.add('Any');
|
|
103
|
+
typingImports.add('Dict');
|
|
104
|
+
for (const field of deduplicatedFields) {
|
|
105
|
+
collectTypingImports(field.type, typingImports);
|
|
106
|
+
}
|
|
107
|
+
const hasOptional = deduplicatedFields.some((f) => isOptionalField(model.name, f, ctx));
|
|
108
|
+
if (hasOptional) typingImports.add('Optional');
|
|
109
|
+
const usesDateTime = deduplicatedFields.some((f) => isDateTimeType(f.type));
|
|
110
|
+
const usesEnum = deps.enums.size > 0;
|
|
111
|
+
|
|
112
|
+
lines.push('from __future__ import annotations');
|
|
113
|
+
lines.push('');
|
|
114
|
+
lines.push('from dataclasses import dataclass');
|
|
115
|
+
if (usesDateTime) {
|
|
116
|
+
lines.push('from datetime import datetime');
|
|
117
|
+
}
|
|
118
|
+
if (usesEnum) {
|
|
119
|
+
lines.push('from enum import Enum');
|
|
120
|
+
}
|
|
121
|
+
lines.push('from typing import cast');
|
|
122
|
+
lines.push(`from typing import ${[...typingImports].sort().join(', ')}`);
|
|
123
|
+
lines.push(`from ${ctx.namespace}._types import _raise_deserialize_error`);
|
|
124
|
+
if (usesDateTime) {
|
|
125
|
+
lines.push(`from ${ctx.namespace}._types import _format_datetime, _parse_datetime`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Import referenced models from their service's models package.
|
|
129
|
+
// Always use direct file imports (not barrel __init__.py) to avoid
|
|
130
|
+
// circular-import chains when common/ models reference service modules.
|
|
131
|
+
if (deps.models.size > 0) {
|
|
132
|
+
lines.push('');
|
|
133
|
+
for (const modelName of [...deps.models].sort()) {
|
|
134
|
+
if (modelName === model.name) continue; // skip self
|
|
135
|
+
const modelService = modelToService.get(modelName);
|
|
136
|
+
const modelDir = resolveDir(modelService);
|
|
137
|
+
if (modelDir === dirName) {
|
|
138
|
+
lines.push(`from .${fileName(modelName)} import ${className(modelName)}`);
|
|
139
|
+
} else {
|
|
140
|
+
lines.push(
|
|
141
|
+
`from ${ctx.namespace}.${dirToModule(modelDir)}.models.${fileName(modelName)} import ${className(modelName)}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Import referenced enums — same direct-file strategy.
|
|
148
|
+
if (deps.enums.size > 0) {
|
|
149
|
+
for (const enumName of [...deps.enums].sort()) {
|
|
150
|
+
const enumService = enumToService.get(enumName);
|
|
151
|
+
const enumDir = resolveDir(enumService);
|
|
152
|
+
if (enumDir === dirName) {
|
|
153
|
+
lines.push(`from .${fileName(enumName)} import ${className(enumName)}`);
|
|
154
|
+
} else {
|
|
155
|
+
lines.push(
|
|
156
|
+
`from ${ctx.namespace}.${dirToModule(enumDir)}.models.${fileName(enumName)} import ${className(enumName)}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
lines.push('');
|
|
163
|
+
lines.push('');
|
|
164
|
+
|
|
165
|
+
// Dataclass definition
|
|
166
|
+
lines.push('@dataclass(slots=True)');
|
|
167
|
+
lines.push(`class ${modelClassName}:`);
|
|
168
|
+
if (model.description) {
|
|
169
|
+
lines.push(` """${model.description}"""`);
|
|
170
|
+
} else {
|
|
171
|
+
// Generate a default docstring from the class name when the spec
|
|
172
|
+
// doesn't provide a description.
|
|
173
|
+
let readable = modelClassName.replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
174
|
+
readable = readable.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
|
|
175
|
+
lines.push(` """${readable} model."""`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
lines.push('');
|
|
179
|
+
|
|
180
|
+
// Sort fields: required first, then optional
|
|
181
|
+
const requiredFields = deduplicatedFields.filter((f) => !isOptionalField(model.name, f, ctx));
|
|
182
|
+
const optionalFields = deduplicatedFields.filter((f) => isOptionalField(model.name, f, ctx));
|
|
183
|
+
|
|
184
|
+
for (const field of requiredFields) {
|
|
185
|
+
const pyFieldName = fieldName(field.name);
|
|
186
|
+
const pyType = resolveModelFieldType(field.type);
|
|
187
|
+
if (field.description || field.deprecated) {
|
|
188
|
+
const parts: string[] = [];
|
|
189
|
+
if (field.description) parts.push(field.description);
|
|
190
|
+
if (field.deprecated) parts.push('.. deprecated:: This field is deprecated.');
|
|
191
|
+
lines.push(` ${pyFieldName}: ${pyType}`);
|
|
192
|
+
lines.push(` """${parts.join('\n\n ')}"""`);
|
|
193
|
+
} else {
|
|
194
|
+
lines.push(` ${pyFieldName}: ${pyType}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const field of optionalFields) {
|
|
199
|
+
const pyFieldName = fieldName(field.name);
|
|
200
|
+
const innerType =
|
|
201
|
+
field.type.kind === 'nullable' ? resolveModelFieldType(field.type.inner) : resolveModelFieldType(field.type);
|
|
202
|
+
const pyType = `Optional[${innerType}]`;
|
|
203
|
+
if (field.description || field.deprecated) {
|
|
204
|
+
const parts: string[] = [];
|
|
205
|
+
if (field.description) parts.push(field.description);
|
|
206
|
+
if (field.deprecated) parts.push('.. deprecated:: This field is deprecated.');
|
|
207
|
+
lines.push(` ${pyFieldName}: ${pyType} = None`);
|
|
208
|
+
lines.push(` """${parts.join('\n\n ')}"""`);
|
|
209
|
+
} else {
|
|
210
|
+
lines.push(` ${pyFieldName}: ${pyType} = None`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// from_dict class method
|
|
215
|
+
lines.push('');
|
|
216
|
+
lines.push(' @classmethod');
|
|
217
|
+
lines.push(` def from_dict(cls, data: Dict[str, Any]) -> "${modelClassName}":`);
|
|
218
|
+
lines.push(` """Deserialize from a dictionary."""`);
|
|
219
|
+
lines.push(' try:');
|
|
220
|
+
lines.push(' return cls(');
|
|
221
|
+
|
|
222
|
+
for (const field of [...requiredFields, ...optionalFields]) {
|
|
223
|
+
const pyFieldName = fieldName(field.name);
|
|
224
|
+
const wireKey = field.name; // Wire keys are snake_case from the spec
|
|
225
|
+
const isRequired = !isOptionalField(model.name, field, ctx);
|
|
226
|
+
const accessor = isRequired ? `data["${wireKey}"]` : `data.get("${wireKey}")`;
|
|
227
|
+
// For deserialization expressions, nullable types must always handle None
|
|
228
|
+
// even when the field itself is required (the key must be present, but value can be null).
|
|
229
|
+
const deserRequired = isRequired && field.type.kind !== 'nullable';
|
|
230
|
+
const walrusVar = `_v_${pyFieldName}`;
|
|
231
|
+
const deserExpr = deserializeField(field.type, accessor, deserRequired, walrusVar);
|
|
232
|
+
lines.push(` ${pyFieldName}=${deserExpr},`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
lines.push(' )');
|
|
236
|
+
lines.push(' except (KeyError, ValueError) as e:');
|
|
237
|
+
lines.push(` _raise_deserialize_error("${modelClassName}", e)`);
|
|
238
|
+
|
|
239
|
+
// to_dict instance method
|
|
240
|
+
lines.push('');
|
|
241
|
+
lines.push(' def to_dict(self) -> Dict[str, Any]:');
|
|
242
|
+
lines.push(' """Serialize to a dictionary."""');
|
|
243
|
+
lines.push(' result: Dict[str, Any] = {}');
|
|
244
|
+
|
|
245
|
+
for (const field of [...requiredFields, ...optionalFields]) {
|
|
246
|
+
const pyFieldName = fieldName(field.name);
|
|
247
|
+
const wireKey = field.name;
|
|
248
|
+
const isRequired = !isOptionalField(model.name, field, ctx);
|
|
249
|
+
|
|
250
|
+
const isNullable = field.type.kind === 'nullable';
|
|
251
|
+
if (isRequired && !isNullable) {
|
|
252
|
+
// Required non-nullable: always serialize directly
|
|
253
|
+
const serExpr = serializeField(field.type, `self.${pyFieldName}`);
|
|
254
|
+
lines.push(` result["${wireKey}"] = ${serExpr}`);
|
|
255
|
+
} else if (isNullable) {
|
|
256
|
+
// Nullable fields should round-trip explicit None as null, even when optional
|
|
257
|
+
const innerType = (field.type as any).inner;
|
|
258
|
+
const serExpr = serializeField(innerType, `self.${pyFieldName}`);
|
|
259
|
+
lines.push(` if self.${pyFieldName} is not None:`);
|
|
260
|
+
lines.push(` result["${wireKey}"] = ${serExpr}`);
|
|
261
|
+
lines.push(` else:`);
|
|
262
|
+
lines.push(` result["${wireKey}"] = None`);
|
|
263
|
+
} else {
|
|
264
|
+
// Optional non-nullable fields should be omitted when unset
|
|
265
|
+
const serExpr = serializeField(field.type, `self.${pyFieldName}`);
|
|
266
|
+
lines.push(` if self.${pyFieldName} is not None:`);
|
|
267
|
+
lines.push(` result["${wireKey}"] = ${serExpr}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
lines.push(' return result');
|
|
272
|
+
|
|
273
|
+
files.push({
|
|
274
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
|
|
275
|
+
content: lines.join('\n'),
|
|
276
|
+
integrateTarget: true,
|
|
277
|
+
overwriteExisting: true,
|
|
278
|
+
});
|
|
279
|
+
if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
|
|
280
|
+
emittedModelSymbolsByDir.get(dirName)!.push(model.name);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Generate __init__.py barrel files for each models/ directory
|
|
284
|
+
// Include both models and enums
|
|
285
|
+
const symbolsByDir = new Map<string, string[]>();
|
|
286
|
+
for (const [dirName, names] of emittedModelSymbolsByDir) {
|
|
287
|
+
const key = `src/${ctx.namespace}/${dirName}/models`;
|
|
288
|
+
if (!symbolsByDir.has(key)) symbolsByDir.set(key, []);
|
|
289
|
+
symbolsByDir.get(key)!.push(...names);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Also include enums in the barrels using the enum emitter's actual output placement.
|
|
293
|
+
const reachableEnumNames = collectReachableEnumNames(ctx);
|
|
294
|
+
const emittedEnums = ctx.spec.enums.filter((enumDef) => reachableEnumNames.has(enumDef.name));
|
|
295
|
+
const enumSymbolsByDir = collectGeneratedEnumSymbolsByDir(emittedEnums, ctx);
|
|
296
|
+
for (const [dirName, names] of enumSymbolsByDir) {
|
|
297
|
+
const key = `src/${ctx.namespace}/${dirName}/models`;
|
|
298
|
+
if (!symbolsByDir.has(key)) symbolsByDir.set(key, []);
|
|
299
|
+
symbolsByDir.get(key)!.push(...names);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Build set of service directory model paths — these get their parent __init__.py
|
|
303
|
+
// from generateServiceInits in client.ts, so we must not create a competing one here.
|
|
304
|
+
const serviceDirModelPaths = new Set<string>();
|
|
305
|
+
for (const service of ctx.spec.services) {
|
|
306
|
+
const dirName = mountDirMap.get(service.name) ?? resolveDir(service.name);
|
|
307
|
+
serviceDirModelPaths.add(`src/${ctx.namespace}/${dirName}/models`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const [dirPath, names] of symbolsByDir) {
|
|
311
|
+
// Use `import X as X` syntax for explicit re-exports (required by pyright strict)
|
|
312
|
+
const uniqueNames = [...new Set(names)].sort();
|
|
313
|
+
const importLines: string[] = [];
|
|
314
|
+
for (const name of uniqueNames) {
|
|
315
|
+
importLines.push(`from .${fileName(name)} import ${className(name)} as ${className(name)}`);
|
|
316
|
+
}
|
|
317
|
+
const imports = importLines.join('\n');
|
|
318
|
+
files.push({
|
|
319
|
+
path: `${dirPath}/__init__.py`,
|
|
320
|
+
content: imports,
|
|
321
|
+
integrateTarget: true,
|
|
322
|
+
overwriteExisting: true,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Only generate parent __init__.py for non-service dirs (e.g., common/).
|
|
326
|
+
// Service dirs get their __init__.py from generateServiceInits in client.ts
|
|
327
|
+
// which includes both the resource class re-export and model star import.
|
|
328
|
+
if (!serviceDirModelPaths.has(dirPath)) {
|
|
329
|
+
const parentDir = dirPath.replace(/\/models$/, '');
|
|
330
|
+
const reExports = [...new Set(names)]
|
|
331
|
+
.sort()
|
|
332
|
+
.map((name) => `from .models import ${className(name)} as ${className(name)}`)
|
|
333
|
+
.join('\n');
|
|
334
|
+
files.push({
|
|
335
|
+
path: `${parentDir}/__init__.py`,
|
|
336
|
+
content: reExports,
|
|
337
|
+
integrateTarget: true,
|
|
338
|
+
overwriteExisting: true,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return files;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function collectTypingImports(ref: any, imports: Set<string>): void {
|
|
347
|
+
switch (ref.kind) {
|
|
348
|
+
case 'array':
|
|
349
|
+
imports.add('List');
|
|
350
|
+
collectTypingImports(ref.items, imports);
|
|
351
|
+
break;
|
|
352
|
+
case 'nullable':
|
|
353
|
+
imports.add('Optional');
|
|
354
|
+
collectTypingImports(ref.inner, imports);
|
|
355
|
+
break;
|
|
356
|
+
case 'union':
|
|
357
|
+
imports.add('Union');
|
|
358
|
+
for (const v of ref.variants) collectTypingImports(v, imports);
|
|
359
|
+
break;
|
|
360
|
+
case 'map':
|
|
361
|
+
imports.add('Dict');
|
|
362
|
+
collectTypingImports(ref.valueType, imports);
|
|
363
|
+
break;
|
|
364
|
+
case 'literal':
|
|
365
|
+
imports.add('Literal');
|
|
366
|
+
break;
|
|
367
|
+
case 'primitive':
|
|
368
|
+
if (ref.type === 'unknown') imports.add('Any');
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function collectReachableEnumNames(ctx: EmitterContext): Set<string> {
|
|
374
|
+
const referencedModels = new Set<string>();
|
|
375
|
+
const referencedEnums = new Set<string>();
|
|
376
|
+
|
|
377
|
+
const collectFromTypeRef = (ref: any): void => {
|
|
378
|
+
walkTypeRef(ref, {
|
|
379
|
+
model: (r) => referencedModels.add(r.name),
|
|
380
|
+
enum: (r) => referencedEnums.add(r.name),
|
|
381
|
+
});
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
for (const service of ctx.spec.services) {
|
|
385
|
+
for (const op of service.operations) {
|
|
386
|
+
for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
|
|
387
|
+
collectFromTypeRef(p.type);
|
|
388
|
+
}
|
|
389
|
+
if (op.requestBody) collectFromTypeRef(op.requestBody);
|
|
390
|
+
collectFromTypeRef(op.response);
|
|
391
|
+
if (op.pagination) collectFromTypeRef(op.pagination.itemType);
|
|
392
|
+
for (const err of op.errors) {
|
|
393
|
+
if (err.type) collectFromTypeRef(err.type);
|
|
394
|
+
}
|
|
395
|
+
if (op.successResponses) {
|
|
396
|
+
for (const sr of op.successResponses) {
|
|
397
|
+
collectFromTypeRef(sr.type);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const modelsByName = new Map(ctx.spec.models.map((m) => [m.name, m]));
|
|
404
|
+
const visited = new Set<string>();
|
|
405
|
+
const queue = [...referencedModels];
|
|
406
|
+
while (queue.length > 0) {
|
|
407
|
+
const name = queue.pop()!;
|
|
408
|
+
if (visited.has(name)) continue;
|
|
409
|
+
visited.add(name);
|
|
410
|
+
const model = modelsByName.get(name);
|
|
411
|
+
if (!model) continue;
|
|
412
|
+
for (const field of model.fields) {
|
|
413
|
+
collectFromTypeRef(field.type);
|
|
414
|
+
for (const modelName of referencedModels) {
|
|
415
|
+
if (!visited.has(modelName)) queue.push(modelName);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return referencedEnums;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function collectModelUsage(spec: EmitterContext['spec']): {
|
|
424
|
+
requestOnly: Set<string>;
|
|
425
|
+
response: Set<string>;
|
|
426
|
+
mixed: Set<string>;
|
|
427
|
+
} {
|
|
428
|
+
const request = new Set<string>();
|
|
429
|
+
const response = new Set<string>();
|
|
430
|
+
|
|
431
|
+
for (const service of spec.services) {
|
|
432
|
+
for (const op of service.operations) {
|
|
433
|
+
const plan = planOperation(op);
|
|
434
|
+
if (plan.responseModelName) {
|
|
435
|
+
response.add(plan.responseModelName);
|
|
436
|
+
}
|
|
437
|
+
if (op.pagination?.itemType.kind === 'model') {
|
|
438
|
+
response.add(op.pagination.itemType.name);
|
|
439
|
+
}
|
|
440
|
+
if (op.requestBody?.kind === 'model') {
|
|
441
|
+
request.add(op.requestBody.name);
|
|
442
|
+
}
|
|
443
|
+
if (op.requestBody?.kind === 'union') {
|
|
444
|
+
for (const variant of op.requestBody.variants ?? []) {
|
|
445
|
+
if (variant.kind === 'model') request.add(variant.name);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const mixed = new Set<string>();
|
|
452
|
+
for (const name of request) {
|
|
453
|
+
if (response.has(name)) mixed.add(name);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const requestOnly = new Set([...request].filter((name) => !mixed.has(name)));
|
|
457
|
+
const responseOnly = new Set([...response].filter((name) => !mixed.has(name)));
|
|
458
|
+
|
|
459
|
+
return { requestOnly, response: responseOnly, mixed };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function compareAliasPriority(left: string, right: string, usage: ReturnType<typeof collectModelUsage>): number {
|
|
463
|
+
const score = (name: string): number => {
|
|
464
|
+
if (usage.response.has(name)) return 0;
|
|
465
|
+
if (usage.mixed.has(name)) return 1;
|
|
466
|
+
if (usage.requestOnly.has(name)) return 2;
|
|
467
|
+
return 3;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const diff = score(left) - score(right);
|
|
471
|
+
if (diff !== 0) return diff;
|
|
472
|
+
return left.localeCompare(right);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function canAliasModels(canonical: string, alias: string, usage: ReturnType<typeof collectModelUsage>): boolean {
|
|
476
|
+
// Don't alias across request/response boundaries — a request-only model
|
|
477
|
+
// and a response-only model may look identical today but evolve independently.
|
|
478
|
+
if (
|
|
479
|
+
(usage.response.has(canonical) && usage.requestOnly.has(alias)) ||
|
|
480
|
+
(usage.response.has(alias) && usage.requestOnly.has(canonical))
|
|
481
|
+
) {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function isOptionalField(modelName: string, field: Model['fields'][number], ctx: EmitterContext): boolean {
|
|
488
|
+
void modelName;
|
|
489
|
+
void ctx;
|
|
490
|
+
// A field is optional (gets = None default) only if it's not required or deprecated.
|
|
491
|
+
// Nullable-required fields (required: true, type: nullable) are NOT optional —
|
|
492
|
+
// they must appear in the API response (value can be null, but key must be present).
|
|
493
|
+
// The spec's required status always takes precedence over the old SDK's API surface.
|
|
494
|
+
if (!field.required || field.deprecated) return true;
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function resolveModelFieldType(ref: any): string {
|
|
499
|
+
// Handle nullable datetime: return Optional[datetime] to preserve nullable wrapper
|
|
500
|
+
if (ref.kind === 'nullable' && isDateTimeType(ref.inner)) {
|
|
501
|
+
return 'Optional[datetime]';
|
|
502
|
+
}
|
|
503
|
+
if (isDateTimeType(ref)) {
|
|
504
|
+
return 'datetime';
|
|
505
|
+
}
|
|
506
|
+
return mapTypeRef(ref);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function isDateTimeType(ref: any): boolean {
|
|
510
|
+
if (ref.kind === 'nullable') {
|
|
511
|
+
return isDateTimeType(ref.inner);
|
|
512
|
+
}
|
|
513
|
+
return ref.kind === 'primitive' && ref.type === 'string' && ref.format === 'date-time';
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function deserializeField(ref: any, accessor: string, isRequired: boolean, walrusVar: string = '_v'): string {
|
|
517
|
+
if (isDateTimeType(ref)) {
|
|
518
|
+
if (isRequired) {
|
|
519
|
+
return `_parse_datetime(${accessor})`;
|
|
520
|
+
}
|
|
521
|
+
return `_parse_datetime(${walrusVar}) if (${walrusVar} := ${accessor}) is not None else None`;
|
|
522
|
+
}
|
|
523
|
+
switch (ref.kind) {
|
|
524
|
+
case 'model': {
|
|
525
|
+
if (isRequired) {
|
|
526
|
+
return `${className(ref.name)}.from_dict(cast(Dict[str, Any], ${accessor}))`;
|
|
527
|
+
}
|
|
528
|
+
return `${className(ref.name)}.from_dict(cast(Dict[str, Any], ${walrusVar})) if (${walrusVar} := ${accessor}) is not None else None`;
|
|
529
|
+
}
|
|
530
|
+
case 'array': {
|
|
531
|
+
if (ref.items.kind === 'model') {
|
|
532
|
+
const listExpr = `[${className(ref.items.name)}.from_dict(cast(Dict[str, Any], item)) for item in cast(list[Any], ${isRequired ? accessor : walrusVar})]`;
|
|
533
|
+
if (isRequired) {
|
|
534
|
+
return listExpr;
|
|
535
|
+
}
|
|
536
|
+
// For optional arrays, preserve None instead of converting to []
|
|
537
|
+
return `${listExpr} if (${walrusVar} := ${accessor}) is not None else None`;
|
|
538
|
+
}
|
|
539
|
+
if (ref.items.kind === 'enum') {
|
|
540
|
+
const enumClass = className(ref.items.name);
|
|
541
|
+
const listExpr = `[${enumClass}(item) for item in cast(list[Any], ${isRequired ? accessor : walrusVar})]`;
|
|
542
|
+
if (isRequired) {
|
|
543
|
+
return listExpr;
|
|
544
|
+
}
|
|
545
|
+
return `${listExpr} if (${walrusVar} := ${accessor}) is not None else None`;
|
|
546
|
+
}
|
|
547
|
+
return accessor;
|
|
548
|
+
}
|
|
549
|
+
case 'enum': {
|
|
550
|
+
const enumClass = className(ref.name);
|
|
551
|
+
if (isRequired) {
|
|
552
|
+
return `${enumClass}(${accessor})`;
|
|
553
|
+
}
|
|
554
|
+
return `${enumClass}(${walrusVar}) if (${walrusVar} := ${accessor}) is not None else None`;
|
|
555
|
+
}
|
|
556
|
+
case 'nullable':
|
|
557
|
+
return deserializeField(ref.inner, accessor, false, walrusVar);
|
|
558
|
+
case 'union': {
|
|
559
|
+
const modelVariants = (ref.variants ?? []).filter((v: any) => v.kind === 'model');
|
|
560
|
+
const uniqueModels = [...new Set(modelVariants.map((v: any) => v.name))];
|
|
561
|
+
if (uniqueModels.length === 1) {
|
|
562
|
+
return deserializeField({ kind: 'model', name: uniqueModels[0] }, accessor, isRequired, walrusVar);
|
|
563
|
+
}
|
|
564
|
+
// Mixed unions — pass through (would need runtime discriminant logic)
|
|
565
|
+
return accessor;
|
|
566
|
+
}
|
|
567
|
+
default:
|
|
568
|
+
return accessor;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function serializeField(ref: any, accessor: string): string {
|
|
573
|
+
if (isDateTimeType(ref)) {
|
|
574
|
+
return `_format_datetime(${accessor})`;
|
|
575
|
+
}
|
|
576
|
+
switch (ref.kind) {
|
|
577
|
+
case 'model':
|
|
578
|
+
return `${accessor}.to_dict()`;
|
|
579
|
+
case 'enum':
|
|
580
|
+
return `${accessor}.value if isinstance(${accessor}, Enum) else ${accessor}`;
|
|
581
|
+
case 'array': {
|
|
582
|
+
if (ref.items.kind === 'model') {
|
|
583
|
+
return `[item.to_dict() for item in ${accessor}]`;
|
|
584
|
+
}
|
|
585
|
+
if (ref.items.kind === 'enum') {
|
|
586
|
+
return `[item.value if isinstance(item, Enum) else item for item in ${accessor}]`;
|
|
587
|
+
}
|
|
588
|
+
return accessor;
|
|
589
|
+
}
|
|
590
|
+
case 'union': {
|
|
591
|
+
const modelVariants = (ref.variants ?? []).filter((v: any) => v.kind === 'model');
|
|
592
|
+
const uniqueModels = [...new Set(modelVariants.map((v: any) => v.name))];
|
|
593
|
+
if (uniqueModels.length === 1) {
|
|
594
|
+
return `${accessor}.to_dict()`;
|
|
595
|
+
}
|
|
596
|
+
return accessor;
|
|
597
|
+
}
|
|
598
|
+
default:
|
|
599
|
+
return accessor;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Build recursive structural hashes for all models.
|
|
605
|
+
*
|
|
606
|
+
* Model references are resolved to their own structural hash (bottom-up) and
|
|
607
|
+
* enum references are resolved to their value-set hash. This means
|
|
608
|
+
* structurally-identical model *trees* — like the dozens of per-event Context /
|
|
609
|
+
* ContextActor / ContextGoogleAnalyticsSession sub-models in the spec — get
|
|
610
|
+
* the same hash even though their IR names differ.
|
|
611
|
+
*/
|
|
612
|
+
function buildRecursiveHashMap(models: Model[], enums: Enum[]): Map<string, string> {
|
|
613
|
+
const modelByName = new Map(models.map((m) => [m.name, m]));
|
|
614
|
+
const hashCache = new Map<string, string>();
|
|
615
|
+
const visiting = new Set<string>(); // cycle guard
|
|
616
|
+
|
|
617
|
+
// Pre-compute enum value hashes so identically-valued enums hash the same.
|
|
618
|
+
const enumVH = new Map<string, string>();
|
|
619
|
+
for (const e of enums) {
|
|
620
|
+
enumVH.set(
|
|
621
|
+
e.name,
|
|
622
|
+
[...e.values]
|
|
623
|
+
.map((v) => String(v.value))
|
|
624
|
+
.sort()
|
|
625
|
+
.join('|'),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function modelHash(name: string): string {
|
|
630
|
+
const cached = hashCache.get(name);
|
|
631
|
+
if (cached != null) return cached;
|
|
632
|
+
if (visiting.has(name)) return `m:${name}`; // cycle — fall back to name
|
|
633
|
+
visiting.add(name);
|
|
634
|
+
|
|
635
|
+
const model = modelByName.get(name);
|
|
636
|
+
if (!model) {
|
|
637
|
+
visiting.delete(name);
|
|
638
|
+
return `m:${name}`; // unknown model
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const hash = [...model.fields]
|
|
642
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
643
|
+
.map((f) => `${f.name}:${deepTypeHash(f.type)}:${f.required}`)
|
|
644
|
+
.join('|');
|
|
645
|
+
|
|
646
|
+
visiting.delete(name);
|
|
647
|
+
hashCache.set(name, hash);
|
|
648
|
+
return hash;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function deepTypeHash(ref: any): string {
|
|
652
|
+
switch (ref.kind) {
|
|
653
|
+
case 'primitive':
|
|
654
|
+
return `p:${ref.type}${ref.format ? `:${ref.format}` : ''}`;
|
|
655
|
+
case 'model':
|
|
656
|
+
return `m:{${modelHash(ref.name)}}`;
|
|
657
|
+
case 'enum': {
|
|
658
|
+
const vh = enumVH.get(ref.name);
|
|
659
|
+
return vh != null ? `e:{${vh}}` : `e:${ref.name}`;
|
|
660
|
+
}
|
|
661
|
+
case 'array':
|
|
662
|
+
return `a:${deepTypeHash(ref.items)}`;
|
|
663
|
+
case 'nullable':
|
|
664
|
+
return `n:${deepTypeHash(ref.inner)}`;
|
|
665
|
+
case 'union':
|
|
666
|
+
return `u:${(ref.variants ?? [])
|
|
667
|
+
.map((v: any) => deepTypeHash(v))
|
|
668
|
+
.sort()
|
|
669
|
+
.join(',')}`;
|
|
670
|
+
case 'map':
|
|
671
|
+
return `d:${deepTypeHash(ref.valueType)}`;
|
|
672
|
+
case 'literal':
|
|
673
|
+
return `l:${String(ref.value)}`;
|
|
674
|
+
default:
|
|
675
|
+
return 'unknown';
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
for (const model of models) {
|
|
680
|
+
modelHash(model.name);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return hashCache;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Import and re-export shared model detection utilities
|
|
687
|
+
import { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
|
|
688
|
+
export { isListMetadataModel, isListWrapperModel };
|