pi-python-helper 0.1.1 → 0.3.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 +48 -0
- package/README.md +85 -125
- package/docs/compatibility.md +17 -1
- package/docs/tools.md +47 -2
- package/extensions/index.ts +2 -0
- package/extensions/shared.ts +37 -1
- package/extensions/tools/environment.ts +20 -5
- package/extensions/tools/test-config.ts +148 -0
- package/extensions/tools/testing.ts +95 -9
- package/extensions/tools/validation.ts +310 -46
- package/helpers/scan_project.py +81 -1
- package/package.json +1 -1
- package/skills/python-development/SKILL.md +17 -9
- package/src/build/commands.ts +17 -2
- package/src/build/failure.ts +81 -16
- package/src/build/pytest-audit.ts +226 -0
- package/src/build/quality.ts +49 -0
- package/src/build/selection.ts +176 -9
- package/src/build/sync.ts +70 -0
- package/src/core/result.ts +28 -1
- package/src/dependencies/aliases.ts +21 -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 +71 -9
- package/src/validation/bundle.ts +41 -4
- package/src/validation/tdd.ts +94 -21
|
@@ -4,9 +4,17 @@ import { result, failure } from '../../src/core/result.ts';
|
|
|
4
4
|
import { detectPythonEnvironment } from '../../src/environment/discovery.ts';
|
|
5
5
|
import { inspectProject } from '../../src/project/inspect.ts';
|
|
6
6
|
import { readInstalledDistributions } from '../../src/project/installed.ts';
|
|
7
|
-
import {
|
|
7
|
+
import { detectPytestConfiguration } from '../../src/project/pytest-config.ts';
|
|
8
|
+
import { findTestDirectories, isGitIgnored } from '../../src/project/root.ts';
|
|
8
9
|
import { runScanProject } from '../../src/project/scanner.ts';
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
hasDirectory,
|
|
12
|
+
messageOf,
|
|
13
|
+
readPytestIniFiles,
|
|
14
|
+
resolveProjectRoot,
|
|
15
|
+
text,
|
|
16
|
+
type Pi,
|
|
17
|
+
} from '../shared.ts';
|
|
10
18
|
|
|
11
19
|
export function registerEnvironmentTools(pi: Pi): void {
|
|
12
20
|
pi.registerTool({
|
|
@@ -110,14 +118,21 @@ export function registerEnvironmentTools(pi: Pi): void {
|
|
|
110
118
|
const venvPath = join(root, '.venv');
|
|
111
119
|
const venvDir = (await hasDirectory(venvPath)) ? venvPath : undefined;
|
|
112
120
|
const venvIgnored = venvDir ? await isGitIgnored(root, '.venv') : undefined;
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
// Tests frequently live inside the package they cover, so the whole tree
|
|
122
|
+
// is searched instead of only `./tests`.
|
|
123
|
+
const testDirectories = await findTestDirectories(root);
|
|
124
|
+
const pytestConfiguration = detectPytestConfiguration({
|
|
125
|
+
pyprojectConfigured: scan.payload.manifest?.toolConfiguration?.pytest === true,
|
|
126
|
+
iniFiles: await readPytestIniFiles(root),
|
|
127
|
+
});
|
|
115
128
|
const installed = venvDir ? await readInstalledDistributions(venvDir) : undefined;
|
|
116
129
|
const inspection = inspectProject({
|
|
117
130
|
payload: scan.payload,
|
|
118
131
|
venvDir,
|
|
119
132
|
venvIgnored,
|
|
120
|
-
hasTestsDirectory,
|
|
133
|
+
hasTestsDirectory: testDirectories.length > 0,
|
|
134
|
+
testDirectories,
|
|
135
|
+
pytestConfiguration,
|
|
121
136
|
installed,
|
|
122
137
|
});
|
|
123
138
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { failure, result, type Diagnostic } from '../../src/core/result.ts';
|
|
4
|
+
import {
|
|
5
|
+
auditPytestConfiguration,
|
|
6
|
+
resolvePytestOptions,
|
|
7
|
+
type AsyncTestFile,
|
|
8
|
+
} from '../../src/build/pytest-audit.ts';
|
|
9
|
+
import { buildDeclaredIndex, normalizeName } from '../../src/dependencies/plan.ts';
|
|
10
|
+
import { isRunnableTestFile } from '../../src/project/paths.ts';
|
|
11
|
+
import { isDirectory } from '../../src/project/root.ts';
|
|
12
|
+
import { runScanProject } from '../../src/project/scanner.ts';
|
|
13
|
+
import { messageOf, readPytestIniFiles, resolveProjectRoot, text, type Pi } from '../shared.ts';
|
|
14
|
+
|
|
15
|
+
export function registerTestConfigTools(pi: Pi): void {
|
|
16
|
+
pi.registerTool({
|
|
17
|
+
name: 'py_test_config',
|
|
18
|
+
label: 'Python Test Config',
|
|
19
|
+
description:
|
|
20
|
+
'Audit pytest configuration against the declared plugins and the tests on disk, and report options that make tests pass without running. Read-only.',
|
|
21
|
+
promptSnippet: 'Validate pytest configuration and detect tests that never run',
|
|
22
|
+
promptGuidelines: [
|
|
23
|
+
'Use py_test_config when a test run reports fewer tests than expected, when async tests may be silently skipped, or before trusting a green run.',
|
|
24
|
+
'Use py_test_config after changing pyproject.toml, pytest.ini, or the test layout to confirm the configuration still matches the project.',
|
|
25
|
+
],
|
|
26
|
+
parameters: Type.Object({
|
|
27
|
+
path: Type.Optional(
|
|
28
|
+
Type.String({ description: 'Project directory to audit; defaults to the project root.' }),
|
|
29
|
+
),
|
|
30
|
+
}),
|
|
31
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
32
|
+
const started = Date.now();
|
|
33
|
+
try {
|
|
34
|
+
const root = (await resolveProjectRoot(ctx.cwd, params.path)) ?? ctx.cwd;
|
|
35
|
+
const scan = await runScanProject(ctx.cwd, { root, mode: 'all', maxFiles: 2000 }, signal);
|
|
36
|
+
if (!scan.ok || !scan.payload) {
|
|
37
|
+
return text(
|
|
38
|
+
failure(
|
|
39
|
+
ctx.cwd,
|
|
40
|
+
started,
|
|
41
|
+
scan.message ?? 'The project scanner failed.',
|
|
42
|
+
scan.code ?? 'SCANNER_FAILED',
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const manifest = scan.payload.manifest;
|
|
48
|
+
const declared = new Set(
|
|
49
|
+
buildDeclaredIndex(
|
|
50
|
+
manifest ?? {
|
|
51
|
+
dependencies: [],
|
|
52
|
+
optionalDependencies: {},
|
|
53
|
+
dependencyGroups: {},
|
|
54
|
+
},
|
|
55
|
+
).keys(),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const resolution = resolvePytestOptions({
|
|
59
|
+
pyprojectOptions: manifest?.pytestOptions ?? null,
|
|
60
|
+
iniFiles: await readPytestIniFiles(root),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Only files pytest would actually collect can hide a test, so a script
|
|
64
|
+
// that happens to define `async def test_*` is not reported.
|
|
65
|
+
const testFiles = (scan.payload.imports?.files ?? []).filter((file) =>
|
|
66
|
+
isRunnableTestFile(file.path),
|
|
67
|
+
);
|
|
68
|
+
const unmarkedAsyncTests: AsyncTestFile[] = testFiles
|
|
69
|
+
.map((file) => ({
|
|
70
|
+
path: file.path,
|
|
71
|
+
tests: (file.asyncTests ?? []).filter(
|
|
72
|
+
(name) => !(file.asyncioMarkedTests ?? []).includes(name),
|
|
73
|
+
),
|
|
74
|
+
}))
|
|
75
|
+
.filter((entry) => entry.tests.length > 0);
|
|
76
|
+
|
|
77
|
+
const missingTestPaths: string[] = [];
|
|
78
|
+
for (const entry of resolution.options.testpaths) {
|
|
79
|
+
if (!(await isDirectory(join(root, entry)))) missingTestPaths.push(entry);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const findings = auditPytestConfiguration({
|
|
83
|
+
sources: resolution.sources,
|
|
84
|
+
options: resolution.options,
|
|
85
|
+
declared,
|
|
86
|
+
unmarkedAsyncTests,
|
|
87
|
+
missingTestPaths,
|
|
88
|
+
hasTestFiles: testFiles.length > 0,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const toDiagnostic = (finding: (typeof findings)[number]): Diagnostic => ({
|
|
92
|
+
code: finding.code,
|
|
93
|
+
message: finding.suggestion
|
|
94
|
+
? `${finding.message} ${finding.suggestion}`
|
|
95
|
+
: finding.message,
|
|
96
|
+
severity: finding.severity,
|
|
97
|
+
});
|
|
98
|
+
const errors = findings.filter((finding) => finding.severity === 'error');
|
|
99
|
+
const warnings = findings.filter((finding) => finding.severity !== 'error');
|
|
100
|
+
|
|
101
|
+
return text(
|
|
102
|
+
result(ctx.cwd, started, {
|
|
103
|
+
ok: errors.length === 0,
|
|
104
|
+
summary:
|
|
105
|
+
findings.length === 0
|
|
106
|
+
? `pytest configuration is consistent (${resolution.sources[0] ?? 'no configuration file'}, ${testFiles.length} test file(s)).`
|
|
107
|
+
: `${errors.length} error(s) and ${warnings.length} warning(s) in the pytest configuration.`,
|
|
108
|
+
data: {
|
|
109
|
+
sources: resolution.sources,
|
|
110
|
+
options: resolution.options,
|
|
111
|
+
findings,
|
|
112
|
+
unmarkedAsyncTests,
|
|
113
|
+
missingTestPaths,
|
|
114
|
+
testFileCount: testFiles.length,
|
|
115
|
+
declared: {
|
|
116
|
+
pytestAsyncio: declared.has(normalizeName('pytest-asyncio')),
|
|
117
|
+
pytestCov: declared.has(normalizeName('pytest-cov')),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
evidence: [
|
|
121
|
+
{
|
|
122
|
+
kind: 'pytest_config_audit',
|
|
123
|
+
sources: resolution.sources,
|
|
124
|
+
findings: findings.map((finding) => finding.code),
|
|
125
|
+
unmarkedAsyncTests: unmarkedAsyncTests.reduce(
|
|
126
|
+
(total, entry) => total + entry.tests.length,
|
|
127
|
+
0,
|
|
128
|
+
),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
warnings: warnings.map(toDiagnostic),
|
|
132
|
+
errors: errors.map(toDiagnostic),
|
|
133
|
+
suggestions: findings
|
|
134
|
+
.filter((finding) => finding.suggestion)
|
|
135
|
+
.map((finding) => ({
|
|
136
|
+
message: finding.suggestion as string,
|
|
137
|
+
confidence: 'high' as const,
|
|
138
|
+
})),
|
|
139
|
+
projectRoot: root,
|
|
140
|
+
pythonVersion: scan.payload.pythonVersion,
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return text(failure(ctx.cwd, started, messageOf(error), 'INTERNAL_ERROR'));
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -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,21 +94,41 @@ 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
|
+
// `changed` still holds non-Python paths, so "nothing to select" has to be
|
|
102
|
+
// decided on what the selector actually matched.
|
|
103
|
+
const hasPythonChange =
|
|
104
|
+
selection.changedSourceFiles.length > 0 || selection.changedTestFiles.length > 0;
|
|
105
|
+
// Only files pytest collects tests from become targets; naming
|
|
106
|
+
// `tests/utils.py` as a target overstates the run.
|
|
107
|
+
const targets = selection.selected
|
|
108
|
+
.filter(
|
|
109
|
+
(entry) => isRunnableTestFile(entry.path) || basename(entry.path) === 'conftest.py',
|
|
110
|
+
)
|
|
111
|
+
.map((entry) => entry.path);
|
|
72
112
|
const command = pytestCommand(ctx.cwd, {
|
|
73
|
-
targets: selection.fellBackToAll ? [] :
|
|
113
|
+
targets: selection.fellBackToAll ? [] : targets,
|
|
74
114
|
});
|
|
75
115
|
|
|
76
116
|
return text(
|
|
77
117
|
result(ctx.cwd, started, {
|
|
78
|
-
ok: selection.selected.length > 0 || testFiles.length === 0,
|
|
118
|
+
ok: selection.selected.length > 0 || testFiles.length === 0 || !hasPythonChange,
|
|
79
119
|
summary:
|
|
80
120
|
`${selection.selected.length} test file(s) selected from ${testFiles.length} known test file(s) ` +
|
|
81
121
|
`for ${selection.changedSourceFiles.length} changed source file(s) (${changedSource}).` +
|
|
82
122
|
(selection.fellBackToAll
|
|
83
123
|
? ' No match was found, so the full suite is in scope.'
|
|
84
|
-
:
|
|
85
|
-
|
|
124
|
+
: selection.narrowed
|
|
125
|
+
? ''
|
|
126
|
+
: ' Every candidate matched, so nothing was narrowed.'),
|
|
127
|
+
data: {
|
|
128
|
+
...selection,
|
|
129
|
+
pytestTargets: targets,
|
|
130
|
+
noNarrowing: !selection.fellBackToAll && !selection.narrowed,
|
|
131
|
+
},
|
|
86
132
|
evidence: [
|
|
87
133
|
{
|
|
88
134
|
kind: 'test_selection',
|
|
@@ -90,10 +136,22 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
90
136
|
changedTestFiles: selection.changedTestFiles,
|
|
91
137
|
selected: selection.selected,
|
|
92
138
|
fellBackToAll: selection.fellBackToAll,
|
|
139
|
+
narrowed: selection.narrowed,
|
|
140
|
+
importEvidenceUsed: selection.importEvidenceUsed,
|
|
93
141
|
},
|
|
94
142
|
],
|
|
95
|
-
warnings:
|
|
96
|
-
|
|
143
|
+
warnings: [
|
|
144
|
+
...(!hasPythonChange
|
|
145
|
+
? [
|
|
146
|
+
{
|
|
147
|
+
code: 'NO_CHANGED_PATHS',
|
|
148
|
+
message:
|
|
149
|
+
'No changed Python file was found, so nothing could be selected. Pass changedPaths explicitly when the change is not visible to git.',
|
|
150
|
+
severity: 'warning' as const,
|
|
151
|
+
},
|
|
152
|
+
]
|
|
153
|
+
: []),
|
|
154
|
+
...(testFiles.length === 0
|
|
97
155
|
? [
|
|
98
156
|
{
|
|
99
157
|
code: 'NO_TEST_FILES',
|
|
@@ -102,7 +160,27 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
102
160
|
severity: 'warning' as const,
|
|
103
161
|
},
|
|
104
162
|
]
|
|
105
|
-
: [],
|
|
163
|
+
: []),
|
|
164
|
+
...(!selection.fellBackToAll && !selection.narrowed && selection.selected.length > 0
|
|
165
|
+
? [
|
|
166
|
+
{
|
|
167
|
+
code: 'NO_NARROWING',
|
|
168
|
+
message: `${selection.selected.length} of ${testFiles.length} test file(s) matched, so this selection is the whole suite rather than a focused target.`,
|
|
169
|
+
severity: 'warning' as const,
|
|
170
|
+
},
|
|
171
|
+
]
|
|
172
|
+
: []),
|
|
173
|
+
...(selection.fellBackToAll || (!selection.importEvidenceUsed && changed.length > 0)
|
|
174
|
+
? [
|
|
175
|
+
{
|
|
176
|
+
code: 'SELECTION_WITHOUT_IMPORT_EVIDENCE',
|
|
177
|
+
message:
|
|
178
|
+
'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.',
|
|
179
|
+
severity: 'warning' as const,
|
|
180
|
+
},
|
|
181
|
+
]
|
|
182
|
+
: []),
|
|
183
|
+
],
|
|
106
184
|
errors: [],
|
|
107
185
|
suggestions: selection.selected.length
|
|
108
186
|
? [
|
|
@@ -131,6 +209,7 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
131
209
|
promptGuidelines: [
|
|
132
210
|
'Use py_test with execute=false first; a preview is never a passing test run.',
|
|
133
211
|
'Use py_test after changing Python sources; it does not rebuild anything, so run py_sync first when dependencies changed.',
|
|
212
|
+
'Use py_test with extraArgs to run project-standard pytest flags such as coverage options that the tool does not model directly.',
|
|
134
213
|
],
|
|
135
214
|
parameters: Type.Object({
|
|
136
215
|
targets: Type.Optional(Type.Array(Type.String())),
|
|
@@ -138,6 +217,12 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
138
217
|
Type.Boolean({ description: 'Rerun only tests that failed last time (--lf).' }),
|
|
139
218
|
),
|
|
140
219
|
keyword: Type.Optional(Type.String({ description: 'pytest -k expression.' })),
|
|
220
|
+
extraArgs: Type.Optional(
|
|
221
|
+
Type.Array(Type.String(), {
|
|
222
|
+
description:
|
|
223
|
+
'Extra pytest arguments passed verbatim as an argument array, e.g. ["--cov=my_pkg", "--cov-branch"] or ["-m", "unit"].',
|
|
224
|
+
}),
|
|
225
|
+
),
|
|
141
226
|
maxFail: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
|
|
142
227
|
execute: Type.Optional(Type.Boolean()),
|
|
143
228
|
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 3600 })),
|
|
@@ -151,6 +236,7 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
151
236
|
targets: params.targets,
|
|
152
237
|
lastFailed: params.lastFailed,
|
|
153
238
|
keyword: params.keyword,
|
|
239
|
+
extraArgs: params.extraArgs,
|
|
154
240
|
maxFail: params.maxFail,
|
|
155
241
|
});
|
|
156
242
|
if (!params.execute) {
|