Skip to content

Commit 790dbfb

Browse files
authored
Merge pull request #255 from bbopen/refactor/0.6.1
0.6.1: maintenance — decompose complexity hotspots, dedup dispatch, drop dead exports
2 parents a6fb83e + 6ff3da1 commit 790dbfb

14 files changed

Lines changed: 704 additions & 534 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## [0.6.1](https://github.com/bbopen/tywrap/compare/v0.6.0...v0.6.1) (2026-05-31)
4+
5+
A maintenance release. Nothing you call changes — no API, behavior, or wire-protocol changes.
6+
7+
Internal cleanup only: removed two dead exports, broke the eleven worst complexity hotspots into smaller helpers with their output unchanged (cache-key generation, type-hint validation, the dev watch/reload paths, the subprocess write queue, module discovery, path and interpreter resolution, and an annotation-parser helper), and factored the duplicated request/response dispatch in the codec and RPC client into one path. The static-analysis actionable-complexity count dropped from 14 to 3 — the remaining three are deferred to 0.7.0 or left as-is on purpose.
8+
39
## [0.6.0](https://github.com/bbopen/tywrap/compare/v0.5.1...v0.6.0) (2026-05-31)
410

511
A cleanup release. There are no new features — this is one breaking pass that renames much of the runtime vocabulary, trims the public API, and tightens a couple of defaults, so the churn happens once instead of dribbling across several releases. If you only use the high-level `tywrap()`, `generate()`, and `defineConfig()` API, most of this won't touch you. The wire protocol is unchanged, so a 0.5.x client and a 0.6.0 bridge still talk to each other.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tywrap",
3-
"version": "0.6.0",
3+
"version": "0.6.1",
44
"description": "Generate type-safe TypeScript wrappers for any Python library — Node.js, Deno, Bun, and browsers via Pyodide.",
55
"type": "module",
66
"main": "dist/index.js",

src/core/annotation-parser.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -118,30 +118,35 @@ export function parseAnnotationToPythonType(
118118
return { kind: 'custom', name: n };
119119
};
120120

121+
// Extracts the leading quoted string from the body of a `Name(...)` factory call
122+
// (the `inner` already stripped of the outer `Name(` and trailing `)`). Returns
123+
// the unquoted name, or null when the body is not a well-formed quoted argument.
124+
const extractQuotedFactoryArg = (inner: string): string | null => {
125+
if (inner.length < 2) {
126+
return null;
127+
}
128+
129+
const quote = inner[0];
130+
if ((quote !== "'" && quote !== '"') || inner[inner.length - 1] !== quote) {
131+
return null;
132+
}
133+
134+
const commaIndex = inner.indexOf(',');
135+
const quoted = commaIndex === -1 ? inner : inner.slice(0, commaIndex).trimEnd();
136+
if (quoted.length < 2 || quoted[quoted.length - 1] !== quote) {
137+
return null;
138+
}
139+
140+
return quoted.slice(1, -1);
141+
};
142+
121143
const parseTypingFactoryName = (text: string, name: string): string | null => {
122144
for (const prefix of modulePrefixes) {
123145
const start = `${prefix}${name}(`;
124146
if (!text.startsWith(start) || !text.endsWith(')')) {
125147
continue;
126148
}
127-
128-
const inner = text.slice(start.length, -1).trim();
129-
if (inner.length < 2) {
130-
return null;
131-
}
132-
133-
const quote = inner[0];
134-
if ((quote !== "'" && quote !== '"') || inner[inner.length - 1] !== quote) {
135-
return null;
136-
}
137-
138-
const commaIndex = inner.indexOf(',');
139-
const quoted = commaIndex === -1 ? inner : inner.slice(0, commaIndex).trimEnd();
140-
if (quoted.length < 2 || quoted[quoted.length - 1] !== quote) {
141-
return null;
142-
}
143-
144-
return quoted.slice(1, -1);
149+
return extractQuotedFactoryArg(text.slice(start.length, -1).trim());
145150
}
146151

147152
return null;

src/core/discovery.ts

Lines changed: 70 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -122,38 +122,61 @@ export class ModuleDiscovery {
122122
return cached.path;
123123
}
124124

125-
// Try to resolve using Python's module finder
126-
if (processUtils.isAvailable()) {
127-
try {
128-
const pythonPath = await this.getPythonExecutable();
129-
const result = await processUtils.exec(
130-
pythonPath,
131-
[
132-
'-c',
133-
`import ${moduleName}; print(${moduleName}.__file__ if hasattr(${moduleName}, '__file__') else '${moduleName}.__path__[0]' if hasattr(${moduleName}, '__path__') else 'builtin')`,
134-
],
135-
{ timeoutMs: this.options.timeoutMs }
136-
);
125+
// Try to resolve using Python's module finder, then fall back to a
126+
// filesystem search across the known Python paths.
127+
return (
128+
(await this.resolveViaInterpreter(moduleName)) ??
129+
(await this.resolveViaSearchPaths(moduleName))
130+
);
131+
}
132+
133+
/**
134+
* Resolve a module path by asking the Python interpreter for `__file__`.
135+
* Caches and returns the resolved path, or `null` when the interpreter is
136+
* unavailable, reports a builtin, or fails.
137+
*/
138+
private async resolveViaInterpreter(moduleName: string): Promise<string | null> {
139+
if (!processUtils.isAvailable()) {
140+
return null;
141+
}
137142

138-
if (result.code === 0 && result.stdout.trim() !== 'builtin') {
139-
const modulePath = result.stdout.trim();
143+
try {
144+
const pythonPath = await this.getPythonExecutable();
145+
const result = await processUtils.exec(
146+
pythonPath,
147+
[
148+
'-c',
149+
`import ${moduleName}; print(${moduleName}.__file__ if hasattr(${moduleName}, '__file__') else '${moduleName}.__path__[0]' if hasattr(${moduleName}, '__path__') else 'builtin')`,
150+
],
151+
{ timeoutMs: this.options.timeoutMs }
152+
);
140153

141-
// Cache the result
142-
this.moduleCache.set(moduleName, {
143-
name: moduleName,
144-
path: modulePath,
145-
isPackage: modulePath.endsWith('__init__.py'),
146-
dependencies: [],
147-
});
154+
if (result.code === 0 && result.stdout.trim() !== 'builtin') {
155+
const modulePath = result.stdout.trim();
148156

149-
return modulePath;
150-
}
151-
} catch (error) {
152-
log.warn('Failed to resolve module', { moduleName, error: String(error) });
157+
// Cache the result
158+
this.moduleCache.set(moduleName, {
159+
name: moduleName,
160+
path: modulePath,
161+
isPackage: modulePath.endsWith('__init__.py'),
162+
dependencies: [],
163+
});
164+
165+
return modulePath;
153166
}
167+
} catch (error) {
168+
log.warn('Failed to resolve module', { moduleName, error: String(error) });
154169
}
155170

156-
// Fallback: search in known Python paths
171+
return null;
172+
}
173+
174+
/**
175+
* Fallback resolution: probe `<path>/<module>.py` and
176+
* `<path>/<module>/__init__.py` across the known Python search paths.
177+
* Returns the first existing candidate, or `null`.
178+
*/
179+
private async resolveViaSearchPaths(moduleName: string): Promise<string | null> {
157180
const searchPaths = await this.getPythonSearchPaths();
158181

159182
for (const searchPath of searchPaths) {
@@ -237,40 +260,33 @@ export class ModuleDiscovery {
237260
*/
238261
parseDependenciesFromSource(source: string): string[] {
239262
const dependencies: string[] = [];
240-
const lines = source.split('\n');
241263

242-
for (const line of lines) {
243-
const trimmed = line.trim();
244-
245-
// Skip comments and empty lines
246-
if (trimmed.startsWith('#') || trimmed === '') {
247-
continue;
264+
for (const line of source.split('\n')) {
265+
const moduleName = this.extractImportedModule(line.trim());
266+
if (moduleName) {
267+
dependencies.push(moduleName);
248268
}
269+
}
249270

250-
// Match import statements
251-
const importMatch = trimmed.match(/^import\s+([a-zA-Z_][a-zA-Z0-9_\.]*)/);
252-
if (importMatch?.[1]) {
253-
const fullModuleName = importMatch[1];
254-
const moduleName = fullModuleName.split('.')[0]; // Get top-level module
255-
if (moduleName) {
256-
dependencies.push(moduleName);
257-
}
258-
continue;
259-
}
271+
return [...new Set(dependencies)]; // Remove duplicates
272+
}
260273

261-
// Match from ... import statements
262-
const fromMatch = trimmed.match(/^from\s+([a-zA-Z_][a-zA-Z0-9_\.]*)\s+import/);
263-
if (fromMatch?.[1]) {
264-
const fullModuleName = fromMatch[1];
265-
const moduleName = fullModuleName.split('.')[0]; // Get top-level module
266-
if (moduleName) {
267-
dependencies.push(moduleName);
268-
}
269-
continue;
270-
}
274+
/**
275+
* Extract the top-level module name imported by a single (trimmed) source
276+
* line, or `null` when the line is a comment, blank, or not an import.
277+
*/
278+
private extractImportedModule(trimmed: string): string | null {
279+
// Skip comments and empty lines
280+
if (trimmed.startsWith('#') || trimmed === '') {
281+
return null;
271282
}
272283

273-
return [...new Set(dependencies)]; // Remove duplicates
284+
// Match `import x.y.z` and `from x.y.z import ...` statements.
285+
const match =
286+
trimmed.match(/^import\s+([a-zA-Z_][a-zA-Z0-9_\.]*)/) ??
287+
trimmed.match(/^from\s+([a-zA-Z_][a-zA-Z0-9_\.]*)\s+import/);
288+
289+
return match?.[1]?.split('.')[0] ?? null; // Get top-level module
274290
}
275291

276292
/**

src/core/validation.ts

Lines changed: 84 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -195,60 +195,96 @@ export class ValidationEngine {
195195
errors: AnalysisError[],
196196
warnings: AnalysisWarning[]
197197
): void {
198-
// Check return type (skip __init__ methods as they conventionally don't need return type annotations)
199-
if (this.isEmptyType(func.returnType) && func.name !== '__init__') {
200-
if (this.config.allowMissingTypeHints) {
201-
warnings.push({
202-
type: 'missing-type',
203-
message: `Function '${func.name}' is missing return type annotation`,
204-
});
205-
} else if (this.config.strictTypeChecking) {
206-
// In strict mode without allowMissingTypeHints, we warn first then error
207-
warnings.push({
208-
type: 'missing-type',
209-
message: `Function '${func.name}' is missing return type annotation`,
210-
});
211-
errors.push({
212-
type: 'type',
213-
message: `Function '${func.name}' requires return type annotation`,
214-
});
215-
}
216-
}
198+
this.checkReturnTypeHint(func, errors, warnings);
217199

218200
// Check parameter types (skip 'self' and 'cls' parameters)
219201
for (const param of func.parameters) {
220-
if (this.isEmptyType(param.type) && param.name !== 'self' && param.name !== 'cls') {
221-
if (this.config.allowMissingTypeHints) {
222-
warnings.push({
223-
type: 'missing-type',
224-
message: `Parameter '${param.name}' in function '${func.name}' is missing type annotation`,
225-
});
226-
} else if (this.config.strictTypeChecking) {
227-
// In strict mode without allowMissingTypeHints, we warn first then error
228-
warnings.push({
229-
type: 'missing-type',
230-
message: `Parameter '${param.name}' in function '${func.name}' is missing type annotation`,
231-
});
232-
errors.push({
233-
type: 'type',
234-
message: `Parameter '${param.name}' in function '${func.name}' requires type annotation`,
235-
});
236-
}
237-
}
202+
this.checkParameterTypeHint(func, param, errors, warnings);
203+
this.checkParameterTypeStructure(param, errors);
204+
}
238205

239-
// Validate type structure only in strict mode (skip 'self' and 'cls' parameters)
240-
if (
241-
this.config.strictTypeChecking &&
242-
!this.config.allowMissingTypeHints &&
243-
param.name !== 'self' &&
244-
param.name !== 'cls'
245-
) {
246-
const typeErrors = this.validateTypeAnnotation(param.type, `parameter '${param.name}'`);
247-
errors.push(...typeErrors);
248-
}
206+
this.checkReturnTypeStructure(func, errors);
207+
}
208+
209+
/**
210+
* Emit a missing-type warning, and—in strict mode—an accompanying error.
211+
* In strict mode without allowMissingTypeHints, we warn first then error.
212+
*/
213+
private reportMissingType(
214+
warningMessage: string,
215+
errorMessage: string,
216+
warnings: AnalysisWarning[],
217+
errors: AnalysisError[]
218+
): void {
219+
if (this.config.allowMissingTypeHints) {
220+
warnings.push({ type: 'missing-type', message: warningMessage });
221+
} else if (this.config.strictTypeChecking) {
222+
warnings.push({ type: 'missing-type', message: warningMessage });
223+
errors.push({ type: 'type', message: errorMessage });
249224
}
225+
}
250226

251-
// Validate return type structure only in strict mode
227+
/**
228+
* Check the function return type annotation (skip __init__ methods as they
229+
* conventionally don't need return type annotations).
230+
*/
231+
private checkReturnTypeHint(
232+
func: PythonFunction,
233+
errors: AnalysisError[],
234+
warnings: AnalysisWarning[]
235+
): void {
236+
if (this.isEmptyType(func.returnType) && func.name !== '__init__') {
237+
this.reportMissingType(
238+
`Function '${func.name}' is missing return type annotation`,
239+
`Function '${func.name}' requires return type annotation`,
240+
warnings,
241+
errors
242+
);
243+
}
244+
}
245+
246+
/**
247+
* Check a single parameter's type annotation (skip 'self' and 'cls').
248+
*/
249+
private checkParameterTypeHint(
250+
func: PythonFunction,
251+
param: PythonFunction['parameters'][number],
252+
errors: AnalysisError[],
253+
warnings: AnalysisWarning[]
254+
): void {
255+
if (this.isEmptyType(param.type) && param.name !== 'self' && param.name !== 'cls') {
256+
this.reportMissingType(
257+
`Parameter '${param.name}' in function '${func.name}' is missing type annotation`,
258+
`Parameter '${param.name}' in function '${func.name}' requires type annotation`,
259+
warnings,
260+
errors
261+
);
262+
}
263+
}
264+
265+
/**
266+
* Validate a single parameter's type structure only in strict mode
267+
* (skip 'self' and 'cls' parameters).
268+
*/
269+
private checkParameterTypeStructure(
270+
param: PythonFunction['parameters'][number],
271+
errors: AnalysisError[]
272+
): void {
273+
if (
274+
this.config.strictTypeChecking &&
275+
!this.config.allowMissingTypeHints &&
276+
param.name !== 'self' &&
277+
param.name !== 'cls'
278+
) {
279+
const typeErrors = this.validateTypeAnnotation(param.type, `parameter '${param.name}'`);
280+
errors.push(...typeErrors);
281+
}
282+
}
283+
284+
/**
285+
* Validate the return type structure only in strict mode.
286+
*/
287+
private checkReturnTypeStructure(func: PythonFunction, errors: AnalysisError[]): void {
252288
if (this.config.strictTypeChecking && !this.config.allowMissingTypeHints) {
253289
const returnTypeErrors = this.validateTypeAnnotation(func.returnType, 'return type');
254290
errors.push(...returnTypeErrors);

0 commit comments

Comments
 (0)