@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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
package/src/php/wrappers.ts
CHANGED
|
@@ -31,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) : '
|
|
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
|
-
|
|
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
|
+
};
|
package/src/python/client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/python/enums.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
140
|
+
lines.push('from typing import Literal, TypeAlias');
|
|
139
141
|
lines.push('');
|
|
140
|
-
const literals = uniqueValues.map((v) =>
|
|
142
|
+
const literals = uniqueValues.map((v) =>
|
|
143
|
+
typeof v.value === 'string'
|
|
144
|
+
? `"${v.value}"`
|
|
145
|
+
: typeof v.value === 'boolean'
|
|
146
|
+
? v.value
|
|
147
|
+
? 'True'
|
|
148
|
+
: 'False'
|
|
149
|
+
: String(v.value),
|
|
150
|
+
);
|
|
141
151
|
lines.push(`${cls}: TypeAlias = Union[Literal[${literals.join(', ')}], str]`);
|
|
142
152
|
files.push({
|
|
143
153
|
path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
|
|
@@ -157,7 +167,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
157
167
|
memberName = `${memberName}_${suffix}`;
|
|
158
168
|
}
|
|
159
169
|
usedNames.add(memberName);
|
|
160
|
-
const valueStr =
|
|
170
|
+
const valueStr =
|
|
171
|
+
typeof v.value === 'string'
|
|
172
|
+
? `"${v.value}"`
|
|
173
|
+
: typeof v.value === 'boolean'
|
|
174
|
+
? v.value
|
|
175
|
+
? 'True'
|
|
176
|
+
: 'False'
|
|
177
|
+
: String(v.value);
|
|
161
178
|
lines.push(` ${memberName} = ${valueStr}`);
|
|
162
179
|
if (v.description || v.deprecated) {
|
|
163
180
|
const parts: string[] = [];
|
|
@@ -180,7 +197,15 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
180
197
|
lines.push('');
|
|
181
198
|
lines.push(
|
|
182
199
|
`${cls}Literal: TypeAlias = Literal[${uniqueValues
|
|
183
|
-
.map((v) =>
|
|
200
|
+
.map((v) =>
|
|
201
|
+
typeof v.value === 'string'
|
|
202
|
+
? `"${v.value}"`
|
|
203
|
+
: typeof v.value === 'boolean'
|
|
204
|
+
? v.value
|
|
205
|
+
? 'True'
|
|
206
|
+
: 'False'
|
|
207
|
+
: String(v.value),
|
|
208
|
+
)
|
|
184
209
|
.join(', ')}]`,
|
|
185
210
|
);
|
|
186
211
|
}
|
|
@@ -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
|
|
224
|
+
'from typing import TypeAlias',
|
|
200
225
|
`from .${fileName(enumDef.name)} import ${cls}`,
|
|
201
226
|
'',
|
|
202
227
|
`${aliasName}: TypeAlias = ${cls}`,
|
package/src/python/index.ts
CHANGED
|
@@ -8,10 +8,9 @@ import type {
|
|
|
8
8
|
Enum,
|
|
9
9
|
Service,
|
|
10
10
|
} from '@workos/oagen';
|
|
11
|
-
import * as fs from 'node:fs';
|
|
12
|
-
import * as path from 'node:path';
|
|
13
11
|
|
|
14
12
|
import { generateModels } from './models.js';
|
|
13
|
+
import { detectDiscriminators } from '../shared/model-utils.js';
|
|
15
14
|
import { generateEnums } from './enums.js';
|
|
16
15
|
import { generateResources } from './resources.js';
|
|
17
16
|
import { generateClient } from './client.js';
|
|
@@ -27,11 +26,23 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
|
27
26
|
return files;
|
|
28
27
|
}
|
|
29
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Annotate models with implicit discriminator info (without full oneOf
|
|
31
|
+
* field flattening which can break existing model structures/fixtures).
|
|
32
|
+
*/
|
|
33
|
+
function withDiscriminators(ctx: EmitterContext): EmitterContext {
|
|
34
|
+
const annotated = detectDiscriminators(ctx.spec.models);
|
|
35
|
+
if (annotated === ctx.spec.models) return ctx;
|
|
36
|
+
const spec: ApiSpec = { ...ctx.spec, models: annotated };
|
|
37
|
+
return { ...ctx, spec };
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
export const pythonEmitter: Emitter = {
|
|
31
41
|
language: 'python',
|
|
32
42
|
|
|
33
43
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
34
|
-
|
|
44
|
+
const annotated = detectDiscriminators(models);
|
|
45
|
+
return ensureTrailingNewlines(generateModels(annotated, ctx));
|
|
35
46
|
},
|
|
36
47
|
|
|
37
48
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
@@ -39,11 +50,11 @@ export const pythonEmitter: Emitter = {
|
|
|
39
50
|
},
|
|
40
51
|
|
|
41
52
|
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
42
|
-
return ensureTrailingNewlines(generateResources(services, ctx));
|
|
53
|
+
return ensureTrailingNewlines(generateResources(services, withDiscriminators(ctx)));
|
|
43
54
|
},
|
|
44
55
|
|
|
45
56
|
generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
46
|
-
return ensureTrailingNewlines(generateClient(spec, ctx));
|
|
57
|
+
return ensureTrailingNewlines(generateClient(spec, withDiscriminators(ctx)));
|
|
47
58
|
},
|
|
48
59
|
|
|
49
60
|
generateErrors(): GeneratedFile[] {
|
|
@@ -59,7 +70,15 @@ export const pythonEmitter: Emitter = {
|
|
|
59
70
|
},
|
|
60
71
|
|
|
61
72
|
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
62
|
-
|
|
73
|
+
// Use original spec for model filtering (requestOnlyModelNames etc.) but
|
|
74
|
+
// annotate discriminator models so dispatch tests are generated.
|
|
75
|
+
const annotated = detectDiscriminators(spec.models);
|
|
76
|
+
// Only replace discriminator-annotated models, keep the rest unchanged
|
|
77
|
+
// so request-only filtering and field-based logic work correctly.
|
|
78
|
+
const annotatedByName = new Map(annotated.filter((m: any) => m.discriminator).map((m) => [m.name, m]));
|
|
79
|
+
const testModels = spec.models.map((m) => annotatedByName.get(m.name) ?? m);
|
|
80
|
+
const testSpec: ApiSpec = { ...spec, models: testModels };
|
|
81
|
+
return ensureTrailingNewlines(generateTests(testSpec, { ...ctx, spec: testSpec }));
|
|
63
82
|
},
|
|
64
83
|
|
|
65
84
|
generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
@@ -70,26 +89,15 @@ export const pythonEmitter: Emitter = {
|
|
|
70
89
|
return '# This file is auto-generated by oagen. Do not edit.';
|
|
71
90
|
},
|
|
72
91
|
|
|
73
|
-
formatCommand(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (hasRuff || hasRuffInPyproject || hasRuffInNox) {
|
|
84
|
-
return {
|
|
85
|
-
cmd: 'bash',
|
|
86
|
-
args: [
|
|
87
|
-
'-c',
|
|
88
|
-
'PY_FILES=$(printf "%s\\n" "$@" | grep "\\.py$"); [ -n "$PY_FILES" ] && echo "$PY_FILES" | xargs ruff check --fix --quiet 2>/dev/null; [ -n "$PY_FILES" ] && echo "$PY_FILES" | xargs ruff format --quiet',
|
|
89
|
-
'--',
|
|
90
|
-
],
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
return null;
|
|
92
|
+
formatCommand(_targetDir: string): FormatCommand | null {
|
|
93
|
+
return {
|
|
94
|
+
cmd: 'bash',
|
|
95
|
+
args: [
|
|
96
|
+
'-c',
|
|
97
|
+
'PY_FILES=$(printf "%s\\n" "$@" | grep "\\.py$" || true); if [ -n "$PY_FILES" ]; then echo "$PY_FILES" | xargs ruff check --fix --quiet 2>/dev/null; echo "$PY_FILES" | xargs ruff format --quiet; fi',
|
|
98
|
+
'--',
|
|
99
|
+
],
|
|
100
|
+
batchSize: 1000,
|
|
101
|
+
};
|
|
94
102
|
},
|
|
95
103
|
};
|
package/src/python/models.ts
CHANGED
|
@@ -18,6 +18,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
18
18
|
irService ? (mountDirMap.get(irService) ?? 'common') : 'common';
|
|
19
19
|
const files: GeneratedFile[] = [];
|
|
20
20
|
const emittedModelSymbolsByDir = new Map<string, string[]>();
|
|
21
|
+
// Overrides fileName() for symbols that live in a differently-named file.
|
|
22
|
+
// Used for variant type aliases (e.g. EventSchemaVariant → event_schema).
|
|
23
|
+
const symbolToFile = new Map<string, string>();
|
|
21
24
|
const modelUsage = collectModelUsage(ctx.spec);
|
|
22
25
|
|
|
23
26
|
// Build recursive structural hashes for deduplication.
|
|
@@ -45,6 +48,10 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
// Track emitted file paths to prevent duplicates when synthetic models from
|
|
52
|
+
// oneOf enrichment collide with existing IR models in snake_case.
|
|
53
|
+
const emittedFilePaths = new Set<string>();
|
|
54
|
+
|
|
48
55
|
for (const model of models) {
|
|
49
56
|
// Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
|
|
50
57
|
if (isListWrapperModel(model)) continue;
|
|
@@ -55,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
|
|
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
|
-
|
|
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 (
|
package/src/python/naming.ts
CHANGED
|
@@ -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
|
}
|