@workos/oagen-emitters 0.14.4 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +19 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-BGVaMGqe.mjs → plugin-C2Hp2Vs2.mjs} +1039 -274
  6. package/dist/plugin-C2Hp2Vs2.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/package.json +7 -7
  9. package/renovate.json +1 -61
  10. package/src/go/client.ts +1 -1
  11. package/src/go/enums.ts +77 -0
  12. package/src/kotlin/enums.ts +11 -4
  13. package/src/node/client.ts +158 -2
  14. package/src/node/discriminated-models.ts +68 -24
  15. package/src/node/field-plan.ts +64 -8
  16. package/src/node/index.ts +59 -3
  17. package/src/node/models.ts +73 -30
  18. package/src/node/naming.ts +14 -1
  19. package/src/node/node-overrides.ts +4 -37
  20. package/src/node/options.ts +29 -1
  21. package/src/node/resources.ts +553 -89
  22. package/src/node/tests.ts +108 -7
  23. package/src/php/fixtures.ts +4 -1
  24. package/src/php/models.ts +3 -1
  25. package/src/php/resources.ts +40 -11
  26. package/src/php/tests.ts +22 -12
  27. package/src/python/client.ts +0 -8
  28. package/src/python/enums.ts +41 -15
  29. package/src/python/fixtures.ts +23 -7
  30. package/src/python/models.ts +26 -5
  31. package/src/python/resources.ts +71 -3
  32. package/src/python/tests.ts +70 -12
  33. package/src/python/wrappers.ts +25 -4
  34. package/src/ruby/client.ts +0 -1
  35. package/src/rust/resources.ts +10 -7
  36. package/src/shared/non-spec-services.ts +0 -5
  37. package/test/go/enums.test.ts +24 -0
  38. package/test/node/resources.test.ts +11 -1
  39. package/test/node/tests.test.ts +3 -3
  40. package/test/php/client.test.ts +0 -1
  41. package/test/php/resources.test.ts +50 -0
  42. package/test/rust/resources.test.ts +9 -0
  43. package/dist/plugin-BGVaMGqe.mjs.map +0 -1
package/src/node/tests.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import type { ApiSpec, Service, Operation, Model, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
4
- import { planOperation, toCamelCase } from '@workos/oagen';
4
+ import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
5
5
  import { unwrapListModel, ID_PREFIXES } from './fixtures.js';
6
6
  import {
7
7
  fieldName,
@@ -27,13 +27,17 @@ import {
27
27
  computeNonEventReachable,
28
28
  } from './utils.js';
29
29
  import { groupByMount } from '../shared/resolved-ops.js';
30
- import { isNodeOwnedService } from './options.js';
30
+ import { isNodeOwnedService, nodeOptions } from './options.js';
31
31
 
32
32
  type BaselineMethod = {
33
33
  params: Array<{ name: string; type: string; optional?: boolean; passingStyle?: string }>;
34
34
  returnType?: string;
35
35
  };
36
36
 
37
+ function operationOverrideFor(ctx: EmitterContext, op: Operation) {
38
+ return nodeOptions(ctx).operationOverrides?.[`${op.httpMethod.toUpperCase()} ${op.path}`];
39
+ }
40
+
37
41
  function baselineMethodFor(service: Service, method: string, ctx: EmitterContext): BaselineMethod | undefined {
38
42
  const serviceClass = resolveResourceClassName(service, ctx);
39
43
  return ctx.apiSurface?.classes?.[serviceClass]?.methods?.[method]?.[0] as BaselineMethod | undefined;
@@ -48,6 +52,48 @@ function optionsObjectParam(method: BaselineMethod | undefined): { name: string;
48
52
  return { name: param.name, type: param.type };
49
53
  }
50
54
 
55
+ function configuredOptionsMethod(ctx: EmitterContext, op: Operation): BaselineMethod | undefined {
56
+ const optionsType = operationOverrideFor(ctx, op)?.optionsType;
57
+ if (!optionsType) return undefined;
58
+ return {
59
+ params: [{ name: 'options', type: optionsType, passingStyle: 'options_object' }],
60
+ };
61
+ }
62
+
63
+ function methodOptionsName(method: string, serviceClass: string): string {
64
+ if (method === 'list') return `${toPascalCase(serviceClass)}ListOptions`;
65
+ return `${toPascalCase(method)}Options`;
66
+ }
67
+
68
+ function hasOptionsInput(op: Operation, plan: any): boolean {
69
+ return op.pathParams.length > 0 || plan.hasBody || plan.isPaginated || op.queryParams.length > 0;
70
+ }
71
+
72
+ function optionsMethodFor(
73
+ service: Service,
74
+ method: string,
75
+ op: Operation,
76
+ plan: any,
77
+ ctx: EmitterContext,
78
+ ): BaselineMethod | undefined {
79
+ const baseline = baselineMethodFor(service, method, ctx);
80
+ if (optionsObjectParam(baseline)) return baseline;
81
+
82
+ const configured = configuredOptionsMethod(ctx, op);
83
+ if (configured) return configured;
84
+
85
+ if (!hasOptionsInput(op, plan)) return baseline;
86
+ return {
87
+ params: [
88
+ {
89
+ name: 'options',
90
+ type: methodOptionsName(method, resolveResourceClassName(service, ctx)),
91
+ passingStyle: 'options_object',
92
+ },
93
+ ],
94
+ };
95
+ }
96
+
51
97
  function autoPaginatableItemType(returnType: string | undefined): string | undefined {
52
98
  return returnType?.match(/\bAutoPaginatable<\s*([A-Za-z_$][\w$]*)/)?.[1];
53
99
  }
@@ -249,7 +295,7 @@ function generateServiceTest(
249
295
  lines.push(' beforeEach(() => fetch.resetMocks());');
250
296
 
251
297
  for (const { op, plan, method } of plans) {
252
- const existingMethod = baselineMethodFor(service, method, ctx);
298
+ const existingMethod = optionsMethodFor(service, method, op, plan, ctx);
253
299
  lines.push('');
254
300
  lines.push(` describe('${method}', () => {`);
255
301
 
@@ -447,6 +493,13 @@ function renderBodyTest(
447
493
  lines.push(' expect(Array.isArray(result)).toBe(true);');
448
494
  }
449
495
 
496
+ const override = ctx ? operationOverrideFor(ctx, op) : undefined;
497
+ if (override?.returnExpression || override?.returnDataProperty) {
498
+ lines.push(' expect(result).toBeDefined();');
499
+ lines.push(' });');
500
+ return;
501
+ }
502
+
450
503
  // Use entity helper if available, otherwise inline assertions
451
504
  const bodyHelperName = ctx ? `expect${resolveInterfaceName(responseModelName, ctx)}` : null;
452
505
  if (bodyHelperName && entityHelpers?.has(bodyHelperName)) {
@@ -503,6 +556,19 @@ function renderGetTest(
503
556
  lines.push(' expect(Array.isArray(result)).toBe(true);');
504
557
  }
505
558
 
559
+ const returnDataProperty = ctx ? operationOverrideFor(ctx, op)?.returnDataProperty : undefined;
560
+ if (returnDataProperty) {
561
+ const responseModel = modelMap.get(responseModelName);
562
+ const returnedField = responseModel?.fields.find((field) => fieldName(field.name) === returnDataProperty);
563
+ if (returnedField?.type.kind === 'array') {
564
+ lines.push(' expect(Array.isArray(result)).toBe(true);');
565
+ } else {
566
+ lines.push(' expect(result).toBeDefined();');
567
+ }
568
+ lines.push(' });');
569
+ return;
570
+ }
571
+
506
572
  // Use entity helper if available, otherwise inline assertions
507
573
  const helperName = ctx ? `expect${resolveInterfaceName(responseModelName, ctx)}` : null;
508
574
  if (helperName && entityHelpers?.has(helperName)) {
@@ -575,14 +641,43 @@ function buildOptionsObjectTestArg(
575
641
  entries.push(`${optionField}: ${JSON.stringify(pathParamTestValue(param, localName))}`);
576
642
  }
577
643
 
644
+ if (plan.isPaginated) {
645
+ entries.push("order: 'desc'");
646
+ }
647
+ const queryParams = plan.isPaginated
648
+ ? op.queryParams.filter((param) => !['limit', 'before', 'after', 'order'].includes(param.name))
649
+ : op.queryParams;
650
+ for (const param of queryParams) {
651
+ const localName = fieldName(param.name);
652
+ const value = fixtureValueForType(param.type, param.name, 'Options', modelMap) ?? "'test'";
653
+ entries.push(`${localName}: ${value}`);
654
+ }
655
+
578
656
  if (plan.hasBody) {
579
657
  const payload = buildTestPayload(op, modelMap);
580
- if (payload) entries.push(...objectLiteralEntries(payload.camelCaseObj));
658
+ if (payload) {
659
+ const bodyFieldMap = ctx ? operationOverrideFor(ctx, op)?.bodyFieldMap : undefined;
660
+ entries.push(...mapBodyOptionEntries(objectLiteralEntries(payload.camelCaseObj), bodyFieldMap));
661
+ } else if (entries.length === 0) {
662
+ return '({} as any)';
663
+ }
581
664
  }
582
665
 
583
666
  return `{ ${entries.join(', ')} }`;
584
667
  }
585
668
 
669
+ function mapBodyOptionEntries(entries: string[], bodyFieldMap: Record<string, string> | undefined): string[] {
670
+ const reverseMap = new Map(Object.entries(bodyFieldMap ?? {}).map(([source, target]) => [target, source]));
671
+ if (reverseMap.size === 0) return entries;
672
+
673
+ return entries.map((entry) => {
674
+ const match = entry.match(/^([A-Za-z_$][\w$]*)\s*:/);
675
+ if (!match) return entry;
676
+ const source = reverseMap.get(match[1]);
677
+ return source ? entry.replace(match[1], source) : entry;
678
+ });
679
+ }
680
+
586
681
  function objectLiteralEntries(literal: string): string[] {
587
682
  const trimmed = literal.trim();
588
683
  if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return [];
@@ -693,7 +788,12 @@ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<str
693
788
  // Objects and arrays need toEqual with JSON serialization
694
789
  assertions.push(`expect(${accessor}.${domainField}).toEqual(${JSON.stringify(field.example)});`);
695
790
  } else {
696
- const exampleLiteral = typeof field.example === 'string' ? `'${field.example}'` : String(field.example);
791
+ const exampleLiteral =
792
+ isDateTime && typeof field.example === 'string'
793
+ ? `'${new Date(field.example).toISOString()}'`
794
+ : typeof field.example === 'string'
795
+ ? `'${field.example}'`
796
+ : String(field.example);
697
797
  assertions.push(`expect(${fieldAccessor}).toBe(${exampleLiteral});`);
698
798
  }
699
799
  continue;
@@ -739,7 +839,7 @@ function fixtureValueForType(
739
839
  ): string | null {
740
840
  switch (ref.kind) {
741
841
  case 'primitive':
742
- return fixtureValueForPrimitive(ref.type, ref.format, name, modelName);
842
+ return fixtureValueForPrimitive(ref.type, ref.format, name, modelName, wire);
743
843
  case 'literal':
744
844
  return typeof ref.value === 'string' ? `'${ref.value}'` : String(ref.value);
745
845
  case 'enum':
@@ -781,10 +881,11 @@ function fixtureValueForPrimitive(
781
881
  format: string | undefined,
782
882
  name: string,
783
883
  modelName: string,
884
+ wire?: boolean,
784
885
  ): string | null {
785
886
  switch (type) {
786
887
  case 'string':
787
- if (format === 'date-time') return "'2023-01-01T00:00:00.000Z'";
888
+ if (format === 'date-time') return wire ? "'2023-01-01T00:00:00.000Z'" : "new Date('2023-01-01T00:00:00.000Z')";
788
889
  if (format === 'date') return "'2023-01-01'";
789
890
  if (format === 'uuid') return "'00000000-0000-0000-0000-000000000000'";
790
891
  if (name === 'id') {
@@ -1,5 +1,6 @@
1
1
  import type { Model, TypeRef, Enum } from '@workos/oagen';
2
2
  import { isListMetadataModel, isListWrapperModel } from './models.js';
3
+ import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
3
4
  import { snakeName } from './naming.js';
4
5
 
5
6
  /**
@@ -34,9 +35,11 @@ export function generateFixtures(spec: {
34
35
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
35
36
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
36
37
  const files: { path: string; content: string }[] = [];
38
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
39
+ const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
37
40
 
38
41
  for (const model of spec.models) {
39
- if (isListMetadataModel(model)) continue;
42
+ if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
40
43
  if (isListWrapperModel(model)) continue;
41
44
 
42
45
  const fixture = generateModelFixture(model, modelMap, enumMap);
package/src/php/models.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  isListMetadataModel,
9
9
  isListWrapperModel,
10
10
  collectNonPaginatedResponseModelNames,
11
+ collectReferencedListMetadataModels,
11
12
  } from '../shared/model-utils.js';
12
13
  export { isListMetadataModel, isListWrapperModel };
13
14
 
@@ -40,9 +41,10 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
40
41
  // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
41
42
  // code references them by name and the pagination iterator doesn't unwrap them.
42
43
  const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
44
+ const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
43
45
 
44
46
  for (const model of models) {
45
- if (isListMetadataModel(model)) continue;
47
+ if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
46
48
  if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
47
49
  const name = className(model.name);
48
50
  const lines: string[] = [];
@@ -1,4 +1,12 @@
1
- import type { Service, Operation, Model, EmitterContext, GeneratedFile, ResolvedOperation } from '@workos/oagen';
1
+ import type {
2
+ Service,
3
+ Operation,
4
+ Model,
5
+ EmitterContext,
6
+ GeneratedFile,
7
+ ResolvedOperation,
8
+ Parameter,
9
+ } from '@workos/oagen';
2
10
  import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
3
11
  import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
4
12
  import { className, fieldName, resolveMethodName, buildExportedClassNameSet, resolveServiceTarget } from './naming.js';
@@ -243,7 +251,8 @@ function generateMethod(
243
251
  ]);
244
252
 
245
253
  const isRedirect = isRedirectEndpoint(op, resolvedOp);
246
- const params = buildMethodParams(op, plan, modelMap, ctx, hiddenParams);
254
+ const materializeQueryDefaults = !isRedirect;
255
+ const params = buildMethodParams(op, plan, modelMap, ctx, hiddenParams, { materializeQueryDefaults });
247
256
  const returnType = isRedirect ? 'string' : getReturnType(plan, ctx);
248
257
 
249
258
  // PHPDoc block
@@ -302,9 +311,10 @@ function generateMethod(
302
311
  const phpName = fieldName(q.name);
303
312
  if (seenDocParams.has(phpName)) continue;
304
313
  seenDocParams.add(phpName);
305
- // Spec-defaulted enum params are non-nullable (the signature default is the
306
- // enum case, never null). Without a spec default, the param is nullable.
307
- const hasEnumDefault = q.default != null && q.type.kind === 'enum';
314
+ // Spec-defaulted enum params on HTTP calls are non-nullable because the
315
+ // signature default is the enum case. URL builders keep them nullable so
316
+ // omitted optional query params stay omitted from the generated URL.
317
+ const hasEnumDefault = shouldMaterializeQueryDefault(q, materializeQueryDefaults);
308
318
  const nullSuffix = !q.required && !hasEnumDefault && !docType.endsWith('|null') ? '|null' : '';
309
319
  const prefix = q.deprecated ? '(deprecated) ' : '';
310
320
  let desc = q.description ? ` ${prefix}${q.description}` : q.deprecated ? ' (deprecated)' : '';
@@ -358,7 +368,7 @@ function generateMethod(
358
368
 
359
369
  if (isRedirect) {
360
370
  // Redirect endpoint: construct URL client-side instead of making HTTP request
361
- const queryLines = buildQueryArray(op, hiddenParams);
371
+ const queryLines = buildQueryArray(op, hiddenParams, { materializeQueryDefaults });
362
372
  const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
363
373
  const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
364
374
  const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
@@ -617,6 +627,7 @@ function buildMethodParams(
617
627
  modelMap: Map<string, Model>,
618
628
  ctx: EmitterContext,
619
629
  hiddenParams?: Set<string>,
630
+ opts?: { materializeQueryDefaults?: boolean },
620
631
  ): string[] {
621
632
  // Collect all params into required/optional buckets to avoid
622
633
  // PHP's "required after optional" deprecation.
@@ -684,7 +695,7 @@ function buildMethodParams(
684
695
  usedNames.add(phpName);
685
696
  if (q.required) {
686
697
  required.push(`${phpType} $${phpName}`);
687
- } else if (q.default != null && q.type.kind === 'enum') {
698
+ } else if (shouldMaterializeQueryDefault(q, opts?.materializeQueryDefaults ?? true)) {
688
699
  // Spec-provided default for an enum-typed param: emit a non-nullable
689
700
  // typed default (e.g. PaginationOrder $order = PaginationOrder::Desc).
690
701
  // Only enums are safe to default this way — primitives stay nullable so
@@ -748,7 +759,17 @@ function isEnumType(ref: import('@workos/oagen').TypeRef): boolean {
748
759
  return false;
749
760
  }
750
761
 
751
- function buildQueryArray(op: Operation, hiddenParams?: Set<string>): string[] {
762
+ function isDateTimeType(ref: import('@workos/oagen').TypeRef): boolean {
763
+ if (ref.kind === 'primitive' && ref.format === 'date-time') return true;
764
+ if (ref.kind === 'nullable') return isDateTimeType(ref.inner);
765
+ return false;
766
+ }
767
+
768
+ function buildQueryArray(
769
+ op: Operation,
770
+ hiddenParams?: Set<string>,
771
+ opts?: { materializeQueryDefaults?: boolean },
772
+ ): string[] {
752
773
  const hidden = hiddenParams ?? new Set();
753
774
  const groupedParams = collectGroupedParamNames(op);
754
775
  return op.queryParams
@@ -756,16 +777,24 @@ function buildQueryArray(op: Operation, hiddenParams?: Set<string>): string[] {
756
777
  .map((q) => {
757
778
  const phpName = fieldName(q.name);
758
779
  if (isEnumType(q.type)) {
759
- // Mirrors the signature: enum params with a spec default are
760
- // non-nullable, so we can dereference ->value without the nullsafe op.
761
- const hasEnumDefault = q.default != null && q.type.kind === 'enum';
780
+ // Mirrors the signature: only materialized enum defaults are
781
+ // non-nullable, so other optional enum params use the nullsafe op.
782
+ const hasEnumDefault = shouldMaterializeQueryDefault(q, opts?.materializeQueryDefaults ?? true);
762
783
  const nullsafe = q.required || hasEnumDefault ? '' : '?';
763
784
  return `'${q.name}' => $${phpName}${nullsafe}->value,`;
764
785
  }
786
+ if (isDateTimeType(q.type)) {
787
+ const nullsafe = q.required ? '' : '?';
788
+ return `'${q.name}' => $${phpName}${nullsafe}->format(\\DateTimeInterface::RFC3339_EXTENDED),`;
789
+ }
765
790
  return `'${q.name}' => $${phpName},`;
766
791
  });
767
792
  }
768
793
 
794
+ function shouldMaterializeQueryDefault(param: Parameter, enabled: boolean): boolean {
795
+ return enabled && param.default != null && param.type.kind === 'enum';
796
+ }
797
+
769
798
  function phpLiteral(value: unknown): string {
770
799
  if (typeof value === 'string') return `'${value}'`;
771
800
  if (typeof value === 'number') return String(value);
package/src/php/tests.ts CHANGED
@@ -375,9 +375,15 @@ function buildTestArgs(
375
375
  return args.join(', ');
376
376
  }
377
377
 
378
- function generateTestValue(ref: { kind: string; type?: string; name?: string }, ctx?: EmitterContext): string {
378
+ function generateTestValue(
379
+ ref: { kind: string; type?: string; name?: string; format?: string },
380
+ ctx?: EmitterContext,
381
+ ): string {
379
382
  switch (ref.kind) {
380
383
  case 'primitive':
384
+ if (ref.format === 'date-time') {
385
+ return "new \\DateTimeImmutable('2023-01-01T00:00:00Z')";
386
+ }
381
387
  switch (ref.type) {
382
388
  case 'string':
383
389
  return "'test_value'";
@@ -529,17 +535,21 @@ function emitQueryAssertions(lines: string[], op: Operation, ctx: EmitterContext
529
535
  lines.push(` $this->assertSame('${e.values[0].value}', $query['${q.name}']);`);
530
536
  }
531
537
  } else if (innerType.kind === 'primitive') {
532
- switch (innerType.type) {
533
- case 'string':
534
- lines.push(` $this->assertSame('test_value', $query['${q.name}']);`);
535
- break;
536
- case 'integer':
537
- case 'number':
538
- lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
539
- break;
540
- case 'boolean':
541
- lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
542
- break;
538
+ if ((innerType as { format?: string }).format === 'date-time') {
539
+ lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
540
+ } else {
541
+ switch (innerType.type) {
542
+ case 'string':
543
+ lines.push(` $this->assertSame('test_value', $query['${q.name}']);`);
544
+ break;
545
+ case 'integer':
546
+ case 'number':
547
+ lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
548
+ break;
549
+ case 'boolean':
550
+ lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
551
+ break;
552
+ }
543
553
  }
544
554
  }
545
555
  }
@@ -32,14 +32,6 @@ const PYTHON_NON_SPEC_WIRING: Record<string, PythonNonSpecWiring> = {
32
32
  ctorArg: 'self',
33
33
  docstring: 'Passwordless authentication sessions.',
34
34
  },
35
- vault: {
36
- importLine: 'from .vault import AsyncVault, Vault',
37
- prop: 'vault',
38
- syncClass: 'Vault',
39
- asyncClass: 'AsyncVault',
40
- ctorArg: 'self',
41
- docstring: 'Vault encryption, key management, and secret storage.',
42
- },
43
35
  actions: {
44
36
  importLine: 'from .actions import Actions, AsyncActions',
45
37
  prop: 'actions',
@@ -49,18 +49,29 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
49
49
  const canonicalCls = className(canonicalName);
50
50
  const aliasCls = className(enumDef.name);
51
51
  const lines: string[] = [];
52
- lines.push('from typing import TypeAlias');
53
52
  // Use explicit __all__ to prevent ruff F401 from stripping the re-export
54
53
  // Always use direct file import to avoid barrel dependency on the canonical
55
54
  if (canonicalDir === dirName) {
55
+ lines.push('from typing import TypeAlias');
56
56
  lines.push(`from .${fileName(canonicalName)} import ${canonicalCls}`);
57
+ lines.push('');
58
+ lines.push(`${aliasCls}: TypeAlias = ${canonicalCls}`);
57
59
  } else {
58
- lines.push(
59
- `from ${ctx.namespace}.${dirToModule(canonicalDir)}.models.${fileName(canonicalName)} import ${canonicalCls}`,
60
- );
60
+ // Cross-service enum aliases use TYPE_CHECKING + __getattr__ to avoid
61
+ // circular imports caused by service __init__.py eagerly re-exporting
62
+ // all models (e.g. common → radar → common cycle).
63
+ const modPath = `${ctx.namespace}.${dirToModule(canonicalDir)}.models.${fileName(canonicalName)}`;
64
+ lines.push('from typing import TYPE_CHECKING');
65
+ lines.push('');
66
+ lines.push('if TYPE_CHECKING:');
67
+ lines.push(` from ${modPath} import ${canonicalCls} as ${aliasCls}`);
68
+ lines.push('else:');
69
+ lines.push(' def __getattr__(name: str):');
70
+ lines.push(` if name == "${aliasCls}":`);
71
+ lines.push(` from ${modPath} import ${canonicalCls}`);
72
+ lines.push(` return ${canonicalCls}`);
73
+ lines.push(' raise AttributeError(f"module {__name__!r} has no attribute {name!r}")');
61
74
  }
62
- lines.push('');
63
- lines.push(`${aliasCls}: TypeAlias = ${canonicalCls}`);
64
75
  lines.push(`__all__ = ["${aliasCls}"]`);
65
76
  files.push({
66
77
  path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
@@ -71,19 +82,34 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
71
82
 
72
83
  // Also generate compat alias files for dedup aliases (they may have compat aliases too)
73
84
  for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
74
- const importLine =
75
- canonicalDir === dirName
76
- ? `from .${fileName(canonicalName)} import ${canonicalCls}`
77
- : `from ${ctx.namespace}.${dirToModule(canonicalDir)}.models.${fileName(canonicalName)} import ${canonicalCls}`;
78
- files.push({
79
- path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
80
- content: [
85
+ let compatContent: string;
86
+ if (canonicalDir === dirName) {
87
+ compatContent = [
81
88
  'from typing import TypeAlias',
82
- importLine,
89
+ `from .${fileName(canonicalName)} import ${canonicalCls}`,
83
90
  '',
84
91
  `${aliasName}: TypeAlias = ${canonicalCls}`,
85
92
  `__all__ = ["${aliasName}"]`,
86
- ].join('\n'),
93
+ ].join('\n');
94
+ } else {
95
+ const modPath = `${ctx.namespace}.${dirToModule(canonicalDir)}.models.${fileName(canonicalName)}`;
96
+ compatContent = [
97
+ 'from typing import TYPE_CHECKING',
98
+ '',
99
+ 'if TYPE_CHECKING:',
100
+ ` from ${modPath} import ${canonicalCls} as ${aliasName}`,
101
+ 'else:',
102
+ ' def __getattr__(name: str):',
103
+ ` if name == "${aliasName}":`,
104
+ ` from ${modPath} import ${canonicalCls}`,
105
+ ` return ${canonicalCls}`,
106
+ ' raise AttributeError(f"module {__name__!r} has no attribute {name!r}")',
107
+ `__all__ = ["${aliasName}"]`,
108
+ ].join('\n');
109
+ }
110
+ files.push({
111
+ path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
112
+ content: compatContent,
87
113
  integrateTarget: true,
88
114
  overwriteExisting: true,
89
115
  });
@@ -117,7 +117,7 @@ export function generateModelFixture(
117
117
  // Use the original field name as the wire key (matches from_dict access patterns)
118
118
  const wireName = field.name;
119
119
  if (field.example !== undefined) {
120
- fixture[wireName] = field.example;
120
+ fixture[wireName] = normalizeDateTimeExample(field, field.example);
121
121
  } else {
122
122
  fixture[wireName] = generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
123
123
  }
@@ -129,12 +129,10 @@ export function generateModelFixture(
129
129
  const variantModel = modelMap.get(variantName);
130
130
  if (variantModel) {
131
131
  for (const field of variantModel.fields) {
132
- if (!(field.name in fixture)) {
133
- fixture[field.name] =
134
- field.example !== undefined
135
- ? field.example
136
- : generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
137
- }
132
+ fixture[field.name] =
133
+ field.example !== undefined
134
+ ? normalizeDateTimeExample(field, field.example)
135
+ : generateFieldValue(field.type, field.name, variantName, modelMap, enumMap);
138
136
  }
139
137
  }
140
138
  }
@@ -187,6 +185,24 @@ function generateFieldValue(
187
185
  }
188
186
  }
189
187
 
188
+ /**
189
+ * Normalize date-time example values to millisecond precision so that
190
+ * round-trip tests (from_dict → to_dict) produce identical output.
191
+ * Python's _format_datetime always serializes with milliseconds.
192
+ */
193
+ function normalizeDateTimeExample(
194
+ field: { type: { kind: string; format?: string; type?: string; inner?: any } },
195
+ value: any,
196
+ ): any {
197
+ if (typeof value !== 'string') return value;
198
+ const ref = field.type.kind === 'nullable' ? field.type.inner : field.type;
199
+ if (ref?.kind !== 'primitive' || ref?.type !== 'string' || ref?.format !== 'date-time') return value;
200
+ const match = value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/);
201
+ if (!match) return value;
202
+ const [, prefix, , suffix] = match;
203
+ return `${prefix}.000${suffix}`;
204
+ }
205
+
190
206
  function generatePrimitiveValue(type: string, format: string | undefined, name: string, modelName: string): any {
191
207
  switch (type) {
192
208
  case 'string':
@@ -51,6 +51,12 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
51
51
  // otherwise the wrapper's module imports a class that was never written.
52
52
  const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
53
53
 
54
+ // Discriminator model names — fields referencing these should use the Variant union type
55
+ const discriminatorNames = new Set<string>();
56
+ for (const m of models) {
57
+ if ((m as any).discriminator) discriminatorNames.add(m.name);
58
+ }
59
+
54
60
  for (const model of models) {
55
61
  // Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
56
62
  if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
@@ -201,7 +207,6 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
201
207
  const canonicalClassName = className(canonicalName);
202
208
  const lines: string[] = [];
203
209
  lines.push('from typing import TypeAlias');
204
- // Always use direct file import to avoid barrel dependency on the canonical
205
210
  if (canonicalDir === dirName) {
206
211
  lines.push(`from .${fileName(canonicalName)} import ${canonicalClassName}`);
207
212
  } else {
@@ -274,11 +279,15 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
274
279
  if (modelName === model.name) continue; // skip self
275
280
  const modelService = modelToService.get(modelName);
276
281
  const modelDir = resolveDir(modelService);
282
+ // For discriminator models, also import the Variant type alias (lives in same file)
283
+ const importNames = discriminatorNames.has(modelName)
284
+ ? `${className(modelName)}, ${className(modelName)}Variant`
285
+ : className(modelName);
277
286
  if (modelDir === dirName) {
278
- lines.push(`from .${fileName(modelName)} import ${className(modelName)}`);
287
+ lines.push(`from .${fileName(modelName)} import ${importNames}`);
279
288
  } else {
280
289
  lines.push(
281
- `from ${ctx.namespace}.${dirToModule(modelDir)}.models.${fileName(modelName)} import ${className(modelName)}`,
290
+ `from ${ctx.namespace}.${dirToModule(modelDir)}.models.${fileName(modelName)} import ${importNames}`,
282
291
  );
283
292
  }
284
293
  }
@@ -321,9 +330,21 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
321
330
  const requiredFields = deduplicatedFields.filter((f) => !isOptionalField(model.name, f, ctx));
322
331
  const optionalFields = deduplicatedFields.filter((f) => isOptionalField(model.name, f, ctx));
323
332
 
333
+ // Rewrite discriminator model references to their Variant union type.
334
+ // E.g. `"ConnectApplication"` → `"ConnectApplicationVariant"`
335
+ const rewriteDiscriminatorType = (typeStr: string): string => {
336
+ for (const discName of discriminatorNames) {
337
+ const quoted = `"${className(discName)}"`;
338
+ if (typeStr.includes(quoted)) {
339
+ typeStr = typeStr.replace(quoted, `"${className(discName)}Variant"`);
340
+ }
341
+ }
342
+ return typeStr;
343
+ };
344
+
324
345
  for (const field of requiredFields) {
325
346
  const pyFieldName = fieldName(field.name);
326
- const pyType = resolveModelFieldType(field.type);
347
+ const pyType = rewriteDiscriminatorType(resolveModelFieldType(field.type));
327
348
  if (field.description || field.deprecated) {
328
349
  const parts: string[] = [];
329
350
  if (field.description) parts.push(field.description);
@@ -339,7 +360,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
339
360
  const pyFieldName = fieldName(field.name);
340
361
  const innerType =
341
362
  field.type.kind === 'nullable' ? resolveModelFieldType(field.type.inner) : resolveModelFieldType(field.type);
342
- const pyType = `Optional[${innerType}]`;
363
+ const pyType = `Optional[${rewriteDiscriminatorType(innerType)}]`;
343
364
  if (field.description || field.deprecated) {
344
365
  const parts: string[] = [];
345
366
  if (field.description) parts.push(field.description);