pi-python-helper 0.1.1 → 0.2.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 +30 -0
- package/README.md +85 -125
- package/docs/compatibility.md +17 -1
- package/docs/tools.md +25 -2
- package/extensions/shared.ts +24 -0
- package/extensions/tools/environment.ts +30 -5
- package/extensions/tools/testing.ts +80 -8
- package/extensions/tools/validation.ts +310 -46
- package/helpers/scan_project.py +15 -1
- package/package.json +1 -1
- package/skills/python-development/SKILL.md +11 -6
- package/src/build/commands.ts +17 -2
- package/src/build/failure.ts +56 -0
- package/src/build/quality.ts +49 -0
- package/src/build/selection.ts +176 -9
- package/src/build/sync.ts +70 -0
- package/src/dependencies/plan.ts +58 -9
- package/src/environment/discovery.ts +30 -3
- package/src/environment/tools.ts +41 -2
- package/src/project/conformance.ts +101 -25
- package/src/project/inspect.ts +45 -6
- package/src/project/paths.ts +15 -0
- package/src/project/pytest-config.ts +45 -0
- package/src/project/root.ts +85 -1
- package/src/project/scanner.ts +61 -9
- package/src/validation/bundle.ts +41 -4
- package/src/validation/tdd.ts +94 -21
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { Type } from 'typebox';
|
|
2
|
+
import { basename } from 'node:path';
|
|
2
3
|
import { failure, result } from '../../src/core/result.ts';
|
|
3
4
|
import { runCommand } from '../../src/core/runner.ts';
|
|
4
5
|
import { pytestCommand } from '../../src/build/commands.ts';
|
|
5
6
|
import { changedPaths, listTestFiles } from '../../src/build/discover.ts';
|
|
6
7
|
import { diagnoseFailure, refineWithDeclarations } from '../../src/build/failure.ts';
|
|
7
8
|
import { parsePytestOutput } from '../../src/build/pytest.ts';
|
|
8
|
-
import { selectTests } from '../../src/build/selection.ts';
|
|
9
|
+
import { selectTests, type TestImportMap } from '../../src/build/selection.ts';
|
|
9
10
|
import { buildDeclaredIndex } from '../../src/dependencies/plan.ts';
|
|
11
|
+
import { isRunnableTestFile } from '../../src/project/paths.ts';
|
|
10
12
|
import { runScanProject } from '../../src/project/scanner.ts';
|
|
11
13
|
import { messageOf, resolveProjectRoot, text, type Pi } from '../shared.ts';
|
|
12
14
|
|
|
@@ -33,6 +35,30 @@ async function refine(
|
|
|
33
35
|
return refineWithDeclarations(diagnosis, { declared, localModules });
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Map each test file to the dotted modules it imports.
|
|
40
|
+
*
|
|
41
|
+
* A test named `test_db_session.py` gives no naming hint that it covers
|
|
42
|
+
* `db/database.py`; the fact that it imports `pkg.db.database` does. The scanner
|
|
43
|
+
* supplies this because only a real parser may be trusted with Python source.
|
|
44
|
+
*/
|
|
45
|
+
async function collectTestImports(
|
|
46
|
+
root: string,
|
|
47
|
+
cwd: string,
|
|
48
|
+
signal: AbortSignal | undefined,
|
|
49
|
+
): Promise<TestImportMap | undefined> {
|
|
50
|
+
const scan = await runScanProject(cwd, { root, mode: 'imports' }, signal);
|
|
51
|
+
const files = scan.payload?.imports?.files;
|
|
52
|
+
if (!scan.ok || !files) return undefined;
|
|
53
|
+
const map: TestImportMap = {};
|
|
54
|
+
for (const entry of files) {
|
|
55
|
+
if (!isRunnableTestFile(entry.path) && basename(entry.path) !== 'conftest.py') continue;
|
|
56
|
+
if (!entry.importModules?.length) continue;
|
|
57
|
+
map[entry.path] = entry.importModules;
|
|
58
|
+
}
|
|
59
|
+
return map;
|
|
60
|
+
}
|
|
61
|
+
|
|
36
62
|
export function registerTestingTools(pi: Pi): void {
|
|
37
63
|
pi.registerTool({
|
|
38
64
|
name: 'py_test_select',
|
|
@@ -68,9 +94,19 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
68
94
|
changedSource = discovered.source === 'git' ? 'git' : (discovered.error ?? 'none');
|
|
69
95
|
}
|
|
70
96
|
const testFiles = params.testFiles ?? (await listTestFiles(root));
|
|
71
|
-
const
|
|
97
|
+
const testImports = params.testFiles
|
|
98
|
+
? undefined
|
|
99
|
+
: await collectTestImports(root, ctx.cwd, signal);
|
|
100
|
+
const selection = selectTests(changed, testFiles, { testImports });
|
|
101
|
+
// Only files pytest collects tests from become targets; naming
|
|
102
|
+
// `tests/utils.py` as a target overstates the run.
|
|
103
|
+
const targets = selection.selected
|
|
104
|
+
.filter(
|
|
105
|
+
(entry) => isRunnableTestFile(entry.path) || basename(entry.path) === 'conftest.py',
|
|
106
|
+
)
|
|
107
|
+
.map((entry) => entry.path);
|
|
72
108
|
const command = pytestCommand(ctx.cwd, {
|
|
73
|
-
targets: selection.fellBackToAll ? [] :
|
|
109
|
+
targets: selection.fellBackToAll ? [] : targets,
|
|
74
110
|
});
|
|
75
111
|
|
|
76
112
|
return text(
|
|
@@ -81,8 +117,14 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
81
117
|
`for ${selection.changedSourceFiles.length} changed source file(s) (${changedSource}).` +
|
|
82
118
|
(selection.fellBackToAll
|
|
83
119
|
? ' No match was found, so the full suite is in scope.'
|
|
84
|
-
:
|
|
85
|
-
|
|
120
|
+
: selection.narrowed
|
|
121
|
+
? ''
|
|
122
|
+
: ' Every candidate matched, so nothing was narrowed.'),
|
|
123
|
+
data: {
|
|
124
|
+
...selection,
|
|
125
|
+
pytestTargets: targets,
|
|
126
|
+
noNarrowing: !selection.fellBackToAll && !selection.narrowed,
|
|
127
|
+
},
|
|
86
128
|
evidence: [
|
|
87
129
|
{
|
|
88
130
|
kind: 'test_selection',
|
|
@@ -90,10 +132,12 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
90
132
|
changedTestFiles: selection.changedTestFiles,
|
|
91
133
|
selected: selection.selected,
|
|
92
134
|
fellBackToAll: selection.fellBackToAll,
|
|
135
|
+
narrowed: selection.narrowed,
|
|
136
|
+
importEvidenceUsed: selection.importEvidenceUsed,
|
|
93
137
|
},
|
|
94
138
|
],
|
|
95
|
-
warnings:
|
|
96
|
-
testFiles.length === 0
|
|
139
|
+
warnings: [
|
|
140
|
+
...(testFiles.length === 0
|
|
97
141
|
? [
|
|
98
142
|
{
|
|
99
143
|
code: 'NO_TEST_FILES',
|
|
@@ -102,7 +146,27 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
102
146
|
severity: 'warning' as const,
|
|
103
147
|
},
|
|
104
148
|
]
|
|
105
|
-
: [],
|
|
149
|
+
: []),
|
|
150
|
+
...(!selection.fellBackToAll && !selection.narrowed && selection.selected.length > 0
|
|
151
|
+
? [
|
|
152
|
+
{
|
|
153
|
+
code: 'NO_NARROWING',
|
|
154
|
+
message: `${selection.selected.length} of ${testFiles.length} test file(s) matched, so this selection is the whole suite rather than a focused target.`,
|
|
155
|
+
severity: 'warning' as const,
|
|
156
|
+
},
|
|
157
|
+
]
|
|
158
|
+
: []),
|
|
159
|
+
...(selection.fellBackToAll || (!selection.importEvidenceUsed && changed.length > 0)
|
|
160
|
+
? [
|
|
161
|
+
{
|
|
162
|
+
code: 'SELECTION_WITHOUT_IMPORT_EVIDENCE',
|
|
163
|
+
message:
|
|
164
|
+
'No candidate was matched by an actual import of a changed module, so this selection rests on file naming alone. Run the full suite or py_test with lastFailed=true when the change is broad.',
|
|
165
|
+
severity: 'warning' as const,
|
|
166
|
+
},
|
|
167
|
+
]
|
|
168
|
+
: []),
|
|
169
|
+
],
|
|
106
170
|
errors: [],
|
|
107
171
|
suggestions: selection.selected.length
|
|
108
172
|
? [
|
|
@@ -131,6 +195,7 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
131
195
|
promptGuidelines: [
|
|
132
196
|
'Use py_test with execute=false first; a preview is never a passing test run.',
|
|
133
197
|
'Use py_test after changing Python sources; it does not rebuild anything, so run py_sync first when dependencies changed.',
|
|
198
|
+
'Use py_test with extraArgs to run project-standard pytest flags such as coverage options that the tool does not model directly.',
|
|
134
199
|
],
|
|
135
200
|
parameters: Type.Object({
|
|
136
201
|
targets: Type.Optional(Type.Array(Type.String())),
|
|
@@ -138,6 +203,12 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
138
203
|
Type.Boolean({ description: 'Rerun only tests that failed last time (--lf).' }),
|
|
139
204
|
),
|
|
140
205
|
keyword: Type.Optional(Type.String({ description: 'pytest -k expression.' })),
|
|
206
|
+
extraArgs: Type.Optional(
|
|
207
|
+
Type.Array(Type.String(), {
|
|
208
|
+
description:
|
|
209
|
+
'Extra pytest arguments passed verbatim as an argument array, e.g. ["--cov=my_pkg", "--cov-branch"] or ["-m", "unit"].',
|
|
210
|
+
}),
|
|
211
|
+
),
|
|
141
212
|
maxFail: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
|
|
142
213
|
execute: Type.Optional(Type.Boolean()),
|
|
143
214
|
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 3600 })),
|
|
@@ -151,6 +222,7 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
151
222
|
targets: params.targets,
|
|
152
223
|
lastFailed: params.lastFailed,
|
|
153
224
|
keyword: params.keyword,
|
|
225
|
+
extraArgs: params.extraArgs,
|
|
154
226
|
maxFail: params.maxFail,
|
|
155
227
|
});
|
|
156
228
|
if (!params.execute) {
|