@contractkit/plugin-python 0.11.6 → 0.11.7

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.
@@ -1,4 +1,4 @@
1
- import type { ContractRootNode, ModelNode, FieldNode, ContractTypeNode } from '@contractkit/core';
1
+ import type { ContractRootNode, ModelNode, FieldNode, ContractTypeNode, ScalarTypeNode } from '@contractkit/core';
2
2
  import { computeModelsWithInput, topoSortModels, collectExternalRefs, collectExternalInputRefs } from '@contractkit/core';
3
3
 
4
4
  // ─── Public entry point ────────────────────────────────────────────────────
@@ -173,6 +173,15 @@ function scanTypeImports(type: ContractTypeNode, imports: ImportTracker): void {
173
173
 
174
174
  // ─── Type rendering ───────────────────────────────────────────────────────
175
175
 
176
+ /**
177
+ * Render a ContractKit type node as its Python type annotation (Pydantic v2 conventions).
178
+ *
179
+ * @param modelsWithInput - Model names that have a distinct `Input` variant; a `ref` to one of
180
+ * these renders as `<Name>Input` when `forInput` is set.
181
+ * @param forInput - When true, request-side refs use their `Input` variant.
182
+ * @throws {Error} Via the scalar renderer, if a scalar name has no Python mapping (guards against
183
+ * a scalar being added to core without updating this plugin).
184
+ */
176
185
  export function renderPyType(type: ContractTypeNode, modelsWithInput?: Set<string>, forInput = false): string {
177
186
  switch (type.kind) {
178
187
  case 'scalar':
@@ -209,7 +218,7 @@ export function renderPyType(type: ContractTypeNode, modelsWithInput?: Set<strin
209
218
  }
210
219
  }
211
220
 
212
- function renderScalar(name: string): string {
221
+ function renderScalar(name: ScalarTypeNode['name']): string {
213
222
  switch (name) {
214
223
  case 'string':
215
224
  case 'email':
@@ -231,6 +240,8 @@ function renderScalar(name: string): string {
231
240
  return 'datetime';
232
241
  case 'duration':
233
242
  return 'timedelta';
243
+ case 'interval':
244
+ return 'str';
234
245
  case 'uuid':
235
246
  return 'UUID';
236
247
  case 'null':
@@ -241,8 +252,10 @@ function renderScalar(name: string): string {
241
252
  case 'json':
242
253
  case 'object':
243
254
  return 'Any';
244
- default:
245
- return 'Any';
255
+ default: {
256
+ const _exhaustive: never = name;
257
+ throw new Error(`plugin-python: unmapped scalar '${String(_exhaustive)}' — add a case`);
258
+ }
246
259
  }
247
260
  }
248
261
 
@@ -261,6 +274,19 @@ export function toPythonFieldName(name: string): string {
261
274
  return result;
262
275
  }
263
276
 
277
+ // ─── Comment emission ─────────────────────────────────────────────────────
278
+
279
+ /**
280
+ * Render `text` as one `#`-prefixed Python comment line per source line.
281
+ * Descriptions come from `#` doc comments and may contain embedded newlines;
282
+ * emitting them verbatim would leave every line after the first uncommented
283
+ * (a SyntaxError in the generated module). `indent` is the whitespace prefix
284
+ * that precedes `# ` at the call site.
285
+ */
286
+ function commentLines(text: string, indent: string): string[] {
287
+ return text.split('\n').map(line => `${indent}# ${line}`);
288
+ }
289
+
264
290
  // ─── Model generation ─────────────────────────────────────────────────────
265
291
 
266
292
  function generateModel(model: ModelNode, allModelsWithInput: Set<string>, imports: ImportTracker): string[] {
@@ -278,7 +304,7 @@ function generateModel(model: ModelNode, allModelsWithInput: Set<string>, import
278
304
 
279
305
  function generateTypeAlias(model: ModelNode, allModelsWithInput: Set<string>, _: ImportTracker): string[] {
280
306
  const lines: string[] = [];
281
- if (model.description) lines.push(`# ${model.description}`);
307
+ if (model.description) lines.push(...commentLines(model.description, ''));
282
308
  if (model.deprecated) lines.push('# @deprecated');
283
309
  lines.push(`${model.name} = ${renderPyType(model.type!, allModelsWithInput)}`);
284
310
  if (allModelsWithInput.has(model.name)) {
@@ -289,7 +315,7 @@ function generateTypeAlias(model: ModelNode, allModelsWithInput: Set<string>, _:
289
315
 
290
316
  function generateSimpleModel(model: ModelNode, allModelsWithInput: Set<string>, imports: ImportTracker): string[] {
291
317
  const lines: string[] = [];
292
- if (model.description) lines.push(`# ${model.description}`);
318
+ if (model.description) lines.push(...commentLines(model.description, ''));
293
319
  if (model.deprecated) lines.push('# @deprecated');
294
320
 
295
321
  const baseList = model.bases && model.bases.length > 0 ? model.bases.join(', ') : 'BaseModel';
@@ -317,7 +343,7 @@ function generateSplitModel(model: ModelNode, allModelsWithInput: Set<string>, i
317
343
 
318
344
  // Read model — omit writeonly fields
319
345
  const readFields = model.fields.filter(f => f.visibility !== 'writeonly');
320
- if (model.description) lines.push(`# ${model.description}`);
346
+ if (model.description) lines.push(...commentLines(model.description, ''));
321
347
  if (model.deprecated) lines.push('# @deprecated');
322
348
 
323
349
  const readBaseList = model.bases && model.bases.length > 0 ? model.bases.join(', ') : 'BaseModel';
@@ -387,7 +413,7 @@ function renderField(field: FieldNode, allModelsWithInput: Set<string>, imports:
387
413
  }
388
414
 
389
415
  if (field.deprecated) lines.push(` # @deprecated`);
390
- if (field.description) lines.push(` # ${field.description}`);
416
+ if (field.description) lines.push(...commentLines(field.description, ' '));
391
417
 
392
418
  let rhs: string;
393
419
  if (fieldAnnotations.length > 0) {
@@ -358,4 +358,68 @@ describe('generatePythonClient', () => {
358
358
  expect(output).not.toContain('_fetch_with_headers');
359
359
  });
360
360
  });
361
+
362
+ // ─── Docstring injection (regression) ─────────────────────────────────
363
+
364
+ describe('docstring safety', () => {
365
+ it('escapes """ in op name/description so it cannot close the docstring early', () => {
366
+ const root = opRoot([
367
+ opRoute('/payments', [
368
+ opOperation('get', {
369
+ sdk: 'getPayment',
370
+ name: 'Bad """ name',
371
+ description: 'desc with """ triple quote',
372
+ responses: [opResponse(200, 'Payment')],
373
+ }),
374
+ ]),
375
+ ]);
376
+ const output = generatePythonClient(root);
377
+
378
+ // The escaped form is emitted...
379
+ expect(output).toContain('Bad \\"\\"\\" name');
380
+ expect(output).toContain('desc with \\"\\"\\" triple quote');
381
+
382
+ // ...and the docstring body is only closed by the real delimiter, not the
383
+ // injected one. Between the two `"""` fences there must be exactly the two
384
+ // sanitized body lines and nothing that terminates early.
385
+ const idx = output.indexOf('async def get_payment');
386
+ const body = output.slice(idx);
387
+ const open = body.indexOf(' """');
388
+ const close = body.indexOf(' """', open + 1);
389
+ const between = body.slice(open + ' """'.length, close);
390
+ expect(between).not.toMatch(/"""/); // no unescaped triple-quote inside the docstring
391
+ // The method body after the docstring is intact.
392
+ expect(body.slice(close)).toContain('await self._fetch');
393
+ });
394
+
395
+ it('guards a trailing backslash in a description', () => {
396
+ const root = opRoot([
397
+ opRoute('/payments', [
398
+ opOperation('get', {
399
+ sdk: 'getPayment',
400
+ description: 'ends with backslash\\',
401
+ responses: [opResponse(200, 'Payment')],
402
+ }),
403
+ ]),
404
+ ]);
405
+ const output = generatePythonClient(root);
406
+ // Trailing backslash is neutralized (space appended) so it can't escape the delimiter.
407
+ expect(output).toContain(' ends with backslash\\ \n');
408
+ });
409
+
410
+ it('keeps each line of a multi-line description indented in the docstring', () => {
411
+ const root = opRoot([
412
+ opRoute('/payments', [
413
+ opOperation('get', {
414
+ sdk: 'getPayment',
415
+ description: 'first line\nsecond line',
416
+ responses: [opResponse(200, 'Payment')],
417
+ }),
418
+ ]),
419
+ ]);
420
+ const output = generatePythonClient(root);
421
+ expect(output).toContain(' first line');
422
+ expect(output).toContain(' second line');
423
+ });
424
+ });
361
425
  });
@@ -27,6 +27,11 @@ describe('renderPyType', () => {
27
27
  expect(renderPyType(scalarType('unknown'))).toBe('Any');
28
28
  expect(renderPyType(scalarType('json'))).toBe('Any');
29
29
  expect(renderPyType(scalarType('object'))).toBe('Any');
30
+ expect(renderPyType(scalarType('interval'))).toBe('str');
31
+ });
32
+
33
+ it('throws on an unmapped scalar name', () => {
34
+ expect(() => renderPyType({ kind: 'scalar', name: 'decimal' } as any)).toThrow(/unmapped scalar 'decimal'/);
30
35
  });
31
36
 
32
37
  it('renders enum', () => {
@@ -293,3 +298,47 @@ describe('generatePydanticModels', () => {
293
298
  expect(output).toContain('meta: dict[str, str]');
294
299
  });
295
300
  });
301
+
302
+ // ─── Multi-line descriptions (regression) ─────────────────────────────────
303
+
304
+ describe('multi-line descriptions', () => {
305
+ it('comments every line of a multi-line model description', () => {
306
+ const root = contractRoot([
307
+ model('Payment', [field('amount', scalarType('number'))], {
308
+ description: 'A payment record.\nSecond line of docs.',
309
+ }),
310
+ ]);
311
+ const output = generatePydanticModels(root);
312
+ expect(output).toContain('# A payment record.');
313
+ expect(output).toContain('# Second line of docs.');
314
+ // No physical line of a description may be left uncommented (would be a SyntaxError).
315
+ expect(output).not.toMatch(/^Second line of docs\.$/m);
316
+ });
317
+
318
+ it('comments every line of a multi-line field description with field indent', () => {
319
+ const root = contractRoot([
320
+ model('Payment', [
321
+ field('amount', scalarType('number'), {
322
+ description: 'Amount in cents.\nMust be non-negative.',
323
+ }),
324
+ ]),
325
+ ]);
326
+ const output = generatePydanticModels(root);
327
+ expect(output).toContain(' # Amount in cents.');
328
+ expect(output).toContain(' # Must be non-negative.');
329
+ expect(output).not.toMatch(/^\s*Must be non-negative\.$/m);
330
+ });
331
+
332
+ it('comments every line of a multi-line type-alias description', () => {
333
+ const root = contractRoot([
334
+ model('Status', [], {
335
+ type: enumType('pending', 'done'),
336
+ description: 'Lifecycle status.\nExtra detail line.',
337
+ }),
338
+ ]);
339
+ const output = generatePydanticModels(root);
340
+ expect(output).toContain('# Lifecycle status.');
341
+ expect(output).toContain('# Extra detail line.');
342
+ expect(output).not.toMatch(/^Extra detail line\.$/m);
343
+ });
344
+ });
package/coverage/base.css DELETED
@@ -1,224 +0,0 @@
1
- body, html {
2
- margin:0; padding: 0;
3
- height: 100%;
4
- }
5
- body {
6
- font-family: Helvetica Neue, Helvetica, Arial;
7
- font-size: 14px;
8
- color:#333;
9
- }
10
- .small { font-size: 12px; }
11
- *, *:after, *:before {
12
- -webkit-box-sizing:border-box;
13
- -moz-box-sizing:border-box;
14
- box-sizing:border-box;
15
- }
16
- h1 { font-size: 20px; margin: 0;}
17
- h2 { font-size: 14px; }
18
- pre {
19
- font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
20
- margin: 0;
21
- padding: 0;
22
- -moz-tab-size: 2;
23
- -o-tab-size: 2;
24
- tab-size: 2;
25
- }
26
- a { color:#0074D9; text-decoration:none; }
27
- a:hover { text-decoration:underline; }
28
- .strong { font-weight: bold; }
29
- .space-top1 { padding: 10px 0 0 0; }
30
- .pad2y { padding: 20px 0; }
31
- .pad1y { padding: 10px 0; }
32
- .pad2x { padding: 0 20px; }
33
- .pad2 { padding: 20px; }
34
- .pad1 { padding: 10px; }
35
- .space-left2 { padding-left:55px; }
36
- .space-right2 { padding-right:20px; }
37
- .center { text-align:center; }
38
- .clearfix { display:block; }
39
- .clearfix:after {
40
- content:'';
41
- display:block;
42
- height:0;
43
- clear:both;
44
- visibility:hidden;
45
- }
46
- .fl { float: left; }
47
- @media only screen and (max-width:640px) {
48
- .col3 { width:100%; max-width:100%; }
49
- .hide-mobile { display:none!important; }
50
- }
51
-
52
- .quiet {
53
- color: #7f7f7f;
54
- color: rgba(0,0,0,0.5);
55
- }
56
- .quiet a { opacity: 0.7; }
57
-
58
- .fraction {
59
- font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
60
- font-size: 10px;
61
- color: #555;
62
- background: #E8E8E8;
63
- padding: 4px 5px;
64
- border-radius: 3px;
65
- vertical-align: middle;
66
- }
67
-
68
- div.path a:link, div.path a:visited { color: #333; }
69
- table.coverage {
70
- border-collapse: collapse;
71
- margin: 10px 0 0 0;
72
- padding: 0;
73
- }
74
-
75
- table.coverage td {
76
- margin: 0;
77
- padding: 0;
78
- vertical-align: top;
79
- }
80
- table.coverage td.line-count {
81
- text-align: right;
82
- padding: 0 5px 0 20px;
83
- }
84
- table.coverage td.line-coverage {
85
- text-align: right;
86
- padding-right: 10px;
87
- min-width:20px;
88
- }
89
-
90
- table.coverage td span.cline-any {
91
- display: inline-block;
92
- padding: 0 5px;
93
- width: 100%;
94
- }
95
- .missing-if-branch {
96
- display: inline-block;
97
- margin-right: 5px;
98
- border-radius: 3px;
99
- position: relative;
100
- padding: 0 4px;
101
- background: #333;
102
- color: yellow;
103
- }
104
-
105
- .skip-if-branch {
106
- display: none;
107
- margin-right: 10px;
108
- position: relative;
109
- padding: 0 4px;
110
- background: #ccc;
111
- color: white;
112
- }
113
- .missing-if-branch .typ, .skip-if-branch .typ {
114
- color: inherit !important;
115
- }
116
- .coverage-summary {
117
- border-collapse: collapse;
118
- width: 100%;
119
- }
120
- .coverage-summary tr { border-bottom: 1px solid #bbb; }
121
- .keyline-all { border: 1px solid #ddd; }
122
- .coverage-summary td, .coverage-summary th { padding: 10px; }
123
- .coverage-summary tbody { border: 1px solid #bbb; }
124
- .coverage-summary td { border-right: 1px solid #bbb; }
125
- .coverage-summary td:last-child { border-right: none; }
126
- .coverage-summary th {
127
- text-align: left;
128
- font-weight: normal;
129
- white-space: nowrap;
130
- }
131
- .coverage-summary th.file { border-right: none !important; }
132
- .coverage-summary th.pct { }
133
- .coverage-summary th.pic,
134
- .coverage-summary th.abs,
135
- .coverage-summary td.pct,
136
- .coverage-summary td.abs { text-align: right; }
137
- .coverage-summary td.file { white-space: nowrap; }
138
- .coverage-summary td.pic { min-width: 120px !important; }
139
- .coverage-summary tfoot td { }
140
-
141
- .coverage-summary .sorter {
142
- height: 10px;
143
- width: 7px;
144
- display: inline-block;
145
- margin-left: 0.5em;
146
- background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
147
- }
148
- .coverage-summary .sorted .sorter {
149
- background-position: 0 -20px;
150
- }
151
- .coverage-summary .sorted-desc .sorter {
152
- background-position: 0 -10px;
153
- }
154
- .status-line { height: 10px; }
155
- /* yellow */
156
- .cbranch-no { background: yellow !important; color: #111; }
157
- /* dark red */
158
- .red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
159
- .low .chart { border:1px solid #C21F39 }
160
- .highlighted,
161
- .highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
162
- background: #C21F39 !important;
163
- }
164
- /* medium red */
165
- .cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
166
- /* light red */
167
- .low, .cline-no { background:#FCE1E5 }
168
- /* light green */
169
- .high, .cline-yes { background:rgb(230,245,208) }
170
- /* medium green */
171
- .cstat-yes { background:rgb(161,215,106) }
172
- /* dark green */
173
- .status-line.high, .high .cover-fill { background:rgb(77,146,33) }
174
- .high .chart { border:1px solid rgb(77,146,33) }
175
- /* dark yellow (gold) */
176
- .status-line.medium, .medium .cover-fill { background: #f9cd0b; }
177
- .medium .chart { border:1px solid #f9cd0b; }
178
- /* light yellow */
179
- .medium { background: #fff4c2; }
180
-
181
- .cstat-skip { background: #ddd; color: #111; }
182
- .fstat-skip { background: #ddd; color: #111 !important; }
183
- .cbranch-skip { background: #ddd !important; color: #111; }
184
-
185
- span.cline-neutral { background: #eaeaea; }
186
-
187
- .coverage-summary td.empty {
188
- opacity: .5;
189
- padding-top: 4px;
190
- padding-bottom: 4px;
191
- line-height: 1;
192
- color: #888;
193
- }
194
-
195
- .cover-fill, .cover-empty {
196
- display:inline-block;
197
- height: 12px;
198
- }
199
- .chart {
200
- line-height: 0;
201
- }
202
- .cover-empty {
203
- background: white;
204
- }
205
- .cover-full {
206
- border-right: none !important;
207
- }
208
- pre.prettyprint {
209
- border: none !important;
210
- padding: 0 !important;
211
- margin: 0 !important;
212
- }
213
- .com { color: #999 !important; }
214
- .ignore-none { color: #999; font-weight: normal; }
215
-
216
- .wrapper {
217
- min-height: 100%;
218
- height: auto !important;
219
- height: 100%;
220
- margin: 0 auto -48px;
221
- }
222
- .footer, .push {
223
- height: 48px;
224
- }
@@ -1,87 +0,0 @@
1
- /* eslint-disable */
2
- var jumpToCode = (function init() {
3
- // Classes of code we would like to highlight in the file view
4
- var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
5
-
6
- // Elements to highlight in the file listing view
7
- var fileListingElements = ['td.pct.low'];
8
-
9
- // We don't want to select elements that are direct descendants of another match
10
- var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
11
-
12
- // Selector that finds elements on the page to which we can jump
13
- var selector =
14
- fileListingElements.join(', ') +
15
- ', ' +
16
- notSelector +
17
- missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
18
-
19
- // The NodeList of matching elements
20
- var missingCoverageElements = document.querySelectorAll(selector);
21
-
22
- var currentIndex;
23
-
24
- function toggleClass(index) {
25
- missingCoverageElements
26
- .item(currentIndex)
27
- .classList.remove('highlighted');
28
- missingCoverageElements.item(index).classList.add('highlighted');
29
- }
30
-
31
- function makeCurrent(index) {
32
- toggleClass(index);
33
- currentIndex = index;
34
- missingCoverageElements.item(index).scrollIntoView({
35
- behavior: 'smooth',
36
- block: 'center',
37
- inline: 'center'
38
- });
39
- }
40
-
41
- function goToPrevious() {
42
- var nextIndex = 0;
43
- if (typeof currentIndex !== 'number' || currentIndex === 0) {
44
- nextIndex = missingCoverageElements.length - 1;
45
- } else if (missingCoverageElements.length > 1) {
46
- nextIndex = currentIndex - 1;
47
- }
48
-
49
- makeCurrent(nextIndex);
50
- }
51
-
52
- function goToNext() {
53
- var nextIndex = 0;
54
-
55
- if (
56
- typeof currentIndex === 'number' &&
57
- currentIndex < missingCoverageElements.length - 1
58
- ) {
59
- nextIndex = currentIndex + 1;
60
- }
61
-
62
- makeCurrent(nextIndex);
63
- }
64
-
65
- return function jump(event) {
66
- if (
67
- document.getElementById('fileSearch') === document.activeElement &&
68
- document.activeElement != null
69
- ) {
70
- // if we're currently focused on the search input, we don't want to navigate
71
- return;
72
- }
73
-
74
- switch (event.which) {
75
- case 78: // n
76
- case 74: // j
77
- goToNext();
78
- break;
79
- case 66: // b
80
- case 75: // k
81
- case 80: // p
82
- goToPrevious();
83
- break;
84
- }
85
- };
86
- })();
87
- window.addEventListener('keydown', jumpToCode);