pi-python-helper 0.1.1 → 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.
@@ -4,13 +4,19 @@ import { findProjectRoot, findVenvDir, isFile } from '../project/root.ts';
4
4
  import {
5
5
  type EnvironmentSection,
6
6
  type LockPackage,
7
- resolveInterpreter,
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,
@@ -22,10 +22,20 @@ export type ToolVersionSource = 'lock' | 'cli' | 'unknown';
22
22
 
23
23
  export interface ToolAvailability {
24
24
  name: string;
25
- /** True when the tool is runnable, either locally or through `uv run`. */
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) || lockVersion !== undefined,
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
+ }
@@ -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 {