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.
@@ -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 { 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
+ 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
- const hasTestsDirectory =
114
- (await hasDirectory(join(root, 'tests'))) || (await hasDirectory(join(root, 'test')));
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 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
+ // `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 ? [] : selection.selected.map((entry) => entry.path),
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
- data: { ...selection, pytestTargets: selection.selected.map((entry) => entry.path) },
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
- testFiles.length === 0
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) {