pi-python-helper 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +85 -125
- package/docs/compatibility.md +17 -1
- package/docs/tools.md +47 -2
- package/extensions/index.ts +2 -0
- package/extensions/shared.ts +37 -1
- package/extensions/tools/environment.ts +20 -5
- package/extensions/tools/test-config.ts +148 -0
- package/extensions/tools/testing.ts +95 -9
- package/extensions/tools/validation.ts +310 -46
- package/helpers/scan_project.py +81 -1
- package/package.json +1 -1
- package/skills/python-development/SKILL.md +17 -9
- package/src/build/commands.ts +17 -2
- package/src/build/failure.ts +81 -16
- package/src/build/pytest-audit.ts +226 -0
- package/src/build/quality.ts +49 -0
- package/src/build/selection.ts +176 -9
- package/src/build/sync.ts +70 -0
- package/src/core/result.ts +28 -1
- package/src/dependencies/aliases.ts +21 -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 +71 -9
- package/src/validation/bundle.ts +41 -4
- package/src/validation/tdd.ts +94 -21
package/src/build/selection.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { basename, dirname, join } from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isPythonFile,
|
|
4
|
+
isRunnableTestFile,
|
|
5
|
+
isTestFile,
|
|
6
|
+
parentDir,
|
|
7
|
+
pathTokens,
|
|
8
|
+
toPosix,
|
|
9
|
+
} from '../project/paths.ts';
|
|
3
10
|
|
|
4
11
|
export interface TestSelection {
|
|
5
12
|
path: string;
|
|
@@ -11,13 +18,40 @@ export interface SelectionResult {
|
|
|
11
18
|
selected: TestSelection[];
|
|
12
19
|
/** True when no changed file could be mapped and every test file is returned. */
|
|
13
20
|
fellBackToAll: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* False when the selection covers every considered test file, so the
|
|
23
|
+
* candidate list was not narrowed at all. Reported so a caller does not read
|
|
24
|
+
* "30 of 30 selected" as a focused run.
|
|
25
|
+
*/
|
|
26
|
+
narrowed: boolean;
|
|
14
27
|
changedSourceFiles: string[];
|
|
15
28
|
changedTestFiles: string[];
|
|
16
29
|
consideredTestFiles: string[];
|
|
30
|
+
/** Files under a test directory that pytest does not collect tests from. */
|
|
31
|
+
supportFiles: string[];
|
|
32
|
+
/**
|
|
33
|
+
* True when at least one candidate was matched by an actual import of a
|
|
34
|
+
* changed module, which is the strongest available signal. False means the
|
|
35
|
+
* selection rests on naming conventions alone.
|
|
36
|
+
*/
|
|
37
|
+
importEvidenceUsed: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Dotted module names imported by each test file, keyed by test path.
|
|
42
|
+
* Supplied by the scanner because a test named `test_db_session.py` gives no
|
|
43
|
+
* naming hint that it covers `db/database.py`; its imports do.
|
|
44
|
+
*/
|
|
45
|
+
export type TestImportMap = Record<string, string[]>;
|
|
46
|
+
|
|
47
|
+
export interface SelectionOptions {
|
|
48
|
+
testImports?: TestImportMap;
|
|
17
49
|
}
|
|
18
50
|
|
|
19
51
|
const SCORE = {
|
|
20
52
|
changedTestItself: 100,
|
|
53
|
+
/** Importing the changed module is stronger than any name coincidence. */
|
|
54
|
+
importsChangedModule: 90,
|
|
21
55
|
sameStemSameDir: 80,
|
|
22
56
|
sameStem: 60,
|
|
23
57
|
sameDirectory: 40,
|
|
@@ -46,34 +80,132 @@ function firstImportableSegment(path: string): string {
|
|
|
46
80
|
return (segments[start] ?? '').replace(/\.py$/i, '').toLowerCase();
|
|
47
81
|
}
|
|
48
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Dotted module names a source path could be imported as.
|
|
85
|
+
*
|
|
86
|
+
* `src/pkg/db/database.py` is imported as `pkg.db.database`, and an
|
|
87
|
+
* `__init__.py` is the package itself, so both forms are produced with and
|
|
88
|
+
* without the `src/` prefix.
|
|
89
|
+
*/
|
|
90
|
+
export function modulePathsFromFile(path: string): string[] {
|
|
91
|
+
const posix = toPosix(path);
|
|
92
|
+
if (!isPythonFile(posix)) return [];
|
|
93
|
+
const segments = posix.split('/').filter((segment) => segment.length > 0);
|
|
94
|
+
const srcIndex = segments.lastIndexOf('src');
|
|
95
|
+
const trimmed = (srcIndex === -1 ? segments : segments.slice(srcIndex + 1)).map((segment) =>
|
|
96
|
+
segment.replace(/\.py$/i, ''),
|
|
97
|
+
);
|
|
98
|
+
if (trimmed.length === 0) return [];
|
|
99
|
+
if (trimmed.at(-1) === '__init__') trimmed.pop();
|
|
100
|
+
if (trimmed.length === 0) return [];
|
|
101
|
+
const dotted = trimmed.join('.');
|
|
102
|
+
const withoutRoot = trimmed.slice(1).join('.');
|
|
103
|
+
const candidates = [dotted];
|
|
104
|
+
if (srcIndex !== -1) candidates.push(segments.slice(srcIndex).join('.').replace(/\.py$/i, ''));
|
|
105
|
+
if (withoutRoot.length > 0) candidates.push(withoutRoot);
|
|
106
|
+
return [...new Set(candidates)];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True when a test imports the module, or a parent package of it. */
|
|
110
|
+
function importsModule(imported: string[], modules: string[]): string | undefined {
|
|
111
|
+
let best: string | undefined;
|
|
112
|
+
for (const candidate of modules) {
|
|
113
|
+
for (const entry of imported) {
|
|
114
|
+
const matches =
|
|
115
|
+
entry === candidate ||
|
|
116
|
+
entry.startsWith(`${candidate}.`) ||
|
|
117
|
+
candidate.startsWith(`${entry}.`);
|
|
118
|
+
if (!matches) continue;
|
|
119
|
+
// Report the most specific import: naming `pkg` when the file actually
|
|
120
|
+
// imports `pkg.routes.admin` overstates how broadly the test is coupled.
|
|
121
|
+
if (!best || entry.length > best.length) best = entry;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return best;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Values that appear in at least this share of the candidates carry no
|
|
129
|
+
* information about *which* candidate to run: in a project whose tests all live
|
|
130
|
+
* inside the package under test, the package name matches every file.
|
|
131
|
+
*/
|
|
132
|
+
const UBIQUITOUS_SHARE = 0.5;
|
|
133
|
+
|
|
134
|
+
function ubiquitousValues(documents: string[][]): Set<string> {
|
|
135
|
+
const counts = new Map<string, number>();
|
|
136
|
+
for (const values of documents) {
|
|
137
|
+
for (const value of new Set(values)) {
|
|
138
|
+
if (value.length === 0) continue;
|
|
139
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const threshold = Math.max(2, documents.length * UBIQUITOUS_SHARE);
|
|
143
|
+
const ubiquitous = new Set<string>();
|
|
144
|
+
for (const [value, count] of counts) {
|
|
145
|
+
if (count >= threshold) ubiquitous.add(value);
|
|
146
|
+
}
|
|
147
|
+
return ubiquitous;
|
|
148
|
+
}
|
|
149
|
+
|
|
49
150
|
/**
|
|
50
151
|
* Rank test files against changed paths using pytest conventions first and
|
|
51
152
|
* token overlap second. The convention signals are strong enough in Python that
|
|
52
153
|
* a name match should always outrank a fuzzy token match.
|
|
154
|
+
*
|
|
155
|
+
* Signals shared by every candidate are discarded rather than scored. Without
|
|
156
|
+
* that step a package-rooted test tree (`<package>/tests/`) matches its own
|
|
157
|
+
* package on every file and the selection degenerates into the full suite.
|
|
53
158
|
*/
|
|
54
|
-
export function selectTests(
|
|
159
|
+
export function selectTests(
|
|
160
|
+
changedPaths: string[],
|
|
161
|
+
testFiles: string[],
|
|
162
|
+
options: SelectionOptions = {},
|
|
163
|
+
): SelectionResult {
|
|
55
164
|
const changed = changedPaths.map(toPosix).filter(isPythonFile);
|
|
56
165
|
const changedSourceFiles = changed.filter((path) => !isTestFile(path));
|
|
57
166
|
const changedTestFiles = changed.filter(isTestFile);
|
|
58
167
|
const considered = [...new Set(testFiles.map(toPosix).filter(isPythonFile))].sort();
|
|
168
|
+
const supportFiles = considered.filter((path) => !isRunnableTestFile(path));
|
|
169
|
+
const testImports = options.testImports ?? {};
|
|
59
170
|
|
|
60
171
|
if (!changed.length) {
|
|
61
172
|
return {
|
|
62
173
|
selected: [],
|
|
63
174
|
fellBackToAll: false,
|
|
175
|
+
narrowed: false,
|
|
64
176
|
changedSourceFiles,
|
|
65
177
|
changedTestFiles,
|
|
66
178
|
consideredTestFiles: considered,
|
|
179
|
+
supportFiles,
|
|
180
|
+
importEvidenceUsed: false,
|
|
67
181
|
};
|
|
68
182
|
}
|
|
69
183
|
|
|
70
184
|
const sourceTokens = new Set(changedSourceFiles.flatMap(pathTokens));
|
|
71
185
|
const sourceModules = new Set(changedSourceFiles.map(firstImportableSegment));
|
|
72
|
-
const sourceDirs = new Set(changedSourceFiles.map(parentDir));
|
|
73
186
|
const sourceStems = new Set(changedSourceFiles.map(normalizedStem));
|
|
187
|
+
const sourceModulePaths = changedSourceFiles.map((path) => modulePathsFromFile(path));
|
|
188
|
+
|
|
189
|
+
const ubiquitousTokens = ubiquitousValues(considered.map((path) => pathTokens(path)));
|
|
190
|
+
const ubiquitousModules = ubiquitousValues(
|
|
191
|
+
considered.map((path) => [firstImportableSegment(path)]),
|
|
192
|
+
);
|
|
193
|
+
// A source directory that contains every test file (the package root) cannot
|
|
194
|
+
// distinguish candidates, so it does not score.
|
|
195
|
+
const sourceDirs = new Set(
|
|
196
|
+
[...new Set(changedSourceFiles.map(parentDir))].filter(
|
|
197
|
+
(directory) =>
|
|
198
|
+
directory === '' || !considered.every((path) => path.startsWith(`${directory}/`)),
|
|
199
|
+
),
|
|
200
|
+
);
|
|
74
201
|
|
|
202
|
+
let importEvidenceUsed = false;
|
|
75
203
|
const selections: TestSelection[] = [];
|
|
76
204
|
for (const testFile of considered) {
|
|
205
|
+
// Test infrastructure is not a target, but it can still affect the run, so it
|
|
206
|
+
// is reported separately instead of being scored as a test file.
|
|
207
|
+
if (!isRunnableTestFile(testFile)) continue;
|
|
208
|
+
|
|
77
209
|
const reasons: string[] = [];
|
|
78
210
|
let score = 0;
|
|
79
211
|
|
|
@@ -81,6 +213,19 @@ export function selectTests(changedPaths: string[], testFiles: string[]): Select
|
|
|
81
213
|
score += SCORE.changedTestItself;
|
|
82
214
|
reasons.push('the test file itself changed');
|
|
83
215
|
}
|
|
216
|
+
|
|
217
|
+
const imported = testImports[testFile];
|
|
218
|
+
if (imported && imported.length > 0) {
|
|
219
|
+
const matched = sourceModulePaths
|
|
220
|
+
.map((modules) => importsModule(imported, modules))
|
|
221
|
+
.find((value) => value !== undefined);
|
|
222
|
+
if (matched) {
|
|
223
|
+
score += SCORE.importsChangedModule;
|
|
224
|
+
importEvidenceUsed = true;
|
|
225
|
+
reasons.push(`imports the changed module "${matched}"`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
84
229
|
const testStem = normalizedStem(testFile);
|
|
85
230
|
const testDir = parentDir(testFile);
|
|
86
231
|
if (sourceStems.has(testStem)) {
|
|
@@ -99,13 +244,15 @@ export function selectTests(changedPaths: string[], testFiles: string[]): Select
|
|
|
99
244
|
}
|
|
100
245
|
|
|
101
246
|
const module = firstImportableSegment(testFile);
|
|
102
|
-
if (module && sourceModules.has(module)) {
|
|
247
|
+
if (module && sourceModules.has(module) && !ubiquitousModules.has(module)) {
|
|
103
248
|
score += SCORE.sharedModule;
|
|
104
249
|
reasons.push(`covers module "${module}"`);
|
|
105
250
|
}
|
|
106
251
|
|
|
107
252
|
const tokens = pathTokens(testFile);
|
|
108
|
-
const shared = tokens.filter(
|
|
253
|
+
const shared = tokens.filter(
|
|
254
|
+
(token) => sourceTokens.has(token) && !ubiquitousTokens.has(token),
|
|
255
|
+
);
|
|
109
256
|
if (shared.length) {
|
|
110
257
|
score += SCORE.sharedToken * Math.min(shared.length, 2);
|
|
111
258
|
reasons.push(`shares token(s): ${shared.slice(0, 4).join(', ')}`);
|
|
@@ -125,14 +272,34 @@ export function selectTests(changedPaths: string[], testFiles: string[]): Select
|
|
|
125
272
|
}
|
|
126
273
|
}
|
|
127
274
|
|
|
275
|
+
const runnableConsidered = considered.filter(isRunnableTestFile);
|
|
276
|
+
const selectedRunnable = selections.filter((entry) => isRunnableTestFile(entry.path));
|
|
128
277
|
const fellBackToAll = selections.length === 0 && considered.length > 0;
|
|
278
|
+
if (fellBackToAll) {
|
|
279
|
+
return {
|
|
280
|
+
selected: considered.map((path) => ({
|
|
281
|
+
path,
|
|
282
|
+
score: 0,
|
|
283
|
+
reason: 'no match; running the full suite',
|
|
284
|
+
})),
|
|
285
|
+
fellBackToAll: true,
|
|
286
|
+
narrowed: false,
|
|
287
|
+
changedSourceFiles,
|
|
288
|
+
changedTestFiles,
|
|
289
|
+
consideredTestFiles: considered,
|
|
290
|
+
supportFiles,
|
|
291
|
+
importEvidenceUsed,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
129
295
|
return {
|
|
130
|
-
selected:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
fellBackToAll,
|
|
296
|
+
selected: selections,
|
|
297
|
+
fellBackToAll: false,
|
|
298
|
+
narrowed: selectedRunnable.length < runnableConsidered.length,
|
|
134
299
|
changedSourceFiles,
|
|
135
300
|
changedTestFiles,
|
|
136
301
|
consideredTestFiles: considered,
|
|
302
|
+
supportFiles,
|
|
303
|
+
importEvidenceUsed,
|
|
137
304
|
};
|
|
138
305
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uv prints a per-package inventory when `uv sync` changes the environment:
|
|
3
|
+
*
|
|
4
|
+
* ```text
|
|
5
|
+
* Uninstalled 10 packages in 305ms
|
|
6
|
+
* - coverage==7.15.4
|
|
7
|
+
* - pytest==9.0.3
|
|
8
|
+
* + ruff==0.15.5
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Removals matter because a sync that silently drops the project's own dev
|
|
12
|
+
* tooling leaves every later step unable to run, so the inventory is parsed
|
|
13
|
+
* rather than discarded.
|
|
14
|
+
*/
|
|
15
|
+
export interface SyncInventory {
|
|
16
|
+
installed: string[];
|
|
17
|
+
uninstalled: string[];
|
|
18
|
+
/** One of `uv`'s summary lines, e.g. `Audited 78 packages in 3ms`. */
|
|
19
|
+
summaryLines: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const COUNT_LINE = /^(Installed|Uninstalled|Prepared|Audited|Resolved)\b/;
|
|
23
|
+
const PACKAGE_LINE = /^\s*([+-])\s*([A-Za-z0-9._-]+)(?:==(\S+))?\s*$/;
|
|
24
|
+
|
|
25
|
+
function stripVersion(name: string): string {
|
|
26
|
+
return name.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read the installed/uninstalled inventory out of uv's stdout. Anything that
|
|
31
|
+
* does not match the documented layout is ignored rather than guessed at, so a
|
|
32
|
+
* future uv output change degrades to "no inventory" instead of a wrong one.
|
|
33
|
+
*/
|
|
34
|
+
export function parseSyncOutput(stdout: string, stderr = ''): SyncInventory {
|
|
35
|
+
const installed: string[] = [];
|
|
36
|
+
const uninstalled: string[] = [];
|
|
37
|
+
const summaryLines: string[] = [];
|
|
38
|
+
let section: 'installed' | 'uninstalled' | null = null;
|
|
39
|
+
|
|
40
|
+
for (const rawLine of `${stdout}\n${stderr}`.split(/\r?\n/)) {
|
|
41
|
+
const line = rawLine.trimEnd();
|
|
42
|
+
if (COUNT_LINE.test(line.trim())) {
|
|
43
|
+
summaryLines.push(line.trim());
|
|
44
|
+
if (/^Uninstalled\b/.test(line.trim())) section = 'uninstalled';
|
|
45
|
+
else if (/^Installed\b/.test(line.trim())) section = 'installed';
|
|
46
|
+
else section = null;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const match = line.match(PACKAGE_LINE);
|
|
50
|
+
if (!match || !section) continue;
|
|
51
|
+
const name = stripVersion(match[2]);
|
|
52
|
+
if (match[1] === '-' && section === 'uninstalled') uninstalled.push(name);
|
|
53
|
+
else if (match[1] === '+' && section === 'installed') installed.push(name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { installed, uninstalled, summaryLines };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A sync that removes distributions is worth reporting even when it exits zero:
|
|
61
|
+
* on its own it is not a failure, but it explains every later "command not
|
|
62
|
+
* found" failure in the same run.
|
|
63
|
+
*/
|
|
64
|
+
export function describeRemovals(inventory: SyncInventory): string | undefined {
|
|
65
|
+
if (inventory.uninstalled.length === 0) return undefined;
|
|
66
|
+
const shown = inventory.uninstalled.slice(0, 12).join(', ');
|
|
67
|
+
const rest =
|
|
68
|
+
inventory.uninstalled.length > 12 ? `, … (+${inventory.uninstalled.length - 12})` : '';
|
|
69
|
+
return `uv sync removed ${inventory.uninstalled.length} distribution(s) from .venv: ${shown}${rest}.`;
|
|
70
|
+
}
|
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/dependencies/plan.ts
CHANGED
|
@@ -63,7 +63,16 @@ export interface UndeclaredImport {
|
|
|
63
63
|
files: string[];
|
|
64
64
|
fileCount: number;
|
|
65
65
|
providers: string[];
|
|
66
|
-
|
|
66
|
+
/**
|
|
67
|
+
* The distribution to declare, when the analysing interpreter could determine
|
|
68
|
+
* it. Absent otherwise: `uv add <import name>` would then install a different
|
|
69
|
+
* package, or nothing at all, because import names and distribution names
|
|
70
|
+
* frequently disagree (`wconfig` ships inside `wpyconf`, `yaml` inside
|
|
71
|
+
* `PyYAML`).
|
|
72
|
+
*/
|
|
73
|
+
suggestedDistribution?: string;
|
|
74
|
+
/** True when the suggestion comes from installed metadata rather than a guess. */
|
|
75
|
+
providerKnown: boolean;
|
|
67
76
|
typeCheckingOnly: boolean;
|
|
68
77
|
reason: string;
|
|
69
78
|
}
|
|
@@ -95,6 +104,12 @@ export interface DependencyPlan {
|
|
|
95
104
|
requiresPythonMismatch: { manifest: string; lock: string } | null;
|
|
96
105
|
};
|
|
97
106
|
providerMappingReliable: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Third-party imports the analysing interpreter could not map to an installed
|
|
109
|
+
* distribution. A high count means the interpreter is not the project's own,
|
|
110
|
+
* so "undeclared" may really be "import name differs from distribution name".
|
|
111
|
+
*/
|
|
112
|
+
unmappedImports: number;
|
|
98
113
|
unparsable: { path: string; error: string }[];
|
|
99
114
|
warnings: Diagnostic[];
|
|
100
115
|
notes: Diagnostic[];
|
|
@@ -147,13 +162,19 @@ export function planDependencies(
|
|
|
147
162
|
const runtimeRelevant = !entry.typeCheckingOnly && runtimeFiles.length > 0;
|
|
148
163
|
|
|
149
164
|
if (matched.length === 0) {
|
|
165
|
+
// Prefer the distribution that actually owns the module. The static alias
|
|
166
|
+
// table is a fallback for checkouts where nothing is installed.
|
|
167
|
+
const suggested =
|
|
168
|
+
entry.providers[0] ??
|
|
169
|
+
IMPORT_ALIASES[entry.import]?.[0] ??
|
|
170
|
+
IMPORT_ALIASES[normalizeName(entry.import)]?.[0];
|
|
150
171
|
undeclared.push({
|
|
151
172
|
import: entry.import,
|
|
152
173
|
files: entry.files,
|
|
153
174
|
fileCount: entry.fileCount,
|
|
154
175
|
providers: entry.providers,
|
|
155
|
-
suggestedDistribution:
|
|
156
|
-
|
|
176
|
+
suggestedDistribution: suggested,
|
|
177
|
+
providerKnown: suggested !== undefined,
|
|
157
178
|
typeCheckingOnly: entry.typeCheckingOnly,
|
|
158
179
|
reason: entry.typeCheckingOnly
|
|
159
180
|
? 'imported only under TYPE_CHECKING and declared in neither [project] tables nor uv.lock'
|
|
@@ -199,11 +220,21 @@ export function planDependencies(
|
|
|
199
220
|
entry.files[0],
|
|
200
221
|
),
|
|
201
222
|
);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
223
|
+
const distribution = entry.suggestedDistribution;
|
|
224
|
+
if (distribution) {
|
|
225
|
+
const flag = entry.typeCheckingOnly ? ' --dev' : '';
|
|
226
|
+
suggestions.push({
|
|
227
|
+
message: `Declare ${distribution} with uv add${flag} ${distribution}.`,
|
|
228
|
+
confidence: entry.providers.length ? 'high' : 'medium',
|
|
229
|
+
command: `uv add${flag} ${distribution}`,
|
|
230
|
+
});
|
|
231
|
+
} else {
|
|
232
|
+
// Fabricating a command here would install the wrong package or fail.
|
|
233
|
+
suggestions.push({
|
|
234
|
+
message: `Look up the distribution that provides "${entry.import}" and declare that name: import names and distribution names frequently disagree, and the analysing interpreter could not map this one.`,
|
|
235
|
+
confidence: 'low',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
207
238
|
}
|
|
208
239
|
|
|
209
240
|
for (const entry of misplaced) {
|
|
@@ -252,6 +283,11 @@ export function planDependencies(
|
|
|
252
283
|
});
|
|
253
284
|
}
|
|
254
285
|
|
|
286
|
+
const thirdPartyCount = imports?.thirdParty.length ?? 0;
|
|
287
|
+
const unmappedImports = (imports?.thirdParty ?? []).filter(
|
|
288
|
+
(entry) => entry.providers.length === 0,
|
|
289
|
+
).length;
|
|
290
|
+
|
|
255
291
|
if (imports?.providersUnavailable) {
|
|
256
292
|
notes.push({
|
|
257
293
|
code: 'PROVIDER_MAPPING_HEURISTIC',
|
|
@@ -259,6 +295,12 @@ export function planDependencies(
|
|
|
259
295
|
'No installed distributions were visible to the analysing interpreter, so import-to-distribution mapping relied on a static alias table.',
|
|
260
296
|
severity: 'info',
|
|
261
297
|
});
|
|
298
|
+
} else if (unmappedImports > 0) {
|
|
299
|
+
notes.push({
|
|
300
|
+
code: 'UNMAPPED_IMPORTS',
|
|
301
|
+
message: `${unmappedImports} of ${thirdPartyCount} third-party import(s) could not be mapped to an installed distribution, so their distribution names are unknown rather than merely undeclared.`,
|
|
302
|
+
severity: 'info',
|
|
303
|
+
});
|
|
262
304
|
}
|
|
263
305
|
if (imports && !imports.stdlibAvailable) {
|
|
264
306
|
notes.push({
|
|
@@ -288,7 +330,14 @@ export function planDependencies(
|
|
|
288
330
|
misplaced,
|
|
289
331
|
unused,
|
|
290
332
|
drift,
|
|
291
|
-
|
|
333
|
+
// A partial mapping is disclosed through `unmappedImports`; the claim here is
|
|
334
|
+
// only that the interpreter could see an installed environment at all. When
|
|
335
|
+
// it could see one yet owned none of the project's imports, it is not the
|
|
336
|
+
// project's interpreter and nothing it reports should be trusted.
|
|
337
|
+
providerMappingReliable:
|
|
338
|
+
!(imports?.providersUnavailable ?? true) &&
|
|
339
|
+
!(thirdPartyCount > 0 && unmappedImports === thirdPartyCount),
|
|
340
|
+
unmappedImports,
|
|
292
341
|
unparsable: imports?.unparsable ?? [],
|
|
293
342
|
warnings,
|
|
294
343
|
notes,
|
|
@@ -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
|
+
}
|