@contractkit/plugin-python 0.9.4 → 0.10.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/src/index.ts CHANGED
@@ -1,5 +1,22 @@
1
- import { resolve, join } from 'node:path';
2
- import type { ContractKitPlugin } from '@contractkit/core';
1
+ import { resolve, join, relative } from 'node:path';
2
+ import { existsSync, readFileSync, rmSync, readdirSync, rmdirSync } from 'node:fs';
3
+ import type {
4
+ ContractKitPlugin,
5
+ PluginContext,
6
+ ContractRootNode,
7
+ OpRootNode,
8
+ ModelNode,
9
+ IncrementalManifest,
10
+ IncrementalUnit,
11
+ } from '@contractkit/core';
12
+ import {
13
+ runIncrementalCodegen,
14
+ parseIncrementalManifest,
15
+ emptyIncrementalManifest,
16
+ hashFingerprint,
17
+ collectTransitiveModelRefs,
18
+ collectTypeRefs,
19
+ } from '@contractkit/core';
3
20
  import { generatePydanticModels, deriveModelsModuleName } from './codegen-models.js';
4
21
  import {
5
22
  generatePythonClient,
@@ -23,121 +40,312 @@ export interface PythonSdkPluginConfig {
23
40
  includeInternal?: boolean;
24
41
  }
25
42
 
26
- // ─── Default export: loaded via plugins array, reads config from ctx.options ─
43
+ /**
44
+ * Bumped when the Python codegen output shape changes in a way that should
45
+ * invalidate every per-file fingerprint. Mixed into the manifest's
46
+ * `codegenVersion`, so a plugin upgrade forces full regeneration even when no
47
+ * `.ck` files have changed.
48
+ */
49
+ export const PYTHON_CODEGEN_VERSION = '1';
50
+
51
+ const MANIFEST_FILENAME = '.contractkit-python-manifest.json';
27
52
 
28
53
  const plugin: ContractKitPlugin = {
29
54
  name: 'python-sdk',
30
- cacheKey: 'python-sdk',
31
55
  async generateTargets(inputs, ctx) {
32
56
  const config = ctx.options as PythonSdkPluginConfig;
33
- return createPythonSdkPlugin(config, ctx.rootDir).generateTargets!(inputs, ctx);
57
+ await runPythonCodegen(inputs, ctx, config, ctx.rootDir);
34
58
  },
35
59
  };
36
60
 
37
61
  export default plugin;
38
62
 
39
- // ─── Factory: for programmatic use with explicit config ────────────────────
40
-
41
63
  export function createPythonSdkPlugin(config: PythonSdkPluginConfig, rootDir: string): ContractKitPlugin {
42
64
  return {
43
65
  name: 'python-sdk',
44
- cacheKey: `python-sdk:${JSON.stringify(config)}`,
45
- async generateTargets({ contractRoots, opRoots, modelsWithInput: _modelsWithInput }, ctx) {
46
- const modelsWithInput = _modelsWithInput as Set<string>;
47
- const outDir = resolve(rootDir, config.baseDir ?? 'python-sdk');
48
-
49
- // ── Build model module path map ──
50
- // model name → importable Python module string, e.g. "._models_payment"
51
- const modelModulePaths = new Map<string, string>();
52
- const contractEntries: { moduleName: string; outPath: string; root: (typeof contractRoots)[number] }[] = [];
53
-
54
- for (const contractRoot of contractRoots) {
55
- const moduleName = deriveModelsModuleName(contractRoot.file);
56
- const outPath = join(outDir, `${moduleName}.py`);
57
- contractEntries.push({ moduleName, outPath, root: contractRoot });
58
-
59
- for (const model of contractRoot.models) {
60
- modelModulePaths.set(model.name, `.${moduleName}`);
61
- if (modelsWithInput.has(model.name)) {
62
- modelModulePaths.set(`${model.name}Input`, `.${moduleName}`);
63
- }
64
- }
65
- }
66
+ async generateTargets(inputs, ctx) {
67
+ await runPythonCodegen(inputs, ctx, config, rootDir);
68
+ },
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Shared orchestration. Builds per-file fingerprints, reuses unchanged outputs from
74
+ * the manifest, regenerates only the affected client/model files, and rewrites the
75
+ * shared aggregator + base files (cheap, depend only on the set of public clients).
76
+ *
77
+ * Honors `ctx.cacheEnabled` so `--force` bypasses the per-file cache.
78
+ */
79
+ async function runPythonCodegen(
80
+ inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
81
+ ctx: PluginContext,
82
+ config: PythonSdkPluginConfig,
83
+ rootDir: string,
84
+ ): Promise<void> {
85
+ const { contractRoots, opRoots } = inputs;
86
+ const modelsWithInput = inputs.modelsWithInput as Set<string>;
87
+ const outDir = resolve(rootDir, config.baseDir ?? 'python-sdk');
66
88
 
67
- // ── Emit model files ──
68
- for (const { moduleName, outPath, root } of contractEntries) {
69
- const content = generatePydanticModels(root, {
70
- modelModulePaths,
71
- currentModule: `.${moduleName}`,
72
- modelsWithInput,
73
- });
74
- ctx.emitFile(outPath, content);
89
+ // ── Build cross-file lookup tables ───────────────────────────────────────
90
+ const modelModulePaths = new Map<string, string>();
91
+ const modelMap = new Map<string, ModelNode>();
92
+ const contractEntries: { moduleName: string; relPath: string; root: ContractRootNode }[] = [];
93
+
94
+ for (const root of contractRoots) {
95
+ const moduleName = deriveModelsModuleName(root.file);
96
+ contractEntries.push({ moduleName, relPath: `${moduleName}.py`, root });
97
+ for (const model of root.models) {
98
+ modelMap.set(model.name, model);
99
+ modelModulePaths.set(model.name, `.${moduleName}`);
100
+ if (modelsWithInput.has(model.name)) {
101
+ modelModulePaths.set(`${model.name}Input`, `.${moduleName}`);
75
102
  }
103
+ }
104
+ }
105
+
106
+ // Stable, sorted view of modelsWithInput for fingerprint slicing — only the
107
+ // intersection with each unit's referenced names ends up in its fingerprint.
108
+ const modelsWithInputArray = [...modelsWithInput].sort();
76
109
 
77
- // ── Emit client files ──
78
- const clientInfos: { moduleName: string; className: string; propertyName: string }[] = [];
79
- for (const opRoot of opRoots) {
80
- if (!hasPublicOperations(opRoot, config.includeInternal)) continue;
81
- const moduleName = deriveClientModuleName(opRoot.file);
82
- const outPath = join(outDir, `${moduleName}.py`);
83
- clientInfos.push({
84
- moduleName,
85
- className: deriveClientClassName(opRoot.file),
86
- propertyName: deriveClientPropertyName(opRoot.file),
87
- });
88
- ctx.emitFile(
89
- outPath,
90
- generatePythonClient(opRoot, {
110
+ const prevManifest: IncrementalManifest = ctx.cacheEnabled ? readManifest(outDir) : emptyIncrementalManifest(PYTHON_CODEGEN_VERSION);
111
+ const units: IncrementalUnit[] = [];
112
+
113
+ // ── Per-contract-root model files ────────────────────────────────────────
114
+ for (const { moduleName, relPath, root } of contractEntries) {
115
+ const ownNames = new Set(root.models.map(m => m.name));
116
+ const externalRefs = collectExternalRefsFromContract(root, ownNames);
117
+ // Module paths that this file actually imports — excludes self-refs and any
118
+ // cross-file refs not used by this file's models.
119
+ const referencedModulePaths: Record<string, string> = {};
120
+ for (const ref of [...externalRefs].sort()) {
121
+ const path = modelModulePaths.get(ref);
122
+ if (path) referencedModulePaths[ref] = path;
123
+ const inputPath = modelModulePaths.get(`${ref}Input`);
124
+ if (inputPath) referencedModulePaths[`${ref}Input`] = inputPath;
125
+ }
126
+ const relevantInputModels = modelsWithInputArray.filter(name => ownNames.has(name) || externalRefs.has(name));
127
+
128
+ const fingerprint = hashFingerprint({
129
+ kind: 'models',
130
+ v: PYTHON_CODEGEN_VERSION,
131
+ relPath,
132
+ currentModule: `.${moduleName}`,
133
+ root,
134
+ referencedModulePaths,
135
+ modelsWithInput: relevantInputModels,
136
+ });
137
+
138
+ units.push({
139
+ key: `models::${relPath}`,
140
+ fingerprint,
141
+ render: () => [
142
+ {
143
+ relativePath: relPath,
144
+ content: generatePydanticModels(root, {
145
+ modelModulePaths,
146
+ currentModule: `.${moduleName}`,
147
+ modelsWithInput,
148
+ }),
149
+ },
150
+ ],
151
+ });
152
+ }
153
+
154
+ // ── Per-op-root client files ─────────────────────────────────────────────
155
+ const clientInfos: { moduleName: string; className: string; propertyName: string }[] = [];
156
+
157
+ for (const root of opRoots) {
158
+ if (!hasPublicOperations(root, config.includeInternal)) continue;
159
+ const moduleName = deriveClientModuleName(root.file);
160
+ const relPath = `${moduleName}.py`;
161
+ clientInfos.push({
162
+ moduleName,
163
+ className: deriveClientClassName(root.file),
164
+ propertyName: deriveClientPropertyName(root.file),
165
+ });
166
+
167
+ const referencedModels = collectOpRootModelRefs(root, modelMap);
168
+ const referencedModulePaths: Record<string, string> = {};
169
+ for (const ref of [...referencedModels].sort()) {
170
+ const path = modelModulePaths.get(ref);
171
+ if (path) referencedModulePaths[ref] = path;
172
+ const inputPath = modelModulePaths.get(`${ref}Input`);
173
+ if (inputPath) referencedModulePaths[`${ref}Input`] = inputPath;
174
+ }
175
+ const relevantInputModels = modelsWithInputArray.filter(name => referencedModels.has(name));
176
+
177
+ const fingerprint = hashFingerprint({
178
+ kind: 'client',
179
+ v: PYTHON_CODEGEN_VERSION,
180
+ relPath,
181
+ currentModule: `.${moduleName}`,
182
+ root,
183
+ referencedModulePaths,
184
+ modelsWithInput: relevantInputModels,
185
+ includeInternal: config.includeInternal ?? false,
186
+ });
187
+
188
+ units.push({
189
+ key: `client::${relPath}`,
190
+ fingerprint,
191
+ render: () => [
192
+ {
193
+ relativePath: relPath,
194
+ content: generatePythonClient(root, {
91
195
  modelModulePaths,
92
196
  currentModule: `.${moduleName}`,
93
197
  modelsWithInput,
94
198
  includeInternal: config.includeInternal,
95
199
  }),
96
- );
97
- }
200
+ },
201
+ ],
202
+ });
203
+ }
204
+
205
+ // ── Global files: base client, requirements, aggregator ──────────────────
206
+ // The aggregator (__init__.py) depends on the public-clients list. Writing it
207
+ // every run is cheap (a few imports + a class body), so we skip a separate
208
+ // unit for it. base_client.py and requirements.txt are constants.
209
+ const sdkClassName = config.packageName
210
+ ? config.packageName
211
+ .split(/[-._\s]+/)
212
+ .map(s => s.charAt(0).toUpperCase() + s.slice(1))
213
+ .join('') + 'Sdk'
214
+ : 'Sdk';
215
+
216
+ const initLines: string[] = [
217
+ '# Auto-generated by contractkit-plugin-python-sdk. Do not edit manually.',
218
+ 'from ._base_client import BaseClient, SdkError',
219
+ ];
220
+ for (const c of clientInfos) {
221
+ initLines.push(`from .${c.moduleName} import ${c.className}`);
222
+ }
223
+ initLines.push('');
224
+ initLines.push('');
225
+ if (clientInfos.length > 0) {
226
+ initLines.push(`class ${sdkClassName}(BaseClient):`);
227
+ initLines.push(` def __init__(self, base_url: str, headers: dict[str, str] | None = None):`);
228
+ initLines.push(` super().__init__(base_url, headers)`);
229
+ for (const c of clientInfos) {
230
+ initLines.push(` self.${c.propertyName} = ${c.className}(base_url, headers)`);
231
+ }
232
+ initLines.push('');
233
+ } else {
234
+ initLines.push(`class ${sdkClassName}(BaseClient):`);
235
+ initLines.push(` pass`);
236
+ initLines.push('');
237
+ }
238
+ const allNames = ['BaseClient', 'SdkError', sdkClassName, ...clientInfos.map(c => c.className)];
239
+ initLines.push(`__all__ = [${allNames.map(n => JSON.stringify(n)).join(', ')}]`);
240
+ initLines.push('');
241
+
242
+ const globalFiles = [
243
+ { relativePath: '_base_client.py', content: BASE_CLIENT_PY },
244
+ { relativePath: 'requirements.txt', content: 'httpx\npydantic>=2.0\n' },
245
+ { relativePath: '__init__.py', content: initLines.join('\n') },
246
+ ];
247
+
248
+ const result = runIncrementalCodegen({
249
+ codegenVersion: PYTHON_CODEGEN_VERSION,
250
+ manifestFilename: MANIFEST_FILENAME,
251
+ prevManifest,
252
+ globalFiles,
253
+ units,
254
+ fileExists: relPath => existsSync(resolve(outDir, relPath)),
255
+ });
256
+
257
+ deleteStalePaths(outDir, result.deletedPaths);
258
+
259
+ for (const { relativePath, content } of result.filesToWrite) {
260
+ ctx.emitFile(resolve(outDir, relativePath), content);
261
+ }
262
+ // Suppress unused import warning — `relative` is reserved for future use.
263
+ void relative;
264
+ }
98
265
 
99
- // ── Emit shared _base_client.py ──
100
- ctx.emitFile(join(outDir, '_base_client.py'), BASE_CLIENT_PY);
101
-
102
- // ── Emit requirements.txt ──
103
- ctx.emitFile(join(outDir, 'requirements.txt'), 'httpx\npydantic>=2.0\n');
104
-
105
- // ── Emit __init__.py aggregator ──
106
- const sdkClassName = config.packageName
107
- ? config.packageName
108
- .split(/[-._\s]+/)
109
- .map(s => s.charAt(0).toUpperCase() + s.slice(1))
110
- .join('') + 'Sdk'
111
- : 'Sdk';
112
-
113
- const initLines: string[] = [
114
- '# Auto-generated by contractkit-plugin-python-sdk. Do not edit manually.',
115
- 'from ._base_client import BaseClient, SdkError',
116
- ];
117
- for (const c of clientInfos) {
118
- initLines.push(`from .${c.moduleName} import ${c.className}`);
266
+ /** Collect every model name referenced by the contract root that isn't defined within it. */
267
+ function collectExternalRefsFromContract(root: ContractRootNode, ownNames: Set<string>): Set<string> {
268
+ const refs = new Set<string>();
269
+ for (const m of root.models) {
270
+ if (m.type) collectTypeRefs(m.type, refs);
271
+ for (const f of m.fields) collectTypeRefs(f.type, refs);
272
+ if (m.bases) {
273
+ for (const b of m.bases) refs.add(b);
274
+ }
275
+ }
276
+ for (const own of ownNames) refs.delete(own);
277
+ return refs;
278
+ }
279
+
280
+ /** Collect every model name referenced (transitively) by an op root. */
281
+ function collectOpRootModelRefs(root: OpRootNode, modelMap: Map<string, ModelNode>): Set<string> {
282
+ const seeds = [];
283
+ for (const route of root.routes) {
284
+ if (route.params) seeds.push(...paramSourceTypes(route.params));
285
+ for (const op of route.operations) {
286
+ if (op.query) seeds.push(...paramSourceTypes(op.query));
287
+ if (op.headers) seeds.push(...paramSourceTypes(op.headers));
288
+ if (op.request) {
289
+ for (const body of op.request.bodies) seeds.push(body.bodyType);
119
290
  }
120
- initLines.push('');
121
- initLines.push('');
122
- if (clientInfos.length > 0) {
123
- initLines.push(`class ${sdkClassName}(BaseClient):`);
124
- initLines.push(` def __init__(self, base_url: str, headers: dict[str, str] | None = None):`);
125
- initLines.push(` super().__init__(base_url, headers)`);
126
- for (const c of clientInfos) {
127
- initLines.push(` self.${c.propertyName} = ${c.className}(base_url, headers)`);
291
+ for (const resp of op.responses) {
292
+ if (resp.bodyType) seeds.push(resp.bodyType);
293
+ if (resp.headers) {
294
+ for (const h of resp.headers) seeds.push(h.type);
128
295
  }
129
- initLines.push('');
130
- } else {
131
- initLines.push(`class ${sdkClassName}(BaseClient):`);
132
- initLines.push(` pass`);
133
- initLines.push('');
134
296
  }
297
+ }
298
+ }
299
+ return collectTransitiveModelRefs(seeds, modelMap);
300
+ }
135
301
 
136
- const allNames = ['BaseClient', 'SdkError', sdkClassName, ...clientInfos.map(c => c.className)];
137
- initLines.push(`__all__ = [${allNames.map(n => JSON.stringify(n)).join(', ')}]`);
138
- initLines.push('');
302
+ function paramSourceTypes(src: NonNullable<OpRootNode['routes'][number]['params']>): Parameters<typeof collectTypeRefs>[0][] {
303
+ const out: Parameters<typeof collectTypeRefs>[0][] = [];
304
+ if (src.kind === 'params') {
305
+ for (const n of src.nodes) out.push(n.type);
306
+ } else if (src.kind === 'ref') {
307
+ out.push({ kind: 'ref', name: src.name } as Parameters<typeof collectTypeRefs>[0]);
308
+ } else if (src.kind === 'type') {
309
+ out.push(src.node);
310
+ }
311
+ return out;
312
+ }
139
313
 
140
- ctx.emitFile(join(outDir, '__init__.py'), initLines.join('\n'));
141
- },
142
- };
314
+ /** Read the previous run's manifest. Returns an empty manifest when missing or unreadable. */
315
+ function readManifest(outDir: string): IncrementalManifest {
316
+ const manifestPath = resolve(outDir, MANIFEST_FILENAME);
317
+ if (!existsSync(manifestPath)) return emptyIncrementalManifest(PYTHON_CODEGEN_VERSION);
318
+ try {
319
+ return parseIncrementalManifest(readFileSync(manifestPath, 'utf-8'));
320
+ } catch {
321
+ return emptyIncrementalManifest(PYTHON_CODEGEN_VERSION);
322
+ }
323
+ }
324
+
325
+ /** Delete paths from the prior manifest that aren't produced this run. Mirrors the Bruno cleanup approach. */
326
+ function deleteStalePaths(outDir: string, relPaths: string[]): void {
327
+ if (relPaths.length === 0) return;
328
+ const removedDirs = new Set<string>();
329
+ for (const rel of relPaths) {
330
+ const abs = resolve(outDir, rel);
331
+ if (existsSync(abs)) {
332
+ rmSync(abs, { force: true });
333
+ removedDirs.add(join(abs, '..'));
334
+ }
335
+ }
336
+ for (const dir of removedDirs) {
337
+ let current = dir;
338
+ while (current.startsWith(outDir) && current !== outDir) {
339
+ try {
340
+ if (readdirSync(current).length === 0) {
341
+ rmdirSync(current);
342
+ current = join(current, '..');
343
+ } else {
344
+ break;
345
+ }
346
+ } catch {
347
+ break;
348
+ }
349
+ }
350
+ }
143
351
  }