@workos/oagen-emitters 0.3.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.
Files changed (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -31,13 +31,14 @@ 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) : 'mixed';
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
  }
38
38
  const op2 = resolvedOp.operation;
39
39
  const returnDocType = op2.response.kind === 'model' ? `\\${ns}\\Resource\\${className(op2.response.name)}` : 'mixed';
40
40
  docParts.push(`@return ${returnDocType}`);
41
+ docParts.push(`@throws \\${ns}\\Exception\\WorkOSException`);
41
42
  lines.push(...phpDocComment(docParts.join('\n'), 4));
42
43
 
43
44
  lines.push(` public function ${method}(`);
@@ -56,7 +57,11 @@ function emitWrapperMethod(
56
57
  requiredParams.push(` ${phpType} $${phpName},`);
57
58
  }
58
59
  } else {
59
- optionalParamLines.push(` mixed $${phpName} = null,`);
60
+ if (isOptional) {
61
+ optionalParamLines.push(` ?string $${phpName} = null,`);
62
+ } else {
63
+ requiredParams.push(` string $${phpName},`);
64
+ }
60
65
  }
61
66
  }
62
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
+ };
@@ -1,23 +1,17 @@
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. */
9
9
  interface PythonNonSpecWiring {
10
- /** Python import line (e.g. "from .vault import Vault, AsyncVault") */
11
10
  importLine: string;
12
- /** Property name on the client class */
13
11
  prop: string;
14
- /** Sync class name */
15
12
  syncClass: string;
16
- /** Async class name, or null if no async variant */
17
13
  asyncClass: string | null;
18
- /** Constructor expression — 'self' if the class takes the client, '' if stateless */
19
14
  ctorArg: 'self' | '';
20
- /** One-line docstring for the client property */
21
15
  docstring?: string;
22
16
  }
23
17
 
@@ -159,13 +153,25 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
159
153
  const resolvedName = resolveResourceClassName(service, ctx);
160
154
  const clsName = className(resolvedName);
161
155
  const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
162
- lines.push(`from .${dirToModule(dirName)}._resource import ${clsName}, Async${clsName}`);
156
+ const importLine = `from .${dirToModule(dirName)}._resource import ${clsName}, Async${clsName}`;
157
+ if (importLine.length > 88) {
158
+ lines.push(`from .${dirToModule(dirName)}._resource import (`);
159
+ lines.push(` ${clsName},`);
160
+ lines.push(` Async${clsName},`);
161
+ lines.push(')');
162
+ } else {
163
+ lines.push(importLine);
164
+ }
163
165
  }
164
- // Non-spec service imports (driven by shared/non-spec-services.ts)
166
+ // Non-spec service imports wrapped in ignore markers so the merger
167
+ // matches them positionally and doesn't displace them.
168
+ lines.push('');
169
+ lines.push('# @oagen-ignore-start — non-spec service imports (hand-maintained)');
165
170
  for (const s of NON_SPEC_SERVICES) {
166
171
  const w = PYTHON_NON_SPEC_WIRING[s.id];
167
172
  if (w) lines.push(w.importLine);
168
173
  }
174
+ lines.push('# @oagen-ignore-end');
169
175
  lines.push('');
170
176
  lines.push('');
171
177
 
@@ -188,7 +194,7 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
188
194
  generatedProps.add(prop);
189
195
  }
190
196
  emitCompatClientPropertyAliases(lines, generatedProps, false);
191
- emitCompatClientAccessors(lines, false);
197
+ emitNonSpecClientAccessors(lines, false);
192
198
 
193
199
  lines.push('');
194
200
  lines.push('');
@@ -211,29 +217,59 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
211
217
  asyncGeneratedProps.add(prop);
212
218
  }
213
219
  emitCompatClientPropertyAliases(lines, asyncGeneratedProps, true);
214
- emitCompatClientAccessors(lines, true);
220
+ emitNonSpecClientAccessors(lines, true);
215
221
 
216
222
  return [
217
223
  {
218
224
  path: `src/${ctx.namespace}/_client.py`,
219
225
  content: lines.join('\n'),
220
- integrateTarget: true,
221
226
  overwriteExisting: true,
222
227
  },
223
228
  ];
224
229
  }
225
230
 
231
+ function emitNonSpecClientAccessors(lines: string[], isAsync: boolean): void {
232
+ lines.push('');
233
+ lines.push(' # @oagen-ignore-start — non-spec service accessors (hand-maintained)');
234
+ for (const s of NON_SPEC_SERVICES) {
235
+ const w = PYTHON_NON_SPEC_WIRING[s.id];
236
+ if (!w) continue;
237
+ const typeName = isAsync ? (w.asyncClass ?? w.syncClass) : w.syncClass;
238
+ const arg = w.ctorArg === 'self' ? 'self' : '';
239
+
240
+ lines.push('');
241
+ lines.push(' @functools.cached_property');
242
+ lines.push(` def ${w.prop}(self) -> ${typeName}:`);
243
+ if (w.docstring) {
244
+ lines.push(` """${w.docstring}"""`);
245
+ }
246
+ lines.push(` return ${typeName}(${arg})`);
247
+ }
248
+ lines.push('');
249
+ lines.push(' # @oagen-ignore-end');
250
+ }
251
+
226
252
  function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
227
253
  const files: GeneratedFile[] = [];
228
254
  const topLevel = deduplicateByMount(spec.services, ctx);
229
255
  const serviceDirMap = buildMountDirMap(ctx);
230
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
+
231
261
  for (const service of topLevel) {
232
262
  const resolvedName = resolveResourceClassName(service, ctx);
233
263
  const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
234
264
  const lines: string[] = [];
235
265
 
236
- lines.push(`from ._resource import ${resolvedName}, Async${resolvedName}`);
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(', ')}`);
237
273
  lines.push('from .models import *');
238
274
 
239
275
  files.push({
@@ -274,24 +310,6 @@ function emitCompatClientPropertyAliases(lines: string[], generatedProps: Set<st
274
310
  }
275
311
  }
276
312
 
277
- function emitCompatClientAccessors(lines: string[], isAsync: boolean): void {
278
- for (const s of NON_SPEC_SERVICES) {
279
- const w = PYTHON_NON_SPEC_WIRING[s.id];
280
- if (!w) continue;
281
- // Skip async-only services when emitting the sync client, and vice versa
282
- const typeName = isAsync ? (w.asyncClass ?? w.syncClass) : w.syncClass;
283
- const arg = w.ctorArg === 'self' ? 'self' : '';
284
-
285
- lines.push('');
286
- lines.push(' @functools.cached_property');
287
- lines.push(` def ${w.prop}(self) -> ${typeName}:`);
288
- if (w.docstring) {
289
- lines.push(` """${w.docstring}"""`);
290
- }
291
- lines.push(` return ${typeName}(${arg})`);
292
- }
293
- }
294
-
295
313
  /**
296
314
  * Generate types/<service>/__init__.py re-export barrels so that
297
315
  * `from workos.types.<service> import Model` continues to work.
@@ -37,12 +37,14 @@ 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);
43
45
  const aliasCls = className(enumDef.name);
44
46
  const lines: string[] = [];
45
- lines.push('from typing_extensions import TypeAlias');
47
+ lines.push('from typing import TypeAlias');
46
48
  // Use explicit __all__ to prevent ruff F401 from stripping the re-export
47
49
  // Always use direct file import to avoid barrel dependency on the canonical
48
50
  if (canonicalDir === dirName) {
@@ -71,7 +73,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
71
73
  files.push({
72
74
  path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
73
75
  content: [
74
- 'from typing_extensions import TypeAlias',
76
+ 'from typing import TypeAlias',
75
77
  importLine,
76
78
  '',
77
79
  `${aliasName}: TypeAlias = ${canonicalCls}`,
@@ -96,7 +98,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
96
98
 
97
99
  if (enumDef.values.length === 0) {
98
100
  lines.push('from typing import Union');
99
- lines.push('from typing_extensions import TypeAlias');
101
+ lines.push('from typing import TypeAlias');
100
102
  lines.push('');
101
103
  lines.push(`${cls}: TypeAlias = str`);
102
104
  } else {
@@ -118,7 +120,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
118
120
  if (allStrings) {
119
121
  lines.push('from enum import Enum');
120
122
  lines.push('from typing import Optional');
121
- lines.push('from typing_extensions import Literal, TypeAlias');
123
+ lines.push('from typing import Literal, TypeAlias');
122
124
  lines.push('');
123
125
  lines.push('');
124
126
  lines.push(`class ${cls}(str, Enum):`);
@@ -126,7 +128,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
126
128
  lines.push('');
127
129
  } else if (allIntegers) {
128
130
  lines.push('from enum import IntEnum');
129
- lines.push('from typing_extensions import Literal, TypeAlias');
131
+ lines.push('from typing import Literal, TypeAlias');
130
132
  lines.push('');
131
133
  lines.push('');
132
134
  lines.push(`class ${cls}(IntEnum):`);
@@ -135,9 +137,17 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
135
137
  } else {
136
138
  // Mixed types — fall back to Union[Literal[...], str]
137
139
  lines.push('from typing import Union');
138
- lines.push('from typing_extensions import Literal, TypeAlias');
140
+ lines.push('from typing import Literal, TypeAlias');
139
141
  lines.push('');
140
- const literals = uniqueValues.map((v) => (typeof v.value === 'string' ? `"${v.value}"` : String(v.value)));
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 = typeof v.value === 'string' ? `"${v.value}"` : String(v.value);
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) => (typeof v.value === 'string' ? `"${v.value}"` : String(v.value)))
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
  }
@@ -196,7 +221,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
196
221
  files.push({
197
222
  path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
198
223
  content: [
199
- 'from typing_extensions import TypeAlias',
224
+ 'from typing import TypeAlias',
200
225
  `from .${fileName(enumDef.name)} import ${cls}`,
201
226
  '',
202
227
  `${aliasName}: TypeAlias = ${cls}`,
@@ -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
- return ensureTrailingNewlines(generateModels(models, ctx));
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
- return ensureTrailingNewlines(generateTests(spec, ctx));
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(targetDir: string): FormatCommand | null {
74
- const hasRuff =
75
- fs.existsSync(path.join(targetDir, 'ruff.toml')) || fs.existsSync(path.join(targetDir, '.ruff.toml'));
76
- // Also check pyproject.toml for [tool.ruff] section
77
- const pyproject = path.join(targetDir, 'pyproject.toml');
78
- const hasRuffInPyproject = fs.existsSync(pyproject) && fs.readFileSync(pyproject, 'utf8').includes('[tool.ruff]');
79
- // Check for noxfile that uses ruff
80
- const noxfile = path.join(targetDir, 'noxfile.py');
81
- const hasRuffInNox = fs.existsSync(noxfile) && fs.readFileSync(noxfile, 'utf8').includes('ruff');
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
  };
@@ -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,14 +62,140 @@ 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);
64
197
  const lines: string[] = [];
65
- lines.push('from typing_extensions import TypeAlias');
198
+ lines.push('from typing import TypeAlias');
66
199
  // Always use direct file import to avoid barrel dependency on the canonical
67
200
  if (canonicalDir === dirName) {
68
201
  lines.push(`from .${fileName(canonicalName)} import ${canonicalClassName}`);
@@ -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
- importLines.push(`from .${fileName(name)} import ${className(name)} as ${className(name)}`);
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 (
@@ -1,24 +1,7 @@
1
1
  import type { Operation, Service, EmitterContext } from '@workos/oagen';
2
2
  import { toPascalCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
4
- import { stripUrnPrefix } from '../shared/naming-utils.js';
5
-
6
- /**
7
- * Map of lowercase acronym forms to their correct casing.
8
- * Applied as a post-processing step after toPascalCase.
9
- */
10
- const ACRONYM_FIXES: [RegExp, string][] = [
11
- [/Workos/g, 'WorkOS'],
12
- [/Sso/g, 'SSO'],
13
- [/Mfa/g, 'MFA'],
14
- [/Jwt/g, 'JWT'],
15
- [/Cors/g, 'CORS'],
16
- [/Saml/g, 'SAML'],
17
- [/Scim/g, 'SCIM'],
18
- [/Rbac/g, 'RBAC'],
19
- [/Oauth/g, 'OAuth'],
20
- [/Oidc/g, 'OIDC'],
21
- ];
4
+ import { stripUrnPrefix, applyAcronymFixes } from '../shared/naming-utils.js';
22
5
 
23
6
  /**
24
7
  * Python class names that collide with builtins or typing imports.
@@ -41,10 +24,7 @@ const PYTHON_RESERVED_CLASS_NAMES = new Set([
41
24
 
42
25
  /** PascalCase class name with acronym preservation. */
43
26
  export function className(name: string): string {
44
- let result = toPascalCase(stripUrnPrefix(name));
45
- for (const [pattern, replacement] of ACRONYM_FIXES) {
46
- result = result.replace(pattern, replacement);
47
- }
27
+ let result = applyAcronymFixes(toPascalCase(stripUrnPrefix(name)));
48
28
  if (PYTHON_RESERVED_CLASS_NAMES.has(result)) {
49
29
  result += 'Model';
50
30
  }