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.
@@ -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 actual = installedByNormalized.get(entry.normalized);
195
- const localProject = isLocalProjectEntry(entry);
196
- const rootProject = isRootProjectEntry(entry, projectName);
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: entry.name,
205
- expected: entry.version ?? undefined,
243
+ name: primary.name,
244
+ expected: primary.version ?? undefined,
206
245
  message: rootProject
207
- ? `uv.lock records "${entry.name}" as an editable install, but it is absent from .venv. The project is not importable and no test can exercise it.`
208
- : `uv.lock records the local project "${entry.name}" as an editable install, but it is absent from .venv.`,
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(entry.normalized)) {
249
+ } else if (required.has(normalized)) {
211
250
  counts.missing += 1;
212
251
  findings.push({
213
252
  code: 'INSTALLED_PACKAGE_MISSING',
214
- name: entry.name,
215
- expected: entry.version ?? undefined,
216
- message: `"${entry.name}" is locked and required unconditionally, but it is not installed in .venv.`,
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 += 1;
221
- conditionalAbsent.push(`${entry.name}@${entry.version ?? '?'}`);
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) checks.projectInstalled = true;
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: entry.name,
277
+ name: primary.name,
233
278
  actual: actual.version,
234
279
  message: rootProject
235
- ? `"${entry.name}" is installed from a materialised copy instead of an editable link, so tests would import a stale snapshot of the sources.`
236
- : `The local project "${entry.name}" is installed from a materialised copy instead of an editable link.`,
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
- if (entry.version && actual.version !== entry.version) {
247
- counts.mismatched += 1;
248
- findings.push({
249
- code: 'INSTALLED_VERSION_MISMATCH',
250
- name: entry.name,
251
- expected: entry.version,
252
- actual: actual.version,
253
- message: `"${entry.name}" is locked at ${entry.version} but ${actual.version} is installed in .venv.`,
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',
@@ -5,6 +5,7 @@ import {
5
5
  type ConformanceReport,
6
6
  } from './conformance.ts';
7
7
  import type { InstalledEnvironment } from './installed.ts';
8
+ import type { PytestConfiguration } from './pytest-config.ts';
8
9
  import type { LockComparison, LockSection, ManifestSection, ScanPayload } from './scanner.ts';
9
10
 
10
11
  export interface ProjectInspection {
@@ -44,6 +45,13 @@ export interface InspectInput {
44
45
  /** `undefined` when no .gitignore exists, so the check stays honest. */
45
46
  venvIgnored?: boolean;
46
47
  hasTestsDirectory: boolean;
48
+ /** Test directories found relative to the root; names them in the diagnostic. */
49
+ testDirectories?: string[];
50
+ /**
51
+ * Where pytest configuration was found. The scanner only reads
52
+ * `pyproject.toml`, so INI files are decided by the caller.
53
+ */
54
+ pytestConfiguration?: PytestConfiguration;
47
55
  /** Distributions read from `.venv`; omit to skip the conformance comparison. */
48
56
  installed?: InstalledEnvironment;
49
57
  }
@@ -220,6 +228,8 @@ function collectEnvironmentDiagnostics(
220
228
  venvDir: string | undefined,
221
229
  venvIgnored: boolean | undefined,
222
230
  hasTestsDirectory: boolean,
231
+ testDirectories: string[] | undefined,
232
+ pytestConfiguration: PytestConfiguration | undefined,
223
233
  collector: DiagnosticCollector,
224
234
  ): void {
225
235
  if (!venvDir) {
@@ -246,15 +256,27 @@ function collectEnvironmentDiagnostics(
246
256
  collector.notes.push({
247
257
  code: 'TESTS_DIRECTORY_MISSING',
248
258
  message:
249
- 'No tests/ directory was found; test selection and TDD gates cannot match changed sources.',
259
+ 'No tests directory was found in the project root or inside a top-level package, so test selection and TDD gates cannot match changed sources.',
260
+ severity: 'info',
261
+ });
262
+ } else if (testDirectories && testDirectories.length > 0) {
263
+ collector.notes.push({
264
+ code: 'TESTS_DIRECTORY_FOUND',
265
+ message: `Tests live in ${testDirectories.slice(0, 4).join(', ')}${testDirectories.length > 4 ? `, … (+${testDirectories.length - 4})` : ''}. Confirm testpaths covers them when running pytest without a target.`,
250
266
  severity: 'info',
251
267
  });
252
268
  }
253
269
 
254
- if (manifest?.pyprojectPath && manifest.toolConfiguration && !manifest.toolConfiguration.pytest) {
270
+ // pytest can be configured from pytest.ini, tox.ini, or setup.cfg, not only
271
+ // from pyproject.toml; checking one file reported "not configured" for most
272
+ // projects that use pytest's own configuration file.
273
+ const pytestConfigured =
274
+ pytestConfiguration?.configured ?? manifest?.toolConfiguration?.pytest === true;
275
+ if (manifest?.pyprojectPath && !pytestConfigured) {
255
276
  collector.notes.push({
256
277
  code: 'PYTEST_NOT_CONFIGURED',
257
- message: 'pyproject.toml has no [tool.pytest.ini_options] table.',
278
+ message:
279
+ 'No pytest configuration was found in pyproject.toml, pytest.ini, tox.ini, or setup.cfg.',
258
280
  severity: 'info',
259
281
  });
260
282
  }
@@ -283,7 +305,15 @@ function collectEnvironmentDiagnostics(
283
305
  * whole diagnostic surface is unit-testable without touching a filesystem.
284
306
  */
285
307
  export function inspectProject(input: InspectInput): ProjectInspection {
286
- const { payload, venvDir, venvIgnored, hasTestsDirectory, installed } = input;
308
+ const {
309
+ payload,
310
+ venvDir,
311
+ venvIgnored,
312
+ hasTestsDirectory,
313
+ testDirectories,
314
+ pytestConfiguration,
315
+ installed,
316
+ } = input;
287
317
  const manifest = payload.manifest;
288
318
  const lock = payload.lock;
289
319
  const comparison = payload.lockComparison;
@@ -292,7 +322,16 @@ export function inspectProject(input: InspectInput): ProjectInspection {
292
322
  const collector: DiagnosticCollector = { warnings: [], notes: [], suggestions: [] };
293
323
  collectManifestDiagnostics(manifest, root, collector);
294
324
  collectLockDiagnostics(lock, comparison, collector);
295
- collectEnvironmentDiagnostics(root, manifest, venvDir, venvIgnored, hasTestsDirectory, collector);
325
+ collectEnvironmentDiagnostics(
326
+ root,
327
+ manifest,
328
+ venvDir,
329
+ venvIgnored,
330
+ hasTestsDirectory,
331
+ testDirectories,
332
+ pytestConfiguration,
333
+ collector,
334
+ );
296
335
 
297
336
  const { warnings, notes, suggestions } = collector;
298
337
 
@@ -311,7 +350,7 @@ export function inspectProject(input: InspectInput): ProjectInspection {
311
350
  if (conformance.findings.some((finding) => finding.code === 'PROJECT_NOT_INSTALLED')) {
312
351
  suggestions.push({
313
352
  message:
314
- 'The project is not installed in .venv. Run uv sync; if that fails, the build backend could not find the package (check that the module directory name matches [project] name).',
353
+ 'The project is recorded as an editable install but is missing from .venv. Run uv sync; if that fails, the build backend could not find the package (check that the module directory name matches [project] name).',
315
354
  confidence: 'high',
316
355
  command: 'uv sync',
317
356
  });
@@ -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
+ }
@@ -1,4 +1,5 @@
1
- import { access, stat } from 'node:fs/promises';
1
+ import { constants } from 'node:fs';
2
+ import { access, readdir, stat } from 'node:fs/promises';
2
3
  import { dirname, join, resolve } from 'node:path';
3
4
 
4
5
  /** Files that mark the root of a uv-managed Python project. */
@@ -56,6 +57,89 @@ export async function findVenvDir(root: string): Promise<string | undefined> {
56
57
  return (await isDirectory(candidate)) ? candidate : undefined;
57
58
  }
58
59
 
60
+ const TEST_DIRECTORY_NAMES = new Set(['test', 'tests', 'testing']);
61
+
62
+ /** Directories that can never contain the project's own tests. */
63
+ const UNSCANNABLE_DIRECTORIES = new Set([
64
+ '.git',
65
+ '.venv',
66
+ 'venv',
67
+ '.tox',
68
+ '.nox',
69
+ '__pycache__',
70
+ 'node_modules',
71
+ 'build',
72
+ 'dist',
73
+ '.eggs',
74
+ '.mypy_cache',
75
+ '.ruff_cache',
76
+ '.pytest_cache',
77
+ ]);
78
+
79
+ /**
80
+ * Find the directories that hold tests, relative to the project root.
81
+ *
82
+ * Looking only for `./tests` misses the common layout where the suite lives
83
+ * inside the package it tests (`<package>/tests/`), which made the tool claim a
84
+ * project with hundreds of tests had none.
85
+ */
86
+ export async function findTestDirectories(root: string, maxDepth = 3): Promise<string[]> {
87
+ const found: string[] = [];
88
+ const walk = async (directory: string, relative: string, depth: number): Promise<void> => {
89
+ let entries;
90
+ try {
91
+ entries = await readdir(directory, { withFileTypes: true });
92
+ } catch {
93
+ return;
94
+ }
95
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
96
+ if (!entry.isDirectory()) continue;
97
+ const name = entry.name;
98
+ if (name.startsWith('.')) continue;
99
+ const childRelative = relative ? `${relative}/${name}` : name;
100
+ if (TEST_DIRECTORY_NAMES.has(name)) {
101
+ found.push(childRelative);
102
+ continue;
103
+ }
104
+ if (UNSCANNABLE_DIRECTORIES.has(name)) continue;
105
+ if (depth < maxDepth) await walk(join(directory, name), childRelative, depth + 1);
106
+ }
107
+ };
108
+ await walk(root, '', 1);
109
+ return found;
110
+ }
111
+
112
+ const WINDOWS = process.platform === 'win32';
113
+
114
+ /**
115
+ * Locate the interpreter inside a project virtual environment.
116
+ *
117
+ * The interpreter that runs the read-only scanner decides which
118
+ * `site-packages` it can see: a host `python3` maps four modules while the
119
+ * project's own interpreter maps the whole environment, so asking the wrong one
120
+ * makes every import name look like it has no providing distribution.
121
+ */
122
+ export async function findVenvInterpreter(venvDir: string): Promise<string | undefined> {
123
+ const directories = WINDOWS
124
+ ? [join(venvDir, 'Scripts'), join(venvDir, 'bin')]
125
+ : [join(venvDir, 'bin'), join(venvDir, 'Scripts')];
126
+ const names = WINDOWS ? ['python.exe', 'python'] : ['python', 'python3'];
127
+ for (const directory of directories) {
128
+ for (const name of names) {
129
+ const candidate = join(directory, name);
130
+ try {
131
+ const info = await stat(candidate);
132
+ if (!info.isFile()) continue;
133
+ if (!WINDOWS) await access(candidate, constants.X_OK);
134
+ return candidate;
135
+ } catch {
136
+ continue;
137
+ }
138
+ }
139
+ }
140
+ return undefined;
141
+ }
142
+
59
143
  export async function isGitIgnored(root: string, entry: string): Promise<boolean | undefined> {
60
144
  const gitignore = join(root, '.gitignore');
61
145
  try {
@@ -1,4 +1,5 @@
1
1
  import { runCommand } from '../core/runner.ts';
2
+ import { findVenvDir, findVenvInterpreter } from './root.ts';
2
3
 
3
4
  export const HELPER_URL = new URL('../../helpers/scan_project.py', import.meta.url);
4
5
 
@@ -13,7 +14,7 @@ export type ScanMode =
13
14
  | 'manifest,imports';
14
15
 
15
16
  /** Bumped by the scanner when the request or result document changes shape. */
16
- export const SUPPORTED_SCANNER_VERSION = 1;
17
+ export const SUPPORTED_SCANNER_VERSION = 3;
17
18
 
18
19
  export interface DeclaredDependency {
19
20
  raw: string;
@@ -39,6 +40,12 @@ export interface ManifestSection {
39
40
  buildRequires: string[];
40
41
  entryPoints: string[];
41
42
  toolConfiguration: Record<string, boolean>;
43
+ /**
44
+ * `[tool.pytest.ini_options]` as written, or null when the table is absent.
45
+ * Left untyped because pytest accepts options this package does not model;
46
+ * only the audited keys are read.
47
+ */
48
+ pytestOptions?: Record<string, unknown> | null;
42
49
  layout: 'src' | 'flat';
43
50
  modules: string[];
44
51
  legacySetupPy: boolean;
@@ -88,7 +95,20 @@ export interface ImportSection {
88
95
  stdlibAvailable: boolean;
89
96
  layout: 'src' | 'flat';
90
97
  localModules: string[];
91
- files: { path: string; imports: string[]; typeCheckingImports: string[] }[];
98
+ files: {
99
+ path: string;
100
+ imports: string[];
101
+ /**
102
+ * Full dotted module names the file references, so a test file can be
103
+ * matched to the module it imports rather than only by file name.
104
+ */
105
+ importModules?: string[];
106
+ typeCheckingImports: string[];
107
+ /** `async def test_*` names in this file. */
108
+ asyncTests?: string[];
109
+ /** The subset of `asyncTests` that carries an async plugin marker. */
110
+ asyncioMarkedTests?: string[];
111
+ }[];
92
112
  thirdParty: {
93
113
  import: string;
94
114
  files: string[];
@@ -137,6 +157,8 @@ export interface ScanPayload {
137
157
  export interface ScanOutcome {
138
158
  ok: boolean;
139
159
  interpreter?: string;
160
+ /** Whether the interpreter came from the project environment or from PATH. */
161
+ interpreterOrigin?: 'venv' | 'path';
140
162
  payload?: ScanPayload;
141
163
  /** Diagnostic code the caller can surface verbatim when `ok` is false. */
142
164
  code?:
@@ -145,17 +167,23 @@ export interface ScanOutcome {
145
167
  stderr?: string;
146
168
  }
147
169
 
148
- let interpreterPromise: Promise<string | undefined> | undefined;
170
+ const interpreterPromises = new Map<string, Promise<string | undefined>>();
149
171
 
150
172
  /**
151
173
  * Pick the interpreter used for read-only analysis. `python3` is preferred so a
152
174
  * `python` that points at a legacy Python 2 install is never selected.
175
+ *
176
+ * Results are cached per key so repeated tool calls in one session do not probe
177
+ * PATH again.
153
178
  */
154
179
  export async function resolveInterpreter(
155
180
  cwd: string,
156
181
  signal?: AbortSignal,
182
+ cacheKey = 'path',
157
183
  ): Promise<string | undefined> {
158
- interpreterPromise ??= (async () => {
184
+ const cached = interpreterPromises.get(cacheKey);
185
+ if (cached) return cached;
186
+ const pending = (async () => {
159
187
  for (const candidate of ['python3', 'python']) {
160
188
  const probe = await runCommand(candidate, ['-c', 'import sys; print(sys.version_info[0])'], {
161
189
  cwd,
@@ -167,7 +195,29 @@ export async function resolveInterpreter(
167
195
  }
168
196
  return undefined;
169
197
  })();
170
- return interpreterPromise;
198
+ interpreterPromises.set(cacheKey, pending);
199
+ return pending;
200
+ }
201
+
202
+ /**
203
+ * Resolve the interpreter whose `site-packages` describe this project.
204
+ *
205
+ * The scanner answers "which distribution provides this import?" by asking the
206
+ * interpreter it runs under. A host `python3` sees only its own site-packages,
207
+ * so every project dependency looks unowned; the project's own interpreter sees
208
+ * the environment that `uv sync` actually built.
209
+ */
210
+ export async function resolveProjectInterpreter(
211
+ root: string,
212
+ cwd: string,
213
+ signal?: AbortSignal,
214
+ ): Promise<{ interpreter?: string; origin: 'venv' | 'path' }> {
215
+ const venvDir = await findVenvDir(root);
216
+ if (venvDir) {
217
+ const venvInterpreter = await findVenvInterpreter(venvDir);
218
+ if (venvInterpreter) return { interpreter: venvInterpreter, origin: 'venv' };
219
+ }
220
+ return { interpreter: await resolveInterpreter(cwd, signal), origin: 'path' };
171
221
  }
172
222
 
173
223
  /**
@@ -180,12 +230,14 @@ export async function runScanProject(
180
230
  request: { root: string; mode: ScanMode; maxFiles?: number },
181
231
  signal?: AbortSignal,
182
232
  ): Promise<ScanOutcome> {
183
- const interpreter = await resolveInterpreter(cwd, signal);
233
+ const resolved = await resolveProjectInterpreter(request.root, cwd, signal);
234
+ const interpreter = resolved.interpreter;
235
+ const origin = resolved.origin;
184
236
  if (!interpreter) {
185
237
  return {
186
238
  ok: false,
187
239
  code: 'PYTHON_NOT_FOUND',
188
- message: 'No Python 3 interpreter was found on PATH.',
240
+ message: 'No Python 3 interpreter was found in the project environment or on PATH.',
189
241
  };
190
242
  }
191
243
  const helper = HELPER_URL.pathname;
@@ -200,6 +252,7 @@ export async function runScanProject(
200
252
  return {
201
253
  ok: false,
202
254
  interpreter,
255
+ interpreterOrigin: origin,
203
256
  code: 'SCANNER_FAILED',
204
257
  message: 'The project scanner timed out.',
205
258
  };
@@ -208,6 +261,7 @@ export async function runScanProject(
208
261
  return {
209
262
  ok: false,
210
263
  interpreter,
264
+ interpreterOrigin: origin,
211
265
  code: 'SCANNER_FAILED',
212
266
  message: 'The project scanner exited with an error.',
213
267
  stderr: run.stderr.trim() || undefined,
@@ -216,7 +270,13 @@ export async function runScanProject(
216
270
  try {
217
271
  const payload = JSON.parse(run.stdout) as ScanPayload;
218
272
  if (payload.error) {
219
- return { ok: false, interpreter, code: 'SCANNER_FAILED', message: payload.error };
273
+ return {
274
+ ok: false,
275
+ interpreter,
276
+ interpreterOrigin: origin,
277
+ code: 'SCANNER_FAILED',
278
+ message: payload.error,
279
+ };
220
280
  }
221
281
  // Refuse to interpret a document whose shape may have changed.
222
282
  if (
@@ -226,15 +286,17 @@ export async function runScanProject(
226
286
  return {
227
287
  ok: false,
228
288
  interpreter,
289
+ interpreterOrigin: origin,
229
290
  code: 'SCANNER_VERSION_MISMATCH',
230
291
  message: `The scanner reported protocol version ${payload.scannerVersion}, but this extension understands version ${SUPPORTED_SCANNER_VERSION}.`,
231
292
  };
232
293
  }
233
- return { ok: true, interpreter, payload };
294
+ return { ok: true, interpreter, interpreterOrigin: origin, payload };
234
295
  } catch {
235
296
  return {
236
297
  ok: false,
237
298
  interpreter,
299
+ interpreterOrigin: origin,
238
300
  code: 'SCANNER_INVALID_OUTPUT',
239
301
  message: 'The project scanner did not return valid JSON.',
240
302
  stderr: run.stdout.slice(0, 2000),