diff --git a/src/main/cli.ts b/src/main/cli.ts index b0728e99..e09bbf0c 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -449,6 +449,8 @@ export async function runCommandInEnvironment( ' && ' ); + // TODO: implement timeout. in case there is network issues + return new Promise((resolve, reject) => { const shell = isWin ? spawn('cmd', ['/c', commandScript], { @@ -465,6 +467,7 @@ export async function runCommandInEnvironment( shell.on('close', code => { if (code !== 0) { console.error('Shell exit with code:', code); + resolve(false); } resolve(true); }); diff --git a/src/main/eventtypes.ts b/src/main/eventtypes.ts index a05dedc5..72058fc2 100644 --- a/src/main/eventtypes.ts +++ b/src/main/eventtypes.ts @@ -55,7 +55,9 @@ export enum EventTypeMain { SetServerLaunchArgs = 'set-server-launch-args', SetServerEnvVars = 'set-server-env-vars', SetCtrlWBehavior = 'set-ctrl-w-behavior', - SetAuthDialogResponse = 'set-auth-dialog-response' + SetAuthDialogResponse = 'set-auth-dialog-response', + InstallPythonEnvRequirements = 'install-python-env-requirements', + ShowLogs = 'show-logs' } // events sent to Renderer process diff --git a/src/main/registry.ts b/src/main/registry.ts index f974338e..9ca9bc96 100644 --- a/src/main/registry.ts +++ b/src/main/registry.ts @@ -12,7 +12,9 @@ import { IDisposable, IEnvironmentType, IPythonEnvironment, - IVersionContainer + IPythonEnvResolveError, + IVersionContainer, + PythonEnvResolveErrorType } from './tokens'; import { envPathForPythonPath, @@ -45,6 +47,7 @@ export interface IRegistry { getCurrentPythonEnvironment: () => IPythonEnvironment; getAdditionalPathIncludesForPythonPath: (pythonPath: string) => string; getRequirements: () => Registry.IRequirement[]; + getRequirementsPipInstallCommand: () => string; getEnvironmentInfo(pythonPath: string): Promise; getRunningServerList(): Promise; dispose(): Promise; @@ -53,17 +56,17 @@ export interface IRegistry { } export const SERVER_TOKEN_PREFIX = 'jlab:srvr:'; +const MIN_JLAB_VERSION_REQUIRED = '3.0.0'; export class Registry implements IRegistry, IDisposable { constructor() { - const minJLabVersionRequired = '3.0.0'; - this._requirements = [ { name: 'jupyterlab', moduleName: 'jupyterlab', commands: ['--version'], - versionRange: new semver.Range(`>=${minJLabVersionRequired}`) + versionRange: new semver.Range(`>=${MIN_JLAB_VERSION_REQUIRED}`), + pipCommand: `"jupyterlab>=${MIN_JLAB_VERSION_REQUIRED}"` } ]; @@ -78,18 +81,22 @@ export class Registry implements IRegistry, IDisposable { pythonPath = getBundledPythonPath(); } - const defaultEnv = this._resolveEnvironmentSync(pythonPath); - - if (defaultEnv) { - this._defaultEnv = defaultEnv; - if ( - defaultEnv.type === IEnvironmentType.CondaRoot && - !this._condaRootPath - ) { - // this call overrides user set appData.condaRootPath - // which is probably better for compatibility - this.setCondaRootPath(getEnvironmentPath(defaultEnv)); + try { + const defaultEnv = this._resolveEnvironmentSync(pythonPath); + + if (defaultEnv) { + this._defaultEnv = defaultEnv; + if ( + defaultEnv.type === IEnvironmentType.CondaRoot && + !this._condaRootPath + ) { + // this call overrides user set appData.condaRootPath + // which is probably better for compatibility + this.setCondaRootPath(getEnvironmentPath(defaultEnv)); + } } + } catch (error) { + // } if (!this._condaRootPath && appData.condaRootPath) { @@ -99,9 +106,13 @@ export class Registry implements IRegistry, IDisposable { // set default env from appData.condaRootPath if (!this._defaultEnv) { const pythonPath = pythonPathForEnvPath(appData.condaRootPath, true); - const defaultEnv = this._resolveEnvironmentSync(pythonPath); - if (defaultEnv) { - this._defaultEnv = defaultEnv; + try { + const defaultEnv = this._resolveEnvironmentSync(pythonPath); + if (defaultEnv) { + this._defaultEnv = defaultEnv; + } + } catch (error) { + // } } } @@ -222,17 +233,32 @@ export class Registry implements IRegistry, IDisposable { private _resolveEnvironmentSync(pythonPath: string): IPythonEnvironment { if (!this._pathExistsSync(pythonPath)) { - return; + throw { + type: PythonEnvResolveErrorType.PathNotFound + } as IPythonEnvResolveError; } - const env = this.getEnvironmentInfoSync(pythonPath); + let env: IPythonEnvironment; - if ( - env && - this._environmentSatisfiesRequirements(env, this._requirements) - ) { - return env; + try { + env = this.getEnvironmentInfoSync(pythonPath); + } catch (error) { + log.error( + `Failed to get environment info at path '${pythonPath}'.`, + error + ); + throw { + type: PythonEnvResolveErrorType.ResolveError + } as IPythonEnvResolveError; + } + + if (!this._environmentSatisfiesRequirements(env, this._requirements)) { + throw { + type: PythonEnvResolveErrorType.RequirementsNotSatisfied + } as IPythonEnvResolveError; } + + return env; } /** @@ -330,22 +356,14 @@ export class Registry implements IRegistry, IDisposable { return inUserSetEnvList; } - try { - const env = this._resolveEnvironmentSync(pythonPath); - if (env) { - this._userSetEnvironments.push(env); - this._updateEnvironments(); - this._environmentListUpdated.emit(); - } - - return env; - } catch (error) { - console.error( - `Failed to add the Python environment at: ${pythonPath}`, - error - ); - return; + const env = this._resolveEnvironmentSync(pythonPath); + if (env) { + this._userSetEnvironments.push(env); + this._updateEnvironments(); + this._environmentListUpdated.emit(); } + + return env; } validatePythonEnvironmentAtPath(pythonPath: string): boolean { @@ -386,7 +404,7 @@ export class Registry implements IRegistry, IDisposable { defaultKernel: envInfo.defaultKernel }; } catch (error) { - console.error( + log.error( `Failed to get environment info at path '${pythonPath}'.`, error ); @@ -446,6 +464,16 @@ export class Registry implements IRegistry, IDisposable { return this._requirements; } + getRequirementsPipInstallCommand(): string { + const cmdList = ['pip install']; + + this._requirements.forEach(req => { + cmdList.push(req.pipCommand); + }); + + return cmdList.join(' '); + } + getRunningServerList(): Promise { return new Promise(resolve => { if (this._defaultEnv) { @@ -886,21 +914,6 @@ export class Registry implements IRegistry, IDisposable { }); } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - private _runPythonModuleCommandSync( - pythonPath: string, - moduleName: string, - commands: string[] - ): string { - const totalCommands = ['-m', moduleName].concat(commands); - const runOptions = { - env: { PATH: this.getAdditionalPathIncludesForPythonPath(pythonPath) } - }; - - return this._runCommandSync(pythonPath, totalCommands, runOptions); - } - private async _runCommand( executablePath: string, commands: string[], @@ -971,11 +984,7 @@ export class Registry implements IRegistry, IDisposable { commands: string[], options?: ExecFileOptions ): string { - try { - return execFileSync(executablePath, commands, options).toString(); - } catch (error) { - return 'EXEC:ERROR'; - } + return execFileSync(executablePath, commands, options).toString(); } private _sortEnvironments( @@ -1116,6 +1125,11 @@ export namespace Registry { * The Range of acceptable version produced by the previous commands field */ versionRange: semver.Range; + + /** + * pip install command + */ + pipCommand: string; } export const COMMON_CONDA_LOCATIONS = [ diff --git a/src/main/sessionwindow/sessionwindow.ts b/src/main/sessionwindow/sessionwindow.ts index 80587d2c..e9eb0e35 100644 --- a/src/main/sessionwindow/sessionwindow.ts +++ b/src/main/sessionwindow/sessionwindow.ts @@ -22,7 +22,9 @@ import { TitleBarView } from '../titlebarview/titlebarview'; import { clearSession, DarkThemeBGColor, + envPathForPythonPath, getBundledPythonPath, + getLogFilePath, isDarkTheme, LightThemeBGColor } from '../utils'; @@ -31,7 +33,8 @@ import { IDisposable, IPythonEnvironment, IRect, - IVersionContainer + IVersionContainer, + PythonEnvResolveErrorType } from '../tokens'; import { IRegistry } from '../registry'; import { IApplication } from '../app'; @@ -44,6 +47,7 @@ import { SessionConfig } from '../config/sessionconfig'; import { ISignal, Signal } from '@lumino/signaling'; import { EventTypeMain } from '../eventtypes'; import { EventManager } from '../eventmanager'; +import { runCommandInEnvironment } from '../cli'; export enum ContentViewType { Welcome = 'welcome', @@ -667,6 +671,37 @@ export class SessionWindow implements IDisposable { } ); + this._evm.registerEventHandler( + EventTypeMain.InstallPythonEnvRequirements, + (event, pythonPath: string, installCommand: string) => { + if (event.sender !== this._progressView?.view?.view?.webContents) { + return; + } + + const envPath = envPathForPythonPath(decodeURIComponent(pythonPath)); + + this._showProgressView('Installing required packages'); + + const command = `python -m ${decodeURIComponent(installCommand)}`; + runCommandInEnvironment(envPath, command).then(result => { + if (result) { + this._hideProgressView(); + } else { + this._showProgressView( + 'Failed to install required packages', + `
+ + ` + ); + } + }); + } + ); + + this._evm.registerEventHandler(EventTypeMain.ShowLogs, event => { + shell.openPath(getLogFilePath()); + }); + this._evm.registerEventHandler( EventTypeMain.OpenDroppedFiles, (event, fileOrFolders: string[]) => { @@ -712,14 +747,42 @@ export class SessionWindow implements IDisposable { 'Restarting server using the selected Python enviroment' ); - const env = this._registry.addEnvironment(path); - - if (!env) { + try { + this._registry.addEnvironment(path); + } catch (error) { + let message = `Error! Python environment at '${path}' is not compatible.`; + let requirementInstallCmd = ''; + if ( + error?.type === PythonEnvResolveErrorType.RequirementsNotSatisfied + ) { + requirementInstallCmd = this._registry.getRequirementsPipInstallCommand(); + message = `Error! Required Python packages not found in the selected environment. You can install using '${requirementInstallCmd}' command.`; + } else if (error?.type === PythonEnvResolveErrorType.PathNotFound) { + message = `Error! File not found at '${path}'.`; + } else if (error?.type === PythonEnvResolveErrorType.ResolveError) { + message = `Error! Failed to get environment information at '${path}'.`; + } this._showProgressView( 'Invalid Environment', - `
Error! Python environment at '${path}' is not compatible.
- - `, + `
${message}
+ ${ + error?.type === PythonEnvResolveErrorType.RequirementsNotSatisfied + ? `` + : '' + } + + + `, false ); @@ -1172,23 +1235,23 @@ export class SessionWindow implements IDisposable { pythonPath = this._wsSettings.getValue(SettingType.pythonPath); if (pythonPath) { - const env = this._registry.addEnvironment(pythonPath); - - if (!env) { + try { + this._registry.addEnvironment(pythonPath); + } catch (error) { // reset python path to default this._wsSettings.setValue(SettingType.pythonPath, ''); this._showProgressView( 'Invalid Environment configured for project', `
Error! Python environment at '${pythonPath}' is not compatible.
- ${ - recentSessionIndex !== undefined - ? `` - : '' - } - `, + ${ + recentSessionIndex !== undefined + ? `` + : '' + } + `, false ); diff --git a/src/main/settingsdialog/preload.ts b/src/main/settingsdialog/preload.ts index 9a1136c2..c90b356b 100644 --- a/src/main/settingsdialog/preload.ts +++ b/src/main/settingsdialog/preload.ts @@ -31,6 +31,9 @@ contextBridge.exposeInMainWorld('electronAPI', { checkForUpdates: () => { ipcRenderer.send(EventTypeMain.CheckForUpdates); }, + showLogs: () => { + ipcRenderer.send(EventTypeMain.ShowLogs); + }, launchInstallerDownloadPage: () => { ipcRenderer.send(EventTypeMain.LaunchInstallerDownloadPage); }, diff --git a/src/main/settingsdialog/settingsdialog.ts b/src/main/settingsdialog/settingsdialog.ts index b806513d..da57ffda 100644 --- a/src/main/settingsdialog/settingsdialog.ts +++ b/src/main/settingsdialog/settingsdialog.ts @@ -515,6 +515,7 @@ export class SettingsDialog { Verbose Debug + Show logs @@ -551,6 +552,10 @@ export class SettingsDialog { window.electronAPI.checkForUpdates(); } + function handleShowLogs(el) { + window.electronAPI.showLogs(); + } + function onLogLevelChanged(el) { window.electronAPI.setLogLevel(el.value); } diff --git a/src/main/tokens.ts b/src/main/tokens.ts index b1a456a3..b492eb96 100644 --- a/src/main/tokens.ts +++ b/src/main/tokens.ts @@ -64,6 +64,17 @@ export interface IPythonEnvironment { defaultKernel: string; } +export enum PythonEnvResolveErrorType { + PathNotFound = 'path-not-found', + ResolveError = 'resolve-error', + RequirementsNotSatisfied = 'requirements-not-satisfied' +} + +export interface IPythonEnvResolveError { + type: PythonEnvResolveErrorType; + message?: string; +} + export interface IDisposable { dispose(): Promise; } diff --git a/src/main/utils.ts b/src/main/utils.ts index d3d4e5c1..593759c2 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -510,3 +510,23 @@ export function createUnsignScriptInEnv(envPath: string): string { ' ' )} && ${removeRuntimeFlagCommand} && cd -`; } + +export function getLogFilePath(processType: 'main' | 'renderer' = 'main') { + switch (process.platform) { + case 'win32': + return path.join( + getUserDataDir(), + `\\jupyterlab-desktop\\logs\\${processType}.log` + ); + case 'darwin': + return path.join( + getUserHomeDir(), + `/Library/Logs/jupyterlab-desktop/${processType}.log` + ); + default: + return path.join( + getUserHomeDir(), + `/.config/jupyterlab-desktop/logs/${processType}.log` + ); + } +}