pi-python-helper 0.1.0 → 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 +39 -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/dependencies.ts +0 -120
- package/extensions/tools/environment.ts +30 -5
- package/extensions/tools/testing.ts +80 -8
- package/extensions/tools/validation.ts +427 -44
- 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 +63 -55
- package/src/build/quality.ts +49 -0
- package/src/build/selection.ts +176 -9
- package/src/build/sync.ts +70 -0
- package/src/build/traceback.ts +55 -0
- package/src/dependencies/aliases.ts +101 -0
- package/src/dependencies/plan.ts +60 -112
- 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 +126 -53
- 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,9 +1,6 @@
|
|
|
1
1
|
import { Type } from 'typebox';
|
|
2
2
|
import { failure, result } from '../../src/core/result.ts';
|
|
3
|
-
import { runCommand } from '../../src/core/runner.ts';
|
|
4
3
|
import { planDependencies } from '../../src/dependencies/plan.ts';
|
|
5
|
-
import { buildCompletionEvidence } from '../../src/validation/evidence.ts';
|
|
6
|
-
import { checkTdd } from '../../src/validation/tdd.ts';
|
|
7
4
|
import { messageOf, resolveProjectRoot, text, type Pi } from '../shared.ts';
|
|
8
5
|
import { runScanProject } from '../../src/project/scanner.ts';
|
|
9
6
|
|
|
@@ -83,121 +80,4 @@ export function registerDependencyTools(pi: Pi): void {
|
|
|
83
80
|
}
|
|
84
81
|
},
|
|
85
82
|
});
|
|
86
|
-
|
|
87
|
-
pi.registerTool({
|
|
88
|
-
name: 'py_tdd_checkpoint',
|
|
89
|
-
label: 'Python TDD Checkpoint',
|
|
90
|
-
description:
|
|
91
|
-
'Check whether production Python changes have related test changes before implementation is considered complete. Read-only.',
|
|
92
|
-
promptSnippet: 'Check the Python TDD checkpoint for changed files',
|
|
93
|
-
promptGuidelines: [
|
|
94
|
-
'Use py_tdd_checkpoint before reporting Python implementation work as complete.',
|
|
95
|
-
],
|
|
96
|
-
parameters: Type.Object({
|
|
97
|
-
changedPaths: Type.Optional(Type.Array(Type.String(), { maxItems: 500 })),
|
|
98
|
-
testChangedPaths: Type.Optional(Type.Array(Type.String(), { maxItems: 500 })),
|
|
99
|
-
}),
|
|
100
|
-
async execute(_id, params, signal, _update, ctx) {
|
|
101
|
-
const started = Date.now();
|
|
102
|
-
let changedPaths = params.changedPaths ?? [];
|
|
103
|
-
let source = 'argument';
|
|
104
|
-
if (!changedPaths.length) {
|
|
105
|
-
const diff = await runCommand('git', ['diff', '--name-only', 'HEAD'], {
|
|
106
|
-
cwd: ctx.cwd,
|
|
107
|
-
signal,
|
|
108
|
-
timeoutMs: 10000,
|
|
109
|
-
maxBytes: 100_000,
|
|
110
|
-
});
|
|
111
|
-
changedPaths = diff.stdout
|
|
112
|
-
.split(/\r?\n/)
|
|
113
|
-
.map((line) => line.trim())
|
|
114
|
-
.filter(Boolean);
|
|
115
|
-
source = 'git diff';
|
|
116
|
-
}
|
|
117
|
-
const checkpoint = checkTdd(changedPaths, params.testChangedPaths ?? changedPaths);
|
|
118
|
-
return text(
|
|
119
|
-
result(ctx.cwd, started, {
|
|
120
|
-
ok: checkpoint.ok,
|
|
121
|
-
summary: checkpoint.ok
|
|
122
|
-
? `TDD checkpoint passed across ${changedPaths.length} changed path(s) from ${source}.`
|
|
123
|
-
: 'TDD checkpoint found production changes without a related test change.',
|
|
124
|
-
data: { ...checkpoint, changedPaths, source },
|
|
125
|
-
evidence: checkpoint.reasons.map((message) => ({ kind: 'tdd_blocker', message })),
|
|
126
|
-
warnings: checkpoint.reasons.map((message) => ({
|
|
127
|
-
code: 'TDD_CHECKPOINT',
|
|
128
|
-
message,
|
|
129
|
-
severity: 'warning' as const,
|
|
130
|
-
})),
|
|
131
|
-
errors: [],
|
|
132
|
-
suggestions: checkpoint.ok
|
|
133
|
-
? []
|
|
134
|
-
: [
|
|
135
|
-
{
|
|
136
|
-
message:
|
|
137
|
-
'Add the smallest focused test for the changed behaviour, or explain why the change needs no test.',
|
|
138
|
-
confidence: 'high' as const,
|
|
139
|
-
},
|
|
140
|
-
],
|
|
141
|
-
}),
|
|
142
|
-
);
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
pi.registerTool({
|
|
147
|
-
name: 'py_completion_evidence',
|
|
148
|
-
label: 'Python Completion Evidence',
|
|
149
|
-
description:
|
|
150
|
-
'Build a conservative completion report from environment sync and test execution results. Read-only.',
|
|
151
|
-
promptSnippet: 'Create evidence for a Python completion report',
|
|
152
|
-
promptGuidelines: [
|
|
153
|
-
'Use py_completion_evidence before claiming Python work is complete; a partial run is not evidence.',
|
|
154
|
-
],
|
|
155
|
-
parameters: Type.Object({
|
|
156
|
-
syncExecuted: Type.Boolean({
|
|
157
|
-
description: 'Whether uv lock --check / uv sync actually ran.',
|
|
158
|
-
}),
|
|
159
|
-
syncOk: Type.Boolean(),
|
|
160
|
-
testExecuted: Type.Boolean({ description: 'Whether pytest actually ran.' }),
|
|
161
|
-
testOk: Type.Boolean(),
|
|
162
|
-
stale: Type.Boolean({ description: 'Whether stale artifacts were detected.' }),
|
|
163
|
-
changedPaths: Type.Array(Type.String(), { maxItems: 500 }),
|
|
164
|
-
}),
|
|
165
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
166
|
-
const started = Date.now();
|
|
167
|
-
const evidence = buildCompletionEvidence(params);
|
|
168
|
-
return text(
|
|
169
|
-
result(ctx.cwd, started, {
|
|
170
|
-
ok: evidence.ok,
|
|
171
|
-
summary: evidence.ok
|
|
172
|
-
? 'Completion evidence is sufficient for the supplied checks.'
|
|
173
|
-
: 'Completion evidence is incomplete or contains failing checks.',
|
|
174
|
-
data: evidence,
|
|
175
|
-
evidence: evidence.blockers.map((message) => ({ kind: 'completion_blocker', message })),
|
|
176
|
-
warnings: evidence.blockers.map((message) => ({
|
|
177
|
-
code: 'INCOMPLETE_EVIDENCE',
|
|
178
|
-
message,
|
|
179
|
-
severity: 'warning' as const,
|
|
180
|
-
})),
|
|
181
|
-
errors: evidence.ok
|
|
182
|
-
? []
|
|
183
|
-
: [
|
|
184
|
-
{
|
|
185
|
-
code: 'COMPLETION_NOT_PROVEN',
|
|
186
|
-
message: 'The supplied evidence does not prove completion.',
|
|
187
|
-
severity: 'error' as const,
|
|
188
|
-
},
|
|
189
|
-
],
|
|
190
|
-
suggestions: evidence.ok
|
|
191
|
-
? []
|
|
192
|
-
: [
|
|
193
|
-
{
|
|
194
|
-
message:
|
|
195
|
-
'Run py_validation_bundle and address every blocker before reporting completion.',
|
|
196
|
-
confidence: 'high' as const,
|
|
197
|
-
},
|
|
198
|
-
],
|
|
199
|
-
}),
|
|
200
|
-
);
|
|
201
|
-
},
|
|
202
|
-
});
|
|
203
83
|
}
|
|
@@ -4,9 +4,27 @@ 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
|
+
readTextIfExists,
|
|
14
|
+
resolveProjectRoot,
|
|
15
|
+
text,
|
|
16
|
+
type Pi,
|
|
17
|
+
} from '../shared.ts';
|
|
18
|
+
|
|
19
|
+
/** INI files that can carry pytest configuration, in the order they are read. */
|
|
20
|
+
const PYTEST_INI_FILES = ['pytest.ini', 'tox.ini', 'setup.cfg'];
|
|
21
|
+
|
|
22
|
+
async function readPytestIniFiles(root: string): Promise<Record<string, string | undefined>> {
|
|
23
|
+
const entries = await Promise.all(
|
|
24
|
+
PYTEST_INI_FILES.map(async (name) => [name, await readTextIfExists(join(root, name))] as const),
|
|
25
|
+
);
|
|
26
|
+
return Object.fromEntries(entries);
|
|
27
|
+
}
|
|
10
28
|
|
|
11
29
|
export function registerEnvironmentTools(pi: Pi): void {
|
|
12
30
|
pi.registerTool({
|
|
@@ -110,14 +128,21 @@ export function registerEnvironmentTools(pi: Pi): void {
|
|
|
110
128
|
const venvPath = join(root, '.venv');
|
|
111
129
|
const venvDir = (await hasDirectory(venvPath)) ? venvPath : undefined;
|
|
112
130
|
const venvIgnored = venvDir ? await isGitIgnored(root, '.venv') : undefined;
|
|
113
|
-
|
|
114
|
-
|
|
131
|
+
// Tests frequently live inside the package they cover, so the whole tree
|
|
132
|
+
// is searched instead of only `./tests`.
|
|
133
|
+
const testDirectories = await findTestDirectories(root);
|
|
134
|
+
const pytestConfiguration = detectPytestConfiguration({
|
|
135
|
+
pyprojectConfigured: scan.payload.manifest?.toolConfiguration?.pytest === true,
|
|
136
|
+
iniFiles: await readPytestIniFiles(root),
|
|
137
|
+
});
|
|
115
138
|
const installed = venvDir ? await readInstalledDistributions(venvDir) : undefined;
|
|
116
139
|
const inspection = inspectProject({
|
|
117
140
|
payload: scan.payload,
|
|
118
141
|
venvDir,
|
|
119
142
|
venvIgnored,
|
|
120
|
-
hasTestsDirectory,
|
|
143
|
+
hasTestsDirectory: testDirectories.length > 0,
|
|
144
|
+
testDirectories,
|
|
145
|
+
pytestConfiguration,
|
|
121
146
|
installed,
|
|
122
147
|
});
|
|
123
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,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) {
|