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.
@@ -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) {