pi-python-helper 0.2.0 → 0.4.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 +30 -0
- package/docs/tools.md +26 -3
- package/extensions/index.ts +2 -0
- package/extensions/shared.ts +13 -1
- package/extensions/tools/dependencies.ts +1 -1
- package/extensions/tools/environment.ts +7 -13
- package/extensions/tools/test-config.ts +148 -0
- package/extensions/tools/testing.ts +15 -1
- package/extensions/tools/validation.ts +5 -1
- package/helpers/scan_project.py +67 -1
- package/package.json +4 -1
- package/skills/python-development/SKILL.md +8 -5
- package/src/build/failure.ts +25 -16
- package/src/build/pytest-audit.ts +226 -0
- package/src/build/selection.ts +28 -256
- package/src/build/staleness.ts +28 -104
- package/src/core/result.ts +24 -97
- package/src/core/runner.ts +3 -94
- package/src/dependencies/aliases.ts +21 -0
- package/src/project/scanner.ts +11 -1
- package/src/validation/bundle.ts +40 -75
- package/src/validation/evidence.ts +25 -21
- package/src/validation/tdd.ts +21 -130
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ does not guarantee a stable public tool schema.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Adopt `pi-helper-core` (`^0.1.1`) for the shared response envelope, bounded command runner, TDD checkpoint, validation-bundle gate, completion evidence, artifact staleness, and test selection. `src/core/result.ts` and `src/core/runner.ts` are now thin shims, and `src/validation/` and `src/build/` supply only Python signals, labels, and rules.
|
|
15
|
+
- **Breaking:** tool metadata no longer carries `pythonVersion`; the interpreter version now lives in the ecosystem-neutral `metadata.toolchain` (`{ kind: 'python', version }`).
|
|
16
|
+
- Validation and completion messages now use the shared core wording (`uv lock --check`/`uv sync` labels); the drift check reports a mismatch against "the lockfile" generically.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `test/core-dependency.test.ts` pins the `pi-helper-core` dependency and the shared behaviours the Python tools delegate to.
|
|
21
|
+
|
|
22
|
+
## [0.3.0] - 2026-09-21
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `py_test_config` audits the pytest configuration pytest will actually use against the plugins the project declares and the tests on disk. It reports the cases that make a run look green while tests never execute (`ASYNC_TESTS_WITHOUT_PLUGIN`, `ASYNC_TESTS_REQUIRE_MARKER`), the options pytest rejects before collection (`ASYNCIO_MODE_WITHOUT_PLUGIN`, `COVERAGE_OPTION_WITHOUT_PLUGIN`), and a `testpaths` entry that does not exist (`TESTPATH_MISSING`). Only syntax separates an unmarked coroutine test from a marked one, so the decision is made on the AST rather than by matching text.
|
|
27
|
+
- The scanner reports `[tool.pytest.ini_options]` as `manifest.pytestOptions` and, per scanned file, `asyncTests` and `asyncioMarkedTests` (protocol version 3).
|
|
28
|
+
- Every tool response carries `attention`: `true` whenever the caller must act (`ok: false`, or a warning or error). It is derived centrally in `result()`, so `ok: false` always implies `attention: true` and no diagnostic is silently dropped. `ok` is now documented as the tool's **verdict** — "the project state is acceptable / the command succeeded / the gate may proceed" — rather than "the tool ran", so a check that finds a problem still returns `ok: false` without having failed. An `info` diagnostic is informational and does not raise `attention`.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **Breaking:** scanner protocol `SCANNER_VERSION` is now 3. A scanner reporting version 2 is rejected with `SCANNER_VERSION_MISMATCH`.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `py_test_select` no longer returns `ok: false` with no diagnostic at all when the change set contains no Python file (or git reports no changes). An empty selection is an answer rather than a failure, so it now returns `ok: true` and explains itself with the `NO_CHANGED_PATHS` warning.
|
|
37
|
+
- `py_failure_diagnose` no longer fabricates `uv add <import name>` for a `ModuleNotFoundError` whose providing distribution is unknown, matching the rule `py_dependency_plan` already follows. The alias table resolves a single provider (`yaml` → `uv add pyyaml`), while an import with several candidates (`cv2`) or none asks the caller to verify the distribution instead of installing the wrong package.
|
|
38
|
+
- `py_failure_diagnose` no longer repeats the same suggestion twice when a missing module is neither project code nor declared. `refineWithDeclarations` appended its generic "verify the distribution" advice on top of what the classifier had already reported, so a single undeclared import produced two suggestions with two duplicates.
|
|
39
|
+
|
|
10
40
|
## [0.2.0] - 2026-09-22
|
|
11
41
|
|
|
12
42
|
### Fixed
|
package/docs/tools.md
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
모든 도구는 동일한 `PyToolResult` 규격을 반환합니다. 반환 형태는 구조만 기록하며 값·경로·버전·소요시간은 스냅샷에서 제외합니다.
|
|
14
14
|
|
|
15
|
+
- `attention`: boolean
|
|
15
16
|
- `commands` (optional): array of
|
|
16
17
|
- `args`: array<string>
|
|
17
18
|
- `cwd`: string
|
|
@@ -29,12 +30,13 @@
|
|
|
29
30
|
- `cwd`: string
|
|
30
31
|
- `durationMs`: number
|
|
31
32
|
- `projectRoot` (optional): string
|
|
32
|
-
- `pythonVersion` (optional): string
|
|
33
33
|
- `toolVersion`: string
|
|
34
|
+
- `toolchain` (optional): object
|
|
35
|
+
- `kind`: string
|
|
36
|
+
- `source`: string
|
|
37
|
+
- `version`: string
|
|
34
38
|
- `truncated`: boolean
|
|
35
39
|
- `ok`: boolean
|
|
36
|
-
- `projectRoot` (optional): string
|
|
37
|
-
- `pythonVersion` (optional): string
|
|
38
40
|
- `suggestions`: array of
|
|
39
41
|
- `command` (optional): string
|
|
40
42
|
- `confidence`: string
|
|
@@ -466,6 +468,27 @@ Preview or run pytest through uv run --frozen and summarise failures by test, fi
|
|
|
466
468
|
- `risk`: string
|
|
467
469
|
- `executed`: boolean
|
|
468
470
|
|
|
471
|
+
### `py_test_config`
|
|
472
|
+
|
|
473
|
+
Audit pytest configuration against the declared plugins and the tests on disk, and report options that make tests pass without running. Read-only.
|
|
474
|
+
|
|
475
|
+
- 시스템 프롬프트 한 줄: `Validate pytest configuration and detect tests that never run`
|
|
476
|
+
- 라벨: Python Test Config
|
|
477
|
+
- 프로젝트 상태 변경: 없음 (읽기 전용)
|
|
478
|
+
|
|
479
|
+
**파라미터**
|
|
480
|
+
|
|
481
|
+
| 파라미터 | 타입 | 필수 | 설명 |
|
|
482
|
+
|---|---|---|---|
|
|
483
|
+
| `path` | `string` | 아니오 | Project directory to audit; defaults to the project root. |
|
|
484
|
+
|
|
485
|
+
**프롬프트 가이드라인**
|
|
486
|
+
|
|
487
|
+
- 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.
|
|
488
|
+
- Use py_test_config after changing pyproject.toml, pytest.ini, or the test layout to confirm the configuration still matches the project.
|
|
489
|
+
|
|
490
|
+
**반환 `data` 형태**: 캡처되지 않음
|
|
491
|
+
|
|
469
492
|
### `py_test_select`
|
|
470
493
|
|
|
471
494
|
Select focused pytest targets from changed files using pytest naming conventions, without running tests. Read-only.
|
package/extensions/index.ts
CHANGED
|
@@ -3,12 +3,14 @@ import { detectPythonEnvironment } from '../src/environment/discovery.ts';
|
|
|
3
3
|
import { registerDependencyTools } from './tools/dependencies.ts';
|
|
4
4
|
import { registerEnvironmentTools } from './tools/environment.ts';
|
|
5
5
|
import { registerTestingTools } from './tools/testing.ts';
|
|
6
|
+
import { registerTestConfigTools } from './tools/test-config.ts';
|
|
6
7
|
import { registerValidationTools } from './tools/validation.ts';
|
|
7
8
|
|
|
8
9
|
export default function (pi: ExtensionAPI): void {
|
|
9
10
|
registerEnvironmentTools(pi);
|
|
10
11
|
registerDependencyTools(pi);
|
|
11
12
|
registerTestingTools(pi);
|
|
13
|
+
registerTestConfigTools(pi);
|
|
12
14
|
registerValidationTools(pi);
|
|
13
15
|
|
|
14
16
|
pi.registerCommand('py-status', {
|
package/extensions/shared.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { open } from 'node:fs/promises';
|
|
3
|
-
import { basename, dirname, resolve } from 'node:path';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { findProjectRoot, isDirectory, isFile } from '../src/project/root.ts';
|
|
5
5
|
|
|
6
6
|
export type Pi = ExtensionAPI;
|
|
@@ -70,3 +70,15 @@ export async function readTextIfExists(
|
|
|
70
70
|
export function messageOf(error: unknown): string {
|
|
71
71
|
return error instanceof Error ? error.message : String(error);
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/** INI files that can carry pytest configuration, in the order they are read. */
|
|
75
|
+
export const PYTEST_INI_FILES = ['pytest.ini', 'tox.ini', 'setup.cfg'] as const;
|
|
76
|
+
|
|
77
|
+
export async function readPytestIniFiles(
|
|
78
|
+
root: string,
|
|
79
|
+
): Promise<Record<string, string | undefined>> {
|
|
80
|
+
const entries = await Promise.all(
|
|
81
|
+
PYTEST_INI_FILES.map(async (name) => [name, await readTextIfExists(join(root, name))] as const),
|
|
82
|
+
);
|
|
83
|
+
return Object.fromEntries(entries);
|
|
84
|
+
}
|
|
@@ -72,7 +72,7 @@ export function registerDependencyTools(pi: Pi): void {
|
|
|
72
72
|
errors: [],
|
|
73
73
|
suggestions: plan.suggestions,
|
|
74
74
|
projectRoot: root,
|
|
75
|
-
|
|
75
|
+
toolchain: { kind: 'python', version: scan.payload.pythonVersion, source: 'project' },
|
|
76
76
|
}),
|
|
77
77
|
);
|
|
78
78
|
} catch (error) {
|
|
@@ -10,22 +10,12 @@ import { runScanProject } from '../../src/project/scanner.ts';
|
|
|
10
10
|
import {
|
|
11
11
|
hasDirectory,
|
|
12
12
|
messageOf,
|
|
13
|
-
|
|
13
|
+
readPytestIniFiles,
|
|
14
14
|
resolveProjectRoot,
|
|
15
15
|
text,
|
|
16
16
|
type Pi,
|
|
17
17
|
} from '../shared.ts';
|
|
18
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
|
-
}
|
|
28
|
-
|
|
29
19
|
export function registerEnvironmentTools(pi: Pi): void {
|
|
30
20
|
pi.registerTool({
|
|
31
21
|
name: 'py_environment',
|
|
@@ -77,7 +67,11 @@ export function registerEnvironmentTools(pi: Pi): void {
|
|
|
77
67
|
confidence: 'medium' as const,
|
|
78
68
|
})),
|
|
79
69
|
projectRoot: environment.projectRoot,
|
|
80
|
-
|
|
70
|
+
toolchain: {
|
|
71
|
+
kind: 'python',
|
|
72
|
+
version: python?.version,
|
|
73
|
+
source: environment.projectRoot ? 'project' : 'path',
|
|
74
|
+
},
|
|
81
75
|
}),
|
|
82
76
|
);
|
|
83
77
|
} catch (error) {
|
|
@@ -185,7 +179,7 @@ export function registerEnvironmentTools(pi: Pi): void {
|
|
|
185
179
|
errors: [],
|
|
186
180
|
suggestions: inspection.suggestions,
|
|
187
181
|
projectRoot: inspection.root,
|
|
188
|
-
|
|
182
|
+
toolchain: { kind: 'python', version: scan.payload.pythonVersion, source: 'project' },
|
|
189
183
|
}),
|
|
190
184
|
);
|
|
191
185
|
} catch (error) {
|
|
@@ -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
|
+
toolchain: { kind: 'python', version: scan.payload.pythonVersion, source: 'project' },
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return text(failure(ctx.cwd, started, messageOf(error), 'INTERNAL_ERROR'));
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -98,6 +98,10 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
98
98
|
? undefined
|
|
99
99
|
: await collectTestImports(root, ctx.cwd, signal);
|
|
100
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;
|
|
101
105
|
// Only files pytest collects tests from become targets; naming
|
|
102
106
|
// `tests/utils.py` as a target overstates the run.
|
|
103
107
|
const targets = selection.selected
|
|
@@ -111,7 +115,7 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
111
115
|
|
|
112
116
|
return text(
|
|
113
117
|
result(ctx.cwd, started, {
|
|
114
|
-
ok: selection.selected.length > 0 || testFiles.length === 0,
|
|
118
|
+
ok: selection.selected.length > 0 || testFiles.length === 0 || !hasPythonChange,
|
|
115
119
|
summary:
|
|
116
120
|
`${selection.selected.length} test file(s) selected from ${testFiles.length} known test file(s) ` +
|
|
117
121
|
`for ${selection.changedSourceFiles.length} changed source file(s) (${changedSource}).` +
|
|
@@ -137,6 +141,16 @@ export function registerTestingTools(pi: Pi): void {
|
|
|
137
141
|
},
|
|
138
142
|
],
|
|
139
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
|
+
: []),
|
|
140
154
|
...(testFiles.length === 0
|
|
141
155
|
? [
|
|
142
156
|
{
|
|
@@ -590,7 +590,11 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
590
590
|
],
|
|
591
591
|
commands,
|
|
592
592
|
projectRoot: root,
|
|
593
|
-
|
|
593
|
+
toolchain: {
|
|
594
|
+
kind: 'python',
|
|
595
|
+
version: scan.payload?.pythonVersion,
|
|
596
|
+
source: 'project',
|
|
597
|
+
},
|
|
594
598
|
}),
|
|
595
599
|
);
|
|
596
600
|
} catch (error) {
|
package/helpers/scan_project.py
CHANGED
|
@@ -37,7 +37,10 @@ from pathlib import Path
|
|
|
37
37
|
# caller can refuse to interpret a document it does not understand.
|
|
38
38
|
# 2: each scanned file reports `importModules`, the full dotted module names it
|
|
39
39
|
# references, so test selection can map a test file to the module it imports.
|
|
40
|
-
|
|
40
|
+
# 3: the manifest reports `[tool.pytest.ini_options]` as `pytestOptions`, and each
|
|
41
|
+
# scanned file reports `asyncTests`/`asyncioMarkedTests`, so a configuration
|
|
42
|
+
# audit can tell an async test that runs from one that is silently skipped.
|
|
43
|
+
SCANNER_VERSION = 3
|
|
41
44
|
|
|
42
45
|
KNOWN_SECTIONS = ("environment", "manifest", "imports")
|
|
43
46
|
EXIT_OK = 0
|
|
@@ -234,6 +237,62 @@ def detect_layout(root: Path) -> tuple[str, list[str]]:
|
|
|
234
237
|
return layout, sorted(set(modules))
|
|
235
238
|
|
|
236
239
|
|
|
240
|
+
def json_safe(value):
|
|
241
|
+
"""Recursively coerce a TOML value into something `json.dumps` accepts.
|
|
242
|
+
|
|
243
|
+
`tomllib` can produce datetimes, which are valid TOML but not JSON; a single
|
|
244
|
+
one would turn the whole document into a scanner crash.
|
|
245
|
+
"""
|
|
246
|
+
if isinstance(value, dict):
|
|
247
|
+
return {str(key): json_safe(item) for key, item in value.items()}
|
|
248
|
+
if isinstance(value, (list, tuple)):
|
|
249
|
+
return [json_safe(item) for item in value]
|
|
250
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
251
|
+
return value
|
|
252
|
+
return str(value)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
ASYNC_MARKERS = {"asyncio", "anyio", "trio"}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _dotted_name(node: ast.AST) -> str:
|
|
259
|
+
"""Rebuild `a.b.c` from an attribute chain, or "" when it is not one."""
|
|
260
|
+
parts = []
|
|
261
|
+
current = node
|
|
262
|
+
while isinstance(current, ast.Attribute):
|
|
263
|
+
parts.append(current.attr)
|
|
264
|
+
current = current.value
|
|
265
|
+
if not isinstance(current, ast.Name):
|
|
266
|
+
return ""
|
|
267
|
+
parts.append(current.id)
|
|
268
|
+
return ".".join(reversed(parts))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def async_test_functions(tree: ast.AST) -> tuple:
|
|
272
|
+
"""Async test functions and the subset that carries an async marker.
|
|
273
|
+
|
|
274
|
+
pytest-asyncio's default `strict` mode runs a coroutine test only when it is
|
|
275
|
+
marked, so an unmarked `async def test_*` is collected and then silently
|
|
276
|
+
skipped. Only the syntax distinguishes the two, so this must be an AST walk
|
|
277
|
+
rather than a text search.
|
|
278
|
+
"""
|
|
279
|
+
async_tests: list = []
|
|
280
|
+
marked: list = []
|
|
281
|
+
for node in ast.walk(tree):
|
|
282
|
+
if not isinstance(node, ast.AsyncFunctionDef):
|
|
283
|
+
continue
|
|
284
|
+
if not node.name.startswith("test"):
|
|
285
|
+
continue
|
|
286
|
+
async_tests.append(node.name)
|
|
287
|
+
for decorator in node.decorator_list:
|
|
288
|
+
target = decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
289
|
+
dotted = _dotted_name(target)
|
|
290
|
+
if dotted.rsplit(".", 1)[-1] in ASYNC_MARKERS and ".mark." in f".{dotted}":
|
|
291
|
+
marked.append(node.name)
|
|
292
|
+
break
|
|
293
|
+
return sorted(async_tests), sorted(marked)
|
|
294
|
+
|
|
295
|
+
|
|
237
296
|
def scan_manifests(root: Path) -> dict:
|
|
238
297
|
result: dict = {
|
|
239
298
|
"pyprojectPath": None,
|
|
@@ -249,6 +308,7 @@ def scan_manifests(root: Path) -> dict:
|
|
|
249
308
|
"buildRequires": [],
|
|
250
309
|
"entryPoints": [],
|
|
251
310
|
"toolConfiguration": {},
|
|
311
|
+
"pytestOptions": None,
|
|
252
312
|
"layout": None,
|
|
253
313
|
"modules": [],
|
|
254
314
|
"legacySetupPy": False,
|
|
@@ -310,6 +370,9 @@ def scan_manifests(root: Path) -> dict:
|
|
|
310
370
|
key: key in tool
|
|
311
371
|
for key in ("ruff", "mypy", "pytest", "coverage", "pyright", "ty", "hatch")
|
|
312
372
|
}
|
|
373
|
+
pytest_table = tool.get("pytest") if isinstance(tool.get("pytest"), dict) else {}
|
|
374
|
+
ini_options = pytest_table.get("ini_options")
|
|
375
|
+
result["pytestOptions"] = json_safe(ini_options) if isinstance(ini_options, dict) else None
|
|
313
376
|
uv_table = tool.get("uv") if isinstance(tool.get("uv"), dict) else {}
|
|
314
377
|
workspace = uv_table.get("workspace") if isinstance(uv_table.get("workspace"), dict) else {}
|
|
315
378
|
members = workspace.get("members")
|
|
@@ -598,12 +661,15 @@ def scan_imports(root: Path, max_files: int) -> dict:
|
|
|
598
661
|
collector.visit(tree)
|
|
599
662
|
names = collector.all
|
|
600
663
|
names.discard("")
|
|
664
|
+
async_tests, asyncio_marked = async_test_functions(tree)
|
|
601
665
|
files.append(
|
|
602
666
|
{
|
|
603
667
|
"path": relative,
|
|
604
668
|
"imports": sorted(names),
|
|
605
669
|
"importModules": sorted(collector.modules),
|
|
606
670
|
"typeCheckingImports": sorted(collector.type_checking),
|
|
671
|
+
"asyncTests": async_tests,
|
|
672
|
+
"asyncioMarkedTests": asyncio_marked,
|
|
607
673
|
}
|
|
608
674
|
)
|
|
609
675
|
for name in names:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-python-helper",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Python (uv) development tools for the pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -69,5 +69,8 @@
|
|
|
69
69
|
"prettier": "^3.9.8",
|
|
70
70
|
"tsx": "^4.23.13",
|
|
71
71
|
"typescript": "^7.0.2"
|
|
72
|
+
},
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"pi-helper-core": "^0.1.1"
|
|
72
75
|
}
|
|
73
76
|
}
|
|
@@ -19,11 +19,12 @@ license: Apache-2.0
|
|
|
19
19
|
4. import 이름과 배포 이름은 다를 수 있습니다(`PIL`/`pillow`, `yaml`/`PyYAML`). `py_dependency_plan`의 제안을 `uv add`로 적용하세요.
|
|
20
20
|
5. 소스 코드를 수정한 후에는 `py_test_select`로 변경 파일과 연관된 테스트를 선별하세요. 테스트가 변경 모듈을 **실제로 import**하면 가장 강한 근거이며, 이름 규약은 그 다음입니다. `narrowed: false`나 `NO_NARROWING`은 "30개 중 30개 선택"처럼 결과가 좁혀지지 않았다는 뜻이고, `SELECTION_WITHOUT_IMPORT_EVIDENCE`는 근거가 파일 이름뿐이라는 뜻이므로 변경이 넓다면 전체 스위트나 `lastFailed=true`를 사용하세요.
|
|
21
21
|
6. 테스트 실행은 `py_test`를 사용하세요. `execute=false`로 먼저 미리보기하고, 실제 실행 시에만 `execute=true`를 전달합니다. 커버리지 플래그나 `-m` 마커 선택처럼 도구가 모델링하지 않는 프로젝트 표준 옵션은 `extraArgs`로 넘기세요.
|
|
22
|
-
7.
|
|
23
|
-
8.
|
|
24
|
-
9.
|
|
25
|
-
10.
|
|
26
|
-
11. `
|
|
22
|
+
7. 테스트가 예상보다 적게 실행되거나 초록색 결과를 믿기 어려울 때는 `py_test_config`로 pytest 설정을 검증하세요. `ASYNC_TESTS_WITHOUT_PLUGIN`/`ASYNC_TESTS_REQUIRE_MARKER`는 async 테스트가 **수집만 되고 실행되지 않는** 상태(플러그인 미선언 또는 `asyncio_mode` 미설정 + 마커 없음)를 뜻하며, `COVERAGE_OPTION_WITHOUT_PLUGIN`과 `ASYNCIO_MODE_WITHOUT_PLUGIN`은 실행 전에 pytest가 오류로 중단되는 설정입니다. `TESTPATH_MISSING`은 `testpaths`가 존재하지 않는 디렉터리를 가리킨다는 경고입니다.
|
|
23
|
+
8. 실패 출력이 있을 때는 `py_failure_diagnose`를 사용하세요. `site-packages` 내부 프레임은 원인이 아니며, 도구는 첫 번째 프로젝트 프레임을 지목합니다. 실행 파일을 찾지 못해 명령이 시작되지 못한 경우(`Failed to spawn`, `command not found`)는 `tool_not_installed`로 분류됩니다.
|
|
24
|
+
9. 의존성이 바뀌었거나 `.venv`가 오래된 경우 `py_sync`로 `uv lock --check` 또는 `uv sync --frozen`을 미리보기/실행하세요. sync는 `[project.optional-dependencies]`의 extra를 함께 요청하므로 dev 도구가 extra로 선언된 프로젝트에서도 삭제되지 않습니다. `SYNC_REMOVED_PACKAGES`가 보이면 그것이 이후 "command not found"의 원인입니다.
|
|
25
|
+
10. 재현이 어려운 실패는 `py_test`의 `lastFailed=true`(`--lf`)로 직전 실패만 다시 실행하세요.
|
|
26
|
+
11. 작업 완료를 보고하기 전에 `py_validation_bundle`(lock 검사 → sync → pytest → 환경 정합성 → 오래된 아티팩트 검사)을 실행하고, `py_completion_evidence`로 근거가 충분한지 확인하세요. 정합성이 `drifted`나 `unverifiable`이면 테스트가 통과했어도 게이트는 실패합니다. 각 응답에서 조치 필요 여부는 `attention`으로 판단하세요.
|
|
27
|
+
12. `py_tdd_checkpoint`로 프로덕션 변경에 대응하는 테스트 변경이 있는지 확인하세요.
|
|
27
28
|
|
|
28
29
|
## 안전 규칙 (Safety)
|
|
29
30
|
|
|
@@ -34,6 +35,8 @@ license: Apache-2.0
|
|
|
34
35
|
|
|
35
36
|
## 해석 규칙 (Interpretation rules)
|
|
36
37
|
|
|
38
|
+
- `ok`는 도구의 **판정**이며 "도구가 실행됐다"는 뜻이 아닙니다. 검사 도구는 문제를 찾으면 도구 자체가 실패하지 않았어도 `ok: false`를 반환합니다. 조치가 필요한지는 `attention`을 읽으세요: `ok: false`이거나 경고·오류가 하나라도 있으면 `true`입니다.
|
|
39
|
+
- `ok: false`에는 항상 그것을 설명하는 진단(`warnings` 또는 `errors`)이 함께 옵니다. 설명 없는 `ok: false`를 보면 도구 결함이므로 그대로 보고하세요. 게이트 도구는 판정을 `data.ok`(`checkpoint.ok`, `evidence.ok`, `summary.ok`)에도 노출합니다.
|
|
37
40
|
- `PROJECT_INSTALLED_NOT_EDITABLE`는 프로젝트가 환경에 live link가 아니라 복사본으로 설치되어, 소스 변경이 테스트에 반영되지 않음을 의미합니다. `uv sync`로 해결하세요.
|
|
38
41
|
- `PROJECT_NOT_INSTALLED`는 lock이 editable 설치를 기대하는데 `.venv`에 프로젝트가 없다는 뜻입니다. `uv sync`를 실행하고, 그래도 실패하면 빌드 백엔드가 패키지를 찾지 못한 것입니다(`[project] name`과 모듈 디렉터리 이름이 일치하는지 확인).
|
|
39
42
|
- `PROJECT_VIRTUAL_SOURCE`는 정상입니다. `[build-system]`이 없으면 uv는 프로젝트를 `virtual` 소스로 기록하고 `.venv`에 설치하지 않습니다. 누락(`PROJECT_NOT_INSTALLED`)으로 취급하지 말고 `[build-system]` 추가 여부만 검토하세요.
|
package/src/build/failure.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Suggestion } from '../core/result.ts';
|
|
2
|
+
import { importAliasCandidates } from '../dependencies/aliases.ts';
|
|
2
3
|
|
|
3
4
|
export type FailureKind =
|
|
4
5
|
| 'tool_not_installed'
|
|
@@ -222,6 +223,24 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
|
|
|
222
223
|
|
|
223
224
|
if (kind === 'module_not_found' && missing) {
|
|
224
225
|
const module = missing[1].split('.')[0];
|
|
226
|
+
// The traceback names an import, not a distribution. `uv add <import name>`
|
|
227
|
+
// installs a different package or nothing at all when the two disagree, so a
|
|
228
|
+
// command is emitted only when the alias table yields exactly one provider.
|
|
229
|
+
const providers = importAliasCandidates(module);
|
|
230
|
+
const resolveSuggestion: Suggestion =
|
|
231
|
+
providers.length === 1
|
|
232
|
+
? {
|
|
233
|
+
message: `Declare the distribution providing "${module}" with uv add ${providers[0]} if it is third-party. Import names often differ from distribution names (PIL/pillow, yaml/PyYAML).`,
|
|
234
|
+
confidence: 'medium',
|
|
235
|
+
command: `uv add ${providers[0]}`,
|
|
236
|
+
}
|
|
237
|
+
: {
|
|
238
|
+
message:
|
|
239
|
+
providers.length > 1
|
|
240
|
+
? `"${module}" is provided by more than one distribution (${providers.join(', ')}). Verify which one the project needs before adding it.`
|
|
241
|
+
: `The distribution providing "${module}" is unknown. Verify the distribution name before adding it; import names often differ from distribution names (PIL/pillow, yaml/PyYAML).`,
|
|
242
|
+
confidence: 'medium',
|
|
243
|
+
};
|
|
225
244
|
return {
|
|
226
245
|
kind: 'module_not_found',
|
|
227
246
|
summary: `Import failed because the module "${module}" could not be found.`,
|
|
@@ -231,11 +250,7 @@ export function diagnoseFailure(output: string): FailureDiagnosis {
|
|
|
231
250
|
firstUserFrame: userFrame,
|
|
232
251
|
evidence: [{ message: missing[0].trim(), file: userFrame?.path, line: userFrame?.line }],
|
|
233
252
|
suggestions: [
|
|
234
|
-
|
|
235
|
-
message: `Declare the distribution that provides "${module}" with uv add ${module} if it is third-party. Import names often differ from distribution names (PIL/pillow, yaml/PyYAML).`,
|
|
236
|
-
confidence: 'medium',
|
|
237
|
-
command: `uv add ${module}`,
|
|
238
|
-
},
|
|
253
|
+
resolveSuggestion,
|
|
239
254
|
{
|
|
240
255
|
message:
|
|
241
256
|
'If the module is project code, run uv sync so the project package is installed in editable mode.',
|
|
@@ -404,7 +419,6 @@ export function refineWithDeclarations(
|
|
|
404
419
|
const module = diagnosis.missingModule;
|
|
405
420
|
if (!module) return diagnosis;
|
|
406
421
|
const normalized = module.replace(/[-_.]+/g, '-').toLowerCase();
|
|
407
|
-
const suggestions: Suggestion[] = [];
|
|
408
422
|
|
|
409
423
|
if (input.localModules.has(module)) {
|
|
410
424
|
return {
|
|
@@ -447,14 +461,9 @@ export function refineWithDeclarations(
|
|
|
447
461
|
};
|
|
448
462
|
}
|
|
449
463
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
suggestions.push({
|
|
456
|
-
message: 'Import names can differ from distribution names (PIL/pillow, yaml/PyYAML).',
|
|
457
|
-
confidence: 'medium',
|
|
458
|
-
});
|
|
459
|
-
return { ...diagnosis, suggestions: [...diagnosis.suggestions, ...suggestions] };
|
|
464
|
+
// The module is neither project code nor declared, so this refinement adds no
|
|
465
|
+
// new information: `diagnoseFailure` already reported the unknown provider.
|
|
466
|
+
// Appending here duplicated those suggestions, so the diagnosis is returned
|
|
467
|
+
// unchanged.
|
|
468
|
+
return diagnosis;
|
|
460
469
|
}
|