From 2bde594a9ff4204f25a991463d52807f764d14e5 Mon Sep 17 00:00:00 2001 From: Lars Reimann Date: Sat, 13 Apr 2024 18:28:36 +0200 Subject: [PATCH] feat: installer for the runner (#1038) ### Summary of Changes Add a command "Install the Safe-DS Runner" to automatically install the runner and update the settings accordingly. It also gets suggested in the error message if the runner could not be started. --- package-lock.json | 1 + .../safe-ds-messaging-provider.ts | 92 +++++++--- packages/safe-ds-lang/src/language/index.ts | 10 +- .../src/language/runner/safe-ds-runner.ts | 17 +- .../src/language/safe-ds-module.ts | 19 +- packages/safe-ds-vscode/package.json | 6 + .../src/extension/commands/installRunner.ts | 170 ++++++++++++++++++ .../src/extension/mainClient.ts | 17 +- 8 files changed, 298 insertions(+), 34 deletions(-) rename packages/safe-ds-lang/src/{language/lsp => communication}/safe-ds-messaging-provider.ts (59%) create mode 100644 packages/safe-ds-vscode/src/extension/commands/installRunner.ts diff --git a/package-lock.json b/package-lock.json index f96dd6ebc..da28b0bb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14371,6 +14371,7 @@ "version": "0.11.0", "license": "MIT", "dependencies": { + "semver": "^7.6.0", "vscode-languageclient": "^9.0.1" }, "devDependencies": { diff --git a/packages/safe-ds-lang/src/language/lsp/safe-ds-messaging-provider.ts b/packages/safe-ds-lang/src/communication/safe-ds-messaging-provider.ts similarity index 59% rename from packages/safe-ds-lang/src/language/lsp/safe-ds-messaging-provider.ts rename to packages/safe-ds-lang/src/communication/safe-ds-messaging-provider.ts index 3d5447caa..4445cdad8 100644 --- a/packages/safe-ds-lang/src/language/lsp/safe-ds-messaging-provider.ts +++ b/packages/safe-ds-lang/src/communication/safe-ds-messaging-provider.ts @@ -1,5 +1,5 @@ -import { SafeDsServices } from '../safe-ds-module.js'; -import { Connection } from 'vscode-languageserver'; +import { SafeDsServices } from '../language/safe-ds-module.js'; +import { Connection, MessageActionItem } from 'vscode-languageserver'; import { Disposable } from 'vscode-languageserver-protocol'; /* c8 ignore start */ @@ -11,6 +11,7 @@ export class SafeDsMessagingProvider { private readonly connection: Connection | undefined; private logger: Logger | undefined = undefined; private userMessageProvider: UserMessageProvider | undefined = undefined; + private messageBroker: MessageBroker | undefined = undefined; constructor(services: SafeDsServices) { this.connection = services.shared.lsp.Connection; @@ -86,11 +87,18 @@ export class SafeDsMessagingProvider { * Depending on the client this might be a modal dialog with a confirmation button or a notification in a * notification center. */ - showInformationMessage(message: string): void { + showInformationMessage(message: string): void; + async showInformationMessage(message: string, ...actions: T[]): Promise; + async showInformationMessage( + message: string, + ...actions: T[] + ): Promise { if (this.userMessageProvider?.showInformationMessage) { - this.userMessageProvider.showInformationMessage(message); + return this.userMessageProvider.showInformationMessage(message, ...actions); } else if (this.connection) { - this.connection.window.showInformationMessage(message); + return this.connection.window.showInformationMessage(message, ...actions); + } else { + return undefined; } } @@ -100,11 +108,15 @@ export class SafeDsMessagingProvider { * Depending on the client this might be a modal dialog with a confirmation button or a notification in a * notification center. */ - showWarningMessage(message: string): void { + showWarningMessage(message: string): void; + async showWarningMessage(message: string, ...actions: T[]): Promise; + async showWarningMessage(message: string, ...actions: T[]): Promise { if (this.userMessageProvider?.showWarningMessage) { - this.userMessageProvider.showWarningMessage(message); + return this.userMessageProvider.showWarningMessage(message, ...actions); } else if (this.connection) { - this.connection.window.showWarningMessage(message); + return this.connection.window.showWarningMessage(message, ...actions); + } else { + return undefined; } } @@ -114,11 +126,15 @@ export class SafeDsMessagingProvider { * Depending on the client this might be a modal dialog with a confirmation button or a notification in a * notification center. */ - showErrorMessage(message: string): void { + showErrorMessage(message: string): void; + async showErrorMessage(message: string, ...actions: T[]): Promise; + async showErrorMessage(message: string, ...actions: T[]): Promise { if (this.userMessageProvider?.showErrorMessage) { - this.userMessageProvider.showErrorMessage(message); + return this.userMessageProvider.showErrorMessage(message, ...actions); } else if (this.connection) { - this.connection.window.showErrorMessage(message); + return this.connection.window.showErrorMessage(message, ...actions); + } else { + return undefined; } } @@ -128,25 +144,29 @@ export class SafeDsMessagingProvider { * @param method The method to register a request handler for. * @param handler The handler to install. */ - onNotification(method: string, handler: (...params: any[]) => void): Disposable { + onNotification(method: string, handler: (...args: any[]) => void): Disposable { if (this.connection) { return this.connection.onNotification(method, handler); + } else if (this.messageBroker?.onNotification) { + return this.messageBroker.onNotification(method, handler); + } else { + return { + dispose() {}, + }; } - - return { - dispose() {}, - }; } /** * Send a notification to the client. * * @param method The method to invoke on the client. - * @param params The notification's parameters. + * @param args The notification's parameters. */ - async sendNotification(method: string, ...params: any): Promise { + async sendNotification(method: string, ...args: any): Promise { if (this.connection) { - await this.connection.sendNotification(method, params); + await this.connection.sendNotification(method, args); + } else if (this.messageBroker?.sendNotification) { + await this.messageBroker.sendNotification(method, ...args); } } @@ -163,6 +183,13 @@ export class SafeDsMessagingProvider { setUserMessageProvider(userMessageProvider: UserMessageProvider) { this.userMessageProvider = userMessageProvider; } + + /** + * Set the message broker to use for communicating with the client. + */ + setMessageBroker(messageBroker: MessageBroker) { + this.messageBroker = messageBroker; + } } /* c8 ignore stop */ @@ -204,15 +231,36 @@ export interface UserMessageProvider { /** * Prominently show an information message. The message should be short and human-readable. */ - showInformationMessage?: (message: string) => void; + showInformationMessage?: (message: string, ...actions: T[]) => Thenable; /** * Prominently show a warning message. The message should be short and human-readable. */ - showWarningMessage?: (message: string) => void; + showWarningMessage?: (message: string, ...actions: T[]) => Thenable; /** * Prominently show an error message. The message should be short and human-readable. */ - showErrorMessage?: (message: string) => void; + showErrorMessage?: (message: string, ...actions: T[]) => Thenable; +} + +/** + * A message broker for communicating with the client. + */ +export interface MessageBroker { + /** + * Installs a notification handler for the given method. + * + * @param method The method to register a request handler for. + * @param handler The handler to install. + */ + onNotification?: (method: string, handler: (...args: any[]) => void) => Disposable; + + /** + * Send a notification to the client. + * + * @param method The method to invoke on the client. + * @param args The notification's parameters. + */ + sendNotification?: (method: string, ...args: any[]) => Promise; } diff --git a/packages/safe-ds-lang/src/language/index.ts b/packages/safe-ds-lang/src/language/index.ts index 1bdf16c6c..db48f57a0 100644 --- a/packages/safe-ds-lang/src/language/index.ts +++ b/packages/safe-ds-lang/src/language/index.ts @@ -1,4 +1,4 @@ -import { RPC_RUNNER_STARTED } from './runner/safe-ds-runner.js'; +import { pipVersionRange, RPC_RUNNER_INSTALL, RPC_RUNNER_STARTED } from './runner/safe-ds-runner.js'; // Services export type { SafeDsServices } from './safe-ds-module.js'; @@ -22,5 +22,13 @@ export * as messages from './runner/messages.js'; // Remote procedure calls export const rpc = { + runnerInstall: RPC_RUNNER_INSTALL, runnerStarted: RPC_RUNNER_STARTED, }; + +// Dependencies +export const dependencies = { + 'safe-ds-runner': { + pipVersionRange, + }, +}; diff --git a/packages/safe-ds-lang/src/language/runner/safe-ds-runner.ts b/packages/safe-ds-lang/src/language/runner/safe-ds-runner.ts index 2ad2fb4b5..dc3955496 100644 --- a/packages/safe-ds-lang/src/language/runner/safe-ds-runner.ts +++ b/packages/safe-ds-lang/src/language/runner/safe-ds-runner.ts @@ -18,16 +18,17 @@ import { SafeDsAnnotations } from '../builtins/safe-ds-annotations.js'; import { SafeDsPythonGenerator } from '../generation/safe-ds-python-generator.js'; import { isSdsModule } from '../generated/ast.js'; import semver from 'semver'; -import { SafeDsMessagingProvider } from '../lsp/safe-ds-messaging-provider.js'; +import { SafeDsMessagingProvider } from '../../communication/safe-ds-messaging-provider.js'; // Most of the functionality cannot be tested automatically as a functioning runner setup would always be required +export const RPC_RUNNER_INSTALL = 'runner/install'; export const RPC_RUNNER_STARTED = 'runner/started'; -const LOWEST_SUPPORTED_VERSION = '0.10.0'; -const LOWEST_UNSUPPORTED_VERSION = '0.11.0'; -const npmVersionRange = `>=${LOWEST_SUPPORTED_VERSION} <${LOWEST_UNSUPPORTED_VERSION}`; -const pipVersionRange = `>=${LOWEST_SUPPORTED_VERSION},<${LOWEST_UNSUPPORTED_VERSION}`; +const LOWEST_SUPPORTED_RUNNER_VERSION = '0.10.0'; +const LOWEST_UNSUPPORTED_RUNNER_VERSION = '0.11.0'; +const npmVersionRange = `>=${LOWEST_SUPPORTED_RUNNER_VERSION} <${LOWEST_UNSUPPORTED_RUNNER_VERSION}`; +export const pipVersionRange = `>=${LOWEST_SUPPORTED_RUNNER_VERSION},<${LOWEST_UNSUPPORTED_RUNNER_VERSION}`; const RUNNER_TAG = 'Runner'; @@ -175,9 +176,13 @@ export class SafeDsRunner { } } catch (error) { this.error(`Could not start runner: ${error instanceof Error ? error.message : error}`); - this.messaging.showErrorMessage( + const action = await this.messaging.showErrorMessage( `The runner process could not be started: ${error instanceof Error ? error.message : error}`, + { title: 'Install runner' }, ); + if (action?.title === 'Install runner') { + await this.messaging.sendNotification(RPC_RUNNER_INSTALL); + } return; } // Start the runner at the specified port diff --git a/packages/safe-ds-lang/src/language/safe-ds-module.ts b/packages/safe-ds-lang/src/language/safe-ds-module.ts index fa5b8e40c..769670d06 100644 --- a/packages/safe-ds-lang/src/language/safe-ds-module.ts +++ b/packages/safe-ds-lang/src/language/safe-ds-module.ts @@ -45,7 +45,12 @@ import { SafeDsTypeFactory } from './typing/safe-ds-type-factory.js'; import { SafeDsMarkdownGenerator } from './generation/safe-ds-markdown-generator.js'; import { SafeDsCompletionProvider } from './lsp/safe-ds-completion-provider.js'; import { SafeDsFuzzyMatcher } from './lsp/safe-ds-fuzzy-matcher.js'; -import { type Logger, SafeDsMessagingProvider, type UserMessageProvider } from './lsp/safe-ds-messaging-provider.js'; +import { + type Logger, + MessageBroker, + SafeDsMessagingProvider, + type UserMessageProvider, +} from '../communication/safe-ds-messaging-provider.js'; import { SafeDsConfigurationProvider } from './workspace/safe-ds-configuration-provider.js'; import { SafeDsCodeLensProvider } from './lsp/safe-ds-code-lens-provider.js'; @@ -239,12 +244,16 @@ export const createSafeDsServices = async function ( /* c8 ignore next 2 */ SafeDs.lsp.MessagingProvider.setLogger(options.logger); } + if (options?.messageBroker) { + /* c8 ignore next 2 */ + SafeDs.lsp.MessagingProvider.setMessageBroker(options.messageBroker); + } if (!options?.omitBuiltins) { await shared.workspace.WorkspaceManager.initializeWorkspace([]); } if (options?.runnerCommand) { /* c8 ignore next 2 */ - SafeDs.runtime.Runner.updateRunnerCommand(options.runnerCommand); + await SafeDs.runtime.Runner.updateRunnerCommand(options.runnerCommand); } if (options?.userMessageProvider) { /* c8 ignore next 2 */ @@ -269,6 +278,12 @@ export interface ModuleOptions { */ omitBuiltins?: boolean; + /** + * A message broker for communicating with the client. If the broker lacks a capability, we fall back to the + * language server connection, if available. + */ + messageBroker?: MessageBroker; + /** * Command to start the runner. */ diff --git a/packages/safe-ds-vscode/package.json b/packages/safe-ds-vscode/package.json index 355146bc4..d20df2606 100644 --- a/packages/safe-ds-vscode/package.json +++ b/packages/safe-ds-vscode/package.json @@ -183,6 +183,11 @@ "title": "Dump Diagnostics to JSON", "category": "Safe-DS" }, + { + "command": "safe-ds.installRunner", + "title": "Install the Safe-DS Runner", + "category": "Safe-DS" + }, { "command": "safe-ds.openDiagnosticsDumps", "title": "Open Diagnostics Dumps in New VS Code Window", @@ -224,6 +229,7 @@ "deploy": "vsce publish" }, "dependencies": { + "semver": "^7.6.0", "vscode-languageclient": "^9.0.1" }, "devDependencies": { diff --git a/packages/safe-ds-vscode/src/extension/commands/installRunner.ts b/packages/safe-ds-vscode/src/extension/commands/installRunner.ts new file mode 100644 index 000000000..7180df13c --- /dev/null +++ b/packages/safe-ds-vscode/src/extension/commands/installRunner.ts @@ -0,0 +1,170 @@ +import vscode, { ExtensionContext, Uri } from 'vscode'; +import child_process from 'node:child_process'; +import semver from 'semver'; +import { dependencies, SafeDsServices } from '@safe-ds/lang'; +import { logError, printOutputMessage } from '../output.js'; +import fs from 'node:fs'; + +const pythonCommandCandidates = ['python3', 'python', 'py']; + +const LOWEST_SUPPORTED_PYTHON_VERSION = '3.11.0'; +const LOWEST_UNSUPPORTED_PYTHON_VERSION = '3.13.0'; +const npmVersionRange = `>=${LOWEST_SUPPORTED_PYTHON_VERSION} <${LOWEST_UNSUPPORTED_PYTHON_VERSION}`; + +export const installRunner = (context: ExtensionContext, services: SafeDsServices) => async () => { + // If the runner is already started, do nothing + if (services.runtime.Runner.isPythonServerAvailable()) { + vscode.window.showInformationMessage('The runner is already installed and running.'); + return; + } + + // Install the runner if it is not already installed + if (!fs.existsSync(getRunnerCommand(context))) { + const success = await doInstallRunner(context); + if (!success) { + return; + } + } + + // Set the runner command in the configuration + await vscode.workspace + .getConfiguration() + .update('safe-ds.runner.command', getRunnerCommand(context), vscode.ConfigurationTarget.Global); + + vscode.window.showInformationMessage('The runner has been installed successfully.'); +}; + +/** + * Installs the runner in a virtual environment. Returns true if the installation was successful. + */ +const doInstallRunner = async (context: ExtensionContext): Promise => { + // Check if a matching Python interpreter is available + const pythonCommand = await getPythonCommand(); + if (!pythonCommand) { + vscode.window.showErrorMessage('Could not find a matching Python interpreter.'); + logError('Could not find a matching Python interpreter.'); + return false; + } + + // Create a virtual environment for the runner + let success = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + title: 'Creating a virtual environment...', + }, + async () => { + try { + await createRunnerVirtualEnvironment(context, pythonCommand); + return true; + } catch (error) { + vscode.window.showErrorMessage('Failed to create a virtual environment.'); + logError(String(error)); + return false; + } + }, + ); + if (!success) { + return false; + } + + // Install the runner in the virtual environment + success = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + title: 'Installing the runner (this may take a few minutes)...', + }, + async () => { + try { + await installRunnerInVirtualEnvironment(context); + return true; + } catch (error) { + vscode.window.showErrorMessage('Failed to install the runner.'); + logError(String(error)); + return false; + } + }, + ); + return success; +}; + +const getPythonCommand = async (): Promise => { + for (const candidate of pythonCommandCandidates) { + if (await isMatchingPython(candidate)) { + return candidate; + } + } + + return undefined; +}; + +const isMatchingPython = async (pythonCommand: string): Promise => { + return new Promise((resolve) => { + child_process.exec( + `${pythonCommand} -c "import platform; print(platform.python_version())"`, + (error, stdout) => { + if (!error && semver.satisfies(stdout, npmVersionRange)) { + resolve(true); + } else { + resolve(false); + } + }, + ); + }); +}; + +const createRunnerVirtualEnvironment = async (context: ExtensionContext, pythonCommand: string): Promise => { + return new Promise((resolve, reject) => { + child_process.exec(`${pythonCommand} -m venv ${runnerVirtualEnvironmentUri(context).fsPath}`, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +}; + +const installRunnerInVirtualEnvironment = async (context: ExtensionContext): Promise => { + return new Promise((resolve, reject) => { + const installCommand = `${getPipCommand(context)} install "safe-ds-runner${dependencies['safe-ds-runner'].pipVersionRange}"`; + const process = child_process.spawn(installCommand, { shell: true }); + + process.stdout.on('data', (data: Buffer) => { + printOutputMessage(data.toString().trim()); + }); + process.stderr.on('data', (data: Buffer) => { + logError(data.toString().trim()); + }); + + process.on('error', (error) => { + reject(error); + }); + process.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(`Runner installation failed with code ${code}.`); + } + }); + }); +}; + +const getPipCommand = (context: ExtensionContext): string => { + if (process.platform === 'win32') { + return `${runnerVirtualEnvironmentUri(context).fsPath}\\Scripts\\pip.exe`; + } else { + return `${runnerVirtualEnvironmentUri(context).fsPath}/bin/pip`; + } +}; + +const getRunnerCommand = (context: ExtensionContext): string => { + if (process.platform === 'win32') { + return `${runnerVirtualEnvironmentUri(context).fsPath}\\Scripts\\safe-ds-runner.exe`; + } else { + return `${runnerVirtualEnvironmentUri(context).fsPath}/bin/safe-ds-runner`; + } +}; + +const runnerVirtualEnvironmentUri = (context: ExtensionContext): Uri => { + return vscode.Uri.joinPath(context.globalStorageUri, 'runnerVenv'); +}; diff --git a/packages/safe-ds-vscode/src/extension/mainClient.ts b/packages/safe-ds-vscode/src/extension/mainClient.ts index 0166a2e9c..18cf75db1 100644 --- a/packages/safe-ds-vscode/src/extension/mainClient.ts +++ b/packages/safe-ds-vscode/src/extension/mainClient.ts @@ -13,6 +13,7 @@ import { dumpDiagnostics } from './commands/dumpDiagnostics.js'; import { openDiagnosticsDumps } from './commands/openDiagnosticsDumps.js'; import { Range } from 'vscode-languageclient'; import { isSdsPlaceholder, SdsPipeline } from '../../../safe-ds-lang/src/language/generated/ast.js'; +import { installRunner } from './commands/installRunner.js'; let client: LanguageClient; let services: SafeDsServices; @@ -38,14 +39,21 @@ export const activate = async function (context: vscode.ExtensionContext) { ).SafeDs; client = createLanguageClient(context); - client.onNotification(rpc.runnerStarted, async (port: number) => { - await services.runtime.Runner.connectToPort(port); - }); + registerNotificationListeners(context); await client.start(); registerVSCodeCommands(context); }; +const registerNotificationListeners = function (context: vscode.ExtensionContext) { + client.onNotification(rpc.runnerInstall, async () => { + await installRunner(context, services)(); + }); + client.onNotification(rpc.runnerStarted, async (port: number) => { + await services.runtime.Runner.connectToPort(port); + }); +}; + // This function is called when the extension is deactivated. export const deactivate = async function (): Promise { await services.runtime.Runner.stopPythonServer(); @@ -103,6 +111,9 @@ const registerVSCodeCommands = function (context: vscode.ExtensionContext) { }; context.subscriptions.push(vscode.commands.registerCommand('safe-ds.dumpDiagnostics', dumpDiagnostics(context))); + context.subscriptions.push( + vscode.commands.registerCommand('safe-ds.installRunner', installRunner(context, services)), + ); context.subscriptions.push( vscode.commands.registerCommand('safe-ds.openDiagnosticsDumps', openDiagnosticsDumps(context)), );