@workos/oagen-emitters 0.4.0 → 0.5.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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/README.md +35 -224
- package/dist/index.d.mts +9 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -15234
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +5 -5
- package/oagen.config.ts +5 -373
- package/package.json +10 -34
- package/src/dotnet/index.ts +6 -4
- package/src/dotnet/models.ts +58 -82
- package/src/dotnet/naming.ts +44 -6
- package/src/dotnet/resources.ts +350 -29
- package/src/dotnet/tests.ts +44 -24
- package/src/dotnet/type-map.ts +44 -17
- package/src/dotnet/wrappers.ts +21 -10
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +10 -5
- package/src/go/models.ts +6 -1
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +14 -0
- package/src/kotlin/client.ts +7 -2
- package/src/kotlin/enums.ts +30 -3
- package/src/kotlin/models.ts +97 -6
- package/src/kotlin/naming.ts +7 -1
- package/src/kotlin/resources.ts +370 -39
- package/src/kotlin/tests.ts +120 -6
- package/src/node/client.ts +38 -11
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +156 -52
- package/src/node/tests.ts +76 -27
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/models.ts +0 -33
- package/src/php/resources.ts +199 -18
- package/src/php/tests.ts +26 -2
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +6 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +13 -3
- package/src/python/enums.ts +28 -3
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +138 -1
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +131 -7
- package/src/shared/naming-utils.ts +36 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/test/dotnet/client.test.ts +2 -2
- package/test/dotnet/models.test.ts +7 -9
- package/test/dotnet/resources.test.ts +135 -3
- package/test/dotnet/tests.test.ts +5 -5
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +1 -1
- package/test/kotlin/resources.test.ts +210 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +134 -26
- package/test/node/utils.test.ts +140 -0
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +66 -1
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
package/src/php/wrappers.ts
CHANGED
|
@@ -31,7 +31,7 @@ function emitWrapperMethod(
|
|
|
31
31
|
// PHPDoc block
|
|
32
32
|
const docParts: string[] = [];
|
|
33
33
|
for (const { paramName, field, isOptional } of wrapperParams) {
|
|
34
|
-
const docType = field ? mapTypeRefForPHPDoc(field.type) : '
|
|
34
|
+
const docType = field ? mapTypeRefForPHPDoc(field.type) : 'string';
|
|
35
35
|
const nullSuffix = isOptional && !docType.endsWith('|null') ? '|null' : '';
|
|
36
36
|
docParts.push(`@param ${docType}${nullSuffix} $${fieldName(paramName)}`);
|
|
37
37
|
}
|
|
@@ -57,7 +57,11 @@ function emitWrapperMethod(
|
|
|
57
57
|
requiredParams.push(` ${phpType} $${phpName},`);
|
|
58
58
|
}
|
|
59
59
|
} else {
|
|
60
|
-
|
|
60
|
+
if (isOptional) {
|
|
61
|
+
optionalParamLines.push(` ?string $${phpName} = null,`);
|
|
62
|
+
} else {
|
|
63
|
+
requiredParams.push(` string $${phpName},`);
|
|
64
|
+
}
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
optionalParamLines.push(` ?\\${ns}\\RequestOptions $options = null,`);
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { OagenConfig } from '@workos/oagen';
|
|
4
|
+
import { nodeEmitter } from './node/index.js';
|
|
5
|
+
import { pythonEmitter } from './python/index.js';
|
|
6
|
+
import { phpEmitter } from './php/index.js';
|
|
7
|
+
import { goEmitter } from './go/index.js';
|
|
8
|
+
import { dotnetEmitter } from './dotnet/index.js';
|
|
9
|
+
import { kotlinEmitter } from './kotlin/index.js';
|
|
10
|
+
import { rubyEmitter } from './ruby/index.js';
|
|
11
|
+
import { nodeExtractor } from './compat/extractors/node.js';
|
|
12
|
+
import { rubyExtractor } from './compat/extractors/ruby.js';
|
|
13
|
+
import { pythonExtractor } from './compat/extractors/python.js';
|
|
14
|
+
import { phpExtractor } from './compat/extractors/php.js';
|
|
15
|
+
import { goExtractor } from './compat/extractors/go.js';
|
|
16
|
+
import { rustExtractor } from './compat/extractors/rust.js';
|
|
17
|
+
import { kotlinExtractor } from './compat/extractors/kotlin.js';
|
|
18
|
+
import { dotnetExtractor } from './compat/extractors/dotnet.js';
|
|
19
|
+
import { elixirExtractor } from './compat/extractors/elixir.js';
|
|
20
|
+
|
|
21
|
+
// Resolve smoke runner paths relative to the package root so they work
|
|
22
|
+
// regardless of which project loads the config (CWD-independent).
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const smokeDir = path.resolve(__dirname, '..', 'smoke');
|
|
25
|
+
|
|
26
|
+
export const workosEmittersPlugin: Pick<OagenConfig, 'emitters' | 'extractors' | 'smokeRunners'> = {
|
|
27
|
+
emitters: [nodeEmitter, pythonEmitter, phpEmitter, goEmitter, dotnetEmitter, kotlinEmitter, rubyEmitter],
|
|
28
|
+
extractors: [
|
|
29
|
+
nodeExtractor,
|
|
30
|
+
rubyExtractor,
|
|
31
|
+
pythonExtractor,
|
|
32
|
+
phpExtractor,
|
|
33
|
+
goExtractor,
|
|
34
|
+
rustExtractor,
|
|
35
|
+
kotlinExtractor,
|
|
36
|
+
dotnetExtractor,
|
|
37
|
+
elixirExtractor,
|
|
38
|
+
],
|
|
39
|
+
smokeRunners: {
|
|
40
|
+
node: path.join(smokeDir, 'sdk-node.ts'),
|
|
41
|
+
ruby: path.join(smokeDir, 'sdk-ruby.ts'),
|
|
42
|
+
python: path.join(smokeDir, 'sdk-python.ts'),
|
|
43
|
+
php: path.join(smokeDir, 'sdk-php.ts'),
|
|
44
|
+
go: path.join(smokeDir, 'sdk-go.ts'),
|
|
45
|
+
rust: path.join(smokeDir, 'sdk-rust.ts'),
|
|
46
|
+
elixir: path.join(smokeDir, 'sdk-elixir.ts'),
|
|
47
|
+
kotlin: path.join(smokeDir, 'sdk-kotlin.ts'),
|
|
48
|
+
dotnet: path.join(smokeDir, 'sdk-dotnet.ts'),
|
|
49
|
+
},
|
|
50
|
+
};
|
package/src/python/client.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase } from '@workos/oagen';
|
|
3
3
|
import { className, resolveServiceDir, servicePropertyName, buildMountDirMap, dirToModule } from './naming.js';
|
|
4
|
-
import { resolveResourceClassName } from './resources.js';
|
|
5
|
-
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
4
|
+
import { resolveResourceClassName, collectParameterGroupClassNames } from './resources.js';
|
|
5
|
+
import { getMountTarget, groupByMount } from '../shared/resolved-ops.js';
|
|
6
6
|
import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
|
|
7
7
|
|
|
8
8
|
/** Python-specific wiring for each non-spec service. */
|
|
@@ -254,12 +254,22 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
254
254
|
const topLevel = deduplicateByMount(spec.services, ctx);
|
|
255
255
|
const serviceDirMap = buildMountDirMap(ctx);
|
|
256
256
|
|
|
257
|
+
// Build a map from mount target -> operations so we can discover parameter
|
|
258
|
+
// group dataclasses that need re-exporting from __init__.py.
|
|
259
|
+
const mountGroups = groupByMount(ctx);
|
|
260
|
+
|
|
257
261
|
for (const service of topLevel) {
|
|
258
262
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
259
263
|
const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
|
|
260
264
|
const lines: string[] = [];
|
|
261
265
|
|
|
262
|
-
|
|
266
|
+
// Collect parameter group class names from all operations in this mount group
|
|
267
|
+
const mountTarget = getMountTarget(service, ctx);
|
|
268
|
+
const ops = mountGroups.get(mountTarget)?.operations ?? service.operations;
|
|
269
|
+
const groupClassNames = collectParameterGroupClassNames(ops);
|
|
270
|
+
|
|
271
|
+
const resourceImports = [resolvedName, `Async${resolvedName}`, ...groupClassNames];
|
|
272
|
+
lines.push(`from ._resource import ${resourceImports.join(', ')}`);
|
|
263
273
|
lines.push('from .models import *');
|
|
264
274
|
|
|
265
275
|
files.push({
|
package/src/python/enums.ts
CHANGED
|
@@ -37,6 +37,8 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
37
37
|
// If this enum is an alias for a canonical enum, generate a type alias file
|
|
38
38
|
const canonicalName = aliasOf.get(enumDef.name);
|
|
39
39
|
if (canonicalName) {
|
|
40
|
+
// Skip when alias and canonical produce the same file name (self-import)
|
|
41
|
+
if (fileName(enumDef.name) === fileName(canonicalName)) continue;
|
|
40
42
|
const canonicalService = enumToService.get(canonicalName);
|
|
41
43
|
const canonicalDir = resolveDir(canonicalService);
|
|
42
44
|
const canonicalCls = className(canonicalName);
|
|
@@ -137,7 +139,15 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
137
139
|
lines.push('from typing import Union');
|
|
138
140
|
lines.push('from typing import Literal, TypeAlias');
|
|
139
141
|
lines.push('');
|
|
140
|
-
const literals = uniqueValues.map((v) =>
|
|
142
|
+
const literals = uniqueValues.map((v) =>
|
|
143
|
+
typeof v.value === 'string'
|
|
144
|
+
? `"${v.value}"`
|
|
145
|
+
: typeof v.value === 'boolean'
|
|
146
|
+
? v.value
|
|
147
|
+
? 'True'
|
|
148
|
+
: 'False'
|
|
149
|
+
: String(v.value),
|
|
150
|
+
);
|
|
141
151
|
lines.push(`${cls}: TypeAlias = Union[Literal[${literals.join(', ')}], str]`);
|
|
142
152
|
files.push({
|
|
143
153
|
path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
|
|
@@ -157,7 +167,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
157
167
|
memberName = `${memberName}_${suffix}`;
|
|
158
168
|
}
|
|
159
169
|
usedNames.add(memberName);
|
|
160
|
-
const valueStr =
|
|
170
|
+
const valueStr =
|
|
171
|
+
typeof v.value === 'string'
|
|
172
|
+
? `"${v.value}"`
|
|
173
|
+
: typeof v.value === 'boolean'
|
|
174
|
+
? v.value
|
|
175
|
+
? 'True'
|
|
176
|
+
: 'False'
|
|
177
|
+
: String(v.value);
|
|
161
178
|
lines.push(` ${memberName} = ${valueStr}`);
|
|
162
179
|
if (v.description || v.deprecated) {
|
|
163
180
|
const parts: string[] = [];
|
|
@@ -180,7 +197,15 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
180
197
|
lines.push('');
|
|
181
198
|
lines.push(
|
|
182
199
|
`${cls}Literal: TypeAlias = Literal[${uniqueValues
|
|
183
|
-
.map((v) =>
|
|
200
|
+
.map((v) =>
|
|
201
|
+
typeof v.value === 'string'
|
|
202
|
+
? `"${v.value}"`
|
|
203
|
+
: typeof v.value === 'boolean'
|
|
204
|
+
? v.value
|
|
205
|
+
? 'True'
|
|
206
|
+
: 'False'
|
|
207
|
+
: String(v.value),
|
|
208
|
+
)
|
|
184
209
|
.join(', ')}]`,
|
|
185
210
|
);
|
|
186
211
|
}
|
package/src/python/index.ts
CHANGED
|
@@ -8,10 +8,9 @@ import type {
|
|
|
8
8
|
Enum,
|
|
9
9
|
Service,
|
|
10
10
|
} from '@workos/oagen';
|
|
11
|
-
import * as fs from 'node:fs';
|
|
12
|
-
import * as path from 'node:path';
|
|
13
11
|
|
|
14
12
|
import { generateModels } from './models.js';
|
|
13
|
+
import { detectDiscriminators } from '../shared/model-utils.js';
|
|
15
14
|
import { generateEnums } from './enums.js';
|
|
16
15
|
import { generateResources } from './resources.js';
|
|
17
16
|
import { generateClient } from './client.js';
|
|
@@ -27,11 +26,23 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
|
27
26
|
return files;
|
|
28
27
|
}
|
|
29
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Annotate models with implicit discriminator info (without full oneOf
|
|
31
|
+
* field flattening which can break existing model structures/fixtures).
|
|
32
|
+
*/
|
|
33
|
+
function withDiscriminators(ctx: EmitterContext): EmitterContext {
|
|
34
|
+
const annotated = detectDiscriminators(ctx.spec.models);
|
|
35
|
+
if (annotated === ctx.spec.models) return ctx;
|
|
36
|
+
const spec: ApiSpec = { ...ctx.spec, models: annotated };
|
|
37
|
+
return { ...ctx, spec };
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
export const pythonEmitter: Emitter = {
|
|
31
41
|
language: 'python',
|
|
32
42
|
|
|
33
43
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
34
|
-
|
|
44
|
+
const annotated = detectDiscriminators(models);
|
|
45
|
+
return ensureTrailingNewlines(generateModels(annotated, ctx));
|
|
35
46
|
},
|
|
36
47
|
|
|
37
48
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
@@ -39,11 +50,11 @@ export const pythonEmitter: Emitter = {
|
|
|
39
50
|
},
|
|
40
51
|
|
|
41
52
|
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
42
|
-
return ensureTrailingNewlines(generateResources(services, ctx));
|
|
53
|
+
return ensureTrailingNewlines(generateResources(services, withDiscriminators(ctx)));
|
|
43
54
|
},
|
|
44
55
|
|
|
45
56
|
generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
46
|
-
return ensureTrailingNewlines(generateClient(spec, ctx));
|
|
57
|
+
return ensureTrailingNewlines(generateClient(spec, withDiscriminators(ctx)));
|
|
47
58
|
},
|
|
48
59
|
|
|
49
60
|
generateErrors(): GeneratedFile[] {
|
|
@@ -59,7 +70,15 @@ export const pythonEmitter: Emitter = {
|
|
|
59
70
|
},
|
|
60
71
|
|
|
61
72
|
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
62
|
-
|
|
73
|
+
// Use original spec for model filtering (requestOnlyModelNames etc.) but
|
|
74
|
+
// annotate discriminator models so dispatch tests are generated.
|
|
75
|
+
const annotated = detectDiscriminators(spec.models);
|
|
76
|
+
// Only replace discriminator-annotated models, keep the rest unchanged
|
|
77
|
+
// so request-only filtering and field-based logic work correctly.
|
|
78
|
+
const annotatedByName = new Map(annotated.filter((m: any) => m.discriminator).map((m) => [m.name, m]));
|
|
79
|
+
const testModels = spec.models.map((m) => annotatedByName.get(m.name) ?? m);
|
|
80
|
+
const testSpec: ApiSpec = { ...spec, models: testModels };
|
|
81
|
+
return ensureTrailingNewlines(generateTests(testSpec, { ...ctx, spec: testSpec }));
|
|
63
82
|
},
|
|
64
83
|
|
|
65
84
|
generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
@@ -70,26 +89,15 @@ export const pythonEmitter: Emitter = {
|
|
|
70
89
|
return '# This file is auto-generated by oagen. Do not edit.';
|
|
71
90
|
},
|
|
72
91
|
|
|
73
|
-
formatCommand(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (hasRuff || hasRuffInPyproject || hasRuffInNox) {
|
|
84
|
-
return {
|
|
85
|
-
cmd: 'bash',
|
|
86
|
-
args: [
|
|
87
|
-
'-c',
|
|
88
|
-
'PY_FILES=$(printf "%s\\n" "$@" | grep "\\.py$"); [ -n "$PY_FILES" ] && echo "$PY_FILES" | xargs ruff check --fix --quiet 2>/dev/null; [ -n "$PY_FILES" ] && echo "$PY_FILES" | xargs ruff format --quiet',
|
|
89
|
-
'--',
|
|
90
|
-
],
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
return null;
|
|
92
|
+
formatCommand(_targetDir: string): FormatCommand | null {
|
|
93
|
+
return {
|
|
94
|
+
cmd: 'bash',
|
|
95
|
+
args: [
|
|
96
|
+
'-c',
|
|
97
|
+
'PY_FILES=$(printf "%s\\n" "$@" | grep "\\.py$" || true); if [ -n "$PY_FILES" ]; then echo "$PY_FILES" | xargs ruff check --fix --quiet 2>/dev/null; echo "$PY_FILES" | xargs ruff format --quiet; fi',
|
|
98
|
+
'--',
|
|
99
|
+
],
|
|
100
|
+
batchSize: 1000,
|
|
101
|
+
};
|
|
94
102
|
},
|
|
95
103
|
};
|
package/src/python/models.ts
CHANGED
|
@@ -18,6 +18,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
18
18
|
irService ? (mountDirMap.get(irService) ?? 'common') : 'common';
|
|
19
19
|
const files: GeneratedFile[] = [];
|
|
20
20
|
const emittedModelSymbolsByDir = new Map<string, string[]>();
|
|
21
|
+
// Overrides fileName() for symbols that live in a differently-named file.
|
|
22
|
+
// Used for variant type aliases (e.g. EventSchemaVariant → event_schema).
|
|
23
|
+
const symbolToFile = new Map<string, string>();
|
|
21
24
|
const modelUsage = collectModelUsage(ctx.spec);
|
|
22
25
|
|
|
23
26
|
// Build recursive structural hashes for deduplication.
|
|
@@ -45,6 +48,10 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
// Track emitted file paths to prevent duplicates when synthetic models from
|
|
52
|
+
// oneOf enrichment collide with existing IR models in snake_case.
|
|
53
|
+
const emittedFilePaths = new Set<string>();
|
|
54
|
+
|
|
48
55
|
for (const model of models) {
|
|
49
56
|
// Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
|
|
50
57
|
if (isListWrapperModel(model)) continue;
|
|
@@ -55,9 +62,135 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
55
62
|
const dirName = resolveDir(service);
|
|
56
63
|
const modelClassName = className(model.name);
|
|
57
64
|
|
|
65
|
+
// Skip models whose file path was already emitted (name collision after snake_case)
|
|
66
|
+
const modelFilePath = `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`;
|
|
67
|
+
if (emittedFilePaths.has(modelFilePath)) continue;
|
|
68
|
+
emittedFilePaths.add(modelFilePath);
|
|
69
|
+
|
|
70
|
+
// If this model is a discriminated union dispatcher, generate a factory class
|
|
71
|
+
// instead of a regular dataclass (e.g. EventSchema where each variant has
|
|
72
|
+
// event: const: "..."). The dispatcher owns a Union type alias and a from_dict
|
|
73
|
+
// method that routes to the correct concrete variant at runtime.
|
|
74
|
+
if ((model as any).discriminator) {
|
|
75
|
+
const disc = (model as any).discriminator as { property: string; mapping: Record<string, string> };
|
|
76
|
+
const variantNames = Object.values(disc.mapping); // model names, e.g. ["ActionAuthenticationDenied", ...]
|
|
77
|
+
const variantTypeName = `${modelClassName}Variant`; // e.g. "EventSchemaVariant"
|
|
78
|
+
|
|
79
|
+
const dispLines: string[] = [];
|
|
80
|
+
dispLines.push('from __future__ import annotations');
|
|
81
|
+
dispLines.push('');
|
|
82
|
+
dispLines.push('from dataclasses import dataclass');
|
|
83
|
+
dispLines.push('from typing import Any, ClassVar, Dict, Union, cast');
|
|
84
|
+
dispLines.push(`from ${ctx.namespace}._types import _raise_deserialize_error`);
|
|
85
|
+
dispLines.push('');
|
|
86
|
+
|
|
87
|
+
// Import each variant model from its resolved location
|
|
88
|
+
const sortedVariants = [...new Set(variantNames)].sort();
|
|
89
|
+
for (const variantModelName of sortedVariants) {
|
|
90
|
+
const variantService = modelToService.get(variantModelName);
|
|
91
|
+
const variantDir = resolveDir(variantService);
|
|
92
|
+
if (variantDir === dirName) {
|
|
93
|
+
dispLines.push(`from .${fileName(variantModelName)} import ${className(variantModelName)}`);
|
|
94
|
+
} else {
|
|
95
|
+
dispLines.push(
|
|
96
|
+
`from ${ctx.namespace}.${dirToModule(variantDir)}.models.${fileName(variantModelName)} import ${className(variantModelName)}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Unknown variant for forward-compatible unknown discriminator values
|
|
102
|
+
const unknownClassName = `${modelClassName}Unknown`;
|
|
103
|
+
dispLines.push('');
|
|
104
|
+
dispLines.push('');
|
|
105
|
+
dispLines.push('@dataclass(slots=True)');
|
|
106
|
+
dispLines.push(`class ${unknownClassName}:`);
|
|
107
|
+
dispLines.push(` """Unknown variant of ${modelClassName} not yet recognized by this SDK version."""`);
|
|
108
|
+
dispLines.push('');
|
|
109
|
+
dispLines.push(' raw_data: Dict[str, Any]');
|
|
110
|
+
dispLines.push(' """The raw payload, preserved so callers can still inspect the data."""');
|
|
111
|
+
dispLines.push('');
|
|
112
|
+
dispLines.push(' @classmethod');
|
|
113
|
+
dispLines.push(` def from_dict(cls, data: Dict[str, Any]) -> "${unknownClassName}":`);
|
|
114
|
+
dispLines.push(' """Wrap raw data in an unknown variant."""');
|
|
115
|
+
dispLines.push(' return cls(raw_data=data)');
|
|
116
|
+
dispLines.push('');
|
|
117
|
+
dispLines.push(' def to_dict(self) -> Dict[str, Any]:');
|
|
118
|
+
dispLines.push(' """Return the original raw data."""');
|
|
119
|
+
dispLines.push(' return dict(self.raw_data)');
|
|
120
|
+
|
|
121
|
+
dispLines.push('');
|
|
122
|
+
dispLines.push('');
|
|
123
|
+
|
|
124
|
+
// Union type alias — includes unknown variant for forward compatibility
|
|
125
|
+
dispLines.push(`${variantTypeName} = Union[`);
|
|
126
|
+
for (const variantModelName of sortedVariants) {
|
|
127
|
+
dispLines.push(` ${className(variantModelName)},`);
|
|
128
|
+
}
|
|
129
|
+
dispLines.push(` ${unknownClassName},`);
|
|
130
|
+
dispLines.push(']');
|
|
131
|
+
|
|
132
|
+
dispLines.push('');
|
|
133
|
+
dispLines.push('');
|
|
134
|
+
|
|
135
|
+
// Dispatcher class
|
|
136
|
+
if (model.description) {
|
|
137
|
+
dispLines.push(`class ${modelClassName}:`);
|
|
138
|
+
dispLines.push(` """${model.description}"""`);
|
|
139
|
+
} else {
|
|
140
|
+
dispLines.push(`class ${modelClassName}:`);
|
|
141
|
+
dispLines.push(` """Discriminated union dispatcher (discriminated by '${disc.property}')."""`);
|
|
142
|
+
}
|
|
143
|
+
dispLines.push('');
|
|
144
|
+
dispLines.push(` _DISPATCH: ClassVar[Dict[str, type]] = {`);
|
|
145
|
+
for (const [value, variantModelName] of Object.entries(disc.mapping).sort(([a], [b]) => a.localeCompare(b))) {
|
|
146
|
+
dispLines.push(` "${value}": ${className(variantModelName)},`);
|
|
147
|
+
}
|
|
148
|
+
dispLines.push(' }');
|
|
149
|
+
dispLines.push('');
|
|
150
|
+
dispLines.push(' @classmethod');
|
|
151
|
+
dispLines.push(` def from_dict(cls, data: Dict[str, Any]) -> "${variantTypeName}":`);
|
|
152
|
+
dispLines.push(' """Deserialize from a dictionary, dispatching to the correct variant."""');
|
|
153
|
+
dispLines.push(` if "${disc.property}" not in data:`);
|
|
154
|
+
dispLines.push(
|
|
155
|
+
` _raise_deserialize_error("${modelClassName}", ValueError("Missing required field '${disc.property}'"))`,
|
|
156
|
+
);
|
|
157
|
+
dispLines.push(` disc_value = data["${disc.property}"]`);
|
|
158
|
+
dispLines.push(' if disc_value is None:');
|
|
159
|
+
dispLines.push(
|
|
160
|
+
` _raise_deserialize_error("${modelClassName}", ValueError("${disc.property} must not be None"))`,
|
|
161
|
+
);
|
|
162
|
+
dispLines.push(' dispatch_cls = cls._DISPATCH.get(disc_value)');
|
|
163
|
+
dispLines.push(' if dispatch_cls is not None:');
|
|
164
|
+
dispLines.push(` return cast("${variantTypeName}", dispatch_cls.from_dict(data))`);
|
|
165
|
+
dispLines.push(` return ${unknownClassName}.from_dict(data)`);
|
|
166
|
+
|
|
167
|
+
files.push({
|
|
168
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
|
|
169
|
+
content: dispLines.join('\n'),
|
|
170
|
+
integrateTarget: true,
|
|
171
|
+
overwriteExisting: true,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
|
|
175
|
+
emittedModelSymbolsByDir.get(dirName)!.push(model.name);
|
|
176
|
+
// Also register the variant type alias and unknown variant in the barrel,
|
|
177
|
+
// pointing to the same file as the dispatcher.
|
|
178
|
+
emittedModelSymbolsByDir.get(dirName)!.push(variantTypeName);
|
|
179
|
+
symbolToFile.set(variantTypeName, fileName(model.name));
|
|
180
|
+
emittedModelSymbolsByDir.get(dirName)!.push(unknownClassName);
|
|
181
|
+
symbolToFile.set(unknownClassName, fileName(model.name));
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
58
185
|
// If this model is an alias for a canonical model, generate a type alias file
|
|
59
186
|
const canonicalName = aliasOf.get(model.name);
|
|
60
187
|
if (canonicalName) {
|
|
188
|
+
// Skip when alias and canonical produce the same file name (self-import).
|
|
189
|
+
// This happens when synthetic models from oneOf enrichment collide with
|
|
190
|
+
// existing IR models in snake_case (e.g., Foo_bar vs FooBar).
|
|
191
|
+
if (fileName(model.name) === fileName(canonicalName)) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
61
194
|
const canonicalService = modelToService.get(canonicalName);
|
|
62
195
|
const canonicalDir = resolveDir(canonicalService);
|
|
63
196
|
const canonicalClassName = className(canonicalName);
|
|
@@ -312,7 +445,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
312
445
|
const uniqueNames = [...new Set(names)].sort();
|
|
313
446
|
const importLines: string[] = [];
|
|
314
447
|
for (const name of uniqueNames) {
|
|
315
|
-
|
|
448
|
+
const fileNameForSymbol = symbolToFile.get(name) ?? fileName(name);
|
|
449
|
+
importLines.push(`from .${fileNameForSymbol} import ${className(name)} as ${className(name)}`);
|
|
316
450
|
}
|
|
317
451
|
const imports = importLines.join('\n');
|
|
318
452
|
files.push({
|
|
@@ -473,6 +607,9 @@ function compareAliasPriority(left: string, right: string, usage: ReturnType<typ
|
|
|
473
607
|
}
|
|
474
608
|
|
|
475
609
|
function canAliasModels(canonical: string, alias: string, usage: ReturnType<typeof collectModelUsage>): boolean {
|
|
610
|
+
// Don't alias when both models produce the same file name — the TypeAlias
|
|
611
|
+
// file would import from itself (e.g., FooBar aliased to Foo_bar).
|
|
612
|
+
if (fileName(canonical) === fileName(alias)) return false;
|
|
476
613
|
// Don't alias across request/response boundaries — a request-only model
|
|
477
614
|
// and a response-only model may look identical today but evolve independently.
|
|
478
615
|
if (
|