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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface TracebackFrame {
|
|
2
|
+
path: string;
|
|
3
|
+
line: number;
|
|
4
|
+
func: string;
|
|
5
|
+
/** True for site-packages, the standard library, and pytest internals. */
|
|
6
|
+
library: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const FRAME_RE = /^\s*File "([^"]+)", line (\d+), in (.+?)\s*$/;
|
|
10
|
+
/**
|
|
11
|
+
* pytest `--tb=short` replaces the `File "..."` form with `path:line: in func`,
|
|
12
|
+
* so both notations must be recognised or short tracebacks yield no frame at all.
|
|
13
|
+
*/
|
|
14
|
+
const PYTEST_FRAME_RE = /^\s*([^\s:]+\.py):(\d+): in (.+?)\s*$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A frame belongs to library code when it sits in an installed distribution or
|
|
18
|
+
* in the interpreter's own library tree. Pointing the agent at those frames is
|
|
19
|
+
* how it ends up editing site-packages instead of the project.
|
|
20
|
+
*/
|
|
21
|
+
export function isLibraryFrame(path: string): boolean {
|
|
22
|
+
const normalized = path.replace(/\\/g, '/');
|
|
23
|
+
return (
|
|
24
|
+
/\/site-packages\//.test(normalized) ||
|
|
25
|
+
/\/dist-packages\//.test(normalized) ||
|
|
26
|
+
/\/lib\/python3\.\d+\//.test(normalized) ||
|
|
27
|
+
/\/python3\.\d+\//.test(normalized) ||
|
|
28
|
+
/<frozen /.test(normalized) ||
|
|
29
|
+
/\/_pytest\//.test(normalized) ||
|
|
30
|
+
/\/pluggy\//.test(normalized)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function extractTracebackFrames(output: string): TracebackFrame[] {
|
|
35
|
+
const frames: TracebackFrame[] = [];
|
|
36
|
+
for (const line of output.split(/\r?\n/)) {
|
|
37
|
+
const match = line.match(FRAME_RE) ?? line.match(PYTEST_FRAME_RE);
|
|
38
|
+
if (!match) continue;
|
|
39
|
+
const path = match[1];
|
|
40
|
+
frames.push({
|
|
41
|
+
path,
|
|
42
|
+
line: Number.parseInt(match[2], 10),
|
|
43
|
+
func: match[3].trim(),
|
|
44
|
+
library: isLibraryFrame(path),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return frames;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function firstUserFrame(frames: TracebackFrame[]): TracebackFrame | undefined {
|
|
51
|
+
for (let index = frames.length - 1; index >= 0; index -= 1) {
|
|
52
|
+
if (!frames[index].library) return frames[index];
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import name and distribution name frequently disagree. The scanner supplies
|
|
3
|
+
* the authoritative mapping when the analysing interpreter has the package
|
|
4
|
+
* installed; this table covers the cases where it does not, so an uninstalled
|
|
5
|
+
* checkout is still analysed correctly.
|
|
6
|
+
*/
|
|
7
|
+
export const IMPORT_ALIASES: Record<string, string[]> = {
|
|
8
|
+
PIL: ['pillow'],
|
|
9
|
+
yaml: ['pyyaml'],
|
|
10
|
+
dateutil: ['python-dateutil'],
|
|
11
|
+
bs4: ['beautifulsoup4'],
|
|
12
|
+
cv2: ['opencv-python', 'opencv-python-headless'],
|
|
13
|
+
sklearn: ['scikit-learn'],
|
|
14
|
+
skimage: ['scikit-image'],
|
|
15
|
+
dotenv: ['python-dotenv'],
|
|
16
|
+
attr: ['attrs'],
|
|
17
|
+
attrs: ['attrs'],
|
|
18
|
+
jwt: ['pyjwt'],
|
|
19
|
+
jose: ['python-jose'],
|
|
20
|
+
serial: ['pyserial'],
|
|
21
|
+
OpenSSL: ['pyopenssl'],
|
|
22
|
+
Crypto: ['pycryptodome'],
|
|
23
|
+
pkg_resources: ['setuptools'],
|
|
24
|
+
MySQLdb: ['mysqlclient'],
|
|
25
|
+
googleapiclient: ['google-api-python-client'],
|
|
26
|
+
github: ['PyGithub'],
|
|
27
|
+
pytest_cov: ['pytest-cov'],
|
|
28
|
+
_pytest: ['pytest'],
|
|
29
|
+
pytest_asyncio: ['pytest-asyncio'],
|
|
30
|
+
psycopg: ['psycopg', 'psycopg-binary'],
|
|
31
|
+
psycopg2: ['psycopg2', 'psycopg2-binary'],
|
|
32
|
+
prometheus_client: ['prometheus-client'],
|
|
33
|
+
grpc: ['grpcio'],
|
|
34
|
+
kafka: ['kafka-python'],
|
|
35
|
+
docker: ['docker'],
|
|
36
|
+
numpy: ['numpy'],
|
|
37
|
+
pandas: ['pandas'],
|
|
38
|
+
ruamel: ['ruamel.yaml'],
|
|
39
|
+
setuptools: ['setuptools'],
|
|
40
|
+
mako: ['Mako'],
|
|
41
|
+
pytz: ['pytz'],
|
|
42
|
+
tzlocal: ['tzlocal'],
|
|
43
|
+
win32com: ['pywin32'],
|
|
44
|
+
lxml: ['lxml'],
|
|
45
|
+
matplotlib: ['matplotlib'],
|
|
46
|
+
seaborn: ['seaborn'],
|
|
47
|
+
sqlalchemy: ['sqlalchemy', 'SQLAlchemy'],
|
|
48
|
+
pydantic: ['pydantic'],
|
|
49
|
+
fastapi: ['fastapi'],
|
|
50
|
+
starlette: ['starlette'],
|
|
51
|
+
uvicorn: ['uvicorn'],
|
|
52
|
+
celery: ['celery'],
|
|
53
|
+
redis: ['redis'],
|
|
54
|
+
boto3: ['boto3'],
|
|
55
|
+
botocore: ['botocore'],
|
|
56
|
+
httpx: ['httpx'],
|
|
57
|
+
aiohttp: ['aiohttp'],
|
|
58
|
+
werkzeug: ['werkzeug'],
|
|
59
|
+
flask: ['flask'],
|
|
60
|
+
django: ['django'],
|
|
61
|
+
jinja2: ['jinja2'],
|
|
62
|
+
typer: ['typer'],
|
|
63
|
+
click: ['click'],
|
|
64
|
+
rich: ['rich'],
|
|
65
|
+
tqdm: ['tqdm'],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Distributions that are normally invoked as a console script, not imported. */
|
|
69
|
+
export const CONSOLE_ONLY: Set<string> = new Set([
|
|
70
|
+
'ruff',
|
|
71
|
+
'mypy',
|
|
72
|
+
'pyright',
|
|
73
|
+
'ty',
|
|
74
|
+
'pytest',
|
|
75
|
+
'pytest-cov',
|
|
76
|
+
'coverage',
|
|
77
|
+
'pre-commit',
|
|
78
|
+
'tox',
|
|
79
|
+
'nox',
|
|
80
|
+
'hatch',
|
|
81
|
+
'hatchling',
|
|
82
|
+
'build',
|
|
83
|
+
'twine',
|
|
84
|
+
'black',
|
|
85
|
+
'isort',
|
|
86
|
+
'flake8',
|
|
87
|
+
'pylint',
|
|
88
|
+
'sphinx',
|
|
89
|
+
'mkdocs',
|
|
90
|
+
'uvicorn',
|
|
91
|
+
'gunicorn',
|
|
92
|
+
'alembic',
|
|
93
|
+
'celery',
|
|
94
|
+
'honcho',
|
|
95
|
+
'maturin',
|
|
96
|
+
'setuptools-scm',
|
|
97
|
+
'pip-audit',
|
|
98
|
+
'bandit',
|
|
99
|
+
'commitizen',
|
|
100
|
+
'towncrier',
|
|
101
|
+
]);
|
package/src/dependencies/plan.ts
CHANGED
|
@@ -3,109 +3,8 @@ import { warn } from '../core/result.ts';
|
|
|
3
3
|
import { isTestFile } from '../project/paths.ts';
|
|
4
4
|
import type { DeclaredDependency, ScanPayload } from '../project/scanner.ts';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* the authoritative mapping when the analysing interpreter has the package
|
|
9
|
-
* installed; this table covers the cases where it does not, so an uninstalled
|
|
10
|
-
* checkout is still analysed correctly.
|
|
11
|
-
*/
|
|
12
|
-
export const IMPORT_ALIASES: Record<string, string[]> = {
|
|
13
|
-
PIL: ['pillow'],
|
|
14
|
-
yaml: ['pyyaml'],
|
|
15
|
-
dateutil: ['python-dateutil'],
|
|
16
|
-
bs4: ['beautifulsoup4'],
|
|
17
|
-
cv2: ['opencv-python', 'opencv-python-headless'],
|
|
18
|
-
sklearn: ['scikit-learn'],
|
|
19
|
-
skimage: ['scikit-image'],
|
|
20
|
-
dotenv: ['python-dotenv'],
|
|
21
|
-
attr: ['attrs'],
|
|
22
|
-
attrs: ['attrs'],
|
|
23
|
-
jwt: ['pyjwt'],
|
|
24
|
-
jose: ['python-jose'],
|
|
25
|
-
serial: ['pyserial'],
|
|
26
|
-
OpenSSL: ['pyopenssl'],
|
|
27
|
-
Crypto: ['pycryptodome'],
|
|
28
|
-
pkg_resources: ['setuptools'],
|
|
29
|
-
MySQLdb: ['mysqlclient'],
|
|
30
|
-
googleapiclient: ['google-api-python-client'],
|
|
31
|
-
github: ['PyGithub'],
|
|
32
|
-
pytest_cov: ['pytest-cov'],
|
|
33
|
-
_pytest: ['pytest'],
|
|
34
|
-
pytest_asyncio: ['pytest-asyncio'],
|
|
35
|
-
psycopg: ['psycopg', 'psycopg-binary'],
|
|
36
|
-
psycopg2: ['psycopg2', 'psycopg2-binary'],
|
|
37
|
-
prometheus_client: ['prometheus-client'],
|
|
38
|
-
grpc: ['grpcio'],
|
|
39
|
-
kafka: ['kafka-python'],
|
|
40
|
-
docker: ['docker'],
|
|
41
|
-
numpy: ['numpy'],
|
|
42
|
-
pandas: ['pandas'],
|
|
43
|
-
ruamel: ['ruamel.yaml'],
|
|
44
|
-
setuptools: ['setuptools'],
|
|
45
|
-
mako: ['Mako'],
|
|
46
|
-
pytz: ['pytz'],
|
|
47
|
-
tzlocal: ['tzlocal'],
|
|
48
|
-
win32com: ['pywin32'],
|
|
49
|
-
lxml: ['lxml'],
|
|
50
|
-
matplotlib: ['matplotlib'],
|
|
51
|
-
seaborn: ['seaborn'],
|
|
52
|
-
sqlalchemy: ['sqlalchemy', 'SQLAlchemy'],
|
|
53
|
-
pydantic: ['pydantic'],
|
|
54
|
-
fastapi: ['fastapi'],
|
|
55
|
-
starlette: ['starlette'],
|
|
56
|
-
uvicorn: ['uvicorn'],
|
|
57
|
-
celery: ['celery'],
|
|
58
|
-
redis: ['redis'],
|
|
59
|
-
boto3: ['boto3'],
|
|
60
|
-
botocore: ['botocore'],
|
|
61
|
-
httpx: ['httpx'],
|
|
62
|
-
aiohttp: ['aiohttp'],
|
|
63
|
-
werkzeug: ['werkzeug'],
|
|
64
|
-
flask: ['flask'],
|
|
65
|
-
django: ['django'],
|
|
66
|
-
jinja2: ['jinja2'],
|
|
67
|
-
typer: ['typer'],
|
|
68
|
-
click: ['click'],
|
|
69
|
-
rich: ['rich'],
|
|
70
|
-
tqdm: ['tqdm'],
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
/** Distributions that are normally invoked as a console script, not imported. */
|
|
74
|
-
const CONSOLE_ONLY = new Set(
|
|
75
|
-
[
|
|
76
|
-
'ruff',
|
|
77
|
-
'mypy',
|
|
78
|
-
'pyright',
|
|
79
|
-
'ty',
|
|
80
|
-
'pytest',
|
|
81
|
-
'pytest-cov',
|
|
82
|
-
'coverage',
|
|
83
|
-
'pre-commit',
|
|
84
|
-
'tox',
|
|
85
|
-
'nox',
|
|
86
|
-
'hatch',
|
|
87
|
-
'hatchling',
|
|
88
|
-
'build',
|
|
89
|
-
'twine',
|
|
90
|
-
'black',
|
|
91
|
-
'isort',
|
|
92
|
-
'flake8',
|
|
93
|
-
'pylint',
|
|
94
|
-
'sphinx',
|
|
95
|
-
'mkdocs',
|
|
96
|
-
'uvicorn',
|
|
97
|
-
'gunicorn',
|
|
98
|
-
'alembic',
|
|
99
|
-
'celery',
|
|
100
|
-
'honcho',
|
|
101
|
-
'maturin',
|
|
102
|
-
'setuptools-scm',
|
|
103
|
-
'pip-audit',
|
|
104
|
-
'bandit',
|
|
105
|
-
'commitizen',
|
|
106
|
-
'towncrier',
|
|
107
|
-
].map((name) => name),
|
|
108
|
-
);
|
|
6
|
+
import { CONSOLE_ONLY, IMPORT_ALIASES } from './aliases.ts';
|
|
7
|
+
export { CONSOLE_ONLY, IMPORT_ALIASES };
|
|
109
8
|
|
|
110
9
|
const NORMALIZE_RE = /[-_.]+/g;
|
|
111
10
|
|
|
@@ -164,7 +63,16 @@ export interface UndeclaredImport {
|
|
|
164
63
|
files: string[];
|
|
165
64
|
fileCount: number;
|
|
166
65
|
providers: string[];
|
|
167
|
-
|
|
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;
|
|
168
76
|
typeCheckingOnly: boolean;
|
|
169
77
|
reason: string;
|
|
170
78
|
}
|
|
@@ -196,6 +104,12 @@ export interface DependencyPlan {
|
|
|
196
104
|
requiresPythonMismatch: { manifest: string; lock: string } | null;
|
|
197
105
|
};
|
|
198
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;
|
|
199
113
|
unparsable: { path: string; error: string }[];
|
|
200
114
|
warnings: Diagnostic[];
|
|
201
115
|
notes: Diagnostic[];
|
|
@@ -248,13 +162,19 @@ export function planDependencies(
|
|
|
248
162
|
const runtimeRelevant = !entry.typeCheckingOnly && runtimeFiles.length > 0;
|
|
249
163
|
|
|
250
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];
|
|
251
171
|
undeclared.push({
|
|
252
172
|
import: entry.import,
|
|
253
173
|
files: entry.files,
|
|
254
174
|
fileCount: entry.fileCount,
|
|
255
175
|
providers: entry.providers,
|
|
256
|
-
suggestedDistribution:
|
|
257
|
-
|
|
176
|
+
suggestedDistribution: suggested,
|
|
177
|
+
providerKnown: suggested !== undefined,
|
|
258
178
|
typeCheckingOnly: entry.typeCheckingOnly,
|
|
259
179
|
reason: entry.typeCheckingOnly
|
|
260
180
|
? 'imported only under TYPE_CHECKING and declared in neither [project] tables nor uv.lock'
|
|
@@ -300,11 +220,21 @@ export function planDependencies(
|
|
|
300
220
|
entry.files[0],
|
|
301
221
|
),
|
|
302
222
|
);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
+
}
|
|
308
238
|
}
|
|
309
239
|
|
|
310
240
|
for (const entry of misplaced) {
|
|
@@ -353,6 +283,11 @@ export function planDependencies(
|
|
|
353
283
|
});
|
|
354
284
|
}
|
|
355
285
|
|
|
286
|
+
const thirdPartyCount = imports?.thirdParty.length ?? 0;
|
|
287
|
+
const unmappedImports = (imports?.thirdParty ?? []).filter(
|
|
288
|
+
(entry) => entry.providers.length === 0,
|
|
289
|
+
).length;
|
|
290
|
+
|
|
356
291
|
if (imports?.providersUnavailable) {
|
|
357
292
|
notes.push({
|
|
358
293
|
code: 'PROVIDER_MAPPING_HEURISTIC',
|
|
@@ -360,6 +295,12 @@ export function planDependencies(
|
|
|
360
295
|
'No installed distributions were visible to the analysing interpreter, so import-to-distribution mapping relied on a static alias table.',
|
|
361
296
|
severity: 'info',
|
|
362
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
|
+
});
|
|
363
304
|
}
|
|
364
305
|
if (imports && !imports.stdlibAvailable) {
|
|
365
306
|
notes.push({
|
|
@@ -389,7 +330,14 @@ export function planDependencies(
|
|
|
389
330
|
misplaced,
|
|
390
331
|
unused,
|
|
391
332
|
drift,
|
|
392
|
-
|
|
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,
|
|
393
341
|
unparsable: imports?.unparsable ?? [],
|
|
394
342
|
warnings,
|
|
395
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
|
+
}
|