pi-python-helper 0.1.0 → 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 +39 -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/dependencies.ts +0 -120
- package/extensions/tools/environment.ts +30 -5
- package/extensions/tools/testing.ts +80 -8
- package/extensions/tools/validation.ts +427 -44
- 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 +63 -55
- package/src/build/quality.ts +49 -0
- package/src/build/selection.ts +176 -9
- package/src/build/sync.ts +70 -0
- package/src/build/traceback.ts +55 -0
- package/src/dependencies/aliases.ts +101 -0
- package/src/dependencies/plan.ts +60 -112
- 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 +126 -53
- 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
|
@@ -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,7 +5,8 @@ import {
|
|
|
5
5
|
type ConformanceReport,
|
|
6
6
|
} from './conformance.ts';
|
|
7
7
|
import type { InstalledEnvironment } from './installed.ts';
|
|
8
|
-
import type {
|
|
8
|
+
import type { PytestConfiguration } from './pytest-config.ts';
|
|
9
|
+
import type { LockComparison, LockSection, ManifestSection, ScanPayload } from './scanner.ts';
|
|
9
10
|
|
|
10
11
|
export interface ProjectInspection {
|
|
11
12
|
root: string;
|
|
@@ -44,64 +45,67 @@ 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
|
}
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const { payload, venvDir, venvIgnored, hasTestsDirectory, installed } = input;
|
|
57
|
-
const manifest = payload.manifest;
|
|
58
|
-
const lock = payload.lock;
|
|
59
|
-
const comparison = payload.lockComparison;
|
|
60
|
-
const warnings: Diagnostic[] = [];
|
|
61
|
-
const notes: Diagnostic[] = [];
|
|
62
|
-
const suggestions: Suggestion[] = [];
|
|
59
|
+
interface DiagnosticCollector {
|
|
60
|
+
warnings: Diagnostic[];
|
|
61
|
+
notes: Diagnostic[];
|
|
62
|
+
suggestions: Suggestion[];
|
|
63
|
+
}
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
function collectManifestDiagnostics(
|
|
66
|
+
manifest: ManifestSection | null | undefined,
|
|
67
|
+
root: string,
|
|
68
|
+
collector: DiagnosticCollector,
|
|
69
|
+
): void {
|
|
65
70
|
if (manifest?.tomlError) {
|
|
66
|
-
warnings.push(
|
|
71
|
+
collector.warnings.push(
|
|
67
72
|
warn('TOML_PARSE_ERROR', manifest.tomlError, manifest.pyprojectPath ?? undefined),
|
|
68
73
|
);
|
|
69
74
|
}
|
|
70
75
|
for (const message of manifest?.warnings ?? []) {
|
|
71
|
-
warnings.push(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
notes.push({ code: 'LOCKFILE_NOTE', message, severity: 'info' });
|
|
76
|
+
collector.warnings.push(
|
|
77
|
+
warn('MANIFEST_WARNING', message, manifest?.pyprojectPath ?? undefined),
|
|
78
|
+
);
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
if (!manifest?.pyprojectPath) {
|
|
78
|
-
warnings.push(
|
|
82
|
+
collector.warnings.push(
|
|
79
83
|
warn(
|
|
80
84
|
'PYPROJECT_MISSING',
|
|
81
85
|
'pyproject.toml was not found; dependencies, layout, and tool configuration cannot be verified.',
|
|
82
86
|
),
|
|
83
87
|
);
|
|
84
|
-
suggestions.push({
|
|
88
|
+
collector.suggestions.push({
|
|
85
89
|
message: 'Run uv init to create a pyproject.toml, then uv add the runtime dependencies.',
|
|
86
90
|
confidence: 'high',
|
|
87
91
|
command: 'uv init',
|
|
88
92
|
});
|
|
89
93
|
} else if (!manifest.name) {
|
|
90
|
-
warnings.push(
|
|
94
|
+
collector.warnings.push(
|
|
91
95
|
warn(
|
|
92
96
|
'PROJECT_NAME_MISSING',
|
|
93
97
|
'pyproject.toml has no [project] name, so the installed distribution name is unknown.',
|
|
94
98
|
manifest.pyprojectPath ?? undefined,
|
|
95
99
|
),
|
|
96
100
|
);
|
|
97
|
-
suggestions.push({
|
|
101
|
+
collector.suggestions.push({
|
|
98
102
|
message: 'Add a [project] table with name and version to pyproject.toml.',
|
|
99
103
|
confidence: 'high',
|
|
100
104
|
});
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
if (manifest?.legacySetupPy || manifest?.legacySetupCfg) {
|
|
104
|
-
warnings.push(
|
|
108
|
+
collector.warnings.push(
|
|
105
109
|
warn(
|
|
106
110
|
'LEGACY_PACKAGING',
|
|
107
111
|
`The project still uses ${[
|
|
@@ -113,14 +117,14 @@ export function inspectProject(input: InspectInput): ProjectInspection {
|
|
|
113
117
|
root,
|
|
114
118
|
),
|
|
115
119
|
);
|
|
116
|
-
suggestions.push({
|
|
120
|
+
collector.suggestions.push({
|
|
117
121
|
message: 'Move dependency metadata from setup.py/setup.cfg into [project] in pyproject.toml.',
|
|
118
122
|
confidence: 'medium',
|
|
119
123
|
});
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
if (manifest?.requirementsFiles.length && manifest.pyprojectPath) {
|
|
123
|
-
warnings.push(
|
|
127
|
+
collector.warnings.push(
|
|
124
128
|
warn(
|
|
125
129
|
'DUPLICATE_DEPENDENCY_SOURCE',
|
|
126
130
|
`${manifest.requirementsFiles.join(', ')} also declares dependencies; uv resolves from pyproject.toml and uv.lock only.`,
|
|
@@ -129,14 +133,31 @@ export function inspectProject(input: InspectInput): ProjectInspection {
|
|
|
129
133
|
);
|
|
130
134
|
}
|
|
131
135
|
|
|
136
|
+
if (manifest?.legacySetupPy && manifest.buildBackend === null && !manifest.pyprojectPath) {
|
|
137
|
+
collector.suggestions.push({
|
|
138
|
+
message: 'uv manages dependencies from pyproject.toml; migrate before running uv sync.',
|
|
139
|
+
confidence: 'medium',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function collectLockDiagnostics(
|
|
145
|
+
lock: LockSection | null | undefined,
|
|
146
|
+
comparison: LockComparison | null | undefined,
|
|
147
|
+
collector: DiagnosticCollector,
|
|
148
|
+
): void {
|
|
149
|
+
for (const message of lock?.warnings ?? []) {
|
|
150
|
+
collector.notes.push({ code: 'LOCKFILE_NOTE', message, severity: 'info' });
|
|
151
|
+
}
|
|
152
|
+
|
|
132
153
|
if (!lock?.present) {
|
|
133
|
-
notes.push({
|
|
154
|
+
collector.notes.push({
|
|
134
155
|
code: 'LOCKFILE_MISSING',
|
|
135
156
|
message:
|
|
136
157
|
'uv.lock was not found, so dependency drift and exact resolved versions cannot be verified.',
|
|
137
158
|
severity: 'info',
|
|
138
159
|
});
|
|
139
|
-
suggestions.push({
|
|
160
|
+
collector.suggestions.push({
|
|
140
161
|
message: 'Run uv lock to record resolved versions in uv.lock.',
|
|
141
162
|
confidence: 'high',
|
|
142
163
|
command: 'uv lock',
|
|
@@ -144,14 +165,14 @@ export function inspectProject(input: InspectInput): ProjectInspection {
|
|
|
144
165
|
}
|
|
145
166
|
|
|
146
167
|
if (comparison?.requiresPythonMismatch) {
|
|
147
|
-
warnings.push(
|
|
168
|
+
collector.warnings.push(
|
|
148
169
|
warn(
|
|
149
170
|
'REQUIRES_PYTHON_MISMATCH',
|
|
150
171
|
`pyproject.toml requires-python is "${comparison.requiresPythonMismatch.manifest}" but uv.lock records "${comparison.requiresPythonMismatch.lock}".`,
|
|
151
172
|
lock?.path ?? undefined,
|
|
152
173
|
),
|
|
153
174
|
);
|
|
154
|
-
suggestions.push({
|
|
175
|
+
collector.suggestions.push({
|
|
155
176
|
message: 'Run uv lock so the lockfile reflects the current requires-python constraint.',
|
|
156
177
|
confidence: 'high',
|
|
157
178
|
command: 'uv lock',
|
|
@@ -159,7 +180,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
|
|
|
159
180
|
}
|
|
160
181
|
|
|
161
182
|
for (const name of comparison?.missingFromLock ?? []) {
|
|
162
|
-
warnings.push(
|
|
183
|
+
collector.warnings.push(
|
|
163
184
|
warn(
|
|
164
185
|
'LOCKFILE_MISSING_DEPENDENCY',
|
|
165
186
|
`"${name}" is declared in pyproject.toml but absent from uv.lock.`,
|
|
@@ -168,7 +189,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
|
|
|
168
189
|
);
|
|
169
190
|
}
|
|
170
191
|
if (comparison?.missingFromLock.length) {
|
|
171
|
-
suggestions.push({
|
|
192
|
+
collector.suggestions.push({
|
|
172
193
|
message: 'Run uv lock to add the missing declarations to the lockfile.',
|
|
173
194
|
confidence: 'high',
|
|
174
195
|
command: 'uv lock',
|
|
@@ -176,7 +197,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
|
|
|
176
197
|
}
|
|
177
198
|
|
|
178
199
|
for (const entry of comparison?.unsatisfiedInLock ?? []) {
|
|
179
|
-
warnings.push(
|
|
200
|
+
collector.warnings.push(
|
|
180
201
|
warn(
|
|
181
202
|
'LOCKFILE_UNSATISFIED_DEPENDENCY',
|
|
182
203
|
`"${entry.name}" is locked at ${entry.locked} which does not satisfy "${entry.specifier}".`,
|
|
@@ -186,59 +207,82 @@ export function inspectProject(input: InspectInput): ProjectInspection {
|
|
|
186
207
|
}
|
|
187
208
|
|
|
188
209
|
if (lock?.present && comparison && !comparison.specifierCheckAvailable) {
|
|
189
|
-
notes.push({
|
|
210
|
+
collector.notes.push({
|
|
190
211
|
code: 'SPECIFIER_CHECK_UNAVAILABLE',
|
|
191
212
|
message:
|
|
192
213
|
'The packaging library was unavailable, so only declared-versus-locked names were compared, not version constraints.',
|
|
193
214
|
severity: 'info',
|
|
194
215
|
});
|
|
195
|
-
suggestions.push({
|
|
216
|
+
collector.suggestions.push({
|
|
196
217
|
message:
|
|
197
218
|
'Install the packaging library in the analysing interpreter to compare declared version constraints against uv.lock.',
|
|
198
219
|
confidence: 'medium',
|
|
199
220
|
command: 'python3 -m pip install packaging',
|
|
200
221
|
});
|
|
201
222
|
}
|
|
223
|
+
}
|
|
202
224
|
|
|
225
|
+
function collectEnvironmentDiagnostics(
|
|
226
|
+
root: string,
|
|
227
|
+
manifest: ManifestSection | null | undefined,
|
|
228
|
+
venvDir: string | undefined,
|
|
229
|
+
venvIgnored: boolean | undefined,
|
|
230
|
+
hasTestsDirectory: boolean,
|
|
231
|
+
testDirectories: string[] | undefined,
|
|
232
|
+
pytestConfiguration: PytestConfiguration | undefined,
|
|
233
|
+
collector: DiagnosticCollector,
|
|
234
|
+
): void {
|
|
203
235
|
if (!venvDir) {
|
|
204
|
-
notes.push({
|
|
236
|
+
collector.notes.push({
|
|
205
237
|
code: 'VENV_MISSING',
|
|
206
238
|
message: 'No .venv directory exists at the project root; run uv sync before running tests.',
|
|
207
239
|
severity: 'info',
|
|
208
240
|
});
|
|
209
241
|
} else if (venvIgnored === false) {
|
|
210
|
-
warnings.push(
|
|
242
|
+
collector.warnings.push(
|
|
211
243
|
warn(
|
|
212
244
|
'VENV_NOT_IGNORED',
|
|
213
245
|
'.venv exists but is not listed in .gitignore.',
|
|
214
246
|
`${root}/.gitignore`,
|
|
215
247
|
),
|
|
216
248
|
);
|
|
217
|
-
suggestions.push({
|
|
249
|
+
collector.suggestions.push({
|
|
218
250
|
message: 'Add .venv/ to .gitignore so the environment is never committed.',
|
|
219
251
|
confidence: 'high',
|
|
220
252
|
});
|
|
221
253
|
}
|
|
222
254
|
|
|
223
255
|
if (!hasTestsDirectory) {
|
|
224
|
-
notes.push({
|
|
256
|
+
collector.notes.push({
|
|
225
257
|
code: 'TESTS_DIRECTORY_MISSING',
|
|
226
258
|
message:
|
|
227
|
-
'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.`,
|
|
228
266
|
severity: 'info',
|
|
229
267
|
});
|
|
230
268
|
}
|
|
231
269
|
|
|
232
|
-
|
|
233
|
-
|
|
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) {
|
|
276
|
+
collector.notes.push({
|
|
234
277
|
code: 'PYTEST_NOT_CONFIGURED',
|
|
235
|
-
message:
|
|
278
|
+
message:
|
|
279
|
+
'No pytest configuration was found in pyproject.toml, pytest.ini, tox.ini, or setup.cfg.',
|
|
236
280
|
severity: 'info',
|
|
237
281
|
});
|
|
238
282
|
}
|
|
239
283
|
|
|
240
284
|
if (manifest?.layout === 'src' && manifest.modules.length === 0) {
|
|
241
|
-
warnings.push(
|
|
285
|
+
collector.warnings.push(
|
|
242
286
|
warn(
|
|
243
287
|
'EMPTY_SRC_LAYOUT',
|
|
244
288
|
'The src/ directory exists but contains no importable module directories or modules.',
|
|
@@ -247,20 +291,49 @@ export function inspectProject(input: InspectInput): ProjectInspection {
|
|
|
247
291
|
);
|
|
248
292
|
}
|
|
249
293
|
|
|
250
|
-
if (manifest?.legacySetupPy && manifest.buildBackend === null && !manifest.pyprojectPath) {
|
|
251
|
-
suggestions.push({
|
|
252
|
-
message: 'uv manages dependencies from pyproject.toml; migrate before running uv sync.',
|
|
253
|
-
confidence: 'medium',
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
|
|
257
294
|
if (manifest?.uvWorkspaceMembers.length) {
|
|
258
|
-
notes.push({
|
|
295
|
+
collector.notes.push({
|
|
259
296
|
code: 'UV_WORKSPACE',
|
|
260
297
|
message: `This is a uv workspace with ${manifest.uvWorkspaceMembers.length} member(s); scope build and test tools per member.`,
|
|
261
298
|
severity: 'info',
|
|
262
299
|
});
|
|
263
300
|
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Turn a scanner payload into a project model plus diagnostics. Pure so the
|
|
305
|
+
* whole diagnostic surface is unit-testable without touching a filesystem.
|
|
306
|
+
*/
|
|
307
|
+
export function inspectProject(input: InspectInput): ProjectInspection {
|
|
308
|
+
const {
|
|
309
|
+
payload,
|
|
310
|
+
venvDir,
|
|
311
|
+
venvIgnored,
|
|
312
|
+
hasTestsDirectory,
|
|
313
|
+
testDirectories,
|
|
314
|
+
pytestConfiguration,
|
|
315
|
+
installed,
|
|
316
|
+
} = input;
|
|
317
|
+
const manifest = payload.manifest;
|
|
318
|
+
const lock = payload.lock;
|
|
319
|
+
const comparison = payload.lockComparison;
|
|
320
|
+
const root = payload.root;
|
|
321
|
+
|
|
322
|
+
const collector: DiagnosticCollector = { warnings: [], notes: [], suggestions: [] };
|
|
323
|
+
collectManifestDiagnostics(manifest, root, collector);
|
|
324
|
+
collectLockDiagnostics(lock, comparison, collector);
|
|
325
|
+
collectEnvironmentDiagnostics(
|
|
326
|
+
root,
|
|
327
|
+
manifest,
|
|
328
|
+
venvDir,
|
|
329
|
+
venvIgnored,
|
|
330
|
+
hasTestsDirectory,
|
|
331
|
+
testDirectories,
|
|
332
|
+
pytestConfiguration,
|
|
333
|
+
collector,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const { warnings, notes, suggestions } = collector;
|
|
264
337
|
|
|
265
338
|
const conformance =
|
|
266
339
|
installed !== undefined
|
|
@@ -277,7 +350,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
|
|
|
277
350
|
if (conformance.findings.some((finding) => finding.code === 'PROJECT_NOT_INSTALLED')) {
|
|
278
351
|
suggestions.push({
|
|
279
352
|
message:
|
|
280
|
-
'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).',
|
|
281
354
|
confidence: 'high',
|
|
282
355
|
command: 'uv sync',
|
|
283
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
|
+
}
|