Skip to content

Commit a34b517

Browse files
Copiloteleanorjboyd
andcommitted
Implement searchPaths setting with FINALSEARCHSET functionality
Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com>
1 parent ef07c6d commit a34b517

4 files changed

Lines changed: 313 additions & 4 deletions

File tree

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@
109109
"description": "%python-envs.terminal.useEnvFile.description%",
110110
"default": false,
111111
"scope": "resource"
112+
},
113+
"python-env.searchPaths": {
114+
"type": "array",
115+
"description": "%python-env.searchPaths.description%",
116+
"default": [],
117+
"scope": "resource",
118+
"items": {
119+
"type": "string"
120+
}
112121
}
113122
}
114123
},

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"python-envs.terminal.autoActivationType.shellStartup": "Activation by modifying the terminal shell startup script. To use this feature we will need to modify your shell startup scripts.",
1212
"python-envs.terminal.autoActivationType.off": "No automatic activation of environments.",
1313
"python-envs.terminal.useEnvFile.description": "Controls whether environment variables from .env files and python.envFile setting are injected into terminals.",
14+
"python-env.searchPaths.description": "Additional search paths for Python environments. Can be direct executable paths (/bin/python), environment directories, or regex patterns (efficiency warning applies to regex).",
1415
"python-envs.terminal.revertStartupScriptChanges.title": "Revert Shell Startup Script Changes",
1516
"python-envs.reportIssue.title": "Report Issue",
1617
"python-envs.setEnvManager.title": "Set Environment Manager",

src/managers/common/nativePythonFinder.ts

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import * as ch from 'child_process';
22
import * as fs from 'fs-extra';
33
import * as path from 'path';
44
import { PassThrough } from 'stream';
5-
import { Disposable, ExtensionContext, LogOutputChannel, Uri } from 'vscode';
5+
import { Disposable, ExtensionContext, LogOutputChannel, Uri, workspace } from 'vscode';
66
import * as rpc from 'vscode-jsonrpc/node';
77
import { PythonProjectApi } from '../../api';
88
import { ENVS_EXTENSION_ID, PYTHON_EXTENSION_ID } from '../../common/constants';
99
import { getExtension } from '../../common/extension.apis';
10-
import { traceVerbose } from '../../common/logging';
10+
import { traceVerbose, traceLog } from '../../common/logging';
1111
import { untildify } from '../../common/utils/pathUtils';
1212
import { isWindows } from '../../common/utils/platformUtils';
1313
import { createRunningWorkerPool, WorkerPool } from '../../common/utils/workerPool';
@@ -326,10 +326,24 @@ class NativePythonFinderImpl implements NativePythonFinder {
326326
* Must be invoked when ever there are changes to any data related to the configuration details.
327327
*/
328328
private async configure() {
329+
// Get custom virtual environment directories
330+
const customVenvDirs = getCustomVirtualEnvDirs();
331+
332+
// Get final search set from searchPaths setting
333+
const finalSearchSet = await createFinalSearchSet();
334+
335+
// Combine and deduplicate all environment directories
336+
const allEnvironmentDirectories = [...customVenvDirs, ...finalSearchSet];
337+
const environmentDirectories = Array.from(new Set(allEnvironmentDirectories));
338+
339+
traceLog('Custom venv directories:', customVenvDirs);
340+
traceLog('Final search set:', finalSearchSet);
341+
traceLog('Combined environment directories:', environmentDirectories);
342+
329343
const options: ConfigurationOptions = {
330344
workspaceDirectories: this.api.getPythonProjects().map((item) => item.uri.fsPath),
331-
// We do not want to mix this with `search_paths`
332-
environmentDirectories: getCustomVirtualEnvDirs(),
345+
// Include both custom venv dirs and search paths
346+
environmentDirectories,
333347
condaExecutable: getPythonSettingAndUntildify<string>('condaPath'),
334348
poetryExecutable: getPythonSettingAndUntildify<string>('poetryPath'),
335349
cacheDirectory: this.cacheDirectory?.fsPath,
@@ -380,6 +394,93 @@ function getPythonSettingAndUntildify<T>(name: string, scope?: Uri): T | undefin
380394
return value;
381395
}
382396

397+
function getPythonEnvSettingAndUntildify<T>(name: string, scope?: Uri): T | undefined {
398+
const value = getConfiguration('python-env', scope).get<T>(name);
399+
if (typeof value === 'string') {
400+
return value ? (untildify(value as string) as unknown as T) : undefined;
401+
}
402+
return value;
403+
}
404+
405+
/**
406+
* Creates the final search set from configured search paths.
407+
* Handles executables, directories, and regex patterns.
408+
*/
409+
async function createFinalSearchSet(): Promise<string[]> {
410+
const searchPaths = getPythonEnvSettingAndUntildify<string[]>('searchPaths') ?? [];
411+
const finalSearchSet: string[] = [];
412+
413+
traceLog('Processing search paths:', searchPaths);
414+
415+
for (const searchPath of searchPaths) {
416+
try {
417+
if (!searchPath || searchPath.trim() === '') {
418+
continue;
419+
}
420+
421+
const trimmedPath = searchPath.trim();
422+
423+
// Check if it's a regex pattern (contains regex special characters)
424+
// Note: Windows paths contain backslashes, so we need to be more careful
425+
const regexChars = /[*?[\]{}()^$+|]/;
426+
const hasBackslash = trimmedPath.includes('\\');
427+
const isWindowsPath = hasBackslash && (trimmedPath.match(/^[A-Za-z]:\\/) || trimmedPath.match(/^\\\\[^\\]+\\/));
428+
const isRegexPattern = regexChars.test(trimmedPath) || (hasBackslash && !isWindowsPath);
429+
430+
if (isRegexPattern) {
431+
traceLog('Processing regex pattern:', trimmedPath);
432+
traceLog('Warning: Using regex patterns in searchPaths may cause performance issues');
433+
434+
// Use workspace.findFiles to search for python executables
435+
const foundFiles = await workspace.findFiles(trimmedPath, null);
436+
437+
for (const file of foundFiles) {
438+
const filePath = file.fsPath;
439+
// Check if it's likely a python executable
440+
if (filePath.toLowerCase().includes('python') || path.basename(filePath).startsWith('python')) {
441+
// Get grand-grand parent folder (file -> bin -> env -> this)
442+
const grandGrandParent = path.dirname(path.dirname(path.dirname(filePath)));
443+
if (grandGrandParent && grandGrandParent !== path.dirname(grandGrandParent)) {
444+
finalSearchSet.push(grandGrandParent);
445+
traceLog('Added grand-grand parent from regex match:', grandGrandParent);
446+
}
447+
}
448+
}
449+
}
450+
// Check if it's a direct executable path
451+
else if (await fs.pathExists(trimmedPath) && (await fs.stat(trimmedPath)).isFile()) {
452+
traceLog('Processing executable path:', trimmedPath);
453+
// Get grand-grand parent folder
454+
const grandGrandParent = path.dirname(path.dirname(path.dirname(trimmedPath)));
455+
if (grandGrandParent && grandGrandParent !== path.dirname(grandGrandParent)) {
456+
finalSearchSet.push(grandGrandParent);
457+
traceLog('Added grand-grand parent from executable:', grandGrandParent);
458+
}
459+
}
460+
// Check if it's a directory path
461+
else if (await fs.pathExists(trimmedPath) && (await fs.stat(trimmedPath)).isDirectory()) {
462+
traceLog('Processing directory path:', trimmedPath);
463+
// Add directory as-is
464+
finalSearchSet.push(trimmedPath);
465+
traceLog('Added directory as-is:', trimmedPath);
466+
}
467+
// If path doesn't exist, try to check if it could be an executable that might exist later
468+
else {
469+
traceLog('Path does not exist, treating as potential directory:', trimmedPath);
470+
// Treat as directory path for future resolution
471+
finalSearchSet.push(trimmedPath);
472+
}
473+
} catch (error) {
474+
traceLog('Error processing search path:', searchPath, error);
475+
}
476+
}
477+
478+
// Remove duplicates and return
479+
const uniquePaths = Array.from(new Set(finalSearchSet));
480+
traceLog('Final search set created:', uniquePaths);
481+
return uniquePaths;
482+
}
483+
383484
export function getCacheDirectory(context: ExtensionContext): Uri {
384485
return Uri.joinPath(context.globalStorageUri, 'pythonLocator');
385486
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import assert from 'node:assert';
2+
import * as path from 'path';
3+
import * as sinon from 'sinon';
4+
5+
// Simple tests for the searchPaths functionality
6+
suite('NativePythonFinder SearchPaths Tests', () => {
7+
teardown(() => {
8+
sinon.restore();
9+
});
10+
11+
suite('Configuration reading', () => {
12+
test('should handle python-env configuration namespace', () => {
13+
// Test that we can distinguish between python and python-env namespaces
14+
assert.strictEqual('python-env', 'python-env');
15+
assert.notStrictEqual('python-env', 'python');
16+
});
17+
18+
test('should handle empty search paths array', () => {
19+
const searchPaths: string[] = [];
20+
assert.deepStrictEqual(searchPaths, []);
21+
assert.strictEqual(searchPaths.length, 0);
22+
});
23+
24+
test('should handle populated search paths array', () => {
25+
const searchPaths = ['/usr/bin/python', '/home/user/.virtualenvs', '**/bin/python*'];
26+
assert.strictEqual(searchPaths.length, 3);
27+
assert.deepStrictEqual(searchPaths, ['/usr/bin/python', '/home/user/.virtualenvs', '**/bin/python*']);
28+
});
29+
});
30+
31+
suite('Regex pattern detection', () => {
32+
test('should correctly identify regex patterns', () => {
33+
const regexPatterns = [
34+
'**/bin/python*',
35+
'**/*.py',
36+
'python[0-9]*',
37+
'python{3,4}',
38+
'python+',
39+
'python?',
40+
'python.*',
41+
'[Pp]ython'
42+
];
43+
44+
const regexChars = /[*?[\]{}()^$+|\\]/;
45+
regexPatterns.forEach(pattern => {
46+
assert.ok(regexChars.test(pattern), `Pattern ${pattern} should be detected as regex`);
47+
});
48+
});
49+
50+
test('should not identify regular paths as regex', () => {
51+
const regularPaths = [
52+
'/usr/bin/python',
53+
'/home/user/python',
54+
'C:\\Python\\python.exe',
55+
'/opt/python3.9'
56+
];
57+
58+
const regexChars = /[*?[\]{}()^$+|\\]/;
59+
regularPaths.forEach(testPath => {
60+
// Note: Windows paths contain backslashes which are regex chars,
61+
// but we'll handle this in the actual implementation
62+
if (!testPath.includes('\\')) {
63+
assert.ok(!regexChars.test(testPath), `Path ${testPath} should not be detected as regex`);
64+
}
65+
});
66+
});
67+
68+
test('should handle Windows paths specially', () => {
69+
const windowsPath = 'C:\\Python\\python.exe';
70+
const regexChars = /[*?[\]{}()^$+|\\]/;
71+
72+
// Windows paths contain backslashes which are regex characters
73+
// Our implementation should handle this case
74+
assert.ok(regexChars.test(windowsPath), 'Windows paths contain regex chars');
75+
});
76+
});
77+
78+
suite('Grand-grand parent path extraction', () => {
79+
test('should extract correct grand-grand parent from executable path', () => {
80+
const executablePath = '/home/user/.virtualenvs/myenv/bin/python';
81+
const expected = '/home/user/.virtualenvs';
82+
83+
// Test path manipulation logic
84+
const grandGrandParent = path.dirname(path.dirname(path.dirname(executablePath)));
85+
assert.strictEqual(grandGrandParent, expected);
86+
});
87+
88+
test('should handle deep nested paths', () => {
89+
const executablePath = '/very/deep/nested/path/to/env/bin/python';
90+
const expected = '/very/deep/nested/path/to';
91+
92+
const grandGrandParent = path.dirname(path.dirname(path.dirname(executablePath)));
93+
assert.strictEqual(grandGrandParent, expected);
94+
});
95+
96+
test('should handle shallow paths gracefully', () => {
97+
const executablePath = '/bin/python';
98+
99+
const grandGrandParent = path.dirname(path.dirname(path.dirname(executablePath)));
100+
// This should result in root
101+
assert.ok(grandGrandParent);
102+
assert.strictEqual(grandGrandParent, '/');
103+
});
104+
105+
test('should handle Windows style paths', function () {
106+
// Skip this test on non-Windows systems since path.dirname behaves differently
107+
if (process.platform !== 'win32') {
108+
this.skip();
109+
return;
110+
}
111+
112+
const executablePath = 'C:\\Users\\user\\envs\\myenv\\Scripts\\python.exe';
113+
114+
const grandGrandParent = path.dirname(path.dirname(path.dirname(executablePath)));
115+
const expected = 'C:\\Users\\user\\envs';
116+
assert.strictEqual(grandGrandParent, expected);
117+
});
118+
});
119+
120+
suite('Array deduplication logic', () => {
121+
test('should remove duplicate paths', () => {
122+
const paths = ['/path1', '/path2', '/path1', '/path3', '/path2'];
123+
const unique = Array.from(new Set(paths));
124+
125+
assert.strictEqual(unique.length, 3);
126+
assert.deepStrictEqual(unique, ['/path1', '/path2', '/path3']);
127+
});
128+
129+
test('should handle empty arrays', () => {
130+
const paths: string[] = [];
131+
const unique = Array.from(new Set(paths));
132+
133+
assert.strictEqual(unique.length, 0);
134+
assert.deepStrictEqual(unique, []);
135+
});
136+
137+
test('should handle single item arrays', () => {
138+
const paths = ['/single/path'];
139+
const unique = Array.from(new Set(paths));
140+
141+
assert.strictEqual(unique.length, 1);
142+
assert.deepStrictEqual(unique, ['/single/path']);
143+
});
144+
});
145+
146+
suite('String trimming and validation', () => {
147+
test('should handle empty and whitespace-only strings', () => {
148+
const testStrings = ['', ' ', '\t\n', 'valid'];
149+
const filtered = testStrings.filter(s => s && s.trim() !== '');
150+
151+
assert.deepStrictEqual(filtered, ['valid']);
152+
});
153+
154+
test('should trim whitespace from paths', () => {
155+
const pathWithWhitespace = ' /path/to/python ';
156+
const trimmed = pathWithWhitespace.trim();
157+
158+
assert.strictEqual(trimmed, '/path/to/python');
159+
});
160+
});
161+
162+
suite('Python executable detection', () => {
163+
test('should identify python-like filenames', () => {
164+
const filenames = [
165+
'python',
166+
'python3',
167+
'python3.9',
168+
'python.exe',
169+
'Python.exe',
170+
'python3.11.exe'
171+
];
172+
173+
filenames.forEach(filename => {
174+
const lowerFilename = filename.toLowerCase();
175+
assert.ok(
176+
lowerFilename.includes('python') || path.basename(lowerFilename).startsWith('python'),
177+
`${filename} should be identified as python executable`
178+
);
179+
});
180+
});
181+
182+
test('should not identify non-python files', () => {
183+
const filenames = [
184+
'node',
185+
'npm',
186+
'pip',
187+
'bash',
188+
'zsh'
189+
];
190+
191+
filenames.forEach(filename => {
192+
const lowerFilename = filename.toLowerCase();
193+
const isPython = lowerFilename.includes('python') || path.basename(lowerFilename).startsWith('python');
194+
assert.ok(!isPython, `${filename} should not be identified as python executable`);
195+
});
196+
});
197+
});
198+
});

0 commit comments

Comments
 (0)