Skip to content

Commit 17c04cf

Browse files
authored
Know posix path locator (#14184)
* Known posix path locator * Add tests * Add iterEnvs test * More tests * Address comments
1 parent 78c65a0 commit 17c04cf

File tree

14 files changed

+356
-26
lines changed

14 files changed

+356
-26
lines changed

src/client/pythonEnvironments/common/externalDependencies.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,16 @@ export function getGlobalPersistentStore<T>(key: string): IPersistentStore<T> {
6161
export async function getFileInfo(filePath: string): Promise<{ctime:number, mtime:number}> {
6262
const data = await fsapi.lstat(filePath);
6363
return {
64-
ctime: data.ctime.getUTCDate(),
65-
mtime: data.mtime.getUTCDate(),
64+
ctime: data.ctime.valueOf(),
65+
mtime: data.mtime.valueOf(),
6666
};
6767
}
68+
69+
export async function resolveSymbolicLink(filepath:string): Promise<string> {
70+
const stats = await fsapi.lstat(filepath);
71+
if (stats.isSymbolicLink()) {
72+
const link = await fsapi.readlink(filepath);
73+
return resolveSymbolicLink(link);
74+
}
75+
return filepath;
76+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as fsapi from 'fs-extra';
5+
import * as path from 'path';
6+
import { getPathEnvironmentVariable } from '../../common/utils/platform';
7+
8+
/**
9+
* Checks if a given path ends with python*.exe
10+
* @param {string} interpreterPath : Path to python interpreter.
11+
* @returns {boolean} : Returns true if the path matches pattern for windows python executable.
12+
*/
13+
export function isPosixPythonBin(interpreterPath:string): boolean {
14+
/**
15+
* This Reg-ex matches following file names:
16+
* python
17+
* python3
18+
* python38
19+
* python3.8
20+
*/
21+
const posixPythonBinPattern = /^python(\d+(\.\d+)?)?$/;
22+
23+
return posixPythonBinPattern.test(path.basename(interpreterPath));
24+
}
25+
26+
export async function commonPosixBinPaths(): Promise<string[]> {
27+
const searchPaths = (getPathEnvironmentVariable() || '')
28+
.split(path.delimiter)
29+
.filter((p) => p.length > 0);
30+
31+
const paths: string[] = Array.from(new Set(
32+
[
33+
'/bin',
34+
'/etc',
35+
'/lib',
36+
'/lib/x86_64-linux-gnu',
37+
'/lib64',
38+
'/sbin',
39+
'/snap/bin',
40+
'/usr/bin',
41+
'/usr/games',
42+
'/usr/include',
43+
'/usr/lib',
44+
'/usr/lib/x86_64-linux-gnu',
45+
'/usr/lib64',
46+
'/usr/libexec',
47+
'/usr/local',
48+
'/usr/local/bin',
49+
'/usr/local/etc',
50+
'/usr/local/games',
51+
'/usr/local/lib',
52+
'/usr/local/sbin',
53+
'/usr/sbin',
54+
'/usr/share',
55+
'~/.local/bin',
56+
].concat(searchPaths),
57+
));
58+
59+
const exists = await Promise.all(paths.map((p) => fsapi.pathExists(p)));
60+
return paths.filter((_, index) => exists[index]);
61+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as fsapi from 'fs-extra';
5+
import * as path from 'path';
6+
import { traceError, traceInfo } from '../../../../common/logger';
7+
8+
import { Architecture } from '../../../../common/utils/platform';
9+
import {
10+
PythonEnvInfo, PythonEnvKind, PythonReleaseLevel, PythonVersion,
11+
} from '../../../base/info';
12+
import { parseVersion } from '../../../base/info/pythonVersion';
13+
import { ILocator, IPythonEnvsIterator } from '../../../base/locator';
14+
import { PythonEnvsWatcher } from '../../../base/watcher';
15+
import { getFileInfo, resolveSymbolicLink } from '../../../common/externalDependencies';
16+
import { commonPosixBinPaths, isPosixPythonBin } from '../../../common/posixUtils';
17+
18+
async function getPythonBinFromKnownPaths(): Promise<string[]> {
19+
const knownPaths = await commonPosixBinPaths();
20+
const pythonBins:Set<string> = new Set();
21+
// eslint-disable-next-line no-restricted-syntax
22+
for (const knownPath of knownPaths) {
23+
// eslint-disable-next-line no-await-in-loop
24+
const files = (await fsapi.readdir(knownPath))
25+
.map((filename:string) => path.join(knownPath, filename))
26+
.filter(isPosixPythonBin);
27+
28+
// eslint-disable-next-line no-restricted-syntax
29+
for (const file of files) {
30+
// Ensure that we have a collection of unique global binaries by
31+
// resolving all symlinks to the target binaries.
32+
try {
33+
// eslint-disable-next-line no-await-in-loop
34+
const resolvedBin = await resolveSymbolicLink(file);
35+
pythonBins.add(resolvedBin);
36+
traceInfo(`Found: ${file} --> ${resolvedBin}`);
37+
} catch (ex) {
38+
traceError('Failed to resolve symbolic link: ', ex);
39+
}
40+
}
41+
}
42+
43+
return Array.from(pythonBins);
44+
}
45+
46+
export class PosixKnownPathsLocator extends PythonEnvsWatcher implements ILocator {
47+
private kind: PythonEnvKind = PythonEnvKind.OtherGlobal;
48+
49+
public iterEnvs(): IPythonEnvsIterator {
50+
const buildEnvInfo = (bin:string) => this.buildEnvInfo(bin);
51+
const iterator = async function* () {
52+
const exes = await getPythonBinFromKnownPaths();
53+
yield* exes.map(buildEnvInfo);
54+
};
55+
return iterator();
56+
}
57+
58+
public resolveEnv(env: string | PythonEnvInfo): Promise<PythonEnvInfo | undefined> {
59+
const executablePath = typeof env === 'string' ? env : env.executable.filename;
60+
return this.buildEnvInfo(executablePath);
61+
}
62+
63+
private async buildEnvInfo(bin:string): Promise<PythonEnvInfo> {
64+
let version:PythonVersion;
65+
try {
66+
version = parseVersion(path.basename(bin));
67+
} catch (ex) {
68+
traceError(`Failed to parse version from path: ${bin}`, ex);
69+
version = {
70+
major: -1,
71+
minor: -1,
72+
micro: -1,
73+
release: { level: PythonReleaseLevel.Final, serial: -1 },
74+
sysVersion: undefined,
75+
};
76+
}
77+
return {
78+
name: '',
79+
location: '',
80+
kind: this.kind,
81+
executable: {
82+
filename: bin,
83+
sysPrefix: '',
84+
...(await getFileInfo(bin)),
85+
},
86+
version,
87+
arch: Architecture.Unknown,
88+
distro: { org: '' },
89+
};
90+
}
91+
}

src/client/pythonEnvironments/discovery/locators/services/windowsStoreLocator.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import * as fsapi from 'fs-extra';
55
import * as path from 'path';
6-
import { traceWarning } from '../../../../common/logger';
6+
import { traceError, traceWarning } from '../../../../common/logger';
77
import { Architecture, getEnvironmentVariable } from '../../../../common/utils/platform';
88
import {
99
PythonEnvInfo, PythonEnvKind, PythonReleaseLevel, PythonVersion,
@@ -138,7 +138,8 @@ export class WindowsStoreLocator extends PythonEnvsWatcher implements ILocator {
138138
let version:PythonVersion;
139139
try {
140140
version = parseVersion(path.basename(exe));
141-
} catch (e) {
141+
} catch (ex) {
142+
traceError(`Failed to parse version from path: ${exe}`, ex);
142143
version = {
143144
major: 3,
144145
minor: -1,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real binary
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real binary
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real binary
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real binary
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real binary
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real binary
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real binary
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as assert from 'assert';
5+
import { zip } from 'lodash';
6+
import { PythonEnvInfo } from '../../../../client/pythonEnvironments/base/info';
7+
8+
export function assertEnvEqual(actual:PythonEnvInfo | undefined, expected: PythonEnvInfo | undefined):void {
9+
assert.notStrictEqual(actual, undefined);
10+
assert.notStrictEqual(expected, undefined);
11+
12+
if (actual) {
13+
// ensure ctime and mtime are greater than -1
14+
assert.ok(actual?.executable.ctime > -1);
15+
assert.ok(actual?.executable.mtime > -1);
16+
17+
// No need to match these, so reset them
18+
actual.executable.ctime = -1;
19+
actual.executable.mtime = -1;
20+
21+
assert.deepStrictEqual(actual, expected);
22+
}
23+
}
24+
25+
export function assertEnvsEqual(
26+
actualEnvs:(PythonEnvInfo | undefined)[],
27+
expectedEnvs: (PythonEnvInfo | undefined)[],
28+
):void{
29+
zip(actualEnvs, expectedEnvs).forEach((value) => {
30+
const [actual, expected] = value;
31+
assertEnvEqual(actual, expected);
32+
});
33+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as path from 'path';
5+
import * as sinon from 'sinon';
6+
import * as platformApis from '../../../../client/common/utils/platform';
7+
import {
8+
PythonEnvInfo, PythonEnvKind, PythonReleaseLevel, PythonVersion,
9+
} from '../../../../client/pythonEnvironments/base/info';
10+
import { InterpreterInformation } from '../../../../client/pythonEnvironments/base/info/interpreter';
11+
import { parseVersion } from '../../../../client/pythonEnvironments/base/info/pythonVersion';
12+
import { getEnvs } from '../../../../client/pythonEnvironments/base/locatorUtils';
13+
import { PosixKnownPathsLocator } from '../../../../client/pythonEnvironments/discovery/locators/services/posixKnownPathsLocator';
14+
import { TEST_LAYOUT_ROOT } from '../../common/commonTestConstants';
15+
import { assertEnvEqual, assertEnvsEqual } from './envTestUtils';
16+
17+
suite('Posix Known Path Locator', () => {
18+
let getPathEnvVar: sinon.SinonStub;
19+
const testPosixKnownPathsRoot = path.join(TEST_LAYOUT_ROOT, 'posixroot');
20+
21+
const testLocation1 = path.join(testPosixKnownPathsRoot, 'location1');
22+
const testLocation2 = path.join(testPosixKnownPathsRoot, 'location2');
23+
const testLocation3 = path.join(testPosixKnownPathsRoot, 'location3');
24+
25+
const testFileData:Map<string, string[]> = new Map();
26+
27+
testFileData.set(testLocation1, ['python', 'python3']);
28+
testFileData.set(testLocation2, ['python', 'python37', 'python38']);
29+
testFileData.set(testLocation3, ['python3.7', 'python3.8']);
30+
31+
function createExpectedInterpreterInfo(
32+
executable: string,
33+
sysVersion?: string,
34+
sysPrefix?: string,
35+
versionStr?:string,
36+
): InterpreterInformation {
37+
let version:PythonVersion;
38+
try {
39+
version = parseVersion(versionStr ?? path.basename(executable));
40+
if (sysVersion) {
41+
version.sysVersion = sysVersion;
42+
}
43+
} catch (e) {
44+
version = {
45+
major: -1,
46+
minor: -1,
47+
micro: -1,
48+
release: { level: PythonReleaseLevel.Final, serial: -1 },
49+
sysVersion,
50+
};
51+
}
52+
return {
53+
version,
54+
arch: platformApis.Architecture.Unknown,
55+
executable: {
56+
filename: executable,
57+
sysPrefix: sysPrefix ?? '',
58+
ctime: -1,
59+
mtime: -1,
60+
},
61+
};
62+
}
63+
64+
setup(() => {
65+
getPathEnvVar = sinon.stub(platformApis, 'getPathEnvironmentVariable');
66+
});
67+
teardown(() => {
68+
getPathEnvVar.restore();
69+
});
70+
test('iterEnvs(): get python bin from known test roots', async () => {
71+
const testLocations = [testLocation1, testLocation2, testLocation3];
72+
getPathEnvVar.returns(testLocations.join(path.delimiter));
73+
74+
const envs:PythonEnvInfo[] = [];
75+
testLocations.forEach((location) => {
76+
const binaries = testFileData.get(location);
77+
if (binaries) {
78+
binaries.forEach((binary) => {
79+
envs.push({
80+
name: '',
81+
location: '',
82+
kind: PythonEnvKind.OtherGlobal,
83+
distro: { org: '' },
84+
...createExpectedInterpreterInfo(path.join(location, binary)),
85+
});
86+
});
87+
}
88+
});
89+
const expectedEnvs = envs.sort((a, b) => a.executable.filename.localeCompare(b.executable.filename));
90+
91+
const locator = new PosixKnownPathsLocator();
92+
const actualEnvs = (await getEnvs(locator.iterEnvs()))
93+
.filter((e) => e.executable.filename.indexOf('posixroot') > 0)
94+
.sort((a, b) => a.executable.filename.localeCompare(b.executable.filename));
95+
assertEnvsEqual(actualEnvs, expectedEnvs);
96+
});
97+
test('resolveEnv(string)', async () => {
98+
const pythonPath = path.join(testLocation1, 'python');
99+
const expected = {
100+
name: '',
101+
location: '',
102+
kind: PythonEnvKind.OtherGlobal,
103+
distro: { org: '' },
104+
...createExpectedInterpreterInfo(pythonPath),
105+
};
106+
107+
const locator = new PosixKnownPathsLocator();
108+
const actual = await locator.resolveEnv(pythonPath);
109+
assertEnvEqual(actual, expected);
110+
});
111+
test('resolveEnv(PythonEnvInfo)', async () => {
112+
const pythonPath = path.join(testLocation1, 'python');
113+
const expected = {
114+
115+
name: '',
116+
location: '',
117+
kind: PythonEnvKind.OtherGlobal,
118+
distro: { org: '' },
119+
...createExpectedInterpreterInfo(pythonPath),
120+
};
121+
122+
// Partially filled in env info object
123+
const input:PythonEnvInfo = {
124+
name: '',
125+
location: '',
126+
kind: PythonEnvKind.Unknown,
127+
distro: { org: '' },
128+
arch: platformApis.Architecture.Unknown,
129+
executable: {
130+
filename: pythonPath,
131+
sysPrefix: '',
132+
ctime: -1,
133+
mtime: -1,
134+
},
135+
version: {
136+
major: -1,
137+
minor: -1,
138+
micro: -1,
139+
release: { level: PythonReleaseLevel.Final, serial: -1 },
140+
},
141+
};
142+
143+
const locator = new PosixKnownPathsLocator();
144+
const actual = await locator.resolveEnv(input);
145+
146+
assertEnvEqual(actual, expected);
147+
});
148+
});

0 commit comments

Comments
 (0)