pi-python-helper 0.1.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/CHANGELOG.md +60 -0
- package/CONTRIBUTING.md +77 -0
- package/LICENSE +17 -0
- package/README.md +172 -0
- package/SECURITY.md +24 -0
- package/docs/compatibility.md +64 -0
- package/docs/tools.md +518 -0
- package/extensions/index.ts +28 -0
- package/extensions/shared.ts +48 -0
- package/extensions/tools/dependencies.ts +203 -0
- package/extensions/tools/environment.ts +171 -0
- package/extensions/tools/testing.ts +350 -0
- package/extensions/tools/validation.ts +344 -0
- package/helpers/scan_project.py +777 -0
- package/package.json +73 -0
- package/skills/python-development/SKILL.md +45 -0
- package/src/build/commands.ts +65 -0
- package/src/build/discover.ts +98 -0
- package/src/build/failure.ts +452 -0
- package/src/build/pytest.ts +118 -0
- package/src/build/selection.ts +138 -0
- package/src/build/staleness.ts +117 -0
- package/src/core/result.ts +102 -0
- package/src/core/runner.ts +96 -0
- package/src/core/safety.ts +181 -0
- package/src/core/version.ts +20 -0
- package/src/dependencies/plan.ts +398 -0
- package/src/environment/discovery.ts +166 -0
- package/src/environment/tools.ts +141 -0
- package/src/project/conformance.ts +343 -0
- package/src/project/inspect.ts +351 -0
- package/src/project/installed.ts +225 -0
- package/src/project/paths.ts +74 -0
- package/src/project/root.ts +72 -0
- package/src/project/scanner.ts +243 -0
- package/src/validation/bundle.ts +65 -0
- package/src/validation/evidence.ts +33 -0
- package/src/validation/tdd.ts +62 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import type { Suggestion } from '../core/result.ts';
|
|
2
|
+
|
|
3
|
+
export type FailureKind =
|
|
4
|
+
| 'module_not_found'
|
|
5
|
+
| 'environment_not_synced'
|
|
6
|
+
| 'import_error'
|
|
7
|
+
| 'syntax_error'
|
|
8
|
+
| 'collection_error'
|
|
9
|
+
| 'fixture_error'
|
|
10
|
+
| 'assertion'
|
|
11
|
+
| 'runtime_error'
|
|
12
|
+
| 'lockfile_out_of_date'
|
|
13
|
+
| 'resolution_error'
|
|
14
|
+
| 'dependency_conflict'
|
|
15
|
+
| 'timeout'
|
|
16
|
+
| 'unknown';
|
|
17
|
+
|
|
18
|
+
export interface TracebackFrame {
|
|
19
|
+
path: string;
|
|
20
|
+
line: number;
|
|
21
|
+
func: string;
|
|
22
|
+
/** True for site-packages, the standard library, and pytest internals. */
|
|
23
|
+
library: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FailureEvidence {
|
|
27
|
+
file?: string;
|
|
28
|
+
line?: number;
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FailureDiagnosis {
|
|
33
|
+
kind: FailureKind;
|
|
34
|
+
summary: string;
|
|
35
|
+
missingModule?: string;
|
|
36
|
+
importTarget?: { name: string; module: string };
|
|
37
|
+
exceptionType?: string;
|
|
38
|
+
frames: TracebackFrame[];
|
|
39
|
+
/** Last frame outside site-packages: the line the user should look at. */
|
|
40
|
+
firstUserFrame?: TracebackFrame;
|
|
41
|
+
evidence: FailureEvidence[];
|
|
42
|
+
suggestions: Suggestion[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const FRAME_RE = /^\s*File "([^"]+)", line (\d+), in (.+?)\s*$/;
|
|
46
|
+
/**
|
|
47
|
+
* pytest `--tb=short` replaces the `File "..."` form with `path:line: in func`,
|
|
48
|
+
* so both notations must be recognised or short tracebacks yield no frame at all.
|
|
49
|
+
*/
|
|
50
|
+
const PYTEST_FRAME_RE = /^\s*([^\s:]+\.py):(\d+): in (.+?)\s*$/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A frame belongs to library code when it sits in an installed distribution or
|
|
54
|
+
* in the interpreter's own library tree. Pointing the agent at those frames is
|
|
55
|
+
* how it ends up editing site-packages instead of the project.
|
|
56
|
+
*/
|
|
57
|
+
export function isLibraryFrame(path: string): boolean {
|
|
58
|
+
const normalized = path.replace(/\\/g, '/');
|
|
59
|
+
return (
|
|
60
|
+
/\/site-packages\//.test(normalized) ||
|
|
61
|
+
/\/dist-packages\//.test(normalized) ||
|
|
62
|
+
/\/lib\/python3\.\d+\//.test(normalized) ||
|
|
63
|
+
/\/python3\.\d+\//.test(normalized) ||
|
|
64
|
+
/<frozen /.test(normalized) ||
|
|
65
|
+
/\/_pytest\//.test(normalized) ||
|
|
66
|
+
/\/pluggy\//.test(normalized)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function extractTracebackFrames(output: string): TracebackFrame[] {
|
|
71
|
+
const frames: TracebackFrame[] = [];
|
|
72
|
+
for (const line of output.split(/\r?\n/)) {
|
|
73
|
+
const match = line.match(FRAME_RE) ?? line.match(PYTEST_FRAME_RE);
|
|
74
|
+
if (!match) continue;
|
|
75
|
+
const path = match[1];
|
|
76
|
+
frames.push({
|
|
77
|
+
path,
|
|
78
|
+
line: Number.parseInt(match[2], 10),
|
|
79
|
+
func: match[3].trim(),
|
|
80
|
+
library: isLibraryFrame(path),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return frames;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function firstUserFrame(frames: TracebackFrame[]): TracebackFrame | undefined {
|
|
87
|
+
for (let index = frames.length - 1; index >= 0; index -= 1) {
|
|
88
|
+
if (!frames[index].library) return frames[index];
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function lastMatch(output: string, pattern: RegExp): RegExpMatchArray | undefined {
|
|
94
|
+
const matches = [...output.matchAll(new RegExp(pattern.source, `${pattern.flags}g`))];
|
|
95
|
+
return matches.at(-1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Classify the first actionable cause in bounded command output. The order of
|
|
100
|
+
* the checks matters: a resolution failure means nothing downstream is
|
|
101
|
+
* trustworthy, and a missing module explains the traceback that follows it.
|
|
102
|
+
*/
|
|
103
|
+
export function diagnoseFailure(output: string): FailureDiagnosis {
|
|
104
|
+
const frames = extractTracebackFrames(output);
|
|
105
|
+
const userFrame = firstUserFrame(frames);
|
|
106
|
+
|
|
107
|
+
const lockProblem = lastMatch(
|
|
108
|
+
output,
|
|
109
|
+
/(?:lockfile at .*needs to be updated|--locked was provided|lockfile .* is not up to date|`uv lock`)/i,
|
|
110
|
+
);
|
|
111
|
+
if (lockProblem) {
|
|
112
|
+
return {
|
|
113
|
+
kind: 'lockfile_out_of_date',
|
|
114
|
+
summary:
|
|
115
|
+
'uv refused to continue because uv.lock no longer matches pyproject.toml (--locked/--frozen was used).',
|
|
116
|
+
exceptionType: 'uv',
|
|
117
|
+
frames,
|
|
118
|
+
firstUserFrame: userFrame,
|
|
119
|
+
evidence: [{ message: lockProblem[0].trim() }],
|
|
120
|
+
suggestions: [
|
|
121
|
+
{
|
|
122
|
+
message: 'Run uv lock and commit the refreshed uv.lock before rerunning the command.',
|
|
123
|
+
confidence: 'high',
|
|
124
|
+
command: 'uv lock',
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const conflict = lastMatch(
|
|
131
|
+
output,
|
|
132
|
+
/(?:Because .+ depends on .+|No solution found when resolving dependencies|version solving failed)/,
|
|
133
|
+
);
|
|
134
|
+
if (conflict) {
|
|
135
|
+
return {
|
|
136
|
+
kind: 'dependency_conflict',
|
|
137
|
+
summary: 'uv could not find a version set that satisfies every declared constraint.',
|
|
138
|
+
exceptionType: 'uv',
|
|
139
|
+
frames,
|
|
140
|
+
firstUserFrame: userFrame,
|
|
141
|
+
evidence: [{ message: conflict[0].trim() }],
|
|
142
|
+
suggestions: [
|
|
143
|
+
{
|
|
144
|
+
message:
|
|
145
|
+
'Inspect the reported conflict chain and relax or pin the offending requirement.',
|
|
146
|
+
confidence: 'high',
|
|
147
|
+
command: 'uv lock --verbose',
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const resolveFailure = lastMatch(
|
|
154
|
+
output,
|
|
155
|
+
/(?:Failed to resolve requirements|error: Failed to download|Could not find a version that satisfies)/,
|
|
156
|
+
);
|
|
157
|
+
if (resolveFailure) {
|
|
158
|
+
return {
|
|
159
|
+
kind: 'resolution_error',
|
|
160
|
+
summary: 'uv could not resolve or download a declared requirement.',
|
|
161
|
+
exceptionType: 'uv',
|
|
162
|
+
frames,
|
|
163
|
+
firstUserFrame: userFrame,
|
|
164
|
+
evidence: [{ message: resolveFailure[0].trim() }],
|
|
165
|
+
suggestions: [
|
|
166
|
+
{
|
|
167
|
+
message:
|
|
168
|
+
'Check the package name and version constraint, then retry with uv lock --verbose.',
|
|
169
|
+
confidence: 'medium',
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const missing = lastMatch(output, /ModuleNotFoundError: No module named '([^']+)'/);
|
|
176
|
+
const cannotImport = lastMatch(
|
|
177
|
+
output,
|
|
178
|
+
/ImportError: cannot import name '([^']+)' from '([^']+)'/,
|
|
179
|
+
);
|
|
180
|
+
const syntax = lastMatch(output, /SyntaxError: (.+)/);
|
|
181
|
+
const fixture = lastMatch(
|
|
182
|
+
output,
|
|
183
|
+
/(?:fixture '[^']+' not found|ERROR at setup of|error in .* fixture)/,
|
|
184
|
+
);
|
|
185
|
+
const collection = lastMatch(
|
|
186
|
+
output,
|
|
187
|
+
/(?:errors during collection|Interrupted: \d+ error|ERROR collecting)/,
|
|
188
|
+
);
|
|
189
|
+
const assertion = lastMatch(output, /^\s*E\s+(AssertionError.*|assert .*)$/m);
|
|
190
|
+
// pytest prefixes the exception line with `E ` inside a report, so the marker
|
|
191
|
+
// must be optional here or a plain NameError/ValueError looks unclassified.
|
|
192
|
+
const genericError = lastMatch(output, /^(?:\s*E\s+)?(?:[A-Za-z_.]*(?:Error|Exception)): (.+)$/m);
|
|
193
|
+
|
|
194
|
+
// Pick the cause that appears first in the output. "First actionable" must be
|
|
195
|
+
// positional: a fixture error printed after a failing assertion is not the
|
|
196
|
+
// root cause the user needs to read first.
|
|
197
|
+
const candidates: { kind: FailureKind; index: number }[] = [];
|
|
198
|
+
const consider = (kind: FailureKind, pattern: RegExp) => {
|
|
199
|
+
const match = output.match(pattern);
|
|
200
|
+
if (match?.index !== undefined) candidates.push({ kind, index: match.index });
|
|
201
|
+
};
|
|
202
|
+
consider('module_not_found', /ModuleNotFoundError: No module named '([^']+)'/);
|
|
203
|
+
consider('import_error', /ImportError: cannot import name '([^']+)' from '([^']+)'/);
|
|
204
|
+
consider('syntax_error', /SyntaxError: (.+)/);
|
|
205
|
+
consider('fixture_error', /(?:fixture '[^']+' not found|ERROR at setup of|error in .* fixture)/);
|
|
206
|
+
consider(
|
|
207
|
+
'collection_error',
|
|
208
|
+
/(?:errors during collection|Interrupted: \d+ error|ERROR collecting)/,
|
|
209
|
+
);
|
|
210
|
+
consider('assertion', /^\s*E\s+(AssertionError.*|assert .*)$/m);
|
|
211
|
+
consider('runtime_error', /^(?:\s*E\s+)?(?:[A-Za-z_.]*(?:Error|Exception)): (.+)$/m);
|
|
212
|
+
candidates.sort((left, right) => left.index - right.index);
|
|
213
|
+
const kind: FailureKind = candidates[0]?.kind ?? 'unknown';
|
|
214
|
+
|
|
215
|
+
if (kind === 'module_not_found' && missing) {
|
|
216
|
+
const module = missing[1].split('.')[0];
|
|
217
|
+
return {
|
|
218
|
+
kind: 'module_not_found',
|
|
219
|
+
summary: `Import failed because the module "${module}" could not be found.`,
|
|
220
|
+
missingModule: module,
|
|
221
|
+
exceptionType: 'ModuleNotFoundError',
|
|
222
|
+
frames,
|
|
223
|
+
firstUserFrame: userFrame,
|
|
224
|
+
evidence: [{ message: missing[0].trim(), file: userFrame?.path, line: userFrame?.line }],
|
|
225
|
+
suggestions: [
|
|
226
|
+
{
|
|
227
|
+
message: `Declare the distribution that provides "${module}" with uv add ${module} if it is third-party. Import names often differ from distribution names (PIL/pillow, yaml/PyYAML).`,
|
|
228
|
+
confidence: 'medium',
|
|
229
|
+
command: `uv add ${module}`,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
message:
|
|
233
|
+
'If the module is project code, run uv sync so the project package is installed in editable mode.',
|
|
234
|
+
confidence: 'medium',
|
|
235
|
+
command: 'uv sync',
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const cannotImportBranch = kind === 'import_error' ? cannotImport : undefined;
|
|
242
|
+
if (cannotImportBranch) {
|
|
243
|
+
return {
|
|
244
|
+
kind: 'import_error',
|
|
245
|
+
summary: `The name "${cannotImportBranch[1]}" does not exist in module "${cannotImportBranch[2]}".`,
|
|
246
|
+
importTarget: { name: cannotImportBranch[1], module: cannotImportBranch[2] },
|
|
247
|
+
exceptionType: 'ImportError',
|
|
248
|
+
frames,
|
|
249
|
+
firstUserFrame: userFrame,
|
|
250
|
+
evidence: [
|
|
251
|
+
{ message: cannotImportBranch[0].trim(), file: userFrame?.path, line: userFrame?.line },
|
|
252
|
+
],
|
|
253
|
+
suggestions: [
|
|
254
|
+
{
|
|
255
|
+
message: `Verify the symbol name in "${cannotImportBranch[2]}" and whether the installed version exposes it.`,
|
|
256
|
+
confidence: 'medium',
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (kind === 'syntax_error' && syntax) {
|
|
263
|
+
const location = lastMatch(output, /File "([^"]+)", line (\d+)/);
|
|
264
|
+
return {
|
|
265
|
+
kind: 'syntax_error',
|
|
266
|
+
summary: `A file could not be parsed: ${syntax[1].trim()}`,
|
|
267
|
+
exceptionType: 'SyntaxError',
|
|
268
|
+
frames,
|
|
269
|
+
firstUserFrame: userFrame,
|
|
270
|
+
evidence: [
|
|
271
|
+
{
|
|
272
|
+
message: syntax[0].trim(),
|
|
273
|
+
file: location?.[1],
|
|
274
|
+
line: location ? Number.parseInt(location[2], 10) : undefined,
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
suggestions: [
|
|
278
|
+
{
|
|
279
|
+
message: 'Fix the syntax error at the reported file and line before rerunning.',
|
|
280
|
+
confidence: 'high',
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (kind === 'fixture_error' && fixture) {
|
|
287
|
+
return {
|
|
288
|
+
kind: 'fixture_error',
|
|
289
|
+
summary: 'A pytest fixture could not be resolved for a test.',
|
|
290
|
+
exceptionType: 'fixture',
|
|
291
|
+
frames,
|
|
292
|
+
firstUserFrame: userFrame,
|
|
293
|
+
evidence: [{ message: fixture[0].trim(), file: userFrame?.path, line: userFrame?.line }],
|
|
294
|
+
suggestions: [
|
|
295
|
+
{
|
|
296
|
+
message: 'Define the fixture in a conftest.py that is in scope for the failing test.',
|
|
297
|
+
confidence: 'medium',
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (kind === 'collection_error' && collection) {
|
|
304
|
+
return {
|
|
305
|
+
kind: 'collection_error',
|
|
306
|
+
summary: 'pytest could not collect the test suite, so no test result is trustworthy.',
|
|
307
|
+
exceptionType: 'pytest',
|
|
308
|
+
frames,
|
|
309
|
+
firstUserFrame: userFrame,
|
|
310
|
+
evidence: [{ message: collection[0].trim(), file: userFrame?.path, line: userFrame?.line }],
|
|
311
|
+
suggestions: [
|
|
312
|
+
{
|
|
313
|
+
message:
|
|
314
|
+
'Resolve the import or syntax error reported for the collected file, then rerun.',
|
|
315
|
+
confidence: 'high',
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (kind === 'assertion' && assertion) {
|
|
322
|
+
return {
|
|
323
|
+
kind: 'assertion',
|
|
324
|
+
summary: 'A test assertion failed; the expectation does not match the observed behaviour.',
|
|
325
|
+
exceptionType: 'AssertionError',
|
|
326
|
+
frames,
|
|
327
|
+
firstUserFrame: userFrame,
|
|
328
|
+
evidence: [
|
|
329
|
+
{
|
|
330
|
+
message: assertion[1].trim().slice(0, 500),
|
|
331
|
+
file: userFrame?.path,
|
|
332
|
+
line: userFrame?.line,
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
suggestions: [
|
|
336
|
+
{
|
|
337
|
+
message:
|
|
338
|
+
'Inspect the failing expectation and the production code that produced the value.',
|
|
339
|
+
confidence: 'medium',
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (kind === 'runtime_error' && genericError) {
|
|
346
|
+
const exceptionType = genericError[0].trim().split(':')[0].trim().replace(/^E\s+/, '');
|
|
347
|
+
return {
|
|
348
|
+
kind: 'runtime_error',
|
|
349
|
+
summary: genericError[0].trim().slice(0, 300),
|
|
350
|
+
exceptionType,
|
|
351
|
+
frames,
|
|
352
|
+
firstUserFrame: userFrame,
|
|
353
|
+
evidence: [
|
|
354
|
+
{
|
|
355
|
+
message: genericError[0].trim().slice(0, 500),
|
|
356
|
+
file: userFrame?.path,
|
|
357
|
+
line: userFrame?.line,
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
suggestions: [
|
|
361
|
+
{
|
|
362
|
+
message: userFrame
|
|
363
|
+
? `Start from ${userFrame.path}:${userFrame.line}; frames inside site-packages are not the cause.`
|
|
364
|
+
: 'The failure has no project frame; check whether the command ran in the intended environment.',
|
|
365
|
+
confidence: 'low',
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
kind: 'unknown',
|
|
373
|
+
summary:
|
|
374
|
+
'No recognised Python, pytest, or uv failure pattern was found in the captured output.',
|
|
375
|
+
frames,
|
|
376
|
+
firstUserFrame: userFrame,
|
|
377
|
+
evidence: [],
|
|
378
|
+
suggestions: [
|
|
379
|
+
{
|
|
380
|
+
message:
|
|
381
|
+
'Rerun the command with more verbose output so the first actionable cause is captured.',
|
|
382
|
+
confidence: 'low',
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Improve a diagnosis using the project model. A module that is declared but
|
|
390
|
+
* still unimportable is an environment problem, not a missing declaration.
|
|
391
|
+
*/
|
|
392
|
+
export function refineWithDeclarations(
|
|
393
|
+
diagnosis: FailureDiagnosis,
|
|
394
|
+
input: { declared: Set<string>; localModules: Set<string> },
|
|
395
|
+
): FailureDiagnosis {
|
|
396
|
+
const module = diagnosis.missingModule;
|
|
397
|
+
if (!module) return diagnosis;
|
|
398
|
+
const normalized = module.replace(/[-_.]+/g, '-').toLowerCase();
|
|
399
|
+
const suggestions: Suggestion[] = [];
|
|
400
|
+
|
|
401
|
+
if (input.localModules.has(module)) {
|
|
402
|
+
return {
|
|
403
|
+
...diagnosis,
|
|
404
|
+
kind: 'environment_not_synced',
|
|
405
|
+
summary: `"${module}" is project code but is not importable from the active interpreter.`,
|
|
406
|
+
suggestions: [
|
|
407
|
+
{
|
|
408
|
+
message:
|
|
409
|
+
'The package is not installed into the environment. Run uv sync so the project is installed in editable mode.',
|
|
410
|
+
confidence: 'high',
|
|
411
|
+
command: 'uv sync',
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
message: 'Confirm the import path matches the src layout (src/<package>/...).',
|
|
415
|
+
confidence: 'medium',
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (input.declared.has(normalized)) {
|
|
422
|
+
return {
|
|
423
|
+
...diagnosis,
|
|
424
|
+
kind: 'environment_not_synced',
|
|
425
|
+
summary: `"${module}" is declared in pyproject.toml but is missing from the active environment.`,
|
|
426
|
+
suggestions: [
|
|
427
|
+
{
|
|
428
|
+
message: 'The environment is out of sync with the lockfile. Run uv sync --frozen.',
|
|
429
|
+
confidence: 'high',
|
|
430
|
+
command: 'uv sync --frozen',
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
message:
|
|
434
|
+
'If the command ran outside the project environment, re-run it through uv run so the correct interpreter is used.',
|
|
435
|
+
confidence: 'high',
|
|
436
|
+
command: 'uv run --frozen pytest',
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
suggestions.push({
|
|
443
|
+
message: `Declare the distribution providing "${module}" with uv add ${module}, or verify the import name.`,
|
|
444
|
+
confidence: 'medium',
|
|
445
|
+
command: `uv add ${module}`,
|
|
446
|
+
});
|
|
447
|
+
suggestions.push({
|
|
448
|
+
message: 'Import names can differ from distribution names (PIL/pillow, yaml/PyYAML).',
|
|
449
|
+
confidence: 'medium',
|
|
450
|
+
});
|
|
451
|
+
return { ...diagnosis, suggestions: [...diagnosis.suggestions, ...suggestions] };
|
|
452
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export interface PytestCounts {
|
|
2
|
+
passed: number;
|
|
3
|
+
failed: number;
|
|
4
|
+
errors: number;
|
|
5
|
+
skipped: number;
|
|
6
|
+
xfailed: number;
|
|
7
|
+
xpassed: number;
|
|
8
|
+
deselected: number;
|
|
9
|
+
warnings: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PytestFailure {
|
|
13
|
+
/** Full node id, for example `tests/test_api.py::TestApi::test_get`. */
|
|
14
|
+
test: string;
|
|
15
|
+
file?: string;
|
|
16
|
+
message: string;
|
|
17
|
+
kind: 'FAILED' | 'ERROR';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PytestReport {
|
|
21
|
+
counts: PytestCounts;
|
|
22
|
+
failures: PytestFailure[];
|
|
23
|
+
summaryLine?: string;
|
|
24
|
+
noTestsRan: boolean;
|
|
25
|
+
/** Set when pytest never reached its summary, for example on a hard crash. */
|
|
26
|
+
incomplete: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const EMPTY_COUNTS: PytestCounts = {
|
|
30
|
+
passed: 0,
|
|
31
|
+
failed: 0,
|
|
32
|
+
errors: 0,
|
|
33
|
+
skipped: 0,
|
|
34
|
+
xfailed: 0,
|
|
35
|
+
xpassed: 0,
|
|
36
|
+
deselected: 0,
|
|
37
|
+
warnings: 0,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function countFromLine(line: string, pattern: RegExp): number {
|
|
41
|
+
const match = line.match(pattern);
|
|
42
|
+
return match ? Number.parseInt(match[1], 10) : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isSummaryLine(line: string): boolean {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed) return false;
|
|
48
|
+
if (/^=+.*=+$/.test(trimmed)) return true;
|
|
49
|
+
return /(?:passed|failed|error|no tests ran)/.test(trimmed) && trimmed.length < 200;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse `pytest -q --tb=short -rf` output. The final summary line carries the
|
|
54
|
+
* counts and the short test summary section carries the failing node ids.
|
|
55
|
+
*/
|
|
56
|
+
export function parsePytestOutput(stdout: string, stderr = ''): PytestReport {
|
|
57
|
+
const text = `${stdout}\n${stderr}`;
|
|
58
|
+
const lines = text.split(/\r?\n/);
|
|
59
|
+
|
|
60
|
+
let summaryLine: string | undefined;
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
if (isSummaryLine(line)) summaryLine = line.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const counted = summaryLine ?? '';
|
|
66
|
+
const counts: PytestCounts = {
|
|
67
|
+
passed: countFromLine(counted, /(\d+) passed/),
|
|
68
|
+
failed: countFromLine(counted, /(\d+) failed/),
|
|
69
|
+
errors: countFromLine(counted, /(\d+) errors?\b/),
|
|
70
|
+
skipped: countFromLine(counted, /(\d+) skipped/),
|
|
71
|
+
xfailed: countFromLine(counted, /(\d+) xfailed/),
|
|
72
|
+
xpassed: countFromLine(counted, /(\d+) xpassed/),
|
|
73
|
+
deselected: countFromLine(counted, /(\d+) deselected/),
|
|
74
|
+
warnings: countFromLine(counted, /(\d+) warnings?\b/),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const noTestsRan = /no tests ran/.test(counted);
|
|
78
|
+
|
|
79
|
+
const failures: PytestFailure[] = [];
|
|
80
|
+
const seen = new Set<string>();
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
const match = line.match(/^(FAILED|ERROR)\s+(\S+?)(?:\s+-\s+(.*))?$/);
|
|
83
|
+
if (!match) continue;
|
|
84
|
+
const [, kind, nodeId, message] = match;
|
|
85
|
+
if (seen.has(`${kind} ${nodeId}`)) continue;
|
|
86
|
+
seen.add(`${kind} ${nodeId}`);
|
|
87
|
+
const fileIndex = nodeId.indexOf('::');
|
|
88
|
+
failures.push({
|
|
89
|
+
test: nodeId,
|
|
90
|
+
file: fileIdToPath(fileIndex === -1 ? nodeId : nodeId.slice(0, fileIndex)),
|
|
91
|
+
message: (message ?? '').trim(),
|
|
92
|
+
kind: kind as 'FAILED' | 'ERROR',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const incomplete =
|
|
97
|
+
counts.passed + counts.failed + counts.errors + counts.skipped === 0 &&
|
|
98
|
+
!noTestsRan &&
|
|
99
|
+
!/(?:collected \d+ item|test session starts)/.test(text);
|
|
100
|
+
|
|
101
|
+
return { counts, failures, summaryLine, noTestsRan, incomplete };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** pytest prints collected node ids relative to the invocation root. */
|
|
105
|
+
function fileIdToPath(fileId: string): string | undefined {
|
|
106
|
+
const trimmed = fileId.trim();
|
|
107
|
+
return trimmed.endsWith('.py') ? trimmed : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function totalTests(counts: PytestCounts): number {
|
|
111
|
+
return (
|
|
112
|
+
counts.passed + counts.failed + counts.errors + counts.skipped + counts.xfailed + counts.xpassed
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function emptyCounts(): PytestCounts {
|
|
117
|
+
return { ...EMPTY_COUNTS };
|
|
118
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { basename, dirname, join } from 'node:path';
|
|
2
|
+
import { isPythonFile, isTestFile, parentDir, pathTokens, toPosix } from '../project/paths.ts';
|
|
3
|
+
|
|
4
|
+
export interface TestSelection {
|
|
5
|
+
path: string;
|
|
6
|
+
score: number;
|
|
7
|
+
reason: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SelectionResult {
|
|
11
|
+
selected: TestSelection[];
|
|
12
|
+
/** True when no changed file could be mapped and every test file is returned. */
|
|
13
|
+
fellBackToAll: boolean;
|
|
14
|
+
changedSourceFiles: string[];
|
|
15
|
+
changedTestFiles: string[];
|
|
16
|
+
consideredTestFiles: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SCORE = {
|
|
20
|
+
changedTestItself: 100,
|
|
21
|
+
sameStemSameDir: 80,
|
|
22
|
+
sameStem: 60,
|
|
23
|
+
sameDirectory: 40,
|
|
24
|
+
sharedToken: 20,
|
|
25
|
+
sharedModule: 30,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function stem(path: string): string {
|
|
29
|
+
return basename(toPosix(path)).replace(/\.py$/i, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Strip the pytest prefix/suffix so `test_parser.py` and `parser.py` compare equal. */
|
|
33
|
+
function normalizedStem(path: string): string {
|
|
34
|
+
return stem(path)
|
|
35
|
+
.replace(/^test_/, '')
|
|
36
|
+
.replace(/_test$/, '')
|
|
37
|
+
.toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function firstImportableSegment(path: string): string {
|
|
41
|
+
const segments = toPosix(path)
|
|
42
|
+
.split('/')
|
|
43
|
+
.filter((segment) => segment.length > 0);
|
|
44
|
+
const index = segments.lastIndexOf('src');
|
|
45
|
+
const start = index === -1 ? 0 : index + 1;
|
|
46
|
+
return (segments[start] ?? '').replace(/\.py$/i, '').toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Rank test files against changed paths using pytest conventions first and
|
|
51
|
+
* token overlap second. The convention signals are strong enough in Python that
|
|
52
|
+
* a name match should always outrank a fuzzy token match.
|
|
53
|
+
*/
|
|
54
|
+
export function selectTests(changedPaths: string[], testFiles: string[]): SelectionResult {
|
|
55
|
+
const changed = changedPaths.map(toPosix).filter(isPythonFile);
|
|
56
|
+
const changedSourceFiles = changed.filter((path) => !isTestFile(path));
|
|
57
|
+
const changedTestFiles = changed.filter(isTestFile);
|
|
58
|
+
const considered = [...new Set(testFiles.map(toPosix).filter(isPythonFile))].sort();
|
|
59
|
+
|
|
60
|
+
if (!changed.length) {
|
|
61
|
+
return {
|
|
62
|
+
selected: [],
|
|
63
|
+
fellBackToAll: false,
|
|
64
|
+
changedSourceFiles,
|
|
65
|
+
changedTestFiles,
|
|
66
|
+
consideredTestFiles: considered,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sourceTokens = new Set(changedSourceFiles.flatMap(pathTokens));
|
|
71
|
+
const sourceModules = new Set(changedSourceFiles.map(firstImportableSegment));
|
|
72
|
+
const sourceDirs = new Set(changedSourceFiles.map(parentDir));
|
|
73
|
+
const sourceStems = new Set(changedSourceFiles.map(normalizedStem));
|
|
74
|
+
|
|
75
|
+
const selections: TestSelection[] = [];
|
|
76
|
+
for (const testFile of considered) {
|
|
77
|
+
const reasons: string[] = [];
|
|
78
|
+
let score = 0;
|
|
79
|
+
|
|
80
|
+
if (changedTestFiles.includes(testFile)) {
|
|
81
|
+
score += SCORE.changedTestItself;
|
|
82
|
+
reasons.push('the test file itself changed');
|
|
83
|
+
}
|
|
84
|
+
const testStem = normalizedStem(testFile);
|
|
85
|
+
const testDir = parentDir(testFile);
|
|
86
|
+
if (sourceStems.has(testStem)) {
|
|
87
|
+
score += SCORE.sameStem;
|
|
88
|
+
reasons.push(`module name matches "${testStem}"`);
|
|
89
|
+
if (sourceDirs.has(testDir)) {
|
|
90
|
+
score += SCORE.sameStemSameDir - SCORE.sameStem;
|
|
91
|
+
reasons.push('same directory as the changed module');
|
|
92
|
+
}
|
|
93
|
+
} else if (sourceDirs.has(testDir)) {
|
|
94
|
+
score += SCORE.sameDirectory;
|
|
95
|
+
reasons.push('same directory as a changed module');
|
|
96
|
+
} else if (sourceDirs.has(dirname(testDir)) || sourceDirs.has(join(dirname(testDir), ''))) {
|
|
97
|
+
score += SCORE.sameDirectory - 10;
|
|
98
|
+
reasons.push('nested under a changed directory');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const module = firstImportableSegment(testFile);
|
|
102
|
+
if (module && sourceModules.has(module)) {
|
|
103
|
+
score += SCORE.sharedModule;
|
|
104
|
+
reasons.push(`covers module "${module}"`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const tokens = pathTokens(testFile);
|
|
108
|
+
const shared = tokens.filter((token) => sourceTokens.has(token));
|
|
109
|
+
if (shared.length) {
|
|
110
|
+
score += SCORE.sharedToken * Math.min(shared.length, 2);
|
|
111
|
+
reasons.push(`shares token(s): ${shared.slice(0, 4).join(', ')}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (score > 0) {
|
|
115
|
+
selections.push({ path: testFile, score, reason: reasons.join('; ') });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
selections.sort((left, right) => right.score - left.score || left.path.localeCompare(right.path));
|
|
120
|
+
|
|
121
|
+
// A conftest can affect every test, so it is always in scope once tests run.
|
|
122
|
+
for (const testFile of considered) {
|
|
123
|
+
if (basename(testFile) === 'conftest.py' && !selections.some((s) => s.path === testFile)) {
|
|
124
|
+
selections.push({ path: testFile, score: 1, reason: 'shared conftest fixture scope' });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const fellBackToAll = selections.length === 0 && considered.length > 0;
|
|
129
|
+
return {
|
|
130
|
+
selected: fellBackToAll
|
|
131
|
+
? considered.map((path) => ({ path, score: 0, reason: 'no match; running the full suite' }))
|
|
132
|
+
: selections,
|
|
133
|
+
fellBackToAll,
|
|
134
|
+
changedSourceFiles,
|
|
135
|
+
changedTestFiles,
|
|
136
|
+
consideredTestFiles: considered,
|
|
137
|
+
};
|
|
138
|
+
}
|