diff --git a/extensions/notebook/src/dialog/pythonPathLookup.ts b/extensions/notebook/src/dialog/pythonPathLookup.ts index ed1f31f9cb..588d0bf082 100644 --- a/extensions/notebook/src/dialog/pythonPathLookup.ts +++ b/extensions/notebook/src/dialog/pythonPathLookup.ts @@ -13,19 +13,25 @@ export interface PythonPathInfo { version: string; } +export interface PythonCommand { + command: string; + args?: string[]; +} + export class PythonPathLookup { - private condaLocations: string[]; + private readonly _condaLocations: string[]; + private readonly _pythonCommands: PythonCommand[]; constructor() { if (process.platform !== constants.winPlatform) { let userFolder = process.env['HOME']; - this.condaLocations = [ + this._condaLocations = [ '/opt/*conda*/bin/python3', '/usr/share/*conda*/bin/python3', `${userFolder}/*conda*/bin/python3` ]; } else { let userFolder = process.env['USERPROFILE'].replace(/\\/g, '/').replace('C:', ''); - this.condaLocations = [ + this._condaLocations = [ '/ProgramData/[Mm]iniconda*/python.exe', '/ProgramData/[Aa]naconda*/python.exe', `${userFolder}/[Mm]iniconda*/python.exe`, @@ -34,34 +40,43 @@ export class PythonPathLookup { `${userFolder}/AppData/Local/Continuum/[Aa]naconda*/python.exe` ]; } - } - public async getSuggestions(): Promise { - let pythonSuggestions = await this.getPythonSuggestions(); - let condaSuggestions = await this.getCondaSuggestions(); + this._pythonCommands = [ + { command: 'python3.7' }, + { command: 'python3.6' }, + { command: 'python3' }, + { command: 'python' } + ]; - if (pythonSuggestions) { - if (condaSuggestions && condaSuggestions.length > 0) { - pythonSuggestions = pythonSuggestions.concat(condaSuggestions); - } - return this.getInfoForPaths(pythonSuggestions); - } else { - return []; + if (process.platform === constants.winPlatform) { + this._pythonCommands.concat([ + { command: 'py', args: ['-3.7'] }, + { command: 'py', args: ['-3.6'] }, + { command: 'py', args: ['-3'] } + ]); } } - private async getCondaSuggestions(): Promise { + public async getSuggestions(): Promise { + let pythonSuggestions = await this.getPythonSuggestions(this._pythonCommands); + let condaSuggestions = await this.getCondaSuggestions(this._condaLocations); + + let allSuggestions = pythonSuggestions.concat(condaSuggestions); + return this.getInfoForPaths(allSuggestions); + } + + public async getCondaSuggestions(condaLocations: string[]): Promise { try { - let condaResults = await Promise.all(this.condaLocations.map(location => this.globSearch(location))); + let condaResults = await Promise.all(condaLocations.map(location => this.globSearch(location))); let condaFiles = condaResults.reduce((first, second) => first.concat(second)); return condaFiles.filter(condaPath => condaPath && condaPath.length > 0); } catch (err) { - console.log(`Problem encountered getting Conda installations: ${err}`); + console.log(`Problem encountered getting Conda installs: ${err}`); } return []; } - private globSearch(globPattern: string): Promise { + public globSearch(globPattern: string): Promise { return new Promise((resolve, reject) => { glob(globPattern, (err, files) => { if (err) { @@ -72,20 +87,17 @@ export class PythonPathLookup { }); } - private async getPythonSuggestions(): Promise { - let pathsToCheck = this.getPythonCommands(); - - let pythonPaths = await Promise.all(pathsToCheck.map(item => this.getPythonPath(item))); - let results: string[]; - if (pythonPaths) { - results = pythonPaths.filter(path => path && path.length > 0); - } else { - results = []; + public async getPythonSuggestions(pythonCommands: PythonCommand[]): Promise { + try { + let pythonPaths = await Promise.all(pythonCommands.map(item => this.getPythonPath(item))); + return pythonPaths.filter(path => path && path.length > 0); + } catch (err) { + console.log(`Problem encountered getting Python installs: ${err}`); } - return results; + return []; } - private async getPythonPath(options: { command: string; args?: string[] }): Promise { + public async getPythonPath(options: PythonCommand): Promise { try { let args = Array.isArray(options.args) ? options.args : []; args = args.concat(['-c', '"import sys;print(sys.executable)"']); @@ -102,20 +114,7 @@ export class PythonPathLookup { return undefined; } - private getPythonCommands(): { command: string; args?: string[] }[] { - const paths = ['python3.7', 'python3.6', 'python3', 'python'] - .map(item => { return { command: item }; }); - if (process.platform !== constants.winPlatform) { - return paths; - } - - const versions = ['3.7', '3.6', '3']; - return paths.concat(versions.map(version => { - return { command: 'py', args: [`-${version}`] }; - })); - } - - private async getInfoForPaths(pythonPaths: string[]): Promise { + public async getInfoForPaths(pythonPaths: string[]): Promise { let pathsInfo = await Promise.all(pythonPaths.map(path => this.getInfoForPath(path))); // Remove duplicate paths, and entries with missing values @@ -140,7 +139,7 @@ export class PythonPathLookup { }); } - private async getInfoForPath(pythonPath: string): Promise { + public async getInfoForPath(pythonPath: string): Promise { try { // "python --version" returns nothing from executeBufferedCommand with Python 2.X, // so use sys.version_info here instead. diff --git a/extensions/notebook/src/test/python/pythonPath.test.ts b/extensions/notebook/src/test/python/pythonPath.test.ts new file mode 100644 index 0000000000..49bb3bf731 --- /dev/null +++ b/extensions/notebook/src/test/python/pythonPath.test.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as sinon from 'sinon'; +import * as should from 'should'; +import * as path from 'path'; +import * as os from 'os'; +import * as uuid from 'uuid'; +import * as fs from 'fs-extra'; +import * as rimraf from 'rimraf'; +import * as utils from '../../common/utils'; +import { PythonPathInfo, PythonPathLookup } from '../../dialog/pythonPathLookup'; +import * as constants from '../../common/constants'; +import { promisify } from 'util'; + +describe('PythonPathLookup', function () { + let pathLookup: PythonPathLookup; + + beforeEach(() => { + pathLookup = new PythonPathLookup(); + }); + + afterEach(function (): void { + sinon.restore(); + }); + + it('getSuggestions', async () => { + sinon.stub(pathLookup, 'getPythonSuggestions').resolves(['testPath/Python38/python']); + sinon.stub(pathLookup, 'getCondaSuggestions').resolves(['testPath/Anaconda/conda']); + + let expectedResults: PythonPathInfo[] = [{ + installDir: 'testPath/Python38/python', + version: '3.8.0' + }, { + installDir: 'testPath/Anaconda/conda', + version: '3.6.0' + }]; + + let getInfoStub = sinon.stub(pathLookup, 'getInfoForPaths').resolves(expectedResults); + + let results = await pathLookup.getSuggestions(); + should(results).be.deepEqual(expectedResults); + should(getInfoStub.callCount).be.equal(1); + should(getInfoStub.firstCall.args[0].length).be.equal(2); + }); + + it('getCondaSuggestions', async () => { + sinon.stub(pathLookup, 'globSearch') + .onCall(0).resolves(['a', undefined, 'b']) + .onCall(1).resolves(['', 'c']) + .onCall(2).resolves(['d']); + + // globSearch is mocked out, so only the number of location args matters here + let result: string[] = await should(pathLookup.getCondaSuggestions(['1', '2', '3'])).not.be.rejected(); + should(result).be.deepEqual(['a', 'b', 'c', 'd']); + }); + + it('getCondaSuggestions - return empty array on error', async () => { + sinon.stub(pathLookup, 'globSearch').rejects('Planned test failure.'); + + let result: string[] = await should(pathLookup.getCondaSuggestions(['testPath'])).not.be.rejected(); + should(result).not.be.undefined(); + should(result.length).be.equal(0); + }); + + it('getPythonSuggestions', async () => { + let expectedPath = 'testPath/Python38'; + sinon.stub(pathLookup, 'getPythonPath') + .onCall(0).resolves(undefined) + .onCall(1).resolves(expectedPath) + .onCall(2).resolves(''); + + // getPythonPath is mocked out, so only the number of command args matters here + let result: string[] = await should(pathLookup.getPythonSuggestions([{ command: '1' }, { command: '2' }, { command: '3' }])).not.be.rejected(); + should(result).not.be.undefined(); + should(result.length).be.equal(1); + should(result[0]).be.equal(expectedPath); + }); + + it('getPythonSuggestions - return empty array on error', async () => { + sinon.stub(pathLookup, 'getPythonPath').rejects('Planned test failure.'); + + let result: string[] = await should(pathLookup.getPythonSuggestions([{ command: 'testPath/Python38/python' }])).not.be.rejected(); + should(result).not.be.undefined(); + should(result.length).be.equal(0); + }); + + it('getPythonPath', async () => { + let expectedPath = 'testPath/Python38'; + sinon.stub(utils, 'executeBufferedCommand').resolves(expectedPath); + sinon.stub(utils, 'exists').resolves(true); + + let result: string = await should(pathLookup.getPythonPath({ command: 'testPath/Python38/python' })).not.be.rejected(); + should(result).be.equal(expectedPath); + }); + + it('getPythonPath - return undefined if resulting path does not exist', async () => { + let expectedPath = 'testPath/Python38'; + sinon.stub(utils, 'executeBufferedCommand').resolves(expectedPath); + sinon.stub(utils, 'exists').resolves(false); + + let result: string = await should(pathLookup.getPythonPath({ command: 'testPath/Python38/python' })).not.be.rejected(); + should(result).be.undefined(); + }); + + it('getPythonPath - return undefined on error', async () => { + sinon.stub(utils, 'executeBufferedCommand').rejects('Planned test failure.'); + + let result: string = await should(pathLookup.getPythonPath({ command: 'testPath/Python38/python' })).not.be.rejected(); + should(result).be.undefined(); + }); + + it('getPythonPath - return undefined if command returns no data', async () => { + sinon.stub(utils, 'executeBufferedCommand') + .onCall(0).resolves(undefined) + .onCall(1).resolves(''); + + let result: string = await should(pathLookup.getPythonPath({ command: 'testPath/Python38/python' })).not.be.rejected(); + should(result).be.undefined(); + + result = await should(pathLookup.getPythonPath({ command: 'testPath/Python38/python' })).not.be.rejected(); + should(result).be.undefined(); + }); + + it('globSearch', async () => { + let testFolder = path.join(os.tmpdir(), `PythonPathTest_${uuid.v4()}`); + await fs.mkdir(testFolder); + try { + let standaloneFile = path.join(testFolder, 'testFile.txt'); + let folderFile1 = path.join(testFolder, 'TestFolder1', 'testFile.txt'); + let folderFile2 = path.join(testFolder, 'TestFolder2', 'testFile.txt'); + + await fs.ensureFile(standaloneFile); + await fs.ensureFile(folderFile1); + await fs.ensureFile(folderFile2); + + let normalizedFolderPath: string; + if (process.platform === constants.winPlatform) { + let driveColonIndex = testFolder.indexOf(':'); + normalizedFolderPath = testFolder.substr(driveColonIndex + 1).replace(/\\/g, '/'); + } else { + normalizedFolderPath = testFolder; + } + + let searchResults = await pathLookup.globSearch(`${normalizedFolderPath}/*.txt`); + should(searchResults).be.deepEqual([standaloneFile]); + + searchResults = await pathLookup.globSearch(`${normalizedFolderPath}/[tT]estFolder*/*.txt`); + should(searchResults).be.deepEqual([folderFile1, folderFile2]); + + searchResults = await pathLookup.globSearch(`${normalizedFolderPath}/[tT]estFolder*/*.csv`); + should(searchResults).be.deepEqual([]); + } finally { + await promisify(rimraf)(testFolder); + } + }); + + it('getInfoForPaths', async () => { + let expectedPathInfo: PythonPathInfo = { + installDir: 'testPath/Python38', + version: '3.8.0' + }; + + let callNumber = 0; + sinon.stub(pathLookup, 'getInfoForPath') + .onCall(callNumber++).resolves({ + installDir: undefined, + version: '' + }) + .onCall(callNumber++).resolves({ + installDir: '', + version: undefined + }) + .onCall(callNumber++).resolves({ + installDir: '', + version: '' + }) + .onCall(callNumber++).resolves({ + installDir: 'testPath/Python', + version: '2.7.0' + }) + .onCall(callNumber++).resolves(expectedPathInfo) + .onCall(callNumber++).resolves(expectedPathInfo) + .onCall(callNumber++).rejects('Unexpected number of getInfoForPath calls.'); + + // getInfoForPath is mocked out above, so only the number of path arguments matters here + let result = await pathLookup.getInfoForPaths(['1', '2', '3', '4', '5', '6']); + + // The path lookup should filter out any invalid path info, any Python 2 info, and any duplicates. + // So, we should be left with a single info object using the mocked setup above. + should(result).be.deepEqual([expectedPathInfo]); + }); + + it('getInfoForPaths - empty array arg', async () => { + let getInfoStub = sinon.stub(pathLookup, 'getInfoForPath').rejects('Unexpected getInfoForPath call'); + let result = await pathLookup.getInfoForPaths([]); + should(result).not.be.undefined; + should(result.length).be.equal(0); + should(getInfoStub.callCount).be.equal(0); + }); + + it('getInfoForPath', async () => { + let pythonVersion = '3.8.0'; + let pythonFolder = 'testPath/Python38'; + sinon.stub(utils, 'executeBufferedCommand') + .onFirstCall().resolves(pythonVersion) + .onSecondCall().resolves(pythonFolder); + + let expectedResult: PythonPathInfo = { + installDir: pythonFolder, + version: pythonVersion + }; + let result = await pathLookup.getInfoForPath(`${pythonFolder}/python`); + should(result).deepEqual(expectedResult); + }); + + it('getInfoForPath - Return undefined string on error', async () => { + sinon.stub(utils, 'executeBufferedCommand').rejects('Planned test failure.'); + + let pathInfoPromise = pathLookup.getInfoForPath('testPath/Python38/python'); + let result = await should(pathInfoPromise).not.be.rejected(); + should(result).be.undefined(); + }); + + it('getInfoForPath - Return undefined if commands return no data', async () => { + sinon.stub(utils, 'executeBufferedCommand').resolves(''); + + let pathInfoPromise = pathLookup.getInfoForPath('testPath/Python38/python'); + let result = await should(pathInfoPromise).not.be.rejected(); + should(result).be.undefined(); + }); +});