pi-python-helper 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +85 -125
- package/docs/compatibility.md +17 -1
- package/docs/tools.md +25 -2
- package/extensions/shared.ts +24 -0
- package/extensions/tools/environment.ts +30 -5
- package/extensions/tools/testing.ts +80 -8
- package/extensions/tools/validation.ts +310 -46
- package/helpers/scan_project.py +15 -1
- package/package.json +1 -1
- package/skills/python-development/SKILL.md +11 -6
- package/src/build/commands.ts +17 -2
- package/src/build/failure.ts +56 -0
- package/src/build/quality.ts +49 -0
- package/src/build/selection.ts +176 -9
- package/src/build/sync.ts +70 -0
- package/src/dependencies/plan.ts +58 -9
- package/src/environment/discovery.ts +30 -3
- package/src/environment/tools.ts +41 -2
- package/src/project/conformance.ts +101 -25
- package/src/project/inspect.ts +45 -6
- package/src/project/paths.ts +15 -0
- package/src/project/pytest-config.ts +45 -0
- package/src/project/root.ts +85 -1
- package/src/project/scanner.ts +61 -9
- package/src/validation/bundle.ts +41 -4
- package/src/validation/tdd.ts +94 -21
|
@@ -4,13 +4,19 @@ import { findProjectRoot, findVenvDir, isFile } from '../project/root.ts';
|
|
|
4
4
|
import {
|
|
5
5
|
type EnvironmentSection,
|
|
6
6
|
type LockPackage,
|
|
7
|
-
|
|
7
|
+
resolveProjectInterpreter,
|
|
8
8
|
runScanProject,
|
|
9
9
|
} from '../project/scanner.ts';
|
|
10
10
|
import { inspectTools, type ToolAvailability } from './tools.ts';
|
|
11
11
|
|
|
12
12
|
export interface PythonEnvironment {
|
|
13
13
|
interpreter?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Whether the analysed interpreter belongs to the project environment or was
|
|
16
|
+
* taken from PATH. Analysis facts such as `site-packages` contents and the
|
|
17
|
+
* reported version are only the project's when this is `venv`.
|
|
18
|
+
*/
|
|
19
|
+
interpreterOrigin?: 'venv' | 'path';
|
|
14
20
|
python?: EnvironmentSection;
|
|
15
21
|
projectRoot?: string;
|
|
16
22
|
venvDir?: string;
|
|
@@ -57,12 +63,13 @@ export async function detectPythonEnvironment(
|
|
|
57
63
|
): Promise<PythonEnvironment> {
|
|
58
64
|
const warnings: Diagnostic[] = [];
|
|
59
65
|
const suggestions: string[] = [];
|
|
60
|
-
const interpreter = await resolveInterpreter(cwd, signal);
|
|
61
66
|
const projectRoot = await findProjectRoot(cwd);
|
|
62
67
|
const scanRoot = projectRoot ?? cwd;
|
|
63
68
|
|
|
64
69
|
let python: EnvironmentSection | undefined;
|
|
65
70
|
let lockPackages: LockPackage[] = [];
|
|
71
|
+
const resolved = await resolveProjectInterpreter(scanRoot, cwd, signal);
|
|
72
|
+
const interpreter = resolved.interpreter;
|
|
66
73
|
if (interpreter) {
|
|
67
74
|
const scan = await runScanProject(
|
|
68
75
|
cwd,
|
|
@@ -79,7 +86,7 @@ export async function detectPythonEnvironment(
|
|
|
79
86
|
warnings.push(
|
|
80
87
|
warn(
|
|
81
88
|
'PYTHON_NOT_FOUND',
|
|
82
|
-
'No Python 3 interpreter was found on PATH; every analysis tool degrades to static inspection only.',
|
|
89
|
+
'No Python 3 interpreter was found in the project environment or on PATH; every analysis tool degrades to static inspection only.',
|
|
83
90
|
),
|
|
84
91
|
);
|
|
85
92
|
suggestions.push('Install Python 3.11 or newer so pyproject.toml and uv.lock can be parsed.');
|
|
@@ -101,6 +108,25 @@ export async function detectPythonEnvironment(
|
|
|
101
108
|
const lockPresent = projectRoot ? await isFile(`${projectRoot}/uv.lock`) : false;
|
|
102
109
|
const tools = await inspectTools({ venvDir, lockPackages });
|
|
103
110
|
|
|
111
|
+
// A distribution recorded in the lockfile is installable, not installed. When
|
|
112
|
+
// the project environment exists but a declared tool has no console script,
|
|
113
|
+
// the environment was synced without it and every later step that needs it
|
|
114
|
+
// will fail, so this is reported rather than left to be discovered later.
|
|
115
|
+
const missingDeclaredTools = venvDir ? tools.filter((tool) => tool.installable) : [];
|
|
116
|
+
if (missingDeclaredTools.length > 0) {
|
|
117
|
+
const names = missingDeclaredTools.map((tool) => tool.name).join(', ');
|
|
118
|
+
warnings.push(
|
|
119
|
+
warn(
|
|
120
|
+
'TOOL_NOT_INSTALLED',
|
|
121
|
+
`${names} ${missingDeclaredTools.length === 1 ? 'is' : 'are'} recorded in uv.lock but has no executable in .venv, so it cannot be run right now.`,
|
|
122
|
+
venvDir,
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
suggestions.push(
|
|
126
|
+
'Run uv sync --frozen --all-groups --all-extras: a plain uv sync removes extras declared in [project.optional-dependencies].',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
104
130
|
const uvVersion = await toolVersion(cwd, 'uv', signal);
|
|
105
131
|
if (!uvVersion) {
|
|
106
132
|
warnings.push(
|
|
@@ -155,6 +181,7 @@ export async function detectPythonEnvironment(
|
|
|
155
181
|
|
|
156
182
|
return {
|
|
157
183
|
interpreter,
|
|
184
|
+
interpreterOrigin: resolved.origin,
|
|
158
185
|
python,
|
|
159
186
|
projectRoot,
|
|
160
187
|
venvDir,
|
package/src/environment/tools.ts
CHANGED
|
@@ -22,10 +22,20 @@ export type ToolVersionSource = 'lock' | 'cli' | 'unknown';
|
|
|
22
22
|
|
|
23
23
|
export interface ToolAvailability {
|
|
24
24
|
name: string;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* True only when a runnable executable was found right now.
|
|
27
|
+
*
|
|
28
|
+
* A distribution recorded in `uv.lock` is *installable*, not available: the
|
|
29
|
+
* environment may have had it removed. Reporting it as available made a
|
|
30
|
+
* broken virtual environment look healthy.
|
|
31
|
+
*/
|
|
26
32
|
available: boolean;
|
|
27
33
|
/** True when `uv.lock` records the distribution, so `uv sync` can install it. */
|
|
28
34
|
declared: boolean;
|
|
35
|
+
/** True when `uv sync` would provide the tool that is not runnable yet. */
|
|
36
|
+
installable: boolean;
|
|
37
|
+
/** True when the console script was found inside the project environment. */
|
|
38
|
+
installed: boolean;
|
|
29
39
|
/** Absolute path when an executable was found. */
|
|
30
40
|
executable?: string;
|
|
31
41
|
/** Where the executable was found: the project environment or the host PATH. */
|
|
@@ -128,8 +138,10 @@ export async function inspectTools(input: ToolProbeInput = {}): Promise<ToolAvai
|
|
|
128
138
|
|
|
129
139
|
return {
|
|
130
140
|
name,
|
|
131
|
-
available: Boolean(executable)
|
|
141
|
+
available: Boolean(executable),
|
|
132
142
|
declared: lockVersion !== undefined,
|
|
143
|
+
installable: lockVersion !== undefined && !executable,
|
|
144
|
+
installed: venvScript !== undefined,
|
|
133
145
|
executable,
|
|
134
146
|
origin,
|
|
135
147
|
version: lockVersion,
|
|
@@ -139,3 +151,30 @@ export async function inspectTools(input: ToolProbeInput = {}): Promise<ToolAvai
|
|
|
139
151
|
}),
|
|
140
152
|
);
|
|
141
153
|
}
|
|
154
|
+
|
|
155
|
+
export interface RequiredToolCheck {
|
|
156
|
+
/** False when there was no project environment to inspect at all. */
|
|
157
|
+
checked: boolean;
|
|
158
|
+
venvDir?: string;
|
|
159
|
+
/** Names with no console script in the project environment. */
|
|
160
|
+
missing: string[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Confirm that the tools a later step depends on are runnable *now*.
|
|
165
|
+
*
|
|
166
|
+
* `uv sync` can legitimately finish with exit code 0 while removing the very
|
|
167
|
+
* tools the next step needs, so the environment is re-checked between the two
|
|
168
|
+
* instead of trusting the exit code.
|
|
169
|
+
*/
|
|
170
|
+
export async function checkRequiredTools(
|
|
171
|
+
venvDir: string | undefined,
|
|
172
|
+
names: readonly string[],
|
|
173
|
+
): Promise<RequiredToolCheck> {
|
|
174
|
+
if (!venvDir) return { checked: false, missing: [] };
|
|
175
|
+
const missing: string[] = [];
|
|
176
|
+
for (const name of names) {
|
|
177
|
+
if (!(await findVenvScript(venvDir, name))) missing.push(name);
|
|
178
|
+
}
|
|
179
|
+
return { checked: true, venvDir, missing };
|
|
180
|
+
}
|
|
@@ -50,6 +50,12 @@ export interface ConformanceReport {
|
|
|
50
50
|
installedScanned: boolean;
|
|
51
51
|
projectInstalled: boolean | null;
|
|
52
52
|
projectEditable: boolean | null;
|
|
53
|
+
/**
|
|
54
|
+
* False when the root project is a `virtual` lock source, which uv creates
|
|
55
|
+
* for a project without a `[build-system]` table. Such a project is never
|
|
56
|
+
* installed into `.venv`, so its absence is expected rather than drift.
|
|
57
|
+
*/
|
|
58
|
+
projectInstallable: boolean | null;
|
|
53
59
|
};
|
|
54
60
|
counts: {
|
|
55
61
|
lockPackages: number;
|
|
@@ -59,6 +65,11 @@ export interface ConformanceReport {
|
|
|
59
65
|
/** Locked entries that are conditional for this platform and correctly absent. */
|
|
60
66
|
conditional: number;
|
|
61
67
|
untracked: number;
|
|
68
|
+
/**
|
|
69
|
+
* Normalized names that uv.lock pins more than once (a marker split, such as
|
|
70
|
+
* `argon2-cffi-bindings` at 21.2.0 for Python 3.14 and 25.1.0 below it).
|
|
71
|
+
*/
|
|
72
|
+
markerSplitNames: number;
|
|
62
73
|
};
|
|
63
74
|
findings: ConformanceFinding[];
|
|
64
75
|
warnings: Diagnostic[];
|
|
@@ -147,6 +158,7 @@ export function compareInstalledConformance(input: ConformanceInput): Conformanc
|
|
|
147
158
|
installedScanned: venvPresent,
|
|
148
159
|
projectInstalled: null as boolean | null,
|
|
149
160
|
projectEditable: null as boolean | null,
|
|
161
|
+
projectInstallable: null as boolean | null,
|
|
150
162
|
};
|
|
151
163
|
const counts = {
|
|
152
164
|
lockPackages: lock?.packages.length ?? 0,
|
|
@@ -155,6 +167,7 @@ export function compareInstalledConformance(input: ConformanceInput): Conformanc
|
|
|
155
167
|
missing: 0,
|
|
156
168
|
conditional: 0,
|
|
157
169
|
untracked: 0,
|
|
170
|
+
markerSplitNames: 0,
|
|
158
171
|
};
|
|
159
172
|
|
|
160
173
|
for (const message of installed?.warnings ?? []) {
|
|
@@ -189,51 +202,83 @@ export function compareInstalledConformance(input: ConformanceInput): Conformanc
|
|
|
189
202
|
const lockByNormalized = new Map(lock.packages.map((entry) => [entry.normalized, entry]));
|
|
190
203
|
const required = requiredInstalledNames(lock, new Set(installedByNormalized.keys()), input);
|
|
191
204
|
const conditionalAbsent: string[] = [];
|
|
205
|
+
const markerSplitNames: string[] = [];
|
|
206
|
+
const virtualRoots: string[] = [];
|
|
192
207
|
|
|
208
|
+
// uv writes one lock entry per marker branch, so a name can appear several
|
|
209
|
+
// times with different versions. Comparing a single arbitrary entry reported a
|
|
210
|
+
// version mismatch on a correctly synced environment (21.2.0 is locked for
|
|
211
|
+
// Python 3.14 while a 3.12 environment rightly has 25.1.0).
|
|
212
|
+
const variantsByNormalized = new Map<string, LockPackage[]>();
|
|
193
213
|
for (const entry of lock.packages) {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
214
|
+
const variants = variantsByNormalized.get(entry.normalized);
|
|
215
|
+
if (variants) variants.push(entry);
|
|
216
|
+
else variantsByNormalized.set(entry.normalized, [entry]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const [normalized, variants] of variantsByNormalized) {
|
|
220
|
+
const primary = variants[0];
|
|
221
|
+
const actual = installedByNormalized.get(normalized);
|
|
222
|
+
const localProject = variants.some((variant) => isLocalProjectEntry(variant));
|
|
223
|
+
const rootProject = variants.some((variant) => isRootProjectEntry(variant, projectName));
|
|
224
|
+
if (variants.length > 1) {
|
|
225
|
+
counts.markerSplitNames += 1;
|
|
226
|
+
markerSplitNames.push(primary.name);
|
|
227
|
+
}
|
|
197
228
|
|
|
198
229
|
if (!actual) {
|
|
199
230
|
if (localProject) {
|
|
231
|
+
if (rootProject && variants.some((variant) => variant.source === 'virtual')) {
|
|
232
|
+
// Without a [build-system] uv records the project as a virtual source
|
|
233
|
+
// and never installs it; its absence from .venv is not drift.
|
|
234
|
+
checks.projectInstalled = false;
|
|
235
|
+
checks.projectInstallable = false;
|
|
236
|
+
virtualRoots.push(primary.name);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
200
239
|
counts.missing += 1;
|
|
201
240
|
if (rootProject) checks.projectInstalled = false;
|
|
202
241
|
findings.push({
|
|
203
242
|
code: 'PROJECT_NOT_INSTALLED',
|
|
204
|
-
name:
|
|
205
|
-
expected:
|
|
243
|
+
name: primary.name,
|
|
244
|
+
expected: primary.version ?? undefined,
|
|
206
245
|
message: rootProject
|
|
207
|
-
? `uv.lock records "${
|
|
208
|
-
: `uv.lock records the local project "${
|
|
246
|
+
? `uv.lock records "${primary.name}" as an editable install, but it is absent from .venv. The project is not importable and no test can exercise it.`
|
|
247
|
+
: `uv.lock records the local project "${primary.name}" as an editable install, but it is absent from .venv.`,
|
|
209
248
|
});
|
|
210
|
-
} else if (required.has(
|
|
249
|
+
} else if (required.has(normalized)) {
|
|
211
250
|
counts.missing += 1;
|
|
212
251
|
findings.push({
|
|
213
252
|
code: 'INSTALLED_PACKAGE_MISSING',
|
|
214
|
-
name:
|
|
215
|
-
expected:
|
|
216
|
-
message: `"${
|
|
253
|
+
name: primary.name,
|
|
254
|
+
expected: primary.version ?? undefined,
|
|
255
|
+
message: `"${primary.name}" is locked and required unconditionally, but it is not installed in .venv.`,
|
|
217
256
|
});
|
|
218
257
|
} else {
|
|
219
258
|
// Guarded by a platform or version marker, so this platform rightly omits it.
|
|
220
|
-
counts.conditional +=
|
|
221
|
-
|
|
259
|
+
counts.conditional += variants.length;
|
|
260
|
+
for (const variant of variants) {
|
|
261
|
+
const label = `${primary.name}@${variant.version ?? '?'}`;
|
|
262
|
+
if (!conditionalAbsent.includes(label)) conditionalAbsent.push(label);
|
|
263
|
+
}
|
|
222
264
|
}
|
|
223
265
|
continue;
|
|
224
266
|
}
|
|
225
267
|
|
|
226
268
|
if (localProject) {
|
|
227
|
-
if (rootProject)
|
|
269
|
+
if (rootProject) {
|
|
270
|
+
checks.projectInstalled = true;
|
|
271
|
+
checks.projectInstallable = true;
|
|
272
|
+
}
|
|
228
273
|
if (actual.source !== 'editable') {
|
|
229
274
|
if (rootProject) checks.projectEditable = false;
|
|
230
275
|
findings.push({
|
|
231
276
|
code: 'PROJECT_INSTALLED_NOT_EDITABLE',
|
|
232
|
-
name:
|
|
277
|
+
name: primary.name,
|
|
233
278
|
actual: actual.version,
|
|
234
279
|
message: rootProject
|
|
235
|
-
? `"${
|
|
236
|
-
: `The local project "${
|
|
280
|
+
? `"${primary.name}" is installed from a materialised copy instead of an editable link, so tests would import a stale snapshot of the sources.`
|
|
281
|
+
: `The local project "${primary.name}" is installed from a materialised copy instead of an editable link.`,
|
|
237
282
|
});
|
|
238
283
|
} else if (rootProject) {
|
|
239
284
|
checks.projectEditable = true;
|
|
@@ -243,16 +288,31 @@ export function compareInstalledConformance(input: ConformanceInput): Conformanc
|
|
|
243
288
|
continue;
|
|
244
289
|
}
|
|
245
290
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
291
|
+
// Matching any locked variant is positive proof the environment agrees with
|
|
292
|
+
// the lockfile for the markers that apply here.
|
|
293
|
+
if (variants.some((variant) => variant.version === actual.version)) continue;
|
|
294
|
+
|
|
295
|
+
const versions = variants.map((variant) => variant.version).filter((value) => value !== null);
|
|
296
|
+
if (versions.length === 0) {
|
|
297
|
+
notes.push({
|
|
298
|
+
code: 'LOCKED_VERSION_UNKNOWN',
|
|
299
|
+
message: `uv.lock records "${primary.name}" without a version, so the installed ${actual.version} could not be compared.`,
|
|
300
|
+
severity: 'info',
|
|
254
301
|
});
|
|
302
|
+
continue;
|
|
255
303
|
}
|
|
304
|
+
|
|
305
|
+
counts.mismatched += 1;
|
|
306
|
+
findings.push({
|
|
307
|
+
code: 'INSTALLED_VERSION_MISMATCH',
|
|
308
|
+
name: primary.name,
|
|
309
|
+
expected: versions.join(' | '),
|
|
310
|
+
actual: actual.version,
|
|
311
|
+
message:
|
|
312
|
+
versions.length > 1
|
|
313
|
+
? `"${primary.name}" is locked at ${versions.join(' or ')} (marker-dependent) but ${actual.version} is installed in .venv, which matches none of them.`
|
|
314
|
+
: `"${primary.name}" is locked at ${versions[0]} but ${actual.version} is installed in .venv.`,
|
|
315
|
+
});
|
|
256
316
|
}
|
|
257
317
|
|
|
258
318
|
const untrackedCandidates = (installed?.distributions ?? []).filter(
|
|
@@ -284,6 +344,22 @@ export function compareInstalledConformance(input: ConformanceInput): Conformanc
|
|
|
284
344
|
}
|
|
285
345
|
}
|
|
286
346
|
|
|
347
|
+
if (markerSplitNames.length > 0) {
|
|
348
|
+
notes.push({
|
|
349
|
+
code: 'MARKER_SPLIT_LOCK_ENTRIES',
|
|
350
|
+
message: `${markerSplitNames.length} distribution(s) are locked more than once because uv splits them by marker (${markerSplitNames.slice(0, 5).join(', ')}${markerSplitNames.length > 5 ? ', …' : ''}); the installed version is compared against every variant and matches when it equals any one of them.`,
|
|
351
|
+
severity: 'info',
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (virtualRoots.length > 0) {
|
|
356
|
+
notes.push({
|
|
357
|
+
code: 'PROJECT_VIRTUAL_SOURCE',
|
|
358
|
+
message: `uv.lock records "${virtualRoots[0]}" with source = { virtual = "." }, which uv writes for a project without a [build-system] table. It is intentionally never installed into .venv, and imports still resolve from the working directory.`,
|
|
359
|
+
severity: 'info',
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
287
363
|
if (conditionalAbsent.length > 0) {
|
|
288
364
|
notes.push({
|
|
289
365
|
code: 'CONDITIONAL_PACKAGES_ABSENT',
|
package/src/project/inspect.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type ConformanceReport,
|
|
6
6
|
} from './conformance.ts';
|
|
7
7
|
import type { InstalledEnvironment } from './installed.ts';
|
|
8
|
+
import type { PytestConfiguration } from './pytest-config.ts';
|
|
8
9
|
import type { LockComparison, LockSection, ManifestSection, ScanPayload } from './scanner.ts';
|
|
9
10
|
|
|
10
11
|
export interface ProjectInspection {
|
|
@@ -44,6 +45,13 @@ export interface InspectInput {
|
|
|
44
45
|
/** `undefined` when no .gitignore exists, so the check stays honest. */
|
|
45
46
|
venvIgnored?: boolean;
|
|
46
47
|
hasTestsDirectory: boolean;
|
|
48
|
+
/** Test directories found relative to the root; names them in the diagnostic. */
|
|
49
|
+
testDirectories?: string[];
|
|
50
|
+
/**
|
|
51
|
+
* Where pytest configuration was found. The scanner only reads
|
|
52
|
+
* `pyproject.toml`, so INI files are decided by the caller.
|
|
53
|
+
*/
|
|
54
|
+
pytestConfiguration?: PytestConfiguration;
|
|
47
55
|
/** Distributions read from `.venv`; omit to skip the conformance comparison. */
|
|
48
56
|
installed?: InstalledEnvironment;
|
|
49
57
|
}
|
|
@@ -220,6 +228,8 @@ function collectEnvironmentDiagnostics(
|
|
|
220
228
|
venvDir: string | undefined,
|
|
221
229
|
venvIgnored: boolean | undefined,
|
|
222
230
|
hasTestsDirectory: boolean,
|
|
231
|
+
testDirectories: string[] | undefined,
|
|
232
|
+
pytestConfiguration: PytestConfiguration | undefined,
|
|
223
233
|
collector: DiagnosticCollector,
|
|
224
234
|
): void {
|
|
225
235
|
if (!venvDir) {
|
|
@@ -246,15 +256,27 @@ function collectEnvironmentDiagnostics(
|
|
|
246
256
|
collector.notes.push({
|
|
247
257
|
code: 'TESTS_DIRECTORY_MISSING',
|
|
248
258
|
message:
|
|
249
|
-
'No tests
|
|
259
|
+
'No tests directory was found in the project root or inside a top-level package, so test selection and TDD gates cannot match changed sources.',
|
|
260
|
+
severity: 'info',
|
|
261
|
+
});
|
|
262
|
+
} else if (testDirectories && testDirectories.length > 0) {
|
|
263
|
+
collector.notes.push({
|
|
264
|
+
code: 'TESTS_DIRECTORY_FOUND',
|
|
265
|
+
message: `Tests live in ${testDirectories.slice(0, 4).join(', ')}${testDirectories.length > 4 ? `, … (+${testDirectories.length - 4})` : ''}. Confirm testpaths covers them when running pytest without a target.`,
|
|
250
266
|
severity: 'info',
|
|
251
267
|
});
|
|
252
268
|
}
|
|
253
269
|
|
|
254
|
-
|
|
270
|
+
// pytest can be configured from pytest.ini, tox.ini, or setup.cfg, not only
|
|
271
|
+
// from pyproject.toml; checking one file reported "not configured" for most
|
|
272
|
+
// projects that use pytest's own configuration file.
|
|
273
|
+
const pytestConfigured =
|
|
274
|
+
pytestConfiguration?.configured ?? manifest?.toolConfiguration?.pytest === true;
|
|
275
|
+
if (manifest?.pyprojectPath && !pytestConfigured) {
|
|
255
276
|
collector.notes.push({
|
|
256
277
|
code: 'PYTEST_NOT_CONFIGURED',
|
|
257
|
-
message:
|
|
278
|
+
message:
|
|
279
|
+
'No pytest configuration was found in pyproject.toml, pytest.ini, tox.ini, or setup.cfg.',
|
|
258
280
|
severity: 'info',
|
|
259
281
|
});
|
|
260
282
|
}
|
|
@@ -283,7 +305,15 @@ function collectEnvironmentDiagnostics(
|
|
|
283
305
|
* whole diagnostic surface is unit-testable without touching a filesystem.
|
|
284
306
|
*/
|
|
285
307
|
export function inspectProject(input: InspectInput): ProjectInspection {
|
|
286
|
-
const {
|
|
308
|
+
const {
|
|
309
|
+
payload,
|
|
310
|
+
venvDir,
|
|
311
|
+
venvIgnored,
|
|
312
|
+
hasTestsDirectory,
|
|
313
|
+
testDirectories,
|
|
314
|
+
pytestConfiguration,
|
|
315
|
+
installed,
|
|
316
|
+
} = input;
|
|
287
317
|
const manifest = payload.manifest;
|
|
288
318
|
const lock = payload.lock;
|
|
289
319
|
const comparison = payload.lockComparison;
|
|
@@ -292,7 +322,16 @@ export function inspectProject(input: InspectInput): ProjectInspection {
|
|
|
292
322
|
const collector: DiagnosticCollector = { warnings: [], notes: [], suggestions: [] };
|
|
293
323
|
collectManifestDiagnostics(manifest, root, collector);
|
|
294
324
|
collectLockDiagnostics(lock, comparison, collector);
|
|
295
|
-
collectEnvironmentDiagnostics(
|
|
325
|
+
collectEnvironmentDiagnostics(
|
|
326
|
+
root,
|
|
327
|
+
manifest,
|
|
328
|
+
venvDir,
|
|
329
|
+
venvIgnored,
|
|
330
|
+
hasTestsDirectory,
|
|
331
|
+
testDirectories,
|
|
332
|
+
pytestConfiguration,
|
|
333
|
+
collector,
|
|
334
|
+
);
|
|
296
335
|
|
|
297
336
|
const { warnings, notes, suggestions } = collector;
|
|
298
337
|
|
|
@@ -311,7 +350,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
|
|
|
311
350
|
if (conformance.findings.some((finding) => finding.code === 'PROJECT_NOT_INSTALLED')) {
|
|
312
351
|
suggestions.push({
|
|
313
352
|
message:
|
|
314
|
-
'The project is
|
|
353
|
+
'The project is recorded as an editable install but is missing from .venv. Run uv sync; if that fails, the build backend could not find the package (check that the module directory name matches [project] name).',
|
|
315
354
|
confidence: 'high',
|
|
316
355
|
command: 'uv sync',
|
|
317
356
|
});
|
package/src/project/paths.ts
CHANGED
|
@@ -21,6 +21,21 @@ export function isTestFile(path: string): boolean {
|
|
|
21
21
|
return TEST_DIRECTORY.test(posix) || TEST_FILENAME.test(posix);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const RUNNABLE_TEST_FILENAME = /^test_.*\.[a-z]+$/i;
|
|
25
|
+
const RUNNABLE_TEST_SUFFIX = /_test\.[a-z]+$/i;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A file pytest actually collects tests from.
|
|
29
|
+
*
|
|
30
|
+
* Living under `tests/` is not enough: `tests/__init__.py`, `tests/conftest.py`,
|
|
31
|
+
* and `tests/utils.py` are test *infrastructure*, and naming them as pytest
|
|
32
|
+
* targets overstates what the selection covers.
|
|
33
|
+
*/
|
|
34
|
+
export function isRunnableTestFile(path: string): boolean {
|
|
35
|
+
const name = basename(toPosix(path));
|
|
36
|
+
return RUNNABLE_TEST_FILENAME.test(name) || RUNNABLE_TEST_SUFFIX.test(name);
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
export function isSourceFile(path: string): boolean {
|
|
25
40
|
return isPythonFile(path) && !isTestFile(path);
|
|
26
41
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where pytest configuration can live. `pyproject.toml` is read by the scanner
|
|
3
|
+
* (tomllib) and passed in already-decided; the INI files have to be inspected
|
|
4
|
+
* here because only `pytest.ini` is unambiguously a pytest file.
|
|
5
|
+
*/
|
|
6
|
+
export interface PytestConfiguration {
|
|
7
|
+
configured: boolean;
|
|
8
|
+
/** Files that define pytest configuration, in the order they were checked. */
|
|
9
|
+
sources: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PytestConfigurationInput {
|
|
13
|
+
/** True when pyproject.toml has a `[tool.pytest.ini_options]` table. */
|
|
14
|
+
pyprojectConfigured: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Raw contents of `pytest.ini`, `tox.ini`, and `setup.cfg`, keyed by file
|
|
17
|
+
* name. A missing key means the file does not exist.
|
|
18
|
+
*/
|
|
19
|
+
iniFiles: Record<string, string | undefined>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `[pytest]` (pytest.ini) and `[tool:pytest]` (setup.cfg / tox.ini). */
|
|
23
|
+
const PYTEST_SECTION = /^\s*\[(?:tool:)?pytest\]\s*$/m;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Deciding whether pytest is configured used to look at `pyproject.toml`
|
|
27
|
+
* alone, which reports "not configured" for every project that keeps
|
|
28
|
+
* `pytest.ini` — the file pytest itself recommends.
|
|
29
|
+
*
|
|
30
|
+
* `pytest.ini` counts on existence because it has no other purpose. `tox.ini`
|
|
31
|
+
* and `setup.cfg` are shared files, so they only count when they actually carry
|
|
32
|
+
* a pytest section.
|
|
33
|
+
*/
|
|
34
|
+
export function detectPytestConfiguration(input: PytestConfigurationInput): PytestConfiguration {
|
|
35
|
+
const sources: string[] = [];
|
|
36
|
+
if (input.pyprojectConfigured) sources.push('pyproject.toml');
|
|
37
|
+
|
|
38
|
+
for (const name of ['pytest.ini', 'tox.ini', 'setup.cfg']) {
|
|
39
|
+
const content = input.iniFiles[name];
|
|
40
|
+
if (content === undefined) continue;
|
|
41
|
+
if (name === 'pytest.ini' || PYTEST_SECTION.test(content)) sources.push(name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { configured: sources.length > 0, sources };
|
|
45
|
+
}
|
package/src/project/root.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { access, readdir, stat } from 'node:fs/promises';
|
|
2
3
|
import { dirname, join, resolve } from 'node:path';
|
|
3
4
|
|
|
4
5
|
/** Files that mark the root of a uv-managed Python project. */
|
|
@@ -56,6 +57,89 @@ export async function findVenvDir(root: string): Promise<string | undefined> {
|
|
|
56
57
|
return (await isDirectory(candidate)) ? candidate : undefined;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
const TEST_DIRECTORY_NAMES = new Set(['test', 'tests', 'testing']);
|
|
61
|
+
|
|
62
|
+
/** Directories that can never contain the project's own tests. */
|
|
63
|
+
const UNSCANNABLE_DIRECTORIES = new Set([
|
|
64
|
+
'.git',
|
|
65
|
+
'.venv',
|
|
66
|
+
'venv',
|
|
67
|
+
'.tox',
|
|
68
|
+
'.nox',
|
|
69
|
+
'__pycache__',
|
|
70
|
+
'node_modules',
|
|
71
|
+
'build',
|
|
72
|
+
'dist',
|
|
73
|
+
'.eggs',
|
|
74
|
+
'.mypy_cache',
|
|
75
|
+
'.ruff_cache',
|
|
76
|
+
'.pytest_cache',
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Find the directories that hold tests, relative to the project root.
|
|
81
|
+
*
|
|
82
|
+
* Looking only for `./tests` misses the common layout where the suite lives
|
|
83
|
+
* inside the package it tests (`<package>/tests/`), which made the tool claim a
|
|
84
|
+
* project with hundreds of tests had none.
|
|
85
|
+
*/
|
|
86
|
+
export async function findTestDirectories(root: string, maxDepth = 3): Promise<string[]> {
|
|
87
|
+
const found: string[] = [];
|
|
88
|
+
const walk = async (directory: string, relative: string, depth: number): Promise<void> => {
|
|
89
|
+
let entries;
|
|
90
|
+
try {
|
|
91
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
92
|
+
} catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
96
|
+
if (!entry.isDirectory()) continue;
|
|
97
|
+
const name = entry.name;
|
|
98
|
+
if (name.startsWith('.')) continue;
|
|
99
|
+
const childRelative = relative ? `${relative}/${name}` : name;
|
|
100
|
+
if (TEST_DIRECTORY_NAMES.has(name)) {
|
|
101
|
+
found.push(childRelative);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (UNSCANNABLE_DIRECTORIES.has(name)) continue;
|
|
105
|
+
if (depth < maxDepth) await walk(join(directory, name), childRelative, depth + 1);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
await walk(root, '', 1);
|
|
109
|
+
return found;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const WINDOWS = process.platform === 'win32';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Locate the interpreter inside a project virtual environment.
|
|
116
|
+
*
|
|
117
|
+
* The interpreter that runs the read-only scanner decides which
|
|
118
|
+
* `site-packages` it can see: a host `python3` maps four modules while the
|
|
119
|
+
* project's own interpreter maps the whole environment, so asking the wrong one
|
|
120
|
+
* makes every import name look like it has no providing distribution.
|
|
121
|
+
*/
|
|
122
|
+
export async function findVenvInterpreter(venvDir: string): Promise<string | undefined> {
|
|
123
|
+
const directories = WINDOWS
|
|
124
|
+
? [join(venvDir, 'Scripts'), join(venvDir, 'bin')]
|
|
125
|
+
: [join(venvDir, 'bin'), join(venvDir, 'Scripts')];
|
|
126
|
+
const names = WINDOWS ? ['python.exe', 'python'] : ['python', 'python3'];
|
|
127
|
+
for (const directory of directories) {
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
const candidate = join(directory, name);
|
|
130
|
+
try {
|
|
131
|
+
const info = await stat(candidate);
|
|
132
|
+
if (!info.isFile()) continue;
|
|
133
|
+
if (!WINDOWS) await access(candidate, constants.X_OK);
|
|
134
|
+
return candidate;
|
|
135
|
+
} catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
59
143
|
export async function isGitIgnored(root: string, entry: string): Promise<boolean | undefined> {
|
|
60
144
|
const gitignore = join(root, '.gitignore');
|
|
61
145
|
try {
|