@workos/oagen-emitters 0.10.0 → 0.12.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +28 -0
- package/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{plugin-H0KhxbN7.mjs → plugin-C408Wh-o.mjs} +2632 -717
- package/dist/plugin-C408Wh-o.mjs.map +1 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/docs/sdk-architecture/rust.md +323 -0
- package/package.json +2 -2
- package/src/go/models.ts +48 -3
- package/src/index.ts +1 -0
- package/src/php/models.ts +27 -3
- package/src/php/resources.ts +16 -16
- package/src/plugin.ts +2 -1
- package/src/python/enums.ts +11 -54
- package/src/python/models.ts +204 -219
- package/src/python/path-expression.ts +75 -26
- package/src/python/resources.ts +19 -44
- package/src/python/shared-schemas.ts +488 -0
- package/src/python/tests.ts +9 -7
- package/src/ruby/resources.ts +13 -1
- package/src/rust/client.ts +62 -0
- package/src/rust/enums.ts +201 -0
- package/src/rust/fixtures.ts +110 -0
- package/src/rust/index.ts +95 -0
- package/src/rust/manifest.ts +31 -0
- package/src/rust/models.ts +150 -0
- package/src/rust/naming.ts +131 -0
- package/src/rust/resources.ts +689 -0
- package/src/rust/secret.ts +59 -0
- package/src/rust/tests.ts +298 -0
- package/src/rust/type-map.ts +225 -0
- package/test/entrypoint.test.ts +1 -0
- package/test/go/models.test.ts +116 -1
- package/test/go/resources.test.ts +70 -0
- package/test/php/models.test.ts +77 -0
- package/test/php/resources.test.ts +95 -0
- package/test/plugin.test.ts +2 -1
- package/test/python/enums.test.ts +91 -0
- package/test/python/models.test.ts +225 -0
- package/test/python/resources.test.ts +47 -2
- package/test/ruby/resources.test.ts +58 -0
- package/test/rust/client.test.ts +62 -0
- package/test/rust/enums.test.ts +117 -0
- package/test/rust/manifest.test.ts +73 -0
- package/test/rust/models.test.ts +139 -0
- package/test/rust/resources.test.ts +245 -0
- package/test/rust/type-map.test.ts +83 -0
- package/dist/plugin-H0KhxbN7.mjs.map +0 -1
package/src/python/models.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { Model,
|
|
2
|
-
import {
|
|
1
|
+
import type { Model, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { collectFieldDependencies, walkTypeRef } from '@workos/oagen';
|
|
3
3
|
import { mapTypeRef } from './type-map.js';
|
|
4
4
|
import { className, fieldName, fileName, buildMountDirMap, dirToModule } from './naming.js';
|
|
5
|
-
import {
|
|
5
|
+
import { collectGeneratedEnumSymbolsByDir } from './enums.js';
|
|
6
|
+
import { computeSchemaPlacement } from './shared-schemas.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Generate Python dataclass model files from IR Model definitions.
|
|
@@ -11,8 +12,19 @@ import { assignEnumsToServices, collectGeneratedEnumSymbolsByDir } from './enums
|
|
|
11
12
|
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
12
13
|
if (models.length === 0) return [];
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
// Tests sometimes pass models that aren't in ctx.spec.models, so synthesize
|
|
16
|
+
// a spec view with the passed-in models to keep the placement logic accurate.
|
|
17
|
+
const placementSpec = models === ctx.spec.models ? ctx.spec : { ...ctx.spec, models };
|
|
18
|
+
const placement = computeSchemaPlacement(placementSpec, ctx);
|
|
19
|
+
const {
|
|
20
|
+
modelToService,
|
|
21
|
+
enumToService,
|
|
22
|
+
originalModelToService,
|
|
23
|
+
originalEnumToService,
|
|
24
|
+
relocatedModels,
|
|
25
|
+
relocatedEnums,
|
|
26
|
+
modelAliases: aliasOf,
|
|
27
|
+
} = placement;
|
|
16
28
|
const mountDirMap = buildMountDirMap(ctx);
|
|
17
29
|
const resolveDir = (irService: string | undefined) =>
|
|
18
30
|
irService ? (mountDirMap.get(irService) ?? 'common') : 'common';
|
|
@@ -21,32 +33,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
21
33
|
// Overrides fileName() for symbols that live in a differently-named file.
|
|
22
34
|
// Used for variant type aliases (e.g. EventSchemaVariant → event_schema).
|
|
23
35
|
const symbolToFile = new Map<string, string>();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
// Model/enum references are resolved bottom-up so structurally-identical
|
|
28
|
-
// model trees (e.g. event context/actor sub-models) get the same hash.
|
|
29
|
-
const recursiveHashes = buildRecursiveHashMap(models, ctx.spec.enums);
|
|
30
|
-
const hashGroups = new Map<string, string[]>(); // hash -> model names
|
|
31
|
-
for (const model of models) {
|
|
32
|
-
if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
|
|
33
|
-
const hash = recursiveHashes.get(model.name) ?? '';
|
|
34
|
-
if (!hashGroups.has(hash)) hashGroups.set(hash, []);
|
|
35
|
-
hashGroups.get(hash)!.push(model.name);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// For each group of identical models, pick canonical (alphabetically first)
|
|
39
|
-
const aliasOf = new Map<string, string>(); // alias name -> canonical name
|
|
40
|
-
for (const [, names] of hashGroups) {
|
|
41
|
-
if (names.length <= 1) continue;
|
|
42
|
-
const sorted = [...names].sort((a, b) => compareAliasPriority(a, b, modelUsage));
|
|
43
|
-
const canonical = sorted[0];
|
|
44
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
45
|
-
if (canAliasModels(canonical, sorted[i], modelUsage)) {
|
|
46
|
-
aliasOf.set(sorted[i], canonical);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
36
|
+
// Track each emitted symbol's natural (pre-relocation) service so we can
|
|
37
|
+
// re-export relocated symbols from their original service barrel for BC.
|
|
38
|
+
const symbolToOriginalService = new Map<string, string>();
|
|
50
39
|
|
|
51
40
|
// Track emitted file paths to prevent duplicates when synthetic models from
|
|
52
41
|
// oneOf enrichment collide with existing IR models in snake_case.
|
|
@@ -179,6 +168,12 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
179
168
|
symbolToFile.set(variantTypeName, fileName(model.name));
|
|
180
169
|
emittedModelSymbolsByDir.get(dirName)!.push(unknownClassName);
|
|
181
170
|
symbolToFile.set(unknownClassName, fileName(model.name));
|
|
171
|
+
const dispatcherNatural = originalModelToService.get(model.name);
|
|
172
|
+
if (dispatcherNatural) {
|
|
173
|
+
symbolToOriginalService.set(model.name, dispatcherNatural);
|
|
174
|
+
symbolToOriginalService.set(variantTypeName, dispatcherNatural);
|
|
175
|
+
symbolToOriginalService.set(unknownClassName, dispatcherNatural);
|
|
176
|
+
}
|
|
182
177
|
continue;
|
|
183
178
|
}
|
|
184
179
|
|
|
@@ -214,6 +209,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
214
209
|
});
|
|
215
210
|
if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
|
|
216
211
|
emittedModelSymbolsByDir.get(dirName)!.push(model.name);
|
|
212
|
+
const aliasNatural = originalModelToService.get(model.name);
|
|
213
|
+
if (aliasNatural) symbolToOriginalService.set(model.name, aliasNatural);
|
|
217
214
|
continue;
|
|
218
215
|
}
|
|
219
216
|
|
|
@@ -350,12 +347,22 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
350
347
|
lines.push(` def from_dict(cls, data: Dict[str, Any]) -> "${modelClassName}":`);
|
|
351
348
|
lines.push(` """Deserialize from a dictionary."""`);
|
|
352
349
|
lines.push(' try:');
|
|
353
|
-
|
|
350
|
+
|
|
351
|
+
const preludeLines: string[] = [];
|
|
352
|
+
const fieldAssignmentLines: string[] = [];
|
|
354
353
|
|
|
355
354
|
for (const field of [...requiredFields, ...optionalFields]) {
|
|
356
355
|
const pyFieldName = fieldName(field.name);
|
|
357
356
|
const wireKey = field.name; // Wire keys are snake_case from the spec
|
|
358
357
|
const isRequired = !isOptionalField(model.name, field, ctx);
|
|
358
|
+
|
|
359
|
+
const discPrelude = renderDiscriminatedUnionPrelude(field, pyFieldName, wireKey, modelClassName, isRequired);
|
|
360
|
+
if (discPrelude) {
|
|
361
|
+
preludeLines.push(...discPrelude.prelude);
|
|
362
|
+
fieldAssignmentLines.push(` ${pyFieldName}=${discPrelude.expr},`);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
359
366
|
let accessor: string;
|
|
360
367
|
if (field.type.kind === 'literal' && isRequired) {
|
|
361
368
|
// Required literal fields have a statically known value; use .get() with a default
|
|
@@ -369,9 +376,12 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
369
376
|
const deserRequired = isRequired && field.type.kind !== 'nullable';
|
|
370
377
|
const walrusVar = `_v_${pyFieldName}`;
|
|
371
378
|
const deserExpr = deserializeField(field.type, accessor, deserRequired, walrusVar);
|
|
372
|
-
|
|
379
|
+
fieldAssignmentLines.push(` ${pyFieldName}=${deserExpr},`);
|
|
373
380
|
}
|
|
374
381
|
|
|
382
|
+
for (const preludeLine of preludeLines) lines.push(preludeLine);
|
|
383
|
+
lines.push(' return cls(');
|
|
384
|
+
for (const assignment of fieldAssignmentLines) lines.push(assignment);
|
|
375
385
|
lines.push(' )');
|
|
376
386
|
lines.push(' except (KeyError, ValueError) as e:');
|
|
377
387
|
lines.push(` _raise_deserialize_error("${modelClassName}", e)`);
|
|
@@ -418,15 +428,21 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
418
428
|
});
|
|
419
429
|
if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
|
|
420
430
|
emittedModelSymbolsByDir.get(dirName)!.push(model.name);
|
|
431
|
+
const regularNatural = originalModelToService.get(model.name);
|
|
432
|
+
if (regularNatural) symbolToOriginalService.set(model.name, regularNatural);
|
|
421
433
|
}
|
|
422
434
|
|
|
423
435
|
// Generate __init__.py barrel files for each models/ directory
|
|
424
|
-
// Include both models and enums
|
|
425
|
-
|
|
436
|
+
// Include both models and enums.
|
|
437
|
+
// A direct symbol lives in the file at `dirPath/<file>.py`. A re-exported
|
|
438
|
+
// symbol was relocated to common/ but is being mirrored from its natural
|
|
439
|
+
// service barrel for backwards compatibility.
|
|
440
|
+
type BarrelSymbol = { name: string; reExport?: { fromDir: string; file: string } };
|
|
441
|
+
const symbolsByDir = new Map<string, BarrelSymbol[]>();
|
|
426
442
|
for (const [dirName, names] of emittedModelSymbolsByDir) {
|
|
427
443
|
const key = `src/${ctx.namespace}/${dirName}/models`;
|
|
428
444
|
if (!symbolsByDir.has(key)) symbolsByDir.set(key, []);
|
|
429
|
-
symbolsByDir.get(key)!.push(
|
|
445
|
+
for (const name of names) symbolsByDir.get(key)!.push({ name });
|
|
430
446
|
}
|
|
431
447
|
|
|
432
448
|
// Also include enums in the barrels using the enum emitter's actual output placement.
|
|
@@ -436,7 +452,37 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
436
452
|
for (const [dirName, names] of enumSymbolsByDir) {
|
|
437
453
|
const key = `src/${ctx.namespace}/${dirName}/models`;
|
|
438
454
|
if (!symbolsByDir.has(key)) symbolsByDir.set(key, []);
|
|
439
|
-
symbolsByDir.get(key)!.push(
|
|
455
|
+
for (const name of names) symbolsByDir.get(key)!.push({ name });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Backwards-compat re-exports: every relocated model is also re-exported
|
|
459
|
+
// from its pre-relocation service barrel so existing
|
|
460
|
+
// `from workos.<service>.models import X` imports keep working.
|
|
461
|
+
const commonDirName = 'common';
|
|
462
|
+
const addReExport = (naturalService: string | undefined, name: string, sourceFile: string): void => {
|
|
463
|
+
if (!naturalService) return;
|
|
464
|
+
const naturalDir = mountDirMap.get(naturalService) ?? naturalService;
|
|
465
|
+
if (naturalDir === commonDirName) return;
|
|
466
|
+
const key = `src/${ctx.namespace}/${naturalDir}/models`;
|
|
467
|
+
if (!symbolsByDir.has(key)) symbolsByDir.set(key, []);
|
|
468
|
+
symbolsByDir.get(key)!.push({ name, reExport: { fromDir: commonDirName, file: sourceFile } });
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
for (const symbol of symbolToOriginalService.keys()) {
|
|
472
|
+
// Only re-export symbols that ended up relocated (i.e. their owning model is in relocatedModels)
|
|
473
|
+
// or whose dispatcher parent is relocated.
|
|
474
|
+
const naturalService = symbolToOriginalService.get(symbol)!;
|
|
475
|
+
// Find what file the symbol lives in (in common/)
|
|
476
|
+
const file = symbolToFile.get(symbol) ?? fileName(symbol);
|
|
477
|
+
// Only re-export if it actually got relocated — that is, if its primary
|
|
478
|
+
// model name (or the parent it shares a file with) is in relocatedModels.
|
|
479
|
+
const primaryName = file === fileName(symbol) ? symbol : reverseLookupModelByFile(file, ctx);
|
|
480
|
+
if (primaryName && !relocatedModels.has(primaryName)) continue;
|
|
481
|
+
addReExport(naturalService, symbol, file);
|
|
482
|
+
}
|
|
483
|
+
for (const enumName of relocatedEnums) {
|
|
484
|
+
const naturalService = originalEnumToService.get(enumName);
|
|
485
|
+
addReExport(naturalService, enumName, fileName(enumName));
|
|
440
486
|
}
|
|
441
487
|
|
|
442
488
|
// Build set of service directory model paths — these get their parent __init__.py
|
|
@@ -465,13 +511,28 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
465
511
|
}
|
|
466
512
|
}
|
|
467
513
|
|
|
468
|
-
for (const [dirPath,
|
|
514
|
+
for (const [dirPath, symbols] of symbolsByDir) {
|
|
515
|
+
// Deduplicate by symbol name (a direct emission always wins over a stale
|
|
516
|
+
// re-export with the same name).
|
|
517
|
+
const seen = new Map<string, BarrelSymbol>();
|
|
518
|
+
for (const sym of symbols) {
|
|
519
|
+
const existing = seen.get(sym.name);
|
|
520
|
+
if (!existing || (existing.reExport && !sym.reExport)) seen.set(sym.name, sym);
|
|
521
|
+
}
|
|
522
|
+
const uniqueSymbols = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
523
|
+
|
|
469
524
|
// Use `import X as X` syntax for explicit re-exports (required by pyright strict)
|
|
470
|
-
const uniqueNames = [...new Set(names)].sort();
|
|
471
525
|
const importLines: string[] = [];
|
|
472
|
-
for (const
|
|
473
|
-
const
|
|
474
|
-
|
|
526
|
+
for (const sym of uniqueSymbols) {
|
|
527
|
+
const cls = className(sym.name);
|
|
528
|
+
if (sym.reExport) {
|
|
529
|
+
importLines.push(
|
|
530
|
+
`from ${ctx.namespace}.${dirToModule(sym.reExport.fromDir)}.models.${sym.reExport.file} import ${cls} as ${cls}`,
|
|
531
|
+
);
|
|
532
|
+
} else {
|
|
533
|
+
const fileNameForSymbol = symbolToFile.get(sym.name) ?? fileName(sym.name);
|
|
534
|
+
importLines.push(`from .${fileNameForSymbol} import ${cls} as ${cls}`);
|
|
535
|
+
}
|
|
475
536
|
}
|
|
476
537
|
const imports = importLines.join('\n');
|
|
477
538
|
files.push({
|
|
@@ -486,9 +547,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
486
547
|
// which includes both the resource class re-export and model star import.
|
|
487
548
|
if (!serviceDirModelPaths.has(dirPath)) {
|
|
488
549
|
const parentDir = dirPath.replace(/\/models$/, '');
|
|
489
|
-
const reExports =
|
|
490
|
-
.
|
|
491
|
-
.map((name) => `from .models import ${className(name)} as ${className(name)}`)
|
|
550
|
+
const reExports = uniqueSymbols
|
|
551
|
+
.map((sym) => `from .models import ${className(sym.name)} as ${className(sym.name)}`)
|
|
492
552
|
.join('\n');
|
|
493
553
|
files.push({
|
|
494
554
|
path: `${parentDir}/__init__.py`,
|
|
@@ -502,6 +562,18 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
502
562
|
return files;
|
|
503
563
|
}
|
|
504
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Given a snake_case file name, return the IR model name that owns it.
|
|
567
|
+
* Used to attribute dispatcher children (FooVariant, FooUnknown) to their
|
|
568
|
+
* parent model when computing relocation re-exports.
|
|
569
|
+
*/
|
|
570
|
+
function reverseLookupModelByFile(file: string, ctx: EmitterContext): string | undefined {
|
|
571
|
+
for (const m of ctx.spec.models) {
|
|
572
|
+
if (fileName(m.name) === file) return m.name;
|
|
573
|
+
}
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
|
|
505
577
|
function collectTypingImports(ref: any, imports: Set<string>): void {
|
|
506
578
|
switch (ref.kind) {
|
|
507
579
|
case 'array':
|
|
@@ -579,73 +651,6 @@ function collectReachableEnumNames(ctx: EmitterContext): Set<string> {
|
|
|
579
651
|
return referencedEnums;
|
|
580
652
|
}
|
|
581
653
|
|
|
582
|
-
function collectModelUsage(spec: EmitterContext['spec']): {
|
|
583
|
-
requestOnly: Set<string>;
|
|
584
|
-
response: Set<string>;
|
|
585
|
-
mixed: Set<string>;
|
|
586
|
-
} {
|
|
587
|
-
const request = new Set<string>();
|
|
588
|
-
const response = new Set<string>();
|
|
589
|
-
|
|
590
|
-
for (const service of spec.services) {
|
|
591
|
-
for (const op of service.operations) {
|
|
592
|
-
const plan = planOperation(op);
|
|
593
|
-
if (plan.responseModelName) {
|
|
594
|
-
response.add(plan.responseModelName);
|
|
595
|
-
}
|
|
596
|
-
if (op.pagination?.itemType.kind === 'model') {
|
|
597
|
-
response.add(op.pagination.itemType.name);
|
|
598
|
-
}
|
|
599
|
-
if (op.requestBody?.kind === 'model') {
|
|
600
|
-
request.add(op.requestBody.name);
|
|
601
|
-
}
|
|
602
|
-
if (op.requestBody?.kind === 'union') {
|
|
603
|
-
for (const variant of op.requestBody.variants ?? []) {
|
|
604
|
-
if (variant.kind === 'model') request.add(variant.name);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
const mixed = new Set<string>();
|
|
611
|
-
for (const name of request) {
|
|
612
|
-
if (response.has(name)) mixed.add(name);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
const requestOnly = new Set([...request].filter((name) => !mixed.has(name)));
|
|
616
|
-
const responseOnly = new Set([...response].filter((name) => !mixed.has(name)));
|
|
617
|
-
|
|
618
|
-
return { requestOnly, response: responseOnly, mixed };
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function compareAliasPriority(left: string, right: string, usage: ReturnType<typeof collectModelUsage>): number {
|
|
622
|
-
const score = (name: string): number => {
|
|
623
|
-
if (usage.response.has(name)) return 0;
|
|
624
|
-
if (usage.mixed.has(name)) return 1;
|
|
625
|
-
if (usage.requestOnly.has(name)) return 2;
|
|
626
|
-
return 3;
|
|
627
|
-
};
|
|
628
|
-
|
|
629
|
-
const diff = score(left) - score(right);
|
|
630
|
-
if (diff !== 0) return diff;
|
|
631
|
-
return left.localeCompare(right);
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
function canAliasModels(canonical: string, alias: string, usage: ReturnType<typeof collectModelUsage>): boolean {
|
|
635
|
-
// Don't alias when both models produce the same file name — the TypeAlias
|
|
636
|
-
// file would import from itself (e.g., FooBar aliased to Foo_bar).
|
|
637
|
-
if (fileName(canonical) === fileName(alias)) return false;
|
|
638
|
-
// Don't alias across request/response boundaries — a request-only model
|
|
639
|
-
// and a response-only model may look identical today but evolve independently.
|
|
640
|
-
if (
|
|
641
|
-
(usage.response.has(canonical) && usage.requestOnly.has(alias)) ||
|
|
642
|
-
(usage.response.has(alias) && usage.requestOnly.has(canonical))
|
|
643
|
-
) {
|
|
644
|
-
return false;
|
|
645
|
-
}
|
|
646
|
-
return true;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
654
|
function isOptionalField(modelName: string, field: Model['fields'][number], ctx: EmitterContext): boolean {
|
|
650
655
|
void modelName;
|
|
651
656
|
void ctx;
|
|
@@ -683,6 +688,87 @@ function isDateTimeType(ref: any): boolean {
|
|
|
683
688
|
return ref.kind === 'primitive' && ref.type === 'string' && ref.format === 'date-time';
|
|
684
689
|
}
|
|
685
690
|
|
|
691
|
+
/**
|
|
692
|
+
* If `field` is a discriminated-union (or nullable-wrapped discriminated-union)
|
|
693
|
+
* field, return prelude statements that perform strict dispatch and an
|
|
694
|
+
* expression for the `cls(...)` call. Returns null otherwise.
|
|
695
|
+
*
|
|
696
|
+
* Strict dispatch means: an unknown discriminator value raises ValueError
|
|
697
|
+
* naming the parent class, field, observed value, and valid options. The
|
|
698
|
+
* caller's `try/except` block converts that into `_raise_deserialize_error`.
|
|
699
|
+
*/
|
|
700
|
+
function renderDiscriminatedUnionPrelude(
|
|
701
|
+
field: any,
|
|
702
|
+
pyFieldName: string,
|
|
703
|
+
wireKey: string,
|
|
704
|
+
parentClassName: string,
|
|
705
|
+
isRequired: boolean,
|
|
706
|
+
): { prelude: string[]; expr: string } | null {
|
|
707
|
+
let unionRef: any = null;
|
|
708
|
+
let nullable = false;
|
|
709
|
+
if (field.type.kind === 'union' && field.type.discriminator?.mapping) {
|
|
710
|
+
unionRef = field.type;
|
|
711
|
+
} else if (
|
|
712
|
+
field.type.kind === 'nullable' &&
|
|
713
|
+
field.type.inner.kind === 'union' &&
|
|
714
|
+
field.type.inner.discriminator?.mapping
|
|
715
|
+
) {
|
|
716
|
+
unionRef = field.type.inner;
|
|
717
|
+
nullable = true;
|
|
718
|
+
}
|
|
719
|
+
if (!unionRef) return null;
|
|
720
|
+
|
|
721
|
+
const mapping = unionRef.discriminator.mapping as Record<string, string>;
|
|
722
|
+
const entries = Object.entries(mapping);
|
|
723
|
+
if (entries.length === 0) return null;
|
|
724
|
+
|
|
725
|
+
const discProp = unionRef.discriminator.property as string;
|
|
726
|
+
const rawVar = `_${pyFieldName}_raw`;
|
|
727
|
+
const dataVar = `_${pyFieldName}_data`;
|
|
728
|
+
const typeVar = `_${pyFieldName}_disc`;
|
|
729
|
+
const mapVar = `_${pyFieldName}_disc_map`;
|
|
730
|
+
const clsVar = `_${pyFieldName}_cls`;
|
|
731
|
+
const valueVar = `_${pyFieldName}_value`;
|
|
732
|
+
const indent = ' ';
|
|
733
|
+
|
|
734
|
+
const dispatchBlock = (innerIndent: string): string[] => {
|
|
735
|
+
const lines: string[] = [];
|
|
736
|
+
lines.push(`${innerIndent}${dataVar} = cast(Dict[str, Any], ${rawVar})`);
|
|
737
|
+
lines.push(`${innerIndent}${typeVar} = cast(str, ${dataVar}.get("${discProp}"))`);
|
|
738
|
+
lines.push(`${innerIndent}${mapVar}: Dict[str, Any] = {`);
|
|
739
|
+
for (const [value, variantModelName] of entries) {
|
|
740
|
+
lines.push(`${innerIndent} "${value}": ${className(variantModelName)},`);
|
|
741
|
+
}
|
|
742
|
+
lines.push(`${innerIndent}}`);
|
|
743
|
+
lines.push(`${innerIndent}${clsVar} = ${mapVar}.get(${typeVar})`);
|
|
744
|
+
lines.push(`${innerIndent}if ${clsVar} is None:`);
|
|
745
|
+
lines.push(`${innerIndent} raise ValueError(`);
|
|
746
|
+
lines.push(
|
|
747
|
+
`${innerIndent} f"Unknown discriminator '${discProp}' for ${parentClassName}.${pyFieldName}: {${typeVar}!r}. "`,
|
|
748
|
+
);
|
|
749
|
+
lines.push(`${innerIndent} f"Expected one of {sorted(${mapVar})}."`);
|
|
750
|
+
lines.push(`${innerIndent} )`);
|
|
751
|
+
return lines;
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const prelude: string[] = [];
|
|
755
|
+
if (isRequired && !nullable) {
|
|
756
|
+
prelude.push(`${indent}${rawVar} = data["${wireKey}"]`);
|
|
757
|
+
prelude.push(...dispatchBlock(indent));
|
|
758
|
+
return { prelude, expr: `${clsVar}.from_dict(${dataVar})` };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Optional or nullable: handle missing/None explicitly.
|
|
762
|
+
const accessor = isRequired ? `data["${wireKey}"]` : `data.get("${wireKey}")`;
|
|
763
|
+
prelude.push(`${indent}${rawVar} = ${accessor}`);
|
|
764
|
+
prelude.push(`${indent}if ${rawVar} is None:`);
|
|
765
|
+
prelude.push(`${indent} ${valueVar} = None`);
|
|
766
|
+
prelude.push(`${indent}else:`);
|
|
767
|
+
prelude.push(...dispatchBlock(indent + ' '));
|
|
768
|
+
prelude.push(`${indent} ${valueVar} = ${clsVar}.from_dict(${dataVar})`);
|
|
769
|
+
return { prelude, expr: valueVar };
|
|
770
|
+
}
|
|
771
|
+
|
|
686
772
|
function deserializeField(ref: any, accessor: string, isRequired: boolean, walrusVar: string = '_v'): string {
|
|
687
773
|
if (isDateTimeType(ref)) {
|
|
688
774
|
if (isRequired) {
|
|
@@ -726,28 +812,10 @@ function deserializeField(ref: any, accessor: string, isRequired: boolean, walru
|
|
|
726
812
|
case 'nullable':
|
|
727
813
|
return deserializeField(ref.inner, accessor, false, walrusVar);
|
|
728
814
|
case 'union': {
|
|
815
|
+
// Discriminated unions are handled by `renderDiscriminatedUnionPrelude`
|
|
816
|
+
// before deserializeField is called, so they never reach this branch.
|
|
729
817
|
const modelVariants = (ref.variants ?? []).filter((v: any) => v.kind === 'model');
|
|
730
818
|
const uniqueModels = [...new Set(modelVariants.map((v: any) => v.name))] as string[];
|
|
731
|
-
// Discriminated union: dispatch on the discriminator property to call
|
|
732
|
-
// the matching variant's from_dict. Unknown discriminator values fall
|
|
733
|
-
// back to the raw payload so callers can introspect.
|
|
734
|
-
if (ref.discriminator && ref.discriminator.mapping) {
|
|
735
|
-
const entries = Object.entries(ref.discriminator.mapping as Record<string, string>);
|
|
736
|
-
if (entries.length > 0) {
|
|
737
|
-
const dispatchMap = entries.map(([value, modelName]) => `"${value}": ${className(modelName)}`).join(', ');
|
|
738
|
-
const dataExpr = isRequired ? accessor : walrusVar;
|
|
739
|
-
const dataCast = `cast(Dict[str, Any], ${dataExpr})`;
|
|
740
|
-
// The dispatch dict has `str` keys, so pyright (strict) rejects the
|
|
741
|
-
// raw `Any | None` returned by `.get(prop)` even though `dict.get`
|
|
742
|
-
// accepts any hashable. Cast through `str` to satisfy the parameter
|
|
743
|
-
// type — runtime semantics are unchanged because a missing/`None`
|
|
744
|
-
// discriminator simply misses the dispatch and falls through.
|
|
745
|
-
const lookupExpr = `{${dispatchMap}}.get(cast(str, ${dataCast}.get("${ref.discriminator.property}")))`;
|
|
746
|
-
const branch = `(_disc.from_dict(${dataCast}) if (_disc := ${lookupExpr}) is not None else ${dataExpr})`;
|
|
747
|
-
if (isRequired) return branch;
|
|
748
|
-
return `(${branch}) if (${walrusVar} := ${accessor}) is not None else None`;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
819
|
if (uniqueModels.length === 1) {
|
|
752
820
|
return deserializeField({ kind: 'model', name: uniqueModels[0] }, accessor, isRequired, walrusVar);
|
|
753
821
|
}
|
|
@@ -783,11 +851,11 @@ function serializeField(ref: any, accessor: string): string {
|
|
|
783
851
|
if (uniqueModels.length === 1) {
|
|
784
852
|
return `${accessor}.to_dict()`;
|
|
785
853
|
}
|
|
786
|
-
// Discriminated union:
|
|
787
|
-
//
|
|
788
|
-
//
|
|
854
|
+
// Discriminated union: from_dict always produces a concrete dataclass
|
|
855
|
+
// instance (unknown discriminators raise instead of falling back to a
|
|
856
|
+
// raw dict), so the serialized field is unconditionally `.to_dict()`-able.
|
|
789
857
|
if (ref.discriminator && ref.discriminator.mapping && modelVariants.length > 0) {
|
|
790
|
-
return `${accessor}.to_dict()
|
|
858
|
+
return `${accessor}.to_dict()`;
|
|
791
859
|
}
|
|
792
860
|
return accessor;
|
|
793
861
|
}
|
|
@@ -796,89 +864,6 @@ function serializeField(ref: any, accessor: string): string {
|
|
|
796
864
|
}
|
|
797
865
|
}
|
|
798
866
|
|
|
799
|
-
/**
|
|
800
|
-
* Build recursive structural hashes for all models.
|
|
801
|
-
*
|
|
802
|
-
* Model references are resolved to their own structural hash (bottom-up) and
|
|
803
|
-
* enum references are resolved to their value-set hash. This means
|
|
804
|
-
* structurally-identical model *trees* — like the dozens of per-event Context /
|
|
805
|
-
* ContextActor / ContextGoogleAnalyticsSession sub-models in the spec — get
|
|
806
|
-
* the same hash even though their IR names differ.
|
|
807
|
-
*/
|
|
808
|
-
function buildRecursiveHashMap(models: Model[], enums: Enum[]): Map<string, string> {
|
|
809
|
-
const modelByName = new Map(models.map((m) => [m.name, m]));
|
|
810
|
-
const hashCache = new Map<string, string>();
|
|
811
|
-
const visiting = new Set<string>(); // cycle guard
|
|
812
|
-
|
|
813
|
-
// Pre-compute enum value hashes so identically-valued enums hash the same.
|
|
814
|
-
const enumVH = new Map<string, string>();
|
|
815
|
-
for (const e of enums) {
|
|
816
|
-
enumVH.set(
|
|
817
|
-
e.name,
|
|
818
|
-
[...e.values]
|
|
819
|
-
.map((v) => String(v.value))
|
|
820
|
-
.sort()
|
|
821
|
-
.join('|'),
|
|
822
|
-
);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
function modelHash(name: string): string {
|
|
826
|
-
const cached = hashCache.get(name);
|
|
827
|
-
if (cached != null) return cached;
|
|
828
|
-
if (visiting.has(name)) return `m:${name}`; // cycle — fall back to name
|
|
829
|
-
visiting.add(name);
|
|
830
|
-
|
|
831
|
-
const model = modelByName.get(name);
|
|
832
|
-
if (!model) {
|
|
833
|
-
visiting.delete(name);
|
|
834
|
-
return `m:${name}`; // unknown model
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
const hash = [...model.fields]
|
|
838
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
839
|
-
.map((f) => `${f.name}:${deepTypeHash(f.type)}:${f.required}`)
|
|
840
|
-
.join('|');
|
|
841
|
-
|
|
842
|
-
visiting.delete(name);
|
|
843
|
-
hashCache.set(name, hash);
|
|
844
|
-
return hash;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
function deepTypeHash(ref: any): string {
|
|
848
|
-
switch (ref.kind) {
|
|
849
|
-
case 'primitive':
|
|
850
|
-
return `p:${ref.type}${ref.format ? `:${ref.format}` : ''}`;
|
|
851
|
-
case 'model':
|
|
852
|
-
return `m:{${modelHash(ref.name)}}`;
|
|
853
|
-
case 'enum': {
|
|
854
|
-
const vh = enumVH.get(ref.name);
|
|
855
|
-
return vh != null ? `e:{${vh}}` : `e:${ref.name}`;
|
|
856
|
-
}
|
|
857
|
-
case 'array':
|
|
858
|
-
return `a:${deepTypeHash(ref.items)}`;
|
|
859
|
-
case 'nullable':
|
|
860
|
-
return `n:${deepTypeHash(ref.inner)}`;
|
|
861
|
-
case 'union':
|
|
862
|
-
return `u:${(ref.variants ?? [])
|
|
863
|
-
.map((v: any) => deepTypeHash(v))
|
|
864
|
-
.sort()
|
|
865
|
-
.join(',')}`;
|
|
866
|
-
case 'map':
|
|
867
|
-
return `d:${deepTypeHash(ref.valueType)}`;
|
|
868
|
-
case 'literal':
|
|
869
|
-
return `l:${String(ref.value)}`;
|
|
870
|
-
default:
|
|
871
|
-
return 'unknown';
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
for (const model of models) {
|
|
876
|
-
modelHash(model.name);
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
return hashCache;
|
|
880
|
-
}
|
|
881
|
-
|
|
882
867
|
// Import and re-export shared model detection utilities
|
|
883
868
|
import { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
|
|
884
869
|
export { isListMetadataModel, isListWrapperModel };
|