pi-python-helper 0.2.0 → 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.
- package/CHANGELOG.md +18 -0
- package/docs/tools.md +22 -0
- package/extensions/index.ts +2 -0
- package/extensions/shared.ts +13 -1
- package/extensions/tools/environment.ts +1 -11
- package/extensions/tools/test-config.ts +148 -0
- package/extensions/tools/testing.ts +15 -1
- package/helpers/scan_project.py +67 -1
- package/package.json +1 -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/core/result.ts +28 -1
- package/src/dependencies/aliases.ts +21 -0
- package/src/project/scanner.ts +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ does not guarantee a stable public tool schema.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `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.
|
|
15
|
+
- The scanner reports `[tool.pytest.ini_options]` as `manifest.pytestOptions` and, per scanned file, `asyncTests` and `asyncioMarkedTests` (protocol version 3).
|
|
16
|
+
- 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`.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Breaking:** scanner protocol `SCANNER_VERSION` is now 3. A scanner reporting version 2 is rejected with `SCANNER_VERSION_MISMATCH`.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- `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.
|
|
25
|
+
- `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.
|
|
26
|
+
- `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.
|
|
27
|
+
|
|
10
28
|
## [0.2.0] - 2026-09-22
|
|
11
29
|
|
|
12
30
|
### 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
|
|
@@ -466,6 +467,27 @@ Preview or run pytest through uv run --frozen and summarise failures by test, fi
|
|
|
466
467
|
- `risk`: string
|
|
467
468
|
- `executed`: boolean
|
|
468
469
|
|
|
470
|
+
### `py_test_config`
|
|
471
|
+
|
|
472
|
+
Audit pytest configuration against the declared plugins and the tests on disk, and report options that make tests pass without running. Read-only.
|
|
473
|
+
|
|
474
|
+
- 시스템 프롬프트 한 줄: `Validate pytest configuration and detect tests that never run`
|
|
475
|
+
- 라벨: Python Test Config
|
|
476
|
+
- 프로젝트 상태 변경: 없음 (읽기 전용)
|
|
477
|
+
|
|
478
|
+
**파라미터**
|
|
479
|
+
|
|
480
|
+
| 파라미터 | 타입 | 필수 | 설명 |
|
|
481
|
+
|---|---|---|---|
|
|
482
|
+
| `path` | `string` | 아니오 | Project directory to audit; defaults to the project root. |
|
|
483
|
+
|
|
484
|
+
**프롬프트 가이드라인**
|
|
485
|
+
|
|
486
|
+
- 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.
|
|
487
|
+
- Use py_test_config after changing pyproject.toml, pytest.ini, or the test layout to confirm the configuration still matches the project.
|
|
488
|
+
|
|
489
|
+
**반환 `data` 형태**: 캡처되지 않음
|
|
490
|
+
|
|
469
491
|
### `py_test_select`
|
|
470
492
|
|
|
471
493
|
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
|
+
}
|
|
@@ -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',
|
|
@@ -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
|
+
}
|
|
@@ -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
|
{
|
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
|
@@ -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
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { normalizeName } from '../dependencies/plan.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The pytest options this audit reasons about. Only options whose absence
|
|
5
|
+
* changes whether tests *run* are read, so an unknown option can never be
|
|
6
|
+
* misreported: pytest silently ignores a key it does not know, and so does this.
|
|
7
|
+
*/
|
|
8
|
+
export interface PytestOptions {
|
|
9
|
+
asyncioMode?: string;
|
|
10
|
+
addopts?: string;
|
|
11
|
+
testpaths: string[];
|
|
12
|
+
markers: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PytestConfigResolution {
|
|
16
|
+
/** The configuration file pytest will actually use, when one exists. */
|
|
17
|
+
sources: string[];
|
|
18
|
+
options: PytestOptions;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* pytest uses the first configuration file it finds, in this order, and ignores
|
|
23
|
+
* the rest. Merging them would invent options the run never sees.
|
|
24
|
+
*/
|
|
25
|
+
export const PYTEST_CONFIG_PRECEDENCE = [
|
|
26
|
+
'pytest.ini',
|
|
27
|
+
'pyproject.toml',
|
|
28
|
+
'tox.ini',
|
|
29
|
+
'setup.cfg',
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
const INI_SECTION_RE = /^\s*\[(?:tool:)?pytest\]\s*$/m;
|
|
33
|
+
|
|
34
|
+
function emptyOptions(): PytestOptions {
|
|
35
|
+
return { testpaths: [], markers: [] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stringOption(value: unknown): string | undefined {
|
|
39
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stringList(value: unknown): string[] {
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
return value.filter((entry): entry is string => typeof entry === 'string');
|
|
45
|
+
}
|
|
46
|
+
if (typeof value === 'string') {
|
|
47
|
+
return value.split(/\s+/).filter((entry) => entry.length > 0);
|
|
48
|
+
}
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the `[pytest]` / `[tool:pytest]` section of one INI file.
|
|
54
|
+
*
|
|
55
|
+
* `undefined` means "this file does not configure pytest". A file that has the
|
|
56
|
+
* section but none of the options still counts as configured, so the caller can
|
|
57
|
+
* distinguish "no configuration" from "configuration with defaults".
|
|
58
|
+
*/
|
|
59
|
+
export function parseIniPytestOptions(content: string | undefined): PytestOptions | undefined {
|
|
60
|
+
if (content === undefined) return undefined;
|
|
61
|
+
const header = INI_SECTION_RE.exec(content);
|
|
62
|
+
if (!header) return undefined;
|
|
63
|
+
const rest = content.slice(header.index + header[0].length);
|
|
64
|
+
const nextSection = rest.search(/^\s*\[/m);
|
|
65
|
+
const body = nextSection === -1 ? rest : rest.slice(0, nextSection);
|
|
66
|
+
const read = (key: string): string | undefined => {
|
|
67
|
+
const found = new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*(.*)$`, 'm').exec(body);
|
|
68
|
+
const raw = found?.[1]?.trim();
|
|
69
|
+
return raw ? raw : undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const asyncioMode = read('asyncio_mode');
|
|
73
|
+
const addopts = read('addopts');
|
|
74
|
+
const testpaths = read('testpaths');
|
|
75
|
+
return {
|
|
76
|
+
...(asyncioMode ? { asyncioMode } : {}),
|
|
77
|
+
...(addopts ? { addopts } : {}),
|
|
78
|
+
testpaths: testpaths ? testpaths.split(/\s+/).filter((entry) => entry.length > 0) : [],
|
|
79
|
+
markers: [],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the options pytest will use, honouring its first-file-wins rule.
|
|
85
|
+
*
|
|
86
|
+
* `pytest.ini` counts when it merely exists: unlike `tox.ini`/`setup.cfg` it has
|
|
87
|
+
* no other purpose, so an empty file still sets the rootdir configuration.
|
|
88
|
+
*/
|
|
89
|
+
export function resolvePytestOptions(input: {
|
|
90
|
+
pyprojectOptions?: Record<string, unknown> | null;
|
|
91
|
+
iniFiles: Record<string, string | undefined>;
|
|
92
|
+
}): PytestConfigResolution {
|
|
93
|
+
const raw = input.pyprojectOptions;
|
|
94
|
+
const fromPyproject: PytestOptions | undefined = raw
|
|
95
|
+
? {
|
|
96
|
+
...(stringOption(raw['asyncio_mode'])
|
|
97
|
+
? { asyncioMode: stringOption(raw['asyncio_mode']) }
|
|
98
|
+
: {}),
|
|
99
|
+
...(stringOption(raw['addopts']) ? { addopts: stringOption(raw['addopts']) } : {}),
|
|
100
|
+
testpaths: stringList(raw['testpaths']),
|
|
101
|
+
markers: stringList(raw['markers']),
|
|
102
|
+
}
|
|
103
|
+
: undefined;
|
|
104
|
+
|
|
105
|
+
const pytestIni = input.iniFiles['pytest.ini'];
|
|
106
|
+
const candidates: Record<string, PytestOptions | undefined> = {
|
|
107
|
+
'pytest.ini':
|
|
108
|
+
pytestIni === undefined ? undefined : (parseIniPytestOptions(pytestIni) ?? emptyOptions()),
|
|
109
|
+
'pyproject.toml': fromPyproject,
|
|
110
|
+
'tox.ini': parseIniPytestOptions(input.iniFiles['tox.ini']),
|
|
111
|
+
'setup.cfg': parseIniPytestOptions(input.iniFiles['setup.cfg']),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
for (const name of PYTEST_CONFIG_PRECEDENCE) {
|
|
115
|
+
const options = candidates[name];
|
|
116
|
+
if (options) return { sources: [name], options };
|
|
117
|
+
}
|
|
118
|
+
return { sources: [], options: emptyOptions() };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface AsyncTestFile {
|
|
122
|
+
path: string;
|
|
123
|
+
/** Async test names in this file that carry no async plugin marker. */
|
|
124
|
+
tests: string[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface PytestAuditInput {
|
|
128
|
+
sources: string[];
|
|
129
|
+
options: PytestOptions;
|
|
130
|
+
declared: Set<string>;
|
|
131
|
+
/** Async tests that no marker covers, per file. */
|
|
132
|
+
unmarkedAsyncTests: AsyncTestFile[];
|
|
133
|
+
/** Configured `testpaths` entries that do not exist on disk. */
|
|
134
|
+
missingTestPaths: string[];
|
|
135
|
+
hasTestFiles: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface PytestFinding {
|
|
139
|
+
code: string;
|
|
140
|
+
severity: 'info' | 'warning' | 'error';
|
|
141
|
+
message: string;
|
|
142
|
+
suggestion?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Plugins that make pytest run a coroutine test function at all. */
|
|
146
|
+
const ASYNC_PLUGINS = ['pytest-asyncio', 'pytest-anyio', 'anyio', 'pytest-trio'];
|
|
147
|
+
|
|
148
|
+
function declares(declared: Set<string>, names: string[]): boolean {
|
|
149
|
+
return names.some((name) => declared.has(normalizeName(name)));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Report the pytest configuration problems that make tests pass without running.
|
|
154
|
+
*
|
|
155
|
+
* Only two things are asserted: coroutine tests that no plugin and no marker
|
|
156
|
+
* will execute, and options that point at a plugin the project does not declare.
|
|
157
|
+
* Anything less certain is reported as `info` so a false alarm cannot erode the
|
|
158
|
+
* tool's credibility.
|
|
159
|
+
*/
|
|
160
|
+
export function auditPytestConfiguration(input: PytestAuditInput): PytestFinding[] {
|
|
161
|
+
const findings: PytestFinding[] = [];
|
|
162
|
+
const { options, declared } = input;
|
|
163
|
+
const unmarkedFiles = input.unmarkedAsyncTests.filter((entry) => entry.tests.length > 0);
|
|
164
|
+
const unmarkedCount = unmarkedFiles.reduce((total, entry) => total + entry.tests.length, 0);
|
|
165
|
+
const asyncPluginDeclared = declares(declared, ASYNC_PLUGINS);
|
|
166
|
+
const mode = (options.asyncioMode ?? '').trim().toLowerCase();
|
|
167
|
+
|
|
168
|
+
if (unmarkedCount > 0 && !asyncPluginDeclared) {
|
|
169
|
+
findings.push({
|
|
170
|
+
code: 'ASYNC_TESTS_WITHOUT_PLUGIN',
|
|
171
|
+
severity: 'error',
|
|
172
|
+
message: `${unmarkedCount} async test function(s) in ${unmarkedFiles.length} file(s) will not run: no pytest async plugin is declared.`,
|
|
173
|
+
suggestion:
|
|
174
|
+
'Declare pytest-asyncio with uv add --dev pytest-asyncio and mark the tests, or add pytest-asyncio and set asyncio_mode = "auto".',
|
|
175
|
+
});
|
|
176
|
+
} else if (unmarkedCount > 0 && mode !== 'auto') {
|
|
177
|
+
findings.push({
|
|
178
|
+
code: 'ASYNC_TESTS_REQUIRE_MARKER',
|
|
179
|
+
severity: 'error',
|
|
180
|
+
message: `${unmarkedCount} async test function(s) in ${unmarkedFiles.length} file(s) carry no async marker and asyncio_mode is not "auto", so pytest-asyncio's strict default will skip them.`,
|
|
181
|
+
suggestion:
|
|
182
|
+
'Set asyncio_mode = "auto" in [tool.pytest.ini_options], or add @pytest.mark.asyncio to each async test.',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (mode.length > 0 && !declares(declared, ['pytest-asyncio'])) {
|
|
187
|
+
findings.push({
|
|
188
|
+
code: 'ASYNCIO_MODE_WITHOUT_PLUGIN',
|
|
189
|
+
severity: 'error',
|
|
190
|
+
message: `asyncio_mode is set to "${options.asyncioMode}" but pytest-asyncio is not declared, so pytest errors with an unknown option before collecting anything.`,
|
|
191
|
+
suggestion:
|
|
192
|
+
'Declare pytest-asyncio with uv add --dev pytest-asyncio, or remove asyncio_mode from the pytest configuration.',
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const addopts = options.addopts ?? '';
|
|
197
|
+
if (/(^|\s)--cov(=|\s|$)/.test(addopts) && !declares(declared, ['pytest-cov'])) {
|
|
198
|
+
findings.push({
|
|
199
|
+
code: 'COVERAGE_OPTION_WITHOUT_PLUGIN',
|
|
200
|
+
severity: 'error',
|
|
201
|
+
message:
|
|
202
|
+
'addopts passes --cov but pytest-cov is not declared, so every run fails with an unrecognized argument.',
|
|
203
|
+
suggestion: 'Declare pytest-cov with uv add --dev pytest-cov, or remove --cov from addopts.',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const path of input.missingTestPaths) {
|
|
208
|
+
findings.push({
|
|
209
|
+
code: 'TESTPATH_MISSING',
|
|
210
|
+
severity: 'warning',
|
|
211
|
+
message: `testpaths lists "${path}", which does not exist, so a bare pytest run collects nothing from it.`,
|
|
212
|
+
suggestion: `Create ${path} or correct testpaths in ${input.sources[0] ?? 'the pytest configuration'}.`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (input.sources.length === 0 && input.hasTestFiles) {
|
|
217
|
+
findings.push({
|
|
218
|
+
code: 'PYTEST_NOT_CONFIGURED',
|
|
219
|
+
severity: 'info',
|
|
220
|
+
message:
|
|
221
|
+
'No pytest configuration was found: pytest runs with defaults, so testpaths and plugin options are not pinned anywhere.',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return findings;
|
|
226
|
+
}
|
package/src/core/result.ts
CHANGED
|
@@ -45,7 +45,23 @@ export interface ToolMetadata {
|
|
|
45
45
|
* `data`; everything the agent must reason about lives in the typed sections.
|
|
46
46
|
*/
|
|
47
47
|
export interface PyToolResult<T = unknown> {
|
|
48
|
+
/**
|
|
49
|
+
* The tool's own verdict, not "the tool ran". `true` means the question this
|
|
50
|
+
* tool asks was answered affirmatively: the project state is acceptable, the
|
|
51
|
+
* command succeeded, or the gate may proceed. A diagnostic tool that finds a
|
|
52
|
+
* problem therefore returns `ok: false` without the tool itself having
|
|
53
|
+
* failed. Read `attention` for "must the caller act".
|
|
54
|
+
*/
|
|
48
55
|
ok: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* `true` when the caller must act before proceeding: the tool failed, or it
|
|
58
|
+
* emitted a warning or an error. An `info` diagnostic is informational by
|
|
59
|
+
* definition and does not set this. Derived from `ok`, `warnings`, and
|
|
60
|
+
* `errors` unless a tool sets it explicitly, so `ok: false` always implies
|
|
61
|
+
* `attention: true` and no diagnostic is silently dropped. This is the field
|
|
62
|
+
* to read when the question is "do I need to do something".
|
|
63
|
+
*/
|
|
64
|
+
attention: boolean;
|
|
49
65
|
summary: string;
|
|
50
66
|
data?: T;
|
|
51
67
|
evidence: Evidence[];
|
|
@@ -56,10 +72,20 @@ export interface PyToolResult<T = unknown> {
|
|
|
56
72
|
metadata: ToolMetadata;
|
|
57
73
|
}
|
|
58
74
|
|
|
75
|
+
/**
|
|
76
|
+
* An `info` diagnostic records a fact; only a warning or an error asks the
|
|
77
|
+
* caller to do something. Keeping them apart stops a purely informational note
|
|
78
|
+
* from raising `attention`.
|
|
79
|
+
*/
|
|
80
|
+
function isActionable(value: { warnings: Diagnostic[]; errors: Diagnostic[] }): boolean {
|
|
81
|
+
return [...value.warnings, ...value.errors].some((entry) => entry.severity !== 'info');
|
|
82
|
+
}
|
|
83
|
+
|
|
59
84
|
export function result<T>(
|
|
60
85
|
cwd: string,
|
|
61
86
|
startedAt: number,
|
|
62
|
-
value: Omit<PyToolResult<T>, 'metadata'> & {
|
|
87
|
+
value: Omit<PyToolResult<T>, 'metadata' | 'attention'> & {
|
|
88
|
+
attention?: boolean;
|
|
63
89
|
truncated?: boolean;
|
|
64
90
|
projectRoot?: string;
|
|
65
91
|
pythonVersion?: string;
|
|
@@ -67,6 +93,7 @@ export function result<T>(
|
|
|
67
93
|
): PyToolResult<T> {
|
|
68
94
|
return {
|
|
69
95
|
...value,
|
|
96
|
+
attention: value.attention ?? (!value.ok || isActionable(value)),
|
|
70
97
|
metadata: {
|
|
71
98
|
toolVersion: TOOL_VERSION,
|
|
72
99
|
cwd,
|
|
@@ -65,6 +65,27 @@ export const IMPORT_ALIASES: Record<string, string[]> = {
|
|
|
65
65
|
tqdm: ['tqdm'],
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
const NORMALIZE_RE = /[-_.]+/g;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Distribution candidates for an import name, from the static alias table only.
|
|
72
|
+
*
|
|
73
|
+
* Used where the scanner cannot supply the authoritative provider mapping (a
|
|
74
|
+
* traceback names an import, not an installed distribution). Ordering is
|
|
75
|
+
* preserved so a caller can name every candidate when an import maps to more
|
|
76
|
+
* than one distribution: `cv2` is either `opencv-python` or
|
|
77
|
+
* `opencv-python-headless`, and picking one silently would install the wrong
|
|
78
|
+
* variant. An empty result means the provider is unknown, and no `uv add`
|
|
79
|
+
* command may be fabricated from the import name.
|
|
80
|
+
*/
|
|
81
|
+
export function importAliasCandidates(importName: string): string[] {
|
|
82
|
+
const normalized = importName.replace(NORMALIZE_RE, '-').trim().toLowerCase();
|
|
83
|
+
const candidates = new Set<string>();
|
|
84
|
+
for (const alias of IMPORT_ALIASES[importName] ?? []) candidates.add(alias);
|
|
85
|
+
for (const alias of IMPORT_ALIASES[normalized] ?? []) candidates.add(alias);
|
|
86
|
+
return [...candidates];
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
/** Distributions that are normally invoked as a console script, not imported. */
|
|
69
90
|
export const CONSOLE_ONLY: Set<string> = new Set([
|
|
70
91
|
'ruff',
|
package/src/project/scanner.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type ScanMode =
|
|
|
14
14
|
| 'manifest,imports';
|
|
15
15
|
|
|
16
16
|
/** Bumped by the scanner when the request or result document changes shape. */
|
|
17
|
-
export const SUPPORTED_SCANNER_VERSION =
|
|
17
|
+
export const SUPPORTED_SCANNER_VERSION = 3;
|
|
18
18
|
|
|
19
19
|
export interface DeclaredDependency {
|
|
20
20
|
raw: string;
|
|
@@ -40,6 +40,12 @@ export interface ManifestSection {
|
|
|
40
40
|
buildRequires: string[];
|
|
41
41
|
entryPoints: string[];
|
|
42
42
|
toolConfiguration: Record<string, boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* `[tool.pytest.ini_options]` as written, or null when the table is absent.
|
|
45
|
+
* Left untyped because pytest accepts options this package does not model;
|
|
46
|
+
* only the audited keys are read.
|
|
47
|
+
*/
|
|
48
|
+
pytestOptions?: Record<string, unknown> | null;
|
|
43
49
|
layout: 'src' | 'flat';
|
|
44
50
|
modules: string[];
|
|
45
51
|
legacySetupPy: boolean;
|
|
@@ -98,6 +104,10 @@ export interface ImportSection {
|
|
|
98
104
|
*/
|
|
99
105
|
importModules?: string[];
|
|
100
106
|
typeCheckingImports: string[];
|
|
107
|
+
/** `async def test_*` names in this file. */
|
|
108
|
+
asyncTests?: string[];
|
|
109
|
+
/** The subset of `asyncTests` that carries an async plugin marker. */
|
|
110
|
+
asyncioMarkedTests?: string[];
|
|
101
111
|
}[];
|
|
102
112
|
thirdParty: {
|
|
103
113
|
import: string;
|