pi-python-helper 0.1.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 +60 -0
- package/CONTRIBUTING.md +77 -0
- package/LICENSE +17 -0
- package/README.md +172 -0
- package/SECURITY.md +24 -0
- package/docs/compatibility.md +64 -0
- package/docs/tools.md +518 -0
- package/extensions/index.ts +28 -0
- package/extensions/shared.ts +48 -0
- package/extensions/tools/dependencies.ts +203 -0
- package/extensions/tools/environment.ts +171 -0
- package/extensions/tools/testing.ts +350 -0
- package/extensions/tools/validation.ts +344 -0
- package/helpers/scan_project.py +777 -0
- package/package.json +73 -0
- package/skills/python-development/SKILL.md +45 -0
- package/src/build/commands.ts +65 -0
- package/src/build/discover.ts +98 -0
- package/src/build/failure.ts +452 -0
- package/src/build/pytest.ts +118 -0
- package/src/build/selection.ts +138 -0
- package/src/build/staleness.ts +117 -0
- package/src/core/result.ts +102 -0
- package/src/core/runner.ts +96 -0
- package/src/core/safety.ts +181 -0
- package/src/core/version.ts +20 -0
- package/src/dependencies/plan.ts +398 -0
- package/src/environment/discovery.ts +166 -0
- package/src/environment/tools.ts +141 -0
- package/src/project/conformance.ts +343 -0
- package/src/project/inspect.ts +351 -0
- package/src/project/installed.ts +225 -0
- package/src/project/paths.ts +74 -0
- package/src/project/root.ts +72 -0
- package/src/project/scanner.ts +243 -0
- package/src/validation/bundle.ts +65 -0
- package/src/validation/evidence.ts +33 -0
- package/src/validation/tdd.ts +62 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import type { Diagnostic, Suggestion } from '../core/result.ts';
|
|
2
|
+
import { warn } from '../core/result.ts';
|
|
3
|
+
import { isTestFile } from '../project/paths.ts';
|
|
4
|
+
import type { DeclaredDependency, ScanPayload } from '../project/scanner.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Import name and distribution name frequently disagree. The scanner supplies
|
|
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
|
+
);
|
|
109
|
+
|
|
110
|
+
const NORMALIZE_RE = /[-_.]+/g;
|
|
111
|
+
|
|
112
|
+
/** PEP 503 normalization: case and `-`/`_`/`.` are not significant. */
|
|
113
|
+
export function normalizeName(name: string): string {
|
|
114
|
+
return name.replace(NORMALIZE_RE, '-').trim().toLowerCase();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function distributionCandidates(importName: string, providers: string[]): string[] {
|
|
118
|
+
const normalized = normalizeName(importName);
|
|
119
|
+
const candidates = new Set<string>();
|
|
120
|
+
for (const provider of providers) candidates.add(normalizeName(provider));
|
|
121
|
+
for (const alias of IMPORT_ALIASES[importName] ?? []) candidates.add(normalizeName(alias));
|
|
122
|
+
for (const alias of IMPORT_ALIASES[normalized] ?? []) candidates.add(normalizeName(alias));
|
|
123
|
+
candidates.add(normalized);
|
|
124
|
+
candidates.add(`python-${normalized}`);
|
|
125
|
+
candidates.add(`${normalized}-python`);
|
|
126
|
+
return [...candidates].filter((candidate) => candidate.length > 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface DeclaredEntry {
|
|
130
|
+
name: string;
|
|
131
|
+
normalized: string;
|
|
132
|
+
groups: string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type DependencyGroup = string;
|
|
136
|
+
|
|
137
|
+
export function buildDeclaredIndex(manifest: {
|
|
138
|
+
dependencies: DeclaredDependency[];
|
|
139
|
+
optionalDependencies: Record<string, DeclaredDependency[]>;
|
|
140
|
+
dependencyGroups: Record<string, DeclaredDependency[]>;
|
|
141
|
+
}): Map<string, DeclaredEntry> {
|
|
142
|
+
const index = new Map<string, DeclaredEntry>();
|
|
143
|
+
const add = (dependency: DeclaredDependency, group: DependencyGroup) => {
|
|
144
|
+
const normalized = normalizeName(dependency.normalized || dependency.name);
|
|
145
|
+
const existing = index.get(normalized);
|
|
146
|
+
if (existing) {
|
|
147
|
+
if (!existing.groups.includes(group)) existing.groups.push(group);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
index.set(normalized, { name: dependency.name, normalized, groups: [group] });
|
|
151
|
+
};
|
|
152
|
+
for (const dependency of manifest.dependencies) add(dependency, 'runtime');
|
|
153
|
+
for (const [extra, list] of Object.entries(manifest.optionalDependencies)) {
|
|
154
|
+
for (const dependency of list) add(dependency, `optional:${extra}`);
|
|
155
|
+
}
|
|
156
|
+
for (const [group, list] of Object.entries(manifest.dependencyGroups)) {
|
|
157
|
+
for (const dependency of list) add(dependency, `group:${group}`);
|
|
158
|
+
}
|
|
159
|
+
return index;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface UndeclaredImport {
|
|
163
|
+
import: string;
|
|
164
|
+
files: string[];
|
|
165
|
+
fileCount: number;
|
|
166
|
+
providers: string[];
|
|
167
|
+
suggestedDistribution: string;
|
|
168
|
+
typeCheckingOnly: boolean;
|
|
169
|
+
reason: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface MisplacedDependency {
|
|
173
|
+
import: string;
|
|
174
|
+
distribution: string;
|
|
175
|
+
declaredIn: string[];
|
|
176
|
+
runtimeFiles: string[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface UnusedDeclaration {
|
|
180
|
+
name: string;
|
|
181
|
+
normalized: string;
|
|
182
|
+
groups: string[];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface DependencyPlan {
|
|
186
|
+
declaredCount: number;
|
|
187
|
+
declared: DeclaredEntry[];
|
|
188
|
+
thirdPartyImportCount: number;
|
|
189
|
+
undeclared: UndeclaredImport[];
|
|
190
|
+
misplaced: MisplacedDependency[];
|
|
191
|
+
unused: UnusedDeclaration[];
|
|
192
|
+
drift: {
|
|
193
|
+
lockPresent: boolean;
|
|
194
|
+
missingFromLock: string[];
|
|
195
|
+
unsatisfiedInLock: { name: string; specifier: string; locked: string }[];
|
|
196
|
+
requiresPythonMismatch: { manifest: string; lock: string } | null;
|
|
197
|
+
};
|
|
198
|
+
providerMappingReliable: boolean;
|
|
199
|
+
unparsable: { path: string; error: string }[];
|
|
200
|
+
warnings: Diagnostic[];
|
|
201
|
+
notes: Diagnostic[];
|
|
202
|
+
suggestions: Suggestion[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface DependencyPlanOptions {
|
|
206
|
+
/** Reporting declared-but-unimported packages produces false positives by design. */
|
|
207
|
+
includeUnused?: boolean;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Compare what the code imports with what the project declares.
|
|
212
|
+
*
|
|
213
|
+
* Three questions are answered, in decreasing confidence:
|
|
214
|
+
* 1. an imported distribution that is declared nowhere,
|
|
215
|
+
* 2. an imported distribution declared only in a dev group or extra while
|
|
216
|
+
* production code imports it, and
|
|
217
|
+
* 3. an imported distribution missing from `uv.lock` or locked below its
|
|
218
|
+
* declared specifier.
|
|
219
|
+
*/
|
|
220
|
+
export function planDependencies(
|
|
221
|
+
payload: ScanPayload,
|
|
222
|
+
options: DependencyPlanOptions = {},
|
|
223
|
+
): DependencyPlan {
|
|
224
|
+
const imports = payload.imports;
|
|
225
|
+
const manifest = payload.manifest;
|
|
226
|
+
const comparison = payload.lockComparison;
|
|
227
|
+
const warnings: Diagnostic[] = [];
|
|
228
|
+
const notes: Diagnostic[] = [];
|
|
229
|
+
const suggestions: Suggestion[] = [];
|
|
230
|
+
|
|
231
|
+
const declaredIndex = manifest ? buildDeclaredIndex(manifest) : new Map<string, DeclaredEntry>();
|
|
232
|
+
|
|
233
|
+
const undeclared: UndeclaredImport[] = [];
|
|
234
|
+
const misplaced: MisplacedDependency[] = [];
|
|
235
|
+
const usedNormalized = new Set<string>();
|
|
236
|
+
|
|
237
|
+
for (const entry of imports?.thirdParty ?? []) {
|
|
238
|
+
const candidates = distributionCandidates(entry.import, entry.providers);
|
|
239
|
+
const matched = candidates
|
|
240
|
+
.map((candidate) => declaredIndex.get(candidate))
|
|
241
|
+
.filter((value): value is DeclaredEntry => value !== undefined);
|
|
242
|
+
for (const declaration of matched) usedNormalized.add(declaration.normalized);
|
|
243
|
+
|
|
244
|
+
const typeCheckingFiles = new Set(entry.typeCheckingFiles ?? []);
|
|
245
|
+
const runtimeFiles = entry.files.filter(
|
|
246
|
+
(file) => !isTestFile(file) && !typeCheckingFiles.has(file),
|
|
247
|
+
);
|
|
248
|
+
const runtimeRelevant = !entry.typeCheckingOnly && runtimeFiles.length > 0;
|
|
249
|
+
|
|
250
|
+
if (matched.length === 0) {
|
|
251
|
+
undeclared.push({
|
|
252
|
+
import: entry.import,
|
|
253
|
+
files: entry.files,
|
|
254
|
+
fileCount: entry.fileCount,
|
|
255
|
+
providers: entry.providers,
|
|
256
|
+
suggestedDistribution:
|
|
257
|
+
entry.providers[0] ?? IMPORT_ALIASES[entry.import]?.[0] ?? entry.import,
|
|
258
|
+
typeCheckingOnly: entry.typeCheckingOnly,
|
|
259
|
+
reason: entry.typeCheckingOnly
|
|
260
|
+
? 'imported only under TYPE_CHECKING and declared in neither [project] tables nor uv.lock'
|
|
261
|
+
: 'imported by project code but declared in no dependency group',
|
|
262
|
+
});
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const hasRuntimeDeclaration = matched.some((declaration) =>
|
|
267
|
+
declaration.groups.includes('runtime'),
|
|
268
|
+
);
|
|
269
|
+
if (runtimeRelevant && !hasRuntimeDeclaration) {
|
|
270
|
+
const groups = [...new Set(matched.flatMap((declaration) => declaration.groups))];
|
|
271
|
+
misplaced.push({
|
|
272
|
+
import: entry.import,
|
|
273
|
+
distribution: matched[0].name,
|
|
274
|
+
declaredIn: groups,
|
|
275
|
+
runtimeFiles,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const unused: UnusedDeclaration[] = [];
|
|
281
|
+
if (options.includeUnused) {
|
|
282
|
+
for (const declaration of declaredIndex.values()) {
|
|
283
|
+
if (usedNormalized.has(declaration.normalized)) continue;
|
|
284
|
+
if (CONSOLE_ONLY.has(declaration.normalized)) continue;
|
|
285
|
+
unused.push({
|
|
286
|
+
name: declaration.name,
|
|
287
|
+
normalized: declaration.normalized,
|
|
288
|
+
groups: declaration.groups,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const entry of undeclared) {
|
|
294
|
+
warnings.push(
|
|
295
|
+
warn(
|
|
296
|
+
entry.typeCheckingOnly ? 'UNDECLARED_TYPE_ONLY_IMPORT' : 'UNDECLARED_IMPORT',
|
|
297
|
+
`"${entry.import}" is imported${entry.files.length ? ` in ${entry.files[0]}` : ''}${
|
|
298
|
+
entry.fileCount > 1 ? ` and ${entry.fileCount - 1} other file(s)` : ''
|
|
299
|
+
} but is declared in no dependency group.`,
|
|
300
|
+
entry.files[0],
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
suggestions.push({
|
|
304
|
+
message: `Declare ${entry.suggestedDistribution} with uv add${entry.typeCheckingOnly ? ' --dev' : ''} ${entry.suggestedDistribution}.`,
|
|
305
|
+
confidence: entry.providers.length ? 'high' : 'medium',
|
|
306
|
+
command: `uv add${entry.typeCheckingOnly ? ' --dev' : ''} ${entry.suggestedDistribution}`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const entry of misplaced) {
|
|
311
|
+
warnings.push(
|
|
312
|
+
warn(
|
|
313
|
+
'RUNTIME_DEPENDENCY_IN_DEV_GROUP',
|
|
314
|
+
`"${entry.import}" is imported by production code (${entry.runtimeFiles[0]}) but "${entry.distribution}" is declared only in ${entry.declaredIn.join(', ')}.`,
|
|
315
|
+
entry.runtimeFiles[0],
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
suggestions.push({
|
|
319
|
+
message: `Move ${entry.distribution} from ${entry.declaredIn.join(', ')} into [project] dependencies.`,
|
|
320
|
+
confidence: 'high',
|
|
321
|
+
command: `uv add ${entry.distribution}`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (const entry of unused) {
|
|
326
|
+
notes.push({
|
|
327
|
+
code: 'UNUSED_DECLARATION',
|
|
328
|
+
message: `"${entry.name}" is declared in ${entry.groups.join(', ')} but no project file imports it (console-only tools are excluded; verify before removing).`,
|
|
329
|
+
severity: 'info',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const drift = {
|
|
334
|
+
lockPresent: comparison !== undefined && (payload.lock?.present ?? false),
|
|
335
|
+
missingFromLock: comparison?.missingFromLock ?? [],
|
|
336
|
+
unsatisfiedInLock: comparison?.unsatisfiedInLock ?? [],
|
|
337
|
+
requiresPythonMismatch: comparison?.requiresPythonMismatch ?? null,
|
|
338
|
+
};
|
|
339
|
+
if (drift.lockPresent && drift.missingFromLock.length + drift.unsatisfiedInLock.length > 0) {
|
|
340
|
+
warnings.push(
|
|
341
|
+
warn(
|
|
342
|
+
'LOCKFILE_DRIFT',
|
|
343
|
+
`uv.lock disagrees with pyproject.toml (${drift.missingFromLock.length} missing, ${
|
|
344
|
+
drift.unsatisfiedInLock.length
|
|
345
|
+
} unsatisfied). Run uv lock before trusting the environment.`,
|
|
346
|
+
payload.lock?.path ?? undefined,
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
suggestions.push({
|
|
350
|
+
message: 'Run uv lock to resynchronise uv.lock.',
|
|
351
|
+
confidence: 'high',
|
|
352
|
+
command: 'uv lock',
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (imports?.providersUnavailable) {
|
|
357
|
+
notes.push({
|
|
358
|
+
code: 'PROVIDER_MAPPING_HEURISTIC',
|
|
359
|
+
message:
|
|
360
|
+
'No installed distributions were visible to the analysing interpreter, so import-to-distribution mapping relied on a static alias table.',
|
|
361
|
+
severity: 'info',
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
if (imports && !imports.stdlibAvailable) {
|
|
365
|
+
notes.push({
|
|
366
|
+
code: 'STDLIB_LIST_HEURISTIC',
|
|
367
|
+
message:
|
|
368
|
+
'The analysing interpreter predates sys.stdlib_module_names, so standard-library detection used a reduced list and third-party results may over-report.',
|
|
369
|
+
severity: 'info',
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const entry of imports?.unparsable ?? []) {
|
|
374
|
+
notes.push({
|
|
375
|
+
code: 'UNPARSABLE_FILE',
|
|
376
|
+
message: `${entry.path} could not be parsed: ${entry.error}`,
|
|
377
|
+
severity: 'info',
|
|
378
|
+
path: entry.path,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
declaredCount: declaredIndex.size,
|
|
384
|
+
declared: [...declaredIndex.values()].sort((left, right) =>
|
|
385
|
+
left.normalized.localeCompare(right.normalized),
|
|
386
|
+
),
|
|
387
|
+
thirdPartyImportCount: imports?.thirdParty.length ?? 0,
|
|
388
|
+
undeclared,
|
|
389
|
+
misplaced,
|
|
390
|
+
unused,
|
|
391
|
+
drift,
|
|
392
|
+
providerMappingReliable: !(imports?.providersUnavailable ?? true),
|
|
393
|
+
unparsable: imports?.unparsable ?? [],
|
|
394
|
+
warnings,
|
|
395
|
+
notes,
|
|
396
|
+
suggestions,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { type Diagnostic, warn } from '../core/result.ts';
|
|
2
|
+
import { runCommand } from '../core/runner.ts';
|
|
3
|
+
import { findProjectRoot, findVenvDir, isFile } from '../project/root.ts';
|
|
4
|
+
import {
|
|
5
|
+
type EnvironmentSection,
|
|
6
|
+
type LockPackage,
|
|
7
|
+
resolveInterpreter,
|
|
8
|
+
runScanProject,
|
|
9
|
+
} from '../project/scanner.ts';
|
|
10
|
+
import { inspectTools, type ToolAvailability } from './tools.ts';
|
|
11
|
+
|
|
12
|
+
export interface PythonEnvironment {
|
|
13
|
+
interpreter?: string;
|
|
14
|
+
python?: EnvironmentSection;
|
|
15
|
+
projectRoot?: string;
|
|
16
|
+
venvDir?: string;
|
|
17
|
+
uv: { available: boolean; version?: string; lockPresent: boolean };
|
|
18
|
+
tools: ToolAvailability[];
|
|
19
|
+
warnings: Diagnostic[];
|
|
20
|
+
suggestions: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseVersion(output: string): string | undefined {
|
|
24
|
+
const match = output.match(/(\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?)/);
|
|
25
|
+
return match?.[1];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Report the version of a host CLI. Reserved for `uv`, which cannot be resolved
|
|
30
|
+
* from the project lockfile; project tools get their version from `uv.lock`.
|
|
31
|
+
*/
|
|
32
|
+
export async function toolVersion(
|
|
33
|
+
cwd: string,
|
|
34
|
+
name: string,
|
|
35
|
+
signal?: AbortSignal,
|
|
36
|
+
): Promise<string | undefined> {
|
|
37
|
+
const run = await runCommand(name, ['--version'], {
|
|
38
|
+
cwd,
|
|
39
|
+
signal,
|
|
40
|
+
timeoutMs: 5000,
|
|
41
|
+
maxBytes: 4096,
|
|
42
|
+
});
|
|
43
|
+
if (run.code !== 0) return undefined;
|
|
44
|
+
return parseVersion(`${run.stdout}\n${run.stderr}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Describe the interpreter, project root, environment, and tool availability.
|
|
49
|
+
*
|
|
50
|
+
* A single bounded scanner call supplies both the interpreter facts and the
|
|
51
|
+
* lockfile, so availability is resolved from filesystem probes plus the
|
|
52
|
+
* lockfile instead of one subprocess per tool.
|
|
53
|
+
*/
|
|
54
|
+
export async function detectPythonEnvironment(
|
|
55
|
+
cwd: string,
|
|
56
|
+
signal?: AbortSignal,
|
|
57
|
+
): Promise<PythonEnvironment> {
|
|
58
|
+
const warnings: Diagnostic[] = [];
|
|
59
|
+
const suggestions: string[] = [];
|
|
60
|
+
const interpreter = await resolveInterpreter(cwd, signal);
|
|
61
|
+
const projectRoot = await findProjectRoot(cwd);
|
|
62
|
+
const scanRoot = projectRoot ?? cwd;
|
|
63
|
+
|
|
64
|
+
let python: EnvironmentSection | undefined;
|
|
65
|
+
let lockPackages: LockPackage[] = [];
|
|
66
|
+
if (interpreter) {
|
|
67
|
+
const scan = await runScanProject(
|
|
68
|
+
cwd,
|
|
69
|
+
{ root: scanRoot, mode: 'environment,manifest' },
|
|
70
|
+
signal,
|
|
71
|
+
);
|
|
72
|
+
if (scan.ok) {
|
|
73
|
+
python = scan.payload?.environment;
|
|
74
|
+
lockPackages = scan.payload?.lock?.packages ?? [];
|
|
75
|
+
} else if (scan.message) {
|
|
76
|
+
warnings.push(warn(scan.code ?? 'SCANNER_FAILED', scan.message));
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
warnings.push(
|
|
80
|
+
warn(
|
|
81
|
+
'PYTHON_NOT_FOUND',
|
|
82
|
+
'No Python 3 interpreter was found on PATH; every analysis tool degrades to static inspection only.',
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
suggestions.push('Install Python 3.11 or newer so pyproject.toml and uv.lock can be parsed.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!projectRoot) {
|
|
89
|
+
warnings.push(
|
|
90
|
+
warn(
|
|
91
|
+
'PROJECT_NOT_FOUND',
|
|
92
|
+
'No pyproject.toml, uv.lock, or setup.py was found from this directory.',
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
suggestions.push(
|
|
96
|
+
'Run uv init to create a uv-managed project, or change to an existing project directory.',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const venvDir = projectRoot ? await findVenvDir(projectRoot) : undefined;
|
|
101
|
+
const lockPresent = projectRoot ? await isFile(`${projectRoot}/uv.lock`) : false;
|
|
102
|
+
const tools = await inspectTools({ venvDir, lockPackages });
|
|
103
|
+
|
|
104
|
+
const uvVersion = await toolVersion(cwd, 'uv', signal);
|
|
105
|
+
if (!uvVersion) {
|
|
106
|
+
warnings.push(
|
|
107
|
+
warn(
|
|
108
|
+
'UV_NOT_AVAILABLE',
|
|
109
|
+
'The uv CLI was not found on PATH; commands cannot be previewed reliably.',
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
suggestions.push('Install uv (https://docs.astral.sh/uv/) before build or test operations.');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (projectRoot && python && !python.inVirtualEnvironment && !venvDir) {
|
|
116
|
+
warnings.push(
|
|
117
|
+
warn(
|
|
118
|
+
'NO_VIRTUAL_ENVIRONMENT',
|
|
119
|
+
'The resolved interpreter is not inside a virtual environment and no .venv directory exists at the project root.',
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
suggestions.push('Run uv sync to create .venv and install the locked dependencies.');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (projectRoot && python && python.inVirtualEnvironment && venvDir && python.virtualEnv) {
|
|
126
|
+
const active = python.virtualEnv.replace(/\/+$/, '');
|
|
127
|
+
if (active !== venvDir) {
|
|
128
|
+
warnings.push(
|
|
129
|
+
warn(
|
|
130
|
+
'VIRTUAL_ENVIRONMENT_MISMATCH',
|
|
131
|
+
`VIRTUAL_ENV points at ${active} but the project environment is ${venvDir}.`,
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
suggestions.push(
|
|
135
|
+
'Deactivate the unrelated environment, or run uv sync to refresh the project .venv.',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (python && !python.tomlAvailable) {
|
|
141
|
+
warnings.push(
|
|
142
|
+
warn(
|
|
143
|
+
'TOML_PARSER_UNAVAILABLE',
|
|
144
|
+
`Python ${python.version} has no tomllib; pyproject.toml and uv.lock analysis is degraded.`,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
suggestions.push(
|
|
148
|
+
'Use Python 3.11+ (or install tomli in the analysing interpreter) to enable manifest analysis.',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (projectRoot && !lockPresent) {
|
|
153
|
+
suggestions.push('Run uv lock to create uv.lock so dependency drift can be detected.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
interpreter,
|
|
158
|
+
python,
|
|
159
|
+
projectRoot,
|
|
160
|
+
venvDir,
|
|
161
|
+
uv: { available: uvVersion !== undefined, version: uvVersion, lockPresent },
|
|
162
|
+
tools,
|
|
163
|
+
warnings,
|
|
164
|
+
suggestions,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { access, stat } from 'node:fs/promises';
|
|
3
|
+
import { delimiter, join } from 'node:path';
|
|
4
|
+
import { normalizeDistributionName } from '../project/installed.ts';
|
|
5
|
+
import type { LockPackage } from '../project/scanner.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tools worth reporting to the agent. Lint and type diagnostics are delegated
|
|
9
|
+
* to other extensions, but the agent still needs to know whether the project
|
|
10
|
+
* environment provides these executables.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_PROBED_TOOLS = [
|
|
13
|
+
'pytest',
|
|
14
|
+
'ruff',
|
|
15
|
+
'mypy',
|
|
16
|
+
'ty',
|
|
17
|
+
'pyright',
|
|
18
|
+
'pre-commit',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export type ToolVersionSource = 'lock' | 'cli' | 'unknown';
|
|
22
|
+
|
|
23
|
+
export interface ToolAvailability {
|
|
24
|
+
name: string;
|
|
25
|
+
/** True when the tool is runnable, either locally or through `uv run`. */
|
|
26
|
+
available: boolean;
|
|
27
|
+
/** True when `uv.lock` records the distribution, so `uv sync` can install it. */
|
|
28
|
+
declared: boolean;
|
|
29
|
+
/** Absolute path when an executable was found. */
|
|
30
|
+
executable?: string;
|
|
31
|
+
/** Where the executable was found: the project environment or the host PATH. */
|
|
32
|
+
origin?: 'venv' | 'path';
|
|
33
|
+
version?: string;
|
|
34
|
+
versionSource: ToolVersionSource;
|
|
35
|
+
preferredInvocation: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const WINDOWS = process.platform === 'win32';
|
|
39
|
+
const WINDOWS_EXTENSIONS = ['.exe', '.cmd', '.bat', '.ps1'];
|
|
40
|
+
|
|
41
|
+
function executableNames(name: string): string[] {
|
|
42
|
+
return WINDOWS ? WINDOWS_EXTENSIONS.map((extension) => `${name}${extension}`) : [name];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function isExecutableFile(path: string): Promise<boolean> {
|
|
46
|
+
try {
|
|
47
|
+
const info = await stat(path);
|
|
48
|
+
if (!info.isFile()) return false;
|
|
49
|
+
if (WINDOWS) return true;
|
|
50
|
+
await access(path, constants.X_OK);
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find a console script inside a virtual environment. uv puts scripts in `bin`
|
|
59
|
+
* on POSIX and `Scripts` on Windows; both are checked so a project analysed on
|
|
60
|
+
* either platform reports the same tools.
|
|
61
|
+
*/
|
|
62
|
+
export async function findVenvScript(venvDir: string, name: string): Promise<string | undefined> {
|
|
63
|
+
const directories = WINDOWS
|
|
64
|
+
? [join(venvDir, 'Scripts'), join(venvDir, 'bin')]
|
|
65
|
+
: [join(venvDir, 'bin'), join(venvDir, 'Scripts')];
|
|
66
|
+
for (const directory of directories) {
|
|
67
|
+
for (const candidate of executableNames(name)) {
|
|
68
|
+
const path = join(directory, candidate);
|
|
69
|
+
if (await isExecutableFile(path)) return path;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resolve an executable on PATH without spawning a process. */
|
|
76
|
+
export async function resolveOnPath(
|
|
77
|
+
name: string,
|
|
78
|
+
pathValue: string = process.env.PATH ?? '',
|
|
79
|
+
): Promise<string | undefined> {
|
|
80
|
+
for (const directory of pathValue.split(delimiter)) {
|
|
81
|
+
if (!directory) continue;
|
|
82
|
+
for (const candidate of executableNames(name)) {
|
|
83
|
+
const path = join(directory, candidate);
|
|
84
|
+
if (await isExecutableFile(path)) return path;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Versions the lockfile pins, keyed by normalized distribution name. */
|
|
91
|
+
export function lockedVersions(lockPackages: LockPackage[] | undefined): Map<string, string> {
|
|
92
|
+
const versions = new Map<string, string>();
|
|
93
|
+
for (const entry of lockPackages ?? []) {
|
|
94
|
+
if (entry.version) versions.set(entry.normalized, entry.version);
|
|
95
|
+
}
|
|
96
|
+
return versions;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ToolProbeInput {
|
|
100
|
+
venvDir?: string;
|
|
101
|
+
lockPackages?: LockPackage[];
|
|
102
|
+
names?: readonly string[];
|
|
103
|
+
pathValue?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Report which tools the project can run, using only filesystem probes and the
|
|
108
|
+
* already-parsed lockfile.
|
|
109
|
+
*
|
|
110
|
+
* Executing `--version` per tool was the single largest cost in
|
|
111
|
+
* `py_environment` (pytest alone spent 154 ms importing itself), and it reported
|
|
112
|
+
* the *host* version rather than the one the project will use. The lockfile is
|
|
113
|
+
* the authoritative source for a project tool, and PATH or `.venv/bin`
|
|
114
|
+
* presence answers the availability question without a subprocess.
|
|
115
|
+
*/
|
|
116
|
+
export async function inspectTools(input: ToolProbeInput = {}): Promise<ToolAvailability[]> {
|
|
117
|
+
const names = input.names ?? DEFAULT_PROBED_TOOLS;
|
|
118
|
+
const locked = lockedVersions(input.lockPackages);
|
|
119
|
+
|
|
120
|
+
return Promise.all(
|
|
121
|
+
names.map(async (name) => {
|
|
122
|
+
const normalized = normalizeDistributionName(name);
|
|
123
|
+
const venvScript = input.venvDir ? await findVenvScript(input.venvDir, name) : undefined;
|
|
124
|
+
const pathScript = venvScript ? undefined : await resolveOnPath(name, input.pathValue);
|
|
125
|
+
const lockVersion = locked.get(normalized);
|
|
126
|
+
const executable = venvScript ?? pathScript;
|
|
127
|
+
const origin = venvScript ? 'venv' : pathScript ? 'path' : undefined;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
name,
|
|
131
|
+
available: Boolean(executable) || lockVersion !== undefined,
|
|
132
|
+
declared: lockVersion !== undefined,
|
|
133
|
+
executable,
|
|
134
|
+
origin,
|
|
135
|
+
version: lockVersion,
|
|
136
|
+
versionSource: lockVersion ? 'lock' : 'unknown',
|
|
137
|
+
preferredInvocation: `uv run --frozen ${name}`,
|
|
138
|
+
} satisfies ToolAvailability;
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|