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.
@@ -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 { isGitIgnored } from '../../src/project/root.ts';
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 { hasDirectory, messageOf, resolveProjectRoot, text, type Pi } from '../shared.ts';
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
- const hasTestsDirectory =
114
- (await hasDirectory(join(root, 'tests'))) || (await hasDirectory(join(root, 'test')));
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 selection = selectTests(changed, testFiles);
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 ? [] : selection.selected.map((entry) => entry.path),
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
- data: { ...selection, pytestTargets: selection.selected.map((entry) => entry.path) },
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) {