@workos/oagen-emitters 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.oxfmtrc.json +8 -1
  3. package/.release-please-manifest.json +1 -1
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +129 -0
  6. package/dist/index.d.mts +10 -1
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +11943 -2728
  9. package/dist/index.mjs.map +1 -1
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +298 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +137 -46
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-php.ts +28 -26
  23. package/smoke/sdk-python.ts +5 -2
  24. package/smoke/sdk-ruby.ts +17 -3
  25. package/smoke/sdk-rust.ts +16 -3
  26. package/src/go/client.ts +141 -0
  27. package/src/go/enums.ts +196 -0
  28. package/src/go/fixtures.ts +212 -0
  29. package/src/go/index.ts +81 -0
  30. package/src/go/manifest.ts +36 -0
  31. package/src/go/models.ts +254 -0
  32. package/src/go/naming.ts +191 -0
  33. package/src/go/resources.ts +827 -0
  34. package/src/go/tests.ts +751 -0
  35. package/src/go/type-map.ts +82 -0
  36. package/src/go/wrappers.ts +261 -0
  37. package/src/index.ts +3 -0
  38. package/src/node/client.ts +167 -122
  39. package/src/node/enums.ts +13 -4
  40. package/src/node/errors.ts +42 -233
  41. package/src/node/field-plan.ts +726 -0
  42. package/src/node/fixtures.ts +15 -5
  43. package/src/node/index.ts +65 -16
  44. package/src/node/models.ts +264 -96
  45. package/src/node/naming.ts +52 -25
  46. package/src/node/resources.ts +621 -172
  47. package/src/node/sdk-errors.ts +41 -0
  48. package/src/node/tests.ts +71 -27
  49. package/src/node/type-map.ts +4 -2
  50. package/src/node/utils.ts +56 -64
  51. package/src/node/wrappers.ts +151 -0
  52. package/src/php/client.ts +171 -0
  53. package/src/php/enums.ts +67 -0
  54. package/src/php/errors.ts +9 -0
  55. package/src/php/fixtures.ts +181 -0
  56. package/src/php/index.ts +96 -0
  57. package/src/php/manifest.ts +36 -0
  58. package/src/php/models.ts +310 -0
  59. package/src/php/naming.ts +298 -0
  60. package/src/php/resources.ts +561 -0
  61. package/src/php/tests.ts +533 -0
  62. package/src/php/type-map.ts +90 -0
  63. package/src/php/utils.ts +18 -0
  64. package/src/php/wrappers.ts +151 -0
  65. package/src/python/client.ts +337 -0
  66. package/src/python/enums.ts +313 -0
  67. package/src/python/fixtures.ts +196 -0
  68. package/src/python/index.ts +95 -0
  69. package/src/python/manifest.ts +38 -0
  70. package/src/python/models.ts +688 -0
  71. package/src/python/naming.ts +209 -0
  72. package/src/python/resources.ts +1322 -0
  73. package/src/python/tests.ts +1335 -0
  74. package/src/python/type-map.ts +93 -0
  75. package/src/python/wrappers.ts +191 -0
  76. package/src/shared/model-utils.ts +255 -0
  77. package/src/shared/naming-utils.ts +107 -0
  78. package/src/shared/non-spec-services.ts +54 -0
  79. package/src/shared/resolved-ops.ts +109 -0
  80. package/src/shared/wrapper-utils.ts +59 -0
  81. package/test/go/client.test.ts +92 -0
  82. package/test/go/enums.test.ts +132 -0
  83. package/test/go/errors.test.ts +9 -0
  84. package/test/go/models.test.ts +265 -0
  85. package/test/go/resources.test.ts +408 -0
  86. package/test/go/tests.test.ts +143 -0
  87. package/test/node/client.test.ts +199 -94
  88. package/test/node/enums.test.ts +75 -3
  89. package/test/node/errors.test.ts +2 -41
  90. package/test/node/models.test.ts +109 -20
  91. package/test/node/naming.test.ts +37 -4
  92. package/test/node/resources.test.ts +662 -30
  93. package/test/node/serializers.test.ts +36 -7
  94. package/test/node/type-map.test.ts +11 -0
  95. package/test/php/client.test.ts +94 -0
  96. package/test/php/enums.test.ts +173 -0
  97. package/test/php/errors.test.ts +9 -0
  98. package/test/php/models.test.ts +497 -0
  99. package/test/php/resources.test.ts +644 -0
  100. package/test/php/tests.test.ts +118 -0
  101. package/test/python/client.test.ts +200 -0
  102. package/test/python/enums.test.ts +228 -0
  103. package/test/python/errors.test.ts +16 -0
  104. package/test/python/manifest.test.ts +74 -0
  105. package/test/python/models.test.ts +716 -0
  106. package/test/python/resources.test.ts +617 -0
  107. package/test/python/tests.test.ts +202 -0
  108. package/src/node/common.ts +0 -273
  109. package/src/node/config.ts +0 -71
  110. package/src/node/serializers.ts +0 -744
@@ -0,0 +1,151 @@
1
+ import type { EmitterContext, ResolvedOperation, ResolvedWrapper, TypeRef } from '@workos/oagen';
2
+ import { toCamelCase } from '@workos/oagen';
3
+ import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
4
+ import { className, fieldName } from './naming.js';
5
+ import { phpDocComment } from './utils.js';
6
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
7
+
8
+ /**
9
+ * Generate PHP wrapper methods for split union operations.
10
+ */
11
+ export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
12
+ const lines: string[] = [];
13
+ for (const wrapper of resolvedOp.wrappers ?? []) {
14
+ emitWrapperMethod(lines, resolvedOp, wrapper, ctx);
15
+ }
16
+ return lines;
17
+ }
18
+
19
+ function emitWrapperMethod(
20
+ lines: string[],
21
+ resolvedOp: ResolvedOperation,
22
+ wrapper: ResolvedWrapper,
23
+ ctx: EmitterContext,
24
+ ): void {
25
+ const method = toCamelCase(wrapper.name);
26
+ const ns = ctx.namespacePascal;
27
+ const wrapperParams = resolveWrapperParams(wrapper, ctx);
28
+
29
+ lines.push('');
30
+
31
+ // PHPDoc block
32
+ const docParts: string[] = [];
33
+ for (const { paramName, field, isOptional } of wrapperParams) {
34
+ const docType = field ? mapTypeRefForPHPDoc(field.type) : 'mixed';
35
+ const nullSuffix = isOptional && !docType.endsWith('|null') ? '|null' : '';
36
+ docParts.push(`@param ${docType}${nullSuffix} $${fieldName(paramName)}`);
37
+ }
38
+ const op2 = resolvedOp.operation;
39
+ const returnDocType = op2.response.kind === 'model' ? `\\${ns}\\Resource\\${className(op2.response.name)}` : 'mixed';
40
+ docParts.push(`@return ${returnDocType}`);
41
+ lines.push(...phpDocComment(docParts.join('\n'), 4));
42
+
43
+ lines.push(` public function ${method}(`);
44
+
45
+ // Build params: required first, then optional, to avoid PHP deprecation
46
+ const requiredParams: string[] = [];
47
+ const optionalParamLines: string[] = [];
48
+ for (const { paramName, field, isOptional } of wrapperParams) {
49
+ const phpName = fieldName(paramName);
50
+ if (field) {
51
+ const phpType = mapTypeRef(field.type);
52
+ if (isOptional) {
53
+ const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
54
+ optionalParamLines.push(` ${nullableType} $${phpName} = null,`);
55
+ } else {
56
+ requiredParams.push(` ${phpType} $${phpName},`);
57
+ }
58
+ } else {
59
+ optionalParamLines.push(` mixed $${phpName} = null,`);
60
+ }
61
+ }
62
+ optionalParamLines.push(` ?\\${ns}\\RequestOptions $options = null,`);
63
+ for (const p of [...requiredParams, ...optionalParamLines]) {
64
+ lines.push(p);
65
+ }
66
+
67
+ // Return type
68
+ const op = resolvedOp.operation;
69
+ const responseType = op.response.kind === 'model' ? `\\${ns}\\Resource\\${className(op.response.name)}` : 'mixed';
70
+ lines.push(` ): ${responseType} {`);
71
+
72
+ // Build body using array_filter for consistency
73
+ const bodyEntries: string[] = [];
74
+
75
+ // Defaults (always included)
76
+ if (wrapper.defaults) {
77
+ for (const [key, value] of Object.entries(wrapper.defaults)) {
78
+ bodyEntries.push(`'${key}' => ${phpLiteral(value)}`);
79
+ }
80
+ }
81
+
82
+ // Exposed params (extract enum values)
83
+ for (const { paramName, field } of wrapperParams) {
84
+ const phpName = fieldName(paramName);
85
+ if (field && isEnumType(field.type)) {
86
+ bodyEntries.push(`'${paramName}' => $${phpName}?->value`);
87
+ } else {
88
+ bodyEntries.push(`'${paramName}' => $${phpName}`);
89
+ }
90
+ }
91
+
92
+ lines.push(' $body = array_filter([');
93
+ for (const entry of bodyEntries) {
94
+ lines.push(` ${entry},`);
95
+ }
96
+ lines.push(' ], fn ($v) => $v !== null);');
97
+
98
+ // inferFromClient fields need special handling (conditional injection)
99
+ for (const clientField of wrapper.inferFromClient ?? []) {
100
+ const clientExpr = clientFieldExpression(clientField);
101
+ lines.push(` $body['${clientField}'] = ${clientExpr};`);
102
+ }
103
+
104
+ // Delegate to HTTP client
105
+ const httpMethod = op.httpMethod.toUpperCase();
106
+ let path = op.path.startsWith('/') ? op.path.slice(1) : op.path;
107
+ const hasInterpolation = /\{[^}]+\}/.test(path);
108
+ path = path.replace(/\{([^}]+)\}/g, (_match, param) => `{$${fieldName(param)}}`);
109
+ const pathQuote = hasInterpolation ? '"' : "'";
110
+
111
+ lines.push('');
112
+ lines.push(' $response = $this->client->request(');
113
+ lines.push(` method: '${httpMethod}',`);
114
+ lines.push(` path: ${pathQuote}${path}${pathQuote},`);
115
+ lines.push(' body: $body,');
116
+ lines.push(' options: $options,');
117
+ lines.push(' );');
118
+
119
+ if (op.response.kind === 'model') {
120
+ lines.push(` return ${className(op.response.name)}::fromArray($response);`);
121
+ } else {
122
+ lines.push(' return $response;');
123
+ }
124
+
125
+ lines.push(' }');
126
+ }
127
+
128
+ function isEnumType(ref: TypeRef): boolean {
129
+ if (ref.kind === 'enum') return true;
130
+ if (ref.kind === 'nullable') return isEnumType(ref.inner);
131
+ return false;
132
+ }
133
+
134
+ function phpLiteral(value: unknown): string {
135
+ if (typeof value === 'string') return `'${value}'`;
136
+ if (typeof value === 'number') return String(value);
137
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
138
+ return 'null';
139
+ }
140
+
141
+ function clientFieldExpression(field: string): string {
142
+ // Map inferFromClient fields to the actual client/config accessors
143
+ switch (field) {
144
+ case 'client_id':
145
+ return '$this->client->requireClientId()';
146
+ case 'client_secret':
147
+ return '$this->client->requireApiKey()';
148
+ default:
149
+ return `$this->client->${toCamelCase(field)}`;
150
+ }
151
+ }
@@ -0,0 +1,337 @@
1
+ import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
+ import { toPascalCase } from '@workos/oagen';
3
+ import { className, resolveServiceDir, servicePropertyName, buildMountDirMap, dirToModule } from './naming.js';
4
+ import { resolveResourceClassName } from './resources.js';
5
+ import { getMountTarget } from '../shared/resolved-ops.js';
6
+ import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
7
+
8
+ /** Python-specific wiring for each non-spec service. */
9
+ interface PythonNonSpecWiring {
10
+ /** Python import line (e.g. "from .vault import Vault, AsyncVault") */
11
+ importLine: string;
12
+ /** Property name on the client class */
13
+ prop: string;
14
+ /** Sync class name */
15
+ syncClass: string;
16
+ /** Async class name, or null if no async variant */
17
+ asyncClass: string | null;
18
+ /** Constructor expression — 'self' if the class takes the client, '' if stateless */
19
+ ctorArg: 'self' | '';
20
+ /** One-line docstring for the client property */
21
+ docstring?: string;
22
+ }
23
+
24
+ const PYTHON_NON_SPEC_WIRING: Record<string, PythonNonSpecWiring> = {
25
+ passwordless: {
26
+ importLine: 'from .passwordless import AsyncPasswordless, Passwordless',
27
+ prop: 'passwordless',
28
+ syncClass: 'Passwordless',
29
+ asyncClass: 'AsyncPasswordless',
30
+ ctorArg: 'self',
31
+ docstring: 'Passwordless authentication sessions.',
32
+ },
33
+ vault: {
34
+ importLine: 'from .vault import AsyncVault, Vault',
35
+ prop: 'vault',
36
+ syncClass: 'Vault',
37
+ asyncClass: 'AsyncVault',
38
+ ctorArg: 'self',
39
+ docstring: 'Vault encryption, key management, and secret storage.',
40
+ },
41
+ actions: {
42
+ importLine: 'from .actions import Actions, AsyncActions',
43
+ prop: 'actions',
44
+ syncClass: 'Actions',
45
+ asyncClass: 'AsyncActions',
46
+ ctorArg: '',
47
+ docstring: 'Actions logging and audit trail.',
48
+ },
49
+ pkce: {
50
+ importLine: 'from .pkce import PKCE',
51
+ prop: 'pkce',
52
+ syncClass: 'PKCE',
53
+ asyncClass: null,
54
+ ctorArg: '',
55
+ docstring: 'PKCE (Proof Key for Code Exchange) utilities.',
56
+ },
57
+ };
58
+
59
+ /**
60
+ * Generate the slim Python client class (service-wiring only),
61
+ * service __init__.py files, and types barrels.
62
+ *
63
+ * Static HTTP infrastructure lives in _base_client.py (hand-maintained
64
+ * in the target SDK, marked @oagen-ignore-file).
65
+ */
66
+ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
67
+ assertPublicClientReachability(spec, ctx);
68
+
69
+ const files: GeneratedFile[] = [];
70
+
71
+ files.push(...generateWorkOSClient(spec, ctx));
72
+ files.push(...generateServiceInits(spec, ctx));
73
+ files.push(...generateTypesBarrels(spec, ctx));
74
+
75
+ return files;
76
+ }
77
+
78
+ /**
79
+ * Deduplicate services by mount target. Multiple IR services may mount to the
80
+ * same target (e.g., Applications + ApplicationClientSecrets -> Connect).
81
+ * Returns one representative service per unique mount target, using the service
82
+ * whose PascalCase name matches the target (if any), or the first one found.
83
+ */
84
+ function deduplicateByMount(services: Service[], ctx: EmitterContext): Service[] {
85
+ const byTarget = new Map<string, Service>();
86
+ for (const s of services) {
87
+ const target = getMountTarget(s, ctx);
88
+ const existing = byTarget.get(target);
89
+ if (!existing || toPascalCase(s.name) === target) {
90
+ byTarget.set(target, s);
91
+ }
92
+ }
93
+ return [...byTarget.values()];
94
+ }
95
+
96
+ export function buildServiceAccessPaths(services: Service[], ctx: EmitterContext): Map<string, string> {
97
+ const topLevel = deduplicateByMount(services, ctx);
98
+ const paths = new Map<string, string>();
99
+
100
+ for (const service of topLevel) {
101
+ const resolvedName = resolveResourceClassName(service, ctx);
102
+ const prop = servicePropertyName(resolvedName);
103
+ paths.set(service.name, prop);
104
+ }
105
+
106
+ // Build reverse map: mount target name -> access path
107
+ const targetPaths = new Map<string, string>();
108
+ for (const service of topLevel) {
109
+ const target = getMountTarget(service, ctx);
110
+ if (!targetPaths.has(target) && paths.has(service.name)) {
111
+ targetPaths.set(target, paths.get(service.name)!);
112
+ }
113
+ }
114
+
115
+ // Map mounted services to their mount target's access path
116
+ for (const service of services) {
117
+ if (paths.has(service.name)) continue;
118
+ const mountTarget = getMountTarget(service, ctx);
119
+ const targetPath = targetPaths.get(mountTarget) ?? paths.get(mountTarget);
120
+ if (targetPath) paths.set(service.name, targetPath);
121
+ }
122
+
123
+ return paths;
124
+ }
125
+
126
+ function assertPublicClientReachability(spec: ApiSpec, ctx: EmitterContext): void {
127
+ const topLevelServices = deduplicateByMount(spec.services, ctx);
128
+ const accessPaths = buildServiceAccessPaths(spec.services, ctx);
129
+ const unreachableServices = topLevelServices
130
+ .filter((service) => service.operations.length > 0 && !accessPaths.has(service.name))
131
+ .map((service) => service.name);
132
+
133
+ if (unreachableServices.length > 0) {
134
+ throw new Error(`Python emitter reachability audit failed for services: ${unreachableServices.join(', ')}`);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Generate the slim _client.py that subclasses the static _base_client.py
140
+ * and wires spec-driven service accessors.
141
+ */
142
+ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
143
+ const lines: string[] = [];
144
+ const topLevelServices = deduplicateByMount(spec.services, ctx);
145
+
146
+ // --- Imports ---
147
+ lines.push('from __future__ import annotations');
148
+ lines.push('');
149
+ lines.push('import functools');
150
+ lines.push('');
151
+ lines.push('from ._base_client import (');
152
+ lines.push(' WorkOSClient as _SyncBase,');
153
+ lines.push(' AsyncWorkOSClient as _AsyncBase,');
154
+ lines.push(')');
155
+
156
+ // Import resource classes (both sync and async)
157
+ const serviceDirMap = buildMountDirMap(ctx);
158
+ for (const service of topLevelServices) {
159
+ const resolvedName = resolveResourceClassName(service, ctx);
160
+ const clsName = className(resolvedName);
161
+ const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
162
+ lines.push(`from .${dirToModule(dirName)}._resource import ${clsName}, Async${clsName}`);
163
+ }
164
+ // Non-spec service imports (driven by shared/non-spec-services.ts)
165
+ for (const s of NON_SPEC_SERVICES) {
166
+ const w = PYTHON_NON_SPEC_WIRING[s.id];
167
+ if (w) lines.push(w.importLine);
168
+ }
169
+ lines.push('');
170
+ lines.push('');
171
+
172
+ // --- Sync client ---
173
+ lines.push('class WorkOSClient(_SyncBase):');
174
+ lines.push(' """Synchronous WorkOS API client with service accessors."""');
175
+
176
+ // Collect all generated property names
177
+ const generatedProps = new Set<string>();
178
+ for (const service of topLevelServices) {
179
+ const resolvedName = resolveResourceClassName(service, ctx);
180
+ const clsName = className(resolvedName);
181
+ const prop = servicePropertyName(resolvedName);
182
+ const readable = clsName.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
183
+ lines.push('');
184
+ lines.push(' @functools.cached_property');
185
+ lines.push(` def ${prop}(self) -> ${clsName}:`);
186
+ lines.push(` """${readable} API resources."""`);
187
+ lines.push(` return ${clsName}(self)`);
188
+ generatedProps.add(prop);
189
+ }
190
+ emitCompatClientPropertyAliases(lines, generatedProps, false);
191
+ emitCompatClientAccessors(lines, false);
192
+
193
+ lines.push('');
194
+ lines.push('');
195
+
196
+ // --- Async client ---
197
+ lines.push('class AsyncWorkOSClient(_AsyncBase):');
198
+ lines.push(' """Asynchronous WorkOS API client with service accessors."""');
199
+
200
+ const asyncGeneratedProps = new Set<string>();
201
+ for (const service of topLevelServices) {
202
+ const resolvedName = resolveResourceClassName(service, ctx);
203
+ const clsName = className(resolvedName);
204
+ const prop = servicePropertyName(resolvedName);
205
+ const readable = clsName.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
206
+ lines.push('');
207
+ lines.push(' @functools.cached_property');
208
+ lines.push(` def ${prop}(self) -> Async${clsName}:`);
209
+ lines.push(` """${readable} API resources."""`);
210
+ lines.push(` return Async${clsName}(self)`);
211
+ asyncGeneratedProps.add(prop);
212
+ }
213
+ emitCompatClientPropertyAliases(lines, asyncGeneratedProps, true);
214
+ emitCompatClientAccessors(lines, true);
215
+
216
+ return [
217
+ {
218
+ path: `src/${ctx.namespace}/_client.py`,
219
+ content: lines.join('\n'),
220
+ integrateTarget: true,
221
+ overwriteExisting: true,
222
+ },
223
+ ];
224
+ }
225
+
226
+ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
227
+ const files: GeneratedFile[] = [];
228
+ const topLevel = deduplicateByMount(spec.services, ctx);
229
+ const serviceDirMap = buildMountDirMap(ctx);
230
+
231
+ for (const service of topLevel) {
232
+ const resolvedName = resolveResourceClassName(service, ctx);
233
+ const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
234
+ const lines: string[] = [];
235
+
236
+ lines.push(`from ._resource import ${resolvedName}, Async${resolvedName}`);
237
+ lines.push('from .models import *');
238
+
239
+ files.push({
240
+ path: `src/${ctx.namespace}/${dirName}/__init__.py`,
241
+ content: lines.join('\n'),
242
+ integrateTarget: true,
243
+ overwriteExisting: true,
244
+ });
245
+
246
+ // Ensure models/__init__.py exists even if no models are assigned to this service
247
+ files.push({
248
+ path: `src/${ctx.namespace}/${dirName}/models/__init__.py`,
249
+ content: '',
250
+ skipIfExists: true,
251
+ });
252
+ }
253
+
254
+ return files;
255
+ }
256
+
257
+ function emitCompatClientPropertyAliases(lines: string[], generatedProps: Set<string>, isAsync: boolean): void {
258
+ const aliases: Array<{ alias: string; typeName: string; returnExpr: string; docstring: string }> = [];
259
+ if (generatedProps.has('multi_factor_auth') && !generatedProps.has('mfa')) {
260
+ const mfaType = isAsync ? 'AsyncMultiFactorAuth' : 'MultiFactorAuth';
261
+ aliases.push({
262
+ alias: 'mfa',
263
+ typeName: mfaType,
264
+ returnExpr: 'self.multi_factor_auth',
265
+ docstring: '"""Alias for multi_factor_auth."""',
266
+ });
267
+ }
268
+ for (const alias of aliases) {
269
+ lines.push('');
270
+ lines.push(' @functools.cached_property');
271
+ lines.push(` def ${alias.alias}(self) -> ${alias.typeName}:`);
272
+ lines.push(` ${alias.docstring}`);
273
+ lines.push(` return ${alias.returnExpr}`);
274
+ }
275
+ }
276
+
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
+ /**
296
+ * Generate types/<service>/__init__.py re-export barrels so that
297
+ * `from workos.types.<service> import Model` continues to work.
298
+ */
299
+ function generateTypesBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
300
+ const files: GeneratedFile[] = [];
301
+ const serviceDirMap = buildMountDirMap(ctx);
302
+
303
+ // Collect (types dir name -> set of service dirs whose models should be re-exported)
304
+ const typesEntries = new Map<string, Set<string>>();
305
+
306
+ for (const service of spec.services) {
307
+ const resolvedName = resolveResourceClassName(service, ctx);
308
+ const prop = servicePropertyName(resolvedName);
309
+ const dir = serviceDirMap.get(service.name) ?? prop;
310
+ const dirs = typesEntries.get(prop) ?? new Set();
311
+ dirs.add(dir);
312
+ typesEntries.set(prop, dirs);
313
+ }
314
+
315
+ for (const [typesDir, serviceDirs] of typesEntries) {
316
+ const imports = [...serviceDirs]
317
+ .sort()
318
+ .map((dir) => `from ${ctx.namespace}.${dirToModule(dir)}.models import * # noqa: F401,F403`);
319
+
320
+ files.push({
321
+ path: `src/${ctx.namespace}/types/${typesDir}/__init__.py`,
322
+ content: imports.join('\n'),
323
+ integrateTarget: true,
324
+ overwriteExisting: true,
325
+ });
326
+ }
327
+
328
+ // Root types/__init__.py
329
+ files.push({
330
+ path: `src/${ctx.namespace}/types/__init__.py`,
331
+ content: '',
332
+ integrateTarget: true,
333
+ overwriteExisting: true,
334
+ });
335
+
336
+ return files;
337
+ }