@contractkit/plugin-python 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.turbo/turbo-build$colon$ci.log +35 -0
  2. package/.turbo/turbo-build.log +15 -0
  3. package/.turbo/turbo-test$colon$ci.log +59 -0
  4. package/.turbo/turbo-test.log +15 -0
  5. package/CHANGELOG.md +118 -0
  6. package/README.md +86 -0
  7. package/coverage/base.css +224 -0
  8. package/coverage/block-navigation.js +87 -0
  9. package/coverage/clover.xml +559 -0
  10. package/coverage/coverage-final.json +4 -0
  11. package/coverage/favicon.png +0 -0
  12. package/coverage/index.html +131 -0
  13. package/coverage/prettify.css +1 -0
  14. package/coverage/prettify.js +2 -0
  15. package/coverage/sort-arrow-sprite.png +0 -0
  16. package/coverage/sorter.js +210 -0
  17. package/coverage/src/codegen-client.ts.html +2071 -0
  18. package/coverage/src/codegen-models.ts.html +1360 -0
  19. package/coverage/src/index.html +131 -0
  20. package/coverage/tests/helpers.ts.html +667 -0
  21. package/coverage/tests/index.html +116 -0
  22. package/dist/codegen-client.d.ts +30 -0
  23. package/dist/codegen-client.d.ts.map +1 -0
  24. package/dist/codegen-models.d.ts +19 -0
  25. package/dist/codegen-models.d.ts.map +1 -0
  26. package/dist/index.d.ts +17 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +1046 -0
  29. package/dist/index.js.map +1 -0
  30. package/eslint.config.js +6 -0
  31. package/package.json +45 -0
  32. package/src/codegen-client.ts +662 -0
  33. package/src/codegen-models.ts +425 -0
  34. package/src/index.ts +143 -0
  35. package/tests/codegen-client.test.ts +361 -0
  36. package/tests/codegen-models.test.ts +295 -0
  37. package/tests/helpers.ts +194 -0
  38. package/tsconfig.json +9 -0
  39. package/vitest.config.ts +14 -0
@@ -0,0 +1,361 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generatePythonClient, deriveClientClassName, deriveClientModuleName, hasPublicOperations } from '../src/codegen-client.js';
3
+ import {
4
+ scalarType, arrayType, refType, enumType,
5
+ opParam, paramNodes, paramRef, paramType, opRequest, opResponse, opOperation, opRoute, opRoot,
6
+ } from './helpers.js';
7
+
8
+ // ─── deriveClientClassName ────────────────────────────────────────────────
9
+
10
+ describe('deriveClientClassName', () => {
11
+ it('derives class name from file', () => {
12
+ expect(deriveClientClassName('payments.op.ck')).toBe('PaymentsClient');
13
+ expect(deriveClientClassName('ledger.categories.op.ck')).toBe('LedgerCategoriesClient');
14
+ expect(deriveClientClassName('/path/to/users.op.ck')).toBe('UsersClient');
15
+ });
16
+ });
17
+
18
+ describe('deriveClientModuleName', () => {
19
+ it('derives module name from file', () => {
20
+ expect(deriveClientModuleName('payments.op.ck')).toBe('_client_payments');
21
+ expect(deriveClientModuleName('ledger.categories.op.ck')).toBe('_client_ledger_categories');
22
+ });
23
+ });
24
+
25
+ // ─── hasPublicOperations ──────────────────────────────────────────────────
26
+
27
+ describe('hasPublicOperations', () => {
28
+ it('returns false for all-internal ops', () => {
29
+ const root = opRoot([
30
+ opRoute('/internal', [opOperation('get')], undefined, ['internal']),
31
+ ]);
32
+ expect(hasPublicOperations(root)).toBe(false);
33
+ });
34
+
35
+ it('returns true when at least one public op exists', () => {
36
+ const root = opRoot([
37
+ opRoute('/public', [opOperation('get')]),
38
+ ]);
39
+ expect(hasPublicOperations(root)).toBe(true);
40
+ });
41
+ });
42
+
43
+ // ─── generatePythonClient ─────────────────────────────────────────────────
44
+
45
+ describe('generatePythonClient', () => {
46
+ it('generates a class with the right name', () => {
47
+ const root = opRoot([
48
+ opRoute('/payments', [
49
+ opOperation('get', { responses: [opResponse(200, 'Payment')] }),
50
+ ]),
51
+ ], 'payments.op.ck');
52
+ const output = generatePythonClient(root);
53
+ expect(output).toContain('class PaymentsClient(BaseClient):');
54
+ });
55
+
56
+ it('skips internal operations', () => {
57
+ const root = opRoot([
58
+ opRoute('/internal', [opOperation('get', { responses: [opResponse(200, 'User')] })], undefined, ['internal']),
59
+ opRoute('/public', [opOperation('get', { responses: [opResponse(200, 'User')] })]),
60
+ ]);
61
+ const output = generatePythonClient(root);
62
+ const methodCount = (output.match(/async def /g) || []).length;
63
+ expect(methodCount).toBe(1);
64
+ });
65
+
66
+ it('infers method names from path and method', () => {
67
+ const root = opRoot([
68
+ opRoute('/payments', [opOperation('get', { responses: [opResponse(200, 'array(Payment)')] })]),
69
+ opRoute('/payments/{id}', [opOperation('get', { responses: [opResponse(200, 'Payment')] })],
70
+ paramNodes([opParam('id', scalarType('uuid'))])),
71
+ opRoute('/payments', [opOperation('post', { request: opRequest('PaymentInput'), responses: [opResponse(201, 'Payment')] })]),
72
+ ], 'payments.op.ck');
73
+ const output = generatePythonClient(root);
74
+ expect(output).toContain('async def get_payments(self)');
75
+ expect(output).toContain('async def get_payments_by_id(self, id: UUID)');
76
+ expect(output).toContain('async def post_payments(self, body: PaymentInput)');
77
+ });
78
+
79
+ it('uses op.sdk name when provided (converted to snake_case)', () => {
80
+ const root = opRoot([
81
+ opRoute('/payments/{id}', [
82
+ opOperation('get', { sdk: 'getPayment', responses: [opResponse(200, 'Payment')] }),
83
+ ], paramNodes([opParam('id', scalarType('uuid'))])),
84
+ ]);
85
+ const output = generatePythonClient(root);
86
+ expect(output).toContain('async def get_payment(self, id: UUID)');
87
+ });
88
+
89
+ it('uses op.name as method name when op.sdk is not set', () => {
90
+ const root = opRoot([
91
+ opRoute('/payments', [
92
+ opOperation('post', { name: 'Create a Payment', responses: [opResponse(201, 'Payment')] }),
93
+ ]),
94
+ ]);
95
+ const output = generatePythonClient(root);
96
+ expect(output).toContain('async def create_a_payment(self)');
97
+ });
98
+
99
+ it('prefers op.sdk over op.name as method name', () => {
100
+ const root = opRoot([
101
+ opRoute('/payments', [
102
+ opOperation('post', { sdk: 'makePayment', name: 'Create a Payment', responses: [opResponse(201, 'Payment')] }),
103
+ ]),
104
+ ]);
105
+ const output = generatePythonClient(root);
106
+ expect(output).toContain('async def make_payment(self)');
107
+ expect(output).not.toContain('create_a_payment');
108
+ });
109
+
110
+ it('generates void return for operations with no body', () => {
111
+ const root = opRoot([
112
+ opRoute('/payments/{id}', [
113
+ opOperation('delete', { responses: [opResponse(204)] }),
114
+ ], paramNodes([opParam('id', scalarType('uuid'))])),
115
+ ]);
116
+ const output = generatePythonClient(root);
117
+ expect(output).toContain('-> None:');
118
+ expect(output).toContain('return None');
119
+ });
120
+
121
+ it('generates model_validate for model responses', () => {
122
+ const root = opRoot([
123
+ opRoute('/payments/{id}', [
124
+ opOperation('get', { responses: [opResponse(200, 'Payment')] }),
125
+ ], paramNodes([opParam('id', scalarType('uuid'))])),
126
+ ]);
127
+ const output = generatePythonClient(root);
128
+ expect(output).toContain('Payment.model_validate(result)');
129
+ });
130
+
131
+ it('generates list comprehension for array model responses', () => {
132
+ const root = opRoot([
133
+ opRoute('/payments', [
134
+ opOperation('get', { responses: [opResponse(200, 'array(Payment)')] }),
135
+ ]),
136
+ ]);
137
+ const output = generatePythonClient(root);
138
+ expect(output).toContain('[Payment.model_validate(item) for item in result]');
139
+ });
140
+
141
+ it('generates query parameter', () => {
142
+ const root = opRoot([
143
+ opRoute('/payments', [
144
+ opOperation('get', {
145
+ query: [opParam('page', scalarType('int')), opParam('limit', scalarType('int'))],
146
+ responses: [opResponse(200, 'array(Payment)')],
147
+ }),
148
+ ]),
149
+ ]);
150
+ const output = generatePythonClient(root);
151
+ expect(output).toContain('query: dict | None = None');
152
+ expect(output).toContain('params=query');
153
+ });
154
+
155
+ it('generates body parameter for POST', () => {
156
+ const root = opRoot([
157
+ opRoute('/payments', [
158
+ opOperation('post', {
159
+ request: opRequest('PaymentInput'),
160
+ responses: [opResponse(201, 'Payment')],
161
+ }),
162
+ ]),
163
+ ]);
164
+ const output = generatePythonClient(root);
165
+ expect(output).toContain('body: PaymentInput');
166
+ expect(output).toContain('body=body.model_dump(mode="json")');
167
+ });
168
+
169
+ it('generates path param interpolation in f-string', () => {
170
+ const root = opRoot([
171
+ opRoute('/payments/{id}', [
172
+ opOperation('get', { responses: [opResponse(200, 'Payment')] }),
173
+ ], paramNodes([opParam('id', scalarType('uuid'))])),
174
+ ]);
175
+ const output = generatePythonClient(root);
176
+ expect(output).toContain('f"/payments/{id}"');
177
+ });
178
+
179
+ it('imports model types from their modules', () => {
180
+ const modelModulePaths = new Map([['Payment', '._models_payment'], ['PaymentInput', '._models_payment']]);
181
+ const root = opRoot([
182
+ opRoute('/payments', [
183
+ opOperation('post', {
184
+ request: opRequest('PaymentInput'),
185
+ responses: [opResponse(201, 'Payment')],
186
+ }),
187
+ ]),
188
+ ]);
189
+ const output = generatePythonClient(root, { modelModulePaths });
190
+ expect(output).toContain('from ._models_payment import Payment, PaymentInput');
191
+ });
192
+
193
+ it('imports UUID when uuid scalar is used', () => {
194
+ const root = opRoot([
195
+ opRoute('/payments/{id}', [
196
+ opOperation('get', { responses: [opResponse(204)] }),
197
+ ], paramNodes([opParam('id', scalarType('uuid'))])),
198
+ ]);
199
+ const output = generatePythonClient(root);
200
+ expect(output).toContain('from uuid import UUID');
201
+ });
202
+
203
+ it('adds deprecated comment for deprecated operations', () => {
204
+ const root = opRoot([
205
+ opRoute('/old', [
206
+ opOperation('get', { responses: [opResponse(200, 'User')] }),
207
+ ], undefined, ['deprecated']),
208
+ ]);
209
+ const output = generatePythonClient(root);
210
+ expect(output).toContain('# @deprecated');
211
+ });
212
+
213
+ it('forwards a vendor JSON content_type kwarg to _fetch', () => {
214
+ const root = opRoot([
215
+ opRoute('/users', [
216
+ opOperation('post', {
217
+ sdk: 'createUser',
218
+ request: opRequest('User', 'application/vnd.api+json'),
219
+ responses: [opResponse(201, 'User', 'application/vnd.api+json')],
220
+ }),
221
+ ]),
222
+ ]);
223
+ const output = generatePythonClient(root);
224
+ expect(output).toContain('content_type="application/vnd.api+json"');
225
+ });
226
+
227
+ it('omits internal operations by default and includes them when includeInternal is true', () => {
228
+ const root = opRoot([
229
+ opRoute('/public', [opOperation('get', { sdk: 'getPublic', responses: [opResponse(200, 'User')] })]),
230
+ opRoute('/secret', [opOperation('get', { sdk: 'getSecret', responses: [opResponse(200, 'User')] })], undefined, ['internal']),
231
+ ]);
232
+ const defaultOut = generatePythonClient(root);
233
+ expect(defaultOut).toContain('async def get_public(');
234
+ expect(defaultOut).not.toContain('async def get_secret(');
235
+
236
+ const inclusiveOut = generatePythonClient(root, { includeInternal: true });
237
+ expect(inclusiveOut).toContain('async def get_public(');
238
+ expect(inclusiveOut).toContain('async def get_secret(');
239
+ });
240
+
241
+ it('typed body and response as str/bytes for text and binary content types', () => {
242
+ const textRoot = opRoot([
243
+ opRoute('/notes', [
244
+ opOperation('post', {
245
+ sdk: 'putNote',
246
+ request: opRequest('Note', 'text/plain'),
247
+ responses: [opResponse(200, 'Note', 'text/plain')],
248
+ }),
249
+ ]),
250
+ ]);
251
+ const textOut = generatePythonClient(textRoot);
252
+ expect(textOut).toContain('body: str');
253
+ expect(textOut).toContain('-> str:');
254
+ expect(textOut).toContain('body_kind="text"');
255
+ expect(textOut).toContain('response_kind="text"');
256
+
257
+ const binaryRoot = opRoot([
258
+ opRoute('/files', [
259
+ opOperation('get', {
260
+ sdk: 'downloadFile',
261
+ responses: [opResponse(200, 'File', 'application/octet-stream')],
262
+ }),
263
+ ]),
264
+ ]);
265
+ const binaryOut = generatePythonClient(binaryRoot);
266
+ expect(binaryOut).toContain('-> bytes:');
267
+ expect(binaryOut).toContain('response_kind="binary"');
268
+ });
269
+
270
+ it('omits content_type kwarg when the request is plain application/json', () => {
271
+ const root = opRoot([
272
+ opRoute('/users', [
273
+ opOperation('post', {
274
+ sdk: 'createUser',
275
+ request: opRequest('User'),
276
+ responses: [opResponse(201, 'User')],
277
+ }),
278
+ ]),
279
+ ]);
280
+ const output = generatePythonClient(root);
281
+ expect(output).not.toContain('content_type=');
282
+ });
283
+
284
+ it('uses model_dump for Input variant body when modelsWithInput is set', () => {
285
+ const modelsWithInput = new Set(['Payment']);
286
+ const root = opRoot([
287
+ opRoute('/payments', [
288
+ opOperation('post', {
289
+ request: opRequest('Payment'),
290
+ responses: [opResponse(201, 'Payment')],
291
+ }),
292
+ ]),
293
+ ]);
294
+ const output = generatePythonClient(root, { modelsWithInput });
295
+ expect(output).toContain('body: PaymentInput');
296
+ expect(output).toContain('body=body.model_dump(mode="json")');
297
+ });
298
+
299
+ describe('response headers', () => {
300
+ it('emits a TypedDict and tuple return type when response declares headers', () => {
301
+ const root = opRoot([
302
+ opRoute('/transfers/{id}', [
303
+ opOperation('get', {
304
+ sdk: 'getTransfer',
305
+ responses: [
306
+ {
307
+ statusCode: 200,
308
+ contentType: 'application/json',
309
+ bodyType: { kind: 'ref', name: 'Transfer' },
310
+ headers: [
311
+ { name: 'preference-applied', optional: true, type: scalarType('string') },
312
+ { name: 'etag', optional: false, type: scalarType('string') },
313
+ ],
314
+ },
315
+ ],
316
+ }),
317
+ ], paramNodes([opParam('id', scalarType('uuid'))])),
318
+ ]);
319
+ const output = generatePythonClient(root);
320
+ expect(output).toContain('from typing import TypedDict');
321
+ expect(output).toContain('class GetTransferHeaders(TypedDict, total=False):');
322
+ expect(output).toContain(' preference_applied: str # preference-applied (optional)');
323
+ expect(output).toContain(' etag: str # etag (required)');
324
+ expect(output).toContain('-> tuple[Transfer, GetTransferHeaders]:');
325
+ expect(output).toContain('await self._fetch_with_headers(');
326
+ expect(output).toContain('"preference-applied" in _response_headers');
327
+ expect(output).toContain('headers["preference_applied"] = _response_headers["preference-applied"]');
328
+ expect(output).toContain('return Transfer.model_validate(result), headers');
329
+ });
330
+
331
+ it('returns just headers TypedDict for void ops with declared response headers', () => {
332
+ const root = opRoot([
333
+ opRoute('/resources/{id}', [
334
+ opOperation('delete', {
335
+ sdk: 'deleteResource',
336
+ responses: [
337
+ {
338
+ statusCode: 204,
339
+ headers: [{ name: 'x-deleted-at', optional: false, type: scalarType('string') }],
340
+ },
341
+ ],
342
+ }),
343
+ ], paramNodes([opParam('id', scalarType('uuid'))])),
344
+ ]);
345
+ const output = generatePythonClient(root);
346
+ expect(output).toContain('class DeleteResourceHeaders(TypedDict, total=False):');
347
+ expect(output).toContain('-> DeleteResourceHeaders:');
348
+ expect(output).toContain('return headers');
349
+ });
350
+
351
+ it('keeps plain return type when no response headers are declared', () => {
352
+ const root = opRoot([
353
+ opRoute('/users/{id}', [opOperation('get', { sdk: 'getUser', responses: [opResponse(200, 'User')] })], paramNodes([opParam('id', scalarType('uuid'))])),
354
+ ]);
355
+ const output = generatePythonClient(root);
356
+ expect(output).toContain('-> User:');
357
+ expect(output).not.toContain('TypedDict');
358
+ expect(output).not.toContain('_fetch_with_headers');
359
+ });
360
+ });
361
+ });
@@ -0,0 +1,295 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generatePydanticModels, renderPyType, toPythonFieldName, deriveModelsModuleName } from '../src/codegen-models.js';
3
+ import {
4
+ scalarType, arrayType, tupleType, recordType, enumType, literalType,
5
+ unionType, refType, inlineObjectType, lazyType,
6
+ field, model, contractRoot,
7
+ } from './helpers.js';
8
+
9
+ // ─── renderPyType ─────────────────────────────────────────────────────────
10
+
11
+ describe('renderPyType', () => {
12
+ it('renders scalar types', () => {
13
+ expect(renderPyType(scalarType('string'))).toBe('str');
14
+ expect(renderPyType(scalarType('number'))).toBe('float');
15
+ expect(renderPyType(scalarType('int'))).toBe('int');
16
+ expect(renderPyType(scalarType('bigint'))).toBe('int');
17
+ expect(renderPyType(scalarType('boolean'))).toBe('bool');
18
+ expect(renderPyType(scalarType('date'))).toBe('date');
19
+ expect(renderPyType(scalarType('time'))).toBe('time');
20
+ expect(renderPyType(scalarType('datetime'))).toBe('datetime');
21
+ expect(renderPyType(scalarType('duration'))).toBe('timedelta');
22
+ expect(renderPyType(scalarType('uuid'))).toBe('UUID');
23
+ expect(renderPyType(scalarType('email'))).toBe('str');
24
+ expect(renderPyType(scalarType('url'))).toBe('str');
25
+ expect(renderPyType(scalarType('null'))).toBe('None');
26
+ expect(renderPyType(scalarType('binary'))).toBe('bytes');
27
+ expect(renderPyType(scalarType('unknown'))).toBe('Any');
28
+ expect(renderPyType(scalarType('json'))).toBe('Any');
29
+ expect(renderPyType(scalarType('object'))).toBe('Any');
30
+ });
31
+
32
+ it('renders enum', () => {
33
+ expect(renderPyType(enumType('pending', 'completed', 'failed')))
34
+ .toBe('Literal["pending", "completed", "failed"]');
35
+ });
36
+
37
+ it('renders literal', () => {
38
+ expect(renderPyType(literalType('hello'))).toBe('"hello"');
39
+ expect(renderPyType(literalType(42))).toBe('42');
40
+ expect(renderPyType(literalType(true))).toBe('true');
41
+ });
42
+
43
+ it('renders array', () => {
44
+ expect(renderPyType(arrayType(scalarType('string')))).toBe('list[str]');
45
+ expect(renderPyType(arrayType(refType('Payment')))).toBe('list[Payment]');
46
+ });
47
+
48
+ it('renders tuple', () => {
49
+ expect(renderPyType(tupleType(scalarType('string'), scalarType('int')))).toBe('tuple[str, int]');
50
+ expect(renderPyType({ kind: 'tuple', items: [] })).toBe('tuple[()]');
51
+ });
52
+
53
+ it('renders record', () => {
54
+ expect(renderPyType(recordType(scalarType('string'), scalarType('number')))).toBe('dict[str, float]');
55
+ });
56
+
57
+ it('renders union', () => {
58
+ expect(renderPyType(unionType(scalarType('string'), scalarType('int')))).toBe('str | int');
59
+ });
60
+
61
+ it('renders model ref', () => {
62
+ expect(renderPyType(refType('Payment'))).toBe('Payment');
63
+ });
64
+
65
+ it('renders model ref as Input variant when forInput=true and in modelsWithInput', () => {
66
+ const modelsWithInput = new Set(['Payment']);
67
+ expect(renderPyType(refType('Payment'), modelsWithInput, true)).toBe('PaymentInput');
68
+ expect(renderPyType(refType('Payment'), modelsWithInput, false)).toBe('Payment');
69
+ });
70
+
71
+ it('renders inline object as dict', () => {
72
+ expect(renderPyType(inlineObjectType([]))).toBe('dict[str, Any]');
73
+ });
74
+
75
+ it('renders lazy unwrapped', () => {
76
+ expect(renderPyType(lazyType(scalarType('string')))).toBe('str');
77
+ });
78
+
79
+ it('renders intersection as dict', () => {
80
+ expect(renderPyType({ kind: 'intersection', members: [refType('A'), refType('B')] })).toBe('dict[str, Any]');
81
+ });
82
+ });
83
+
84
+ // ─── toPythonFieldName ────────────────────────────────────────────────────
85
+
86
+ describe('toPythonFieldName', () => {
87
+ it('leaves valid snake_case unchanged', () => {
88
+ expect(toPythonFieldName('name')).toBe('name');
89
+ expect(toPythonFieldName('first_name')).toBe('first_name');
90
+ });
91
+
92
+ it('converts camelCase to snake_case', () => {
93
+ expect(toPythonFieldName('createdAt')).toBe('created_at');
94
+ expect(toPythonFieldName('firstName')).toBe('first_name');
95
+ expect(toPythonFieldName('myHTTPClient')).toBe('my_httpclient');
96
+ });
97
+
98
+ it('replaces hyphens with underscores', () => {
99
+ expect(toPythonFieldName('x-event-id')).toBe('x_event_id');
100
+ expect(toPythonFieldName('x-topic')).toBe('x_topic');
101
+ });
102
+
103
+ it('handles mixed separators', () => {
104
+ expect(toPythonFieldName('my.field-name')).toBe('my_field_name');
105
+ });
106
+ });
107
+
108
+ // ─── deriveModelsModuleName ───────────────────────────────────────────────
109
+
110
+ describe('deriveModelsModuleName', () => {
111
+ it('converts file paths to Python module names', () => {
112
+ expect(deriveModelsModuleName('payment.ck')).toBe('_models_payment');
113
+ expect(deriveModelsModuleName('ledger.categories.ck')).toBe('_models_ledger_categories');
114
+ expect(deriveModelsModuleName('/path/to/user.profile.ck')).toBe('_models_user_profile');
115
+ });
116
+ });
117
+
118
+ // ─── generatePydanticModels ───────────────────────────────────────────────
119
+
120
+ describe('generatePydanticModels', () => {
121
+ it('generates a simple model', () => {
122
+ const root = contractRoot([
123
+ model('Payment', [
124
+ field('id', scalarType('uuid')),
125
+ field('amount', scalarType('number')),
126
+ field('status', enumType('pending', 'completed', 'failed')),
127
+ ]),
128
+ ]);
129
+ const output = generatePydanticModels(root);
130
+ expect(output).toContain('class Payment(BaseModel):');
131
+ expect(output).toContain('id: UUID');
132
+ expect(output).toContain('amount: float');
133
+ expect(output).toContain('status: Literal["pending", "completed", "failed"]');
134
+ expect(output).toContain('from pydantic import BaseModel');
135
+ expect(output).toContain('from uuid import UUID');
136
+ expect(output).toContain('from typing import Literal');
137
+ });
138
+
139
+ it('generates optional fields', () => {
140
+ const root = contractRoot([
141
+ model('User', [
142
+ field('id', scalarType('uuid')),
143
+ field('bio', scalarType('string'), { optional: true }),
144
+ ]),
145
+ ]);
146
+ const output = generatePydanticModels(root);
147
+ expect(output).toContain('bio: str | None = None');
148
+ });
149
+
150
+ it('generates fields with defaults', () => {
151
+ const root = contractRoot([
152
+ model('Config', [
153
+ field('status', enumType('active', 'inactive'), { default: 'active' }),
154
+ ]),
155
+ ]);
156
+ const output = generatePydanticModels(root);
157
+ expect(output).toContain('default="active"');
158
+ });
159
+
160
+ it('generates nullable fields', () => {
161
+ const root = contractRoot([
162
+ model('Item', [
163
+ field('description', scalarType('string'), { nullable: true }),
164
+ ]),
165
+ ]);
166
+ const output = generatePydanticModels(root);
167
+ expect(output).toContain('description: str | None');
168
+ });
169
+
170
+ it('generates Field(alias=...) for fields with hyphens', () => {
171
+ const root = contractRoot([
172
+ model('WebhookHeaders', [
173
+ field('x-topic', scalarType('string')),
174
+ field('x-event-id', scalarType('uuid')),
175
+ ]),
176
+ ]);
177
+ const output = generatePydanticModels(root);
178
+ expect(output).toContain('x_topic: str = Field(alias="x-topic")');
179
+ expect(output).toContain('x_event_id: UUID = Field(alias="x-event-id")');
180
+ expect(output).toContain('model_config = ConfigDict(populate_by_name=True)');
181
+ expect(output).toContain('from pydantic import BaseModel, ConfigDict, Field');
182
+ });
183
+
184
+ it('generates Input/Read split for readonly fields', () => {
185
+ const root = contractRoot([
186
+ model('Payment', [
187
+ field('id', scalarType('uuid'), { visibility: 'readonly' }),
188
+ field('amount', scalarType('number')),
189
+ field('createdAt', scalarType('datetime'), { visibility: 'readonly' }),
190
+ ]),
191
+ ]);
192
+ const output = generatePydanticModels(root);
193
+ // Read model has id and createdAt
194
+ expect(output).toContain('class Payment(BaseModel):');
195
+ expect(output).toContain('class PaymentInput(BaseModel):');
196
+ // Input omits readonly fields
197
+ const inputStart = output.indexOf('class PaymentInput');
198
+ const inputSection = output.slice(inputStart);
199
+ expect(inputSection).not.toContain('id: UUID');
200
+ expect(inputSection).not.toContain('created_at: datetime');
201
+ expect(inputSection).toContain('amount: float');
202
+ });
203
+
204
+ it('generates Input/Read split for writeonly fields', () => {
205
+ const root = contractRoot([
206
+ model('UserCreate', [
207
+ field('username', scalarType('string')),
208
+ field('password', scalarType('string'), { visibility: 'writeonly' }),
209
+ ]),
210
+ ]);
211
+ const output = generatePydanticModels(root);
212
+ expect(output).toContain('class UserCreate(BaseModel):');
213
+ expect(output).toContain('class UserCreateInput(BaseModel):');
214
+ // Read model omits writeonly
215
+ const readStart = output.indexOf('class UserCreate(BaseModel):');
216
+ const readEnd = output.indexOf('class UserCreateInput');
217
+ const readSection = output.slice(readStart, readEnd);
218
+ expect(readSection).not.toContain('password: str');
219
+ expect(readSection).toContain('username: str');
220
+ });
221
+
222
+ it('generates a type alias', () => {
223
+ const root = contractRoot([
224
+ model('UserId', [], { type: scalarType('uuid') }),
225
+ ]);
226
+ const output = generatePydanticModels(root);
227
+ expect(output).toContain('UserId = UUID');
228
+ });
229
+
230
+ it('generates datetime imports when needed', () => {
231
+ const root = contractRoot([
232
+ model('Event', [
233
+ field('createdAt', scalarType('datetime')),
234
+ field('date', scalarType('date')),
235
+ field('time', scalarType('time')),
236
+ ]),
237
+ ]);
238
+ const output = generatePydanticModels(root);
239
+ expect(output).toContain('from datetime import date, datetime, time');
240
+ });
241
+
242
+ it('generates timedelta import for duration fields', () => {
243
+ const root = contractRoot([model('Task', [field('timeout', scalarType('duration'))])]);
244
+ const output = generatePydanticModels(root);
245
+ expect(output).toContain('from datetime import timedelta');
246
+ expect(output).toContain('timeout: timedelta');
247
+ });
248
+
249
+ it('includes deprecation comment', () => {
250
+ const root = contractRoot([
251
+ model('OldModel', [field('id', scalarType('uuid'))], { deprecated: true }),
252
+ ]);
253
+ const output = generatePydanticModels(root);
254
+ expect(output).toContain('# @deprecated');
255
+ });
256
+
257
+ it('includes description comment', () => {
258
+ const root = contractRoot([
259
+ model('Payment', [field('id', scalarType('uuid'))], { description: 'A payment record' }),
260
+ ]);
261
+ const output = generatePydanticModels(root);
262
+ expect(output).toContain('# A payment record');
263
+ });
264
+
265
+ it('handles model extending another model', () => {
266
+ const root = contractRoot([
267
+ model('BaseEntity', [field('id', scalarType('uuid'))]),
268
+ model('Payment', [field('amount', scalarType('number'))], { bases: ['BaseEntity'] }),
269
+ ]);
270
+ const output = generatePydanticModels(root);
271
+ expect(output).toContain('class Payment(BaseEntity):');
272
+ });
273
+
274
+ it('emits a comma-separated parent list for multi-base inheritance', () => {
275
+ const root = contractRoot([
276
+ model('A', [field('a', scalarType('string'))]),
277
+ model('B', [field('b', scalarType('int'))]),
278
+ model('Test5', [field('e', scalarType('string'))], { bases: ['A', 'B'] }),
279
+ ]);
280
+ const output = generatePydanticModels(root);
281
+ expect(output).toContain('class Test5(A, B):');
282
+ });
283
+
284
+ it('generates array and record types', () => {
285
+ const root = contractRoot([
286
+ model('Container', [
287
+ field('items', arrayType(refType('Payment'))),
288
+ field('meta', recordType(scalarType('string'), scalarType('string'))),
289
+ ]),
290
+ ]);
291
+ const output = generatePydanticModels(root);
292
+ expect(output).toContain('items: list[Payment]');
293
+ expect(output).toContain('meta: dict[str, str]');
294
+ });
295
+ });