From eeaadd381baa27f49c1433e834e26c547bfe161c Mon Sep 17 00:00:00 2001 From: tomas Date: Wed, 1 Apr 2026 21:21:43 +0000 Subject: [PATCH 01/12] feat(toolkit): Simplify deepnote toolkit kernel management, to not use deepnote environments --- .../deepnote/deepnoteServerStarter.node.ts | 107 +-- .../deepnoteServerStarter.unit.test.ts | 16 +- src/kernels/deepnote/types.ts | 16 +- .../deepnoteKernelAutoSelector.node.ts | 639 ++---------------- ...epnoteKernelAutoSelector.node.unit.test.ts | 343 +++------- .../installer/pipInstaller.node.ts | 10 +- .../installer/productInstaller.node.ts | 17 +- .../interpreter/installer/productNames.ts | 1 + .../installer/productService.node.ts | 1 + src/platform/interpreter/installer/utils.ts | 2 + 10 files changed, 279 insertions(+), 873 deletions(-) diff --git a/src/kernels/deepnote/deepnoteServerStarter.node.ts b/src/kernels/deepnote/deepnoteServerStarter.node.ts index 112f7f0e09..ff2c7e2696 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.node.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.node.ts @@ -24,8 +24,10 @@ import { logger } from '../../platform/logging'; import { ISqlIntegrationEnvVarsProvider } from '../../platform/notebooks/deepnote/types'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; import * as path from '../../platform/vscode-path/path'; -import { DeepnoteServerInfo, IDeepnoteServerStarter, IDeepnoteToolkitInstaller } from './types'; +import { DeepnoteServerInfo, IDeepnoteServerStarter } from './types'; import { DeepnoteAgentSkillsManager } from './deepnoteAgentSkillsManager.node'; +import { getCachedEnvironment } from '../../platform/interpreter/helpers'; +import { IInstaller, InstallerResponse, Product } from '../../platform/interpreter/installer/types'; const MAX_OUTPUT_TRACKING_LENGTH = 5000; const SERVER_STARTUP_TIMEOUT_MS = 120_000; @@ -48,7 +50,7 @@ type PendingOperation = }; interface ProjectContext { - environmentId: string; + interpreterId: string; serverInfo: DeepnoteServerInfo | null; } @@ -71,7 +73,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension constructor( @inject(IProcessServiceFactory) private readonly processServiceFactory: IProcessServiceFactory, - @inject(IDeepnoteToolkitInstaller) private readonly toolkitInstaller: IDeepnoteToolkitInstaller, + @inject(IInstaller) private readonly installer: IInstaller, @inject(DeepnoteAgentSkillsManager) private readonly agentSkillsManager: DeepnoteAgentSkillsManager, @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry, @@ -98,14 +100,11 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension */ public async startServer( interpreter: PythonEnvironment, - venvPath: Uri, - managedVenv: boolean, - additionalPackages: string[], - environmentId: string, deepnoteFileUri: Uri, token?: CancellationToken ): Promise { const fileKey = deepnoteFileUri.fsPath; + const interpreterId = interpreter.id; let pendingOp = this.pendingOperations.get(fileKey); if (pendingOp) { @@ -119,12 +118,12 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension let existingContext = this.projectContexts.get(fileKey); if (existingContext != null) { - const { environmentId: existingEnvironmentId, serverInfo: existingServerInfo } = existingContext; + const { interpreterId: existingInterpreterId, serverInfo: existingServerInfo } = existingContext; - if (existingEnvironmentId === environmentId) { + if (existingInterpreterId === interpreterId) { if (existingServerInfo != null && (await this.isServerRunning(existingServerInfo))) { logger.info( - `Deepnote server already running at ${existingServerInfo.url} for ${fileKey} (environmentId ${environmentId})` + `Deepnote server already running at ${existingServerInfo.url} for ${fileKey} (interpreter ${interpreterId})` ); return existingServerInfo; } @@ -136,14 +135,14 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension } } else { logger.info( - `Stopping existing server for ${fileKey} with environmentId ${existingEnvironmentId} to start new one with environmentId ${environmentId}...` + `Stopping existing server for ${fileKey} with interpreter ${existingInterpreterId} to start new one with interpreter ${interpreterId}...` ); await this.stopServerForEnvironment(existingContext, deepnoteFileUri, token); - existingContext.environmentId = environmentId; + existingContext.interpreterId = interpreterId; } } else { const newContext: ProjectContext = { - environmentId, + interpreterId, serverInfo: null }; @@ -153,16 +152,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension const operation = { type: 'start' as const, - promise: this.startServerForEnvironment( - existingContext, - interpreter, - venvPath, - managedVenv, - additionalPackages, - environmentId, - deepnoteFileUri, - token - ) + promise: this.startServerForEnvironment(existingContext, interpreter, deepnoteFileUri, token) }; this.pendingOperations.set(fileKey, operation); @@ -223,7 +213,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension * Core server start using @deepnote/runtime-core's `startServer`. * * Extension-specific layers: - * - Toolkit/venv installation (before start) + * - Toolkit check/install via IInstaller (before start) * - SQL integration env var injection (via ServerOptions.env) * - Lock file creation (after start, using returned PID) * - Output channel logging (via process stdout/stderr streams) @@ -231,37 +221,49 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension private async startServerForEnvironment( projectContext: ProjectContext, interpreter: PythonEnvironment, - venvPath: Uri, - managedVenv: boolean, - additionalPackages: string[], - environmentId: string, deepnoteFileUri: Uri, token?: CancellationToken ): Promise { const fileKey = deepnoteFileUri.fsPath; + const interpreterId = interpreter.id; Cancellation.throwIfCanceled(token); - logger.info(`Ensuring deepnote-toolkit is installed in venv for environment ${environmentId}...`); - const { pythonInterpreter: venvInterpreter } = await this.toolkitInstaller.ensureVenvAndToolkit( - interpreter, - venvPath, - managedVenv, - token - ); + // Check if deepnote-toolkit is installed, and install if needed + logger.info(`Checking deepnote-toolkit installation for interpreter ${interpreterId}...`); + const isInstalled = await this.installer.isInstalled(Product.deepnoteToolkit, interpreter); - this.agentSkillsManager.ensureSkillsUpdated(environmentId, venvInterpreter); + if (!isInstalled) { + logger.info(`deepnote-toolkit not installed, installing via IInstaller...`); + const { CancellationTokenSource } = await import('vscode'); + const cts = new CancellationTokenSource(); - Cancellation.throwIfCanceled(token); + try { + if (token) { + token.onCancellationRequested(() => cts.cancel()); + } + + const result = await this.installer.install(Product.deepnoteToolkit, interpreter, cts); + + if (result !== InstallerResponse.Installed) { + throw new Error('deepnote-toolkit installation was cancelled or failed'); + } + } finally { + cts.dispose(); + } + } - await this.toolkitInstaller.installAdditionalPackages(venvPath, additionalPackages, token); + this.agentSkillsManager.ensureSkillsUpdated(interpreterId, interpreter); Cancellation.throwIfCanceled(token); - logger.info(`Starting deepnote-toolkit server for ${fileKey} (environmentId ${environmentId})`); + // Derive the environment path from the interpreter + const envPath = this.deriveEnvPath(interpreter); + + logger.info(`Starting deepnote-toolkit server for ${fileKey} (interpreter ${interpreterId})`); this.outputChannel.appendLine(l10n.t('Starting Deepnote server...')); - const extraEnv = await this.gatherSqlIntegrationEnvVars(deepnoteFileUri, environmentId, token); + const extraEnv = await this.gatherSqlIntegrationEnvVars(deepnoteFileUri, interpreterId, token); // Initialize output tracking for error reporting this.serverOutputByFile.set(fileKey, { stdout: '', stderr: '' }); @@ -269,7 +271,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension let serverInfo: DeepnoteServerInfo | undefined; try { serverInfo = await startServer({ - pythonEnv: venvPath.fsPath, + pythonEnv: envPath, workingDirectory: path.dirname(deepnoteFileUri.fsPath), startupTimeoutMs: SERVER_STARTUP_TIMEOUT_MS, env: extraEnv @@ -307,6 +309,29 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension return serverInfo; } + /** + * Derive the environment path from a Python interpreter. + * Uses the cached environment info, or falls back to navigating up from the executable. + */ + private deriveEnvPath(interpreter: PythonEnvironment): string { + const cachedEnv = getCachedEnvironment(interpreter); + // eslint-disable-next-line local-rules/dont-use-fspath + const folderPath = cachedEnv?.environment?.folderUri?.fsPath; + + if (folderPath) { + return folderPath; + } + + const sysPrefix = cachedEnv?.executable?.sysPrefix; + + if (sysPrefix) { + return sysPrefix; + } + + // Fallback: go up from bin/python (or Scripts/python.exe on Windows) + return path.dirname(path.dirname(interpreter.uri.fsPath)); + } + /** * Stop the server using @deepnote/runtime-core's `stopServer` (SIGTERM -> wait -> SIGKILL). */ diff --git a/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts b/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts index f90e3a8ec1..2b7b37c2df 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts @@ -1,12 +1,13 @@ import { assert } from 'chai'; import * as fakeTimers from '@sinonjs/fake-timers'; import { anything, instance, mock, when } from 'ts-mockito'; +import { EventEmitter } from 'vscode'; import { DeepnoteAgentSkillsManager } from './deepnoteAgentSkillsManager.node'; import { DeepnoteServerStarter } from './deepnoteServerStarter.node'; import { IProcessServiceFactory } from '../../platform/common/process/types.node'; import { IAsyncDisposableRegistry, IOutputChannel } from '../../platform/common/types'; -import { IDeepnoteToolkitInstaller } from './types'; +import { IInstaller, InstallerResponse } from '../../platform/interpreter/installer/types'; import { ISqlIntegrationEnvVarsProvider } from '../../platform/notebooks/deepnote/types'; /** @@ -19,7 +20,7 @@ import { ISqlIntegrationEnvVarsProvider } from '../../platform/notebooks/deepnot suite('DeepnoteServerStarter', () => { let serverStarter: DeepnoteServerStarter; let mockProcessServiceFactory: IProcessServiceFactory; - let mockToolkitInstaller: IDeepnoteToolkitInstaller; + let mockInstaller: IInstaller; let mockAgentSkillsManager: DeepnoteAgentSkillsManager; let mockOutputChannel: IOutputChannel; let mockAsyncRegistry: IAsyncDisposableRegistry; @@ -32,7 +33,7 @@ suite('DeepnoteServerStarter', () => { setup(() => { mockProcessServiceFactory = mock(); - mockToolkitInstaller = mock(); + mockInstaller = mock(); mockAgentSkillsManager = mock(); mockOutputChannel = mock(); mockAsyncRegistry = mock(); @@ -40,10 +41,13 @@ suite('DeepnoteServerStarter', () => { when(mockAsyncRegistry.push(anything())).thenReturn(); when(mockOutputChannel.appendLine(anything())).thenReturn(); + when(mockInstaller.isInstalled(anything(), anything())).thenResolve(true); + when(mockInstaller.install(anything(), anything(), anything())).thenResolve(InstallerResponse.Installed); + when(mockInstaller.onInstalled).thenReturn(new EventEmitter().event); serverStarter = new DeepnoteServerStarter( instance(mockProcessServiceFactory), - instance(mockToolkitInstaller), + instance(mockInstaller), instance(mockAgentSkillsManager), instance(mockOutputChannel), instance(mockAsyncRegistry), @@ -62,7 +66,7 @@ suite('DeepnoteServerStarter', () => { // Create a starter without SQL provider const starterWithoutSql = new DeepnoteServerStarter( instance(mockProcessServiceFactory), - instance(mockToolkitInstaller), + instance(mockInstaller), instance(mockAgentSkillsManager), instance(mockOutputChannel), instance(mockAsyncRegistry) @@ -85,7 +89,7 @@ suite('DeepnoteServerStarter', () => { const starterWithCancelledSql = new DeepnoteServerStarter( instance(mockProcessServiceFactory), - instance(mockToolkitInstaller), + instance(mockInstaller), instance(mockAgentSkillsManager), instance(mockOutputChannel), instance(mockAsyncRegistry), diff --git a/src/kernels/deepnote/types.ts b/src/kernels/deepnote/types.ts index a0c17a31ba..444d3e6651 100644 --- a/src/kernels/deepnote/types.ts +++ b/src/kernels/deepnote/types.ts @@ -151,32 +151,24 @@ export interface IDeepnoteToolkitInstaller { export const IDeepnoteServerStarter = Symbol('IDeepnoteServerStarter'); export interface IDeepnoteServerStarter { /** - * Starts a deepnote-toolkit Jupyter server for a kernel environment. - * Environment-based method. + * Starts a deepnote-toolkit Jupyter server using the active Python interpreter. + * Handles checking/installing deepnote-toolkit via the IInstaller infrastructure. * @param interpreter The Python interpreter to use - * @param venvPath The path to the venv - * @param managedVenv Whether the venv is managed by this extension (created by us) - * @param environmentId The environment ID (for server management) * @param deepnoteFileUri The URI of the .deepnote file * @param token Cancellation token to cancel the operation * @returns Connection information (URL, port, etc.) */ startServer( interpreter: PythonEnvironment, - venvPath: vscode.Uri, - managedVenv: boolean, - additionalPackages: string[], - environmentId: string, deepnoteFileUri: vscode.Uri, token?: vscode.CancellationToken ): Promise; /** - * Stops the deepnote-toolkit server for a kernel environment. - * @param environmentId The environment ID + * Stops the deepnote-toolkit server for a .deepnote file. + * @param deepnoteFileUri The URI of the .deepnote file * @param token Cancellation token to cancel the operation */ - // stopServer(environmentId: string, token?: vscode.CancellationToken): Promise; stopServer(deepnoteFileUri: vscode.Uri, token?: vscode.CancellationToken): Promise; /** diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index 63e7129200..e7988635ee 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -8,32 +8,23 @@ import { CancellationToken, CancellationTokenSource, Disposable, - NotebookController, NotebookControllerAffinity, NotebookDocument, - NotebookEditor, ProgressLocation, - QuickPickItem, Uri, commands, env, l10n, - notebooks, window, workspace } from 'vscode'; -import { DeepnoteEnvironment } from '../../kernels/deepnote/environments/deepnoteEnvironment'; import { DEEPNOTE_NOTEBOOK_TYPE, - DEEPNOTE_TOOLKIT_VERSION, DeepnoteKernelConnectionMetadata, - IDeepnoteEnvironmentManager, IDeepnoteKernelAutoSelector, IDeepnoteLspClientManager, - IDeepnoteNotebookEnvironmentMapper, IDeepnoteServerProvider, - IDeepnoteServerStarter, - IDeepnoteToolkitInstaller + IDeepnoteServerStarter } from '../../kernels/deepnote/types'; import { createJupyterConnectionInfo } from '../../kernels/jupyter/jupyterUtils'; import { JupyterLabHelper } from '../../kernels/jupyter/session/jupyterLabHelper'; @@ -51,7 +42,8 @@ import { getDisplayPath } from '../../platform/common/platform/fs-paths.node'; import { IConfigurationService, IDisposableRegistry, IOutputChannel } from '../../platform/common/types'; import { disposeAsync } from '../../platform/common/utils'; import { createDeepnoteServerConfigHandle } from '../../platform/deepnote/deepnoteServerUtils.node'; -import { DeepnoteKernelError, DeepnoteToolkitMissingError } from '../../platform/errors/deepnoteKernelErrors'; +import { DeepnoteKernelError } from '../../platform/errors/deepnoteKernelErrors'; +import { IInterpreterService } from '../../platform/interpreter/contracts'; import { logger } from '../../platform/logging'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; import { IControllerRegistration, IVSCodeNotebookController } from '../controllers/types'; @@ -60,10 +52,6 @@ import { IDeepnoteInitNotebookRunner } from './deepnoteInitNotebookRunner.node'; import { computeRequirementsHash } from './deepnoteProjectUtils'; import { IDeepnoteRequirementsHelper } from './deepnoteRequirementsHelper.node'; -// Constants for NotebookEditor retry logic -const NOTEBOOK_EDITOR_RETRY_COUNT = 10; -const NOTEBOOK_EDITOR_RETRY_DELAY_MS = 100; - /** * Automatically selects and starts Deepnote kernel for .deepnote notebooks */ @@ -73,10 +61,8 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, private readonly notebookConnectionMetadata = new Map(); // Track registered controllers per NOTEBOOK (full URI with query) - one controller per notebook private readonly notebookControllers = new Map(); - // Track environment for each notebook - private readonly notebookEnvironmentsIds = new Map(); - // Track per-notebook placeholder controllers for notebooks without configured environments - private readonly placeholderControllers = new Map(); + // Track interpreter ID for each notebook + private readonly notebookInterpreterIds = new Map(); // Track server handles per PROJECT (baseFileUri) - one server per project private readonly projectServerHandles = new Map(); // Track projects where we need to run init notebook (set during controller setup) @@ -100,12 +86,9 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, @inject(IKernelProvider) private readonly kernelProvider: IKernelProvider, @inject(IDeepnoteRequirementsHelper) private readonly requirementsHelper: IDeepnoteRequirementsHelper, - @inject(IDeepnoteEnvironmentManager) private readonly environmentManager: IDeepnoteEnvironmentManager, @inject(IDeepnoteServerStarter) private readonly serverStarter: IDeepnoteServerStarter, - @inject(IDeepnoteNotebookEnvironmentMapper) - private readonly notebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper, @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, - @inject(IDeepnoteToolkitInstaller) private readonly toolkitInstaller: IDeepnoteToolkitInstaller + @inject(IInterpreterService) private readonly interpreterService: IInterpreterService ) {} public activate() { @@ -170,14 +153,11 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, (result) => { logger.info(`Auto-selecting Deepnote kernel for ${getDisplayPath(notebook.uri)} result: ${result}`); if (!result) { - logger.info(`No environment configured for ${getDisplayPath(notebook.uri)}, showing warning`); - this.showNoEnvironmentWarning(notebook).catch((error) => { - logger.error( - `Error showing no environment warning for ${getDisplayPath(notebook.uri)}`, - error - ); - void this.handleKernelSelectionError(error, notebook); - }); + logger.warn( + `No active Python interpreter found for ${getDisplayPath( + notebook.uri + )}, kernel not selected` + ); } }, (error) => { @@ -187,83 +167,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, ); } - private async showNoEnvironmentWarning(notebook: NotebookDocument): Promise { - logger.info(`Showing no environment warning for ${getDisplayPath(notebook.uri)}`); - const selectEnvironmentAction = l10n.t('Select Environment'); - const cancelAction = l10n.t('Cancel'); - - const selectedAction = await window.showWarningMessage( - l10n.t('No environment configured for this notebook. Please select an environment to continue.'), - { modal: false }, - selectEnvironmentAction, - cancelAction - ); - - logger.info(`Selected action: ${selectedAction}`); - if (selectedAction === selectEnvironmentAction) { - logger.info(`Executing command to pick environment for ${getDisplayPath(notebook.uri)}`); - void commands.executeCommand('deepnote.environments.selectForNotebook', { notebook }); - } - } - - public async pickEnvironment(notebookUri: Uri): Promise { - logger.info(`Picking environment for notebook ${getDisplayPath(notebookUri)}`); - - // Wait for environment manager to finish loading environments from storage - await this.environmentManager.waitForInitialization(); - - const environments = this.environmentManager.listEnvironments(); - const items: (QuickPickItem & { environment?: DeepnoteEnvironment })[] = environments.map((env) => { - return { - label: env.name, - description: getDisplayPath(env.pythonInterpreter.uri), - detail: env.packages?.length - ? l10n.t('Packages: {0}', env.packages.join(', ')) - : l10n.t('No additional packages'), - environment: env - }; - }); - - items.push({ - label: '$(add) Create New Environment', - description: 'Set up a new kernel environment', - alwaysShow: true - }); - - const selected = await window.showQuickPick(items, { - placeHolder: `Select an environment for ${getDisplayPath(notebookUri)}`, - matchOnDescription: true, - matchOnDetail: true - }); - - if (!selected) { - logger.info('User cancelled environment selection'); - return; // User cancelled - } - - if (!selected.environment) { - logger.info('User chose to create new environment - triggering create command'); - - await commands.executeCommand('deepnote.environments.create'); - - const newEnvironments = this.environmentManager.listEnvironments(); - - if (newEnvironments.length > environments.length) { - logger.info('Environment created, showing picker again'); - - return this.pickEnvironment(notebookUri); - } - - logger.info('No new environment created'); - - return; - } - - logger.info(`Selected environment "${selected.environment.name}" for notebook ${getDisplayPath(notebookUri)}`); - - return selected.environment; - } - private onControllerSelectionChanged(event: { notebook: NotebookDocument; controller: IVSCodeNotebookController; @@ -308,16 +211,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } logger.info(`Deepnote notebook closed: ${getDisplayPath(notebook.uri)}`); - - // Clean up placeholder controller if it exists - const notebookKey = notebook.uri.toString(); - const placeholder = this.placeholderControllers.get(notebookKey); - - if (placeholder) { - logger.info(`Disposing placeholder controller for closed notebook: ${getDisplayPath(notebook.uri)}`); - placeholder.dispose(); - this.placeholderControllers.delete(notebookKey); - } } public async onKernelStarted(kernel: IKernel) { @@ -422,21 +315,17 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // cause "command already exists" errors when trying to start new clients await this.lspClientManager.stopLspClients(notebook.uri, token); - // Update the controller with new environment's metadata - // Because we use notebook-based controller IDs, addOrUpdate will call updateConnection() - // on the existing controller instead of creating a new one - const environmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri); - const environment = environmentId ? this.environmentManager.getEnvironment(environmentId) : undefined; + // Get the active interpreter and re-setup the kernel + const interpreter = await this.interpreterService.getActiveInterpreter(notebook.uri); - if (environment == null) { - await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(baseFileUri); - logger.error(`No environment found for notebook ${getDisplayPath(notebook.uri)}`); + if (!interpreter) { + logger.error(`No active Python interpreter found for ${getDisplayPath(notebook.uri)}`); return; } - await this.ensureKernelSelectedWithConfiguration( + await this.ensureKernelSelectedWithInterpreter( notebook, - environment, + interpreter, baseFileUri, notebookKey, projectKey, @@ -459,27 +348,17 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // projectKey identifies the PROJECT for server tracking const projectKey = baseFileUri.fsPath; - const environmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri); - - if (environmentId == null) { - await this.selectPlaceholderController(notebook); + // Get the active Python interpreter + const interpreter = await this.interpreterService.getActiveInterpreter(notebook.uri); + if (!interpreter) { + logger.warn(`No active Python interpreter found for ${getDisplayPath(notebook.uri)}`); return false; } - const environment = environmentId ? this.environmentManager.getEnvironment(environmentId) : undefined; - - if (environment == null) { - logger.info(`No environment found for notebook ${getDisplayPath(notebook.uri)}`); - await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(baseFileUri); - await this.selectPlaceholderController(notebook); - - return false; - } - - await this.ensureKernelSelectedWithConfiguration( + await this.ensureKernelSelectedWithInterpreter( notebook, - environment, + interpreter, baseFileUri, notebookKey, projectKey, @@ -490,26 +369,17 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return true; } - public async ensureKernelSelectedWithConfiguration( + public async ensureKernelSelectedWithInterpreter( notebook: NotebookDocument, - configuration: DeepnoteEnvironment, + interpreter: PythonEnvironment, baseFileUri: Uri, notebookKey: string, projectKey: string, progress: { report(value: { message?: string; increment?: number }): void }, progressToken: CancellationToken ): Promise { - // Dispose placeholder controller if it exists (real controller is taking over) - const placeholder = this.placeholderControllers.get(notebookKey); - - if (placeholder) { - logger.info(`Disposing placeholder controller for ${getDisplayPath(notebook.uri)}`); - placeholder.dispose(); - this.placeholderControllers.delete(notebookKey); - } - - logger.info(`Setting up kernel using configuration: ${configuration.name} (${configuration.id})`); - progress.report({ message: `Using ${configuration.name}...` }); + logger.info(`Setting up kernel using interpreter: ${interpreter.id}`); + progress.report({ message: `Using interpreter ${getDisplayPath(interpreter.uri)}...` }); // Check if Python extension is installed if (!this.pythonExtensionChecker.isPythonExtensionInstalled) { @@ -519,71 +389,38 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } const existingController = this.notebookControllers.get(notebookKey); - const existingEnvironmentId = this.notebookEnvironmentsIds.get(notebookKey); - - if (existingEnvironmentId != null && existingController != null && existingEnvironmentId === configuration.id) { - logger.info(`Existing controller found for notebook ${getDisplayPath(notebook.uri)}, verifying connection`); - - // Verify the controller's interpreter path matches the expected venv path - // This handles cases where notebooks were used in VS Code and now opened in Cursor - if (this.isControllerInterpreterValid(existingController, configuration.venvPath)) { - logger.info(`Controller verified, selecting it`); - await this.ensureControllerSelectedForNotebook(notebook, existingController, progressToken); - - return; - } - - const expectedInterpreter = this.getVenvInterpreterUri(configuration.venvPath); - logger.warn( - `Controller interpreter path mismatch! Expected: ${expectedInterpreter.fsPath}, Got: ${existingController.connection.interpreter?.uri.fsPath}. Recreating controller.` - ); + const existingInterpreterId = this.notebookInterpreterIds.get(notebookKey); - // Dispose old controller and recreate it - existingController.dispose(); - this.notebookControllers.delete(notebookKey); + if (existingInterpreterId != null && existingController != null && existingInterpreterId === interpreter.id) { + logger.info(`Existing controller found for notebook ${getDisplayPath(notebook.uri)}, reusing`); + await this.ensureControllerSelectedForNotebook(notebook, existingController, progressToken); + return; } // Ensure server is running (startServer is idempotent - returns early if already running) - // Note: startServer() will create the venv if it doesn't exist - logger.info(`Ensuring server is running for configuration ${configuration.id}`); + // Server starter handles toolkit check/install via IInstaller internally + logger.info(`Ensuring server is running for interpreter ${interpreter.id}`); progress.report({ message: 'Starting Deepnote server...' }); - const serverInfo = await this.serverStarter.startServer( - configuration.pythonInterpreter, - configuration.venvPath, - configuration.managedVenv, - configuration.packages ?? [], - configuration.id, - baseFileUri, - progressToken - ); + const serverInfo = await this.serverStarter.startServer(interpreter, baseFileUri, progressToken); - this.notebookEnvironmentsIds.set(notebookKey, configuration.id); + this.notebookInterpreterIds.set(notebookKey, interpreter.id); logger.info(`Server running at ${serverInfo.url}`); - // Update last used timestamp - await this.environmentManager.updateLastUsed(configuration.id); - - // Create server provider handle + // Create server provider handle using interpreter ID const serverProviderHandle: JupyterServerProviderHandle = { extensionId: JVSC_EXTENSION_ID, id: 'deepnote-server', - handle: createDeepnoteServerConfigHandle(configuration.id, baseFileUri) + handle: createDeepnoteServerConfigHandle(interpreter.id, baseFileUri) }; // Register the server with the provider (one server per PROJECT) this.serverProvider.registerServer(serverProviderHandle.handle, serverInfo); this.projectServerHandles.set(projectKey, serverProviderHandle.handle); - const lspInterpreterUri = this.getVenvInterpreterUri(configuration.venvPath); - - const lspInterpreter: PythonEnvironment = { - uri: lspInterpreterUri, - id: lspInterpreterUri.fsPath - } as PythonEnvironment; - + // Use the active interpreter directly for LSP (it already has deepnote-toolkit installed) try { - await this.lspClientManager.startLspClients(serverInfo, notebook.uri, lspInterpreter, progressToken); + await this.lspClientManager.startLspClients(serverInfo, notebook.uri, interpreter, progressToken); logger.info(`✓ LSP clients started for ${notebookKey}`); } catch (error) { @@ -592,12 +429,14 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, progress.report({ message: 'Connecting to kernel...' }); + const displayName = `Deepnote: ${getDisplayPath(interpreter.uri)} (${notebookKey})`; + const connectionInfo = createJupyterConnectionInfo( serverProviderHandle, { baseUrl: serverInfo.url, token: serverInfo.token || '', - displayName: `Deepnote: ${configuration.name} (${notebookKey})`, + displayName, authorizationHeader: {} }, this.requestCreator, @@ -612,8 +451,8 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, const kernelSpecs = await sessionManager.getKernelSpecs(); logger.info(`Available kernel specs on Deepnote server: ${kernelSpecs.map((s) => s.name).join(', ')}`); - // Use the extracted kernel selection logic - kernelSpec = this.selectKernelSpec(kernelSpecs, configuration.id); + // Select the default Python kernel (ipykernel-provided python3 spec) + kernelSpec = this.selectKernelSpec(kernelSpecs); logger.info(`✓ Using kernel spec: ${kernelSpec.name} (${kernelSpec.display_name})`); } finally { @@ -622,10 +461,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, progress.report({ message: 'Finalizing kernel setup...' }); - const venvInterpreter = this.getVenvInterpreterUri(configuration.venvPath); - - logger.info(`Using venv path: ${configuration.venvPath.fsPath}`); - logger.info(`Venv interpreter path: ${venvInterpreter.fsPath}`); + logger.info(`Using interpreter: ${interpreter.uri.fsPath}`); // CRITICAL: Use unique notebook-based ID (includes query with notebook ID) // This ensures each notebook gets its own controller/kernel, even within the same project. @@ -637,14 +473,14 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, const projectTitle = notebook.metadata?.deepnoteProjectName || 'Untitled Project'; const newConnectionMetadata = DeepnoteKernelConnectionMetadata.create({ - interpreter: { uri: venvInterpreter, id: venvInterpreter.fsPath }, + interpreter, kernelSpec, baseUrl: serverInfo.url, id: controllerId, projectFilePath: baseFileUri.toString(), serverProviderHandle, serverInfo, - environmentName: configuration.name, + environmentName: getDisplayPath(interpreter.uri), projectName: projectTitle, notebookName: notebookKey }); @@ -717,7 +553,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // Auto-select the controller await this.ensureControllerSelectedForNotebook(notebook, controller, progressToken); - logger.info(`Successfully set up kernel with configuration: ${configuration.name}`); + logger.info(`Successfully set up kernel with interpreter: ${interpreter.id}`); progress.report({ message: 'Kernel ready!' }); } @@ -746,35 +582,20 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } /** - * Select the appropriate kernel spec for an environment. + * Select the default Python kernel spec from the server. * Extracted for testability. * @param kernelSpecs Available kernel specs from the server - * @param environmentId The environment ID to find a kernel for * @returns The selected kernel spec * @throws Error if no suitable kernel spec is found */ - public selectKernelSpec(kernelSpecs: IJupyterKernelSpec[], environmentId: string): IJupyterKernelSpec { - // Look for environment-specific kernel first - const expectedKernelName = `deepnote-${environmentId}`; - logger.info(`Looking for environment-specific kernel: ${expectedKernelName}`); - - const kernelSpec = kernelSpecs.find((s) => s.name === expectedKernelName); + public selectKernelSpec(kernelSpecs: IJupyterKernelSpec[]): IJupyterKernelSpec { + const kernelSpec = + kernelSpecs.find((s) => s.language === 'python') || + kernelSpecs.find((s) => s.name === 'python3') || + kernelSpecs[0]; if (!kernelSpec) { - logger.warn( - `Environment-specific kernel '${expectedKernelName}' not found! Falling back to generic Python kernel.` - ); - // Fallback to any Python kernel - const fallbackKernel = - kernelSpecs.find((s) => s.language === 'python') || - kernelSpecs.find((s) => s.name === 'python3') || - kernelSpecs[0]; - - if (!fallbackKernel) { - throw new Error('No kernel specs available on Deepnote server'); - } - - return fallbackKernel; + throw new Error('No kernel specs available on Deepnote server'); } return kernelSpec; @@ -782,7 +603,7 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, /** * Ensure an environment is configured for the notebook before execution. - * If not configured, shows picker and sets up the kernel. + * Uses the active Python interpreter and the IInstaller infrastructure. * @returns true if environment is ready, false if user cancelled */ public async ensureEnvironmentConfiguredBeforeExecution( @@ -795,113 +616,20 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, const notebookKey = notebook.uri.toString(); const projectKey = baseFileUri.fsPath; - const existingEnvironmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri); - - // No environment configured - need to pick one - if (!existingEnvironmentId) { - return this.pickAndSetupEnvironment(notebook, baseFileUri, notebookKey, projectKey, token); - } - - const environment = this.environmentManager.getEnvironment(existingEnvironmentId); + const interpreter = await this.interpreterService.getActiveInterpreter(notebook.uri); - // Environment no longer exists - remove stale mapping and pick a new one - if (!environment) { - logger.info(`Removing stale environment mapping for ${getDisplayPath(notebook.uri)}`); - await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(baseFileUri); - - return this.pickAndSetupEnvironment(notebook, baseFileUri, notebookKey, projectKey, token); + if (!interpreter) { + logger.warn(`No active Python interpreter found for ${getDisplayPath(notebook.uri)}`); + return false; } const existingController = this.notebookControllers.get(notebookKey); - // Environment and controller already configured - but verify interpreter path still matches if (existingController) { - if (!this.isControllerInterpreterValid(existingController, environment.venvPath)) { - const expectedInterpreter = this.getVenvInterpreterUri(environment.venvPath); - logger.warn( - `Controller interpreter path mismatch! Expected: ${expectedInterpreter.fsPath}, Got: ${existingController.connection.interpreter?.uri.fsPath}. Recreating controller.` - ); - - existingController.dispose(); - this.notebookControllers.delete(notebookKey); - - return this.setupKernelForEnvironment( - notebook, - environment, - baseFileUri, - notebookKey, - projectKey, - token - ); - } - - logger.info(`Environment "${environment.name}" already configured for ${getDisplayPath(notebook.uri)}`); - + logger.info(`Controller already configured for ${getDisplayPath(notebook.uri)}`); return true; } - // Environment exists but controller is missing - set it up - logger.info( - `Environment "${environment.name}" configured but controller missing for ${getDisplayPath( - notebook.uri - )}, triggering setup` - ); - - return this.setupKernelForEnvironment(notebook, environment, baseFileUri, notebookKey, projectKey, token); - } - - /** - * Pick an environment and set up the kernel for a notebook. - */ - private async pickAndSetupEnvironment( - notebook: NotebookDocument, - baseFileUri: Uri, - notebookKey: string, - projectKey: string, - token: CancellationToken - ): Promise { - Cancellation.throwIfCanceled(token); - - logger.info(`No environment configured for ${getDisplayPath(notebook.uri)}, showing picker`); - const selectedEnvironment = await this.pickEnvironment(notebook.uri); - - if (!selectedEnvironment) { - logger.info(`User cancelled environment selection for ${getDisplayPath(notebook.uri)}`); - - return false; - } - - Cancellation.throwIfCanceled(token); - - await this.notebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, selectedEnvironment.id); - - const result = await this.setupKernelForEnvironment( - notebook, - selectedEnvironment, - baseFileUri, - notebookKey, - projectKey, - token - ); - - if (result) { - logger.info(`Environment "${selectedEnvironment.name}" configured for ${getDisplayPath(notebook.uri)}`); - } - - return result; - } - - /** - * Set up the kernel for a given environment. - */ - private async setupKernelForEnvironment( - notebook: NotebookDocument, - environment: DeepnoteEnvironment, - baseFileUri: Uri, - notebookKey: string, - projectKey: string, - token: CancellationToken - ): Promise { try { await window.withProgress( { @@ -910,9 +638,9 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, cancellable: true }, async (progress, progressToken) => { - await this.ensureKernelSelectedWithConfiguration( + await this.ensureKernelSelectedWithInterpreter( notebook, - environment, + interpreter, baseFileUri, notebookKey, projectKey, @@ -924,23 +652,12 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } catch (error) { if (token.isCancellationRequested || isCancellationError(error as Error)) { logger.info(`Kernel setup cancelled for ${getDisplayPath(notebook.uri)}`); - return false; } throw error; } - const createdController = this.notebookControllers.get(notebookKey); - - if (!createdController) { - logger.warn( - `Controller not created for "${environment.name}" on ${getDisplayPath(notebook.uri)} after setup` - ); - - return false; - } - - return true; + return !!this.notebookControllers.get(notebookKey); } /** @@ -964,106 +681,10 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } } - private getVenvInterpreterUri(venvPath: Uri): Uri { - return process.platform === 'win32' - ? Uri.joinPath(venvPath, 'Scripts', 'python.exe') - : Uri.joinPath(venvPath, 'bin', 'python'); - } - - /** - * Check if a controller's interpreter path matches the expected venv path. - * Returns true when no interpreter is present (nothing to validate) or when paths match. - */ - private isControllerInterpreterValid( - controller: { connection: { interpreter?: { uri: Uri } } }, - venvPath: Uri - ): boolean { - const existingInterpreter = controller.connection.interpreter; - - if (!existingInterpreter) { - return true; - } - - const expectedInterpreter = this.getVenvInterpreterUri(venvPath); - - return existingInterpreter.uri.fsPath === expectedInterpreter.fsPath; - } - - /** - * Find the NotebookEditor for a given NotebookDocument. - * Required for properly selecting a kernel with the notebook.selectKernel command. - * Includes retry logic since the editor might not be visible immediately when the document opens. - */ - private async findNotebookEditor(notebook: NotebookDocument): Promise { - // Try to find immediately - let editor = window.visibleNotebookEditors.find((e) => e.notebook.uri.toString() === notebook.uri.toString()); - - if (editor) { - return editor; - } - - // If not found, wait briefly and retry (editor might not be visible yet) - for (let i = 0; i < NOTEBOOK_EDITOR_RETRY_COUNT; i++) { - await new Promise((resolve) => setTimeout(resolve, NOTEBOOK_EDITOR_RETRY_DELAY_MS)); - - editor = window.visibleNotebookEditors.find((e) => e.notebook.uri.toString() === notebook.uri.toString()); - - if (editor) { - return editor; - } - } - - return; - } - - /** - * Create and select a placeholder controller for a notebook without a configured environment. - */ - private async selectPlaceholderController(notebook: NotebookDocument): Promise { - const placeholder = this.createPlaceholderController(notebook); - placeholder.updateNotebookAffinity(notebook, NotebookControllerAffinity.Preferred); - - const notebookEditor = await this.findNotebookEditor(notebook); - - if (notebookEditor) { - await commands.executeCommand('notebook.selectKernel', { - notebookEditor: notebookEditor, - id: placeholder.id, - extension: JVSC_EXTENSION_ID - }); - } else { - logger.warn( - `Could not find NotebookEditor for ${getDisplayPath(notebook.uri)}, kernel may not be selected` - ); - } - } - /** * Handle kernel selection errors with user-friendly messages and actions */ - public async handleKernelSelectionError(error: unknown, notebook: NotebookDocument): Promise { - if (error instanceof DeepnoteToolkitMissingError) { - const installAction = l10n.t('Install'); - const changeEnvironmentAction = l10n.t('Change Environment'); - const selectedAction = await window.showWarningMessage( - l10n.t( - 'Running Deepnote projects requires deepnote-toolkit[server]=={0} to be installed in the selected environment', - DEEPNOTE_TOOLKIT_VERSION - ), - { modal: true }, - installAction, - changeEnvironmentAction - ); - - if (selectedAction === installAction) { - await this.installToolkitAndNotify(error.venvPath, notebook); - } else if (selectedAction === changeEnvironmentAction) { - void commands.executeCommand('deepnote.environments.selectForNotebook', { notebook }); - } - - return; - } - + public async handleKernelSelectionError(error: unknown, _notebook: NotebookDocument): Promise { // Handle DeepnoteKernelError types with specific guidance if (error instanceof DeepnoteKernelError) { // Log the technical details @@ -1117,35 +738,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } } - /** - * Install deepnote-toolkit in an existing venv and rebuild the controller. - */ - private async installToolkitAndNotify(venvPath: string, notebook: NotebookDocument): Promise { - try { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: l10n.t('Installing deepnote-toolkit...'), - cancellable: true - }, - async (progress, token) => { - await this.toolkitInstaller.installToolkitInExistingVenv(Uri.file(venvPath), token); - - // After successful installation, rebuild the controller to use the new environment - progress.report({ message: l10n.t('Starting kernel...') }); - await this.rebuildController(notebook, progress, token); - } - ); - - void window.showInformationMessage(l10n.t('deepnote-toolkit installed successfully')); - } catch (installError) { - logger.error('Failed to install deepnote-toolkit', installError); - const errorMessage = installError instanceof Error ? installError.message : String(installError); - - void window.showErrorMessage(l10n.t('Failed to install deepnote-toolkit: {0}', errorMessage)); - } - } - /** * Read and hash the existing requirements.txt file if it exists. * Returns the same hash format as computeRequirementsHash for comparison. @@ -1181,103 +773,4 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return ''; } } - - /** - * Create a placeholder controller for a notebook without a configured environment. - * Each notebook gets its own placeholder with a unique ID. - * The placeholder's executeHandler shows the environment picker when user tries to run cells. - */ - private createPlaceholderController(notebook: NotebookDocument): NotebookController { - const notebookKey = notebook.uri.toString(); - - // Check if we already have one - const existing = this.placeholderControllers.get(notebookKey); - - if (existing) { - return existing; - } - - const controller = notebooks.createNotebookController( - `deepnote-placeholder-${notebookKey}`, - DEEPNOTE_NOTEBOOK_TYPE, - l10n.t('Deepnote: Select Environment') - ); - - controller.supportsExecutionOrder = true; - controller.supportedLanguages = ['python', 'sql', 'markdown']; - - // Execution handler that shows environment picker when user tries to run without an environment - controller.executeHandler = async (cells, doc) => { - logger.info( - `Placeholder controller execute handler called for ${getDisplayPath(doc.uri)} with ${ - cells.length - } cells` - ); - - // Create a cancellation token that cancels when the notebook is closed - const cts = new CancellationTokenSource(); - const closeListener = workspace.onDidCloseNotebookDocument((closedDoc) => { - if (closedDoc.uri.toString() === doc.uri.toString()) { - logger.info(`Notebook closed during environment setup, cancelling operation`); - cts.cancel(); - } - }); - - try { - const hasEnvironment = await this.ensureEnvironmentConfiguredBeforeExecution(doc, cts.token); - - if (!hasEnvironment) { - logger.info(`User cancelled environment selection, not executing cells`); - - return; - } - - // Environment is now configured, execute the cells through the kernel - const docNotebookKey = doc.uri.toString(); - const realController = this.notebookControllers.get(docNotebookKey); - - if (!realController) { - logger.error(`No controller found after environment configuration for ${docNotebookKey}`); - - return; - } - - logger.info(`Executing ${cells.length} cells through kernel after environment configuration`); - - // Get or create a kernel for this notebook with the new connection - const kernel = this.kernelProvider.getOrCreate(doc, { - metadata: realController.connection, - controller: realController.controller, - resourceUri: doc.uri - }); - - // Execute cells through the kernel - const kernelExecution = this.kernelProvider.getKernelExecution(kernel); - - for (const cell of cells) { - try { - await kernelExecution.executeCell(cell); - } catch (cellError) { - logger.error(`Error executing cell ${cell.index}`, cellError); - // Continue with remaining cells - } - } - - logger.info(`Finished executing ${cells.length} cells`); - } catch (error) { - if (isCancellationError(error)) { - logger.info(`Environment setup cancelled for ${getDisplayPath(doc.uri)}`); - } else { - logger.error(`Error in placeholder controller execute handler`, error); - } - } finally { - closeListener.dispose(); - cts.dispose(); - } - }; - - this.placeholderControllers.set(notebookKey, controller); - - return controller; - } } diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts index 824635343c..cb0b67ee69 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts @@ -2,14 +2,10 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { DeepnoteKernelAutoSelector } from './deepnoteKernelAutoSelector.node'; -import { createMockChildProcess } from '../../kernels/deepnote/deepnoteTestHelpers.node'; import { - IDeepnoteEnvironmentManager, IDeepnoteLspClientManager, - IDeepnoteNotebookEnvironmentMapper, IDeepnoteServerProvider, - IDeepnoteServerStarter, - IDeepnoteToolkitInstaller + IDeepnoteServerStarter } from '../../kernels/deepnote/types'; import { IControllerRegistration, IVSCodeNotebookController } from '../controllers/types'; import { IDisposableRegistry, IOutputChannel } from '../../platform/common/types'; @@ -21,8 +17,8 @@ import { IDeepnoteNotebookManager } from '../types'; import { IKernelProvider, IKernel, IJupyterKernelSpec } from '../../kernels/types'; import { IDeepnoteRequirementsHelper } from './deepnoteRequirementsHelper.node'; import { NotebookDocument, Uri, NotebookController, CancellationToken } from 'vscode'; -import { DeepnoteEnvironment } from '../../kernels/deepnote/environments/deepnoteEnvironment'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; +import { IInterpreterService } from '../../platform/interpreter/contracts'; import { computeRequirementsHash } from './deepnoteProjectUtils'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; @@ -39,11 +35,9 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { let mockNotebookManager: IDeepnoteNotebookManager; let mockKernelProvider: IKernelProvider; let mockRequirementsHelper: IDeepnoteRequirementsHelper; - let mockEnvironmentManager: IDeepnoteEnvironmentManager; let mockServerStarter: IDeepnoteServerStarter; - let mockNotebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper; let mockOutputChannel: IOutputChannel; - let mockToolkitInstaller: IDeepnoteToolkitInstaller; + let mockInterpreterService: IInterpreterService; let mockProgress: { report(value: { message?: string; increment?: number }): void }; let mockCancellationToken: CancellationToken; @@ -69,11 +63,9 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { mockNotebookManager = mock(); mockKernelProvider = mock(); mockRequirementsHelper = mock(); - mockEnvironmentManager = mock(); mockServerStarter = mock(); - mockToolkitInstaller = mock(); - mockNotebookEnvironmentMapper = mock(); mockOutputChannel = mock(); + mockInterpreterService = mock(); mockProgress = { report: sandbox.stub() }; mockCancellationToken = mock(); @@ -135,11 +127,9 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { instance(mockNotebookManager), instance(mockKernelProvider), instance(mockRequirementsHelper), - instance(mockEnvironmentManager), instance(mockServerStarter), - instance(mockNotebookEnvironmentMapper), instance(mockOutputChannel), - instance(mockToolkitInstaller) + instance(mockInterpreterService) ); }); @@ -158,31 +148,31 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { pendingCells: [{ index: 0 }, { index: 1 }] // 2 cells pending }; - // Create mock environment - const mockEnvironment = createMockEnvironment('test-env-id', 'Test Environment'); - - // Mock environment mapper and manager - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn('test-env-id'); - when(mockEnvironmentManager.getEnvironment('test-env-id')).thenReturn(mockEnvironment); + // Mock interpreter service to return an active interpreter + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); when(mockKernelProvider.get(mockNotebook)).thenReturn(instance(mockKernel)); when(mockKernelProvider.getKernelExecution(instance(mockKernel))).thenReturn(mockExecution as any); - // Stub ensureKernelSelectedWithConfiguration to verify it's still called despite pending cells - const ensureKernelSelectedWithConfigurationStub = sandbox - .stub(selector, 'ensureKernelSelectedWithConfiguration') + // Stub ensureKernelSelectedWithInterpreter to verify it's still called despite pending cells + const ensureKernelSelectedWithInterpreterStub = sandbox + .stub(selector, 'ensureKernelSelectedWithInterpreter') .resolves(); // Act await selector.rebuildController(mockNotebook, mockProgress, instance(mockCancellationToken)); // Assert - should proceed despite pending cells assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.calledOnce, + ensureKernelSelectedWithInterpreterStub.calledOnce, true, 'ensureKernelSelected should be called even with pending cells' ); assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.firstCall.args[0], + ensureKernelSelectedWithInterpreterStub.firstCall.args[0], mockNotebook, 'ensureKernelSelected should be called with the notebook' ); @@ -195,16 +185,16 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // Arrange when(mockKernelProvider.get(mockNotebook)).thenReturn(undefined); - // Create mock environment - const mockEnvironment = createMockEnvironment('test-env-id', 'Test Environment'); - - // Mock environment mapper and manager - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn('test-env-id'); - when(mockEnvironmentManager.getEnvironment('test-env-id')).thenReturn(mockEnvironment); + // Mock interpreter service to return an active interpreter + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); - // Stub ensureKernelSelectedWithConfiguration to verify it's called - const ensureKernelSelectedWithConfigurationStub = sandbox - .stub(selector, 'ensureKernelSelectedWithConfiguration') + // Stub ensureKernelSelectedWithInterpreter to verify it's called + const ensureKernelSelectedWithInterpreterStub = sandbox + .stub(selector, 'ensureKernelSelectedWithInterpreter') .resolves(); // Act @@ -212,34 +202,34 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // Assert - should proceed normally without a kernel assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.calledOnce, + ensureKernelSelectedWithInterpreterStub.calledOnce, true, 'ensureKernelSelected should be called even when no kernel exists' ); assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.firstCall.args[0], + ensureKernelSelectedWithInterpreterStub.firstCall.args[0], mockNotebook, 'ensureKernelSelected should be called with the notebook' ); }); - test('should complete successfully and delegate to ensureKernelSelectedWithConfiguration', async () => { - // This test verifies that ensureKernelSelectedWithConfiguration completes successfully + test('should complete successfully and delegate to ensureKernelSelectedWithInterpreter', async () => { + // This test verifies that ensureKernelSelectedWithInterpreter completes successfully // and delegates kernel setup to ensureKernelSelected // Arrange when(mockKernelProvider.get(mockNotebook)).thenReturn(undefined); - // Create mock environment - const mockEnvironment = createMockEnvironment('test-env-id', 'Test Environment'); - - // Mock environment mapper and manager - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn('test-env-id'); - when(mockEnvironmentManager.getEnvironment('test-env-id')).thenReturn(mockEnvironment); + // Mock interpreter service to return an active interpreter + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); - // Stub ensureKernelSelectedWithConfiguration to verify delegation - const ensureKernelSelectedWithConfigurationStub = sandbox - .stub(selector, 'ensureKernelSelectedWithConfiguration') + // Stub ensureKernelSelectedWithInterpreter to verify delegation + const ensureKernelSelectedWithInterpreterStub = sandbox + .stub(selector, 'ensureKernelSelectedWithInterpreter') .resolves(); // Act @@ -247,30 +237,30 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // Assert - method should complete without errors assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.calledOnce, + ensureKernelSelectedWithInterpreterStub.calledOnce, true, - 'ensureKernelSelectedWithConfiguration should be called to set up the new environment' + 'ensureKernelSelectedWithInterpreter should be called to set up the new environment' ); }); - test('should pass cancellation token to ensureKernelSelectedWithConfiguration', async () => { + test('should pass cancellation token to ensureKernelSelectedWithInterpreter', async () => { // This test verifies that rebuildController correctly passes the cancellation token - // to ensureKernelSelectedWithConfiguration, allowing the operation to be cancelled during execution + // to ensureKernelSelectedWithInterpreter, allowing the operation to be cancelled during execution // Arrange when(mockCancellationToken.isCancellationRequested).thenReturn(true); when(mockKernelProvider.get(mockNotebook)).thenReturn(undefined); - // Create mock environment - const mockEnvironment = createMockEnvironment('test-env-id', 'Test Environment'); - - // Mock environment mapper and manager - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn('test-env-id'); - when(mockEnvironmentManager.getEnvironment('test-env-id')).thenReturn(mockEnvironment); + // Mock interpreter service to return an active interpreter + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); - // Stub ensureKernelSelectedWithConfiguration to verify it receives the token - const ensureKernelSelectedWithConfigurationStub = sandbox - .stub(selector, 'ensureKernelSelectedWithConfiguration') + // Stub ensureKernelSelectedWithInterpreter to verify it receives the token + const ensureKernelSelectedWithInterpreterStub = sandbox + .stub(selector, 'ensureKernelSelectedWithInterpreter') .resolves(); // Act @@ -278,50 +268,23 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // Assert assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.calledOnce, + ensureKernelSelectedWithInterpreterStub.calledOnce, true, - 'ensureKernelSelectedWithConfiguration should be called once' + 'ensureKernelSelectedWithInterpreter should be called once' ); assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.firstCall.args[0], + ensureKernelSelectedWithInterpreterStub.firstCall.args[0], mockNotebook, 'ensureKernelSelected should be called with the notebook' ); assert.strictEqual( - ensureKernelSelectedWithConfigurationStub.firstCall.args[6], + ensureKernelSelectedWithInterpreterStub.firstCall.args[6], instance(mockCancellationToken), 'ensureKernelSelected should be called with the cancellation token' ); }); }); - suite('pickEnvironment', () => { - test('should return selected environment when user picks one', async () => { - // Arrange - const notebookUri = Uri.parse('file:///test/notebook.deepnote'); - const mockEnv1 = createMockEnvironment('env-1', 'Environment 1'); - const mockEnv2 = createMockEnvironment('env-2', 'Environment 2'); - const environments = [mockEnv1, mockEnv2]; - - // Mock environment manager - when(mockEnvironmentManager.waitForInitialization()).thenResolve(); - when(mockEnvironmentManager.listEnvironments()).thenReturn(environments); - - // Mock window.showQuickPick to simulate user selecting the first environment - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenResolve({ - label: mockEnv1.name, - description: mockEnv1.pythonInterpreter.uri.fsPath, - environment: mockEnv1 - } as any); - - // Act - const result = await selector.pickEnvironment(notebookUri); - - // Assert - assert.strictEqual(result, mockEnv1, 'Should return the selected environment'); - }); - }); - suite('onKernelStarted', () => { test('should return early and not call initNotebookRunner for non-deepnote notebooks', async () => { // Arrange @@ -343,51 +306,12 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { }); suite('ensureKernelSelected', () => { - test('should return false when no environment ID is assigned to the notebook', async () => { - // Mock environment mapper to return null (no environment assigned) - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn(undefined); - - // Stub ensureKernelSelectedWithConfiguration to track if it gets called - const ensureKernelSelectedStub = sandbox.stub(selector, 'ensureKernelSelectedWithConfiguration').resolves(); - - // Mock commands.executeCommand - when(mockedVSCodeNamespaces.commands.executeCommand(anything(), anything())).thenResolve(); - - // Act - const result = await selector.ensureKernelSelected( - mockNotebook, - mockProgress, - instance(mockCancellationToken) - ); - - // Assert - assert.strictEqual(result, false, 'Should return false when no environment is assigned'); - assert.strictEqual( - ensureKernelSelectedStub.called, - false, - 'ensureKernelSelectedWithConfiguration should not be called' - ); - verify(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).once(); - }); - - test('should return false and remove mapping when environment is not found', async () => { - // Arrange - const environmentId = 'missing-env-id'; - - // Mock environment mapper to return an ID - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn(environmentId); + test('should return false when no active interpreter is found', async () => { + // Mock interpreter service to return undefined (no active interpreter) + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(undefined); - // Mock environment manager to return null (environment not found) - when(mockEnvironmentManager.getEnvironment(environmentId)).thenReturn(undefined); - - // Mock remove environment mapping - when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenResolve(); - - // Stub ensureKernelSelectedWithConfiguration to track if it gets called - const ensureKernelSelectedStub = sandbox.stub(selector, 'ensureKernelSelectedWithConfiguration').resolves(); - - // Mock commands.executeCommand - when(mockedVSCodeNamespaces.commands.executeCommand(anything(), anything())).thenResolve(); + // Stub ensureKernelSelectedWithInterpreter to track if it gets called + const ensureKernelSelectedStub = sandbox.stub(selector, 'ensureKernelSelectedWithInterpreter').resolves(); // Act const result = await selector.ensureKernelSelected( @@ -397,36 +321,29 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { ); // Assert - assert.strictEqual(result, false, 'Should return false when environment is not found'); + assert.strictEqual(result, false, 'Should return false when no active interpreter is found'); assert.strictEqual( ensureKernelSelectedStub.called, false, - 'ensureKernelSelectedWithConfiguration should not be called' + 'ensureKernelSelectedWithInterpreter should not be called' ); - verify(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).once(); - verify(mockEnvironmentManager.getEnvironment(environmentId)).once(); - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).once(); }); - test('should return true and call ensureKernelSelectedWithConfiguration when environment is found', async () => { + test('should return true and call ensureKernelSelectedWithInterpreter when interpreter is found', async () => { // Arrange const baseFileUri = mockNotebook.uri.with({ query: '', fragment: '' }); const notebookKey = mockNotebook.uri.toString(); const projectKey = baseFileUri.fsPath; - const environmentId = 'test-env-id'; - const mockEnvironment = createMockEnvironment(environmentId, 'Test Environment'); - - // Mock environment mapper to return an ID - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).thenReturn(environmentId); - - // Mock environment manager to return the environment - when(mockEnvironmentManager.getEnvironment(environmentId)).thenReturn(mockEnvironment); + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; - // Stub ensureKernelSelectedWithConfiguration to track calls - const ensureKernelSelectedStub = sandbox.stub(selector, 'ensureKernelSelectedWithConfiguration').resolves(); + // Mock interpreter service to return an active interpreter + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); - // Mock commands.executeCommand - when(mockedVSCodeNamespaces.commands.executeCommand(anything(), anything())).thenResolve(); + // Stub ensureKernelSelectedWithInterpreter to track calls + const ensureKernelSelectedStub = sandbox.stub(selector, 'ensureKernelSelectedWithInterpreter').resolves(); // Act const result = await selector.ensureKernelSelected( @@ -436,25 +353,22 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { ); // Assert - assert.strictEqual(result, true, 'Should return true when environment is found'); + assert.strictEqual(result, true, 'Should return true when interpreter is found'); assert.strictEqual( ensureKernelSelectedStub.calledOnce, true, - 'ensureKernelSelectedWithConfiguration should be called once' + 'ensureKernelSelectedWithInterpreter should be called once' ); // Verify it was called with correct arguments const callArgs = ensureKernelSelectedStub.firstCall.args; assert.strictEqual(callArgs[0], mockNotebook, 'First arg should be notebook'); - assert.strictEqual(callArgs[1], mockEnvironment, 'Second arg should be environment'); + assert.deepStrictEqual(callArgs[1], mockInterpreter, 'Second arg should be interpreter'); assert.strictEqual(callArgs[2].toString(), baseFileUri.toString(), 'Third arg should be baseFileUri'); assert.strictEqual(callArgs[3], notebookKey, 'Fourth arg should be notebookKey'); assert.strictEqual(callArgs[4], projectKey, 'Fifth arg should be projectKey'); assert.strictEqual(callArgs[5], mockProgress, 'Sixth arg should be progress'); assert.strictEqual(callArgs[6], instance(mockCancellationToken), 'Seventh arg should be token'); - - verify(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(anything())).once(); - verify(mockEnvironmentManager.getEnvironment(environmentId)).once(); }); }); @@ -702,7 +616,7 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // // CURRENT IMPLEMENTATION BEHAVIOR: // - // 1. If startServer() fails, the error propagates from ensureKernelSelectedWithConfiguration() + // 1. If startServer() fails, the error propagates from ensureKernelSelectedWithInterpreter() // (deepnoteKernelAutoSelector.node.ts:450-467) // // 2. The error is caught and shown to user in the UI layer @@ -738,72 +652,53 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // REAL TDD Tests - These should FAIL if bugs exist suite('Bug Detection: Kernel Selection', () => { - test('BUG-1: Should prefer environment-specific kernel over .env kernel', () => { - // REAL TEST: This will FAIL if the wrong kernel is selected - // - // The selectKernelSpec method is now extracted and testable! + test('Should select the first Python kernel from available specs', () => { + // The selectKernelSpec method selects the first Python kernel available - const envId = 'env123'; const kernelSpecs: IJupyterKernelSpec[] = [ createMockKernelSpec('.env', '.env Python', 'python'), - createMockKernelSpec(`deepnote-${envId}`, 'Deepnote Environment', 'python'), createMockKernelSpec('python3', 'Python 3', 'python') ]; - const selected = selector.selectKernelSpec(kernelSpecs, envId); + const selected = selector.selectKernelSpec(kernelSpecs); - // CRITICAL ASSERTION: Should select environment-specific kernel, NOT .env - assert.strictEqual( - selected?.name, - `deepnote-${envId}`, - `BUG DETECTED: Selected "${selected?.name}" instead of "deepnote-${envId}"! This would use wrong environment.` - ); + // Should select the first Python kernel + assert.strictEqual(selected.language, 'python', 'Should select a Python kernel'); + assert.strictEqual(selected.name, '.env', 'Should select the first Python kernel'); }); - test('BUG-1b: Current implementation falls back to Python kernel (documents expected behavior)', () => { - // This test documents that the current implementation DOES have fallback logic - // - // EXPECTED BEHAVIOR (current): Fall back to generic Python kernel when env-specific kernel not found - // This is a design decision - we don't want to block users if the environment-specific kernel isn't ready yet + test('Should fall back to python3 named kernel when no python language kernel exists first', () => { + // Documents fallback behavior - finds python3 by name if no python language match - const envId = 'env123'; const kernelSpecs: IJupyterKernelSpec[] = [ - createMockKernelSpec('.env', '.env Python', 'python'), + createMockKernelSpec('javascript', 'JavaScript', 'javascript'), createMockKernelSpec('python3', 'Python 3', 'python') ]; - // Should fall back to a Python kernel (this is the current behavior) - const selected = selector.selectKernelSpec(kernelSpecs, envId); + const selected = selector.selectKernelSpec(kernelSpecs); - // Should have selected a fallback kernel (either .env or python3) - assert.ok(selected, 'Should select a fallback kernel'); - assert.strictEqual(selected.language, 'python', 'Fallback should be a Python kernel'); + assert.strictEqual(selected.name, 'python3', 'Should find python3 kernel'); }); - test('Kernel selection: Should find environment-specific kernel when it exists', () => { - const envId = 'my-env'; + test('Kernel selection: Should fall back to first available kernel when no Python kernel exists', () => { const kernelSpecs: IJupyterKernelSpec[] = [ - createMockKernelSpec('python3', 'Python 3', 'python'), - createMockKernelSpec(`deepnote-${envId}`, 'My Environment', 'python') + createMockKernelSpec('javascript', 'JavaScript', 'javascript'), + createMockKernelSpec('r', 'R', 'r') ]; - const selected = selector.selectKernelSpec(kernelSpecs, envId); + const selected = selector.selectKernelSpec(kernelSpecs); - assert.strictEqual(selected?.name, `deepnote-${envId}`); + assert.strictEqual(selected.name, 'javascript', 'Should fall back to first available kernel'); }); - test('Kernel selection: Should fall back to python3 when env kernel missing', () => { - // Documents current fallback behavior - falls back to python3 when env kernel missing - const envId = 'my-env'; - const kernelSpecs: IJupyterKernelSpec[] = [ - createMockKernelSpec('python3', 'Python 3', 'python'), - createMockKernelSpec('javascript', 'JavaScript', 'javascript') - ]; + test('Kernel selection: Should throw when no kernel specs are available', () => { + const kernelSpecs: IJupyterKernelSpec[] = []; - // Should fall back to python3 (current behavior) - const selected = selector.selectKernelSpec(kernelSpecs, envId); - - assert.strictEqual(selected.name, 'python3', 'Should fall back to python3'); + assert.throws( + () => selector.selectKernelSpec(kernelSpecs), + /No kernel specs available/, + 'Should throw when no kernel specs are available' + ); }); }); @@ -834,9 +729,14 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // REAL TEST: This will FAIL if disposal happens too early // // Setup: Create a scenario where we have an old controller and create a new one - const baseFileUri = mockNotebook.uri.with({ query: '', fragment: '' }); + // const baseFileUri = mockNotebook.uri.with({ query: '', fragment: '' }); // const notebookKey = baseFileUri.fsPath; - const newEnv = createMockEnvironment('env-new', 'New Environment', true); + // Mock interpreter service to return an active interpreter + const mockInterpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.parse('/usr/bin/python3') + }; + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(mockInterpreter); // Track call order const callOrder: string[] = []; @@ -860,8 +760,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { when(newController.controller).thenReturn({} as any); // Setup mocks - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri)).thenReturn('env-new'); - when(mockEnvironmentManager.getEnvironment('env-new')).thenReturn(newEnv); when(mockPythonExtensionChecker.isPythonExtensionInstalled).thenReturn(true); // Mock controller registration to track when new controller is added @@ -1019,37 +917,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { }); }); -/** - * Helper function to create mock environments - */ -function createMockEnvironment(id: string, name: string, hasServer: boolean = false): DeepnoteEnvironment { - const mockPythonInterpreter: PythonEnvironment = { - id: `/usr/bin/python3`, - uri: Uri.parse(`/usr/bin/python3`) - }; - - return { - id, - name, - description: `Test environment ${name}`, - pythonInterpreter: mockPythonInterpreter, - venvPath: Uri.file(`/test/venvs/${id}`), - managedVenv: true, - packages: [], - createdAt: new Date(), - lastUsedAt: new Date(), - serverInfo: hasServer - ? { - url: `http://localhost:8888`, - jupyterPort: 8888, - lspPort: 8889, - token: 'test-token', - process: createMockChildProcess() - } - : undefined - }; -} - /** * Helper function to create mock kernel specs */ diff --git a/src/platform/interpreter/installer/pipInstaller.node.ts b/src/platform/interpreter/installer/pipInstaller.node.ts index ae53dde627..6548baf90e 100644 --- a/src/platform/interpreter/installer/pipInstaller.node.ts +++ b/src/platform/interpreter/installer/pipInstaller.node.ts @@ -15,6 +15,8 @@ import { Environment } from '@vscode/python-extension'; import { getEnvironmentType } from '../helpers'; import { workspace } from 'vscode'; +import { DEEPNOTE_TOOLKIT_VERSION } from '../../../kernels/deepnote/types'; + /** * Installer for pip. Default installer for most everything. */ @@ -85,8 +87,14 @@ export class PipInstaller extends ModuleInstaller { if (getEnvironmentType(interpreter) === EnvironmentType.Unknown) { args.push('--user'); } + // deepnote_toolkit's import name differs from the pip package name (deepnote-toolkit[server]) + const pipPackageName = + moduleName === translateProductToModule(Product.deepnoteToolkit) + ? `deepnote-toolkit[server]==${DEEPNOTE_TOOLKIT_VERSION}` + : moduleName; + return { - args: ['-m', 'pip', ...args, moduleName].concat(getPinnedPackages('pip', moduleName)) + args: ['-m', 'pip', ...args, pipPackageName].concat(getPinnedPackages('pip', moduleName)) }; } private isPipAvailable(interpreter: PythonEnvironment | Environment): Promise { diff --git a/src/platform/interpreter/installer/productInstaller.node.ts b/src/platform/interpreter/installer/productInstaller.node.ts index bf59cc22f3..9390224f68 100644 --- a/src/platform/interpreter/installer/productInstaller.node.ts +++ b/src/platform/interpreter/installer/productInstaller.node.ts @@ -7,10 +7,12 @@ import { ProductNames } from './productNames'; import { IInstallationChannelManager, IInstaller, + IModuleInstaller, InstallerResponse, IProductPathService, IProductService, ModuleInstallFlags, + ModuleInstallerType, Product, ProductType } from './types'; @@ -93,8 +95,19 @@ export class DataScienceInstaller { installPipIfRequired?: boolean, silent?: boolean ): Promise { - const channels = this.serviceContainer.get(IInstallationChannelManager); - const installer = await channels.getInstallationChannel(product, interpreter); + let installer: IModuleInstaller | undefined; + + // deepnote-toolkit is PyPI-only with pip-specific [server] extras syntax, + // so always use PipInstaller regardless of environment type. + if (product === Product.deepnoteToolkit) { + const channels = this.serviceContainer.get(IInstallationChannelManager); + const allInstallers = await channels.getInstallationChannels(interpreter); + installer = allInstallers.find((i) => i.type === ModuleInstallerType.Pip); + } else { + const channels = this.serviceContainer.get(IInstallationChannelManager); + installer = await channels.getInstallationChannel(product, interpreter); + } + if (!installer) { return InstallerResponse.Ignore; } diff --git a/src/platform/interpreter/installer/productNames.ts b/src/platform/interpreter/installer/productNames.ts index 5c9eaa559b..246432faa1 100644 --- a/src/platform/interpreter/installer/productNames.ts +++ b/src/platform/interpreter/installer/productNames.ts @@ -12,3 +12,4 @@ ProductNames.set(Product.kernelspec, 'kernelspec'); ProductNames.set(Product.pandas, 'pandas'); ProductNames.set(Product.pip, 'pip'); ProductNames.set(Product.ensurepip, 'ensurepip'); +ProductNames.set(Product.deepnoteToolkit, 'deepnote-toolkit'); diff --git a/src/platform/interpreter/installer/productService.node.ts b/src/platform/interpreter/installer/productService.node.ts index 5059e2b2a0..8af4dfb549 100644 --- a/src/platform/interpreter/installer/productService.node.ts +++ b/src/platform/interpreter/installer/productService.node.ts @@ -20,6 +20,7 @@ export class ProductService implements IProductService { this.ProductTypes.set(Product.pandas, ProductType.DataScience); this.ProductTypes.set(Product.pip, ProductType.DataScience); this.ProductTypes.set(Product.ensurepip, ProductType.DataScience); + this.ProductTypes.set(Product.deepnoteToolkit, ProductType.DataScience); } public getProductType(product: Product): ProductType { return this.ProductTypes.get(product)!; diff --git a/src/platform/interpreter/installer/utils.ts b/src/platform/interpreter/installer/utils.ts index b85bd38d76..2e80f6d481 100644 --- a/src/platform/interpreter/installer/utils.ts +++ b/src/platform/interpreter/installer/utils.ts @@ -23,6 +23,8 @@ export function translateProductToModule(product: Product): string { return 'pip'; case Product.ensurepip: return 'ensurepip'; + case Product.deepnoteToolkit: + return 'deepnote_toolkit'; default: { throw new WrappedError( `Product ${product} cannot be installed as a Python Module.`, From 001526448f69c3ac9cd80fde4453fde7bd42e9ab Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 2 Apr 2026 06:01:35 +0000 Subject: [PATCH 02/12] refactor(deepnote): Enhance deepnote server management and cleanup logic - Updated DeepnoteServerStarter to improve context management and error handling during server startup. - Refactored cancellation token handling to ensure proper disposal and prevent memory leaks. - Enhanced logging for notebook closure to include cleanup of associated metadata. - Added unit tests for controller unselection logic to ensure correct behavior with Deepnote kernels. --- .../deepnote/deepnoteServerStarter.node.ts | 18 ++++--- .../deepnoteKernelAutoSelector.node.ts | 28 +++++----- ...epnoteKernelAutoSelector.node.unit.test.ts | 52 +++++++++++++++++++ src/platform/interpreter/installer/types.ts | 3 +- 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/src/kernels/deepnote/deepnoteServerStarter.node.ts b/src/kernels/deepnote/deepnoteServerStarter.node.ts index ff2c7e2696..ce2d442b6b 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.node.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.node.ts @@ -8,7 +8,7 @@ import * as fs from 'fs-extra'; import { inject, injectable, named, optional } from 'inversify'; import * as os from 'os'; -import { CancellationToken, l10n, Uri } from 'vscode'; +import { CancellationToken, CancellationTokenSource, l10n, Uri } from 'vscode'; import { startServer, stopServer } from '@deepnote/runtime-core'; @@ -138,7 +138,8 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension `Stopping existing server for ${fileKey} with interpreter ${existingInterpreterId} to start new one with interpreter ${interpreterId}...` ); await this.stopServerForEnvironment(existingContext, deepnoteFileUri, token); - existingContext.interpreterId = interpreterId; + existingContext = { interpreterId, serverInfo: null }; + this.projectContexts.set(fileKey, existingContext); } } else { const newContext: ProjectContext = { @@ -235,20 +236,23 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension if (!isInstalled) { logger.info(`deepnote-toolkit not installed, installing via IInstaller...`); - const { CancellationTokenSource } = await import('vscode'); const cts = new CancellationTokenSource(); + let cancellationListener: IDisposable | undefined; try { if (token) { - token.onCancellationRequested(() => cts.cancel()); + cancellationListener = token.onCancellationRequested(() => cts.cancel()); } const result = await this.installer.install(Product.deepnoteToolkit, interpreter, cts); - if (result !== InstallerResponse.Installed) { - throw new Error('deepnote-toolkit installation was cancelled or failed'); + if (result === InstallerResponse.Cancelled) { + throw new Error('deepnote-toolkit installation was cancelled by the user'); + } else if (result !== InstallerResponse.Installed) { + throw new Error('Failed to install deepnote-toolkit. Check the Output panel for details.'); } } finally { + cancellationListener?.dispose(); cts.dispose(); } } @@ -282,7 +286,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension throw new DeepnoteServerStartupError( interpreter.uri.fsPath, - serverInfo?.jupyterPort ?? 0, + 0, 'unknown', capturedOutput?.stdout || '', capturedOutput?.stderr || '', diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index e7988635ee..cd2abd3119 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -203,14 +203,16 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } private onDidCloseNotebook(notebook: NotebookDocument) { - logger.info(`Notebook closed: ${getDisplayPath(notebook.uri)}, with type: ${notebook.notebookType}`); - - // Only handle deepnote notebooks if (notebook.notebookType !== DEEPNOTE_NOTEBOOK_TYPE) { return; } - logger.info(`Deepnote notebook closed: ${getDisplayPath(notebook.uri)}`); + const notebookKey = notebook.uri.toString(); + this.notebookConnectionMetadata.delete(notebookKey); + this.notebookInterpreterIds.delete(notebookKey); + this.notebookControllers.delete(notebookKey); + + logger.info(`Deepnote notebook closed, cleaned up: ${getDisplayPath(notebook.uri)}`); } public async onKernelStarted(kernel: IKernel) { @@ -663,22 +665,20 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, /** * Clear the controller selection for a notebook using a specific environment. * This is used when deleting an environment to unselect its controller from any open notebooks. + * + * Since the refactoring, server handles are keyed by interpreter.id (not environmentId). + * We match by checking if the currently selected controller is one of ours (a Deepnote kernel + * controller), rather than reconstructing a handle from the environmentId. */ - public clearControllerForEnvironment(notebook: NotebookDocument, environmentId: string): void { + public clearControllerForEnvironment(notebook: NotebookDocument, _environmentId: string): void { const selectedController = this.controllerRegistration.getSelected(notebook); if (!selectedController || selectedController.connection.kind !== 'startUsingDeepnoteKernel') { return; } - const expectedHandle = createDeepnoteServerConfigHandle(environmentId, notebook.uri); - - if (selectedController.connection.serverProviderHandle.handle === expectedHandle) { - // Unselect the controller by setting affinity to Default - selectedController.controller.updateNotebookAffinity(notebook, NotebookControllerAffinity.Default); - logger.info( - `Cleared controller for notebook ${getDisplayPath(notebook.uri)} (environment ${environmentId})` - ); - } + // The selected controller is a Deepnote kernel — unselect it + selectedController.controller.updateNotebookAffinity(notebook, NotebookControllerAffinity.Default); + logger.info(`Cleared Deepnote controller for notebook ${getDisplayPath(notebook.uri)}`); } /** diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts index cb0b67ee69..b2d60dd1fb 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts @@ -915,6 +915,58 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { }); }); }); + + suite('clearControllerForEnvironment', () => { + test('should unselect controller when a Deepnote kernel is selected', () => { + const mockSelectedController = mock(); + when(mockSelectedController.connection).thenReturn({ + kind: 'startUsingDeepnoteKernel', + serverProviderHandle: { handle: 'some-handle' } + } as any); + + const mockNativeController = { + updateNotebookAffinity: sandbox.stub() + } as unknown as NotebookController; + when(mockSelectedController.controller).thenReturn(mockNativeController); + + when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(instance(mockSelectedController)); + + selector.clearControllerForEnvironment(mockNotebook, 'any-environment-id'); + + assert.isTrue( + (mockNativeController.updateNotebookAffinity as sinon.SinonStub).calledOnce, + 'Should have called updateNotebookAffinity' + ); + }); + + test('should not unselect controller when no controller is selected', () => { + when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(undefined); + + // Should not throw + selector.clearControllerForEnvironment(mockNotebook, 'any-environment-id'); + }); + + test('should not unselect controller when selected controller is not a Deepnote kernel', () => { + const mockSelectedController = mock(); + when(mockSelectedController.connection).thenReturn({ + kind: 'startUsingLocalKernelSpec' + } as any); + + const mockNativeController = { + updateNotebookAffinity: sandbox.stub() + } as unknown as NotebookController; + when(mockSelectedController.controller).thenReturn(mockNativeController); + + when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(instance(mockSelectedController)); + + selector.clearControllerForEnvironment(mockNotebook, 'any-environment-id'); + + assert.isFalse( + (mockNativeController.updateNotebookAffinity as sinon.SinonStub).called, + 'Should NOT have called updateNotebookAffinity for non-Deepnote kernel' + ); + }); + }); }); /** diff --git a/src/platform/interpreter/installer/types.ts b/src/platform/interpreter/installer/types.ts index bdb5bfee39..550886232c 100644 --- a/src/platform/interpreter/installer/types.ts +++ b/src/platform/interpreter/installer/types.ts @@ -21,7 +21,8 @@ export enum Product { nbconvert = 22, pandas = 23, pip = 27, - ensurepip = 28 + ensurepip = 28, + deepnoteToolkit = 29 } export enum ProductInstallStatus { From 774093732d1e3bc612ff1bbbdf02224858ea837d Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 2 Apr 2026 09:42:52 +0000 Subject: [PATCH 03/12] fix(deepnote): Improve controller management and cleanup logic - Enhanced the logic for clearing notebook controllers to ensure only tracked controllers are unselected. - Updated the `clearControllerForEnvironment` method to clean up associated metadata correctly. - Added unit tests to verify the behavior of environment configuration and controller unselection for Deepnote kernels. - Ensured that the system correctly handles cases where the active interpreter differs from the cached interpreter. --- .../deepnoteKernelAutoSelector.node.ts | 40 +++- ...epnoteKernelAutoSelector.node.unit.test.ts | 178 +++++++++++++++--- .../installer/productInstaller.node.ts | 5 +- .../installer/productInstaller.unit.test.ts | 59 ++++++ 4 files changed, 249 insertions(+), 33 deletions(-) diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index cd2abd3119..e7196633ca 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -626,8 +626,9 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } const existingController = this.notebookControllers.get(notebookKey); + const existingInterpreterId = this.notebookInterpreterIds.get(notebookKey); - if (existingController) { + if (existingController && existingInterpreterId === interpreter.id) { logger.info(`Controller already configured for ${getDisplayPath(notebook.uri)}`); return true; } @@ -663,22 +664,41 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } /** - * Clear the controller selection for a notebook using a specific environment. - * This is used when deleting an environment to unselect its controller from any open notebooks. + * Clear the controller selection for a notebook if it was set up by this selector + * for the given environment. * - * Since the refactoring, server handles are keyed by interpreter.id (not environmentId). - * We match by checking if the currently selected controller is one of ours (a Deepnote kernel - * controller), rather than reconstructing a handle from the environmentId. + * The caller passes an `environmentId` (UUID), but the auto-selector now tracks + * notebooks by interpreter.id. We match by comparing the notebook's tracked + * controller instance against the currently selected controller, so we only + * clear controllers we own — never an unrelated Deepnote kernel. */ - public clearControllerForEnvironment(notebook: NotebookDocument, _environmentId: string): void { + public clearControllerForEnvironment(notebook: NotebookDocument, environmentId: string): void { + const notebookKey = notebook.uri.toString(); + const trackedController = this.notebookControllers.get(notebookKey); + + if (!trackedController) { + return; // We didn't set up a controller for this notebook + } + const selectedController = this.controllerRegistration.getSelected(notebook); - if (!selectedController || selectedController.connection.kind !== 'startUsingDeepnoteKernel') { + if (!selectedController || selectedController.id !== trackedController.id) { + return; // Selected controller isn't the one we own + } + + if (selectedController.connection.kind !== 'startUsingDeepnoteKernel') { return; } - // The selected controller is a Deepnote kernel — unselect it selectedController.controller.updateNotebookAffinity(notebook, NotebookControllerAffinity.Default); - logger.info(`Cleared Deepnote controller for notebook ${getDisplayPath(notebook.uri)}`); + + // Clean up our tracking state for this notebook + this.notebookControllers.delete(notebookKey); + this.notebookConnectionMetadata.delete(notebookKey); + this.notebookInterpreterIds.delete(notebookKey); + + logger.info( + `Cleared Deepnote controller for notebook ${getDisplayPath(notebook.uri)} (environment ${environmentId})` + ); } /** diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts index b2d60dd1fb..0f986b7aaa 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts @@ -372,6 +372,82 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { }); }); + suite('ensureEnvironmentConfiguredBeforeExecution', () => { + const nonCancelledToken: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => {} }) as any + }; + + test('should reconfigure when active interpreter differs from cached interpreter', async () => { + const notebookKey = mockNotebook.uri.toString(); + const interpreterA: PythonEnvironment = { + id: '/usr/bin/python3.10', + uri: Uri.parse('/usr/bin/python3.10') + }; + const interpreterB: PythonEnvironment = { + id: '/usr/bin/python3.12', + uri: Uri.parse('/usr/bin/python3.12') + }; + + // Prime the internal maps: controller exists for interpreter A + const selectorAny = selector as any; + selectorAny.notebookControllers.set(notebookKey, instance(mockController)); + selectorAny.notebookInterpreterIds.set(notebookKey, interpreterA.id); + + // Active interpreter is now B + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(interpreterB); + + // Stub ensureKernelSelectedWithInterpreter to track calls + const ensureStub = sandbox.stub(selector, 'ensureKernelSelectedWithInterpreter').resolves(); + + // withProgress must call through to the task callback + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( + (_opts: any, task: any) => { + return task({ report: sandbox.stub() }, nonCancelledToken); + } + ); + + // Put a controller in the map so the final check returns true + ensureStub.callsFake(async () => { + selectorAny.notebookControllers.set(notebookKey, instance(mockNewController)); + }); + + const result = await selector.ensureEnvironmentConfiguredBeforeExecution(mockNotebook, nonCancelledToken); + + assert.strictEqual(result, true, 'Should return true after reconfiguring'); + assert.strictEqual(ensureStub.calledOnce, true, 'Should call ensureKernelSelectedWithInterpreter'); + assert.deepStrictEqual( + ensureStub.firstCall.args[1], + interpreterB, + 'Should reconfigure with the new interpreter' + ); + }); + + test('should return true immediately when controller exists for the same interpreter', async () => { + const notebookKey = mockNotebook.uri.toString(); + const interpreterA: PythonEnvironment = { + id: '/usr/bin/python3.10', + uri: Uri.parse('/usr/bin/python3.10') + }; + + // Prime the internal maps: controller exists for interpreter A + const selectorAny = selector as any; + selectorAny.notebookControllers.set(notebookKey, instance(mockController)); + selectorAny.notebookInterpreterIds.set(notebookKey, interpreterA.id); + + // Active interpreter is still A + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(interpreterA); + + // Stub ensureKernelSelectedWithInterpreter — should NOT be called + const ensureStub = sandbox.stub(selector, 'ensureKernelSelectedWithInterpreter').resolves(); + + const result = await selector.ensureEnvironmentConfiguredBeforeExecution(mockNotebook, nonCancelledToken); + + assert.strictEqual(result, true, 'Should return true (fast path)'); + assert.strictEqual(ensureStub.called, false, 'Should NOT call ensureKernelSelectedWithInterpreter'); + }); + }); + // Priority 1 Tests - Critical for environment switching // UT-4: Configuration Refresh After startServer suite('Priority 1: Configuration Refresh (UT-4)', () => { @@ -917,53 +993,113 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { }); suite('clearControllerForEnvironment', () => { - test('should unselect controller when a Deepnote kernel is selected', () => { - const mockSelectedController = mock(); - when(mockSelectedController.connection).thenReturn({ - kind: 'startUsingDeepnoteKernel', - serverProviderHandle: { handle: 'some-handle' } - } as any); + test('should unselect and clean up when tracked controller matches selected controller', () => { + const notebookKey = mockNotebook.uri.toString(); + // Set up a tracked controller in the internal map + const trackedController = mock(); + when(trackedController.id).thenReturn('deepnote-notebook-123'); + when(trackedController.connection).thenReturn({ + kind: 'startUsingDeepnoteKernel' + } as any); const mockNativeController = { updateNotebookAffinity: sandbox.stub() } as unknown as NotebookController; - when(mockSelectedController.controller).thenReturn(mockNativeController); + when(trackedController.controller).thenReturn(mockNativeController); + + const selectorAny = selector as any; + selectorAny.notebookControllers.set(notebookKey, instance(trackedController)); + selectorAny.notebookInterpreterIds.set(notebookKey, '/usr/bin/python3'); + selectorAny.notebookConnectionMetadata.set(notebookKey, {} as any); - when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(instance(mockSelectedController)); + // Selected controller is the same one we tracked + when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(instance(trackedController)); - selector.clearControllerForEnvironment(mockNotebook, 'any-environment-id'); + selector.clearControllerForEnvironment(mockNotebook, 'env-uuid-123'); assert.isTrue( (mockNativeController.updateNotebookAffinity as sinon.SinonStub).calledOnce, 'Should have called updateNotebookAffinity' ); + // Verify tracking state is cleaned up + assert.isFalse(selectorAny.notebookControllers.has(notebookKey), 'Should remove from notebookControllers'); + assert.isFalse( + selectorAny.notebookInterpreterIds.has(notebookKey), + 'Should remove from notebookInterpreterIds' + ); + assert.isFalse( + selectorAny.notebookConnectionMetadata.has(notebookKey), + 'Should remove from notebookConnectionMetadata' + ); + }); + + test('should NOT unselect when notebook has no tracked controller', () => { + // notebookControllers map is empty — we didn't set up this notebook + const trackedController = mock(); + const mockNativeController = { + updateNotebookAffinity: sandbox.stub() + } as unknown as NotebookController; + when(trackedController.controller).thenReturn(mockNativeController); + when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(instance(trackedController)); + + selector.clearControllerForEnvironment(mockNotebook, 'env-uuid-123'); + + assert.isFalse( + (mockNativeController.updateNotebookAffinity as sinon.SinonStub).called, + 'Should NOT have called updateNotebookAffinity when we have no tracked controller' + ); }); - test('should not unselect controller when no controller is selected', () => { - when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(undefined); + test('should NOT unselect when selected controller differs from tracked controller', () => { + const notebookKey = mockNotebook.uri.toString(); + + // Track controller A + const controllerA = mock(); + when(controllerA.id).thenReturn('deepnote-notebook-A'); + const selectorAny = selector as any; + selectorAny.notebookControllers.set(notebookKey, instance(controllerA)); + + // But VS Code has controller B selected (different id) + const controllerB = mock(); + when(controllerB.id).thenReturn('deepnote-notebook-B'); + const mockNativeController = { + updateNotebookAffinity: sandbox.stub() + } as unknown as NotebookController; + when(controllerB.controller).thenReturn(mockNativeController); + when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(instance(controllerB)); + + selector.clearControllerForEnvironment(mockNotebook, 'env-uuid-123'); - // Should not throw - selector.clearControllerForEnvironment(mockNotebook, 'any-environment-id'); + assert.isFalse( + (mockNativeController.updateNotebookAffinity as sinon.SinonStub).called, + 'Should NOT unselect a controller we do not own' + ); }); - test('should not unselect controller when selected controller is not a Deepnote kernel', () => { - const mockSelectedController = mock(); - when(mockSelectedController.connection).thenReturn({ + test('should NOT unselect when selected controller is not a Deepnote kernel', () => { + const notebookKey = mockNotebook.uri.toString(); + + // Track a controller + const trackedController = mock(); + when(trackedController.id).thenReturn('deepnote-notebook-123'); + when(trackedController.connection).thenReturn({ kind: 'startUsingLocalKernelSpec' } as any); - const mockNativeController = { updateNotebookAffinity: sandbox.stub() } as unknown as NotebookController; - when(mockSelectedController.controller).thenReturn(mockNativeController); + when(trackedController.controller).thenReturn(mockNativeController); + + const selectorAny = selector as any; + selectorAny.notebookControllers.set(notebookKey, instance(trackedController)); - when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(instance(mockSelectedController)); + when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(instance(trackedController)); - selector.clearControllerForEnvironment(mockNotebook, 'any-environment-id'); + selector.clearControllerForEnvironment(mockNotebook, 'env-uuid-123'); assert.isFalse( (mockNativeController.updateNotebookAffinity as sinon.SinonStub).called, - 'Should NOT have called updateNotebookAffinity for non-Deepnote kernel' + 'Should NOT unselect a non-Deepnote kernel' ); }); }); diff --git a/src/platform/interpreter/installer/productInstaller.node.ts b/src/platform/interpreter/installer/productInstaller.node.ts index 9390224f68..e9e8dac97a 100644 --- a/src/platform/interpreter/installer/productInstaller.node.ts +++ b/src/platform/interpreter/installer/productInstaller.node.ts @@ -99,9 +99,10 @@ export class DataScienceInstaller { // deepnote-toolkit is PyPI-only with pip-specific [server] extras syntax, // so always use PipInstaller regardless of environment type. + // We bypass getInstallationChannels() because it filters by isSupported(), + // and PipInstaller.isSupported() rejects Conda/Pipenv/Poetry interpreters. if (product === Product.deepnoteToolkit) { - const channels = this.serviceContainer.get(IInstallationChannelManager); - const allInstallers = await channels.getInstallationChannels(interpreter); + const allInstallers = this.serviceContainer.getAll(IModuleInstaller); installer = allInstallers.find((i) => i.type === ModuleInstallerType.Pip); } else { const channels = this.serviceContainer.get(IInstallationChannelManager); diff --git a/src/platform/interpreter/installer/productInstaller.unit.test.ts b/src/platform/interpreter/installer/productInstaller.unit.test.ts index dbe9dba1db..27daf48151 100644 --- a/src/platform/interpreter/installer/productInstaller.unit.test.ts +++ b/src/platform/interpreter/installer/productInstaller.unit.test.ts @@ -206,4 +206,63 @@ suite('DataScienceInstaller install', async () => { const result = await dataScienceInstaller.install(Product.ipykernel, testEnvironment, tokenSource); expect(result).to.equal(InstallerResponse.Installed, 'Should be Installed'); }); + + test('Will use pip for deepnoteToolkit even on Conda interpreter (bypasses isSupported filter)', async () => { + const testEnvironment: PythonEnvironment = { + id: interpreterPath.fsPath, + uri: interpreterPath + }; + + // Create a pip installer mock + const pipInstaller = TypeMoq.Mock.ofType(); + pipInstaller.setup((c) => c.type).returns(() => ModuleInstallerType.Pip); + pipInstaller + .setup((c) => + c.installModule( + TypeMoq.It.isValue(Product.deepnoteToolkit), + TypeMoq.It.isValue(testEnvironment), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny() + ) + ) + .returns(() => Promise.resolve()); + pipInstaller.setup((p) => (p as any).then).returns(() => undefined); + + // Create a conda installer mock (would normally be selected for Conda envs) + const condaInstaller = TypeMoq.Mock.ofType(); + condaInstaller.setup((c) => c.type).returns(() => ModuleInstallerType.Conda); + + // serviceContainer.getAll returns both installers — the code must pick pip + serviceContainer + .setup((c) => c.getAll(TypeMoq.It.isValue(IModuleInstaller))) + .returns(() => [condaInstaller.object, pipInstaller.object]); + + const result = await dataScienceInstaller.install(Product.deepnoteToolkit, testEnvironment, tokenSource); + expect(result).to.equal(InstallerResponse.Installed, 'Should be Installed via pip'); + + // Verify pip was called, not conda + pipInstaller.verify( + (c) => + c.installModule( + TypeMoq.It.isValue(Product.deepnoteToolkit), + TypeMoq.It.isValue(testEnvironment), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny() + ), + TypeMoq.Times.once() + ); + condaInstaller.verify( + (c) => + c.installModule( + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + TypeMoq.It.isAny() + ), + TypeMoq.Times.never() + ); + }); }); From 3dd23fe12f98bb8c27186f637da8a803c071d739 Mon Sep 17 00:00:00 2001 From: tomas Date: Mon, 24 Aug 2026 19:49:56 +0000 Subject: [PATCH 04/12] fix(python-api): don't hang forever waiting for the Jupyter API handshake Opening any .deepnote notebook hung at "Starting Deepnote server..." and the kernel never started. DeepnoteServerStarter now checks the toolkit through IInstaller, which reaches IEnvironmentActivationService -> IPythonApiProvider.getApi(). That promise is resolved by `registerPythonApi`, which the Python extension only calls on the extension it knows as `ms-toolsai.jupyter`; this fork ships as `Deepnote.vscode-deepnote`, so the callback never arrives. getApi() is awaited with no token and no timeout, so isInstalled() never returned. The environment based flow never touched this path, which is why it only surfaces now. Fail the promise once the handshake has clearly not landed. Callers already handle it: getActivatedEnvironmentVariablesImpl catches and returns undefined, and createActivatedEnvironment then falls back to unactivated execution. Add an E2E test for the environment-free flow: a workspace whose active interpreter is a bare venv, so opening the notebook installs deepnote-toolkit into that interpreter and runs the cell. The cell prints sys.prefix, so the output proves the kernel ran in that venv rather than a Deepnote-managed environment. Asserts on the venv contents rather than the transient install toast, which is missed on a retry. Verified: E2E test fails (install never runs) without the fix and passes with it; typecheck 0, 2761 unit tests passing, 0 failing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019cqzx43mLQRvVfWbfEUUSx --- src/platform/api/pythonApi.ts | 24 +++ test/e2e/fixtures/interpreter-kernel.deepnote | 23 +++ test/e2e/helpers/notifications.ts | 27 +++ test/e2e/suite/interpreterKernel.e2e.test.ts | 163 ++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 test/e2e/fixtures/interpreter-kernel.deepnote create mode 100644 test/e2e/suite/interpreterKernel.e2e.test.ts diff --git a/src/platform/api/pythonApi.ts b/src/platform/api/pythonApi.ts index 3e68cc60f6..18e620063f 100644 --- a/src/platform/api/pythonApi.ts +++ b/src/platform/api/pythonApi.ts @@ -49,6 +49,10 @@ import { trackInterpreterDiscovery, trackPythonExtensionActivation } from '../.. import { findPythonEnvBelongingToFolder } from '../../notebooks/controllers/preferredKernelConnectionService.node'; import { DisposableMap } from '../common/utils/lifecycle'; +// The Python extension completes its Jupyter handshake asynchronously; if the callback has not +// landed by then it never will (see failApiIfHandshakeNeverLands). +const PYTHON_API_HANDSHAKE_TIMEOUT = 5_000; + export function deserializePythonEnvironment( pythonVersion: Partial | undefined, pythonEnvId: string @@ -161,6 +165,25 @@ export class OldPythonApiProvider implements IPythonApiProvider { return extension?.exports; } + /** + * The Python extension ends the handshake by calling `registerPythonApi` on the extension it + * knows as `ms-toolsai.jupyter`. This fork ships under a different id, so that callback never + * arrives and every `getApi()` awaiter would hang forever. Fail the promise instead — callers + * already degrade to unactivated execution when activation variables are unavailable. + */ + private failApiIfHandshakeNeverLands() { + const timer = setTimeout(() => { + if (!this.api.resolved && !this.api.rejected) { + logger.warn('Python extension did not complete the Jupyter API handshake; continuing without it'); + this.api.reject(new PythonExtensionApiNotExportedError()); + } + }, PYTHON_API_HANDSHAKE_TIMEOUT); + + // Nothing may be awaiting the promise at rejection time. + this.api.promise.catch(noop); + this.disposables.push({ dispose: () => clearTimeout(timer) }); + } + public setApi(api: PythonApi): void { // Never allow accessing python API (we don't want to ever use the API and run code in untrusted API). // Don't assume Python API will always be disabled in untrusted workspaces. @@ -219,6 +242,7 @@ export class OldPythonApiProvider implements IPythonApiProvider { this.api.reject(new PythonExtensionApiNotExportedError()); } else { pythonExtension.exports.jupyter.registerHooks(); + this.failApiIfHandshakeNeverLands(); } this._pythonExtensionHooked.resolve(); } diff --git a/test/e2e/fixtures/interpreter-kernel.deepnote b/test/e2e/fixtures/interpreter-kernel.deepnote new file mode 100644 index 0000000000..ecb1a47e88 --- /dev/null +++ b/test/e2e/fixtures/interpreter-kernel.deepnote @@ -0,0 +1,23 @@ +version: '1.0.0' +metadata: + createdAt: '2025-01-01T00:00:00.000Z' + modifiedAt: '2025-01-01T00:00:00.000Z' +project: + id: e2e-interpreter-kernel-project + name: E2E Interpreter Kernel + notebooks: + - id: e2e-interpreter-kernel-notebook + name: Interpreter Kernel + blocks: + - id: e2e-interpreter-kernel-block + blockGroup: e2e-interpreter-kernel-group + type: code + content: |- + import sys + print("interpreter-kernel-ok") + print(sys.prefix) + sortingKey: a0 + metadata: {} + executionMode: block + isModule: false + settings: {} diff --git a/test/e2e/helpers/notifications.ts b/test/e2e/helpers/notifications.ts index 7f5ccadded..b5d3ca23c4 100644 --- a/test/e2e/helpers/notifications.ts +++ b/test/e2e/helpers/notifications.ts @@ -60,3 +60,30 @@ export async function waitForNotification( return undefined; } } + +/** + * Waits until no visible notification matches `pattern` any more. A progress notification is + * removed when its operation settles, so this gates on "that work finished" rather than on a + * fixed sleep. + */ +export async function waitForNotificationToClear(pattern: RegExp, timeout: number): Promise { + await VSBrowser.instance.driver.wait( + async () => { + const notifications = await new Workbench().getNotifications().catch((error) => { + console.warn('[deepnote-e2e] get notifications:', error); + + return [] as Notification[]; + }); + for (const notification of notifications) { + const message = await notification.getMessage().catch(() => ''); + if (pattern.test(message)) { + return false; + } + } + + return true; + }, + timeout, + `timed out waiting for notifications matching ${pattern} to clear` + ); +} diff --git a/test/e2e/suite/interpreterKernel.e2e.test.ts b/test/e2e/suite/interpreterKernel.e2e.test.ts new file mode 100644 index 0000000000..156adb317b --- /dev/null +++ b/test/e2e/suite/interpreterKernel.e2e.test.ts @@ -0,0 +1,163 @@ +/** + * End-to-end UI test for kernel setup WITHOUT Deepnote environments. + * + * This is the flow the extension uses now that environments are gone: the kernel is built from the + * workspace's *active Python interpreter*, and deepnote-toolkit is installed into that interpreter + * through the Python extension's installer infrastructure (`IInstaller`) — the same mechanism the + * Jupyter extension uses for its own missing dependencies: + * 1. open a one-notebook `.deepnote` file against a workspace whose active interpreter is a bare venv + * 2. the auto-selector picks that interpreter — no environment is created and no picker appears + * 3. deepnote-toolkit is missing, so an "Installing deepnote-toolkit" progress notification shows + * 4. once it installs, the server starts and the kernel controller is bound + * 5. run the cell and assert the rendered stdout + * + * The cell prints `sys.prefix`, so the output proves the kernel really ran inside the venv this test + * created rather than in a Deepnote-managed environment. The suite also asserts the toolkit landed in + * that same venv, which is the load-bearing difference from the environment-based flow. + * + * Prerequisites: + * - The Python extension (`ms-python.python`) must be installed in the test instance + * (`npm run setup:e2e:deps`). + * - `python3` must be on PATH and able to create a venv (CI installs `python3.12-venv`). + * - Network access: the toolkit is installed from PyPI on first kernel start, which is slow. + */ + +import { expect } from 'chai'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { EditorView, VSBrowser, WebView } from 'vscode-extension-tester'; + +import { + FIRST_RUN_OUTPUT_TIMEOUT, + KERNEL_CONNECT_TIMEOUT, + SUITE_TIMEOUT, + WORKBENCH_TIMEOUT, + copyFixtureToTempDir, + openFolderViaDialog, + openWorkspaceFile, + runOnceAndAwaitOutput, + waitForNotification, + waitForNotificationToClear +} from '../helpers'; + +const NOTEBOOK_FILE_NAME = 'interpreter-kernel.deepnote'; +const EXPECTED_OUTPUT = 'interpreter-kernel-ok'; + +// The install toast is observed opportunistically; the venv check below is the real gate, so a +// missed toast must not cost the suite a full kernel-connect timeout. +const TOOLKIT_NOTIFICATION_TIMEOUT = 90_000; + +/** Path to the interpreter inside a venv, for the platform the test is running on. */ +function venvPython(venvDir: string): string { + return process.platform === 'win32' + ? path.join(venvDir, 'Scripts', 'python.exe') + : path.join(venvDir, 'bin', 'python'); +} + +/** True when `deepnote_toolkit` imports in the given interpreter. */ +function isToolkitInstalled(python: string): boolean { + try { + execFileSync(python, ['-c', 'import deepnote_toolkit'], { stdio: 'ignore' }); + + return true; + } catch { + return false; + } +} + +describe('Deepnote E2E — run on the active interpreter (no Deepnote environment)', function () { + this.timeout(SUITE_TIMEOUT); + + let cleanupTempDir: (() => void) | undefined; + let venvDir: string; + let interpreter: string; + + before(async function () { + const { cleanup, tempDir } = copyFixtureToTempDir(NOTEBOOK_FILE_NAME); + cleanupTempDir = cleanup; + + // A throwaway venv is what makes this test deterministic: it is guaranteed not to have + // deepnote-toolkit, so the install path runs on every execution rather than only on a + // machine that happens to be missing the package. + venvDir = path.join(tempDir, '.venv'); + execFileSync('python3', ['-m', 'venv', venvDir], { stdio: 'inherit' }); + interpreter = venvPython(venvDir); + + expect(isToolkitInstalled(interpreter)).to.equal( + false, + 'precondition: the fresh venv must not already provide deepnote-toolkit' + ); + + // Pin the workspace's interpreter so the auto-selector resolves this venv and not whatever + // the Python extension would otherwise discover on the machine. + const vscodeDir = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDir, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDir, 'settings.json'), + JSON.stringify({ 'python.defaultInterpreterPath': interpreter }, undefined, 4) + ); + + await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); + + // Opening the folder reloads the window, so the notebook is opened in the test body — that + // keeps the install notification, which fires during the open, inside the assertions. + await openFolderViaDialog(tempDir); + await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); + }); + + after(async function () { + await new WebView().switchBack().catch((error) => { + console.warn('[deepnote-e2e] switch back from webview during cleanup:', error); + }); + await new EditorView().closeAllEditors().catch((error) => { + console.warn('[deepnote-e2e] close all editors during cleanup:', error); + }); + + try { + cleanupTempDir?.(); + } catch (error) { + console.warn('[deepnote-e2e] remove temp workspace dir during cleanup:', error); + } + }); + + it('installs deepnote-toolkit into the active interpreter, then runs the cell', async function () { + await openWorkspaceFile(NOTEBOOK_FILE_NAME); + + await VSBrowser.instance.driver.wait( + async () => (await new EditorView().getOpenEditorTitles()).some((t) => t.includes(NOTEBOOK_FILE_NAME)), + WORKBENCH_TIMEOUT, + 'Deepnote notebook editor did not open' + ); + + // Opening the notebook auto-selects the kernel, which finds the toolkit missing and installs + // it — no environment is created and the user is never asked to pick one. The progress toast + // is the visible half of that, but it is transient (and absent on a retry, where the toolkit + // is already installed), so it is observed best-effort; the durable assertions are below. + await waitForNotification(/Installing deepnote-toolkit/i, TOOLKIT_NOTIFICATION_TIMEOUT, false); + + // The load-bearing gate: the install landed in the active interpreter. Polling the venv is + // race-free, unlike matching a toast that may already have gone. + await VSBrowser.instance.driver.wait( + () => isToolkitInstalled(interpreter), + KERNEL_CONNECT_TIMEOUT, + 'deepnote-toolkit was never installed into the active interpreter' + ); + + // Waiting for the auto-select toast to be *gone* gates "Run All" on a bound kernel without + // depending on catching it while it is shown. + await waitForNotificationToClear(/Auto-selecting Deepnote kernel/i, KERNEL_CONNECT_TIMEOUT); + + const renderedOutput = await runOnceAndAwaitOutput( + NOTEBOOK_FILE_NAME, + EXPECTED_OUTPUT, + FIRST_RUN_OUTPUT_TIMEOUT + ); + + expect(renderedOutput).to.contain(EXPECTED_OUTPUT); + + // The cell printed sys.prefix: the kernel must be the venv this test created, which is what + // separates "active interpreter" from the old Deepnote-managed environment. + expect(renderedOutput).to.contain(venvDir); + }); +}); From 92e055f7724a53d7cb7f3cb13875154b6d154cc7 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 25 Aug 2026 17:40:15 +0000 Subject: [PATCH 05/12] feat(deepnote): ask before installing the toolkit, and check at kernel start Brings the toolkit install in line with how the Jupyter extension handles a missing Python dependency. Consent. The install no longer runs unattended. DeepnoteToolkitDependencyService mirrors KernelDependencyService: the same modal message ("Running cells with '{env}' requires the {pkg} package."), Install as the default, and a "Select a different Interpreter" escape hatch for users who do not want the package in the interpreter that happens to be active. It cannot reuse that service directly because installMissingDependencies is keyed on a KernelConnectionMetadata, and a Deepnote connection cannot exist until the toolkit server is running -- which is what the check gates. Kernel start, not notebook open. Opening a .deepnote file now only offers a placeholder controller; nothing is installed and no server starts. Running a cell performs the check, the prompt, the install and the server start, then asks the user to re-run -- the same shape main used before environments were removed. Cancellation. Declining or cancelling aborts the kernel start and execution does not proceed, with no error dialog: a user-initiated stop is not a failure. An install that runs and does not take is still reported as a failure. The server starter no longer installs anything; it starts servers. Tests: unit coverage for each consent outcome (verified to fail when the prompt is bypassed). The E2E test now asserts that opening the notebook starts no install, drives the modal, and captures screenshots of the prompt, the kernel-ready state and the cell output. Verified: typecheck 0, 2767 unit tests passing, E2E green with the flow confirmed visually. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019cqzx43mLQRvVfWbfEUUSx --- .../deepnote/deepnoteServerStarter.node.ts | 32 +-- .../deepnoteServerStarter.unit.test.ts | 10 +- .../deepnoteToolkitDependencyService.node.ts | 103 +++++++++ ...pnoteToolkitDependencyService.unit.test.ts | 116 ++++++++++ src/kernels/deepnote/types.ts | 26 +++ .../deepnoteKernelAutoSelector.node.ts | 199 ++++++++++++++---- ...epnoteKernelAutoSelector.node.unit.test.ts | 9 +- src/notebooks/serviceRegistry.node.ts | 6 + src/platform/common/utils/localize.ts | 1 + test/e2e/helpers/modals.ts | 8 +- test/e2e/suite/interpreterKernel.e2e.test.ts | 105 +++++---- 11 files changed, 490 insertions(+), 125 deletions(-) create mode 100644 src/kernels/deepnote/deepnoteToolkitDependencyService.node.ts create mode 100644 src/kernels/deepnote/deepnoteToolkitDependencyService.unit.test.ts diff --git a/src/kernels/deepnote/deepnoteServerStarter.node.ts b/src/kernels/deepnote/deepnoteServerStarter.node.ts index 08fd6ebb67..1509ffae88 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.node.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.node.ts @@ -8,7 +8,7 @@ import * as fs from 'fs-extra'; import { inject, injectable, named } from 'inversify'; import * as os from 'os'; -import { CancellationToken, CancellationTokenSource, l10n, Uri } from 'vscode'; +import { CancellationToken, l10n, Uri } from 'vscode'; import { startServer, stopServer } from '@deepnote/runtime-core'; @@ -21,7 +21,6 @@ import { sleep } from '../../platform/common/utils/async'; import { generateUuid } from '../../platform/common/uuid'; import { DeepnoteServerStartupError } from '../../platform/errors/deepnoteKernelErrors'; import { getCachedEnvironment } from '../../platform/interpreter/helpers'; -import { IInstaller, InstallerResponse, Product } from '../../platform/interpreter/installer/types'; import { logger } from '../../platform/logging'; import { IUserpodApiEndpoints } from '../../platform/notebooks/deepnote/types'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; @@ -74,7 +73,6 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension constructor( @inject(IProcessServiceFactory) private readonly processServiceFactory: IProcessServiceFactory, - @inject(IInstaller) private readonly installer: IInstaller, @inject(DeepnoteAgentSkillsManager) private readonly agentSkillsManager: DeepnoteAgentSkillsManager, @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry, @@ -214,7 +212,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension * Core server start using @deepnote/runtime-core's `startServer`. * * Extension-specific layers: - * - Toolkit check/install via IInstaller (before start) + * - The caller guarantees deepnote-toolkit is installed (IDeepnoteToolkitDependencyService) * - Integration endpoint env var injection (via ServerOptions.env) — these point the toolkit at the * extension's loopback `userpod-api` endpoint, which is how it fetches SQL credentials at kernel init * - Lock file creation (after start, using returned PID) @@ -232,32 +230,6 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension Cancellation.throwIfCanceled(token); // Check if deepnote-toolkit is installed, and install if needed - logger.info(`Checking deepnote-toolkit installation for interpreter ${interpreterId}...`); - const isInstalled = await this.installer.isInstalled(Product.deepnoteToolkit, interpreter); - - if (!isInstalled) { - logger.info(`deepnote-toolkit not installed, installing via IInstaller...`); - const cts = new CancellationTokenSource(); - let cancellationListener: IDisposable | undefined; - - try { - if (token) { - cancellationListener = token.onCancellationRequested(() => cts.cancel()); - } - - const result = await this.installer.install(Product.deepnoteToolkit, interpreter, cts); - - if (result === InstallerResponse.Cancelled) { - throw new Error('deepnote-toolkit installation was cancelled by the user'); - } else if (result !== InstallerResponse.Installed) { - throw new Error('Failed to install deepnote-toolkit. Check the Output panel for details.'); - } - } finally { - cancellationListener?.dispose(); - cts.dispose(); - } - } - this.agentSkillsManager.ensureSkillsUpdated(interpreterId, interpreter); Cancellation.throwIfCanceled(token); diff --git a/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts b/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts index 4775e16153..7703150474 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.unit.test.ts @@ -2,14 +2,13 @@ import { assert } from 'chai'; import * as fakeTimers from '@sinonjs/fake-timers'; import * as sinon from 'sinon'; import { anything, instance, mock, when } from 'ts-mockito'; -import { EventEmitter, Uri } from 'vscode'; +import { Uri } from 'vscode'; import { serializeProjectFile } from '../../notebooks/deepnote/deepnoteTestHelpers'; import { IProcessServiceFactory } from '../../platform/common/process/types.node'; import { IAsyncDisposableRegistry, IOutputChannel } from '../../platform/common/types'; import { PythonExtension } from '@vscode/python-extension'; import { setPythonApi } from '../../platform/interpreter/helpers'; -import { IInstaller, InstallerResponse } from '../../platform/interpreter/installer/types'; import { IUserpodApiEndpoints } from '../../platform/notebooks/deepnote/types'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; import { @@ -42,7 +41,6 @@ suite('DeepnoteServerStarter', () => { let serverStarter: DeepnoteServerStarter; let mockProcessServiceFactory: IProcessServiceFactory; - let mockInstaller: IInstaller; let mockAgentSkillsManager: DeepnoteAgentSkillsManager; let mockOutputChannel: IOutputChannel; let mockAsyncRegistry: IAsyncDisposableRegistry; @@ -53,7 +51,6 @@ suite('DeepnoteServerStarter', () => { resetVSCodeMocks(); mockProcessServiceFactory = mock(); - mockInstaller = mock(); mockAgentSkillsManager = mock(); mockOutputChannel = mock(); mockAsyncRegistry = mock(); @@ -61,10 +58,6 @@ suite('DeepnoteServerStarter', () => { when(mockAsyncRegistry.push(anything())).thenReturn(); when(mockOutputChannel.appendLine(anything())).thenReturn(); - when(mockInstaller.isInstalled(anything(), anything())).thenResolve(true); - when(mockInstaller.install(anything(), anything(), anything())).thenResolve(InstallerResponse.Installed); - when(mockInstaller.onInstalled).thenReturn(new EventEmitter().event); - when(mockUserpodApiEndpoints.ready).thenReturn(Promise.resolve()); when(mockUserpodApiEndpoints.baseUrl).thenReturn(undefined); @@ -80,7 +73,6 @@ suite('DeepnoteServerStarter', () => { serverStarter = new DeepnoteServerStarter( instance(mockProcessServiceFactory), - instance(mockInstaller), instance(mockAgentSkillsManager), instance(mockOutputChannel), instance(mockAsyncRegistry), diff --git a/src/kernels/deepnote/deepnoteToolkitDependencyService.node.ts b/src/kernels/deepnote/deepnoteToolkitDependencyService.node.ts new file mode 100644 index 0000000000..07cce6c341 --- /dev/null +++ b/src/kernels/deepnote/deepnoteToolkitDependencyService.node.ts @@ -0,0 +1,103 @@ +import { inject, injectable } from 'inversify'; +import { CancellationToken, CancellationTokenSource, commands, window } from 'vscode'; + +import { getDisplayPath } from '../../platform/common/platform/fs-paths.node'; +import { IDisposable, Resource } from '../../platform/common/types'; +import { Common, DataScience } from '../../platform/common/utils/localize'; +import { getPythonEnvDisplayName } from '../../platform/interpreter/helpers'; +import { ProductNames } from '../../platform/interpreter/installer/productNames'; +import { IInstaller, InstallerResponse, Product } from '../../platform/interpreter/installer/types'; +import { logger } from '../../platform/logging'; +import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; +import { DeepnoteToolkitDependencyResponse, IDeepnoteToolkitDependencyService } from './types'; + +const SELECT_INTERPRETER_COMMAND = 'python.setInterpreter'; + +/** + * Asks for consent before installing deepnote-toolkit into the user's interpreter, mirroring + * `KernelDependencyService` — same prompt shape, same "cancel is not a failure" semantics. + * + * It cannot reuse that service directly: `installMissingDependencies` is keyed on a + * `KernelConnectionMetadata`, and a Deepnote connection cannot exist until the toolkit server is + * running and has reported its kernelspecs — which is precisely what this check gates. + */ +@injectable() +export class DeepnoteToolkitDependencyService implements IDeepnoteToolkitDependencyService { + constructor(@inject(IInstaller) private readonly installer: IInstaller) {} + + public async ensureToolkitInstalled( + interpreter: PythonEnvironment, + resource: Resource, + token: CancellationToken + ): Promise { + if (await this.installer.isInstalled(Product.deepnoteToolkit, interpreter)) { + return DeepnoteToolkitDependencyResponse.ok; + } + + if (token.isCancellationRequested) { + return DeepnoteToolkitDependencyResponse.cancel; + } + + const moduleName = ProductNames.get(Product.deepnoteToolkit)!; + const message = DataScience.libraryRequiredToLaunchJupyterKernelNotInstalledInterpreter( + getPythonEnvDisplayName(interpreter) || getDisplayPath(interpreter.uri), + moduleName + ); + const selectInterpreter = DataScience.selectDifferentPythonInterpreter; + + logger.info(`${moduleName} missing for ${getDisplayPath(resource)}, prompting to install`); + + const selection = await window.showInformationMessage( + message, + { modal: true }, + Common.install, + selectInterpreter + ); + + if (selection === selectInterpreter) { + await commands.executeCommand(SELECT_INTERPRETER_COMMAND); + + return DeepnoteToolkitDependencyResponse.selectDifferentInterpreter; + } + + if (selection !== Common.install) { + logger.info(`User declined to install ${moduleName}`); + + return DeepnoteToolkitDependencyResponse.cancel; + } + + return this.install(interpreter, moduleName, token); + } + + private async install( + interpreter: PythonEnvironment, + moduleName: string, + token: CancellationToken + ): Promise { + const cts = new CancellationTokenSource(); + let cancellationListener: IDisposable | undefined; + + try { + cancellationListener = token.onCancellationRequested(() => cts.cancel()); + + const result = await this.installer.install(Product.deepnoteToolkit, interpreter, cts); + + if (result === InstallerResponse.Installed) { + return DeepnoteToolkitDependencyResponse.ok; + } + + if (result === InstallerResponse.Cancelled || token.isCancellationRequested) { + logger.info(`${moduleName} installation cancelled`); + + return DeepnoteToolkitDependencyResponse.cancel; + } + + logger.error(`${moduleName} installation did not complete: ${InstallerResponse[result]}`); + + return DeepnoteToolkitDependencyResponse.failed; + } finally { + cancellationListener?.dispose(); + cts.dispose(); + } + } +} diff --git a/src/kernels/deepnote/deepnoteToolkitDependencyService.unit.test.ts b/src/kernels/deepnote/deepnoteToolkitDependencyService.unit.test.ts new file mode 100644 index 0000000000..8fa7668999 --- /dev/null +++ b/src/kernels/deepnote/deepnoteToolkitDependencyService.unit.test.ts @@ -0,0 +1,116 @@ +import { assert } from 'chai'; +import { PythonExtension } from '@vscode/python-extension'; +import * as sinon from 'sinon'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { Uri } from 'vscode'; + +import { setPythonApi } from '../../platform/interpreter/helpers'; +import { resolvableInstance } from '../../test/datascience/helpers'; + +import { IInstaller, InstallerResponse, Product } from '../../platform/interpreter/installer/types'; +import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; +import { DeepnoteToolkitDependencyService } from './deepnoteToolkitDependencyService.node'; +import { DeepnoteToolkitDependencyResponse } from './types'; + +suite('DeepnoteToolkitDependencyService', () => { + const interpreter: PythonEnvironment = { + id: '/usr/bin/python3', + uri: Uri.file('/usr/bin/python3') + }; + const resource = Uri.file('/workspace/project/notebook.deepnote'); + const notCancelled = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose: () => {} }) }; + + let installer: IInstaller; + let service: DeepnoteToolkitDependencyService; + + /** Makes the consent prompt resolve to `choice` (undefined = the user dismissed it). */ + function answerPrompt(choice: string | undefined) { + when( + mockedVSCodeNamespaces.window.showInformationMessage(anything(), anything(), anything(), anything()) + ).thenResolve(choice as never); + } + + setup(() => { + resetVSCodeMocks(); + installer = mock(); + service = new DeepnoteToolkitDependencyService(instance(installer)); + + // The prompt names the environment via getPythonEnvDisplayName, which reads the Python API. + const mockedApi = mock(); + sinon.stub(PythonExtension, 'api').resolves(resolvableInstance(mockedApi)); + const environments = mock(); + when(mockedApi.environments).thenReturn(instance(environments)); + when(environments.known).thenReturn([]); + setPythonApi(instance(mockedApi)); + }); + + teardown(() => { + setPythonApi(undefined as never); + sinon.restore(); + }); + + test('does not prompt when the toolkit is already installed', async () => { + when(installer.isInstalled(Product.deepnoteToolkit, anything())).thenResolve(true); + + const result = await service.ensureToolkitInstalled(interpreter, resource, notCancelled as never); + + assert.strictEqual(result, DeepnoteToolkitDependencyResponse.ok); + verify( + mockedVSCodeNamespaces.window.showInformationMessage(anything(), anything(), anything(), anything()) + ).never(); + verify(installer.install(anything(), anything(), anything())).never(); + }); + + test('installs only after the user consents', async () => { + when(installer.isInstalled(Product.deepnoteToolkit, anything())).thenResolve(false); + when(installer.install(anything(), anything(), anything())).thenResolve(InstallerResponse.Installed); + answerPrompt('Install'); + + const result = await service.ensureToolkitInstalled(interpreter, resource, notCancelled as never); + + assert.strictEqual(result, DeepnoteToolkitDependencyResponse.ok); + verify(installer.install(Product.deepnoteToolkit, anything(), anything())).once(); + }); + + test('does NOT install when the user dismisses the prompt', async () => { + when(installer.isInstalled(Product.deepnoteToolkit, anything())).thenResolve(false); + answerPrompt(undefined); + + const result = await service.ensureToolkitInstalled(interpreter, resource, notCancelled as never); + + assert.strictEqual(result, DeepnoteToolkitDependencyResponse.cancel); + verify(installer.install(anything(), anything(), anything())).never(); + }); + + test('does NOT install when the user opts to change interpreter, and opens the picker', async () => { + when(installer.isInstalled(Product.deepnoteToolkit, anything())).thenResolve(false); + answerPrompt('Select a different Interpreter'); + + const result = await service.ensureToolkitInstalled(interpreter, resource, notCancelled as never); + + assert.strictEqual(result, DeepnoteToolkitDependencyResponse.selectDifferentInterpreter); + verify(installer.install(anything(), anything(), anything())).never(); + verify(mockedVSCodeNamespaces.commands.executeCommand('python.setInterpreter')).once(); + }); + + test('reports a cancelled install as cancel, not failure', async () => { + when(installer.isInstalled(Product.deepnoteToolkit, anything())).thenResolve(false); + when(installer.install(anything(), anything(), anything())).thenResolve(InstallerResponse.Cancelled); + answerPrompt('Install'); + + const result = await service.ensureToolkitInstalled(interpreter, resource, notCancelled as never); + + assert.strictEqual(result, DeepnoteToolkitDependencyResponse.cancel); + }); + + test('reports an install that did not take as failed', async () => { + when(installer.isInstalled(Product.deepnoteToolkit, anything())).thenResolve(false); + when(installer.install(anything(), anything(), anything())).thenResolve(InstallerResponse.Ignore); + answerPrompt('Install'); + + const result = await service.ensureToolkitInstalled(interpreter, resource, notCancelled as never); + + assert.strictEqual(result, DeepnoteToolkitDependencyResponse.failed); + }); +}); diff --git a/src/kernels/deepnote/types.ts b/src/kernels/deepnote/types.ts index 57b13f2e92..a5c9bc551e 100644 --- a/src/kernels/deepnote/types.ts +++ b/src/kernels/deepnote/types.ts @@ -148,6 +148,32 @@ export interface IDeepnoteToolkitInstaller { getVenvHash(deepnoteFileUri: vscode.Uri): string; } +export enum DeepnoteToolkitDependencyResponse { + /** The toolkit is present, or the user approved the install and it succeeded. */ + ok, + /** The user declined or cancelled. Not a failure — nothing should be reported as an error. */ + cancel, + /** The user chose to point the workspace at a different interpreter instead. */ + selectDifferentInterpreter, + /** The install ran and did not succeed. */ + failed +} + +export const IDeepnoteToolkitDependencyService = Symbol('IDeepnoteToolkitDependencyService'); +export interface IDeepnoteToolkitDependencyService { + /** + * Ensures deepnote-toolkit is available in the interpreter, prompting for consent first. + * @param interpreter The interpreter the kernel will run in + * @param resource The notebook the check is running for, used for logging + * @param token Cancellation token to cancel the check or the install + */ + ensureToolkitInstalled( + interpreter: PythonEnvironment, + resource: vscode.Uri | undefined, + token: vscode.CancellationToken + ): Promise; +} + export const IDeepnoteServerStarter = Symbol('IDeepnoteServerStarter'); export interface IDeepnoteServerStarter { /** diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index b4ce53ad36..ed62c2cb0d 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -6,6 +6,8 @@ import * as fs from 'fs'; import { inject, injectable, named, optional } from 'inversify'; import { CancellationToken, + CancellationTokenSource, + NotebookController, NotebookControllerAffinity, NotebookDocument, NotebookEditor, @@ -14,16 +16,19 @@ import { commands, env, l10n, + notebooks, window, workspace } from 'vscode'; import { DEEPNOTE_NOTEBOOK_TYPE, DeepnoteKernelConnectionMetadata, + DeepnoteToolkitDependencyResponse, IDeepnoteKernelAutoSelector, IDeepnoteLspClientManager, IDeepnoteServerProvider, IDeepnoteServerStarter, + IDeepnoteToolkitDependencyService, IServerHandleRegistry } from '../../kernels/deepnote/types'; import { createJupyterConnectionInfo } from '../../kernels/jupyter/jupyterUtils'; @@ -66,6 +71,8 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, private readonly notebookControllers = new Map(); // Track interpreter ID for each notebook private readonly notebookInterpreterIds = new Map(); + // Offered at open so the notebook has something selectable before any server exists + private readonly placeholderControllers = new Map(); constructor( @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, @@ -84,7 +91,9 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, @inject(IDeepnoteServerStarter) private readonly serverStarter: IDeepnoteServerStarter, @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, @inject(IInterpreterService) private readonly interpreterService: IInterpreterService, - @inject(IServerHandleRegistry) private readonly serverHandleRegistry: IServerHandleRegistry + @inject(IServerHandleRegistry) private readonly serverHandleRegistry: IServerHandleRegistry, + @inject(IDeepnoteToolkitDependencyService) + private readonly toolkitDependencyService: IDeepnoteToolkitDependencyService ) {} public activate() { @@ -118,45 +127,13 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, logger.info(`Deepnote notebook opened: ${getDisplayPath(notebook.uri)}`); - // Always try to ensure kernel is selected (this will reuse existing controllers) - // Don't await - let it happen in background so notebook opens quickly - window - .withProgress( - { - location: ProgressLocation.Notification, - title: l10n.t('Auto-selecting Deepnote kernel... {0}', getDisplayPath(notebook.uri)), - cancellable: true - }, - async (progress, token) => { - try { - const result = await this.ensureKernelSelected(notebook, progress, token); - return result; - } catch (error) { - logger.error( - `Failed to auto-select Deepnote kernel for ${getDisplayPath(notebook.uri)}`, - error - ); - void this.handleKernelSelectionError(error, notebook); - return true; - } - } - ) - .then( - (result) => { - logger.info(`Auto-selecting Deepnote kernel for ${getDisplayPath(notebook.uri)} result: ${result}`); - if (!result) { - logger.warn( - `No active Python interpreter found for ${getDisplayPath( - notebook.uri - )}, kernel not selected` - ); - } - }, - (error) => { - logger.error(`Error auto-selecting Deepnote kernel for ${getDisplayPath(notebook.uri)}`, error); - void this.handleKernelSelectionError(error, notebook); - } - ); + // Like the Jupyter extension, opening a notebook only offers a controller. The toolkit + // check, its consent prompt and the server start all wait for the first execution. + try { + await this.selectPlaceholderController(notebook); + } catch (error) { + logger.error(`Failed to offer a Deepnote kernel for ${getDisplayPath(notebook.uri)}`, error); + } } private onControllerSelectionChanged(event: { @@ -186,6 +163,13 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, this.notebookInterpreterIds.delete(notebookKey); this.notebookControllers.delete(notebookKey); + const placeholder = this.placeholderControllers.get(notebookKey); + + if (placeholder) { + placeholder.dispose(); + this.placeholderControllers.delete(notebookKey); + } + logger.info(`Deepnote notebook closed, cleaned up: ${getDisplayPath(notebook.uri)}`); } @@ -236,6 +220,14 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return; } + const dependency = await this.toolkitDependencyService.ensureToolkitInstalled(interpreter, notebook.uri, token); + + if (dependency !== DeepnoteToolkitDependencyResponse.ok) { + logger.info(`deepnote-toolkit unavailable, controller not rebuilt for ${getDisplayPath(notebook.uri)}`); + + return; + } + await this.ensureKernelSelectedWithInterpreter(notebook, interpreter, notebookKey, progress, token); // Setup succeeded. If it registered a new server handle (full setup path), drop the old one. @@ -539,6 +531,43 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return true; } + // Consent before installing into the user's interpreter. Declining or cancelling is not a + // failure: execution simply does not proceed and no error UI is raised. + let dependency: DeepnoteToolkitDependencyResponse; + + try { + dependency = await this.toolkitDependencyService.ensureToolkitInstalled(interpreter, notebook.uri, token); + } catch (error) { + if (token.isCancellationRequested || isCancellationError(error as Error)) { + logger.info(`deepnote-toolkit install cancelled for ${getDisplayPath(notebook.uri)}`); + + return false; + } + + await this.handleKernelSelectionError(error, notebook); + + return false; + } + + if (dependency === DeepnoteToolkitDependencyResponse.failed) { + await this.handleKernelSelectionError( + new Error(l10n.t('Failed to install {0}.', 'deepnote-toolkit')), + notebook + ); + + return false; + } + + if (dependency !== DeepnoteToolkitDependencyResponse.ok) { + logger.info( + `deepnote-toolkit unavailable for ${getDisplayPath(notebook.uri)} (${ + DeepnoteToolkitDependencyResponse[dependency] + }), kernel not started` + ); + + return false; + } + try { await window.withProgress( { @@ -605,6 +634,96 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, ); } + /** + * Offer a controller for a notebook whose kernel has not been set up yet, and select it so the + * notebook has something runnable. Running a cell through it performs the real setup. + */ + private async selectPlaceholderController(notebook: NotebookDocument): Promise { + const placeholder = this.createPlaceholderController(notebook); + placeholder.updateNotebookAffinity(notebook, NotebookControllerAffinity.Preferred); + + const notebookEditor = await this.findNotebookEditor(notebook); + + if (!notebookEditor) { + logger.warn( + `Could not find NotebookEditor for ${getDisplayPath(notebook.uri)}, kernel may not be selected` + ); + + return; + } + + await commands.executeCommand('notebook.selectKernel', { + notebookEditor, + id: placeholder.id, + extension: JVSC_EXTENSION_ID + }); + } + + /** + * One placeholder per notebook. Its execute handler runs the toolkit check, the consent prompt + * and the server start; the cells themselves run on the real controller once it exists. + */ + private createPlaceholderController(notebook: NotebookDocument): NotebookController { + const notebookKey = getNotebookKey(notebook.uri); + const existing = this.placeholderControllers.get(notebookKey); + + if (existing) { + return existing; + } + + const controller = notebooks.createNotebookController( + `deepnote-placeholder-${notebookKey}`, + DEEPNOTE_NOTEBOOK_TYPE, + l10n.t('Deepnote Kernel') + ); + + controller.supportsExecutionOrder = true; + controller.supportedLanguages = ['python', 'sql', 'markdown', 'plaintext']; + + controller.executeHandler = async (cells, doc) => { + logger.info(`Placeholder execute handler for ${getDisplayPath(doc.uri)} with ${cells.length} cells`); + + if (!workspace.isTrusted) { + logger.info(`Workspace is not trusted, skipping kernel setup for ${getDisplayPath(doc.uri)}`); + + return; + } + + const cts = new CancellationTokenSource(); + const closeListener = workspace.onDidCloseNotebookDocument((closedDoc) => { + if (getNotebookKey(closedDoc.uri) === getNotebookKey(doc.uri)) { + logger.info(`Notebook closed during kernel setup, cancelling`); + cts.cancel(); + } + }); + + try { + const ready = await this.ensureEnvironmentConfiguredBeforeExecution(doc, cts.token); + + if (!ready) { + logger.info(`Kernel not set up for ${getDisplayPath(doc.uri)}, cells not executed`); + + return; + } + + void window.showInformationMessage(l10n.t('Kernel ready. Run the cells again to execute them.')); + } catch (error) { + if (isCancellationError(error)) { + logger.info(`Kernel setup cancelled for ${getDisplayPath(doc.uri)}`); + } else { + logger.error(`Error in placeholder execute handler`, error); + } + } finally { + closeListener.dispose(); + cts.dispose(); + } + }; + + this.placeholderControllers.set(notebookKey, controller); + + return controller; + } + /** * Find the NotebookEditor for a given NotebookDocument. * Required for properly selecting a kernel with the notebook.selectKernel command. diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts index defb320fdd..f8f2888e32 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts @@ -3,6 +3,7 @@ import * as sinon from 'sinon'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import { DeepnoteKernelAutoSelector } from './deepnoteKernelAutoSelector.node'; import { ServerHandleRegistry } from '../../kernels/deepnote/deepnoteServerHandleRegistry.node'; +import { DeepnoteToolkitDependencyResponse, IDeepnoteToolkitDependencyService } from '../../kernels/deepnote/types'; import { IDeepnoteLspClientManager, IDeepnoteServerProvider, @@ -46,6 +47,7 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { let mockOutputChannel: IOutputChannel; let mockInterpreterService: IInterpreterService; let registry: ServerHandleRegistry; + let mockToolkitDependencyService: IDeepnoteToolkitDependencyService; let mockProgress: { report(value: { message?: string; increment?: number }): void }; let mockCancellationToken: CancellationToken; @@ -74,6 +76,10 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { mockOutputChannel = mock(); mockInterpreterService = mock(); registry = new ServerHandleRegistry(); + mockToolkitDependencyService = mock(); + when(mockToolkitDependencyService.ensureToolkitInstalled(anything(), anything(), anything())).thenResolve( + DeepnoteToolkitDependencyResponse.ok + ); mockProgress = { report: sandbox.stub() }; mockCancellationToken = mock(); @@ -137,7 +143,8 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { instance(mockServerStarter), instance(mockOutputChannel), instance(mockInterpreterService), - registry + registry, + instance(mockToolkitDependencyService) ); }); diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 7beb5c8916..52d0e09230 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -78,6 +78,7 @@ import { IDeepnoteEnvironmentManager, IDeepnoteNotebookEnvironmentMapper, IDeepnoteLspClientManager, + IDeepnoteToolkitDependencyService, IServerHandleRegistry } from '../kernels/deepnote/types'; import { DeepnoteAgentSkillsManager } from '../kernels/deepnote/deepnoteAgentSkillsManager.node'; @@ -86,6 +87,7 @@ import { DeepnoteServerStarter } from '../kernels/deepnote/deepnoteServerStarter import { DeepnoteKernelAutoSelector } from './deepnote/deepnoteKernelAutoSelector.node'; import { DeepnoteServerProvider } from '../kernels/deepnote/deepnoteServerProvider.node'; import { ServerHandleRegistry } from '../kernels/deepnote/deepnoteServerHandleRegistry.node'; +import { DeepnoteToolkitDependencyService } from '../kernels/deepnote/deepnoteToolkitDependencyService.node'; import { DeepnoteLspClientManager } from '../kernels/deepnote/deepnoteLspClientManager.node'; import { DeepnoteInitNotebookRunner } from './deepnote/deepnoteInitNotebookRunner.node'; import { DeepnoteRequirementsHelper, IDeepnoteRequirementsHelper } from './deepnote/deepnoteRequirementsHelper.node'; @@ -264,6 +266,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addSingleton(IDeepnoteServerProvider, DeepnoteServerProvider); serviceManager.addBinding(IDeepnoteServerProvider, IExtensionSyncActivationService); serviceManager.addSingleton(IServerHandleRegistry, ServerHandleRegistry); + serviceManager.addSingleton( + IDeepnoteToolkitDependencyService, + DeepnoteToolkitDependencyService + ); serviceManager.addSingleton(IDeepnoteKernelAutoSelector, DeepnoteKernelAutoSelector); serviceManager.addBinding(IDeepnoteKernelAutoSelector, IExtensionSyncActivationService); serviceManager.addSingleton(IDeepnoteLspClientManager, DeepnoteLspClientManager); diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 30d282fda7..7bf68e84fe 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -466,6 +466,7 @@ export namespace DataScience { l10n.t('Failure during variable extraction: \r\n{0}', errorMessage); export const selectKernel = l10n.t('Change Kernel'); export const selectDifferentKernel = l10n.t('Select a different Kernel'); + export const selectDifferentPythonInterpreter = l10n.t('Select a different Interpreter'); export const kernelFilterPlaceholder = l10n.t('Choose the kernels that are available in the kernel picker.'); export const recommendedItemCategoryInQuickPick = l10n.t('Recommended'); export const selectedKernelCategoryInQuickPick = l10n.t('Selected'); diff --git a/test/e2e/helpers/modals.ts b/test/e2e/helpers/modals.ts index 993935171f..4a42ee4f04 100644 --- a/test/e2e/helpers/modals.ts +++ b/test/e2e/helpers/modals.ts @@ -6,7 +6,10 @@ import { WORKBENCH_TIMEOUT } from './constants'; * Confirms a `{modal:true}` dialog by clicking the button matching `label`, driving the raw * `.monaco-dialog-box` (ExTester's `ModalDialog` attaches unreliably); `messageIncludes` disambiguates. */ -export async function confirmModalDialog(label: string, options?: { messageIncludes?: string }): Promise { +export async function confirmModalDialog( + label: string, + options?: { messageIncludes?: string; onVisible?: () => Promise } +): Promise { const driver = VSBrowser.instance.driver; const messageIncludes = options?.messageIncludes; @@ -25,6 +28,9 @@ export async function confirmModalDialog(label: string, options?: { messageInclu `modal dialog${messageIncludes ? ` containing "${messageIncludes}"` : ''} did not appear` ); + // Runs while the dialog is still up — the only chance to capture or inspect it. + await options?.onVisible?.(); + const button = await driver.wait( async () => { const selector = '.monaco-dialog-box .dialog-buttons .monaco-button, .monaco-dialog-box .monaco-button'; diff --git a/test/e2e/suite/interpreterKernel.e2e.test.ts b/test/e2e/suite/interpreterKernel.e2e.test.ts index 156adb317b..067af857c6 100644 --- a/test/e2e/suite/interpreterKernel.e2e.test.ts +++ b/test/e2e/suite/interpreterKernel.e2e.test.ts @@ -1,25 +1,22 @@ /** - * End-to-end UI test for kernel setup WITHOUT Deepnote environments. + * End-to-end UI test for kernel setup WITHOUT Deepnote environments, following the Jupyter + * extension's mechanism for a missing Python dependency: + * 1. opening a `.deepnote` file only OFFERS a kernel — nothing is installed and no server starts + * 2. running a cell detects that deepnote-toolkit is missing and asks for consent (modal prompt) + * 3. on "Install" the toolkit goes into the workspace's *active interpreter*, not a managed venv + * 4. the server starts, the real controller binds, and a re-run executes the cell * - * This is the flow the extension uses now that environments are gone: the kernel is built from the - * workspace's *active Python interpreter*, and deepnote-toolkit is installed into that interpreter - * through the Python extension's installer infrastructure (`IInstaller`) — the same mechanism the - * Jupyter extension uses for its own missing dependencies: - * 1. open a one-notebook `.deepnote` file against a workspace whose active interpreter is a bare venv - * 2. the auto-selector picks that interpreter — no environment is created and no picker appears - * 3. deepnote-toolkit is missing, so an "Installing deepnote-toolkit" progress notification shows - * 4. once it installs, the server starts and the kernel controller is bound - * 5. run the cell and assert the rendered stdout + * The workspace's active interpreter is a bare venv this test creates, so the install path runs on + * every execution rather than only on a machine that happens to be missing the package. The cell + * prints `sys.prefix`, so the output proves the kernel ran inside that venv. * - * The cell prints `sys.prefix`, so the output proves the kernel really ran inside the venv this test - * created rather than in a Deepnote-managed environment. The suite also asserts the toolkit landed in - * that same venv, which is the load-bearing difference from the environment-based flow. + * Screenshots are captured at each step into `test/e2e/screenshots/interpreterKernel/` so the flow + * can be confirmed visually — in particular that the consent prompt is actually shown. * * Prerequisites: - * - The Python extension (`ms-python.python`) must be installed in the test instance - * (`npm run setup:e2e:deps`). + * - The Python extension (`ms-python.python`) must be installed in the test instance. * - `python3` must be on PATH and able to create a venv (CI installs `python3.12-venv`). - * - Network access: the toolkit is installed from PyPI on first kernel start, which is slow. + * - Network access: the toolkit is installed from PyPI, which is slow. */ import { expect } from 'chai'; @@ -33,20 +30,22 @@ import { KERNEL_CONNECT_TIMEOUT, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, + clickRunAll, + confirmModalDialog, copyFixtureToTempDir, + createScreenshotter, + dismissAllNotifications, openFolderViaDialog, openWorkspaceFile, runOnceAndAwaitOutput, - waitForNotification, - waitForNotificationToClear + waitForNotification } from '../helpers'; const NOTEBOOK_FILE_NAME = 'interpreter-kernel.deepnote'; const EXPECTED_OUTPUT = 'interpreter-kernel-ok'; -// The install toast is observed opportunistically; the venv check below is the real gate, so a -// missed toast must not cost the suite a full kernel-connect timeout. -const TOOLKIT_NOTIFICATION_TIMEOUT = 90_000; +/** How long the notebook is watched to prove that merely opening it installs nothing. */ +const NO_INSTALL_OBSERVATION_MS = 15_000; /** Path to the interpreter inside a venv, for the platform the test is running on. */ function venvPython(venvDir: string): string { @@ -66,7 +65,7 @@ function isToolkitInstalled(python: string): boolean { } } -describe('Deepnote E2E — run on the active interpreter (no Deepnote environment)', function () { +describe('Deepnote E2E — consent, then install into the active interpreter', function () { this.timeout(SUITE_TIMEOUT); let cleanupTempDir: (() => void) | undefined; @@ -77,9 +76,6 @@ describe('Deepnote E2E — run on the active interpreter (no Deepnote environmen const { cleanup, tempDir } = copyFixtureToTempDir(NOTEBOOK_FILE_NAME); cleanupTempDir = cleanup; - // A throwaway venv is what makes this test deterministic: it is guaranteed not to have - // deepnote-toolkit, so the install path runs on every execution rather than only on a - // machine that happens to be missing the package. venvDir = path.join(tempDir, '.venv'); execFileSync('python3', ['-m', 'venv', venvDir], { stdio: 'inherit' }); interpreter = venvPython(venvDir); @@ -89,8 +85,8 @@ describe('Deepnote E2E — run on the active interpreter (no Deepnote environmen 'precondition: the fresh venv must not already provide deepnote-toolkit' ); - // Pin the workspace's interpreter so the auto-selector resolves this venv and not whatever - // the Python extension would otherwise discover on the machine. + // Pin the workspace's interpreter so the kernel resolves this venv and not whatever the + // Python extension would otherwise discover on the machine. const vscodeDir = path.join(tempDir, '.vscode'); fs.mkdirSync(vscodeDir, { recursive: true }); fs.writeFileSync( @@ -99,9 +95,6 @@ describe('Deepnote E2E — run on the active interpreter (no Deepnote environmen ); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - - // Opening the folder reloads the window, so the notebook is opened in the test body — that - // keeps the install notification, which fires during the open, inside the assertions. await openFolderViaDialog(tempDir); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); }); @@ -121,32 +114,54 @@ describe('Deepnote E2E — run on the active interpreter (no Deepnote environmen } }); - it('installs deepnote-toolkit into the active interpreter, then runs the cell', async function () { - await openWorkspaceFile(NOTEBOOK_FILE_NAME); + it('installs nothing on open, asks before installing, then runs the cell', async function () { + const shot = createScreenshotter(this); + const driver = VSBrowser.instance.driver; - await VSBrowser.instance.driver.wait( + await openWorkspaceFile(NOTEBOOK_FILE_NAME); + await driver.wait( async () => (await new EditorView().getOpenEditorTitles()).some((t) => t.includes(NOTEBOOK_FILE_NAME)), WORKBENCH_TIMEOUT, 'Deepnote notebook editor did not open' ); - // Opening the notebook auto-selects the kernel, which finds the toolkit missing and installs - // it — no environment is created and the user is never asked to pick one. The progress toast - // is the visible half of that, but it is transient (and absent on a retry, where the toolkit - // is already installed), so it is observed best-effort; the durable assertions are below. - await waitForNotification(/Installing deepnote-toolkit/i, TOOLKIT_NOTIFICATION_TIMEOUT, false); + await shot('notebook-open'); - // The load-bearing gate: the install landed in the active interpreter. Polling the venv is - // race-free, unlike matching a toast that may already have gone. - await VSBrowser.instance.driver.wait( + // The load-bearing half of "detect on kernel start, not notebook open". Watching the install + // toast rather than only the venv is what makes this catch a regression: an open-time install + // announces itself within seconds, long before the package would actually land on disk. + const installStartedOnOpen = await waitForNotification( + /Installing deepnote_toolkit/i, + NO_INSTALL_OBSERVATION_MS, + false + ); + + expect(installStartedOnOpen, 'opening the notebook must not start an install').to.equal(undefined); + expect(isToolkitInstalled(interpreter)).to.equal( + false, + 'opening the notebook must not install anything into the interpreter' + ); + + // Running a cell is the user gesture that triggers detection, and the consent prompt. + await dismissAllNotifications().catch(() => undefined); + await clickRunAll(NOTEBOOK_FILE_NAME); + + await confirmModalDialog('Install', { + messageIncludes: 'deepnote-toolkit', + onVisible: async () => { + await shot('consent-prompt'); + } + }); + + await driver.wait( () => isToolkitInstalled(interpreter), KERNEL_CONNECT_TIMEOUT, 'deepnote-toolkit was never installed into the active interpreter' ); - // Waiting for the auto-select toast to be *gone* gates "Run All" on a bound kernel without - // depending on catching it while it is shown. - await waitForNotificationToClear(/Auto-selecting Deepnote kernel/i, KERNEL_CONNECT_TIMEOUT); + // The first run only sets the kernel up; the cells themselves run on the re-run. + await waitForNotification(/Run the cells again/i, KERNEL_CONNECT_TIMEOUT, true); + await shot('kernel-ready'); const renderedOutput = await runOnceAndAwaitOutput( NOTEBOOK_FILE_NAME, @@ -154,6 +169,8 @@ describe('Deepnote E2E — run on the active interpreter (no Deepnote environmen FIRST_RUN_OUTPUT_TIMEOUT ); + await shot('cell-output'); + expect(renderedOutput).to.contain(EXPECTED_OUTPUT); // The cell printed sys.prefix: the kernel must be the venv this test created, which is what From f24b20f90ce0e996197c71511e49e3ce20f1adc0 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 25 Aug 2026 18:01:21 +0000 Subject: [PATCH 06/12] refactor(deepnote): name kernels after the environment, as Jupyter does The Deepnote case returned the .deepnote project title, so the kernel picker described the document rather than the runtime: every notebook in a project carried the same label, and nothing told the user which interpreter the kernel would use -- the one thing worth checking before consenting to an install. Use the same implementation as 'startUsingPythonInterpreter': getDisplayNameOrNameOfPythonKernelConnection, which yields " (Python )" -- e.g. ".venv (Python 3.12.13)" -- falling back to "Python " for an unrecognised environment and to the kernelspec name when there is no interpreter at all. The environmentName fallback below it was already unreachable (projectName always had a value, defaulting to 'Untitled Project'), so the interpreter path this PR started storing there was never displayed. projectName is dropped from the connection metadata: it existed only for this label, is not serialized by toJSON, and the project title is still shown on the editor tab and in the Deepnote status bar. environmentName and notebookName stay -- they participate in connection equality. Verified: 2769 unit tests passing; the naming test fails when the environment branch is removed; E2E green with the picker confirmed reading ".venv (Python 3.12.13)". Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019cqzx43mLQRvVfWbfEUUSx --- src/kernels/deepnote/types.ts | 4 -- src/kernels/helpers.ts | 22 ++++++---- src/kernels/helpers.unit.test.ts | 42 +++++++++++++++++++ .../deepnoteKernelAutoSelector.node.ts | 4 -- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/kernels/deepnote/types.ts b/src/kernels/deepnote/types.ts index a5c9bc551e..e98b268775 100644 --- a/src/kernels/deepnote/types.ts +++ b/src/kernels/deepnote/types.ts @@ -31,7 +31,6 @@ export class DeepnoteKernelConnectionMetadata { public readonly serverProviderHandle: JupyterServerProviderHandle; public readonly serverInfo?: DeepnoteServerInfo; // Store server info for connection public readonly environmentName?: string; // Name of the Deepnote environment for display purposes - public readonly projectName?: string; // Name of the project for display purposes public readonly notebookName?: string; // Name of the notebook for display purposes private constructor(options: { @@ -43,7 +42,6 @@ export class DeepnoteKernelConnectionMetadata { serverProviderHandle: JupyterServerProviderHandle; serverInfo?: DeepnoteServerInfo; environmentName?: string; - projectName?: string; notebookName?: string; }) { this.interpreter = options.interpreter; @@ -54,7 +52,6 @@ export class DeepnoteKernelConnectionMetadata { this.serverProviderHandle = options.serverProviderHandle; this.serverInfo = options.serverInfo; this.environmentName = options.environmentName; - this.projectName = options.projectName; this.notebookName = options.notebookName; } @@ -67,7 +64,6 @@ export class DeepnoteKernelConnectionMetadata { serverProviderHandle: JupyterServerProviderHandle; serverInfo?: DeepnoteServerInfo; environmentName?: string; - projectName?: string; notebookName?: string; }) { return new DeepnoteKernelConnectionMetadata(options); diff --git a/src/kernels/helpers.ts b/src/kernels/helpers.ts index 11c88baa21..1cc307c804 100644 --- a/src/kernels/helpers.ts +++ b/src/kernels/helpers.ts @@ -302,16 +302,22 @@ export function getDisplayNameOrNameOfKernelConnection(kernelConnection: KernelC return `Python ${pythonVersion}`.trim(); } case 'startUsingDeepnoteKernel': { - // Display as "Project Title" - if (kernelConnection.projectName) { - return kernelConnection.projectName; + // Named after the environment the code runs in, exactly as 'startUsingPythonInterpreter' + // does: the kernel picker answers "which interpreter?", and the project title is already + // carried by the editor tab and the Deepnote status bar. + if (!kernelConnection.interpreter) { + return oldDisplayName; } - // For Deepnote kernels, use the environment name if available - if (kernelConnection.environmentName) { - return `Deepnote: ${kernelConnection.environmentName}`; + + if (getEnvironmentType(kernelConnection.interpreter) !== EnvironmentType.Unknown) { + return getDisplayNameOrNameOfPythonKernelConnection(kernelConnection.interpreter); } - // Fallback to kernelspec display name - return oldDisplayName; + + const deepnotePythonVersion = ( + getTelemetrySafeVersion(getCachedVersion(kernelConnection.interpreter)) || '' + ).trim(); + + return `Python ${deepnotePythonVersion}`.trim(); } } return oldDisplayName; diff --git a/src/kernels/helpers.unit.test.ts b/src/kernels/helpers.unit.test.ts index 0d6d163881..bccc4e6acf 100644 --- a/src/kernels/helpers.unit.test.ts +++ b/src/kernels/helpers.unit.test.ts @@ -6,6 +6,7 @@ import { assert } from 'chai'; import { when, instance, mock, anything } from 'ts-mockito'; import { Uri } from 'vscode'; import { getDisplayNameOrNameOfKernelConnection } from './helpers'; +import { DeepnoteKernelConnectionMetadata } from './deepnote/types'; import { IJupyterKernelSpec, LiveRemoteKernelConnectionMetadata, @@ -261,6 +262,47 @@ suite('Kernel Connection Helpers', () => { assert.strictEqual(name, 'kspecname (.env)'); }); }); + suite('Deepnote kernels', () => { + const venv = { uri: Uri.file('/work/.venv/bin/python'), id: '/work/.venv/bin/python' }; + + function deepnoteConnection(interpreter?: PythonEnvironment) { + return DeepnoteKernelConnectionMetadata.create({ + id: 'deepnote-notebook-1', + baseUrl: 'http://127.0.0.1:8888', + kernelSpec: { + argv: [], + display_name: 'deepnote-kernelspec-name', + name: 'python3', + executable: 'python', + language: 'python' + }, + serverProviderHandle: { extensionId: 'ext', id: 'deepnote-server', handle: 'handle' }, + interpreter + }); + } + + test('is named after the environment, not the Deepnote project', () => { + whenKnownEnvironments(environments).thenReturn([ + { + id: venv.id, + path: venv.uri.fsPath, + environment: { name: '.venv', type: 'VirtualEnvironment' }, + version: { major: 3, minor: 12, micro: 13, sysVersion: '3.12.13' } + } + ]); + + const name = getDisplayNameOrNameOfKernelConnection(deepnoteConnection(venv as PythonEnvironment)); + + assert.strictEqual(name, '.venv (Python 3.12.13)'); + }); + + test('falls back to the kernelspec name when there is no interpreter', () => { + const name = getDisplayNameOrNameOfKernelConnection(deepnoteConnection(undefined)); + + assert.strictEqual(name, 'deepnote-kernelspec-name'); + }); + }); + suite('Python kernels (started using kernelspec)', () => { test('Display name if language is python', () => { const name = getDisplayNameOrNameOfKernelConnection( diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index ed62c2cb0d..7b15df9c21 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -361,9 +361,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, // controller instead of creating a new one, avoiding the DISPOSED error. const controllerId = `deepnote-notebook-${notebookKey}`; - // Extract project and notebook titles from metadata for display - const projectTitle = notebook.metadata?.deepnoteProjectName || 'Untitled Project'; - const newConnectionMetadata = DeepnoteKernelConnectionMetadata.create({ interpreter, kernelSpec, @@ -373,7 +370,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, serverProviderHandle, serverInfo, environmentName: getDisplayPath(interpreter.uri), - projectName: projectTitle, notebookName: notebookKey }); From 8cae65711260a63a22d3b149b4f30c8f6d6e9c3c Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 25 Aug 2026 19:22:47 +0000 Subject: [PATCH 07/12] fix(deepnote): describe kernels by their interpreter path, as Jupyter does getKernelDisplayPathFromKernelConnection had no case for 'startUsingDeepnoteKernel', so it fell into the non-Python branch and took the description straight from kernelSpec.executable. The toolkit server serves the stock ipykernel spec, whose executable is a bare "python", so the kernel picker showed "/python" -- a path that does not exist. Group the kind with the other kernelspec-backed Python kinds. The branch already resolves a bare "python" through the connection's interpreter, so the description becomes the environment folder, rendered workspace-relative -- ".venv" for a project-local venv, matching upstream. Verified: the description test fails against the old branch ("/python" instead of "/work/.venv"); 2770 unit tests passing; the E2E now opens the kernel picker and captures it, confirming the entry reads ".venv (Python 3.12.13)" with ".venv" as its description. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019cqzx43mLQRvVfWbfEUUSx --- src/kernels/helpers.ts | 3 ++- src/kernels/helpers.unit.test.ts | 18 +++++++++++++++++- test/e2e/suite/interpreterKernel.e2e.test.ts | 15 ++++++++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/kernels/helpers.ts b/src/kernels/helpers.ts index 1cc307c804..938bad5880 100644 --- a/src/kernels/helpers.ts +++ b/src/kernels/helpers.ts @@ -381,7 +381,8 @@ export function getKernelDisplayPathFromKernelConnection(kernelConnection?: Kern if ( kernelConnection.kind === 'startUsingPythonInterpreter' || ((kernelConnection.kind === 'startUsingRemoteKernelSpec' || - kernelConnection.kind === 'startUsingLocalKernelSpec') && + kernelConnection.kind === 'startUsingLocalKernelSpec' || + kernelConnection.kind === 'startUsingDeepnoteKernel') && kernelConnection.kernelSpec.language === PYTHON_LANGUAGE) ) { const pathValue = diff --git a/src/kernels/helpers.unit.test.ts b/src/kernels/helpers.unit.test.ts index bccc4e6acf..49f410b37f 100644 --- a/src/kernels/helpers.unit.test.ts +++ b/src/kernels/helpers.unit.test.ts @@ -5,7 +5,7 @@ import * as sinon from 'sinon'; import { assert } from 'chai'; import { when, instance, mock, anything } from 'ts-mockito'; import { Uri } from 'vscode'; -import { getDisplayNameOrNameOfKernelConnection } from './helpers'; +import { getDisplayNameOrNameOfKernelConnection, getKernelDisplayPathFromKernelConnection } from './helpers'; import { DeepnoteKernelConnectionMetadata } from './deepnote/types'; import { IJupyterKernelSpec, @@ -296,6 +296,22 @@ suite('Kernel Connection Helpers', () => { assert.strictEqual(name, '.venv (Python 3.12.13)'); }); + test('describes the interpreter, resolving a relative kernelspec executable via the environment', () => { + whenKnownEnvironments(environments).thenReturn([ + { + id: venv.id, + path: venv.uri.fsPath, + environment: { name: '.venv', type: 'VirtualEnvironment', folderUri: Uri.file('/work/.venv') }, + version: { major: 3, minor: 12, micro: 13, sysVersion: '3.12.13' } + } + ]); + + // The toolkit server serves the stock ipykernel spec, whose executable is a bare "python". + const displayPath = getKernelDisplayPathFromKernelConnection(deepnoteConnection(venv as PythonEnvironment)); + + assert.strictEqual(displayPath?.fsPath, Uri.file('/work/.venv').fsPath); + }); + test('falls back to the kernelspec name when there is no interpreter', () => { const name = getDisplayNameOrNameOfKernelConnection(deepnoteConnection(undefined)); diff --git a/test/e2e/suite/interpreterKernel.e2e.test.ts b/test/e2e/suite/interpreterKernel.e2e.test.ts index 067af857c6..d80bcd3309 100644 --- a/test/e2e/suite/interpreterKernel.e2e.test.ts +++ b/test/e2e/suite/interpreterKernel.e2e.test.ts @@ -23,11 +23,12 @@ import { expect } from 'chai'; import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { EditorView, VSBrowser, WebView } from 'vscode-extension-tester'; +import { EditorView, VSBrowser, WebView, Workbench } from 'vscode-extension-tester'; import { FIRST_RUN_OUTPUT_TIMEOUT, KERNEL_CONNECT_TIMEOUT, + QUICK_PICK_TIMEOUT, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, clickRunAll, @@ -38,6 +39,7 @@ import { openFolderViaDialog, openWorkspaceFile, runOnceAndAwaitOutput, + tryOpenInputBox, waitForNotification } from '../helpers'; @@ -176,5 +178,16 @@ describe('Deepnote E2E — consent, then install into the active interpreter', f // The cell printed sys.prefix: the kernel must be the venv this test created, which is what // separates "active interpreter" from the old Deepnote-managed environment. expect(renderedOutput).to.contain(venvDir); + + // The kernel picker is the only place the description is rendered, so open it to capture + // both halves of the entry: the environment name as the label, its path as the description. + await new Workbench().executeCommand('notebook.selectKernel'); + + const picker = await tryOpenInputBox(QUICK_PICK_TIMEOUT); + + expect(picker, 'the kernel picker should open').to.not.equal(undefined); + + await shot('kernel-picker'); + await picker?.cancel(); }); }); From e25fed7d95cf2c9f44dca048853919daa6112afc Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 25 Aug 2026 20:43:34 +0000 Subject: [PATCH 08/12] refactor(deepnote): drop the placeholder controller, register a real one The placeholder came in with #310 to prompt for a Deepnote environment, so it was a leftover of the design this PR removes. Upstream has no equivalent: it creates one controller per discovered connection and only starts a session on execute. Do the same. Opening a notebook now registers a real Deepnote controller for the active interpreter, with the stock python3 spec as a stand-in and no baseUrl. The kernel picker shows one correctly named entry from the moment the file opens, instead of a second "Deepnote Kernel" row that only re-ran setup. The first execution still gates on consent, installs, starts the server and calls addOrUpdate, which updates that controller's connection in place. Because the controller is real, the run that triggered the prompt is the run that executes -- the "run the cells again" step is gone. Both reuse fast paths previously matched on controller + interpreter alone, which a registered-but-not-started controller satisfies; they would have run cells against a server that was never started. They now go through isKernelReady, which additionally requires a connection carrying a baseUrl. Covered by a test that fails without it. Also drops the deepnote-loading-kernel mock, which only existed to satisfy the placeholder's createNotebookController call. Verified: 2771 unit tests passing; E2E green with screenshots confirming the kernel is named ".venv (Python 3.12.13)" at open with nothing installed, and the picker listing a single entry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019cqzx43mLQRvVfWbfEUUSx --- .../deepnoteKernelAutoSelector.node.ts | 167 ++++++++---------- ...epnoteKernelAutoSelector.node.unit.test.ts | 37 ++-- test/e2e/suite/interpreterKernel.e2e.test.ts | 34 ++-- 3 files changed, 116 insertions(+), 122 deletions(-) diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index 7b15df9c21..b5b89361f9 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -7,7 +7,6 @@ import { inject, injectable, named, optional } from 'inversify'; import { CancellationToken, CancellationTokenSource, - NotebookController, NotebookControllerAffinity, NotebookDocument, NotebookEditor, @@ -16,7 +15,6 @@ import { commands, env, l10n, - notebooks, window, workspace } from 'vscode'; @@ -57,6 +55,18 @@ import { IDeepnoteNotebookManager } from '../types'; import { computeRequirementsHash } from './deepnoteProjectUtils'; import { IDeepnoteRequirementsHelper } from './deepnoteRequirementsHelper.node'; +/** + * Stand-in for the spec the toolkit server will report. The server's own spec replaces it once the + * kernel is set up; until then it only has to describe the environment for the kernel picker. + */ +const PENDING_KERNEL_SPEC: IJupyterKernelSpec = { + argv: [], + display_name: 'Python 3 (ipykernel)', + executable: 'python', + language: 'python', + name: 'python3' +}; + const NOTEBOOK_EDITOR_RETRY_COUNT = 10; const NOTEBOOK_EDITOR_RETRY_DELAY_MS = 100; @@ -71,8 +81,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, private readonly notebookControllers = new Map(); // Track interpreter ID for each notebook private readonly notebookInterpreterIds = new Map(); - // Offered at open so the notebook has something selectable before any server exists - private readonly placeholderControllers = new Map(); constructor( @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, @@ -127,10 +135,10 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, logger.info(`Deepnote notebook opened: ${getDisplayPath(notebook.uri)}`); - // Like the Jupyter extension, opening a notebook only offers a controller. The toolkit - // check, its consent prompt and the server start all wait for the first execution. + // Like the Jupyter extension, opening a notebook only registers a controller for the + // environment; nothing is installed and no server starts until the first execution. try { - await this.selectPlaceholderController(notebook); + await this.registerControllerForNotebook(notebook); } catch (error) { logger.error(`Failed to offer a Deepnote kernel for ${getDisplayPath(notebook.uri)}`, error); } @@ -163,13 +171,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, this.notebookInterpreterIds.delete(notebookKey); this.notebookControllers.delete(notebookKey); - const placeholder = this.placeholderControllers.get(notebookKey); - - if (placeholder) { - placeholder.dispose(); - this.placeholderControllers.delete(notebookKey); - } - logger.info(`Deepnote notebook closed, cleaned up: ${getDisplayPath(notebook.uri)}`); } @@ -280,12 +281,14 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return; } - const existingController = this.notebookControllers.get(notebookKey); - const existingInterpreterId = this.notebookInterpreterIds.get(notebookKey); - - if (existingInterpreterId != null && existingController != null && existingInterpreterId === interpreter.id) { + if (this.isKernelReady(notebookKey, interpreter.id)) { logger.info(`Existing controller found for notebook ${getDisplayPath(notebook.uri)}, reusing`); - await this.ensureControllerSelectedForNotebook(notebook, existingController, progressToken); + await this.ensureControllerSelectedForNotebook( + notebook, + this.notebookControllers.get(notebookKey)!, + progressToken + ); + return; } @@ -519,11 +522,9 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return false; } - const existingController = this.notebookControllers.get(notebookKey); - const existingInterpreterId = this.notebookInterpreterIds.get(notebookKey); - - if (existingController && existingInterpreterId === interpreter.id) { + if (this.isKernelReady(notebookKey, interpreter.id)) { logger.info(`Controller already configured for ${getDisplayPath(notebook.uri)}`); + return true; } @@ -631,93 +632,67 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, } /** - * Offer a controller for a notebook whose kernel has not been set up yet, and select it so the - * notebook has something runnable. Running a cell through it performs the real setup. + * True when the notebook's controller is bound to a running server for this interpreter. A + * controller registered at open time carries no baseUrl and is deliberately not "ready". */ - private async selectPlaceholderController(notebook: NotebookDocument): Promise { - const placeholder = this.createPlaceholderController(notebook); - placeholder.updateNotebookAffinity(notebook, NotebookControllerAffinity.Preferred); - - const notebookEditor = await this.findNotebookEditor(notebook); - - if (!notebookEditor) { - logger.warn( - `Could not find NotebookEditor for ${getDisplayPath(notebook.uri)}, kernel may not be selected` - ); - - return; - } - - await commands.executeCommand('notebook.selectKernel', { - notebookEditor, - id: placeholder.id, - extension: JVSC_EXTENSION_ID - }); + private isKernelReady(notebookKey: string, interpreterId: string): boolean { + return ( + this.notebookControllers.has(notebookKey) && + this.notebookInterpreterIds.get(notebookKey) === interpreterId && + !!this.notebookConnectionMetadata.get(notebookKey)?.baseUrl + ); } /** - * One placeholder per notebook. Its execute handler runs the toolkit check, the consent prompt - * and the server start; the cells themselves run on the real controller once it exists. + * Registers a controller for the notebook's active interpreter without starting anything, so the + * notebook has a real, correctly named kernel to select the moment it opens. Its connection + * carries no baseUrl; the first execution starts the server and updates it in place. */ - private createPlaceholderController(notebook: NotebookDocument): NotebookController { - const notebookKey = getNotebookKey(notebook.uri); - const existing = this.placeholderControllers.get(notebookKey); - - if (existing) { - return existing; - } - - const controller = notebooks.createNotebookController( - `deepnote-placeholder-${notebookKey}`, - DEEPNOTE_NOTEBOOK_TYPE, - l10n.t('Deepnote Kernel') - ); - - controller.supportsExecutionOrder = true; - controller.supportedLanguages = ['python', 'sql', 'markdown', 'plaintext']; - - controller.executeHandler = async (cells, doc) => { - logger.info(`Placeholder execute handler for ${getDisplayPath(doc.uri)} with ${cells.length} cells`); + private async registerControllerForNotebook(notebook: NotebookDocument): Promise { + const interpreter = await this.interpreterService.getActiveInterpreter(notebook.uri); - if (!workspace.isTrusted) { - logger.info(`Workspace is not trusted, skipping kernel setup for ${getDisplayPath(doc.uri)}`); + if (!interpreter) { + logger.warn(`No active Python interpreter for ${getDisplayPath(notebook.uri)}, no kernel offered`); - return; - } + return; + } - const cts = new CancellationTokenSource(); - const closeListener = workspace.onDidCloseNotebookDocument((closedDoc) => { - if (getNotebookKey(closedDoc.uri) === getNotebookKey(doc.uri)) { - logger.info(`Notebook closed during kernel setup, cancelling`); - cts.cancel(); - } - }); + const notebookKey = getNotebookKey(notebook.uri); + const connection = DeepnoteKernelConnectionMetadata.create({ + interpreter, + kernelSpec: PENDING_KERNEL_SPEC, + baseUrl: '', + id: `deepnote-notebook-${notebookKey}`, + projectFilePath: notebookKey, + serverProviderHandle: { + extensionId: JVSC_EXTENSION_ID, + id: 'deepnote-server', + handle: createDeepnoteServerConfigHandle(interpreter.id, notebook.uri) + }, + environmentName: getDisplayPath(interpreter.uri), + notebookName: notebookKey + }); - try { - const ready = await this.ensureEnvironmentConfiguredBeforeExecution(doc, cts.token); + const [controller] = this.controllerRegistration.addOrUpdate(connection, [DEEPNOTE_NOTEBOOK_TYPE]); - if (!ready) { - logger.info(`Kernel not set up for ${getDisplayPath(doc.uri)}, cells not executed`); + if (!controller) { + logger.error(`Failed to register a Deepnote controller for ${getDisplayPath(notebook.uri)}`); - return; - } + return; + } - void window.showInformationMessage(l10n.t('Kernel ready. Run the cells again to execute them.')); - } catch (error) { - if (isCancellationError(error)) { - logger.info(`Kernel setup cancelled for ${getDisplayPath(doc.uri)}`); - } else { - logger.error(`Error in placeholder execute handler`, error); - } - } finally { - closeListener.dispose(); - cts.dispose(); - } - }; + this.notebookConnectionMetadata.set(notebookKey, connection); + this.notebookInterpreterIds.set(notebookKey, interpreter.id); + this.notebookControllers.set(notebookKey, controller); + this.controllerRegistration.trackActiveInterpreterControllers([controller]); - this.placeholderControllers.set(notebookKey, controller); + const cts = new CancellationTokenSource(); - return controller; + try { + await this.ensureControllerSelectedForNotebook(notebook, controller, cts.token); + } finally { + cts.dispose(); + } } /** diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts index f8f2888e32..953eb575b6 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts @@ -114,19 +114,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { // Mock disposable registry - push returns the index when(mockDisposableRegistry.push(anything())).thenReturn(0); - // Mock notebooks.createNotebookController to return a mock controller for the loading kernel - const mockLoadingController = { - id: 'deepnote-loading-kernel', - supportsExecutionOrder: false, - supportedLanguages: ['python'], - executeHandler: undefined as unknown, - updateNotebookAffinity: sandbox.stub(), - dispose: sandbox.stub() - } as unknown as NotebookController; - when(mockedVSCodeNamespaces.notebooks!.createNotebookController(anything(), anything(), anything())).thenReturn( - mockLoadingController - ); - // Create selector instance selector = new DeepnoteKernelAutoSelector( instance(mockDisposableRegistry), @@ -526,6 +513,7 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { const selectorAny = selector as any; selectorAny.notebookControllers.set(notebookKey, instance(mockController)); selectorAny.notebookInterpreterIds.set(notebookKey, interpreterA.id); + selectorAny.notebookConnectionMetadata.set(notebookKey, { baseUrl: 'http://127.0.0.1:8888' }); // Active interpreter is still A when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(interpreterA); @@ -538,6 +526,29 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { assert.strictEqual(result, true, 'Should return true (fast path)'); assert.strictEqual(ensureStub.called, false, 'Should NOT call ensureKernelSelectedWithInterpreter'); }); + + test('does NOT treat the controller registered at open as ready (it has no server yet)', async () => { + const notebookKey = mockNotebook.uri.toString(); + const interpreterA: PythonEnvironment = { + id: '/usr/bin/python3.10', + uri: Uri.parse('/usr/bin/python3.10') + }; + + // What registerControllerForNotebook leaves behind: a controller whose connection has no + // baseUrl. Short-circuiting here would run cells against a server that was never started. + const selectorAny = selector as any; + selectorAny.notebookControllers.set(notebookKey, instance(mockController)); + selectorAny.notebookInterpreterIds.set(notebookKey, interpreterA.id); + selectorAny.notebookConnectionMetadata.set(notebookKey, { baseUrl: '' }); + + when(mockInterpreterService.getActiveInterpreter(anything())).thenResolve(interpreterA); + + const ensureStub = sandbox.stub(selector, 'ensureKernelSelectedWithInterpreter').resolves(); + + await selector.ensureEnvironmentConfiguredBeforeExecution(mockNotebook, nonCancelledToken); + + assert.strictEqual(ensureStub.called, true, 'must set the kernel up before executing'); + }); }); // Priority 1 Tests - Critical for environment switching diff --git a/test/e2e/suite/interpreterKernel.e2e.test.ts b/test/e2e/suite/interpreterKernel.e2e.test.ts index d80bcd3309..37fa6a26fa 100644 --- a/test/e2e/suite/interpreterKernel.e2e.test.ts +++ b/test/e2e/suite/interpreterKernel.e2e.test.ts @@ -1,10 +1,11 @@ /** * End-to-end UI test for kernel setup WITHOUT Deepnote environments, following the Jupyter * extension's mechanism for a missing Python dependency: - * 1. opening a `.deepnote` file only OFFERS a kernel — nothing is installed and no server starts + * 1. opening a `.deepnote` file registers a controller named after the interpreter — nothing is + * installed and no server starts * 2. running a cell detects that deepnote-toolkit is missing and asks for consent (modal prompt) * 3. on "Install" the toolkit goes into the workspace's *active interpreter*, not a managed venv - * 4. the server starts, the real controller binds, and a re-run executes the cell + * 4. the server starts, the connection is updated in place, and that same run executes the cell * * The workspace's active interpreter is a bare venv this test creates, so the install path runs on * every execution rather than only on a machine that happens to be missing the package. The cell @@ -26,8 +27,8 @@ import * as path from 'path'; import { EditorView, VSBrowser, WebView, Workbench } from 'vscode-extension-tester'; import { - FIRST_RUN_OUTPUT_TIMEOUT, KERNEL_CONNECT_TIMEOUT, + OUTPUT_POLL_INTERVAL, QUICK_PICK_TIMEOUT, SUITE_TIMEOUT, WORKBENCH_TIMEOUT, @@ -38,7 +39,7 @@ import { dismissAllNotifications, openFolderViaDialog, openWorkspaceFile, - runOnceAndAwaitOutput, + readRenderedOutput, tryOpenInputBox, waitForNotification } from '../helpers'; @@ -127,8 +128,6 @@ describe('Deepnote E2E — consent, then install into the active interpreter', f 'Deepnote notebook editor did not open' ); - await shot('notebook-open'); - // The load-bearing half of "detect on kernel start, not notebook open". Watching the install // toast rather than only the venv is what makes this catch a regression: an open-time install // announces itself within seconds, long before the package would actually land on disk. @@ -138,6 +137,10 @@ describe('Deepnote E2E — consent, then install into the active interpreter', f false ); + // Taken after that window so the notebook has rendered: it shows the kernel already named + // after the interpreter while nothing has been installed and no server is running. + await shot('notebook-open'); + expect(installStartedOnOpen, 'opening the notebook must not start an install').to.equal(undefined); expect(isToolkitInstalled(interpreter)).to.equal( false, @@ -161,14 +164,19 @@ describe('Deepnote E2E — consent, then install into the active interpreter', f 'deepnote-toolkit was never installed into the active interpreter' ); - // The first run only sets the kernel up; the cells themselves run on the re-run. - await waitForNotification(/Run the cells again/i, KERNEL_CONNECT_TIMEOUT, true); - await shot('kernel-ready'); + // No second click: consent updates the existing controller's connection in place, so the run + // that triggered the prompt is the run that executes. + let renderedOutput = ''; - const renderedOutput = await runOnceAndAwaitOutput( - NOTEBOOK_FILE_NAME, - EXPECTED_OUTPUT, - FIRST_RUN_OUTPUT_TIMEOUT + await driver.wait( + async () => { + renderedOutput = await readRenderedOutput(); + + return renderedOutput.includes(EXPECTED_OUTPUT); + }, + KERNEL_CONNECT_TIMEOUT, + `the run that triggered the prompt never rendered "${EXPECTED_OUTPUT}"`, + OUTPUT_POLL_INTERVAL ); await shot('cell-output'); From 602251e907ef6aab67adc1aa85e551eac85c6699 Mon Sep 17 00:00:00 2001 From: tomas Date: Tue, 25 Aug 2026 21:07:00 +0000 Subject: [PATCH 09/12] fix(lint): keep the toolkit version in the platform layer, clear spell check Lint: pipInstaller is platform code and imported DEEPNOTE_TOOLKIT_VERSION from kernels/deepnote/types, tripping import/no-restricted-paths -- the CI failure this PR has carried since it started pinning the pip package. Move the constant to platform/common/constants, where the installer can reach it without crossing the boundary, and point the two kernels-side consumers at it. Spell check, two words: - "fspath" appeared only in an eslint-disable for local-rules/dont-use-fspath in deriveEnvPath. Rather than add it to the dictionary, use the helper that rule points at -- getFilePath -- which drops the suppression along with the word. - "kernelspecs" was prose in a doc comment, so it is reworded to "the kernels it offers". Verified: lint 0 errors, cspell 0 issues, 2771 unit tests passing, E2E green -- the run asserts sys.prefix matches the venv, which exercises the getFilePath swap in deriveEnvPath. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019cqzx43mLQRvVfWbfEUUSx --- src/kernels/deepnote/deepnoteServerStarter.node.ts | 4 ++-- src/kernels/deepnote/deepnoteSharedToolkitInstaller.node.ts | 2 +- src/kernels/deepnote/deepnoteToolkitDependencyService.node.ts | 2 +- src/kernels/deepnote/deepnoteToolkitInstaller.node.ts | 3 ++- src/kernels/deepnote/types.ts | 1 - src/platform/common/constants.ts | 4 ++++ src/platform/interpreter/installer/pipInstaller.node.ts | 2 +- 7 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/kernels/deepnote/deepnoteServerStarter.node.ts b/src/kernels/deepnote/deepnoteServerStarter.node.ts index 1509ffae88..6d9c549f37 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.node.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.node.ts @@ -21,6 +21,7 @@ import { sleep } from '../../platform/common/utils/async'; import { generateUuid } from '../../platform/common/uuid'; import { DeepnoteServerStartupError } from '../../platform/errors/deepnoteKernelErrors'; import { getCachedEnvironment } from '../../platform/interpreter/helpers'; +import { getFilePath } from '../../platform/common/platform/fs-paths.node'; import { logger } from '../../platform/logging'; import { IUserpodApiEndpoints } from '../../platform/notebooks/deepnote/types'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; @@ -298,8 +299,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension */ private deriveEnvPath(interpreter: PythonEnvironment): string { const cachedEnv = getCachedEnvironment(interpreter); - // eslint-disable-next-line local-rules/dont-use-fspath - const folderPath = cachedEnv?.environment?.folderUri?.fsPath; + const folderPath = getFilePath(cachedEnv?.environment?.folderUri); if (folderPath) { return folderPath; diff --git a/src/kernels/deepnote/deepnoteSharedToolkitInstaller.node.ts b/src/kernels/deepnote/deepnoteSharedToolkitInstaller.node.ts index 54f37c3dcc..1bc0b959d5 100644 --- a/src/kernels/deepnote/deepnoteSharedToolkitInstaller.node.ts +++ b/src/kernels/deepnote/deepnoteSharedToolkitInstaller.node.ts @@ -10,7 +10,7 @@ import { IOutputChannel, IExtensionContext } from '../../platform/common/types'; import { STANDARD_OUTPUT_CHANNEL } from '../../platform/common/constants'; import { IFileSystem } from '../../platform/common/platform/types'; import { Cancellation } from '../../platform/common/cancellation'; -import { DEEPNOTE_TOOLKIT_VERSION } from './types'; +import { DEEPNOTE_TOOLKIT_VERSION } from '../../platform/common/constants'; /** * Manages a shared installation of deepnote-toolkit in a versioned extension directory. diff --git a/src/kernels/deepnote/deepnoteToolkitDependencyService.node.ts b/src/kernels/deepnote/deepnoteToolkitDependencyService.node.ts index 07cce6c341..11c3985475 100644 --- a/src/kernels/deepnote/deepnoteToolkitDependencyService.node.ts +++ b/src/kernels/deepnote/deepnoteToolkitDependencyService.node.ts @@ -19,7 +19,7 @@ const SELECT_INTERPRETER_COMMAND = 'python.setInterpreter'; * * It cannot reuse that service directly: `installMissingDependencies` is keyed on a * `KernelConnectionMetadata`, and a Deepnote connection cannot exist until the toolkit server is - * running and has reported its kernelspecs — which is precisely what this check gates. + * running and has reported the kernels it offers — which is precisely what this check gates. */ @injectable() export class DeepnoteToolkitDependencyService implements IDeepnoteToolkitDependencyService { diff --git a/src/kernels/deepnote/deepnoteToolkitInstaller.node.ts b/src/kernels/deepnote/deepnoteToolkitInstaller.node.ts index abaa15114a..b77cbd5bcf 100644 --- a/src/kernels/deepnote/deepnoteToolkitInstaller.node.ts +++ b/src/kernels/deepnote/deepnoteToolkitInstaller.node.ts @@ -18,7 +18,8 @@ import { } from '../../platform/errors/deepnoteKernelErrors'; import { logger } from '../../platform/logging'; import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; -import { DEEPNOTE_TOOLKIT_VERSION, IDeepnoteToolkitInstaller, VenvAndToolkitInstallation } from './types'; +import { IDeepnoteToolkitInstaller, VenvAndToolkitInstallation } from './types'; +import { DEEPNOTE_TOOLKIT_VERSION } from '../../platform/common/constants'; /** * Handles installation of the deepnote-toolkit Python package. diff --git a/src/kernels/deepnote/types.ts b/src/kernels/deepnote/types.ts index e98b268775..d605de52ae 100644 --- a/src/kernels/deepnote/types.ts +++ b/src/kernels/deepnote/types.ts @@ -427,6 +427,5 @@ export interface IDeepnoteLspClientManager { stopAllClients(token?: vscode.CancellationToken): Promise; } -export const DEEPNOTE_TOOLKIT_VERSION = '2.1.1'; export const DEEPNOTE_DEFAULT_PORT = 8888; export const DEEPNOTE_NOTEBOOK_TYPE = 'deepnote'; diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index f840ff1aa5..9b55ab108e 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -22,6 +22,10 @@ export const NOTEBOOK_SELECTOR = [ export const CodespaceExtensionId = 'GitHub.codespaces'; export const JVSC_EXTENSION_ID = 'Deepnote.vscode-deepnote'; + +// Lives here rather than with the Deepnote kernel types so the pip installer, which is platform +// code, can pin the package without importing across the layer boundary. +export const DEEPNOTE_TOOLKIT_VERSION = '2.1.1'; export const DATA_WRANGLER_EXTENSION_ID = 'ms-toolsai.datawrangler'; export const PROPOSED_API_ALLOWED_PUBLISHERS = ['donjayamanne']; export const POWER_TOYS_EXTENSION_ID = 'ms-toolsai.vscode-jupyter-powertoys'; diff --git a/src/platform/interpreter/installer/pipInstaller.node.ts b/src/platform/interpreter/installer/pipInstaller.node.ts index 6548baf90e..39f4e01a35 100644 --- a/src/platform/interpreter/installer/pipInstaller.node.ts +++ b/src/platform/interpreter/installer/pipInstaller.node.ts @@ -15,7 +15,7 @@ import { Environment } from '@vscode/python-extension'; import { getEnvironmentType } from '../helpers'; import { workspace } from 'vscode'; -import { DEEPNOTE_TOOLKIT_VERSION } from '../../../kernels/deepnote/types'; +import { DEEPNOTE_TOOLKIT_VERSION } from '../../common/constants'; /** * Installer for pip. Default installer for most everything. From 6469bc80c118fc66e7bf6a9bc8f42d70803e8c5e Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 27 Aug 2026 11:06:23 +0000 Subject: [PATCH 10/12] test(e2e): drive kernels from the active interpreter, drop the environment suites This PR selects the kernel from the workspace's active Python interpreter, so the Deepnote-environment path no longer decides which kernel a notebook gets: selectEnvironmentForNotebook stores the mapping and then rebuilds the controller from getActiveInterpreter(), ignoring the environment's own venv. The suites that leaned on createEnvironment/selectEnvironmentForNotebook were therefore setting up something the kernel does not read, and would have stopped on the install-consent modal in CI, where the active interpreter has no toolkit. Instead of teaching each suite to provision an interpreter, writeGeneratedSettings now pins python.defaultInterpreterPath to the pre-baked .venv-e2e alongside the existing python.venvPath. That one line gives every temp workspace an interpreter that already carries deepnote-toolkit, so opening a notebook registers a controller and the first Run All goes straight through server start to execution. The setting is machine-overridable, so suite/interpreter/ still gets its toolkit-free venv from its own workspace .vscode/settings.json. The rest is deletion: the two environment suites go (both assert on environment mechanics -- sidecar migration and delete-stops-server -- with no interpreter equivalent), the `environments` matrix group with them, and the now-callerless deepnoteEnvironment.ts helper plus the seven constants only it used. Five suites lose their environment setup calls and nothing else. Verified locally, all five shards against a freshly packaged VSIX: agent 8, execution 4, files 25 (+1 pending), interpreter 1, workspace 23 = 61 passing, 0 failing Also green: typecheck, compile-tsc, compile-e2e, lint, spell-check, and 2780 unit tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019cqzx43mLQRvVfWbfEUUSx --- .github/workflows/e2e.yml | 2 +- CONTRIBUTING.md | 18 +- test/e2e/helpers/constants.ts | 31 +- test/e2e/helpers/deepnoteEnvironment.ts | 221 ---------- test/e2e/helpers/index.ts | 1 - test/e2e/helpers/notebook.ts | 11 +- test/e2e/helpers/venv.ts | 26 +- test/e2e/suite/agent/agentBlock.e2e.test.ts | 18 +- .../environments/environment.e2e.test.ts | 417 ------------------ .../suite/execution/helloWorld.e2e.test.ts | 26 +- .../execution/initNotebookRunner.e2e.test.ts | 11 +- .../integrationsEnvFileInjection.e2e.test.ts | 8 +- .../e2e/suite/workspace/snapshots.e2e.test.ts | 6 - 13 files changed, 59 insertions(+), 737 deletions(-) delete mode 100644 test/e2e/helpers/deepnoteEnvironment.ts delete mode 100644 test/e2e/suite/environments/environment.e2e.test.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7f8f15ee3a..ef853615ef 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - group: [agent, environments, execution, files, interpreter, workspace] + group: [agent, execution, files, interpreter, workspace] env: # Keep ExTester's downloads (test VS Code, ChromeDriver, settings, screenshots) inside the # workspace so the artifact-upload paths are predictable. Both this and .test-extensions are diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e399753731..44578447f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -301,10 +301,11 @@ running inside the Extension Development Host. 1. Run the one-time setup. It fetches the test VS Code build, chromedriver, the Python extension and the mock LLM server, then bakes the `deepnote-toolkit` venv into `.venv-e2e` and writes - `test/e2e/settings.generated.json`, without which `test:e2e` fails at launch. That file carries - `python.venvPath`, which is machine-scoped and so only takes effect from user settings — a - workspace `.vscode/settings.json` is silently ignored. `npm run setup:e2e:venv` regenerates it - without re-downloading VS Code. + `test/e2e/settings.generated.json`, without which `test:e2e` fails at launch. That file pins + `python.defaultInterpreterPath` to the baked venv, which is how every suite gets an interpreter + that already carries the toolkit. Both settings it writes only take effect from user settings, + which is where extest puts this file. `npm run setup:e2e:venv` regenerates it without + re-downloading VS Code. 1. Compile the E2E sources — like the unit tests, they run from compiled JS in `out/`. 1. Run the suites. @@ -326,9 +327,12 @@ On a headless machine, wrap the command in `xvfb-run --auto-servernum --server-a A few things worth knowing before you debug a failure: -- **The suites adopt `.venv-e2e` rather than provisioning their own venv**, which is most of the runtime. - If the Python extension does not offer it, the run still passes but takes several minutes longer and - logs `no interpreter under .venv-e2e was offered` — grep for that first when a run is unexpectedly slow. +- **The suites run against `.venv-e2e` rather than provisioning their own venv**, which is most of the + runtime. When it is missing the pinned interpreter does not exist, so the first run stops on the + install-consent modal and the suite times out there — re-run `npm run setup:e2e:venv` first. +- **A suite that needs a different interpreter** writes its own workspace `.vscode/settings.json`; + `python.defaultInterpreterPath` is `machine-overridable`, so the workspace value wins over the pin. + `suite/interpreter/` does this to get an interpreter without the toolkit. - **Screenshots of failures** are written to `/screenshots/` and uploaded as CI artifacts. - **CI shards by directory**, one job per group, so a new group directory must also be added to the matrix in `.github/workflows/e2e.yml` — the `verify-coverage` job fails the build if a group ran nowhere, or if diff --git a/test/e2e/helpers/constants.ts b/test/e2e/helpers/constants.ts index ddf9bc852c..e36e88690d 100644 --- a/test/e2e/helpers/constants.ts +++ b/test/e2e/helpers/constants.ts @@ -3,7 +3,6 @@ export const WORKBENCH_TIMEOUT = 60_000; export const QUICK_PICK_TIMEOUT = 30_000; -export const ENV_CREATED_TIMEOUT = 120_000; export const KERNEL_CONNECT_TIMEOUT = 300_000; // Mocha per-test timeout applied to the whole suite (overrides the .mocharc default). Stays just @@ -11,10 +10,10 @@ export const KERNEL_CONNECT_TIMEOUT = 300_000; // Deepnote toolkit provisioning). export const SUITE_TIMEOUT = 1_320_000; // 22 min -// A single "Run All" against an already-selected kernel must render output within this window. It -// sits well above a healthy first run (the kernel is bound before the click — see -// selectEnvironmentForNotebook) and below the multi-minute stall a dropped first run would cause, -// so the kernel-binding regression fails here instead of being masked by re-runs. +// A single "Run All" must render output within this window. It sits well above a healthy first run +// (the interpreter already carries the toolkit, so nothing is provisioned) and below the multi-minute +// stall a dropped first run would cause, so the kernel-binding regression fails here instead of +// being masked by re-runs. export const FIRST_RUN_OUTPUT_TIMEOUT = 120_000; // How often to poll the output webview for the expected text. @@ -23,19 +22,6 @@ export const OUTPUT_POLL_INTERVAL = 1_500; // How long to wait for the notebook output iframe (`#active-frame`) to become switchable. export const OUTPUT_FRAME_SWITCH_TIMEOUT = 5_000; -export const INTERPRETER_RETRY_DELAY = 5_000; -export const MAX_CREATE_ATTEMPTS = 6; - -// How long to wait for the interpreter quick pick to open after issuing the create-environment -// command. When no interpreter has been discovered yet the command shows a "No Python interpreters -// found" notification and returns instead, so this wait elapses and the attempt is retried. -export const INTERPRETER_PROMPT_TIMEOUT = 5_000; - -// How long to wait for an optional input box (packages/description) to appear after confirming the -// environment name. When the name already exists the create command short-circuits with an "already -// exists" notification and opens no further inputs, so this wait elapses and the prompts are skipped. -export const OPTIONAL_PROMPT_TIMEOUT = 5_000; - // The in-window simple file/folder dialog needs a beat to resolve a typed path before it accepts. export const DIALOG_RESOLVE_DELAY = 1_500; // "Open Folder" navigates one level toward the typed path per OK, so we re-click OK up to @@ -51,11 +37,6 @@ export const EDITOR_ACTIVE_TIMEOUT = 15_000; // so reading them cannot accidentally match the cell's source in the editor. export const OUTPUT_SELECTOR = '.output_container, .output, .rendered-output'; -// Where the managed venv lives. The directory name is what identifies it in the interpreter quick -// pick; the bare '.venv' marker can only tell venv-shaped interpreters apart from the rest, since it -// is also a substring of '.venv-e2e'. +// Where the managed venv lives. Suites run against its interpreter, pinned in the generated user +// settings (helpers/venv.ts). export const PREBAKED_VENV_DIR_NAME = '.venv-e2e'; -export const ANY_VENV_MARKER = '.venv'; - -// Only elapses when the venv is missing, so it stays well under QUICK_PICK_TIMEOUT. -export const PREBAKED_VENV_FILTER_TIMEOUT = 10_000; diff --git a/test/e2e/helpers/deepnoteEnvironment.ts b/test/e2e/helpers/deepnoteEnvironment.ts deleted file mode 100644 index 6c39ed7d37..0000000000 --- a/test/e2e/helpers/deepnoteEnvironment.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { EditorView, InputBox, QuickPickItem, VSBrowser, Workbench } from 'vscode-extension-tester'; - -import { - ANY_VENV_MARKER, - ENV_CREATED_TIMEOUT, - INTERPRETER_PROMPT_TIMEOUT, - INTERPRETER_RETRY_DELAY, - KERNEL_CONNECT_TIMEOUT, - MAX_CREATE_ATTEMPTS, - OPTIONAL_PROMPT_TIMEOUT, - PREBAKED_VENV_DIR_NAME, - PREBAKED_VENV_FILTER_TIMEOUT, - QUICK_PICK_TIMEOUT -} from './constants'; -import { dismissAllNotifications, waitForNotification } from './notifications'; -import { tryOpenInputBox } from './quickInput'; - -// Command palette labels (category + title) the way `Workbench.executeCommand` matches them. -const CREATE_ENV_COMMAND = 'Deepnote: Create Environment'; -const SELECT_ENV_COMMAND = 'Deepnote: Select Environment for Notebook'; - -/** - * Reads a quick-pick row as the label to filter by plus the label-and-description text to match - * against: the Python extension puts the interpreter path in either field depending on the entry, - * so matching one alone misses venvs described in the other. - */ -async function readPick(pick: QuickPickItem): Promise<{ label: string; text: string }> { - const label = await pick.getLabel(); - - return { label, text: `${label} ${(await pick.getDescription()) ?? ''}` }; -} - -/** - * Picks the pre-baked venv, which the extension adopts instead of provisioning its own. - * - * `useExistingVenv: false` picks an interpreter outside it, so the extension creates and owns the - * venv — only the deletion suite needs that, since deleteEnvironment removes the directory for - * extension-managed environments only. - */ -async function selectInterpreter(interpreterPick: InputBox, useExistingVenv: boolean): Promise { - const driver = VSBrowser.instance.driver; - - if (useExistingVenv) { - await interpreterPick.setText(PREBAKED_VENV_DIR_NAME); - // Wait for the *top row* to be the baked venv, not merely for the list to be non-empty: - // VS Code applies the filter asynchronously, so the stale unfiltered list is briefly still - // there and confirming against it would pick an arbitrary interpreter. - const filtered = await driver - .wait(async () => { - const picks = await interpreterPick.getQuickPicks(); - if (picks.length === 0) { - return undefined; - } - const first = await readPick(picks[0]); - - return first.text.includes(PREBAKED_VENV_DIR_NAME) ? picks[0] : undefined; - }, PREBAKED_VENV_FILTER_TIMEOUT) - .catch(() => undefined); - - if (filtered) { - await interpreterPick.confirm(); - - return; - } - - console.warn( - `[deepnote-e2e] no interpreter under ${PREBAKED_VENV_DIR_NAME} was offered; falling back to ` + - 'the first entry. The run will provision a venv and take several minutes longer — ' + - 'check that `npm run setup:e2e:venv` ran.' - ); - await interpreterPick.setText(''); - } - - const picks = await interpreterPick.getQuickPicks(); - const entries = await Promise.all(picks.map(readPick)); - // Any venv, not just the baked one: the extension adopts whatever venv it is pointed at - // (managedVenv: false), leaving the deletion suite nothing to delete. In CI this resolves to the - // interpreter actions/setup-python installed. - const wanted = entries.find((entry) => !entry.text.includes(ANY_VENV_MARKER))?.label; - - if (wanted) { - // Filter to it and accept with Enter, the same way the baked-venv branch does, rather than - // clicking a row or walking the list: rows intercept positional clicks, and an arrow-key walk - // silently lands on the wrong entry whenever the list scrolls or reorders under it. - await interpreterPick.setText(wanted); - const narrowed = await driver - .wait(async () => { - const filtered = await interpreterPick.getQuickPicks(); - if (filtered.length === 0) { - return false; - } - - return !(await readPick(filtered[0])).text.includes(ANY_VENV_MARKER); - }, PREBAKED_VENV_FILTER_TIMEOUT) - .catch(() => false); - - if (narrowed) { - await interpreterPick.confirm(); - - return; - } - - await interpreterPick.setText(''); - } - - console.warn( - '[deepnote-e2e] no interpreter outside a venv could be filtered to; ' + - `accepting the first entry. Offered: ${JSON.stringify(entries.map((entry) => entry.text))}` - ); - await interpreterPick.confirm(); -} - -/** - * Drives `deepnote.environments.create`: pick interpreter -> name -> skip packages -> skip - * description. Retries while the Python extension is still discovering interpreters, and treats - * "already exists" as success so a retry reuses the environment instead of colliding with it. - * - * Each suite passes its own name, so no suite inherits an environment another one set up. - */ -export async function createEnvironment(name: string, options: { useExistingVenv?: boolean } = {}): Promise { - const driver = VSBrowser.instance.driver; - let lastError: unknown; - - for (let attempt = 1; attempt <= MAX_CREATE_ATTEMPTS; attempt++) { - await new Workbench().executeCommand(CREATE_ENV_COMMAND); - - // Either the interpreter quick pick opens, or (no interpreter discovered yet) the command - // shows a "No Python interpreters found" notification and returns. - const interpreterPick = await tryOpenInputBox(INTERPRETER_PROMPT_TIMEOUT); - if (!interpreterPick) { - await dismissAllNotifications(); - await driver.sleep(INTERPRETER_RETRY_DELAY); - lastError = new Error('interpreter quick pick did not appear (interpreter discovery not ready?)'); - continue; - } - - // Every step below drives a quick pick the workbench can tear down under us, so they share - // one recovery: escape whatever input is open and spend another attempt. Retrying is safe - // because the create command treats an existing name as success. - try { - await driver.wait( - async () => (await interpreterPick.getQuickPicks()).length > 0, - QUICK_PICK_TIMEOUT, - 'no Python interpreters were listed' - ); - - await selectInterpreter(interpreterPick, options.useExistingVenv !== false); - - const nameBox = await InputBox.create(); - await nameBox.setText(name); - await nameBox.confirm(); - - // On an existing name the create command short-circuits after the name prompt with an - // "already exists" notification and opens no further inputs, so only drive the optional - // prompts when the packages box actually appears. This keeps the documented idempotent - // retry path working: a leftover environment is reused rather than failing the test on a - // timed-out InputBox that never opens. - const packagesBox = await tryOpenInputBox(OPTIONAL_PROMPT_TIMEOUT); - if (packagesBox) { - // Packages (optional) — leave empty. - await packagesBox.confirm(); - - // Description (optional) — leave empty. - await (await InputBox.create()).confirm(); - } - - // Treat both the success toast and the "already exists" guard as success: a leftover - // environment from a previous/retried run is fine — it will be selected next. - await waitForNotification(/created successfully|already exists/i, ENV_CREATED_TIMEOUT, false); - - return; - } catch (error) { - await interpreterPick.cancel().catch((cancelError) => { - console.warn('[deepnote-e2e] cancel interpreter quick pick:', cancelError); - }); - await dismissAllNotifications(); - await driver.sleep(INTERPRETER_RETRY_DELAY); - lastError = error; - continue; - } - } - - throw new Error( - `Failed to create a Deepnote environment after ${MAX_CREATE_ATTEMPTS} attempts. ` + - `Ensure the Python extension is installed and an interpreter is discoverable. ` + - `Last error: ${String(lastError)}` - ); -} - -/** - * Drives `deepnote.environments.selectForNotebook`. Selecting the environment rebuilds and - * explicitly selects the notebook's kernel controller (provisioning the venv + toolkit), which is - * what "wait for the kernel to connect" means in this extension. - */ -export async function selectEnvironmentForNotebook(name: string, notebookFileName: string): Promise { - const driver = VSBrowser.instance.driver; - - // The command requires an active `deepnote` notebook — make sure it's focused. - await new EditorView().openEditor(notebookFileName); - - // Clear the "select an environment" prompt and any other toasts; they can overlap the quick pick - // and intercept clicks. - await dismissAllNotifications(); - - await new Workbench().executeCommand(SELECT_ENV_COMMAND); - - const environmentPick = await InputBox.create(QUICK_PICK_TIMEOUT); - // Filter to the environment by name and accept with Enter rather than clicking the row: the - // quick-pick row contains a description `

` that can intercept a positional click. - await environmentPick.setText(name); - await driver.wait( - async () => (await environmentPick.getQuickPicks()).length > 0, - QUICK_PICK_TIMEOUT, - 'environment quick pick was empty' - ); - await environmentPick.confirm(); - - // Best-effort wait for the "switched successfully" toast; the authoritative gate is the rendered - // output, so a missed (auto-dismissed) toast must not fail the test. - await waitForNotification(/switched successfully/i, KERNEL_CONNECT_TIMEOUT, false); -} diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index ea3d7b9fae..8fff268cc7 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -1,6 +1,5 @@ export * from './assertions'; export * from './constants'; -export * from './deepnoteEnvironment'; export * from './deepnoteTree'; export * from './fixtures'; export * from './mockOpenAiServer'; diff --git a/test/e2e/helpers/notebook.ts b/test/e2e/helpers/notebook.ts index 0b7a8d5ec1..643c1952fe 100644 --- a/test/e2e/helpers/notebook.ts +++ b/test/e2e/helpers/notebook.ts @@ -264,12 +264,11 @@ export async function assertMarkersStayAbsent( } /** - * Issues a SINGLE "Run All" after the kernel has been selected and polls the notebook output webview - * until the expected text renders. It deliberately does NOT re-issue "Run All" when output is - * missing: the kernel is already bound before we get here (`selectEnvironmentForNotebook` waits for - * the post-binding "switched successfully" toast), so a first run that renders nothing means the - * execution request was dropped — exactly the kernel-binding regression this suite must catch. - * Re-running until output eventually appeared would mask that bug. + * Issues a SINGLE "Run All" and polls the notebook output webview until the expected text renders. + * It deliberately does NOT re-issue "Run All" when output is missing: the controller is registered + * when the notebook opens and the interpreter already carries the toolkit, so a first run that + * renders nothing means the execution request was dropped — exactly the kernel-binding regression + * this suite must catch. Re-running until output eventually appeared would mask that bug. */ export async function runOnceAndAwaitOutput( notebookFileName: string, diff --git a/test/e2e/helpers/venv.ts b/test/e2e/helpers/venv.ts index 5577be2e07..fc6888ea2b 100644 --- a/test/e2e/helpers/venv.ts +++ b/test/e2e/helpers/venv.ts @@ -10,6 +10,7 @@ const VENV_DIR = path.join(REPO_ROOT, PREBAKED_VENV_DIR_NAME); const SETTINGS_SOURCE = path.join(REPO_ROOT, 'test', 'e2e', 'settings.json'); const SETTINGS_TARGET = path.join(REPO_ROOT, 'test', 'e2e', 'settings.generated.json'); +/** The interpreter inside the pre-baked venv. Suites run against it as the active interpreter. */ function venvPython(): string { return process.platform === 'win32' ? path.join(VENV_DIR, 'Scripts', 'python.exe') @@ -123,13 +124,20 @@ export function ensureManagedVenv(): string { } /** - * Writes the settings file extest hands VS Code: the committed base plus `python.venvPath`, which is - * what makes the Python extension discover the baked venv and offer it in the interpreter quick pick. - * Returns the path to pass to `extest -o`. + * Writes the settings file extest hands VS Code: the committed base plus the two interpreter + * settings. Returns the path to pass to `extest -o`. * - * Generated rather than committed because `venvPath` only takes an absolute path, known at run time. - * It has to land in *user* settings — the setting is `scope: machine`, so VS Code ignores it in a - * workspace `.vscode/settings.json` — and extest writes this file to the test instance's User dir. + * `python.defaultInterpreterPath` is what makes every temp workspace resolve to an interpreter that + * already has deepnote-toolkit, so opening a notebook registers a controller and running a cell goes + * straight to execution — no consent prompt, no provisioning. A suite that needs a different + * interpreter overrides it in its own workspace `.vscode/settings.json`; the setting is + * `scope: machine-overridable`, so the workspace value wins. + * + * `python.venvPath` makes the Python extension discover the venv as an environment rather than a + * bare path, which is what populates the `folderUri` the server starter reads. + * + * Generated rather than committed because both only take absolute paths, known at run time. They + * have to land in *user* settings, which is where extest writes this file. * * Deliberately not a `.venv` symlink inside each workspace: the extension names the kernel spec after * the venv directory and writes it INTO the venv, keeping the first one it finds @@ -139,7 +147,11 @@ export function ensureManagedVenv(): string { */ export function writeGeneratedSettings(): string { const base = JSON.parse(fs.readFileSync(SETTINGS_SOURCE, 'utf8')) as Record; - const settings = { ...base, 'python.venvPath': REPO_ROOT }; + const settings = { + ...base, + 'python.defaultInterpreterPath': venvPython(), + 'python.venvPath': REPO_ROOT + }; fs.writeFileSync(SETTINGS_TARGET, `${JSON.stringify(settings, undefined, 4)}\n`, 'utf8'); diff --git a/test/e2e/suite/agent/agentBlock.e2e.test.ts b/test/e2e/suite/agent/agentBlock.e2e.test.ts index 582a82b013..06eabc8371 100644 --- a/test/e2e/suite/agent/agentBlock.e2e.test.ts +++ b/test/e2e/suite/agent/agentBlock.e2e.test.ts @@ -26,14 +26,12 @@ import { confirmModalDialog, copyFixtureIntoDir, copyFixtureToTempDir, - createEnvironment, createScreenshotter, dismissAllNotifications, openFolderViaDialog, openWorkspaceFile, pointExtensionHostAtMockServer, readCellLayoutHeight, - selectEnvironmentForNotebook, startMockOpenAiServer, storeMockOpenAiApiKey } from '../../helpers'; @@ -49,7 +47,6 @@ const CODE_TOOL_NAME = 'add_code_block'; const MARKDOWN_TOOL_NAME = 'add_markdown_block'; // Leg 3 match: agentCellExecutionHandler add_markdown_block tool result. const MARKDOWN_BLOCK_ADDED_TEXT = 'Markdown block added.'; -const ENVIRONMENT_NAME = 'E2E Agent Env'; const AGENT_RUN_TIMEOUT = 60_000; const PYTHON_OUTPUT_MARKER = 'agent-generated-python-ran'; const GENERATED_PYTHON = `print("${PYTHON_OUTPUT_MARKER}")`; @@ -246,14 +243,11 @@ describe('Deepnote — running an agent block against a stand-in OpenAI API', fu await openFolderViaDialog(copy.tempDir); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - // createEnvironment needs an active deepnote notebook; which one does not matter, and each - // group below binds the kernel for the notebook it actually runs. await openOnly(AGENT_FILE); - await createEnvironment(ENVIRONMENT_NAME); await dismissAllNotifications(); await storeMockOpenAiApiKey(); - await screenshot('environment-created'); + await screenshot('workspace-open'); }); async function releaseMockServer(): Promise { @@ -295,12 +289,11 @@ describe('Deepnote — running an agent block against a stand-in OpenAI API', fu }); describe('one agent block on its own', function () { - // Opens and binds the notebook this group runs, so the group does not care what ran before - // it. Closing and reopening drops the block's generated cells, which is why it happens here - // once and never between the tests below. + // Opens the notebook this group runs, so the group does not care what ran before it. Closing + // and reopening drops the block's generated cells, which is why it happens here once and + // never between the tests below. before(async function () { await openOnly(AGENT_FILE); - await selectEnvironmentForNotebook(ENVIRONMENT_NAME, AGENT_FILE); await dismissAllNotifications(); }); @@ -624,7 +617,6 @@ describe('Deepnote — running an agent block against a stand-in OpenAI API', fu ]); await openOnly(BATCH_FILE); - await selectEnvironmentForNotebook(ENVIRONMENT_NAME, BATCH_FILE); await dismissAllNotifications(); await clickRunAll(BATCH_FILE); @@ -674,7 +666,6 @@ describe('Deepnote — running an agent block against a stand-in OpenAI API', fu ]); await openOnly(STOP_FILE); - await selectEnvironmentForNotebook(ENVIRONMENT_NAME, STOP_FILE); await dismissAllNotifications(); await clickRunAll(STOP_FILE); @@ -720,7 +711,6 @@ describe('Deepnote — running an agent block against a stand-in OpenAI API', fu describe('re-running an agent block that already carries a transcript', function () { before(async function () { await openOnly(HEIGHT_FILE); - await selectEnvironmentForNotebook(ENVIRONMENT_NAME, HEIGHT_FILE); await dismissAllNotifications(); }); diff --git a/test/e2e/suite/environments/environment.e2e.test.ts b/test/e2e/suite/environments/environment.e2e.test.ts deleted file mode 100644 index 34274020c2..0000000000 --- a/test/e2e/suite/environments/environment.e2e.test.ts +++ /dev/null @@ -1,417 +0,0 @@ -/** - * E2E (ExTester): splitting a multi-notebook file migrates its selected environment onto every child. - * Signal: the `.vscode/deepnote.json` sidecar, deleted post-split so a child regenerates it from the migration. - */ -import { expect } from 'chai'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { - By, - EditorView, - InputBox, - SideBarView, - VSBrowser, - WebView, - Workbench, - type ViewItem -} from 'vscode-extension-tester'; - -import { - FIRST_RUN_OUTPUT_TIMEOUT, - KERNEL_CONNECT_TIMEOUT, - QUICK_PICK_TIMEOUT, - SUITE_TIMEOUT, - WORKBENCH_TIMEOUT, - assertNotNull, - confirmModalDialog, - copyFixtureToTempDir, - createEnvironment, - createScreenshotter, - openActivityBarView, - openFolderViaDialog, - openWorkspaceFile, - runOnceAndAwaitOutput, - selectDeepnoteContextMenu, - selectEnvironmentForNotebook, - waitForNotification -} from '../../helpers'; - -const FIXTURE = 'sales-analytics.deepnote'; -const CHILD = 'sales-analytics-overview.deepnote'; -const PROJECT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; -const ENV_NAME = 'E2E Split Migration Env'; -const SPLIT_PROMPT = /multiple notebooks/i; -const SPLIT_ACTION = 'Split into separate files'; -const SPLIT_DONE = /Split into \d+ files\./i; - -const SIDECAR_REGEN_TIMEOUT = 30_000; -const SIDECAR_POLL_INTERVAL = 1_000; - -const INLINE_PROMPT_TIMEOUT = 15_000; -const RELOAD_SETTLE = 3_000; - -/** - * Reloads the VS Code window and waits until it is interactive again. Waiting for the pre-reload - * `.monaco-workbench` to go stale avoids racing the UI against a window still tearing down. - */ -async function reloadWindow(): Promise { - const driver = VSBrowser.instance.driver; - const previousWorkbench = await driver.findElement(By.css('.monaco-workbench')); - - await new Workbench().executeCommand('Developer: Reload Window'); - - await driver - .wait( - async () => { - try { - await previousWorkbench.getTagName(); - - return false; - } catch { - // Stale element reference means the old workbench detached (reload started). - return true; - } - }, - WORKBENCH_TIMEOUT, - 'window did not begin reloading' - ) - .catch(() => undefined); - - await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await driver.sleep(RELOAD_SETTLE); -} - -describe('Deepnote — splitting a file migrates its selected environment onto every child', function () { - this.timeout(SUITE_TIMEOUT); - this.retries(0); // destructive (retires the original to .legacy); not idempotent - - let cleanupTempDir: (() => void) | undefined; - let sidecarEnvId: string | undefined; - - before(async function () { - const driver = VSBrowser.instance.driver; - const screenshot = createScreenshotter(this); - const copy = copyFixtureToTempDir(FIXTURE); - cleanupTempDir = copy.cleanup; - const tempDir = copy.tempDir; - - await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(tempDir); - await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - - await createEnvironment(ENV_NAME); - - await openWorkspaceFile(FIXTURE); - await driver.wait( - async () => (await new EditorView().getOpenEditorTitles()).some((t) => t.includes(FIXTURE)), - WORKBENCH_TIMEOUT, - `${FIXTURE} did not open` - ); - - // Select env WITHOUT dismissing the split prompt: replicate selectEnvironmentForNotebook minus - // its dismissAllNotifications() call, which would kill the still-open split toast. - await new EditorView().openEditor(FIXTURE); - await new Workbench().executeCommand('Deepnote: Select Environment for Notebook'); - const pick = await InputBox.create(QUICK_PICK_TIMEOUT); - await pick.setText(ENV_NAME); - await driver.wait(async () => (await pick.getQuickPicks()).length > 0, QUICK_PICK_TIMEOUT, 'env pick empty'); - await pick.confirm(); - await waitForNotification(/switched successfully/i, KERNEL_CONNECT_TIMEOUT, false); - await screenshot('env-selected'); - - // The kernel rebuild can let the toast collapse out of `getNotifications()` view; if gone, - // reload to clear the prompted-once guard so reopening re-raises the split prompt. - let prompt = await waitForNotification(SPLIT_PROMPT, INLINE_PROMPT_TIMEOUT, false); - if (!prompt) { - console.log('[G1] split prompt not visible inline after env-select; using reload path'); - await reloadWindow(); - await openWorkspaceFile(FIXTURE); - await driver.wait( - async () => (await new EditorView().getOpenEditorTitles()).some((t) => t.includes(FIXTURE)), - WORKBENCH_TIMEOUT, - `${FIXTURE} did not reopen after reload` - ); - prompt = await waitForNotification(SPLIT_PROMPT, WORKBENCH_TIMEOUT, true); - } - await screenshot('split-prompt'); - - // Re-find the toast on each attempt instead of reusing the reference captured before the - // screenshot above: the notification re-renders while it sits there, which stales it. Same - // locate-and-act-in-one-loop shape as clickRunAll. - assertNotNull(prompt, 'split prompt notification'); - await driver.wait( - async () => { - const current = await waitForNotification(SPLIT_PROMPT, INLINE_PROMPT_TIMEOUT, false); - if (!current) { - return false; - } - - try { - await current.takeAction(SPLIT_ACTION); - - return true; - } catch (error) { - console.warn('[deepnote-e2e] take split action (retrying):', error); - - return false; - } - }, - WORKBENCH_TIMEOUT, - `could not take the "${SPLIT_ACTION}" action on the split prompt` - ); - await waitForNotification(SPLIT_DONE, WORKBENCH_TIMEOUT, true); - await driver.sleep(2500); - await screenshot('split-done'); - - // Delete the sidecar so only a child's migrated mapping can rewrite it (proves migration, not - // a stale pre-split entry). - const sidecarPath = path.join(tempDir, '.vscode', 'deepnote.json'); - fs.rmSync(sidecarPath, { force: true }); - - await new EditorView().closeAllEditors().catch(() => undefined); - await openWorkspaceFile(CHILD); - await driver.wait( - async () => (await new EditorView().getOpenEditorTitles()).some((t) => t.includes(CHILD)), - WORKBENCH_TIMEOUT, - `${CHILD} did not open` - ); - - const deadline = Date.now() + SIDECAR_REGEN_TIMEOUT; - while (Date.now() < deadline) { - if (fs.existsSync(sidecarPath)) { - try { - const parsed = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); - const id = parsed?.mappings?.[PROJECT_ID]?.environmentId; - if (typeof id === 'string' && id.length > 0) { - sidecarEnvId = id; - break; - } - } catch { - /* mid-write */ - } - } - await driver.sleep(SIDECAR_POLL_INTERVAL); - } - - if (!sidecarEnvId) { - const vscodeDir = path.join(tempDir, '.vscode'); - const listing = fs.existsSync(vscodeDir) ? fs.readdirSync(vscodeDir) : '(.vscode missing)'; - console.log('[G1] .vscode listing after opening child:', JSON.stringify(listing)); - if (fs.existsSync(sidecarPath)) { - console.log('[G1] sidecar contents:', fs.readFileSync(sidecarPath, 'utf8')); - } - } - console.log('[G1] sidecarEnvId=', JSON.stringify(sidecarEnvId)); - await screenshot('sidecar-regenerated'); - }); - - after(async function () { - await new WebView().switchBack().catch(() => undefined); - await new EditorView().closeAllEditors().catch(() => undefined); - try { - cleanupTempDir?.(); - } catch (e) { - console.warn('[env-g1] cleanup:', e); - } - }); - - it('regenerates the env sidecar for the project from a split child (env migrated)', function () { - expect(sidecarEnvId, 'migrated env id in .vscode/deepnote.json').to.be.a('string').and.not.equal(''); - }); -}); - -// The server starter writes/deletes one PID lock file per running server under this dir. The test -// shares os.tmpdir() with the extension host, so reading the dir is the only cross-process stop signal. -const LOCK_DIR = path.join(os.tmpdir(), 'vscode-deepnote-locks'); - -const DELETE_ENV_NAME = 'E2E Delete Env'; -const G2_FIXTURE = 'marketing-overview.deepnote'; - -const PID_APPEAR_TIMEOUT = 15_000; -const CLOSE_SETTLE = 2_500; -const STOP_AFTER_DELETE_TIMEOUT = 30_000; - -/** PIDs of every currently-tracked running server (parsed from the lock dir). */ -function serverPids(): number[] { - if (!fs.existsSync(LOCK_DIR)) { - return []; - } - - return fs - .readdirSync(LOCK_DIR) - .map((file) => /^server-(\d+)\.json$/.exec(file)) - .filter((match): match is RegExpExecArray => match !== null) - .map((match) => Number(match[1])); -} - -/** - * Cross-platform liveness check via `process.kill(pid, 0)` (sends no signal): throws `ESRCH` when the - * process is gone, `EPERM` when it exists but is owned by another user — so `EPERM` counts as alive. - */ -function isAlive(pid: number): boolean { - try { - process.kill(pid, 0); - - return true; - } catch (error) { - return (error as NodeJS.ErrnoException)?.code === 'EPERM'; - } -} - -/** Opens the Deepnote view container and returns its "Environments" tree section. */ -async function getDeepnoteEnvironmentsSection() { - await openActivityBarView('Deepnote'); - await VSBrowser.instance.driver.sleep(1200); - - const content = new SideBarView().getContent(); - const named = await content.getSection('Environments').catch(() => undefined); - if (named) { - return named; - } - - // Fallback: the environments tree is the second Deepnote section (Explorer is the first). - const sections = await content.getSections(); - - return sections[1] ?? sections[0]; -} - -/** Finds an environment row in the Environments tree by its label (which is the environment name). */ -async function findEnvironmentItem( - section: Awaited>, - name: string -): Promise { - for (const item of await section.getVisibleItems().catch(() => [] as ViewItem[])) { - const label = await (item as unknown as { getLabel(): Promise }).getLabel().catch(() => ''); - if (label.trim() === name) { - return item; - } - } - - return undefined; -} - -describe('Deepnote — deleting an environment stops even a closed-but-running notebook’s server', function () { - this.timeout(SUITE_TIMEOUT); - this.retries(0); // destructive (deletes the venv); not idempotent - - let cleanupTempDir: (() => void) | undefined; - let serverPid: number | undefined; - let aliveWhileClosed = false; - let aliveAfterDelete = true; - let lockFileGoneAfterDelete = false; - - before(async function () { - const driver = VSBrowser.instance.driver; - const screenshot = createScreenshotter(this); - const copy = copyFixtureToTempDir(G2_FIXTURE); - cleanupTempDir = copy.cleanup; - - await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await openFolderViaDialog(copy.tempDir); - await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - - // Servers already running from earlier suites — exclude these when isolating THIS PID. - const pidsBefore = serverPids(); - - await createEnvironment(DELETE_ENV_NAME, { useExistingVenv: false }); - - await openWorkspaceFile(G2_FIXTURE); - await driver.wait( - async () => (await new EditorView().getOpenEditorTitles()).some((t) => t.includes(G2_FIXTURE)), - WORKBENCH_TIMEOUT, - `${G2_FIXTURE} did not open` - ); - - // Running the cell starts the server and writes its PID lock file. - await selectEnvironmentForNotebook(DELETE_ENV_NAME, G2_FIXTURE); - await runOnceAndAwaitOutput(G2_FIXTURE, 'overview', FIRST_RUN_OUTPUT_TIMEOUT); - - // Isolate this run's server PID (the lock file that appeared since we started). - const pidDeadline = Date.now() + PID_APPEAR_TIMEOUT; - while (Date.now() < pidDeadline) { - const fresh = serverPids().filter((pid) => !pidsBefore.includes(pid)); - if (fresh.length > 0) { - serverPid = fresh[0]; - break; - } - await driver.sleep(1000); - } - console.log( - '[G2] serverPid=', - serverPid, - 'lockDir=', - LOCK_DIR, - 'lockDirExists=', - fs.existsSync(LOCK_DIR), - 'pids=', - JSON.stringify(serverPids()) - ); - if (serverPid === undefined) { - const tmpEntries = fs.readdirSync(os.tmpdir()).filter((f) => f.includes('deepnote')); - console.log('[G2] no fresh PID; os.tmpdir() deepnote entries=', JSON.stringify(tmpEntries)); - } - await screenshot('server-running'); - - // Closing a tab does NOT stop the server, so it stays alive here — the state env-delete must clean up. - await new EditorView().closeAllEditors().catch(() => undefined); - await driver.sleep(CLOSE_SETTLE); - aliveWhileClosed = serverPid !== undefined && isAlive(serverPid); - console.log('[G2] aliveWhileClosed=', aliveWhileClosed); - - const section = await getDeepnoteEnvironmentsSection(); - const envItem = await driver.wait( - async () => findEnvironmentItem(section, DELETE_ENV_NAME), - WORKBENCH_TIMEOUT, - `environment row "${DELETE_ENV_NAME}" did not appear in the Environments view` - ); - await screenshot('env-row'); - - await selectDeepnoteContextMenu(envItem as ViewItem, 'Delete Environment'); - await screenshot('delete-menu'); - await confirmModalDialog('Delete', { messageIncludes: DELETE_ENV_NAME }); - - await waitForNotification(/Environment .*deleted/i, WORKBENCH_TIMEOUT, true); - await screenshot('env-deleted'); - - const stopDeadline = Date.now() + STOP_AFTER_DELETE_TIMEOUT; - while (Date.now() < stopDeadline) { - if (serverPid !== undefined && !isAlive(serverPid)) { - aliveAfterDelete = false; - break; - } - await driver.sleep(1000); - } - lockFileGoneAfterDelete = - serverPid !== undefined && !fs.existsSync(path.join(LOCK_DIR, `server-${serverPid}.json`)); - console.log( - '[G2] aliveAfterDelete=', - aliveAfterDelete, - 'lockFileGone=', - lockFileGoneAfterDelete, - 'pids=', - JSON.stringify(serverPids()) - ); - await screenshot('after-stop-check'); - }); - - after(async function () { - await new WebView().switchBack().catch(() => undefined); - await new EditorView().closeAllEditors().catch(() => undefined); - try { - cleanupTempDir?.(); - } catch (error) { - console.warn('[env-g2] cleanup:', error); - } - }); - - it('starts a server whose PID is tracked and survives closing the notebook tab', function () { - expect(serverPid, 'server PID from lock file').to.be.a('number'); - expect(aliveWhileClosed, 'server still running after the tab was closed').to.equal(true); - }); - - it('stops that closed notebook’s server when the environment is deleted', function () { - expect(aliveAfterDelete, 'closed notebook server should be stopped after env delete').to.equal(false); - expect(lockFileGoneAfterDelete, 'server lock file removed after env delete').to.equal(true); - }); -}); diff --git a/test/e2e/suite/execution/helloWorld.e2e.test.ts b/test/e2e/suite/execution/helloWorld.e2e.test.ts index 83b2ae1b26..920bcd3fdb 100644 --- a/test/e2e/suite/execution/helloWorld.e2e.test.ts +++ b/test/e2e/suite/execution/helloWorld.e2e.test.ts @@ -3,19 +3,18 @@ * * It exercises the full Deepnote happy path through the *real* VS Code UI: * 1. open a one-notebook `.deepnote` file containing `print("hello world")` - * 2. create a Deepnote environment (command `deepnote.environments.create`) - * 3. select that environment for the notebook (command `deepnote.environments.selectForNotebook`) - * — this builds and selects the notebook's kernel controller ("kernel connected") - * 4. run the cell (the notebook toolbar's "Run All" button) - * 5. assert the rendered stdout output contains "hello world" + * 2. run the cell (the notebook toolbar's "Run All" button) — the click is what starts the + * kernel, since the controller registered on open connects lazily on first execution + * 3. assert the rendered stdout output contains "hello world" * * The reusable interaction helpers live in `test/e2e/helpers/`; this file is only the suite wiring. * * Prerequisites: * - The Python extension (`ms-python.python`) must be installed in the test instance - * (`npm run setup:e2e:deps`) and at least one Python interpreter must be discoverable. - * - Creating the environment provisions a venv and the Deepnote toolkit, which needs network - * access; the first kernel start can take a few minutes. + * (`npm run setup:e2e:deps`). + * - The active interpreter must already provide deepnote-toolkit. `npm run setup:e2e:venv` bakes + * it into `.venv-e2e` and pins that interpreter for every workspace; without it the run stops on + * the install-consent prompt. */ import { expect } from 'chai'; @@ -26,11 +25,9 @@ import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, copyFixtureToTempDir, - createEnvironment, openFolderViaDialog, openWorkspaceFile, - runOnceAndAwaitOutput, - selectEnvironmentForNotebook + runOnceAndAwaitOutput } from '../../helpers'; const NOTEBOOK_FILE_NAME = 'hello-world.deepnote'; @@ -40,8 +37,6 @@ describe('Deepnote E2E — run "hello world"', function () { // Per-test timeout for the whole suite (overrides the mocharc default for these tests). this.timeout(SUITE_TIMEOUT); - const environmentName = 'E2E Hello Env'; - // Captured in `before` and invoked in `after` to remove the throwaway temp dir. let cleanupTempDir: (() => void) | undefined; @@ -90,10 +85,7 @@ describe('Deepnote E2E — run "hello world"', function () { } }); - it('creates an environment, connects the kernel, runs the cell and renders output', async function () { - await createEnvironment(environmentName); - await selectEnvironmentForNotebook(environmentName, NOTEBOOK_FILE_NAME); - + it('connects the kernel on first run and renders the cell output', async function () { const renderedOutput = await runOnceAndAwaitOutput(NOTEBOOK_FILE_NAME, EXPECTED_OUTPUT, FIRST_RUN_OUTPUT_TIMEOUT); expect(renderedOutput).to.contain(EXPECTED_OUTPUT); }); diff --git a/test/e2e/suite/execution/initNotebookRunner.e2e.test.ts b/test/e2e/suite/execution/initNotebookRunner.e2e.test.ts index e62628da5d..9fa0d3633c 100644 --- a/test/e2e/suite/execution/initNotebookRunner.e2e.test.ts +++ b/test/e2e/suite/execution/initNotebookRunner.e2e.test.ts @@ -14,14 +14,12 @@ import { clickRunAll, copyFixtureIntoDir, copyFixtureToTempDir, - createEnvironment, createScreenshotter, dismissAllNotifications, openFolderViaDialog, openWorkspaceFile, readRenderedOutput, - runOnceAndAwaitOutput, - selectEnvironmentForNotebook + runOnceAndAwaitOutput } from '../../helpers'; const MAIN_FILE = 'etl-pipeline-extract.deepnote'; @@ -93,7 +91,6 @@ async function confirmKernelPickerIfPresent(): Promise { describe('Deepnote — running the sibling init notebook in a main notebook kernel', function () { this.timeout(SUITE_TIMEOUT); - const environmentName = 'E2E Init Runner Env'; let cleanupTempDir: (() => void) | undefined; let screenshot: (label: string) => Promise; @@ -117,10 +114,7 @@ describe('Deepnote — running the sibling init notebook in a main notebook kern `${MAIN_FILE} did not open` ); - // Selecting the environment connects the kernel, which triggers the init run. - await createEnvironment(environmentName); - await selectEnvironmentForNotebook(environmentName, MAIN_FILE); - await screenshot('kernel-connected'); + await screenshot('notebook-open'); }); after(async function () { @@ -138,6 +132,7 @@ describe('Deepnote — running the sibling init notebook in a main notebook kern }); it('runs the init notebook on kernel start so its variable is defined in the main kernel', async function () { + // The first run is what starts the kernel, and the init notebook runs as part of that start. // The Extract cell prints INIT_MARKER, which only exists if the sibling init notebook ran. const output = await runOnceAndAwaitOutput(MAIN_FILE, INIT_MARKER, FIRST_RUN_OUTPUT_TIMEOUT); await screenshot('init-ran-output'); diff --git a/test/e2e/suite/execution/integrationsEnvFileInjection.e2e.test.ts b/test/e2e/suite/execution/integrationsEnvFileInjection.e2e.test.ts index 3b408cdc72..a90fd3fae1 100644 --- a/test/e2e/suite/execution/integrationsEnvFileInjection.e2e.test.ts +++ b/test/e2e/suite/execution/integrationsEnvFileInjection.e2e.test.ts @@ -13,11 +13,9 @@ import { SUITE_TIMEOUT, WORKBENCH_TIMEOUT, copyFixtureToTempDir, - createEnvironment, openFolderViaDialog, openWorkspaceFile, - runOnceAndAwaitOutput, - selectEnvironmentForNotebook + runOnceAndAwaitOutput } from '../../helpers'; const NOTEBOOK_FILE_NAME = 'integrations-env-file.deepnote'; @@ -49,7 +47,6 @@ const DOTENV_CONTENT = 'DEMO_DB_HOST=injected-host.example.com\n'; describe('Deepnote E2E — inject integration env var from `.deepnote.env.yaml`', function () { this.timeout(SUITE_TIMEOUT); - const environmentName = 'E2E Integrations Env'; let cleanupTempDir: (() => void) | undefined; // The temp workspace dir, so the live-refresh assertion can rewrite `.env`. @@ -98,9 +95,6 @@ describe('Deepnote E2E — inject integration env var from `.deepnote.env.yaml`' }); it('injects `PROD_POSTGRES_HOST` from `.env`, then live-refreshes it on a `.env` change without a restart', async function () { - await createEnvironment(environmentName); - await selectEnvironmentForNotebook(environmentName, NOTEBOOK_FILE_NAME); - const first = await runOnceAndAwaitOutput(NOTEBOOK_FILE_NAME, EXPECTED_OUTPUT, FIRST_RUN_OUTPUT_TIMEOUT); expect(first).to.contain(EXPECTED_OUTPUT); diff --git a/test/e2e/suite/workspace/snapshots.e2e.test.ts b/test/e2e/suite/workspace/snapshots.e2e.test.ts index ec2429f1ee..d63ac416c4 100644 --- a/test/e2e/suite/workspace/snapshots.e2e.test.ts +++ b/test/e2e/suite/workspace/snapshots.e2e.test.ts @@ -13,13 +13,11 @@ import { copyFixtureIntoDir, copyFixtureToTempDir, copySnapshotIntoDir, - createEnvironment, createScreenshotter, openFolderViaDialog, openWorkspaceFile, readRenderedOutput, runOnceAndAwaitOutput, - selectEnvironmentForNotebook, waitForNotification } from '../../helpers'; @@ -87,7 +85,6 @@ describe('Deepnote — a legacy project-scoped snapshot still loads its saved ou describe('Deepnote — new snapshots are notebook-scoped and do not bleed between siblings', function () { this.timeout(SUITE_TIMEOUT); - const ENV_NAME = 'E2E Snapshots Env'; const SIBLINGS = [ { file: 'marketing-overview.deepnote', output: 'overview', notebookId: 'e-nb-overview' }, { file: 'marketing-campaigns.deepnote', output: 'campaigns', notebookId: 'e-nb-campaigns' } @@ -110,8 +107,6 @@ describe('Deepnote — new snapshots are notebook-scoped and do not bleed betwee await openFolderViaDialog(tempDir); await VSBrowser.instance.waitForWorkbench(WORKBENCH_TIMEOUT); - await createEnvironment(ENV_NAME); - for (const sib of SIBLINGS) { // Keep exactly ONE editor open: "Run All" is located via `findElements(...)[0]` (first // toolbar in DOM order), so a lingering prior editor's button would be picked and hang the run. @@ -125,7 +120,6 @@ describe('Deepnote — new snapshots are notebook-scoped and do not bleed betwee WORKBENCH_TIMEOUT, `${sib.file} did not open` ); - await selectEnvironmentForNotebook(ENV_NAME, sib.file); await runOnceAndAwaitOutput(sib.file, sib.output, FIRST_RUN_OUTPUT_TIMEOUT); } From 22498b866d2a5d13671dc3214a6a267c71273230 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 27 Aug 2026 13:35:25 +0000 Subject: [PATCH 11/12] fix(snapshots): capture the environment from the active interpreter Snapshots had been recording nothing since this branch stopped routing kernel selection through Deepnote environments. EnvironmentCapture resolved what to record via the notebook->environment mapper, and that mapping is only ever written by the manual "Select Environment for Notebook" command or by the splitter migrating an existing one. On main the kernel path wrote it before starting the kernel, so it was always there by execution time; here nothing does, so captureEnvironment returned undefined on the first branch and the snapshot kept whatever `environment` the source file already had. It failed soft -- one log line, snapshot still written -- so every test stayed green while python version, platform and the package set silently stopped being recorded. Read it off the active interpreter instead, which is the interpreter the kernel actually ran in, so no mapping and no stored state is needed. Two things follow: - Packages come from ` -m pip freeze` rather than a `/bin/pip` path. The old form assumed a venv layout; the active interpreter can be conda, poetry or system, where that binary is not there. - python.environment reports getEnvironmentType() instead of a hardcoded 'venv', whose comment ("we manage the venv ... so this will always be a venv") stopped being true when the kernel moved to the user's own interpreter. The three shell-outs move from private to protected so a subclass can stand in for them; execFile is a module binding and cannot be stubbed under ESM, which is why captureEnvironment had no unit coverage at all before. Verified. The three new capture tests fail against the old behaviour (restored by hand) and pass now; 2785 unit tests green. End to end, against a repackaged VSIX, a snapshot written by the suite went from `environment: {}` to 219 packages with python 3.12.13 / linux-x64, with the 6 snapshot E2E tests passing either way -- they assert nothing about environment, which is why this got through in the first place. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019cqzx43mLQRvVfWbfEUUSx --- .../snapshots/environmentCapture.node.ts | 65 +++++------ .../snapshots/environmentCapture.unit.test.ts | 106 +++++++++++++++++- 2 files changed, 128 insertions(+), 43 deletions(-) diff --git a/src/notebooks/deepnote/snapshots/environmentCapture.node.ts b/src/notebooks/deepnote/snapshots/environmentCapture.node.ts index dcd3c923cb..ab3f96def0 100644 --- a/src/notebooks/deepnote/snapshots/environmentCapture.node.ts +++ b/src/notebooks/deepnote/snapshots/environmentCapture.node.ts @@ -8,12 +8,12 @@ import type { Environment } from '@deepnote/blocks'; import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; import { computeHash } from '../../../platform/common/crypto'; import { raceTimeout } from '../../../platform/common/utils/async'; +import { IInterpreterService } from '../../../platform/interpreter/contracts'; +import { getEnvironmentType } from '../../../platform/interpreter/helpers'; +import { EnvironmentType } from '../../../platform/pythonEnvironments/info'; import { logger } from '../../../platform/logging'; import { parsePipFreezeFile } from './pipFileParser'; -import { IDeepnoteEnvironmentManager, IDeepnoteNotebookEnvironmentMapper } from '../../../kernels/deepnote/types'; import { Uri } from 'vscode'; -import { DeepnoteEnvironment } from '../../../kernels/deepnote/environments/deepnoteEnvironment'; -import * as path from '../../../platform/vscode-path/path'; const captureTimeoutInMilliseconds = 5_000; @@ -31,36 +31,37 @@ export interface IEnvironmentCapture { type PythonEnvironmentType = 'uv' | 'conda' | 'venv' | 'poetry' | 'system'; +// The snapshot schema names a narrower set than the Python extension discovers; anything without a +// counterpart is reported as the plain interpreter it effectively is. +const ENVIRONMENT_TYPES: Partial> = { + [EnvironmentType.Conda]: 'conda', + [EnvironmentType.Poetry]: 'poetry', + [EnvironmentType.Venv]: 'venv', + [EnvironmentType.VirtualEnv]: 'venv', + [EnvironmentType.VirtualEnvWrapper]: 'venv' +}; + @injectable() export class EnvironmentCapture implements IEnvironmentCapture { - constructor( - @inject(IDeepnoteNotebookEnvironmentMapper) - private readonly environmentMapper: IDeepnoteNotebookEnvironmentMapper, - @inject(IDeepnoteEnvironmentManager) private readonly environmentManager: IDeepnoteEnvironmentManager - ) {} + constructor(@inject(IInterpreterService) private readonly interpreterService: IInterpreterService) {} async captureEnvironment(notebookUri: Uri): Promise { - const deepnoteEnvironment = this.getEnvironmentForNotebook(notebookUri); + const interpreter = await this.interpreterService.getActiveInterpreter(notebookUri); - if (!deepnoteEnvironment) { - logger.warn('[EnvironmentCapture] No Deepnote environment found for the given notebook'); + if (!interpreter) { + logger.warn('[EnvironmentCapture] No active Python interpreter for the given notebook'); return undefined; } - const interpreterPath = deepnoteEnvironment.pythonInterpreter.uri.fsPath; - const pipDir = os.platform() === 'win32' ? 'Scripts' : 'bin'; - const pipBinary = os.platform() === 'win32' ? 'pip.exe' : 'pip'; - const pipBinaryPath = path.resolve(deepnoteEnvironment.venvPath.fsPath, pipDir, pipBinary); - - logger.info(`[EnvironmentCapture] Capturing environment for interpreter ${interpreterPath}`); + logger.info(`[EnvironmentCapture] Capturing environment for interpreter ${interpreter.uri.fsPath}`); const platform = `${os.platform()}-${os.arch()}`; const [pythonVersion, pythonEnvironment, packages] = await Promise.all([ - this.determinePythonVersion(deepnoteEnvironment.pythonInterpreter), - this.determinePythonEnvironment(), - this.listPackageVersions(pipBinaryPath) + this.determinePythonVersion(interpreter), + this.determinePythonEnvironment(interpreter), + this.listPackageVersions(interpreter) ]); if (!pythonVersion) { @@ -107,13 +108,11 @@ export class EnvironmentCapture implements IEnvironmentCapture { return `sha256:${hash}`; } - private async determinePythonEnvironment(): Promise { - // We manage the venv for the Environment that we use to run the Deepnote Kernel so this will always be executed in a - // venv environment. Once we support other environment types, we can expand this logic. - return 'venv'; + protected determinePythonEnvironment(interpreter: PythonEnvironment): PythonEnvironmentType { + return ENVIRONMENT_TYPES[getEnvironmentType(interpreter)] ?? 'system'; } - private async determinePythonVersion(interpreter: PythonEnvironment): Promise { + protected async determinePythonVersion(interpreter: PythonEnvironment): Promise { const pythonVersionFromInterpreter = await this.determinePythonVersionFromRunningTheInterpreter(interpreter); if (pythonVersionFromInterpreter) { @@ -184,22 +183,14 @@ export class EnvironmentCapture implements IEnvironmentCapture { return raceTimeout(captureTimeoutInMilliseconds, undefined, getVersion()); } - private getEnvironmentForNotebook(notebookUri: Uri): DeepnoteEnvironment | undefined { - const environmentId = this.environmentMapper.getEnvironmentForNotebook(notebookUri); - - if (!environmentId) { - return undefined; - } - - return this.environmentManager.getEnvironment(environmentId); - } - - private async listPackageVersions(pipBinaryPath: string): Promise> { + // `-m pip` rather than a `/bin/pip` path: the active interpreter may be conda, system or + // poetry, where no such binary exists next to it. + protected async listPackageVersions(interpreter: PythonEnvironment): Promise> { const execFileAsync = promisify(execFile); const getPackages = async (): Promise> => { try { - const output = await execFileAsync(pipBinaryPath, ['freeze', '--local']); + const output = await execFileAsync(interpreter.uri.fsPath, ['-m', 'pip', 'freeze', '--local']); if (output.stderr) { logger.warn('pip freeze returned error output', { stderr: output.stderr }); diff --git a/src/notebooks/deepnote/snapshots/environmentCapture.unit.test.ts b/src/notebooks/deepnote/snapshots/environmentCapture.unit.test.ts index 4daa3d2cae..1c162017c1 100644 --- a/src/notebooks/deepnote/snapshots/environmentCapture.unit.test.ts +++ b/src/notebooks/deepnote/snapshots/environmentCapture.unit.test.ts @@ -1,6 +1,55 @@ import { assert } from 'chai'; - -import { parsePipFreeze } from './environmentCapture.node'; +import { instance, mock, when } from 'ts-mockito'; +import { Uri } from 'vscode'; + +import { IInterpreterService } from '../../../platform/interpreter/contracts'; +import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; +import { EnvironmentCapture, parsePipFreeze } from './environmentCapture.node'; + +const NOTEBOOK = Uri.file('/w/project.deepnote'); +const INTERPRETER: PythonEnvironment = { id: 'py-312', uri: Uri.file('/w/.venv/bin/python') }; + +/** + * Overrides the three members that shell out. `execFile` is imported as a module binding, which + * cannot be stubbed under ESM, so the seam has to be on the instance. + */ +interface Stub { + version?: string; + environment?: 'uv' | 'conda' | 'venv' | 'poetry' | 'system'; + packages?: Record; +} + +class StubbedCapture extends EnvironmentCapture { + public seenPackagesInterpreter: PythonEnvironment | undefined; + + constructor( + interpreterService: IInterpreterService, + private readonly stub: Stub + ) { + super(interpreterService); + } + + protected override async determinePythonVersion(): Promise { + return this.stub.version; + } + + protected override determinePythonEnvironment(): 'uv' | 'conda' | 'venv' | 'poetry' | 'system' { + return this.stub.environment ?? 'venv'; + } + + protected override async listPackageVersions(interpreter: PythonEnvironment): Promise> { + this.seenPackagesInterpreter = interpreter; + + return this.stub.packages ?? {}; + } +} + +function captureWith(interpreter: PythonEnvironment | undefined, stub: Stub): StubbedCapture { + const interpreterService = mock(); + when(interpreterService.getActiveInterpreter(NOTEBOOK)).thenResolve(interpreter); + + return new StubbedCapture(instance(interpreterService), stub); +} suite('EnvironmentCapture', () => { suite('parsePipFreeze', () => { @@ -125,8 +174,53 @@ package4==4.0.0.dev1+local`; }); }); - // Note: The captureEnvironment tests have been removed because the implementation - // uses node:child_process.execFile directly which cannot be stubbed in ES modules. - // Integration tests should be used to verify captureEnvironment behavior. - // The core parsing logic is tested via parsePipFreeze above. + suite('captureEnvironment', () => { + test('captures from the active interpreter, with no Deepnote environment involved', async () => { + const capture = captureWith(INTERPRETER, { + version: '3.12.13', + environment: 'venv', + packages: { numpy: '1.26.0', pandas: '2.1.0' } + }); + + const environment = await capture.captureEnvironment(NOTEBOOK); + + assert.deepStrictEqual(environment?.packages, { numpy: '1.26.0', pandas: '2.1.0' }); + assert.deepStrictEqual(environment?.python, { environment: 'venv', version: '3.12.13' }); + assert.strictEqual(capture.seenPackagesInterpreter, INTERPRETER); + }); + + test('reports the interpreter type it was given rather than assuming a venv', async () => { + const capture = captureWith(INTERPRETER, { version: '3.12.13', environment: 'conda' }); + + const environment = await capture.captureEnvironment(NOTEBOOK); + + assert.strictEqual(environment?.python?.environment, 'conda'); + }); + + test('returns undefined when the notebook has no active interpreter', async () => { + const capture = captureWith(undefined, { version: '3.12.13' }); + + assert.isUndefined(await capture.captureEnvironment(NOTEBOOK)); + }); + + test('returns undefined when the Python version cannot be determined', async () => { + const capture = captureWith(INTERPRETER, { version: undefined, packages: { numpy: '1.26.0' } }); + + assert.isUndefined(await capture.captureEnvironment(NOTEBOOK)); + }); + + test('hashes the package set, so a changed package produces a different hash', async () => { + const before = await captureWith(INTERPRETER, { + version: '3.12.13', + packages: { numpy: '1.26.0' } + }).captureEnvironment(NOTEBOOK); + const after = await captureWith(INTERPRETER, { + version: '3.12.13', + packages: { numpy: '1.26.1' } + }).captureEnvironment(NOTEBOOK); + + assert.match(before?.hash ?? '', /^sha256:/); + assert.notStrictEqual(before?.hash, after?.hash); + }); + }); }); From d5fcf944f9800e3538b3f912d4037616ab79df7a Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 27 Aug 2026 14:08:49 +0000 Subject: [PATCH 12/12] refactor!: remove Deepnote environments The kernel now comes from the workspace's active Python interpreter, so nothing in the execution path consulted a Deepnote environment any more. What remained was a parallel way to describe an interpreter that no longer decided anything: selectEnvironmentForNotebook stored a mapping and then rebuilt the controller from getActiveInterpreter(), ignoring the environment's own venv entirely. BREAKING CHANGE: the Environments view and its six deepnote.environments.* commands are gone, along with the `.vscode/deepnote.json` sidecar. Existing environments are left on disk untouched; nothing reads them. Removed: - src/kernels/deepnote/environments/ (manager, storage, tree view, sidecar writer, notebook mapper, activation service) - IDeepnoteEnvironmentManager and IDeepnoteNotebookEnvironmentMapper - clearControllerForEnvironment, whose only caller was the environments view - the Environments view, its 6 commands, 6 menu entries, the walkthrough step that pointed at the panel, and their nls strings The splitter's env-migration branch goes with it, which cost its rollback test its failure trigger -- the post-rename step it forced to fail was the env mapping removal. Repointed at the refresh callback, which is what still runs after the rename, so the "rolls back a rename it cannot complete" coverage is kept rather than dropped. The sidecar writer's stated purpose was exposing env mappings to external tools. Checked the CLI at /workspace/deepnote before deleting it: it never reads .vscode/deepnote.json, and never writes the snapshot `environment` block either -- the field is optional in the shared schema. Verified: typecheck, compile-tsc, compile-e2e, lint, spell-check all clean; 2680 unit tests passing (101 fewer, all of them environment tests). Full E2E against a repackaged VSIX -- agent 8, execution 4, files 25, interpreter 1, workspace 23 = 61 passing, 0 failing, unchanged from before the removal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019cqzx43mLQRvVfWbfEUUSx --- package.json | 82 -- package.nls.json | 15 +- resources/walkthroughs/environments.png | Bin 136468 -> 0 bytes .../environments/deepnoteEnvironment.ts | 97 -- .../deepnoteEnvironmentManager.node.ts | 298 ---- .../deepnoteEnvironmentManager.unit.test.ts | 500 ------- .../deepnoteEnvironmentStorage.node.ts | 124 -- .../deepnoteEnvironmentStorage.unit.test.ts | 224 --- ...eepnoteEnvironmentTreeDataProvider.node.ts | 155 -- ...teEnvironmentTreeDataProvider.unit.test.ts | 161 --- .../deepnoteEnvironmentTreeItem.node.ts | 126 -- .../deepnoteEnvironmentTreeItem.unit.test.ts | 177 --- .../deepnoteEnvironmentsActivationService.ts | 45 - ...EnvironmentsActivationService.unit.test.ts | 74 - .../deepnoteEnvironmentsView.node.ts | 631 -------- .../deepnoteEnvironmentsView.unit.test.ts | 1269 ----------------- .../deepnoteExtensionSidecarWriter.node.ts | 356 ----- ...eepnoteExtensionSidecarWriter.unit.test.ts | 519 ------- .../deepnoteNotebookEnvironmentMapper.node.ts | 110 -- src/kernels/deepnote/types.ts | 118 -- .../deepnote/deepnoteActivationService.ts | 7 +- .../deepnoteKernelAutoSelector.node.ts | 38 - ...epnoteKernelAutoSelector.node.unit.test.ts | 112 -- .../deepnote/deepnoteMultiNotebookSplitter.ts | 25 - ...deepnoteMultiNotebookSplitter.unit.test.ts | 122 +- src/notebooks/serviceRegistry.node.ts | 36 - 26 files changed, 14 insertions(+), 5407 deletions(-) delete mode 100644 resources/walkthroughs/environments.png delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironment.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironmentStorage.node.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironmentStorage.unit.test.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.node.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.unit.test.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.node.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.unit.test.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.unit.test.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts delete mode 100644 src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts diff --git a/package.json b/package.json index 94aa9f9413..dfdac2c199 100644 --- a/package.json +++ b/package.json @@ -103,41 +103,6 @@ "title": "%deepnote.commands.disableSnapshots.title%", "category": "Deepnote" }, - { - "command": "deepnote.environments.create", - "title": "%deepnote.commands.environments.create.title%", - "category": "Deepnote", - "icon": "$(add)" - }, - { - "command": "deepnote.environments.delete", - "title": "%deepnote.commands.environments.delete.title%", - "category": "Deepnote", - "icon": "$(trash)" - }, - { - "command": "deepnote.environments.managePackages", - "title": "%deepnote.commands.environments.managePackages.title%", - "category": "Deepnote", - "icon": "$(package)" - }, - { - "command": "deepnote.environments.editName", - "title": "%deepnote.commands.environments.editName.title%", - "category": "Deepnote" - }, - { - "command": "deepnote.environments.refresh", - "title": "%deepnote.commands.environments.refresh.title%", - "category": "Deepnote", - "icon": "$(refresh)" - }, - { - "command": "deepnote.environments.selectForNotebook", - "title": "%deepnote.commands.environments.selectForNotebook.title%", - "category": "Deepnote", - "icon": "$(server-environment)" - }, { "command": "deepnote.manageIntegrations", "title": "%deepnote.commands.manageIntegrations.title%", @@ -912,16 +877,6 @@ "command": "deepnote.refreshExplorer", "when": "view == deepnoteExplorer", "group": "navigation@3" - }, - { - "command": "deepnote.environments.create", - "when": "view == deepnoteEnvironments", - "group": "navigation@4" - }, - { - "command": "deepnote.environments.refresh", - "when": "view == deepnoteEnvironments", - "group": "navigation@5" } ], "editor/context": [ @@ -1022,11 +977,6 @@ "group": "navigation@-1", "when": "notebookType == 'deepnote'" }, - { - "command": "deepnote.environments.selectForNotebook", - "group": "navigation@0", - "when": "notebookType == 'deepnote'" - }, { "command": "deepnote.manageIntegrations", "group": "navigation@1", @@ -1601,21 +1551,6 @@ "when": "view == deepnoteExplorer && viewItem != loading", "group": "inline@2" }, - { - "command": "deepnote.environments.managePackages", - "when": "view == deepnoteEnvironments", - "group": "2_manage@1" - }, - { - "command": "deepnote.environments.editName", - "when": "view == deepnoteEnvironments", - "group": "2_manage@2" - }, - { - "command": "deepnote.environments.delete", - "when": "view == deepnoteEnvironments", - "group": "4_danger@1" - }, { "command": "deepnote.addNotebookToProject", "when": "view == deepnoteExplorer && viewItem == projectGroup", @@ -2406,11 +2341,6 @@ "dark": "./resources/dark/deepnote-icon.svg" } }, - { - "id": "deepnoteEnvironments", - "name": "%deepnote.views.environments.name%", - "when": "workspaceFolderCount != 0" - }, { "type": "webview", "id": "deepnoteViewVariables", @@ -2617,18 +2547,6 @@ "completionEvents": [ "onCommand:deepnote.manageIntegrations" ] - }, - { - "id": "deepnote.setupEnvironment", - "title": "%contributes.walkthroughs.deepnoteWelcome.steps.setupEnvironment.title%", - "description": "%contributes.walkthroughs.deepnoteWelcome.steps.setupEnvironment.description%", - "media": { - "image": "resources/walkthroughs/environments.png", - "altText": "%contributes.walkthroughs.deepnoteWelcome.steps.setupEnvironment.media.altText%" - }, - "completionEvents": [ - "onView:deepnoteEnvironments" - ] } ] } diff --git a/package.nls.json b/package.nls.json index 2cf9d50a05..6aa47f1046 100644 --- a/package.nls.json +++ b/package.nls.json @@ -289,17 +289,7 @@ "deepnote.commands.copyNotebookDetails.title": "Copy Active Deepnote Notebook Details", "deepnote.views.explorer.name": "Explorer", "deepnote.views.explorer.welcome": "No Deepnote notebooks found in this workspace.", - "deepnote.views.environments.name": "Environments", "deepnote.command.selectNotebook.title": "Select Notebook", - "deepnote.commands.environments.create.title": "Create Environment", - "deepnote.commands.environments.start.title": "Start Server", - "deepnote.commands.environments.stop.title": "Stop Server", - "deepnote.commands.environments.restart.title": "Restart Server", - "deepnote.commands.environments.delete.title": "Delete Environment", - "deepnote.commands.environments.managePackages.title": "Manage Packages", - "deepnote.commands.environments.editName.title": "Rename Environment", - "deepnote.commands.environments.refresh.title": "Refresh", - "deepnote.commands.environments.selectForNotebook.title": "Select Environment for Notebook", "contributes.walkthroughs.deepnoteWelcome.title": "Get Started with Deepnote", "contributes.walkthroughs.deepnoteWelcome.description": "Your first steps to set up and explore Deepnote notebooks in VS Code.", "contributes.walkthroughs.deepnoteWelcome.steps.exploreProjects.title": "Explore Your Projects", @@ -313,8 +303,5 @@ "contributes.walkthroughs.deepnoteWelcome.steps.notebookBlocks.media.altText": "A Deepnote notebook showing different block types", "contributes.walkthroughs.deepnoteWelcome.steps.connectDataSources.title": "Connect to Your Data Sources", "contributes.walkthroughs.deepnoteWelcome.steps.connectDataSources.description": "Set up integrations to connect your notebooks to databases, data warehouses, and other services. Query your data directly from SQL blocks.", - "contributes.walkthroughs.deepnoteWelcome.steps.connectDataSources.media.altText": "The Manage Integrations panel for connecting data sources", - "contributes.walkthroughs.deepnoteWelcome.steps.setupEnvironment.title": "Set Up a Python Environment", - "contributes.walkthroughs.deepnoteWelcome.steps.setupEnvironment.description": "Create and manage Python environments for your notebooks. Install packages, configure dependencies, and switch between environments.", - "contributes.walkthroughs.deepnoteWelcome.steps.setupEnvironment.media.altText": "The Deepnote Environments panel showing available environments" + "contributes.walkthroughs.deepnoteWelcome.steps.connectDataSources.media.altText": "The Manage Integrations panel for connecting data sources" } diff --git a/resources/walkthroughs/environments.png b/resources/walkthroughs/environments.png deleted file mode 100644 index f37bc38d714cc2406c6578ad7e3db379fccf537c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 136468 zcmXVXXHXN)*L8pZ2}ODr2oM5D4^>2ZN$7%7q)Uy`K?I}|dJ7316p#So52XttHA?S6 z6zLtJB2|jC*XKX)&g|~&{j@W?d+#~-o=rA0(Wj^3rU3u|^hkt`IRHQb0RYIYK~(=N zg~73Y|0b{>!X^*^pk?{LP6j9}=KNkqO5VBwPl%)@JSzboyKC><@Efsu3ee3bz|7X-?Gi#_@FOCeEH%E1R=OteS=k4kinvIt^;kyL48Qs1jll_v!s`S z)ovb2;h?zK3YG}l6%wXv&se=x7%rkybR(fc9|iQ4fs?Yx>zcm9x47tu_iF`FAp6@4fA?1{71=5uY7!Fi zZPklQM8FtHGo-#_MmD|6X~$88)m4)e%6_IjX6>#Quds2-=CmSPp*|VP-7riQxOY&u zVswR2-TWqVE1>|G>A{$wCVdU7RVfDS#Yg5n10Oq@W3me>kN*1K!$N_x)!(A<{S&k+ zePGaMgMJas*RPtsD-Y%;q7giLGJ2}KjX^ofQyQKXB%x^H zz^XKc*OcO+X)wlw#Ec5QKLj=qS6)vpJ*J`Y`$=jn-uWDWEI9@v39}x2Y*7gA7f;^7 zC2Iit;`Z~EqNun=V?)@6bkBQfgRbZPU9pCJzZXozW9iM9J!gGn4%TGji#UetBfWcs zfriL`*Z2EpC?ePmTLW2Z;Q`cOwDFNjK1i&G4CNrI3gRZb4TT`842h#H&&}UCq!Dwo z3=1kORzFL67Q&=m3E91b9M(|h>*`?1(6-y4v=e~XjiJvQ*DEb{-t_A~9^3Pwt|<_C z6=Ahrvs#h{*DZpYtQzcQ_vKMH*dB~(%qAmS*^4z`akK`#Pp9RQKEB|7TLcQ`g8WKS z@~_e;05Tnk%OxbHtw{fwAh$C z!3Q@!i;+~fD|FW?e|tqL9+nvOOLs73OAVbFbV3O|V)08j3{LB$F6Q4b57D|dF&}0~~Ae7oU9<(D> z!K+WHyh6D5Xh`I4r%jpuULB#2wmR6U&m&9T&O#(sQBZSgITZfXu&8A)alQ9RL3Zi! zfG_oXZ{*iL2}i?Eh^SxKn2>f?U{%iB5$f|U^wFba#2-m*PFo#-Qs0Ei@~4>POZI_& z{QjYU?o4}OfWpose^l{TAzB|Ilb~Tuc1_g7gcABb!D{{jK09IG5I6?SSl!c|-Y6kC zu-#AiTR~y7?+1ufa2Jrt0oXnzhaZK-efTXTh{=e)Pfm$qV*H`mnHMz6n8C3y3}`>j zxqka!HC4f|@W!UUDG`<9b!|nUmNKBdmqz%WC>kvhtugiAQ03?ata_fFNANo|iT?Iz z{QgUi!ulh-j%>|3lB}v-+o|jFleRzvyqkI@y)1#5PhaIL#;`0XJRu-BM(WksL(pHN zr9kEsDVhRhUC7~6*X3t*3%R1Kr z)^{He8wJgGZv~$z0G0z_oOd-2mj7lTW;2Ce(pXjAln@fL|EuVz_~*j5Kqgcm^bs%Y zdbv++kdkk!w|BSsp62{7YpU)8Dgg8wl&@#*(HktMm8kXgjWH<)+scwpm(wV#fWg2w z8ps}lf|TUesU_FWru5s_0v-xv-F=iI*^DnblwNbY6LVhd5%U=JI>&~MHfuf$OOzW> zE?_WDho2t(nUW*_nEdh!W;tHC+KiF=DE~z8P#quW)8Cf13m=t<&Ws!$c=) zIg^i$=($B&9En7EHVXz~dtE-gq012~iz{eI3FuJ1bQGY0&_W8EJm1DM>Uio7znw?E z9s18Aig=hM48f4%jNkbDeT92(^dWJ3MLdM0Ia*OXneO&j)r3Bym#PlRU1Sb&_mX1G z{ea|q?=+k2I7D~8)^MG^Va1-rL%3>qNy{WbJK`^VHhjRsxiGwaVJMP_N8K~NkyFw^ zc~_);68>j(?A21Gu{*dg?li5yIYTE}R%jo=&7omX?rN;teTu_(-WGzW>Pab-JeT7c zZ2%xP>;Y~nMp1rTiX$JL6}=o;P=6(%(hT5t`c-J^)lXCnZiTWh&6ttJgvzymo+g4r zxFUb9pq7}JzRK9y=nQ8la*)SzwhFmWbAOX3X+@ZqMcDf)jf*Ry#e05pK*5sq8cAqd zeH5DY&KM1p{<-i({EI#2=42F2Xj*8mz2~17hSe?+46DokF zN+nnu2(YwbE{^Il@7|!e2UDFz|9qAkDX3cEX{hm2T*04Fpk`vI){&b9X=-%*1O+fqUwtmVppkB;TOaj@YY6&h8bQeV z7`}H7hw!}WD7~@?(>O#}(u3)<<@0P1&Ai4j%m4kn$SXVneZ5Xg$oUM+wShf_CNl_4 z0BEJXSGMMW(mFWynMs40X*Gva#iaB(*-f;bZPiuEe^Y=sMvZug$ZTuKuMknlGDlJr z3iT+59zGuhm3~s{FPx6Gbn&y0{>c;D%#)XJZY+6@Pt8fB9^}sdDgMsEtEo$2#? zm|&z85HGm>11AZv(omFI8S&{paKzkhh;*O$f`i`unK@pSn-%1lUuC$?MdYODH_ER z47xPYH|Arm`eD`Vw2>_n z(#KLQ2Nh7b}K+)uLQWFcn2!6I4X?!BSy_CfJaNKb&ABBLE!X)NvPPaWGIE zQ@%d)71M;WE;gAAHx1)|0r#csWcFsUqXK1fs?<{)-3pbMSL2x_yk9hcBH#Mp2L((e z{Juk7h!fMV#cpNr0TXE!%7HvHZdc`@Wu*}%DVS{+;fW*W4N}zVA)~6mQxm~|&X}=@ zl;fwbO9U6cJ6*Z+`~3BErIioOpDcC|aN>axi;{lpe*y3H@bf(`<_5c!-!WcexJ6gT zzol5&%6RI$N3N;FPcj1Chk49;D{)SEeXBM3$Q`aa%gkMZL_2VQzlhP zf*q)DPFc@5Y=3eC;H*x|EZMjK`%S)`v__*B=1*p}leE{zyqs2>XM>#g?i{erJCI(; ze>98!BXUtcyi*G(mDlhKlGn=K`!w8SrlZSg_VvqCQ zKkHj!HYR&A?m92)jXjQ9IjgTN%W+x0#A%Z684rxH>^!NAXl%7(35cy7ECz}k)m@pw zAvUb+x%ZTsCUxj>FD6LhbonxCZ_yy%J=O#tODe(x2>@5-V2$Wh1mOlu2e3i$UIfA@ znM@jwoW>IkXzqnYT8Ow(#!GYL9bF)Nqd9benSV9HNU2})A6|!~d>AI@*GPM%Y0oQgM9;0xM2ihu3I8+KS%(SI>{LVj56Mnvm#n7Q{&dafy` z%loW&jvumPWy;3Xy^FF3vCFcKgenU>3_jF`li2%V+=?J&vN+f>!_-iJD*@Mt$%|+G z6P#4-nA<>m#=1W=LH=<(*L##eXVR@ zPsi#c{!UQ$y^a{svu%6nXXvmB4M%P=WE`kr{6JFT;dDGjU%D^b0cNjN&RC z_CnuULcd%c1`b28vMq1pJHAz5JJ+I|fuMLup15ECLwdsWXW{#0Qsk&HHXkt40bM4Ue`9DlU8e;*g0M@AI5cFVTf)-xE3awSii9POnIDBx9!75ZeZWYMx6uMF*T zzng~Das`Z3Te-@|`f;!>; zBhct#C`zL#xW{Gnv#R^GE7dTSVwsYlmD}SDrSOeaofxh7dk{Bzv(>gHJ^KG&z$S`nLfN1BP}L zSrn}vv+4NB6HlO11htNyFU)=c-oSN)=cLP$aeM!26a*PStO5mv)+c&Fo3$e!C|ch; z4-Mw$&kCWu?FhMGUwNm0Y#^4;5e=Efsy&)<qTf@wU^vx!T!BiX}==o z!!+B0u93m7AD%>AWb^RULeVkhb6b*RMB}Mzjd<^^!KZd#731q7^oVVYQdTA)p@?Kp zh55LVZ8v$;qYYNo%E2GI~*UvOV|U3dl79O-}pz@i{w; zlk0Qz1J$F?NdBHx1%HJ3J6KvOKLBnyNg}6PxOf@sSM=3F{9wJq zsP$N9_`9NjU}-_C9T;%F*>Ec^ND0^z)V@{oy5i)U8E97txW4}*;KO~#%FB^2Y$`bq z?rVlVu*hFXA|D99@i;7p|2|Grp399FmrS9-yYyOz8m`DaH$y|9KW?&sPSqVv0s1`i zMfks1$C%7Y-97o$FAlb+BqbaHz0_GjzMKv$61_Sch|G+&>`+NW)j+AhS;hD~j+Q@P zNqNn#rbY*A>PybXC&y~A1YO;VqwmI6xxGNCAmh)RJ+B&urg9@Z6Ch^|xO~$%^nGdB z%UgGGpslRW`c@~P@fBt^wH{0Y-PYKi1iA9<`{x4f%3jCu!;a4r>#6247qsqHB1`=G$Fgoas ze;oPPO3gI9W^MYG#Gx;zIG7ZH`fW8E@W?1L$c2N4Hkw1u`+56qf7_6}1Sk24UA>RJ zKoRHFA*C6!!)$0f-bZ7$2EP^@2B<4km@}K2WpVyQW9gu%Na%i8DB&$7dD4S{-0p!! z^ckJ66%AwTIh&r+d>%1nOdxR-n`eoSY=LWb;wvN9_}c*`%F zJJbh#-w{=+e9YswH;!GJ% zM4Jol4&1~iI0=~hzcP$x))-3Xy7tmy)!1|?C9y4fp_Xt_qH-Jg!K%V#+~$hI)nYQ` zw(hsj|D)ww)7-&~LWC`T(G6=NU&yUHX7kBRMV0w~<5zO)juKTZaZNpM&N}}+c(F_? z`>RP@x*hbOdI{LxL?5WV^{R%lsdxhlNsIm{+7lHv0S$%8gZ5tH;4*Fk$Pna0g?`*X zDGo1^R5VJCVj*N2x^4|;$Kazd?g!RSiNRaK0x?x3A~M3H$=V{Y{$3%9FpDkOhJ zPKI1!hS@*oIOca?zH0voj2O)KYANFzu+yYsSP9Dzx*0OR?)cIlUOKTW|Be~RkW&2Z zs=ChU;FCF?kX6FYqeC05=!D30tJu_FO=`njYmo*t0OqzNrU$Gbem|vV-J&NpmevL8 zT(r9&a72}V{`(}_*~UPNu>XIaJLpPhBKQ-Xd+5}Ej}fNYWw4STuyA69TE`9OJZMBD zB+F=hvGkJ-;0J*#U*?CBb;1;7u8Dwvo>s_+#E}Whp*kCDKKXvb;LOCj@p|z6{Vic| zu^2UitptKfUZ&1-uPnhc(PS#>`)TQyeaMh;j=b6F_jyPW^94D%r z2xe5P%BpD|$b;1}kJaV*GrIiAX-6wTrbb_(L$F@sppf0*tAi!wIflZhs>Dl!ijZ#9#?#mYFPdY)J#&kt|fj^3dySy|)n4%Z9 zM!wgP(AVdL)m2S4!)luuphh5_t8q06Zt^P)Jc#UDV?aBK99-2RDn;{)xfGB@33c7Uqd$|#MU3~PH$ zd|vLL$e!Y(GwJ1xM+JtYR|g6du+i`{u%3|H(RS0W_1#5acq8JQwlFO%)Toor(~j^! zbZ^YVrKKHf$aR92+2kHkUNy?sd6S1#%90)1@U-!dA~lR;=2jY!wVDapsEyofubj{S zow&sk!Xx{Xb~#5xQflR8b<~`+4Snc-SOoSMWnhMoVRCoEITbuX4Y%)G4 zZ7p=ffjpncJ!DujRUVDmooh!R;W$R2jw=S@$JZ8ZY9RAUAjMQ>jhW8D_+-$?IG3A z%kZCI)Mk|CD~@V>JBS<_%7^>*<$}REY2qW=D2cp;7Pz+WfyPl@aHQCNo!*RR6y%)s z$AM0Lu*_x-3eW!sq}0&2{|p%$t)s!K<2C5jYAbl2i$B^*FkDiH?}~oI#^~;g?RZh5 z7^s}$m)KoI;;PaiAl2ev3&XfRm8Y;LLvOxpDr`MK^95=E} z_$2Egr#Tmm_E9eecGhj4HbQuFSH#v$TE!h__ShqDrvoxjN-Nd+57mNn^2WL@fA@Ox zyS0u^OmE!(c)+P+SiA0>1{b+s=hv(cdR^ex{H`%gm;O-NUZT8+%NYsO2Y^!6UP;&1m`626 z{?GMl%cG`qn;*)p6k3W7&~2M^FfY@B%1CnZK?8!j^3eEbGj5c+0PBx+#g4T7sY*$r zv(|pyv%$3k5sO2~raG;@iTmO5Cj76uRhQ*f)Lbkjr1YL!{xt)T6UfO!=qP!GDbWQ% zd#eF~Qiof$BL?a>{M=kNAOEDn(*f~A!?r*=;^PZi>GlZW-`Q5jDsBfa=dvVYq!H#Z3VL8VT3EQ$;oD0dF=oL8fevL|cE|CLYcz>Y6}nK3R=><6W24IY{G zD7{YXv(4LDSL)3UyoT?Yd`u%zlwFNe-G#erdd}ylGT}tJyk3|I-F5*=y=|{9@*BYo zwwCUc+|Ig^KDE~6n=#Y*;DmW6(=KnA@p9A#Fr@{LaDz&VUB9NEs<3r;z+6kHXO=;~ux7B|E zBNZLh`MSDIbLiA0=(IIQd|LNV5EgL)aa0|7QUSt#SgJ|tGXTDQ9{9p4ZruUeP2NTM z2cKJJ<7{N(#5H{DhC+Eq3t!DBLVtP8ZP~%!U1uG$ys`bpp7fEiDYS}MR_bbiRWug0 ze-=xLm9pXED$A3+iRsfnXZFt5OQIC?TPG>Cv_Eg@h|A+3JswUUCYX=^y(2L6HOr48 z10g8OZMXmzT?wjtAIDi6Mf-5#WpuZt^#Fgx)O&8l-`w8VXbI!^I}pLi89bG>mNuF7 zLD__=kj1|@!DDh@2R9*`BOoTn(*0udYx@6i`ccHnm3Fu?Sef%}0qj108=6uZ=H^G3zq;w*4>PH*z}@9N>C86)vx_TIP($FG(th zZK|RlksD0LR($$!XHX zt@+_bRidm^3}A=q#JmfvLsfYG#2^ki08y){fV^ zf||&QaglY)Xe)-T{Nb=n#A{=|rzhlmDe6CsZ_bq}whsrWvDZnggQmo9^JWwX!g!H7 z#Hvp&RISKd!+g2R1n+?QNlKt>)!hfzh5LzEI{efL*KnC@THh&E;}Ue1gnb_J zt7W^-?j70zJ+YefQZ3#j6GSo*Und z)tiz8Z7_lh*6h36u-|as`4NZY6`YBff1>4@oIq;3cA-Bg2e&(3HF#o6v;F8HyJTo) zH!Olo_u@I7?yZFy4qxa~mU<3pfD2T|37E^rVo>0V%YFNt%xGdip6}zTurf~Xl>gh7 zs(acCKOrP%DMJv8WYtvV*7%G(KR`nPgv@L~Z7#K^e(7dQo%^XN!U_1gW22vw;lBEN zn{j=i=btQv>1>uk8~0oUR{~fjH(pelzSxFK%jvJv0ZJsL(%40%MvzsT;x zYq>nq=ZtD7lt8*KEQ;=3#Ij+nJA2}s0cfkTD}W?~CHBvXHIbqq6d@S6RgLf|o&OB?|XHVMtV z9~S2hM>_xGC@h{+{~ZJ<4a9t7tl)XF&+ic?TpH41G^(ta2C;Z&J0M3PdGcgQM@8|s zvDWb(rS5ZUZ10!GOFqs#NtWNjECz47X9_PRyJafdK~7paerY)|LG2f(v9v58SG{cb z^F8Cql}SC68-ePN$X%6o<%+Go{Gg~sLu0)dKZi((IH``d<9s3UZP3@73ypq97CpU_ z0-+8)^<3_Yrqgdn#XYbijXN>Ka@uy^Zfljym5scEvtAOAs)}r&U)<&)jNUmCT%U{L z2z4C0NFf$0x?p?%eym93xWyK*-IY+#k}Ak-ZEcX}W^fh}Q z7h8wg+x`6=#Et!o8WMP#pLPTT7D;zybQ~OrbkgCKKI4=D%5RTxJaMlb&uPqByAoBh zT-8;nvPdsRg<>!y|A}#!Yd`&;3rjkO6X|Vfamm_qj(@eiwKnZ{?=5`|T3o^(6SivE z9vE{s@k9SH7P4Q}R!#{>xq6BxS?=Vrl~!KU*t9RC2i?DXwvC#$^FEKt2!5?q0<#UV zoYp6pCuU0z-w)wM_wHnNVP5Dx-ot)hQz)wG2#pDaXL$(In3)YAN4X-e$gg-~bhia} zH3brhC0Ra?>m`v^6MoR}*Ym0`qg$lGngZCq9Nnvhd1LNb0g_Cu2dTGmz zC)&QfF}n4Sx)dmMAmATjM}8HU_+{m4%vv1cya@!Fyh^!CKk@UXm#(6+TA4rB7LAuT z^H`<$9bNmB*Oqpj=j{q>mc7WLX$3-dlq+u{7KrIf*meC`k>OGh%fz)+-Iep~ou9XC zE*XktoGpJE7sgU=M8rkzbvNq{)&j65!8fzjsaKbXIM-WuZx}AZ+Wobt{;InT*mngL z$hYKwY^qRTf$z*La(=*E9Rr3=+YI$1MIVi}9GwEk@5WkMNWJ@V^oi zK8$-VFB`F07%7k1^Qu+hg_6s!9Q5=a+n~#@N>>k6xrXz%pYIH@p~b!JfVI1k7}4kv zYR}mQNk%n-i37KnE8z8i{*_j-M)SqUq3b+DZY!y#i(efcnPS+dz?Jk5@dAc_f9+#` zH!30S^<_$GP_B+>#njKxtH9@?QqGR1#4XjrZ}|Ijs>yLt(pF@N>4Z81#movcsVjPA zI*m>#mPgdyLJKksPnU29SU&q8X3(^7x;~zh?;E?#bH?91ZWHDhAHin27mzbbeYv;3 z>L0Un>QLW9<~4jhn@^t31T6Q@HylfMfZmp~A-i`<0OkM{YE)qY*&JO zJKUSk`Z;F#hmaV*(EYQWaZ48bTy}cik*ooOc(W^I$=8gf&4N`Yxik1Pv84&g?9O-Ln1s$`S=Fw@J4poVwz8#eb8L95M1bPFT~))zO}yOCbTO%)4411 zEBdF1#6u>Nv3K(@qsLdW&VX*0hVGKRcs6CUand>E&u@XFPC+%K3`yz+ae=`i`v+7c zW7o)@gL-QCBCt9H$KU^X%Ay}Q0mXClWeLX6+)DV!4zq9?l2xUd+5h?^m7qwo>>qNe z*4vsTo0yTeS%m6(*=|*OO~DRDwZ7&E$5gsOko)t`bl%F zwC<3UVHU2bQY!yf7aZx<*LdwfV{CJ!6BAKX0pSV3LEfg^y%o0`1NrP&h2&yRG0>_6a|0Qb4tH5xx8hMb=1fBbF}{g zO4ABK(5tyuadr1Ts!nAPIJLk0?uzY1{~sc*{rCrqeiQPyM3zZ31zvKyhWp)pq;mBKXL3e=nv z_kZ>D7Q(Jr4C)+j_^An1YLrJ3$B!0yS(Nd3I`03CYBg>RiN>!DCH=%pbTqMGf}hMr z@5p$030-~;R=G--{lJX4gKhT(KjSWRc{jKb)wjT?KO606WfW3Mz#;Q0b!qfrS+@hV z1Ma988pV!}*eVTDD?E;q-tK0NrZXyztr-4?rQx#;Cz5WrsAKl&Jo){dV|Q-0-!j!_ z14fR2C==HR3N9`?LdYD~OvU`wWcQbr*})SN$#;8)22Qgm3EAfCt^D{52Xh1!kIFmF zYqkd!OAO4Em{87;A8h}`-l^)5Cl^|%oVi1dv#IFHbSovRhpM_EAinj(~PBwm1~@8%V!9|dW!m>GnVeC(M*(_ zAco4##A2s=6G?QEs0A!@`f6@7HSuc^!k_1XQzjC8))>rj9mf515DnBa0U*h5aonV% z!(5g+6;4sAl90U&1)RG7gqphj&R~3lsuy!kaAoLjT$71}J(L-F6)t6fQNDRu%x)_!vD6*vk7z+zuFE?VfT)hk z^Z1YyuL02Z+?7n)m+yOSEDi#?j}<`roIB>>^&^?I8R82A_5cFRk5|b^zxX8%wGdT#2 zynfCgpBCxihPkmV4ZG+3iC;8M2v?G^tGnl@xZ=*1Xq4D^XnXL;voi3N^Taaa_By%01m#eyZjn?ja`Q{RdsU( zA!{>3jA&fc+*E29!1!z0VLRCb&7q*1Q62c6LYLz5=q=N*J}0aBDoY!ZS+vz$t%T! zVO+079Fp&=#&*S)54t|YM!HjmD>{Yk4l>M{J>g4x4xa&QqGU!|rGn4$n22`G_U_9X^Gp zVisD6K+%MM;Wf^$$^qHWN?vf^bC_(v7pg_dF}W34y!dP5y#5=Tdz0NKD*QZ{Vk=Sl z3mE^+kS6$HR#p|JeCY|9d8$nxPaxw(Gd^fQ?!}%3g-C=*dAk2N67<;mwYyO7HZBdc zdnaOCum~)7l()y0i}XKi?r-~$tb>s`{^-|4%iR^!geXylFUOx!{~YCMrso$Buw$rc z!yoEfh3O6nDLaqkGIcNzBAf_#4*psxeF2suI!Pp%irVnJC7n(;?!wAobJM5_C?>r^ zlRTkiTv4O7B??PKcz@cfs(^ticCl*&qii?O`N{20Z|CK*NfNxHy_mr}Cv$0Qf$p;JF86TFe zq_VkR3Gf!NrpO;bcQcaN6axvt;hOpW%kg>+->uhazhW~S2|OOiDuGMF69eQ+J4%GF zAU4P^3(y(L^i2yxIJgst1xw*iODHE|W`8-C#U{ReyaK%Kr~#s(6NpLtN32gKDxUYQ z;`dwkt4g1p&=W6f8Fg}Pv;1I^R#9Vb9Z1<2DjxoJUmIt}+tTS~CG-{AmyIjkskPP) zZyg^vf0%vjm*@uIDo=Cd2(;0q%(h}*{GVH9G`4MSehW(l%elP#;Aa*lDeY>z^Nb1U zuG>Yt9n?wlw(s01peIHe;9Hi=h~YaGZG0&4qC1?8$^|4aFwuBKfx=(d!p07b z<}C>EN<yrzmWGTK;iV?z^?5eOqn@9&&SSkQ$(dN|Y1VJb+WQ-7b*}C;gBuWXTm^ zA1k~E?#U07cumQ*cKcpJXs+6n#QVckCi3_5LzH}pn_~(%`aq@X4?-5tVo46RU26`#{ zG!a6mLFTC}-9_ysPe*D*UfBRhBqf}oLZsA>H(Ai9tgY1N>C>lwLl&Lv^gu8++cF?V zOaD-h%#|;$)6Wd0!B{2O+GfD~Q3}-%y!;V!4TzpeSj$~5t?>T6i`#5&Wbljh zRWG|r7C5g9SBvFC6}3Zt&TEU^GF3JMAsmW>w3|Krhx%8x(^jH~kgtl5BY+ve7f5Cl zQeH~YCiYl!wyISA&TkPm@CyVGKb!z(>67m(F^Y|AC|$#^({YLPhzwS|z@Y^MY#0}% z8C58+o$@0|qn0MAf}zmJbBa7oXMW0L9*J@Z&&>+MIKAISFAmOS>@568I_9K&wBq}) zstY!o+$I*#*d_|(x9qsd^@*c&#r~Qr8mw-SJ2@39=w z=S*ZYP#_WU5gn0CuHx9LAD8z!JhNC|4T2W+@Ml$ z-~)FgL|OoM32dMqtX0^co2%y6-U%pkgRV=mY5qr{Vdzz>oj(n)e!odejt z-)h-bN+^=c?{MKKE<-}#g%-)ZmnFr5^~_=5MTR6GeyUtlJmyAwDdP+oWV4@M>;2#x z#ryxhN8yhIxo`hVM6e>=idY1G{a}w)3;5=BlLr1m%Ke}^#Nw%Vdr&Dlb_D*DF6e7~ zcjGQYd#hGp5oi*Ol1v%klW$l(*fN{?=w_+nZW68GK+Edu*_+k7a{gyazn`hn6P~8^ zb$&$XeL7C>n?0!|TtJZEa|##m)LHit_cwwi=q;U z*+pW#p=qo=Z!;YOatZIU^A-75(KkU;X#`!C8YchyG-~YU2Xkz&RE%shej}h#Xq2uY zs5`8&D>nelsKPtsop!<_4&CXMbt<^>vO8=2UX55rb0U)(wNk7sV?9!!0?ID*_?^Ey zGsFLt#-)@Ev#FiaAG>j9oNchEct7^)nZ=C$Z53xBlP@(MNnqlWlyHE*t*L^IKDJdv~b4cB8OOYiosZevp|a$4K;g=EwR56HnIVUdj1q;uu_?rfEn+lkk~r zA^pYZSuFV_<{IQ@Du?L=A%j0^Eh#!#!<=d1Jb?%z2W6PT zPrBGULwm}_T#il`f%}Z>obsgsWA?&<*HO-R-hb?bOd&_)QA}aKf0JZC|!x?rI2h~ zmz&tUQ|lWLkO64pq_wown1hLl7Ys#z`|aB4n1AK!8OTjPY@*>w(Oh2S=igk>uzuA`wyZ4q7dqNB`AonH7=k?W z?`L!$;%5s-Kpfm#4Hp*AEc@U5{uvN@g;gwqlU4UculP$R)z6px)1ax6yM*Z$$iUy( zZjT%Myk~KjWjjwR-8_+H{lE3K05u2rau_tW-4oZj-3fj0-9`EdS=@4dsNt9?z1s|;oMK#N}Uag(_fbS2J z(z_{AwdbH*7Wu4VJr#>3236+i$*kKgoYXynRoVZkPifFJVC=fiIc{ zJpkA#FP9gyctZa()g-`S_gcgEehY^lNXTUeqvohhMHa5Bc_xlL&trq;23thb{{0d` zz8hsW&ReG=2mb9EWSVfC(~sYw&h`1~6sp}wtIyYAqAeKOBO{3C5!!}O#LCX4A(d8$Eu^Qnw@ETyK=r!QY|Z!m$y)Yj(+DOSVb{B=9^uRtl;T`hM{E6(|CEG#HoJXq zS)g9nZ5KF#0uMZ~D+ zyQc4t12A?m9y}F1!zdZ21%3P-tqfz%G7xRXUQr>AdFpT+J0VO*p_7skuH-frtUuE;BRdJRA1-YS#>qG@ zYeu`hqB|5)Ex{(R6B-Vs+)c7>*<{&jZlo}?q*Gt$4=#dIJg z)S<$Pewr(xT9NtKwp|qQed-ujOYsi1XWQmPuHyYXsOHgSh6ju?>q~X%lJ3i_axMBN zTNyWPQ<}yRLul&1DqDG!hM(R$l6e7SMyeI0V>R@mkLah8J*%EhJA)4t9=*Fu)#fLd zWF|?^+w}c$_?t&|^OhEbp%gc&G?>AJCS4k%$t)S;r~!8TOJ$IvY*vQJT;UPFDk9LX z76{{{y!c>u`e8_~QxzzlI)QvxnT}p%jgf)*sN1nSm;Dx`U^JMEUGcZvzi7*R zYG~vM2B^` zaxM*T_SGBfX^h z-whI*b$f5?RF>RNPq`W|cyw>G?|hh!f^<-pmTKCC6tT9fe6FmnlI9p9pEk5-hxvW zrN>O)YM1$cOnqfoTsyQj?(RO&(&Fx}#fnqh-HMeKcP~_kQ`YXXe?nvyzo$Wo5l@vY8Z_;`~4)Ct}xBQ=LJibB|ry2?X4JOU*nW+pZ)E zYSKf2qbLbF8{QAnbSAVh{2DtQX2LtV{IqQ6TLf<+z9*>iuqf+bZe5w)3JfWjCLQVK z;b}VcE^@`TPd5*<0ILnB^5Le2f{S!}7RJy80#i*?21!M4O4;qPP`KS)c?bnE)ve_i zFsoXveknSIzE`2$v?VFYIu8$>Lio-JZD$)2aJ`cK8DvEk;b+Ey6QoSnLpoqb)$@qi zLurnby*zEw?ozujwCBICth=zs{D+M`1Dn?VGK>yRfC zU}TB_*Y?Tf*(j)WTQ8a$& z-;pIT$E3SZp+F-v(|;jldO+vv+DL992VGuh*{HnrD>jEWZl_Y^eWm)-Bire^cWFie zP(27gefz#65#vV1TJ{I~mxOCluk5SeTpH;E_!P_G8>h2R(H~v}5aHCulS-~Zzu~T= zS(GjVWI5`r7BAAy!Zv0i?5EF$y5s~x>YmcQ>=u=#JK-0YQQn6>{UF9_vZpXY{{C56 z%;3Shn07}&T0pNt)-@1aW&kyfYlr}N%4v5%3q`sZMCJg>Zq_TR2A`!7tnFwF?|p`G zgxZ`SJH~Ub+1~>ph5#ZU0^%x&5j$59(r!9(tAw7Ec5ymxp=rgvW;|50op?paM4NhyxwAZk`@MbP`Oh9g|TQJRd_YhQqk# zknPOJ*BFm6g@1iFwA$&HlguJ~d39f?OpeAl>|nr+WbYXg;BJp`8C;R!OC6%Bz&pMd zN_(M1ONc&-DtN^>{ir#@&xC5?7c#*RnkdKrr;Qo zh7ZUqK27qcwI|wGmCao){&;5z(h!hCJAN6+82bK3)=W8ROcb4r?MFdMJ#`*%^H~hs zMdGSJfS@dL9WziM^|KpE;9Xr<`cseq0vc&L8&crJC)tUQp<-}wUX*Sr(C>mj0=fiB z{)`AA1T|@a5sSyghfjv+L9r7rIsmkIOI{})E$3j(tq~kUbyY{WFvxK+sZXU#J6U5n z5-318JT!8d*b`HNKXUKzV9zSr*6`Vg_ApX zoNgjT5YfZV{~I9-qbv4BpK;EfHI!&ez^cprfh(hO2gIL&CV`4NkI*kklEw|PF0@R; z(8xKF{qAKgSPnX8coIZ5$0>-3JjeN|dY<);mU@%Rc_e>uMg8qS<7!x$#c3MB$9tS^ zZ!}7LPMgTN7^HX_rF{CyH@MR0yxrc&`MYMZ$+WSQLmPea4M?A-++vB(wT%ckX2l6~ zvJx4(SXl6LGCSX2vLu>CxDi}qx`wg?dY)Mr<09x7LZqe9NV9%;6~3NAS0FHYBeJQ$ zX*0!-{6qfLok9x7Ps}A0d%*X@nqj+m*%DS{waA^QLVP7ug+(nk^86Ux{J0>J;PK|k zR)shzUXxy;gdTU6)k>{Jil5y5el^xA%ec*yY^<%3Z&nG6UhiMSIt)m8}*?O>+e5_TsXf;w=gBy9khv@? zK=!RQGu4|l{VYa^<(1{i?~Hf*(%q?)=6}NH;^@!lDSwh%5)v`JWL(=wL%t?)2-?V2 z)N<~r0~VNWAp@-59Wm4Qy4rmTsVid$8eQDyf45`!b zbo$=tk(fdyI2w6{%1>%7X^1w>ozVVXljkjaS{_@VU^x1Az!kF902?J~R;mRmrVBV+ zg+)!9-QHa&fpxrSvhc8o52YaXBN#4rbSA%`8%wO7XBj8i@>%f*gJN$YG<#e;v()(h zJyXbO%3Q>uyXKDl&$ob|;TXQEm>kC{r zF*A4bq-af}r9q?ujBF9Tx*PMMnbtC)z?%{A_wd(Se1f_6>wXAx65(Y4l9Y{(x$J9% zdx1%Pae89gtr5E+)$F~Yf;T)<`%DRfiV-c@Q&;0l4mD4R%@^+8nTk$8k5s_@Seue^t5e<}S6Kf`Pa zX3t!bZqpgfc~@A<%QvT^owWEhn9Hw=@>OPOg9|z`soG`C+cP5U(oSNZgp?7G%6gAV z^g|q0)|J+Sjc5w_A;Zf&i@ykZa_UQJfU-mpU^@PA@Y?c$>-k>n85Y{speK%04?*2= zF#8>D*jp+_5}^_J1phkH4-+&uD{X8{z4K{GM_u;6)b|wq_aTL92M&Ei$6EuU9gyY5 zm6~`sIZu&e?(0|oB@3y)+fW4Q&+sB=eLW&-Ku>Tywqs#OM-EZ{oAmJT)w+R+lBe?Q zsK(0zp@A*BG?!1f^3XPV+=XDnK|Mn~|`;A_veiXqlPIX#Xi8LU50a zzCQ8H+=u!1Xx)jG!z|1^$hedevLiw}M>${1gVT7*8tBRY3M30=(f+qc;zuG9Nw544 zyyz@&UZK|72+z#TN&i)hcI@F<1)G081ROtQeEEwY}4(0%oNCQ=cqM ze?I6ecwGCm@XSbkkK+I8%Z6uoI~N@vKMv!KqlD`^o$QE|LJ4D}&${!sHDrzEsHEGdFBWLFL4dH{0s-O}vP)pA`eT@lSTSN( zB*WFJA@d&3VgMVQ;ghxR?;J`U&3v+dar!6!rlnDTHANp4rAJ%+Co58VKb#~yy-JL9 zk9W!Q0LvHPMJ4J5Y2Y+}(u4hFqIz$MvfIMAwVQ=Q948T)nX@YFVSvQH?m)^1(C)?! z1qJ_Y|F5SH)Nnkd#ob&2ACY(S{Nt}^Eyi-3-?Gjq_ayz&dVl$CY9o$1y&IS*EcN*9 z^oju&E%)WT#XDwiLZjmLy0R=d#b!pb#=SxP%B}azq06N4&F6y^gI{Ht4&_GvAJ=M` zKHK3`8`%=C(*{{^V>^iNz`c_P^G~!|iVy=zrNf}T`){Mg7YTBFz8m0~?!#?$o8n(_ zsn7ZiY1no}vs58B8F{jgq>5wj@fN9Nm&OaOxHW~E-Hpqb5AOt;d*p~_g%ES$<`_b< zCfddw&PNC#)lhz?C8E2FNU+&eg=hG8(DPIFb$?V8GP)N)fr*hng{q}D+;x?(xa;$q zFxlMSOq?;d+%<@L;tgmrv6}Z0 zM=Q(E@pG_2xquD{`d1d>varC{=1GimFc9d_e0TEN{Gon|H=~X)6!eHd)AI6m!Fs$m zXI#lqTDC7Fh&{$LQX&wLb^kp69n`b6>LUpopxg2@ZR3hL=Nj?P0I^c$9O;U362u4n z(o(P48zTit>uDv%&%tkgL0A>c+hYD+|BaJ!bGZWk8Pue zYo}Bjb0rJzjS6ov)Q&+^x=i(>+e}@3m$7L_?aD#W6IlQSTdq8W!v9j6L?ToP2u$O9&|9-e$fuuS)vi#Z_&t(3lpPg5cqu14 z;v(r@R;MO}rz!&RIhgvQ*n9|d&uMH#u@SHtNyPz!t~8}2R(~3y%pEl`i9#owI1kd* z=w4X_4C2ak1{>cBBKw9c!#0gZ>*>E3#Btt}=! zccKO!^GycjZt#!-_mHS)U%K?Sf`oXQU(d z2!IWCJz;n(ezECTrda7Hx}9%hXnI-H5o=|shkgj|z6sek&|2Bpr3r}xh0*^r# zis}#lI^v}OVx_G+9_9j~D1jJYQwpQ;Ys4Vf`EvTqWaHuEbNO<%Atijz4-8pb)eQ}8 z5@-24zwW+5%}gKu!ACn(706ph@-uOELRG1xlySbP{nf+?1|PsQHWQ9PEdwXk41?}e-K?c}aIQaNiLIoB+;uDlp8jN>-8*QA$!KC=kHK#PL;8WeMg~jG! zO5}tnZ|vDwo}{8}HL@EBfb)wIbkC4ClIphCQweQR1N}`lY^e90be|AN7U`;my97Yd zOdfWm`eSYvSeJf}z@xnp{2+zRI{lG5mO`w^#JuLg!%hP|ov$pc|Id|A%=a$@Cg~=K zG zMSI?rA@O9f5S|;N4{St-50@6<;d6oD)WXw+>XNh@V3tr*UCY=YkogNc6QMPX9IsX4 zutqUl%FsKd@)E9*FDLWYi?Do0B>9iiP|Mkdj3+A^;fa$Ytu5(oO;|{ha3MGf%jVy7 zPow#>^IiahwvOD+M|-h8T$Al<`-w(;`o#ZvX?yQ>KEVVzPV~svx*5H8_?t6@&=HUQ zUrDm8f+gcmLFu#aXG3J+t@!hd(oOImn;89Iw?+^dZ^9D)5rm9+lL4e}%E~BydK##By82?h zkU+;&6h#6oLvKVujhWd}MZ|w9`N{^nMCdnhsyfUSV zKIcDG$%#Tx5L3@psagpSg`&96M34zQ^&5t7GwJ=024EjJ8gNYborlRD(+H8i#gtRW z4mQl#3ON5KqlwRaV-D$4_U!Jt?Fnvl`7_>+t5`=Zq`*w#{T-@p{|`z;Ro8UATM^%Z*?yK=%5fFfONvgq>Lt?k2INm(NHBM~EzqSw{! ztp`}Y;_w*yoo{xJW6kL>W0y)?@3@Zc1JOq`XtKw(wmx)69EhjHmU!Vs8~6q#v=M;0 zM}L_T>>vVCR?gd}d5cfX!ImW-yGa0$r-eb+S8QK=>|uyQLYDG5`P z2ta(RN|yexW_c*ixit?+@+d$oZ(s5O7J*(U;Nys$4C*5(|j$-;>=YQX>4KK!kEeUG8eMJC`8f!+7cuK6Wat(XNMr8{7BsjgIhVrDf8{bD8>jH9z?|LSNT3TP+UBS?f z!v<_r+TCs3AJ;RVxM5cUzA4N@{d-WE=td_l zk@Sdoyfo|-Nh7DYfW}Ld(2v!3lX>r0Qu+Ovd7XgkNB{zHEC)x&%{sNUFuu7ZBprQ! z|GUdNPxG1e*STEM^eI9#0jDRMnZHtPoPwcwP!JA089hH?j68j@H1Es2O zRWi{SR$J@hsv6l%p^S{8=MQ}6hZ9X^yIId06Ha5n#sZ=UG~UN@&;H?W9;MfJ`NM-{ z)?Dl9i5rcYX3eTpzJ(61w;-E5pJ*H`*x(DgXB%$BcDO{ipNYeL>oBM6FldBEFLyN_ zpWrgA)h6QTR?Li3R@TbARe=q;I7GRH3F#qgIW!@ejzCRb)^DhTC$8UZkZN4=7sp>r z+9k#2duAggatC4uH{v+5X95HayhZx^PB90|xbl?atWllan9FNUt_Rkw`z~_fy+%V=sv`z97&>q@K_IgENC(&?On#Ou`k?Wg+qJK8|5vS{UdnV6$gliBWIb*dqj@;s6kn}3 z)WsMVfaCM7t)hAd=(^)U+Qrla{?)^dB>&M;!xq@}S*y60Onbk7xHL~R`>fVjcka|`XTkKt z0#AL;m*KPdo+)FU321eN(dD_ulF`S(FQ{DuEa04JsBe$LyM1!u3eEEeQZ+j^i2?un z*Mmpxw2emYYI?T!qYY*uyJYxW#rP`KXv{#~ZBiTrP99dDMcOqFllRJ1UHVpBRt_2% zIklze@bV$g_+R1pe=dX-&xhw}p>>YwK;lTcn2u7D1_|-j{j=#{W zuphrAGDGR|UDO$l%SE8%HUNf3hr&2%P&SUYmI@YFrs|DO&1L}LiifIpbaA58dzpw$ z@EnKwg1G(eH==N-hJz&NF8K8feJg9d^Q7uvjQ!{bH%m%R`}E(uF-0(2IS}juqCsQG z3P@YS3;{K#2B*))z^6@JLD~=v7;T;gY{xZRVq%C1;o5AM>@snzmgs5 z3_5Nv)4P{C9$CzHrJK*Yd-(_hCrFCB+Z;xGU)oZx^*$};5B8uQkxvV$6;78cVm3n$ zr-|Ktc*wO?$(aaEfVw8VkI zIMnyTe6p7uQh4~z zyf~4d4`=1+!*hkAHqUcYpY;1)KDDe;WFWNc6akaP94)v9^;>!>G!eXBc@=^wNHjgG zC$yI>kqlSU@nxBe^mCEQ%m}1L#H4gTiU+)eoArD^a{xLToACc`-2jiUm#fJ&SuyL? z*<>L4Ac@XtyY3G6boVIWI`Oj@bzhZ8yc!D}aPz5oyD-K)QM;LBHgEW+F#MktiuYmGD8%ojP{c$|*^mT;C2(KQ#WES+zUd|Esa@zDns;!(tyP62Q4=n9s{3i>(mSey9zdVPXELuEoltT(Z98@WyZF)7bE^{}k8&M@p&? zo;g7^lQjITO8Trw2EOzqFabO1?G81W*b%PX$=z;Mw~w}6i*s2|^*U(UIg$}uPIXT= zE8yN8tv#)rb4%9mpmBep8sG=1<@e6(`kk|H@uycU5UbXC{1^Ji{TtkA;7`mmk<@!r zi_OB#S%=%MEG1)+eu=T^B`T(PahS*ap*3c59QGOAs1y1 zEF(7g+>IVo9IdjY`x+tvbpgkzJUcxrY5e983B5Sf;n-cFctVdZ9c_W^fM%ZQ4i!cGn)3xV8hCh94N z7vGTy`^un0Alz2GUEZGac4D1N&64;kwg>Cyu82~MRPZ%b_5JT906Wc%i3Ok6LvP!) zfKlO~1QD-^2GIUVCTE~4$G%njO^K(!vfcwuo| ztOnsDwuIt6oj*1`P}{H4GredIU+<37Xka>U9N7|~scAjKBPU5)-<(83m`4YdVk>`7 zzXbE#wku*XF6g$vD-JZ(Ph{~uao&=+VEB!4`;g;(HFYZX z+`;?-P5(@r2zfPQ0tgiO^6sLxa^y}b0FPG zG~9~R>v~(zDum8(|41B?Q^S~BhF{S)aQK%>%hwgm1MA7KISK)A@$el zl(7_XMcUroA0q=$OSErR`JUf?v}1qrIzO&9eId-rwm4@{B`>==ADj24L+0aP%*be3 zKV6DFp3Ze)ws}+{q4|T^@Y=Alrr74_`$gbczVGH}RhA7@xWQPqSVG#K6DuN~!XbNd z1gGZ9dWTsVAEe$oLQ<uxWF^!y<^zFx&v{p|6a)aEc$|k;*l9@}?M)_P+3@ORK7+EBDz4)#T7s zbL_%VQoXlEFXhcmz*nQO#d%dS35unLW3)e2&2QTu_v$86gF^$gv{6F?A6HseABTzO zZhu+~$11u){-hMHXz1O29-=fb40d?j48&GE?ULfV4RzSXfMh)Joe7XhFa?To18s9} z`|b4coMubkO!3x-je7lUy%Du@KFa>Jebm?wmWX4R)CL?@gP1Gx69u(QPrnB6jOe8Z zNe~_6qPIzbPPsyOz|gILgl&&jqHoOsWgmn=ECcw{-LGvXpU`R)BH!?Ci`DrE&!CoQ z4ESRVuGAYJ87jED_wv}HY0(Bb2a7^5j`a#^IiYO3`in!abY0UiZ4b zk^XaDjw`E^r`nanvY}K3Y3@j8!QCCab|`I!%p%=7N`hU_xZr?4+dk0k$D4Hv z>Nb6I@Nu@phnwB7ayP|Q^R%r_{5}in)P$$IW9c^w`)YCw;GL@G|8tq`!w@Lld`Mv0L7;Um{z z7@dvY0}US51RS=4|9Ni$hj~vJSG86A+Jt)=@rfy<(B}}$W>Bxwqbl8*{rnT9GmUS0w%`ii?`9g+r*>!w`tlP<#8pw5nNsWQ;&(w`-Z+BdLm;ayh5@iW zb1z%Vu}}R5a~qIxB^dT{8BRWh+nBh$dHMm5+tug@E(XPCi^{p|4RoN}Wz~0M7#gbd z(NI$V5@2}E6TO$y45yh+RUW*JF~(BH-ogGsn}4$Sdb4TUvs1aMbX)GAVs7QV{d=Y9 zMs>eZ67vaqbaFQ$6xWx2r@@ZWw9yAiaDJENo^@uCd-$2-Pm)SjiS?a&los@lO=Hk* zEhKD{`xE>IA5g&Qe`Y}dbP-ePqSWx8;4V4i4)HL(j<=0`?YWAmy}^_XkNm5Q zE3(1WiGPix8<%(y z(!(@uZrVQ17N2r1;q$(jjx-w#K)fFK+y`5gxCiwhL34A04M=%CdqgtT zQ}qnEZr7hKYYbK;Oy;K#efJ=vW=2DO>AkY9xJb@a^0~-d{(B$FsG0SOR4yOYVKEK# zjQZnQV78tWyI z&$hB7lIbs}5SPPn?h(H@+9V3`}W_wTfPrH<~XgHgOE}O2N68C7T1!go#DZ zS~Kf$@?yA%k)6NP{4aC=cr3|!gjM0rwAMS!%>k=4JVC0}er~4bcIyCy27W!;M4p4& zKSV0k&fER|PYa5hFuNpMjRfScc-;`3eN1a!> zz2;VH>#+>IsBgfd7T;1iz^@BI)=f3u@HI{PSI?JaxFxT}Sf>Zh0 z(jXh%FAbY*)T!rEYlLnnIrwQ3Fb@X?pm}Ykp_(sK-%c*Yt@-PqKaj(4Mz#{smk~rf zF3*ekyY-Dj+%rvOuul%5xs3=Bi%mO$I)sEqGeIXT8o)+$&s41TV zOjJUBX%zm9eZUV><|t0wzd=HLcLGbf7i`Zp4`kU9N|;Yi_uyiJ0*vtI-ob|1_7VVz zAiOLr##J9Kww=D3k1u#krPhIvW=V>@WvLorjp;+>!rS?OvD*#3jc=PyQ?*V<3#IK{ z-vyd~d_6+w*su<5;JYSh-yv;Y)IOT`!!=rgS(E=sHMEw&1d`0@_0_iZ$08E6E*{CG z@?onn>{ieoDc+7t4l=A^!WpcX%p^w2&xAg(ST;;If73{@o*7cYQ+Vk8RqH7BKH6{o zTp1`**-npC+6t>G#Fx|i6|G^xPU8O`TCM*3$XmAMgt*PGIkYPkyC`=VWh$nEXyIoB z+#xabDaHF7-yAkn4yKECY50>l7XTt*Si|Ql+5Co2AVlJYtdgUG8JW?Kwcb^iOlXVYzuaMnGHWUrIpOJ-uJ<$xn_q%I zMaYw-a?Q~>!Yf!K5Y*lwp<^D}vSbd;r2Nnjb4O8r@I+vpcC_{apYCtwLrfyh-rs~c zzrPK+yuAFeB{_xGYa;1T(W z+*ML*=mX>oiObnz`yolA+Rqn0UoJlQA=rN$K$|XB)4pd6<< zO^EuF)wZ_+=8r=1i*TY2RI6dTgJ~KH`XE~2&qio-dX9wD^L-cj=2JU?Cj)oVcgOLj z=2Hb7x7KeQg&$Zlthe>lR;twMsY(qBdRPO$312lM2w5?ZCvNBxj z7_=$|^>iT-$~y_wvbk!}rN7V}`DJh0W`9h$>=aaFT<~H_nr|&viHH36a8%Si{M9a3 zd$Nk5q(1q2xf3rc0@A$2&ZmUl#KHr&_V8~jHwmDN3_qncc$;^S|5^nWy$`hLB$S-E zF3tP82>CAY4I>;kAG-$i!<(0_?`@i`{>0oG2@2%RG6_`u?*rrJUVpKC@ayQ1_+PLg z51I;Au0HRP3Y+wV9T*pTp{=@pPe-e(o6kXQ<>{ATI9`FbnH{xz&zF6Wg>SB0{l8D{ zk&Ow04W5_FAj2ciX$)uK#Q+fLD+<6~nN~IIEN$8;Qk1F0YDv(3j?KX5;%8bznS;2) z8{z1byM9RP@BAD!R9Fw;J0U6`2=c;!0VI7Z(kmMj`1l%FB4@22D7Ek%<+J>1im~&% zXTRUxgEF$)J${xUg(_7)UVHvJ$46+L*I_8q?X*I0HjTW%>|@2Gbpj>}qsKU??QLh$ z9PLHCg8nv+dO1 z?lhcy2{k!8=>NwRn^BX;p3ag2#Y^Xo}teuRG4w( z@>IS$9mj5w#6f@fHKJmn40e!s`}qS1kc zq0>$6iCU*+q#yK5IHK_IIuj)~3-{gt^X9y1fY}8^-|dUwMJ!9MV&YRYbC%!jxxWC6NhDgduA&zT z1X6gtWTtG48M04;7#R+I3d zghG!!_d0j^+j@rZL0vArk=-jjp6h-ne@cj#R+I5A4?>&98!@f0A1dqc%bPa$T>IY@ zOIi-}p>J=LjZ1_e;)>y)wLDX#Ub{Sh`yoG2WL*ZM5q+Vw8xcH9!rX+7m2l5BKYWUi zCX+zb6b=2fAlx;7?4y`)(nADl5jLzia@Zuzc+xqZdw$pJS?c{HpGqxB$T|~|gU2nv zM^|U?a>We8tjZpXCrIt*^&$7th?V%h2z1Qn~L< z?WP;WKDOR}oA3>vtv~0o9;Wv?&!W`713{_a^%P?~^bu6W7(lgjKMc2?KMiU(B#yVr zVFF&1sc>P_D>5;0a`kxd;9@cfp+}O1=D@dm!-^8^o`L74NEp%)1UuFOx(zX{5NTww zvPyv3z<4zp3>j71e~rixf{EW_U$19Lf4cC3Wwwk~3{_opqXVncVlCeY=G{(j(*_JM z35SpFcAjpe9*OU2!EO-4?oaxixN-JkjLlNj$Y0(xNx~^%(dQ!3n~(d1>#=%erdMjq zkxHB+*OzLRix&b+K+W)Y-$Su!fbI*7Tv6cpZ<^8ZCyMojrzeJQb~u-w+cfL*NAs|R*Nq^!Z>#s*&CuV|N>gF650AKl zhb608X97lFNHW}@an<`!`QG|pTJ_6cbThhDnIEs>*FP`3BQd6`Y}s|wtCioBa)>m> zndpJ5EXrv_a@)Dsmjc}gM0P+VP}qg{8IK_Yj$^?vvQfCDTBlwGjWbpmWK~@)TDVHd zPWWNtG}M0QgHT35Pv^t^UYkkKEw!*zcJkbZG9SQS^m1G7nE!MmNJcdXr*wVmJZ^is z!{H50LmDwLvBT^=eaAwuWW}*@lkuGN}4Fu0q-e}0~D#ye#tZ?LAuVbki5lSk;LlFW+x;hOO2 zyh(tPeU$EW(JvuBZ~wn^#*;)yO43@hTmH4+O1(W#DyKQCC}bwoC&Rb{>?53+9f2KT z?NgES$NW5pWAit{ZjdA$Qt)mZ5YLDc1D@0yM|g|qpg`vedz2{iL*|KZ*CcSWOk?Rx zYTeKIDoj$Vh*-x&X4bH9s__XCcf}nsmgXjiGB32)R@~=hXHj7I72O`fp6i2sTb03aj(lvF{Jkk{Xx!pIHG1Cc>M(xLG zJ0azrS4tjLDwDC+CQQv82;R>Mz(Way6f%#qX@eH9*v045YIAL@HwM97%T*G%VjsN2 z9|B9D_{-q-sgk{qq#jTIscr z`v8`#NG9v(V0GBfbTfFWYi& z0}_B}C}lWQAq@Q%q029^3@(#a!pVJa}F>i=~>*{Kx^e8baD_tY+57J|cpD za`A3wgHn)CGhjUet-@e;6b?#u|Cigq8*tLPFXb~!omNx~XhS72u+v!h97BE61Rt4+ z6~o>r*nZiw{osOs6FUOPYdN}VuEHYswc-u{T*UAL(!~9lS3G*(z<7~O=KaZYjIBQ$ z!x9_{Gv(dO@m!6hfB^t5BQ93~$1AQC+=)hG)aMSxI-BY{g7-c1(d2>G-7ofpnYj-A zq=SQ~NhtTdkoAde!xoDV>Ki_HP{ZUdZk)yi8(lFEvCr#lJ|jIHTtiPuKTIHRQYNJl zfulJ^pDTHezh(F%~;S~+Q2nfCla@#=-I_d6k~PdoOpXS+X5-(j-4fG$K? zOH(VJafB-i{HQE{NM)iCOZF8Quc{)A^Y(m?vD4wOA%J5?G zLhem$J5)jgCe8w;**qqk2$y?Jqv=lD&+~45b3aMaA*2nRMQiw7*0k{v#wqhW1R8V% z7A#HmFprnz2N}|@J65%QwHbyj-OX1)2@*^CTnGI@(nGHy>nI{%ammr?_fj%J(kMWq z)4D$>x8NUF-&iP|6Gt1Ty`N3csr1Gfc~6T-(5l7wwNE>1OQY|rG^LezMBxO|kv&$oY4sFlwfv8BI=4Td)lKc|8oC=F6xOQ%0?&)s>tO83 z>SGhs;;FlbKk>Qm{^(5j|FYh5!bZ#T2`BCw`E{~}>nrFCRjJ6?&IAmoIe<}x#8adb z1fOw@nzo*n4))v$%k|+^aaKRb$SwFNO6mrxFWthz%+9WZp;d;{A@LG^h#0y0++S6) zc21(*f+RPNP>?Z5P+m%Y-_Mc)>cD#05jL`z7E3`al%5h#KmA{B%Yr#=uKzOlChvk{ z-`s+ADwY|GwlVwh{2V}Ra}DV>QzJ?=+%o<;dZPhVm07>YSem+tN#!P}h|`jEO?@<9 zMsbvNSQhrPJW`A5-o@znG<@mWWRQu^`qNFC?Vs~Z+k3uM7r($sI2b<*0zL62^@lGZ z&v5E8QF!WLjc>yi?~tL5wV2*`vG@Drx|uPBkU=m=-`4}HF3(_f_t2;ea*eIc(zMUa zhQ?+f#X9!6ddJL$e@Cl~d5Ve`-?oi@mt@zm)w@!F&7@OpO$6g>ni>~~q# z2Zg+C9g2K<9G4}#I96*M!Dy(^hvyC}X#hPJ{i*+Yo*-p@q8zd;0D*-tL_7ne7o+;3|nM8-y?0a3lVVN_}IH? zmL@|{*LYM&mS-{Ll9>Lh9l7mVUuiR_d`l@NZ+B_w$5NGQCHS*Vv^29^IRxEoEdB*A zM=u?01aY2t<~0d9oXBx;??Gq!@g5Ed>^a1pR?3g&BKxhBd%C@H-jrGz?`w~ap0C#k zjh?$Ym{0^$0nLMQ-Mp@5EvEx9<}aaYGi8x3owrzj4J6KngT>M$_C`DtiXRAmuuHWK zfF^S40T)Znqg0`}mP7XK2?1HPpsc)+AJbN6zsz>bY*HMugXp7;r=scFf3^lx$Io=$G@Q_i~PNJ6k&i;i@nb$v$-gZ;1j zA{m*)ta`8q;wUo>N_f_}z|9jg1@>+zIBIOYqgTy65>87qGoCZzv|`Olj+_}Ebkf`#9)FgD?&+iiKEDCr z0yFL+X>-!ZUX?k8Upi0JYC9SWtp7@O@4bFJC^$&tw~nq5K*FH1;cHVDB7@(_rqAZq z-_7J-owV9w`{}YGxc=p9VLcO>LvD3N?$17f(Tkd}E#%9$dY(1SFPQaqvr3p(C`0Wj zt#0k&&wy!it}WZgZ!uLo+5n?eJB;_gLM@yW_k&}#O0W;J?Yyvy%5Eb>)npz;9h}s) z7HWwl&|cN!N$8>X!hooyc9tOCH>9xl5cesCEc<79j@ufU3Z|C1KZ-_~!Q%j9%Ma0F+G;f)wSExNMCRxA#r2lwW?MbJQ%iH zrf&sV^}v_}JhqaM)(6rEe9m3JQ2$!SAtJ-`quIq`d$e(W1h@QqX~PuJvjjgDRbl=n}PH%#-A=$rQr4(jMcG}<2sCwbgaf}Cmb zsP_rwwOgf=K>wdEVa~^3x|G3jRxk! zk8&}|hZsr~_FJnZFCIR~TLWd|bA&?_j!q@Q13In7qW448=WEM;h=75(%$IZyi8;jn z?}OQnpiPlN@0(7_f~kP#U517Sa;I{EWd~B&Ixn^D?!K6A>7)DcDP%$!zH*qxzo*5= z4+`67(vS^%$6kaxN)h!H7AAD27pr9|7ZJ_X#O?9u^-12YLeuQ-O-ZzA-y zkgk^t7>uQIsc;RM+CzX?d$VW&;@w>sMI96}cn%bE8JaK`zg?3^59aA2cFD-mF|=I$ zQIA^(Tq}nQ#})l@&R%#wL?DjWyy@}dp>qu-y?5#ldsY3cnE7MoZO!_m5z4Vs*XF&- zq~5xosn<{t6xvIxXH+qikB+g2DFC1Cd05fvjD5TGqELu_fYPC>r=2Yr&hVN9vFK|g z94tgY5?v{7aM1q@H&g&5exm4)$-x$*2{&B-+rI|qOIJx?vhC!el)0TH2d*2JoIO3p zm6&$bF`{=VA-)-3x^I9r{jE~n#R>eJ9DY>5bxR)7Fx)=a?u=<#$)*FC4lOvvOCeTOXzmoL-Yi~aNK2`cnI$jGLjd-JUL9psb#`{X_*?U8hjMGUFdWJ2- z?RfW=*Zje6!*?kREln2*7!91x^eeB{47lQTCXgFw#haKxahje~#xhSeP}CVMXrhO} zf=$d$V;J7nd)|@tne0$W#OMH1o&GI>+d5%s0!__g`w4}d|AIj@r zpc6M3PwBKR&-RB-KGV5(E&qV|jj7iwjjT%1ZGM?`^j%&oF9LGzqrnU>nhf_PbJ&U! zjj{~6fkWa^VOHfr+j$0OGXQ=CGemyUVx{Dwq}0Kno+@_no2hC2HzthT$J2S!ph6#3 zOjLkdX@Gop-^@ESM#fx4EB`YXgYV8UHM8qk^i+HCWBp-c>&W-+K$$pvyMVC3z_&Ku zcaf(um>+7rULeO3rl{1{17N7(S;1hTuBqSuW9lrU;)u3wjk~)$1b25yAUK5J?(Xg` z37Q0l0F65Y3GNysxJx6!t#OA}oO9p1uYNH2)uXGrs`g%MeseBqN(6PVaOs3#N~G?= zr4JDtie2l(Cb?tqD=4zL(nCVSQ6q*TYg|L|3drAEkVXn~hAH9ybYJ7yIHwl<-8yOs zpQq>h`)VX)7U;q!H72C(EN+xkXAYCueJ;+L{JQd>3ahs+a6Kx1O1<+_Ps)de4|11E zRm4b8s-b1o8D)t{SD_60J}?**+IlESEizU`lXlKBdE@4j1nu1v(3GXmr8z?NQY80L zg}27g0wXj9DV7wG;Hf8)M2(x!*zo4C_=;b}1}^j@#69jRVxMcQ3UbvJ&AOj?e6IXU z$6Qi~$9?UGH(9P7^{=6Kp|$Jl6L>LFWMtGC!8$pm-;gr~#+k?1_6FFA`yVC7$f!=?TWkW6fmQwda8=vjmKj0bIb1!{B}RO< zgR6A+d|6WmfP&Jfp_2}QN2a-QrlZ9_jj{86v)jo3_%T?ri=S)#s==`BvgxF`WNT7swa4nT7edel_xK zU+2C%3Z1t@A%PFojxz*&-ou~yq_}|$RpwV;a_f1V{IQV-~1#0bA*AWP^j|T zVVE;_6IVSMw8RU=p34!GYec1F9+UnF-2Ye)!jK;?k88Uf2OpUp6xtHo={~6A{C}nh zn`D?6EX&D4Uwckcjte~mXAQaa5(06xOX?Ja{=MtxQN$`%>N^UIa_nCW+Mm-wVW@>O zd5;o{0wMPg3|IfF$2{b#ufvAj25u;S`+j`-*Io*Swevd^AZ*aMq{-RI2xGGrHrZyA zSFwby#r;+3$luk4axfQOd!>dl@yZFCYyZGvv(`$^Yrp;p%q`@2Xpt^xTG@HP_kT|m zFiuJq;7S4}?CW(*;^wnH(J7AQE9THSE4ZzcGQ>UABGQA#V@9Jz!_(;8g&~U2#}-G={>?`yG%xv( zcP9J{ikuEYBY&a?61`#}K#|`Iw87v310h2k=_TRSev4@3@7*Xpd%P*mhO^`g`47((uaiL2 zZh{hNnW<7}&cn!ti!EdWOqkm_Q_4rZmZnF* z-tw@X{J2ztrgmR30|v|%h;n4jja~pCIzmbrnj4KCWzQi&?$mkY+X1$&A%8poGbm^F ziY0ayJW~L5pi2+FxAek5S{RSYs&U&?L+vk$ zY53}nYx(DZUhFpM8LM%g%Z{7YXVX}q*&9mP0S_$Z#a>`@FfAbU--ZrRzEHN`&MOLo zsYS2%f4*6xd+*cMFchy1{(c0Ii0kX(MS$?rRtB86FL>-I)tS`@28_7`nUbpQs`M8*8) zPE*JQ%re1KR1gaEC6CiF)EfwRPKo|aIIDC%gf7EV4&!V zlF+gZ18liY&okq%e0+S66^N-qb7>?X#dix4g7eqZeFX0-?HeHH6aDAqWUpZ;a~QWm z5W)T5|E5`=fp(4l=je~+(+b7b{-uBn$ zg|pgm41fDg*vr@ZZf{U=D+%C+!k=HMbs+~(gPQ$iUnZSqL+yQl0J~m+x56L2(u2kj z-})?gyw79$^ZG%iKU>Dq62G!*O3USmiLkHQpByIE^8lef%$>az*=yA^IDO%*&R$eu zlV+Rhu!cMWfdzoQ;l|3{BG9GpHK*ayhU?h9d&*@*SG)HqiqGE>O-3#V{ndXHMyJg? zSkN210$SK&k&&4+JV(=^+{IksoZrkIOkFue0P0l!ouK68$hD3ZdE~(?1;+ZEi6lwC*JjGd3J2L%z1!kMbf%VM!4Ih~ZXhf0{!B@{L z8Lt0VAIc!LlSAWQ_0WHlCyB!o?zU!bO}59qLeTlwNje{%WOPJK5yS(|8pj%H9aJPt zjVsus+*$fD+Q{-4+7#}t(#WOeqO7#jOmXk;)n?9US6I$8-?o3@qk z5p&e*O%})0g#Gmjp2E1mz?aXB?R|oWs(W}n0XltJhx>aQ;*IzRr~ERkpxC)>|LipjWmWBJjJRS zXcv05*IxeJu-;%O=k^CQUN}QSW1N>~Z<;ZCoq#VzyxWV;;o}?s%x_czB{mxrud;7| zw@}50--mufiKv0(_%&)Mt;~7^h7PXjU^UGtxZ=O*Xm8#+ZBakb%4v7Q#hTYIklToF zkh`{~1MsI`>aQnw#&s#b|4EvVitj^yY<@#@icHH5M%#rG_B4XKS=@7Ze!4kYRmeZW zQPdm6dxUx!XUg;Xmt@ah<=rd`g8q|bl(Lj!x#=i8Ap@-Ah1Mhka41mK#MttyVMvT$ zYE3zalzKT&kjhhWcvzWTY1qyicHAAy zJTz&aJuydeiEuM$P@Al6z97wVZt&RQVKp1WoL_Bw>vv(Udj5PdY}dymK%D2p=mj*M zV9t2t?IAmA%M2ryUKEz9GDJ#V`U=%fF7&apgFPd}>z*d#!2IC1c2M(qf_0?I=AK)jf1sbi=Ljq8WwK$|x7z|PWjK=Yy3bSQhE6NF%TQ5f7n00oak z7BPi~+z6+uU0udZI zMuF$ZM>h|nsy*Ko*$T!j&?i@6h|zgl(zz{hfWU~8^0SUoOw5-1YU_<74vEkdty9z; z{2e=kJLu|`V~_OgI9*r_5#ssNre7593`#_(+JV0;Lsb`X<*#`Jm&x_4qx0R$OXpgK zqONCQC0nmVb#qnpF=lCg@E}g0 zi~hKk%@>)RknJYW=+@e1{|x&~F9G}H^VPP=&K{iDEA^y@ePqhQ*}cxP$eQEtE|Uxbfu-EL*Au}n856Zcn;aqy09$3p@TxADeX;;i{g zri|QnwmA(?ci*(V_6(J^oKyI0E=8MWo!rh^y>Ir$&e&)A@s8`2)TxdS5Gko!I|_;q z*P4u`r5}(>j9^fiLJwxQZ{}XuTMpUDhmhq?LEwqDe_CP!M*jC1A07v?-@g&_`3g`| zo*O6krJ9b1jT4m7iJigdzk!UJW{d7;$W}MvwV7;Sh~pg}pVo1)PZa%Z7=v9Iuz_?E zd>x~0oP&oM&##RK1Z?%zHTGpoWW$o5gnFO#gGFzzU>%OnLq&)3oF$@9zm{|h_qm#= zK#x~GuyqL;+%Fgbv~Xf{r8zcG*r9B_X} zdExbV{X9IW8ak zf_vRr50E+bvObac+R<%5$_Ih2gRA4GhHDJ}UO+7^Czj=1k-Vfq>sok`vt>Y!jJ8tF zvWJGz?mAO9jedr7m_7ZTlAcnW5Q72AE8TkuoCM)hGNQaEW7nxaF3Um-rN55#i{?7C zXQI$lnoh+wLH^FuXGE_V3YUH_8FJl~Py%O0fNxxHg&bx%=&>8U)4St)RivI2GSM;^ zF+g+(z8Kg*=t`LT8n`UIHke#UGUo@@C-~3#kWha`7m(xKghY3&ovXbV-@EEUU?F&= zhq;vjy>0^0W!}$c%s`e$Tb6SNY8<5mLXeuHbCKr4v?Il>xR-8#{J6$u7&SXtKGoB* zqUvEg-XWnoiH2Gi zS2Gq&rtVMA+n2EuUwR)RGS5DKHRCZ&Xt=XS#+hPm-9d%Z*rZQ2M5s~?Sno$oW$qq@ zo`~RYRRt2?4H*@MePZGG_Eq0Sfr$;hzI&Pl4#{Agdsn&$M&NQHTL=~q8}dS75bdO~ ziclz#R-;YfkRmdu&|NH#o_H{3O4RPNj$H;Q;jyv+~7}KH1XFFO(RO zyB@dbb^Bfq3|%9CCcU1>wTfgBI;zagjWG)3a-9CKjkL*Bq<1JS|&m=iu}F{Bzd1`m~ql9qXpAL2Ffsx(NBLtt1H&W&(>AJgd3% zjSMiZqt}Ezd;$_MM~)3eM(VNv94-UtX{$NhYn)#q(lYGGwQAs38v~q%M5L~GylQ=+ z((ygM)?^?;b*$#{hrd}IfNwG5FJSyFB_wr;domqv%3LCraEdcj(=C_daQym(WgKai zyRzjbinD^~_KZ>R?yzC})=jYzW~H*RMvS;bTt~^a+;x?sUvhIXb|?6+ZM0q$Jc`X5 z(GWCd zdgKeTcAYOllZ1MCa@50A6~?>_|CAwRmMZ+5!2)aN4MDP@Fpo@jN{V9fILiAqZQRb+ zp(WWl0lWCM$8Ic#ohRl1m_HwOmvq)OpGsZIb<#DE;O@}%C%fAK=>7m{8NOzH`&kCM zvbiBfg6Z6`7dHW%p;E=$btLFFQpL*UaNxMV<8|L1&)?V6h{~6PNU+*B$)@TwN!O5$ z!c*B+_EVx>Q5aneF7M>kjCnOGK0VKSSE$sKrO4FZ_BlMrSv`fe)kFs(l1=pHi)0Gr zwNePIsG|H!qbRb@ac|3bg4sn;mx;<;KLAtafQd}%8jPPbZ^ae|{E6>a*E~OnC3v4S&^?KJ`Sb|NCft2^ z^*Wed<8B*{10kbUi+dT_V4wWHsvET*#Eis%bu_Q$WlW{WSAVwN5_Dw#VzW?P@KR)o zErzrC?hKp}u*G~>W^Gs$iHQw$Oz@CxGc> zs4cGSLwyAhX+i+xVJ-cu&3+PC)wh>i`34QVFEYLH;NGb|qzxqQ>XVD?tT?1qD;9Sq zYN0{Ci>1xQ1~zX}HqtB();c9c>jqX`gOuBD{JvqrcWYH$JTlikd@z?h+I2L23_|Q3 z5t|RSmS{)pygTp6H?4IHx*fNEXaPSwr=(uI>#(l;JSoFAb~lr6xlBe%c&fZ+=)JN% zPp+z*H3NfmdOx|`c`@A|o!AwOej0TBb)%_;CS=2qGDSJFWOe+Dw#T-Rw6S0*i3ndw z-Oxd12dT$d^uI1)h7A6xt=`Cu%w#&>){^iH6M7uvj=;4=3k5%EE#y!2n4*4!h~w;r zX?3@n$uO$|AuPud7|#eKEJ<8o^T*B%@UwGsi)cg7(?&<1oM}$L6-CDRrtG%TsMYf2QlX0z~KWze0=PIPhnp=0J?NXIJk;#^uzraUS<#!KmSUbl#-v>?!(EQ6X3 z+0`x`=o!E4oBMilCF>=u3Pjn&+vkW5h;LiEjC$x|p9bx?m8#+H$+sDy_r2U<0KKi% zG05=0EwkR#m6PgQ3aZJ!(kumNGP)!y5~DlAu-n`_5wO*MZxYCg+`-x%VbV!a z&RWrLga1a>exmq0SHL4qYG=-{s5OMMZplOWtzH zD<&Y;UgiB_PH)HhH6wN_e*wtFESR{Jc$tm8<|=H?xGLmfUwD*R_u+H4JMZzYv6J2U zIAB428T-h4>iZ9U(eF_#b~;kYTeYz~k^idsAr@w^BeAn+8xusM>)B7cJe6%n%7JcS zm3q?gy%;}(nry)Z+y_+rpzZek5s}ULHqyOLP6M_<3048oAcEWFYh7E{7tDQA=cpr08k&zJ z*d%x}Ji2f48@QWW!G0NQ)%}rgIe)9P|KCH61TfJ8n}bMJnIUs_hfn-^4_jc(Os+$h z64UsN__zXpkvjoxgM;=0O;unqiF?N%ik|Sf4Id}?sFtq)@s6*-?KU97%SaM%Uj^ev zeLSl%*;V~QS$vJPDMrkzM?C4X$mnPg=E(o9&`rRP-31mhlL#);jKjF`x|Ghu%2u1A zq*!}gTc9ja(SFm_bdp?$6w|u5e){j5`>EeGU!3D^+;Vl{q2F$Z2^?s87`PhHx6t9e z^943${C?YiczpEGxjO3&AcYHKw6-ntqXzk0})mx-MTcU?P zm*x?&%;vMMN576KGI-=T>wnG?N3n-vyqCN76Lu?*(drJ>Tn~>^+vuK1?RAjjAM9pH z&&z8mnSqmvHV zvL-8XiqLr9$DZkepI%5k)HBP0{HUSn>~}1y9J{1qVsf^M<+j+2D|lC+fS%H;Jr}a;5VQU2Fl3jYxK^>Pd0srdKLq}YcW5{ z3A#XucqY_85A9{%UF#5wjt|LEUr&pq@({2Za~P8gAV1)|&ZYW%)G1H&Ji|sYgBIDN z@cIxHVm(x&kW;b5__M@5r$F&`*iGlhH!5Bw<1ytOq?i_mA5!GHsUn?o(7{cp@aHjZ zQbK=vXA{ZaqDax-qD+iF+N8}qI%S?m%^3z?ZL>ii?lTchoQ(FLm_^0_s-SycPPFG2 z)O5qr%CFY(fPgth{`vFY_4B>Ss)^|eH}8&5%P7B~QP>o3bYx%izZ6JdkZ$NaPe^PZ z1>&F&#`53z*l975YPs&k~Eb~{^(yD2Pri--@=8l zE1PU+;o2CxtO<{uU~{a0MNzzoBwrB20&nXR)Y*jC!fwK-kt1gk_<--R=@TwWmoas$?~q1~Re4V&3dPN;Ju%XT z9L#$ks-RNt0SD3B#8c%Dgg90xqNJ~G7vRYfbD=nrIf(ur_5TEHn1CYN_L1Sw?z^Q3 z0dFk&UTLPR=M$z(*L>D|+Tj#w)GIe!6xsus6=FUV4zOiw0@4=W9CP=$*VvLtXMKuS zK-3ei(ZdyWpz3I52Y-~Z))Kb3n)DDVEG4TiSnhq_c`iU*9;9#tP{B3n*8x_&cN5f++{u7SUKVa1T68cY&l3A!sgVjPU{gojLQk1_@5AgKvz{$Ei68& zH_sxfIxKLZ01w`l#8k*zzQ?7p z(^7wl0J|i>L<_0Qpwe5ytR5JwCXXWNxEr*hLh(_4RBfWav<}03@S^&^nmz!UB8<60 z(>eP^R_LhgsU!Jp=G9C=RLk6YKbT3=i?V&#lD`{*zyu>YyZgT2g*_oc8{9$BxD-av zx-(HrD%@cgQKX7yg=S(Z`F}Yb-3Om_-v2j-@jp`ZF8^*R)D}Bg@=}R1b~-8|8}}^Y z=U7uX+y^NuBub_q%<`RzCTU3AoBc@mpP^`A5|@aaFZTC;)VIQ0q=sZ-7CN3gDfUGm zn{On38%X>a_y2yD^dIR3Fi3nI%nSj*$Ij?s^zJ|U3yaRM_$m4w-WfcQPc!JLP`T#g z%=O>7>I&1jNFn$b?WH301DAmj2!V}G3;-O;|2@)!Ifvk|+&4|Qh@!}qYoG#l$?s&{ zMj#qJ5jOlGKGqX-l{_sMokt&)EKCv);oY^g-Oy@fbaK2nQX2)eIWofFvj#+#~VW%MfZX;sA>*O3g zWrdVa{i{uOc+iJslE_o(|9;T>_Ytx!HsEd1^zl-M@o8J`*{@wWX;+;55Pz_!eL|#PfD_4KB_vySH&zU)rtcguXZVp?EkUa9SR= z`-9Ne%SBj#oG>;Mpd#o_ljt-)BelMjxO=Zt0cMyiI{FU8bnKY!juAu7B(_1I=KE?w zlUZKZLMR@nhNXd8IV%3ax#Y}=_QBq5;A}>GNSI{^|-wJKUe$}t3J@921tP^zbMxJ428sXK3uwxe~{9YM*I5BhPCt{ z8J`_4cG3w9WioVKw=z=&`G(2(<0HIFJxh-!71O?|5b>X1|Bj*sXRcUeLMAJCYvY+s zA;6XkJ#~K`m*H6iTioxJ@1%3O+`f;=+7P~xuTcS#iD6&aNd4CSZ@#&h;h7omY65gF z(~W57-r2GrrbdHbS#Bf6S*oBh*WG2*yBM3R>K=sE)OF#(0oPn;uePPn^Od^pf{ZL12LKxR47-IP9O(5lT* z(`9dLPiDCa=M&(Vt%BP|z9B`)4NTcstqzpC+bN-j43wO$I7`PP0!So2;I@NjP|ZXF zHhCkW#@cVl7dH0wfWuD0)K$4OFhpB6#&9e{qb7nUIA5P3fH>r2rxqiJ5{Gc|M-|+f z_!b?Wy$bx_Yy}(|(Tw3@5C;|S0XAl~>=ffo$e8l%&f!L?^@O_gdvvn;nIbiHBV9Kg z2hy63E4bs18DW==9MV3ZDS-YaP>Y-exlGj#FE#U(~hDT<%qAL!S%_@2Tt1jsh~q zY(vdLaMYt$i^9Lp!{}h?N%BVyDC6#?JD%&*t z4XI0ec@uqu)oEUzYUJ*?{-HClU5pA(5JdN5?Pw>+6j-&;Uvw&Yb|3-nBu=1vT3sH< zuWkC+4U)@6R?h4nz4_1El7Mwg3%anb3`cyVSDkx2`auSvs`_`-3D`tm6i&i-gqgGS z9r4HT?4Tm!FPyyz75RTR;BcW;B&V{*BVz?Ucv{ykO>L9eGvM`aY5_x+@`;k5*WTzM zeFTwGF281t(!%WQe#&`j)p9qiYEWK=MR?GWGX`M6r3P@=RF`wjTbm}JL5#rOu^V(` zJvX-y`A8W|N#X02$NxvVDh4`JaqYa@{$XV;u^s)9%%}1>Drlm0gQ58dB(Sk*Egov` z9Ebzp3Va`rhA2Bh5ee=l(}lrTkCQ#dN5+LqLyNx(lC3OAiFh73cEz9eK#*bBCY|^{ zX}KLWk+urXfuWfhfYOejW#mXc=u=Sp`$3OXvJ0I*8UuNz%O;JUw;+Dk+@7r)=z z7o4ZD_o4B3cizhP(QaUd7%af(3!_FCTd-6%FjO`I>D(hMiN761x?8^@NGMTXX`a6^ zJ3xgp_u$;y%vHObd9-{c=64hIWqYXG%&<+W4TF4h)89EPI$3Kc^=Lv{CM6_ZV1;^z z@YfSc1yj{f7Ua#t%(h^dC-OW}QC-mGSl=hE5YCP9;yp9qa@n{i<>*-DwxSrUZ#&m` zbymgv5`ET2-1)FYt7CVt)3CCoFgoY>CkJp3H}ySx8&&kFs2zA3+v)yy1=304Tv4h; z$?;41-4WK>f7OEw^;hGg)`s+lx*;0Z0zE|g;*@4&c|O9h+yMBb<*QSB-RxRwQNJD( zmfg>ldGGNAK=wq`|6)!!CNq=lDI|~&Q4K7sOG`t784~_O7IfLTUG?%{9-cLui2;Si z-SH3at8(tl8PW4$@~^(H-Gfv1_bK=VekppUXN6j$m=WeGtw?`Gp4G-hDLw9NGtaxW zQx#2t#M>ygrZw0>mZ%$m&kY55`(8JN4WGkbZOK+!l#A|N1twkLDj^yPYb+o30Gg{L z*!9{U6iFB!F94Rx(hh|D9cAizN7*6ASyRdfS~Oc(GaZaO;QSpr#8j{m%3z@5luo#+ z?>_3ecjPAglwJ>N^(!_jv~Mth3_my70E_b%2oaE7@c1-yn{MTTPG6h1YXz7huO7?o zufpm5ufiy!0${V8I%zALNA!T%2|DzU>u^o2Bg9*|6qp2JEhx4Kc3k&MbbvD?Rc^;**Ba7tujVY5%CvsRpw9TrU_JYAEm-A0?}X>G4| zn8r|Qw{Y!ML&Fe#r<{##zQ})sV~d^sSS5p-=}W!A?REyw#k=c9txN*FG#v!lm^-r# zf5>=R?qXf}Cz50XL!+2u(1oDq2%b$@gIoGSaPs_7+0g0y3DbXG0cKLY4%m`F=Cp4i ze=h22&dhBfU5Om@k^pp4YgZq_JL$Nj1O1ViFVlh-G%!A{xH||-gk}*0e>fPFp167j zrr+V14&m|x+1{xkHy1~h3zWcom_{KC?#7t39DkkH&ilie+DCCY8y=R~B-SN!T_CS# zh_^uec<8K=5|7aZwz)^YM$8@_u3^*IVrt-;r#ix1*gDrOiU0WlxM!|WUV(?L3LXn? z`BDPl!XA8?2JmXyHVNPl8LS1r}wkbC|z;xEHtvcHt>Rfvy>^^ zwUGnC8^f9J9qW9JR$R8{tXWR1tL8aDlRkS@X+2i@r!WO_&t-hDi)WLfD3Qu=c6=`{ z?hynCflA5|aBwK9HVl?FQ-2RFv_N|ThR$R^dvP#0~# z4Rd9!*;Wz-{r4edTs&5Gl@6@;m(u3%Zw+Xakb9oel_A)6_`1kt07YHyfU+RrtM_Mf z(gB$d+2$*+LX}a$JxX;Qts1A~E@w?yH+nBjj_=lb*!dK0?O;4P{0YvF;%Y_Kvh=Iq zMS0EO*XuUDMx%uL67V!z_I6UBz(SW#FTNe|j!{x2fyP0E!I4*)h_JpyAE$|CV*M@&dwRXn7l&K67n56W~eIQ-V2CbC#G6iPXZzdoou z*{bBPh(VrVTY{e8i($%R^jT+S#3<$`Z?IqFUtGJ01mVp9oxt&HP2vuy|88ecQLJ>K zm{e6dE~+X6{*9Mk0Op@ox!&_bl`qsSA*Q!pwao@pqyH3p#!Au&K0uzSI(w4WB65&e z@2%xh=)&UY;PU5Xl9AIuvBjF6Pc=`Pr!@vzm$J(x^y*vMFMmkfyl0!xG*$vpRt=zn?0DMe; zD3}Z=p)D4~Fz0ra0Fy@6{P7sdjrfL6dhrMnAE`zPzsbtao2k>%>*kjc9#eo9I-K)a zm6)lswD(c8MMjZkD3(WdagOe3bMAyP4!jAS*14Y3s{n?@yrCYv9V3Z>hoqX^)K7#<~%_N$jln}|qWmL{(IlM@&f#`0UruwkM6 z>@S7Sq04SO>*(*@e~#}^3o zn2PK6?tKw=$cDOLmHd|Uts6v)q8o`1yuXf{LFeS8m}V9I41)&vq|&r|-+WRv=01u& zJgTcziSq+Lz8d+ia~WfCc6o(-3|!doX@dg?*l_g8!*KV zm2@1(uNx4o)5I{R>9XU8IdIw5*CbW)la1KEd_2S%W^fWRi5Iqknc8$5D-A#@KA-E)9%c^v47gaYMr zs9D9|F=9`!_=15mxDe1RMtmSf+529FKaRBIsm-GJGt4Lb_u0GQF+_vO3NULtW;Zd- zhV;H)qEWc$FrEz%PyrIYbM$(lOF-NcVRpyP)C)GTgzq+jNP`m2>G;`ozv=52148!B z2ctKRxzRsWo{ake=syZLv~7zkxab($8+WRqvDr~dZ=x77`}}DcdSjETBQjVV7ANLL zp0+VxRl7J={OLa!3veV`*lxI1A&LG%c|`*T6Oi}-B6|1%iAMK{*LqqD*n6)L^6YKA zPwRK?46~%jbvz_hd*k=K8M8exhfWv?J8MG4`T9oLCJ5crG#_sLAPzJRd}bs~V~Y}IyW`-{y+L$&QgwaK zO3z1+1PJQn5RWnrzE`(*E;WXGkhuceS?}jIgHqAX=zWcBe?huo@VK!7Qf4hv7leQe z#5aNgpv`i-?IbGp>W}U|=JvcCcLg^Q|CPwwq^SS?x;jZzz;p(ZhWClZ+M465UoBFR zwTwcN)Og%8t&+%QFdZh~)x7IU!jYN#dmL~|$}x!ZqB6YTw=R?$J{9Yl>Q^;LCM)QQ zsS?9?te_H!33vE<8A(}cZg&c>QO8jrW%kr<}X8cVxDbf-KKv)w}NhHDxTC(!;0TGHXfXn z`exz@Zk8$84!H2#SWFmTgz8}wLKCtwx?DD`g{#9raxiCU?j2!9p&AK-U$}ZxgHyp~ z-RNhH-+I1{2a|1(Ad7|WM0MUqs$TlL91 z=q@bU^vV6JYj^x!a)qib9b9dj*l{OD-{p_nq(PVvdkMIR@tnfj_$-~4TBQv}ERGp3 z9Z-LU=O3m5P~vn*+*gt$p)h;^2pql9Q_rRPXZ(-wmx+vVL+OJ#6O2vZlk8K|{|t2j znzQpE7KVbuy4UX|)$|fumoqFV<#OZ~&{{o2=%GT8DCHfdPhMTDv{fKb2SkB{?`q>W zQ#s|Q^LWE>11`d1AQ7}AUraW`h~L$eiM*$=66t*O%X^89fv8jdXnVMe|L<*>?m2se z$7<)NIca>MpJpI99D(+p#v;A1f^PHS(*Ji+6K1%>d1{be@|0?S)ceJ65mfSX^cxX` zxC+xee)6qhr+$dJAy0#*UOXc5968;j|6TZjzdh<4(ESF2=>ys`_^$?rZ2#=+D_we) zypYx}dw8jGf|=VEJIEw^WoY8)dc&|BhFVo7k%*^rOzFD~S%-tEu^mct29M3YeR+s{ z;Ja7-9n{=d$jj6BT8 z!(OS0d`CW1B1hw%a==-zY#A>sFCY4oMbkqRPz!kr0Zoh=21;M<;B zp2dshJ8p?4zZMDz-NXmP;%9uOC`zK>n%h7iA*#)ERi0%GFP2!o+P!kxQM`bZ%5lIe*G1!lw&s8v~E|vyI=~274eb%32%17Ff&s*Qvd{k@zv+#v~1k%}_{)Ek(mD~D(N zcwdcws)72{ARE*NmwT#L=Y9SUQ!!XvC`@=Lk-gpqC|F`wU*k-*pO)Ft~b_REx|9kuo)6q6+* z5w&ASKLMv!8wyQ~(#U<0oys4|?`8<YLsz)v8jhy3UMoz}s=8&)do*Q-D1%D#+x z=_%_8bFT1LX6>&oNLn#i)J#TCg}tTdim&{>)2aD+(!+j;^QNS^wR#;kM}zpED?U-! zsnsJng6lH3`jtPXR0uGDLrf_d90Y@&W@c?CwPGTiXDd%%OVz8)cwIdxzY;MqTTW;W z+paLx3#c6nA|G5=5WYCcFj(j0Co?-Tpdd}Eto)VP2UGbBCp}T<4O>IMZY`PnJ^X{d zPYb>z!v{_pbXT)QCmDu-`5`s02Ti&s1vziWFyQe}-VG!26mF`Chh*bBK~g(O!bKy- z%33;IwdU;ipDa=H%3htKWuSkHE=VXeZYloxMVX_>7YVdN(N@H_)&eo?VKJQ(kJzj_ zE-3iSaih@#g>dGBUsXRSdFW&Y${vhmY5uxks3^Pf|xZxsh!H}F!Z*Y<*>=dabGb>grd_t5BM^p;&N)7f6(iT zXLbcpvg!J#%hYY)Pca&_la1@trYwvb)y3Kg+{qY6JNn<`eyk^OEE%xJm`Z12VNz~o zWq*8F2tASJyebxOHab6w-BZ0VzFHcaDM^gbEPi^o{nmh%5D*D&8XkuYxv107xc{6FZ0^~ zp2@lAJ087)M_=cc?}Zz;jDBN+?2l=RF{LIHJ%7k^M1wx2v1jk@ahVMi{bQOrILiN! zsLT}U-QTr`G;KZd=XoMijM8!}TQT5_tVt;V(UrY92clzZBGQ0PgFKZk)d2d&`a$n2 zE(i77BP@MxJct!m6lp{pAF%g?d9tJZ7!kfK*_EuX5*2`2F}0D_S6n$*urGi1oB6Zb zaauBm8TDi5AQ}3Q((%7fbT(T(*(=n0j{6|Ze{gFHvc^ zLVrw7-ra4I{}gkN$PsNF+M-qb4dZcp>M87hn8ex3t+sq6b&~BR<(LH%UdoYwC&F#VZerv>xi~3peTrP&r%g-0g~tO)I&!C%Aa{-lbUt zJi}8{GHpxgj+2!-)u2eCRmd9s$7m+To zPsja{VU#{>1C;eq~LB@0?(mX{tL?RdH#x zM43NVvOWh|P#Qn(I{HoRP2^l@`p8i;rjF%~nVL2pHeu&3_*`xF>AS|G)=vsId$U9$ z<0fSO{vAkBYg`@h;o2M?J2&IEA;Qc7ZnW**JYUqM0!KgNIw4ZSu{5MR%VTF+*C>tN z-$)^wHwBBa0kc55odAHN5Ibar`M!`!sMHzMP3zyg`}*>x1uHFnliv z_xg{9&G1MN=oXuuxL((OS@LPttcCSeZcxi-^U^ySbtw_WQXY)#xVNLj4gAz!xZ?MA zGOQrI6^fWD^@aJGT-rT!FxP?|9YikH5G^-@O7I*HkJQW_H>{~>eQ`FdWkg6NkWn|NR1Ki z)zSkRhcyBcu=tLQw;)>4_NI#nyFVhFKxjxB2VRRC1&2*&ZS$JSO$Zfaj^goXCuftk zTCMqas5Xfj-2b~{;7>h`Ieh`d#epLhSN@~TA*#L(nZlTv&FItW$ig2xPy0Gw*v&q< zeAOJv$ex|Rfp>J4)+661ISEs9TLp&pejk;ketK!AfO>q#3w2oIVBPv5kRz6Ha>B6N^?Jc#*N zNF7QFNl>`mqu(CRIS#ghG&-G*i1+9o ztCsa3QrK{R>s*nXyHITptwwKL4{1P26mdp>m!a#)+$w$C@`FGT1l zD5PW4@FUDm1Ql{=)*cq7eJ`G|gnCTUWWa|9y;ZK5Tt*K{0Fxc!pFm!rE$lrF|7>oz zcz()@sXx5=(3cyzA4u{FhlWKRm?#?fmLZbO%`kFOp@*x&4$)N`s*e{I!-ow$S7Pzv z-d)Qwr{kgy<~?wX2f&UhA<3L%rr_G)5jyEb_D?hOOY|Kp%7g}cR4BPvyd;D~2_R8S zdTukgeZcnk=A#G9LVGM#GM?#%gB?8FBYE7aesDBpMMI(PJSET5cB5~b(%??_}pki|?f7^1Pf zjw9(-e#w8qC(^&Sm-4%XiRZTt_wH($=vyHSj_2I zt+ntmH+xYcD!lr}t1;)wnbI%diUZ%{+5*Y`%8cU4xwhsKmz$b7WR1ZKF0@&4;@rlhfh>i)oKu4?T zY7F3!0J8miK1^nPfr9Qkd-DR4NA|-(z%+B#ir&_i@aHnC#tbJS1=2OV{C;X;z%NCr zcOOPd_~LN}@1iEX?c~u19XC)WBk!{BCV3#R*b51=IrIuxc6hnMRN2;xx8;}=O2G(c zg=@z5o3FN!x#V=-5+C1~c$7aC)89|d1h8k8TIJ!YayW}=HF>k?b~yk1ul$@eK+4{8 zubABpbZgkl*m|A6#_{}%6SnuazZDS0x)`MM*OTe$tBvAh06@ao&C#r@jyhg+=mkhIK8O!sHWeT1UxN?vk-$n zLDymOql^v+R2|l&N#OT`u>|a-Yn{$(wZxtnoCj&C-76Q&Tftm#1+RK~6M>&{D5_3t zP{kiQGf^=Y$qs^Q>D5mzNCFW0%E-1g5L4Mvc(a&A()r~2@mQqKj=g?VF2v^#hKs2T zV$7xfK?!{6_Itr5KR6`fUVpl`m%_U6fMs6??xqjuKT+~wtB$SscWHJ{w*_54xbB-g zmP-FAzBzm((du&l!tZn5?On7W_!TP;nid}9F*SWOs+1p+J3bxw`s`^~Xp@G|lgl=I zcMikrPm;)MG)L}b^X4!K+DL;FS(WULJdUn{w^_QmOdI@Sy4KMp8&R*q zhN6$aE)r?#)#SU)k+fYo0|m&-N%jIW_x{$ z(Yn1kV>R8bTnsyo+(^^1@BeCJL}G@#`e(aj(K5-q@Ob{4<7*m#P*(=uM=g}yDMSeA zzy+<|moN*SJz4xz>%sWu=}vm8Fl(MLLrVmAL@aOnqC^g|AX!F2>4%If{zBYg)0Q;1 zdX1+$tfDCFK7D`&or-xec<=TE&*kXZF;@MaSR|Coi&&`@<5evS z2FdHYGc!w`1%9GEqe=5Het-$V*ew}mI+0z@2b zc9vG^_>V+-^HC5r$?<7$U54O)$4^6cd&(!H?UJa}@IHVz?hy#C#TusAt8|@~GdET0 zy%SEq_2mLI($FB!nqd0<@3F+$*WcwyH^66{j@#75E(A_9=Oor0a$RcBzwz%;(!NUZ zG3+k^1_VI%xB2$^d}lBa@k#&wqx7A|UW4Yp8@Lss@k=?QQroe=7<4rx*&`E$n+o_!gzqF^Jj{GKktFzBsg+VrSMk;H~$tmexG>1Bk|d(h(+_7kBEtYbMjr)BAm_9Dr(CYIt<63bYWwWnzmkD$4h} zHRmO%n9PEAx!w{(KF13Drb;ANhRrP!PE&Oz<{Ih#%OOv9a&Y&U`+a|Hu?(GB5^Sic zxgBwFg3QYF^LZ>!vR;R$?9-%8;g2V?i<4i%e5Kx1Qb`PUUVbl-7w2RCb^(+MB}TQ& zJ6oD%>Gu0FuEd{x@}iWfjV0`how+4Q*YM-|hEW>+TlbK8nG9A>#OzwUX^WY1-cMh& z7HTn?Dl`FHMN+xRUU#{NbNfB(RSz{C=#TMfV2jI+hz5_CD#0qkJ zJgYMOPE2O&`7W~D!E_Y~3&{;Z4}$%D# z;LJ@#5Z#Xq*=@+po|I$b$u}PZiQe6b>Wd>kOlB`^!a0jP!*sXY^mRv?5X|k)$29Dl zty$jk_?iR{j>0vX#hI+}-Ushq(?rxCwWmC&3pM)OU{REa?K2bm5Y~}l!{!x!(gBt) zS51(lQ(+e715R1z@S!e*;Z+*ZuJB#ggeP0O4F>@&vRfp?z0;3pHT$1f@5IkL7gW|x zu!nxs{#)!@z+JWu(bR^=YPJqRWLi_*8WWmtex`!sE8nsL;n3uZoaYx7cbYK{ci z0BBV&SZq8hSJ{(ayXsT#9o2ol%VhY2`lH3U(4Xbbjko;ry9vR9UDLU}qpmmG@?UbQ zz9f&X_{;0?_lT6M#u73Jz%$y&E9kA`iI@?5eXPP8|7%pygwNxH8cD#Da}mu~(pP7L zlLL>2Jy%8gho#Ho95xjbJZDVhYN;J{zS?FMcY2YZlTCQMVpVk8XeI1i95+Y93+HaH zzr*wMUG$i_0+}+jZZDG!(P}V1@OpW%TIyhr!5pA&{?wPRK4!Dx$&#ux$fpS{Y4QZW zq2)`@6HSMHMfqz0pN7dLKE^%QZI0rH7Jx%zCu0AEgTE;BBx|I+4jb|F+SABL-5>i{ zW1~_|fpi|cR)jTgIh6w4o=L87R{w&k-jF3cor*v)zXDhKgXwZpb~?|I8=K0Y<3{b; z2Yl92%C&aqL~(f?>9aP}ykX;~$0ahfv9(6EhNuYF>D>BHCsXA`?4G)1Z1YR&s03^u zy=@iGl)d$5OoysdqX*f}d^=osux}4F^urt2R8<=mo3&K{k?RB#2u61E^_mcCSw2*) zhO^%5BH=N9@4IlC`w*5ZwA^z9jGVRN>RD^WSXD7iotvy55{Q`EKV(;fYVH3-?^(=l zi6t@nTCO-f-J7hAR1aV>tUUpuUu*PjzR01wI_>&tpvIyZW4BLVZ)t$jbf%0xi*_OW z?WBQRR>kt+u>DB;iEFI)y#&_L8*eDBfhn7k$4rE4*IjajaG$6y?RzwbFhzpqN6S{S zCeF?V6U=`Z;Th8ha+QEK6byp#k3bX+LlnJkj)fFaQ^-Pu=5?QJcpTEHkf{W2#}M;h{USVZSa7bCg1d$6fHh?VJS0S)S+Uz2chqnWg_)3j$ZQ zr=aGMuc54W^Xu@=lgsO=W1H6_@wjGNW{mE|`5Mtk(r2y^B%)Xv-Q)-&W>$q#$mtQK z-<<87d~^@q0g^}K4mk%(0?U=-*Z12hzz#N{KnSBML0nVf!mD5(_ z<0r30vKXjBbvM7fz%9&AaE>#b8!a#a2-RQzArnOAf55t zmZ*@5W|c;i+4B!d9<9%w@bH01GLp~e`KUP!gEqSSoQagiXq&J$e{;nu3t=ZMqOJ} zQD;#EM~smYqeb6v+>)(}}4kHBbpy ztRU27LBH8%F;2j@K8B@N@ocI-L#3T1<4<~`#L{N}h7Au&eL_vlDok{3FZIt(+{q9Y zA^e2)mRYh~hkHT>eU6yiw?-gu+%x6PQO(PW2H(4?$;IG@4=i)tkrEAubv|dJX|-EN z+X7mFl|LT;>3Fkd)xknwp( z^=HG|O9klR=P2u<^;?oCW994}O1W{W+qAw&bRgy*k)u2+A#YM@6Yf{pS?iHL$-cFsQHl2p#fpFpLucf;!pNRJ`Y4(+%yQAQ zv=vu4(9aH;E(!GTk`iM!UE3MmaIRcPK|-^TRE7W$MK1k!zy^YxgKuiXf+G92F3o{ICQE8bw6OtR zdvwO7iH|&32`7EdQF(*%q=ZpBsJng6VHf1R1X_(*7*Q{X9V5qc#Y!FtYrWfo;6c1g z(oJfAg7Gy+R&R+5On@g4Y!>obGMpno>ROo_w_n9COmwfuR!Of*{&}7;H(h+gRvSGz4@>9E*Hk8#;bvE9W18IH6G?2@H`(?PA+Y?Eh(_*)_p z`To1-m(DH+O9aw-R9xMDj!*A(*Zq3VUdZ^`rDhlJ-CNPuYh*wtN=1t7(pb!iED#$W zyc~T2Wl8TAd_;oPl){(S#POaGM~}m2&PZ&5P(VpvO)xS^=z6z%u-G4XqSfY@<`w2F zc6kg8wxHTfgnRpI*eb3XBA`B2MsNazuu=PXbo1<;A&5KL1%Fn*t3|(YtmL&Nig!^6 zwzQixz&JGT@S7FG>u5F1cCKdCB&#rBGv;MZ4_bs9OCJ_iK)IOqk@6RdaUDU(_ z(>bj9$1}dk#@#>D3^VA|-e$V&n)LM8y>0L*psBiA&Q~)xTB+u2v;%))iloKmbOchz zp38kI=iI~$hWkCpV;eS|CtR>U(?L*h)U(cL*Ql!VQ;OH?8O3;hVE~8gg%}E(9w69nv=6YpZJHJ>)l?pe-0)ZpL;X z823#qnHC>%apMu*lq;FY;=P!AzwMJqt#ERsp%G@CpFpm}_8JXLxnk0}m4E@LSNFXe zc>%2yer^|y-kngF6~yr0DEuHMt+HR~Nk<+~s@3X3y@x$G8%vf(%-5VwR(&xwL($QP zH+p<4m)W;6e6cu%YS+42Ils;F*eI>cgk~?wuU(MZOHN%00irt^xb`_|qJ0P_;v^pb z0_Q_8`G>a9W-S3OhFIQoJ|q5)LkcZwE;c!fhOPm!0aNdMf*eW0mGBSmS!(EY6X%A(St(}|p?N89bWJ>n-nA>euDf5DJdXhVlGjWFph zkWz9R(HxgE9Wab?G*T^^RG{}6nT(y#_LL`M+eA{VhK#S+e|zhC#pQTe_)BIxlPzCE zJbKpO@AJsbg>y*1om?AqETyOVS2nn2YOua~$&N6KH_=C6Sv=c~XR}Ic(fiS;n6lUa zAM+{k;69~x`MK)cw>PhKFK%*62B(0{hb)Iu3f>Xzvw*Nh8q6Mvn^#=s$*8J=D)V(w zF+udkxa5AF@cYsVW-P~Xf0m(r+$lH!==keV&q1qYwlT|W`zyIix!Ic9jd$llgU=<0 zG+k^qwXT}Hv_$xiBd4!)-qj-8dTz;VCKGbab~;7i_rnL=2X(z|j;*@>$Hq`Sal7m2 zfT4QlvH-!u8*SAZhel2gN_gxo9-i6`n)Lm5hT|_(9@aD|6U(ea+^^D ztc?0&B?i&KpWUI#Xpg@}mPG`u7L_(S#yL%CaTqw!zQ_B}kiOFz+8Aj(q-b4EMl)Xe znRO_)JJdH-uK#$NN500wMcvFBre$eppjN{3b?hmO7+PB6hqo4icm1{?$a4@ zOnsxK4e+>z+;?xRXR8Y}zR~-Cp>`P0qz9$_DO5qT!UaH2zk~7XS**^I*?wjkvrbg0Z^GLd6mXn4X%)S^i+TgHJU@DNQh{R5zl=a zO^5!bQN_x|44y4FOhR4AUsV1?ovQ#jI&^kZ4OuXQiZdSGwYyiAWO+t%|Cr8T_k}&M zvB+nIa#f+$vz;5TxO$EB2eYYAawv+7P?B@-nE~JB`>^qOfyWzEuTuD3j7qry6^$-# zZee=)VOM-!uObLzXm>@uo49`c=odCYN`7=dQJ;)EeTjGGyMc9zD)^b?EXHu4km9Td z&*MoGou^A}AGMZA4(D>@JFTCAVW0tRN3Urj5;&`c->?~oOCa$`Yqe_G-YjuD$q=7( zt_u{pC}-vUAXqe?N$j4gLuKd0U7^-IFe$t#iaXrKrdCIvYIu+?0i6vX5hJZcySSWVlT zW2z=oMr`V|17OL4=ZBbP^y8QBbR;ON;o+1kPs!=$h~U^o^9iL!zP|L#@{=n=aTR z=1eJ!WK=jYWsT1Lb(>f_Iw8&QW#xC+JJwo#`5Y^ql!Ych=<%exI+8om%uJ$8Ld&9rErl3HO#d4ppj+p7hj4rWJaewm! zlziiiOkjF$kx&1f5cjoKT_%Imo;?g10MBhyNc-GXttpTx$lhW{YtG|m!u-w4qB#!C z+VjcnaWT4qF-;4u;corNuAy5r3#A!S*Xh`rd-gzNXB`d&6?r6BDvgoTx!4s z$vsZT{ezFO3(6ug_`Ue{CZNOVD2w~v3=$XQ4kieuoxob8D0l$eULbzqGsbJF=5G@9 zP=AJ%>N_w>fA!32x4{6)j~klsBgp2PqLf7XndOyta}^NoIbS(-H*Bf%1rmS->sh>C zj(J`zegP+QU-9+KFa*Y|qT9-~VO5`i;%03cE#zEHE1A{hs9_0OYdQbc74_IeRif1Lz^|sA&?$xPJZLU?1tGEAtw`yO; zOg$Mb_Ef;gm3;Eq>A1qNbzW(D%)GkJS)!hv8NVRcm1aY_=uQS>lma`yOM$K^O5 z{DZAlE_2y)NbuKoNLUyGO<`U++$%2A`B0gV1&U593-!TYvC+uS>=q*)5`jkL4e1u= zV@kx71awXxIceWV--?g7*H819X{c{q@k_qZYX_0}oU$1HRa~cvX2Az0P)O{-vilQJ zh;w18?L8H|S7yQbI$K&9RXs6ssg60UeXc2X-Y`V!Hrt2l6oO%NK!7 ztNOg-^E7-f>K6JpSG?rMNo_F9R|?D!#z1!#QS%b8*b3ruR;toX|_?l2u~wk63Hy z;HEJ;(WA*9*JYENmOhMlW2B=Pa|+#eTRp0!6>sEU1y~|1Wh&?{8T9<#)P|ica@eO~WJ3Fr)dG&Zmo5#hV#3M&9dSU+NmcO@9 zfjjIb-D$ac=QzZNzhRO>e2|bqQAvUdf)(%xN|Lt&t`!Nu;5hj$krpHH`8420nCow_42{ND=%GiLT9^z23nwm-ak zum$lyOJI`q^r9e*P)mgFQzYP>?6>fZ=61wUb5VgqLWr5Z+L}3O9&+;`HTj}O?Bnh@ z(`(8NgJXS791CtY@ax5tyF!0UNaXbPvWiCV(|pG^j^)GR*&Rv9+(uNTP1KIil39TSOTgmyT80Xmw>gTW*TY zJ-~!7;%uh_$vR}|WfNV>q2thgR!En}_v@Z67PNINUaZlZq9Kr`5LdqXE79esax&kV z*Nk+9<89N%oE^s+T_C!ILqS+-$5RsYD3L*Sp2Oy|H5SML623{-Y&N4sO9+^@NPw zM)UO;eJl)D2PfT()K+9pvk!A_$^KcZ%T|)#goEZzad8OA3-#*K^l8xx`6~;krjv|4 zq`g_R*)WT?G=OkVi;sy zkA*eFBVdJq0zoRRg%lHsVW1G-z(Z&g>z$-QR<{wEcRM-an8!US3?l5ccQoN^Mi`Y_ zR&ai|*pjjH?UiTjnOGnNb#ma4W~&KxYF6+N98~3JZ}{qEfhQoa?i`#T{Bei>{)j(_ z1y)jVt$7spEe?$Ooes88JRP}Fb54jVhu1lJL**YN_^&6r-7O3qFYA1%B_1jLbCCdt z%~Z*d_0&3TLo`Efjlz?`CR1w|AKP^PO{>V*;*9W0H2fxgR@YO|=Grx0ATR$^3?TQA z)}JaxrMbJaOv28|YP_njsJDCZj*eJX$0eMt_=^9MU?p1LA$}EZOEJ7mHWkp0-`Ums ziOzr=27ee6ro2a9T5Rm^jRw~K`uy)1!SaJdd>2CsO6cz^YOgt>X_ zeY(%GDFvu=fl3oNu|5g$mEZ9Pg}sxy3}+rF@Kw(Yh{p1W`K?UMz{kC$jw33BLxza; z&ZDOBPVp=3$_{%i@Sa)^1B`%ujY*0CgAc;WMYA>22xmo9d?e-w(&r^R4I>ED-CFZT zje{3y8B6YG%5qk9Z)Ji-TbBr~uF_-;TidD&SuAnk$qZ*to#1em$Y4PYB>bW0&kO$B z`xJZs1SNflv1wqy@Y~_-O%=T$ctDWfXY%F$p<*!FjOuv%^##kxN#I{0B7Yg3$FovD zN1Oe!t~xeU$OvWORva_6XZ*Qk&)Q&k9n2kDlG@XVA9t2?`v)Z^xw?T#>nu<*Hf83n zE+fBws3Rt%S zUMvDPQLmd*{*~;Fdy0zYWY}A?~j1MBQe}T zCqtm||K52qOlLoYbX1_|dDy8YOE7cmotcJ?$JgENWoOM@jjH{x5Sb^2{X1igLbpn_ zGFue|hJkB9BCMm0aYAsAm|#v9o#kLEq~NWQ5VX)xpiy~s`SA_jT5W8fCF_559f?#u zi`x6+{IGj7Y>ISWqhJz4Hbejjkw2R0ncv4DwdRQe)@HY_h?v?G-q85r`E0{@bkg4t znX*yhEh5V%W)*8J<4dY`AFWs1D~`uahga1;v6aj7pv_zVMs(u3 z1OGiOaSk!>S3yK}es(A!rF`((_U(Im6Dq&86|A~IV;Gt=|4mUna9-=fX zW*_sH7?&o(kkx~RxX8AZv0~agAG+1m@z9U@fxP16!5O_BDBH;vi5C`Ld%TV!0fZWb zGOk#X78>zixtC}80Ju9_9Fr(ze3(35Y^qCQO>8_8;-B)y>9&vg_Qdpbu&0~eeJYE`P_FPJVp1?79n=-k$rm%wwF-0&SkvCHlOPXL`Z438i!*aOPs`Nfhxz)%3VqHAg2ajb*@c-JmlVG3^wY+JIv3eL!Z6( zF$L5b%~Y&b^NETuKb)@;*i*;;z8@^PM-6;8^!b}@(V}e)-b6aNKxPEDIOz@fWqL&}#y17KrxY zSyMZ|x=UlTL9Ba&fw--BkUQL?uRIY{#(PXqjhiJj?WjFI&+{66#<0qsOr?z$*X!m1 z>7^1@l|V0^-aS1%swHd^aY4qt`9(o$@-leo6{=u9uBqS|HE}&oNWDJ zBkj+q>INUzY^I*zJ8-MNC`9LWK~HyyFSLUvjgBMRAcDXfXVWXpzr|RGiaN9$x4Y>T zP&3{)`R4jJznibq`2l-LZ$YnhlXi0xVGV{Ce@tjOwVPpcqQwl|U+jWzQKBRcl+-sq zT_o{)LzYa*d_NpCTim|aYII4_6D36@qT?v{XJ+)pH&^i=B06SLGO)6d?wwQ{KYtrnWNz!B2Boy~;JRb<#vB`RB7Wcf@*-uZ_P|wd(Y;MZX{xvFSO#-#E$^BD>mcLU#i3oc!Y@!RdCr_a5*r-&ygH=%kkkt z`pM;8Y9czv$#G}@YDr6S{K3>&d_x-q0=(VM(nKn)QvM~}bB?AauPLkv}ki%bPqdbNFEK4ng1^PH;%)&ZB_l7-V|bFxo(sOZW6 zJit)X_A&GlYJCtG*>C%hr&PMY=_qS|1-Xm)SdC=V_7;pvpandCJQ~b2{_yhju*LYF zbJ3iEv}{B(CO~pxOXljCu!f z)FNs4x8a3_BoFz%u8)NX`1fV>*^2gFJbRf6I9G9bs8Hk|%>p7T#bo%`?fbg*KyKVf zU$EF4@4}?H*Cz4DDyv2`2SZ1mB~{G7pTb+{rig0terrkJPDaMR!IK>IM;SwLQ`Nx2 z-WjS$7XQh#(OnK4JI@q*I?7nA$ok(?`G?H_V8fFue>#v4vR90kavoS( z9F8)g8?PwVtK)r*o9c?fXXJ5 zn-33N$!K1ii+I}Rm-gql=s)|-gil7UdJ?;px|MAi_4byM_|3N}yvE}@hD#gkrNTG( zeR0Od6)t}n+_)ltByaur_Zy(eU*GhW9JZvO%gDevgrd(%g9sNA=+XzWa~niX((S zwm9uI#3f#r_fC5BXzP3{mmH54Vk&m`ZYMu^hT||}r9!$K&S5(}AG4IBoR_*x8{?h| zCzFB#;gLpgBH=%#)mVY6V{`&v%n@+vp{BM876LF3ts{3Zj^x*qfb2OTjr<3DL3`3a zfwT5|)-$-5FT$4h{uBzY+c${)YSl}TRnTwCRxZNwUFsOAvdR`N*`$fF!?LGBe-U_C4 zBGd1#swT9iK|eu?KcZfWr-e$x-6oTo;kcDc5{YInQrGIIMUJy-C;_+=mhEYE+cR^m znHG0HvpHHeigMAu`1z-nCq8QtZDe7m!gtKGuvw}))%m@}Dw9!hvW zCHs2}G5~f=Ze5BQ^SUrpG%XUVz);Rae>uv1_rdm|MND-Ym`vw(`jEuv4k(|kt*Uu~ zdYB}A;4_tl0+EDqXrzu#+(_EdN<5H$PQ}S)!=KT*cWvLWV*!jzwCe9zJ~1c{)DmwH zF({;QhpgDbVQn{PRqd-P`&9dYN+z|JYPZUk!Q-@%_&P|ER$LbJ0^lDHp4A=wrP{fd zNtEhkZl|GSv!A?S@&pwk35NP%l`OmN7FAjd)Bz%eQSeT?vG`53 z-%$NBQ-dUDMB3E9FI#ghtLx_*EuQL;RMFjbXU7**zy4XobU+^CP*wFv&X#TKgMM}$W_Yqd8Ab{#4N@WRZ3M}H@-yf%LmNxWhC9Hqx7UF~6t z1Bmsf1wZ4o;X6p*P_LmF-cGiggRw9WrQ`g&SQq|Y5cM`uS?5Jno$2Afg@r1 z7eui8_=wYJwUO$F3;K>K)(A=c)oVR5eYCbXBuGwWywg_HV7X?LV}YGVg6If!OxIsu z2CKv3$aZ->M(gMD_|CD^uT;7!jHuL7g<%=8O$z8{9Eq$ob?<{2dWL1ah`ELv| zwQr$ih*4j(sD8I!F^qT9)QyyAsdx9~YK?HGwzI|J4V<>c@M^@W zTf+8U8MN=jH*H&S_tKJ0fYLl2+8A{>TY}-$9SDaa+zBt2PbH;teZt#ZqXeFa$p)<} z7mdrVJw+GIj~>iqeEMDHy>Xd@oWo-%A%q%7urR*o$z`)H)N#q+&wyyaxO+I=xpbl4orUX^c`(_DRrp3;(+SK_RYzURnK5z?72dDDL=Hh*&y#` z3ybcik4!qS8GP=PP?k0h>QmpXXACYwBWjO_y5{69XUz+eWz1(Ls;o=Fo0iWxP(#n{ zm8K8cSLLXa56szN5)OX$-for5sLssx8|*CZfAuo>ikV6HJ2LeZvRY%^z#0k_eV12I zYZ_XHOED%uq?R0?x1(JkF0p-am5b%Q!{f;9QczyV`{MEVs?{@V8!@f!k(T``yV$k%0^;H z1-der3GHa0QD$9_2o;gTc2?%BE3V!i z;?z&$j0T$6#tb?8sY` zX*{A~{uEejWu{M%b9zj^-BS9=R<-0i!e!9ywLJ7t3|c=wasfYTiti{gxA3X?oiYlK z+CKg@`T{7IUqe7BqPsxTir-jxUtI9DI$fG(o2*0LCo+hAJN|;Y!wI@pl8rt-U+GL( z1SA#uv-qSkrOApuH5XlMQ7UDJ0;ldTlsz=W(au03W|vt=AQml2oDjg@MqS(GR^OPo zCsKjhAkhJFc@5CPCnlfooDb(KGu zn};5^2HRO_(e4rTGMR|P!h9Zf=v)Y%9XerQ*Y(qfPJ$TP(lRr6_{69rj2M^A;b|i%oeBsu^o81E57*a-ILs`#0Db4m1-P6+ zUKZVM&v+Cr6E_J0cF^tHCmr=&bm1&<9KDL9GPdJIn1y+$O}N7rWt~IzCy}5qHefY0 za&m^6ki`bKna5I=tSbf6HKGu+o@!Wmwu8Xf;u>O_BQ9K`yfi` za_I1pI-0Ssw(f9r52d6`A$8zJIowpoS^&sZ)5>e3(Vk{I9w-LfSv+DhF$X|dP8(dq zewiQn{3~yT5e44P`r6H4d$762tKQj0yJmPUQwM|UkfMk~@ZBvxs7IubcG?0|y6$o| z!c(qQLHAkym^kua?KafE-yDfY!;D-jqv2R;GKVk!lF}RL@;U*IMx)DvP`Aq?gP;82 zd85;JcI6~+rwJ^OMFWq%tx-9QKsP2`e@^mLBaT4&kX+5qoK?e(IhzR8y1WLm=@whHZL(EhA3^tLS0s>*_Y!v#h63_{d(> zU~2wO(#V{}cL2oLhamDJ)3?TI?x76|D(9vSk(g9BExS6{ynhtK2S@g|W}yx0a3z4x zG|PuTqDa)$8x2xMF9t}-G~})#FB{Jcf3VT@(NGW7w zkDV&&wo?*{XEc9buG9`!B%7UpRD`lHbf75T%%B|l17CKuD}wRxOp-wGE?Ml9BbWI}VismJmAqUhut0E z1$oG0$w_q7M#82!NHD_J8(q4T|DeM-TW^YuCH*UtlPf@gD(c}5 zADuN`aDg9#fGNRp_qN=f?RKUi(QL0<1T`JUAq*v)FO`2-m}l?Su(w;<%64hSg74z}%VU%v=-!&|RV z3gYD@;cCY;u66cyvFSOxKb=#2lNb1O5F7wQjFAh>MblJ9V49Q4+eiERuVk9g3$SU^;a^}<`3KBa$+=)E=s*X$HIHn~*bUYXh}EeLsq zC-vu(sVF!!<9Fgy9nn;%bTj^3`l+TRivWpBe_x1Kmc4RzK@$FZEwmAD&!bjxf{Nrte+mWK*H}*=U7Mg%> zpbJU4My>I~o8exU>$YAJ0yYy1y;tan0U)26#z`j7V$l>aj2!iRHp7pj;aWPLg-(CP z**rD^>({mX%GN;1T1o9$_tCGuLoE|ltq=#)7`N;&L}Qwd>hzvB7o>0>uUbL)iD+#7 zabkrUTZh1L=jo2~%){BI$qojy{2|pwZ!9fdFXpw@LvL3Ccg=PqCP5a8s6yN5cNixd z6SZ@WMYO9&C9z5wXn#sl1i}%(iBsnRS!mUL-=B&BJwQl#!nZi-fL7-wPv1cSA zv$$1;fF)+*Ig5V{!B5*QsY|qICU+dtLI-6hrgJgEWrMk76!RNX{O`jQs1OEclC7`R zsO^WW#=n&+0+4msfG&i6IY^JZ;I+k#HCy|Qj)Cq!_^cANKV}LoxnUVB>s7t_xi^-* z9(wKwq$uv*k1yYF5>vWm!9uPFaXUN6|@5i`UjP%R2L^D{Nv+*NY^UfV6?#HLaDWli*?i~+ z7xvzsYs!^6H~1G5Lr#s14HodCV4z`HSZ#ZVNr^g zui?0!tuLz#Gp)QL9mFKRHFnY8+>r?QIDi`bf$%#LrI?1LVy%AvTwqol9pv*wKP7+- z*&49|z{l^VS97;@HQUPZ0gpm>levY!sKVjdaOS!vDYG}KjoD_6 zx}7KqJ_wfMLB|E<+AlLKQpP8&p`40eEdky0a2y?Em}I|{ZwEeV$l}jOEjO3`A5(7` z7Dv~0VM1_+;O@cQU4sV;7F-h~1b26*ahKo_+}+(RxNAdjcbMY&-kEFu@rRaEReko^ zd#!uPg_Fi}v{p`6N8y&-`o>{VINIKZih7pe)i8AR!9{|Z+Xrnu`MW}o`>$wd-NGG% zPgu3Xr{9xN(UU2fWVx4*8YSI%dxOj6vxN~2mpkxPVp z>a@R`=lTWrVI)9-LISBXP7R`$yY(|iWkH~A92hwk8fw_gv!0vLGzBVf&R369%Ty_7 z*ROgx+Uiz6W(gCp->|ooQ4@Ki&df#bU%(q@AN8Tno6R7jg2J%=nDJM6!rUCN=2`8E zjQ>Slxd9sl!tHJ@G1b{V;pHrL;x#;-9}<@@^8^gk>At&eE*peaOG>;$+hg$`qEOBK_3Ll>M5xHA@Z+9f`$0Dunjs3tn=M+bzE z%gs^l{Ot04;vV6 z+YODz=nyb|?JfrM-tQg{J3lGpe?tq4MRy}C3q&jLb{z@Ha}zX*P|`6v2k!7!sjb&a zK%(=m2s=V23Cdp%42ugR{4V31O38=A)(xL6t~D|rHEQub@n{_MgptYmsoj{c4wu01 z#VFhu@YJx?^q9^=G$0>De{-10%kx)lGKsP?=d20VZksy4ay!L!67Z#uEI2I(`uzgn z-SFBUrlr3GlI36+!J>Bkuh$v`5jg26kKtAGrPlPl(W_ja3X%SBOpt29FypCE!z5)- zQp{8<;D@tg5>__5NbMv$g}aY7ye92jgC`anUb3v$4t-KbT5j~<09^Fvq?sb`)pehVPmmhV5TW;S>K_~89uo@4*J}bq;<=0dbh7hkx%a6C=zir-hx0^V08XBut zjnh{}ppp<*C1v8DGM(JP4It8$!L9Fa3|}*QTbIM{)F|`4M4{}SjCukK_)((PnV$7` z$MgbUah@I;%Ez*Z35zYq3yJ)p(B6QfDDPkRyk+!S_(O+AKuodY!1Y^GKAgM*MBEZ( zD<^sPOXO^wW0#hUX4kg~4Tn}sn`ew|a%_=~!F(y#qR#HbxW(?q|2%Fcv<& zT`f~9x)1)4zEU&~7VLwB{t>+z)OWUr3+(D(p?`da6h2AkI@ogXfxX)p3OC)xZ_%Qc%941>|9JCe7|?E}Un=!C(k<7NiIrhiwEts?Rs6Byac zY7*Fjt@%tNs=X+NON|x{(&YQLl@BCQL;Q~sVkL@n@n2f*dbZ(r*r`h82V*Y(5o`Au z$63UIgB7m)hqivy&^}dCdhio~XcZh9kzGEL5}f-6!sE;=aI-C;Alu-b15B!s45%8* zB}Q&;xagE|o>#zN8%ntEUO{ep7XmZ&E!uG zSo>{^%$Y(V_rRLnY`%`g31yE8n+<1WZ`=hKOZMt5I}&w$Fj3B)2ab9^%n_mdr@t}- z(3#LqH;iNb@O}o$b{9nQDyoeQQA(WvhQ|HBJZd)GM^Q&fwqvZn=B@dcBh@3rw>^zw z78S2hF-gr?+nDYh&u#vKx;t_C|QBT78$J$W6^fdV&h_ch&N+(hTCXHqAM?Oag zbS4X1bGVypUOa8ddW9%}{o&x&INo5uYRuyg3ClNDTYj#^J-$@he_;49I<%8FyM6v-6bU2$5{s1iD zXi!&|?_7SWkLtGz9sfxNVS#hJOg_6n%Q>rF2m4KuM%xM3^+BN2!{xTA+FLnUzbNWJ zo-45xA?CA4y^qS4sMK%bG0L}Dz&?Y7#@WB6Q)xj2^EdzbzPEP6?Q>^F_S;a$NE8vO z=8$9eToWFr;w{61lGdq3!8C@Y;yaDYt~%Zo!}U!Xgs5xMX+cb1$oGq*m7`b4;XLDN zH}SD`yeA62$v(>sNgU=!yWLKE!e#Oy-t-nn`0CZs{o$%OyY{sg@(8rN6uU7zn@(i+ zQ4I=JjvP3WdzBLP&U!q)3hCNH)E@x|QtTx6ab2N=#<=UHP&K*k38A%XfKTggu*_&gu4W7{zZNJvN+bq!5-3jeHWjeAL)B9Js~Nrn?8 zVxGlF=Y%m=8DDUEaUz)K^#B4Z2S&`3FBtl0zABE__{Pwi(u9fTz5;_fjg}LnB)B0o$RRD+~x>(PZnBbQWPZql&r9kP3+Lx;i&EP@sC9Pw)% z*b7Qeg!-SpYQt-}#-r1KNa69UWnRsuP47^#-?G05Rs!;&c3NIi&x*ZRok$G|JG3k& zSK64+)J|KA7okh*c7{t_`hI4&@vrumX8~&3p?{Qf#Gmf7zLj^-K);y-OO*uN@Wu@v zYX|{^&VV}D6&xrV_chhNfnMOg|LFiky5ze(QZ0a`Wq!sI7qZdrHoiqe;Pht~Jc-fO zULiK#lhNs$I%OnkZkWyeg`p}Ll5))E<$^e&yPa^Z$@c9tMb$hU#6|Q!!L*PS?DKFQ zOcLqPr$sx=nfg&M3fpr%!yebqjj}9CBmBg4g1@vq;4lp1rT73p+`tlZLycb#T=!4s z`n~K-ov>Xy;Z%;U!uoBlmNLcC)Sq6S`&~#^Eb6TEm&&aKsx%px=?Dc~WoYbXA3)mf zDyV!0AI+hpm%SE?tj9OChspAXGk1(pk+13{@X~Mbd!MK$=cDqi@IBW#J>4f+Awn!o zV=1L~Vt7(xCHSwpH2a2Bkh6aft-PO&;O?w}yT7#b3OUUAu;FvqLrlIgPGq5jIUNr1a6+`J(rEUkgfM5y4nB<= z$1bx6{urjA_TV+OGe*m^zw{j<*QN^5T=EiSmW=2qNJ9`(?oQqvDRB7d%bY5bYTO?- znMWe(0GU^o1G~zAPvzpo+{u0;n`o~VbAaYL;0KtTH5kq&g;n)sg6K7>G$q)W;Yar}F1SjWE4i9_Q4 zt#ls?`M$sGSX<9y-F>|N{ZX~8L1-x&(?2BR3 zVeqyd?Oa%IeX;$uuICVn{mpX-`Y(8iXH2TC_1;-;?d7<&<=1)`urq!Ig0lgbi`T<;6qgA4V2oa?AZLm{iIHU7*-!CQ)33k=% znwA0*KCB~4ID<`?Iu0jJUO>O)+zUa45aIVNOn8nQP5Q-1RhqGpNtV@` z#OYSoqZC)~jMoS<-2(Z_X|-$#ud=#}$^JIobc;H-mzmc`a&cGOp06Y76{ErR+^Q#- zc(Oe*7Y$#yUEXf2aPMt`$C{C`Q|qW?|Bl0=ovsxECH>V9DZR&5(@87poOH#DJ*rIA z08}o5x-fc8PzDTMqWwlp8UClA(C^&SJE#IH6(Ra6__CeU%4UPGk6bNVT#upX?3&LS zIXHuO61yjB^?p}UF?U;-v`C07(A)hO^K~=7R*Pg3v!u@;Bz?L4;rxt`G?8!1u-*)) zg|HyW^@BSi?(XZ=F8Ug8DqI<)I`|w%QKiJ$?1SRU-mZU0=0LC_f|tPsqG2kLQYM_5 zz^tE$jC3|rP)q2w``>O!Om5n>BiuJu-e-L6S13MJDFr?P` z2IEv&cOQ7`O!!$5AY{pgP;ngG3~E?vhraMCu3~)rR4-P@&Bw?D0oWCU>2ZJl=^Stn zbe~I?m4D3I?JU83)b~HlhAnWCfOZi{;%}0MXP!GkEsB2YCxIM0@kPjsxkm=NPG}HP z2^h7Jw!jFj!M-g|;HQ448eSvL7EB3!w4Jg+wDx@-4GRyRH32%oef05$2~v)~SQv&~ z?A%!p@rM&WH+WD8g(;V>7#u>92btmW1stB55R^7WQt9db5>m7XHq`cHQ=I&R=^AE3^DY0FOq^`*mhPt zf?1oS#;uL);5i7{-GzOaS>e!xCl{r2?n!J})V3u6Y0JfwcqX9rIsQBqWyO*wEm<=c z^;K`kNXB_GHJVL>*_mY>l*aBWjKqS&SX=<6wnaJ~cY(8&$Z7r(xveL}DXt2=VZ#Y=RyUyE_Tj z&-qkRh+mTcvgKff%(LwCN=F7S>^pgQ_7n2u6(1Ro@nF)N;BvI8*XF-H2gX8z#^YZH zE35dFCDNT?oPcI?rO%kBvWDC;f}oDeGV30RkD%BLRV?rusJ%1zcA>3v&S0>X>F)BT zo!!8pJc=J>h2YV@s#(lSI~Kn!WQe$jOQLm%Vdi7+cmN#X0@HQ|Lg0vnHUC6>8}ZLB z5C3Kh&e%Zv)+QdyW?^19g>^IMQc6;IBVVJF+VNa*4ZtsB@wd=KDkUMcqm}U0>7jdiJ&!|BetIWj1!<#&$8`QzPbWdNu zArI61JkF)`>Alv}DLOOd&2o&rj@HiP@q7!NkwO&*k@)zWIFGkNUTnozP4&UQR;+S- zfsZY@7J^1P_28o42u1&d_>xr0y(jlI114C>^YJ+rHFHCX5S8gS0L%C)3NhKsQ)vT< zvqC?(H~KCXlqgU(SV0~ElWRg6eOteQs=h6b-1-AuY%mFlkj_fM6|mac@p+Fm6SoCC z#KYTu=Ze@nB2zo<1SbrJ8{Tx`}#75@IVpt<#ZH#Xf_0O$jx zKKD7qxYlc8E%W=al#5fLfJOoo7{c^?@v?%M!3btT4Py>hw*4Chd6mUcGb4QjyF==f~}HBqOF zJr@fYIA(0F+L7CXR3tJZH|H9RF(q%eovJ3NHeoyUIP(Q(rgmvb(~DJg>s!Q#cxa(a zXT6_jJ+CYcUtq{p$+R}v9xyH?$f$5m@J2yC0X&cyY6l}#{*bs_+Q{wU@%XMB6B+#C z!Z}}l?;>_)1qgCfD0w-U**-!?j-RDcc2^EwA#7AM8`#op>G!gP#ie|Og%zZKH1~(e zaV&m1GuVWA%DSoO0zhXTJ8Sl}Aqubh(1G{1{eYprX$I}}wOBF?C*)m1w#DJjM-s8t zvk=#x*#?*98Uqv3&_e+h2O_R2a$|@nBt6gFtYcc&OST{$-C#PntU1TMYSBK&)!!b8z#^=Ko} zxT3tEygTu@$DcsjvyJ`93{L#*dDhK(t=uSm)^}_KzO;kt|#{NPqzx+X#Y@WL)39|6?$`;ma>lp;&5Dl;M}oTH37&3 z)i17{ICx>#{HsII!H3UiECA5tlWIn?7=r9md|qL4o{qB(D$S3_a@~?(BKLy!Bo!m| zcVi0d{>ZewsQ0e&yYLaX$*n&*esf$j5;Kgo=Tz8I+8<*0g2a0QHrA=(MZy&#?K^!D zUD9=EN>IO)#2c}X<=q4%p70o?d{J3Z@X&1$zr50DGPv(qaxt&=vM1_n>~a8L?V8@zMlpaMD4@LOc1cX$N6$n=bFCNbHKT7HJ8Q20G}`m;s8t z*s8iJE#z(K>?Pf^G6~1UHbEQ-lf{kE+?%m8LlU%XNvOC{a>lx0crS*@(syzE`?r3E zcTG9cvmpPPM$`K}QUKY9c=&OFGN*18fi7_5H$~Y#jp{^5zms>UkyU&OtGhTAjM1AF z4!yg`aCOP}j`$~*(EcjWXC(6Y%DCf^>ZYbo=;ZrQkC2+iJxQ2x>OSRRSX6&@w&+## z7&iWY?+LwC{3(LzWL3jWjw6-Yj?4}5IwW7N&d*!gxP?t|w4r^690}1Y^DkXMd1b4h zjLj@X7+S=`=I#M#HEHf|mHG%qbqY^H^cioo#3K4P;spSLE{0>DQ8rf`(}Bc9BRK)% zcOY7vl$4L^XS@z)$owA(>-`&VOLz5tU(s(af72>K9#FNU;bNg;G)05lDIR` zL~y<}_2a3Vb{Xq3(^L@_!m%Mi+ed56Bmw>0a+`A$2)zU0cU z3#++0a^L~Yq`lN0439qL|L@Y@*I!h*gA1#gQqa~CV98mMXwE4kmUJj=q+ydo$P;@A zJvJh7P|XsTr%}nU^Vx$9QWYmPdPj^Egclx74?^b1Ov~&kXS_8c>~wUp{ip7N4J~ps z(LZ*$u`6RBnPZ|qI^AA(H2O(mlHIV9vT{T2*I`!9T6zDq@PD@Xs zIi9?JYk4_9v=(X@8@yG`9}z8?Y0ApD7~X-qUU;>>wo(40-~_~ZkOQd#m9s4eY}?9N z&;19R9oDOZ*?s_iOZ5cSy2NSc@_*k91ATGiJg&sC0!wNyAJTM3N1Le6*e*5ymtD2O z#3ZX`tv%`ud&hiwY=6j{O8v-I7@IpOQICYx!T~WGFPAQHjBW&tmrlGu|8(%15Se>u zBAU2307R$=VHAEA@G}zK!QjN3qhF8Sx51Z9M`g+NP_n_Be|uOE^zI8w3+=7>m-tUX zK&h;-a-+j_<@=Vg8J$6n>hr2`^?1E;x=YZ(bg#!-8brx#6_d}crRIe6Mu!(ynbmwD zO++CY>G~{StT^~VW?84TjS>HU&nWE&L^Pq)Wg=2){QZ5iM`j#r&C=;0(TP~;?{E># zUT*EjGOUXi_MN7nz*W1`WXe~0Acw)k9!|tgt+6Ji+p2ekcTyrZ0`#uyzPahJ|z-nf7C5wgk_=3VyMuJl@5<`7R|Bxx7gRPCFjTnaA? z*B|xBSL-roHr+yTChb=HiVi{3A?0=Vpa1)iukx-#EV>0u2e5UNEhEb83o)8G_^TS= zmy8%eqG#1B_C@T_IdAj_SB=?@%V>>pD00#T%Q*(E!&v1{lcAqTuJ=vRqShnTl_tPl zOdcLMU&ge)fd!@-0^&=>IdH@$oEY1~?2 zqm5|&!`oBjPb^y9G@z`NI?++fqXAjFQI`uK|E@c%Rh?}qFrO*AaeO=dLarV672tp$ z_C7V%xvomk$N^B7D!_crd_TeH`gWWFSGuwN zj9N~U4L&A(j)31f^^+csCEzCy0-?*g%6Ciy!a#Gw%B0=By?!rG*SCfP2}DF}enug0 z$L#NlB;w%7<(_kXM?xt;m7MciOvqKdA&h(XPx!#(%GC<}rZ`zVpG0ZD_oaUY-Y%ECiq2 zddh_NJ|-&Q*I$UO)V#`-of#qN$@08-(WIJw#8Ug z#XIDf`;|ehr1Flf9-TIQo+YoDLrNuoNTuXg-fdfj3_$#&<*v{X-;uthQkn#<;+)a+ z$tACw+&}b91F2rw^6!ma#y_GpSch5}fG$IxsF6^p$t9hD>ukiBK{7PvZ~2)kxuyTi zZuN`#Xz*SFU|f195(8T+yo?}nGe!%1EV5nEE|Qx|DVxGDI)d5QC>c^SvNSuD-Dr-6 zDRS5h;CB{LHPLp(=Iir+UY*WQs57dL;iDM+SEfW%WWwdLNM3kd)Bgy=iiq{*D%#|D ziWb+2p)Ba?9kDGXFOF-oB8g3}Z~f*G^r{QBBIxY~>VR{l2khEP-yC*}4&z~Kr6C4x zMyNS%tYc_CKoJv(w1D=RHUMpULJrW-R*o|*vAByCaRP~7CLGdfuID}z9ib5~DkigJ zUEJOS&=(^%l{`gxf~i^LDraozGVVWH2jXW@BPH?1mr9QhL`c?^ z)Rn=f-DKkoTdemegz>VrGTY)KbMx(RHLqmfh-8k+YUrm{YVhI)%5h9iL-xm~+4_^e zG1Y`t9Sk4YA0%cSDB^$-bAQECIhWFLe=qF_#V%P5wGRWm084^kB z9k(i;=08{J;^@_FhMGy|yloVLfpOsqPbFT1#oydg-T@m%yt>IW>d>952Y^)h@&G`# zWS`ClBeDZq?g(kx7*$YCa?Wj99Ywy=H0^cswbQO%Y++zO0@&k#%y z_H>nYNIrEgJt-{rfUlH&ZvmE|OB9uO?zyX#YjEhh++M5DYnGtyvFN7Vo-7ZIqO%wJ zdE)G5{H@d;re^+1;_sB)})XjT@xH$2P+S%&xlPq@o&(e+h48XHVVT(YKKB#e6 z`Afqpx7+eE(wtnr5UjyRx3b!xBEb9EC6*Hu`bitPgwP%@NoXu0Km6{2Z5<<=`f!k|!h+JIF@IF#xgvZ#I=Vwt`A~^$FU5)Ro;Qa2=5H=R zw*!2JYIFZkvDeW5_bJ;ZFHlWsgOaTAvKg|E5h5sr^o~#yS&*&LiX4nvahZc#a%_~V zz#8{3O7W0PL+YD~+3jFMq>8vgkZaZ2J9fPqj{jyjd`fV*8=3$oxxI80!uivV@Sm!K z;AceKsD>pPLdL9e0_<_sFqn~eO-LTX$B_b#rBJCJF7YDMDr6~_&szn)@LIK=(Inb) zwO^qo<__Miskhh9ea#=qzKu9C3LE|v!6L!bG{1^vOykI6|32{1Di%+&zZaaD4@U!YW#t5CSasyW4T_~$ouy2{J5Ox5Zs0pwEd&+ zcZ4tue+3)&ka*~gGvWKsI|^xu%_OV zM8J=sIBeXV-jDwvC&OQ#$4wArR0}6$fMCGAz$2)h)*QAUhrdWpq+!a;r%f)#_9}df zAm)mJKBA=-Dq5ghoY*%Fa^aC`#XcAS$AG&#QSI;uYmZD*~(D*Uf@XgisY(w z^Ajyk%l7i8VY5+>5O@{o3;My8p}8Ab&3L6XGyYWzSe(BpOY&De-i&ZrON98!S{4yA zsqc{N*uhJU+z(T>lRR)hN;{8gO}i0{TlVGzgHam{br1%8)byx;YA{vv!_DE5kBaDQ z^%ML^1`}EY+&9mZ3q|19k7|*@h~jr2oUaQE+8Y^7iIusR)TwO!M!E_Bw&ZO?#%>zB zcRa0F{vVwm9$wHNeh1pzzPn@wi;s`nk=pQFA*Ix4sd2WE4FNJF)&^B2Zg~!K*;?yM z7H;`p7C!3S(HE*6gm1`el%+(u1z{vLP3QKD(YSrO%L`bb?_5X(*#3J=e&m9jtb=f$ zZdcljQfK+8ygYD)Zy0=cuOq+NfBjdN%6EOdxYgcy{XqX;WpLkoUiHsnVZumjfW1MZ zCN0tfjLzitqn4-3l67F*xCi6VG~0T$E`oeyv_tpGlUME{O)?gX!YuKU@0w4p4y;jf zgd_f0gQi<+rK6(2kQe~)_8@VP>E7*2b| z$9>KXOHPJ!Fn-+}xveTg!^Fn$x-~Xdi@H}DT3k@sM8gL-->{ax<1O@5l&SiM;=C59 zkFzU$EWTUHac6YK=yqT=FL0H$>^*H@!#GyB!FOy+(-gkB#`5i3)DbeGBo3w&opGcpyPKLW9P1JcRO~*YT_}QPPXH(6 z7CO264@WUdH_tjM`w|IlnG`0Le#_(1en#`-()@-0Ygqj3i^NU_h-(VrzIo=u;lU57 z-q@_)SoU+o!#p;I(()j%-we#Wv&dBJD^`mWQ#7A>&RGjs&7oeo#~uilxm6kCzMBh( z*AqS2e`vl|&E{Jp%KTiDYNKnBxLt+Jw;y*WhE_`vp@Mawh%h_+Eo$x$Q4wW`!BS(j zj6NEJ?C^nxeR$PO8@oMCBHVk^2J4M;yOCb&eCqmSl;-mCQOX-8h|!SNgoJwY;gE7X zoiEt=>^}bWsLQb8TVp{JNnUe)i$RG>D<7aX4+K=0Uphbb4l?D2#^%~u++#-+JvCVu zSfbDcqc_4Q4|sm1#qI=rS4D9T;ReZMEq=^lsD{k61xP5R&#Qk{7ExJ3=eAI$g-|rs zF36h2BH_sFUAgMO1GF^5<)YE&Kt>psDq}Jh#eP>#G0}-O7fq;z0WPk%LB2~0QD!x7 zQYz{Ht*ONCOFe%zw-Tww`h;p*uOj<_4wV+&FZAk#1j&b4?A{PeDMHcm_B_9qq5xsC z_?y#qztULz%s?bI2n&@=SP=*gYN@Z3Kn${hj)f{?tRWR|OfTPUM_>vOv4~)d8|?YD zB%h;_hRAkE{B*l3}5~=;TpFlLi{7-?tuh)M23%Xti3FeS3REb7?JF`yG+u$ zbZHh3#a$nHa2YIlq5V6JTKob;;hiNO7%fWDEuoqMKRxuZ&t`8ELn`2MGjru{zE>;v z3?PwFo}55&)q4+_GKh3WBjHloDEbPDu+g zSJ{g=9SKA)sF%(~;#BZ=k>Ex__WlTK;BGo5yzRYpy(ZvI?bnzmdO)!@dHQBnAK+%0Z_x-XCGD%J1tf()SC-+FC zXG&eowPy5C-JSS1Sr9F|j`{`pF!oy-UXAuiV&iqig|o)P?)8X9+z^?x!5%AHgx+5O zOZw=Z3(;M zCqaU$#JgukAY94@3q}$HcCaWPO0) ztE)2Nt@5MKC)DGYn^Mi!1>0R;nT8tj>tW-_!kHwtLL$TvO+Uw-bz_^=SP8MhKblmD z9f?9@I6G}bI6Kbn^T83%=zRI|cUm%gu1wJ~j3Icj61CS3>^EYc!8iDK}NYi zUJPbONuc7VDo~1xFvxE94n@sc5w{U(`!8$ep7c4ZUna%xQvH+}xl7p*JR{cPb3^O( zvb`QybguS0Y7lhFSMG6h$=5F*)3?A4%yIjCXrfQ^8dHJl%*dBNa#~oAa2D{J-9KCa z!Onosri}!aJ-QQ+g%&x-Hbqud)jONuO7Tctbe}_>Sk&PilF~`N8C9VYPE9O$v73$n zN)Iicm4=1NjG~lkFVxZ=s`3!j*Nbhsy%Ei^72!@VlhNKP_iu*)NUqLl$6grjCn;y~xQD~rDU;<~F_9be)#hu3 zHh(<5rVSPJvRU;D;w1}k_QUb|DIXM(xI=^=DxI;i!ZRX#KX-l(PtvoJ2s^#i%dWYo z;C$3+?mybjdDl1he75gJ^hD5DZ4H*u;vt=#PD+N0yL--hzGLuD*KCnoDiIxEPhj`{ z4up=dobYypFPNc=o0`W{J04CTEPGfAWwyJi9q_UI*N@n!LNqy_=zno^-Y_1~t;=cn zd-C!g&v&;0xEBP}o*OJAiQSmMe=JX!#>9d{Q8v;+yLNQq>oOTWCYd6g#n{8X?Gy^bMy;@{E!Btfr_ zPTk3lb#~DgqCRpZz|yvR-VB7z)cE?H`#1S&=AOk^TmvytiWJhFP~kQk9f%kXiK_V% zUHWN2xF3Q_O*mF335l2|oyT`Oho#OC62ZOeiJc*k!2`o;3-xU zAco^|A?E1kqqExDl%5oAep?*DP5rRoJnc*Lsxu$t9ZAYXPn=Ls|MynOz_cnH9Br_E zWYP+Nu?;nE-@(|0PExCzfTu=$1IgF|$oIwh%`=yc(C1nt7at3tE2V&$12}*(xW0R&I?&A+=}p3ZicT--F(d3AumwQRgQB3+W9(IB1Y_g4BD$o z4D=J2q#=uT$lH)8SDUY}!RI-(KcZWXamDLT*PF%KTREMzLpln9<{2m!0QaI&iw~tJ zJ&{dP14$B(R`6j!3hVihfcFk1;+bD?NIOA_0=frthz3F_&@b0rlM^;j^Y}27)8zK@ z(F4Oj#8wXr!awg@`ZpM#afOCfEt||e06mp0h&Vl3pERfe2h)=!Z-MU z6dfYyGbzyHkonU81LA|}X{1V>`8xDSY$gH7J(f59>mV@+YBh~8)qau~SchX0I6 z$SnxQOej%~04(3dXQ0(kbYoJyNjlX6@G}U8;jWsdN5Jr!5)AoNZqPop$ITN6iMI3D zc**7F=)K(b#W--Cn}5Ayg%uU;-)ueI8!TDIP@`82_=5>)05brC$nlo=p?tGE?nc~( zRWf6`ui6Lnrdh{SF79K;DS?6uW&Lf)pmkCCblpY}AddzSa34uVXpsKab&Hs(rfl%5 zn-G$=(}#&0YfmRF0|WMsMPh1gu1!QqBT9 z(pC011Mdy%x6kOO1Wdo= zC$FA~as=VK#cJH5_?cYQ)<%WhJM7ksA9e87hiDEsT+Rj}TO`-!GT&E&{ zXk~KEb3C%Y$YJ*w6jIwGsxm`&*Zcg8daS8nd5MDJ^?<^u)LJ%=-*6H9sMNh4}4-zK`@@QG9sKeV}!Nl!p&5*MrpDaE;AY2UQkF zG6gXfJUzU!RfFE-KqNwi*@=eTFB&52*c<{Ug;JD=D)U${d}V)3qY2cgh;4as`8WB)X;i{{Kp%!d zZE@&va^c71-Sz8@$pqR3!#S)87I8BIP{HO@jH} zWQr_u;%u!xwlJD>bEn}vzl+uzpguQ8C_Qa@M-LLtzQAW;1#(+FP<^)ry*%EeZ>?4d z38#NIAzb*-i6RqvhY~0(7Men{cU3O3o*6R#sf{j+`BJIvh+`R%(mOr|cXt(})v;gL zH2dT17GJ;#`>lfuDl9mA`C0cFiv)c^EB{B0bRVrkq{UT*An7er`K7%BzFzwT zuG<1!>6}|CY2ApAzZwGjY`$|ko%7#7PIEDbmZx0;5&kbFa_Jz(`Th_eF=SRL9pw@j zSZ=93R#g}YT)WZE#J5aokMle>+g+k)gDiTf9@fhw3Uye^6^p8zcGrvesTj7a!&JOO zf*F9Ev(i^h&J01{N%kGPw$-v0{)#Q=h!|U?ibP#@lYa1k{M`Wza&|gX2JKi}?Xq1B7%2Hwk%OEnK%LWSkD!zyk$ebIK# z0{}m>k+KpN6LlCw-~Cjh35tbCg~cV)SpVP>FPfC1RVD^V68l`gbaj+-I=i~KGk8;N z=?~tBD{!tJ+qSX*Mw&@=9oSedhY@AKrYJ6#CWzAdfQ|SWg3>!uxWhw?g+wn87p?MQ z!-a+P5eU4A+%&0s`+eWBP!_X7CJccr&tkJ2Y!rRd5e8BelCHV;o$5Z`?toMZH|Pto zdFtwNGOOEZ^JLF3s!E?kF&>+f5ovIc_Jp|ZpNrh=xEXA{np(b|h`FnnTwG@qCVDy7 z^Nb<%;ttrD0XQC)XaBOd8^48t3~=v0Ty_NbmIRog#$W!*oNgr-aZn(m1v~KG*5lFO zl53J+l&eI{R*$cLY=)8x0^-UY;&9IJl=^Gc3x>9GXGa?wNTJ>|+Im7&ff2l0APFTk zKKshZW6ukSP{^CH8U^_fXkmO!BcJ7!#rOb2Dd5%0wUu@XG3=*};(-oiXb^D!f(#)f zE7W?rIIsBPs%U6RIjz#l*2@qpV8G^`#Vz5C=qJY*ATS*-efO;opd!Zr4utVN9P$h@ z)hU)fEi1eE-zKG&>jR6yh}gl5VvJa_p_HyAW;mmngd%UPgh)=f-N0&dN7q;`azYDi^2pmE?m+dvn2*?zWHIW(Z-1JDTtFY zY#pVvuH40q{!MS@(}-e}+#OJRKF!|1f$6^VlwG$Q_ApZ28YS#a2ME;ZhJNTn>#Z7y zYz&JpteJ!}!Q{h>3TcEoYyKn*@u?7`)OitQLhhu^kVt$bQL&a{me0ir)=_^R=LEW{6V_jx$t8I zrh-0Ku+2x>=k>Dx>sE7^EYPs=gCq<3u*riKpdW6K4J^>>m21<;9NA2y*I*m*0Ojxs z`fzUdPqlByZVjJd#+L*mHBK|7NQE{486~9Q z1mv{n;zvOU*5kX=*HjLzNtuI zkbga3h=W~mA(j&o;M%+a%(jk*sSqjcIQnRTNn)Hgq}&0<{j$F+rGLR~+tyEo-uO+$x|ACUZi&WoxZBT>H@9ygH`^HAcSVvY{nd;L9uic_$ zJ+;q;!;^c>8F+7RmGrVk=;vS+skCt6OXYk+a>x4Ue8(17^j?9Dga){i{&8eraPo}K zu1xKjs5aB*W#551vakBnB@-dVEz(pAI_<+1Im(n3~SRIeWC#my2E!-X0YYAUcqv1rV3E|8T-D%xwQ6H&%B4xQD4yh&KqW z+nLo#vr%k&VV@CC4&@iD8jb!PUE`pZn+-4Sh`cW*1`Q*`LG(fXi|aey5a=b{2Zn$J z)Pp`v-Csx3_S|X4J}#xUnnBl>a0X^|x{YyUk~TNR3wGj-q6Z_Bq}dlT%RO);Rp(OKM5gFf_fhR0*2ndk0i*iU{XME66 zpQwc>9;tV0x4tY$uwb8A;X%;LqM@IuGhflXzEJXxdQP&y{5iaP?vIQw1cE{Ca#JE& zOV6g1h8q5d`Mu1HEtsCG7(EXX4P8($MF`KIM4z%;&($|q8Z?gZKx-X_&Y0lP$4A+Q zG$dP+#xvD4_SP)&-^$Q>|6)war~b?9Wq9#QR{$c(AQAEw7h!-@3C1O}6VS^&RwFaP zmrEo=dhv2&QR2muWPGC#q)3h5%LBaUHKKeTSLVL>MKZn(Ewn$1x4v44ch-evM07|L z+9AmPos_=+_~}BN9Y`Pu%7}jW;!2`SzYtj(mt;Q56F`jJA}AzckB!f2f zb>e1ds@2Q+9bV9xmiV%IYd;Wd?5Dc!fZS+x>k-i4NZykmEP-HzO#q4IS22#Ueafb% z{&H@g=UJ$WS^?;!^L^q0;2JRl zh_y5q0WQ11=ta<>?0=()9>0%dev$pTBj>NlyiU(7-v1}oP^Q(r!ki3zX;b+O>(vXm-(lBU`QL|BO61DZ#I#X?5)NM z#w4kL2Y`P;0A(5w`ytZ90k5GHU-AB`{Yr9up|K?nmD50ksGi8Ju3ZXjpKE_yG21cT^U$#A~5O+ z&eJ$~azJpF{M_dD5;p+)_l0zQMXtKhasl-2;hMD3?UV#EP#peAT#2|}?bg*6E$t6k zF1OGqu?lz^!Xc6{rX0Rh&qP__URz^_lVSiXNw^33E-C1rR6y6M>H``yuBdq#%}8Iv zX4GkQCO+t%u&{<^lhGA?N$ywr0F7A-3rj8@n9nyWJ+r2>@NlzWW3Z-Oq|&l_wo5MA z<4eLsD>L=`Z~Y`5y;>bWC`_+ZrVFPF>^sQOqcts_`nwGhPGFN~=68CI$w6_Wt(6=9 zByLWfi;(wJ6^cN@N6$h!RZ5*~JsIE03jnVCJ`;VaSEu!sOn^Z62@IFt2QZxwSpYU+ zWr0O-%kowHEl)D+YYhaQcFl)QRD!<&@*pLGSr8aOoL3zPUZYE4?GW+47j^g^`5VA0 zOm|__nw!7f>Q)H4jW`180tZ0n668&Etl8wscZ*}V^17Bj3uei_xlzgM&mUlFRRLg% zhv}^MJTJ|WsHCf6@1^WEwTw6b{VF>38vJ^|YIN)Yz118ig?id2<>q83i1+mWP<7Tp zRe)R9N4mSaJEf(&yF?nKMLJVz3#wj#b5N;?<@S6<_bOYh!iwOf28>AWMLQ<=>;3eQ9PMj- zZNaGCA{dNB=8qjt_Oc-U_iJPm-5E6r`t_`Y-@(t2;0B5ax#8vxD{w|n>|8}LO*XBI zl4cpTSurLSy^UZ^6H;dS_6MCZrwARFALP0sU}_3}Fa-6Xc$kbsmm_)r4xmz@7IhZ3 zC6Alij!u^w6#;xS7`MyG#|tAN#l%pEtMvi%8xadKqyA0UWKl2tn};O3klQ(wx$z

S%ARA~bOz4aNF^FbfblWn$4+`%fn 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 diff --git a/src/kernels/deepnote/environments/deepnoteEnvironment.ts b/src/kernels/deepnote/environments/deepnoteEnvironment.ts deleted file mode 100644 index aabff6a693..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironment.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Uri } from 'vscode'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; -import { DeepnoteServerInfo } from '../types'; - -/** - * Represents a Deepnote kernel environment. - * This is the runtime model with full objects. - */ -export interface DeepnoteEnvironment { - /** - * Unique identifier for this environment (UUID) - */ - id: string; - - /** - * User-friendly name for the environment - * Example: "Python 3.11 (Data Science)" - */ - name: string; - - /** - * Python interpreter to use for this kernel - */ - pythonInterpreter: PythonEnvironment; - - /** - * Path to the virtual environment for this environment - */ - venvPath: Uri; - - /** - * Whether the venv is managed by this extension (created by us). - * If true, the venv can be deleted when the environment is removed. - * If false, the venv is external and should be preserved. - */ - managedVenv: boolean; - - /** - * Server information (set when server is running) - */ - serverInfo?: DeepnoteServerInfo; - - /** - * Timestamp when this environment was created - */ - createdAt: Date; - - /** - * Timestamp when this environment was last used - */ - lastUsedAt: Date; - - /** - * Optional list of additional packages to install in the venv - */ - packages?: string[]; - - /** - * Version of deepnote-toolkit installed (if known) - */ - toolkitVersion?: string; - - /** - * Optional description for this environment - */ - description?: string; -} - -/** - * Serializable state for storing environments. - * Uses string paths instead of Uri objects for JSON serialization. - */ -export interface DeepnoteEnvironmentState { - id: string; - name: string; - pythonInterpreterPath: { - id: string; - uri: string; - }; - venvPath: string; - managedVenv?: boolean; - createdAt: string; - lastUsedAt: string; - packages?: string[]; - toolkitVersion?: string; - description?: string; -} - -/** - * Options for creating a new kernel environment - */ -export interface CreateDeepnoteEnvironmentOptions { - name: string; - pythonInterpreter: PythonEnvironment; - packages?: string[]; - description?: string; -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts deleted file mode 100644 index a89ab3f44e..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.node.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { inject, injectable, named } from 'inversify'; -import * as path from '../../../platform/vscode-path/path'; -import { CancellationToken, EventEmitter, l10n, Uri } from 'vscode'; - -import { IExtensionSyncActivationService } from '../../../platform/activation/types'; -import { Cancellation } from '../../../platform/common/cancellation'; -import { STANDARD_OUTPUT_CHANNEL } from '../../../platform/common/constants'; -import { IFileSystem } from '../../../platform/common/platform/types'; -import { IProcessServiceFactory } from '../../../platform/common/process/types.node'; -import { IExtensionContext, IOutputChannel } from '../../../platform/common/types'; -import { generateUuid as uuid } from '../../../platform/common/uuid'; -import { logger } from '../../../platform/logging'; -import { IDeepnoteEnvironmentManager } from '../types'; -import { CreateDeepnoteEnvironmentOptions, DeepnoteEnvironment } from './deepnoteEnvironment'; -import { DeepnoteEnvironmentStorage } from './deepnoteEnvironmentStorage.node'; - -/** - * Manager for Deepnote kernel environments. - * Handles CRUD operations and server lifecycle management. - */ -@injectable() -export class DeepnoteEnvironmentManager implements IExtensionSyncActivationService, IDeepnoteEnvironmentManager { - private environments: Map = new Map(); - private readonly _onDidChangeEnvironments = new EventEmitter(); - public readonly onDidChangeEnvironments = this._onDidChangeEnvironments.event; - private initializationPromise: Promise | undefined; - - constructor( - @inject(IExtensionContext) private readonly context: IExtensionContext, - @inject(DeepnoteEnvironmentStorage) private readonly storage: DeepnoteEnvironmentStorage, - @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, - @inject(IFileSystem) private readonly fileSystem: IFileSystem, - @inject(IProcessServiceFactory) private readonly processServiceFactory: IProcessServiceFactory - ) {} - - /** - * Activate the service (called by VS Code on extension activation) - */ - public activate(): void { - // Store the initialization promise so other components can wait for it - this.initializationPromise = this.initialize().catch((error) => { - logger.error('Failed to activate environment manager', error); - const msg = error instanceof Error ? error.message : String(error); - this.outputChannel.appendLine(l10n.t('Failed to activate environment manager: {0}', msg)); - }); - } - - /** - * Initialize the manager by loading environments from storage - */ - public async initialize(): Promise { - try { - const configs = await this.storage.loadEnvironments(); - this.environments.clear(); - - let needsMigration = false; - - for (const config of configs) { - const venvDirName = path.basename(config.venvPath.fsPath); - - // Check if venv path is under current globalStorage - const expectedVenvParent = Uri.joinPath(this.context.globalStorageUri, 'deepnote-venvs').fsPath; - const actualVenvParent = path.dirname(config.venvPath.fsPath); - logger.info(`Actual venv parent: ${actualVenvParent}`); - logger.info(`Expected venv parent: ${expectedVenvParent}`); - const isInCorrectStorage = actualVenvParent === expectedVenvParent; - logger.info(`Is in correct storage: ${isInCorrectStorage}`); - logger.info(`Managed venv: ${config.managedVenv}`); - - // Check if directory name matches the environment ID and is in correct storage - const isExpectedPath = venvDirName === config.id && isInCorrectStorage; - const needsPathMigration = !isExpectedPath && config.managedVenv === true; - - if (needsPathMigration) { - logger.info( - `Migrating environment "${config.name}" from ${config.venvPath.fsPath} to ID-based path` - ); - - config.venvPath = Uri.joinPath(this.context.globalStorageUri, 'deepnote-venvs', config.id); - config.toolkitVersion = undefined; - - logger.info(`New venv path: ${config.venvPath.fsPath} (will be recreated on next use)`); - needsMigration = true; - } - - this.environments.set(config.id, config); - } - - if (needsMigration) { - logger.info('Saving migrated environments to storage'); - await this.persistEnvironments(); - } - - logger.info(`Initialized environment manager with ${this.environments.size} environments`); - - // Fire event to notify tree view of loaded environments - this._onDidChangeEnvironments.fire(); - } catch (error) { - logger.error('Failed to initialize environment manager', error); - } - } - - /** - * Wait for initialization to complete - */ - public async waitForInitialization(): Promise { - if (this.initializationPromise) { - await this.initializationPromise; - } - } - - /** - * Create a new kernel environment - */ - public async createEnvironment( - options: CreateDeepnoteEnvironmentOptions, - token?: CancellationToken - ): Promise { - Cancellation.throwIfCanceled(token); - - const id = uuid(); - - // Check if the Python interpreter is already in a virtual environment - const existingVenvPath = await this.getVenvPathIfInVenv(options.pythonInterpreter.uri); - logger.info(`Existing venv path: ${existingVenvPath?.fsPath}`); - const venvPath = existingVenvPath ?? Uri.joinPath(this.context.globalStorageUri, 'deepnote-venvs', id); - logger.info(`Venv path: ${venvPath.fsPath}`); - - const environment: DeepnoteEnvironment = { - id, - name: options.name, - pythonInterpreter: options.pythonInterpreter, - managedVenv: existingVenvPath == null, - venvPath, - createdAt: new Date(), - lastUsedAt: new Date(), - packages: options.packages, - description: options.description - }; - - Cancellation.throwIfCanceled(token); - - this.environments.set(id, environment); - await this.persistEnvironments(); - this._onDidChangeEnvironments.fire(); - - logger.info(`Created new environment: ${environment.name} (${id})`); - return environment; - } - - /** - * Get all environments - */ - public listEnvironments(): DeepnoteEnvironment[] { - return Array.from(this.environments.values()); - } - - /** - * Get a specific environment by ID - */ - public getEnvironment(id: string): DeepnoteEnvironment | undefined { - return this.environments.get(id); - } - - /** - * Update an environment's metadata - */ - public async updateEnvironment( - id: string, - updates: Partial> - ): Promise { - const config = this.environments.get(id); - if (!config) { - throw new Error(l10n.t('Environment not found: {0}', id)); - } - - if (updates.name !== undefined) { - config.name = updates.name; - } - if (updates.packages !== undefined) { - config.packages = updates.packages; - } - if (updates.description !== undefined) { - config.description = updates.description; - } - - await this.persistEnvironments(); - this._onDidChangeEnvironments.fire(); - - logger.info(`Updated environment: ${config.name} (${id})`); - } - - /** - * Delete an environment - */ - public async deleteEnvironment(id: string, token?: CancellationToken): Promise { - Cancellation.throwIfCanceled(token); - - const config = this.environments.get(id); - if (!config) { - throw new Error(`Environment not found: ${id}`); - } - - Cancellation.throwIfCanceled(token); - - // Only delete the virtual environment directory if it was created by us (managed venv) - // This prevents accidental deletion of user's original virtual environments - if (config.managedVenv) { - try { - await this.fileSystem.delete(config.venvPath); - logger.info(`Deleted virtual environment directory: ${config.venvPath.fsPath}`); - } catch (error) { - // Log but don't fail - the directory might not exist or might already be deleted - logger.warn(`Failed to delete virtual environment directory: ${config.venvPath.fsPath}`, error); - const msg = error instanceof Error ? error.message : String(error); - this.outputChannel.appendLine( - l10n.t('Failed to delete Deepnote virtual environment directory for "{0}": {1}', config.name, msg) - ); - } - } else { - logger.info(`Skipping deletion of external virtual environment: ${config.venvPath.fsPath}`); - } - - Cancellation.throwIfCanceled(token); - - this.environments.delete(id); - await this.persistEnvironments(); - this._onDidChangeEnvironments.fire(); - - logger.info(`Deleted environment: ${config.name} (${id})`); - } - - /** - * Update the last used timestamp for an environment - */ - public async updateLastUsed(id: string): Promise { - const config = this.environments.get(id); - if (!config) { - return; - } - - config.lastUsedAt = new Date(); - await this.persistEnvironments(); - this._onDidChangeEnvironments.fire(); - } - - /** - * Check if a Python binary is inside a virtual environment by executing it. - * Returns the venv root path if it is, otherwise returns undefined. - * - * Detection is based on comparing sys.prefix vs sys.base_prefix: - * - In a virtual environment, sys.prefix points to the venv directory - * - sys.base_prefix points to the original Python installation - * - If they differ, Python is running inside a venv - */ - private async getVenvPathIfInVenv(pythonUri: Uri): Promise { - try { - const processService = await this.processServiceFactory.create(undefined); - - // Execute Python to check if sys.prefix differs from sys.base_prefix - // If they differ, we're in a virtual environment - // Output format: "is_venv|prefix_path" where is_venv is 1 or 0 - const result = await processService.exec( - pythonUri.fsPath, - ['-c', 'import sys; print("1" if sys.prefix != sys.base_prefix else "0", sys.prefix, sep="|")'], - { timeout: 5000 } - ); - - const output = result.stdout.trim(); - const [isVenv, prefixPath] = output.split('|'); - - if (isVenv === '1' && prefixPath) { - logger.info(`Detected existing virtual environment at: ${prefixPath}`); - return Uri.file(prefixPath); - } - - return undefined; - } catch (ex) { - logger.warn('Failed to check if Python is in a virtual environment', ex); - return undefined; - } - } - - /** - * Persist all environments to storage - */ - private async persistEnvironments(): Promise { - const configs = Array.from(this.environments.values()); - await this.storage.saveEnvironments(configs); - } - - /** - * Dispose of all resources - */ - public dispose(): void { - this._onDidChangeEnvironments.dispose(); - } -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts deleted file mode 100644 index 381e9c9d9a..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentManager.unit.test.ts +++ /dev/null @@ -1,500 +0,0 @@ -import { assert, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import { anything, instance, mock, when, verify } from 'ts-mockito'; -import { Uri } from 'vscode'; -import * as fs from 'fs'; -import * as os from 'os'; - -import { DeepnoteEnvironmentManager } from './deepnoteEnvironmentManager.node'; -import { DeepnoteEnvironmentStorage } from './deepnoteEnvironmentStorage.node'; -import { IFileSystem } from '../../../platform/common/platform/types'; -import { IProcessService, IProcessServiceFactory } from '../../../platform/common/process/types.node'; -import { IExtensionContext, IOutputChannel } from '../../../platform/common/types'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; - -use(chaiAsPromised); - -suite('DeepnoteEnvironmentManager', () => { - let manager: DeepnoteEnvironmentManager; - let mockContext: IExtensionContext; - let mockStorage: DeepnoteEnvironmentStorage; - let mockOutputChannel: IOutputChannel; - let mockFileSystem: IFileSystem; - let mockProcessServiceFactory: IProcessServiceFactory; - let mockProcessService: IProcessService; - let testGlobalStoragePath: string; - - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3'), - version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } - } as PythonEnvironment; - - setup(() => { - mockContext = mock(); - mockStorage = mock(); - mockOutputChannel = mock(); - mockFileSystem = mock(); - mockProcessServiceFactory = mock(); - mockProcessService = mock(); - - // Create a temporary directory for test storage - testGlobalStoragePath = fs.mkdtempSync(`${os.tmpdir()}/deepnote-test-`); - - when(mockContext.globalStorageUri).thenReturn(Uri.file(testGlobalStoragePath)); - when(mockStorage.loadEnvironments()).thenResolve([]); - when(mockStorage.saveEnvironments(anything())).thenResolve(); - when(mockOutputChannel.appendLine(anything())).thenReturn(); - - // Configure mockFileSystem to actually delete directories for testing - when(mockFileSystem.delete(anything())).thenCall((uri: Uri) => { - const dirPath = uri.fsPath; - if (fs.existsSync(dirPath)) { - fs.rmSync(dirPath, { recursive: true, force: true }); - } - return Promise.resolve(); - }); - - // Configure mock process service to make getVenvPathIfInVenv return undefined - // (stdout starts with '0' means "not in a virtual environment") - when(mockProcessServiceFactory.create(anything(), anything())).thenResolve(instance(mockProcessService)); - when(mockProcessService.exec(anything(), anything(), anything())).thenResolve({ - stdout: '0|/usr/lib/python3', - stderr: '' - }); - - manager = new DeepnoteEnvironmentManager( - instance(mockContext), - instance(mockStorage), - instance(mockOutputChannel), - instance(mockFileSystem), - instance(mockProcessServiceFactory) - ); - }); - - teardown(() => { - // Clean up the temporary directory after each test - if (testGlobalStoragePath && fs.existsSync(testGlobalStoragePath)) { - fs.rmSync(testGlobalStoragePath, { recursive: true, force: true }); - } - }); - - suite('activate', () => { - test('should load environments on activation', async () => { - const existingConfigs = [ - { - id: 'existing-config', - name: 'Existing', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - } - ]; - - when(mockStorage.loadEnvironments()).thenResolve(existingConfigs); - - manager.activate(); - // Wait for async initialization - await manager.waitForInitialization(); - - const configs = manager.listEnvironments(); - assert.strictEqual(configs.length, 1); - assert.strictEqual(configs[0].id, 'existing-config'); - }); - }); - - suite('createEnvironment', () => { - test('should create a new kernel environment', async () => { - const config = await manager.createEnvironment({ - name: 'Test Config', - pythonInterpreter: testInterpreter, - packages: ['numpy'], - description: 'Test description' - }); - - assert.strictEqual(config.name, 'Test Config'); - assert.strictEqual(config.pythonInterpreter, testInterpreter); - assert.deepStrictEqual(config.packages, ['numpy']); - assert.strictEqual(config.description, 'Test description'); - assert.ok(config.id); - assert.ok(config.venvPath); - assert.ok(config.createdAt); - assert.ok(config.lastUsedAt); - - verify(mockStorage.saveEnvironments(anything())).once(); - }); - - test('should generate unique IDs for each environment', async () => { - const config1 = await manager.createEnvironment({ - name: 'Config 1', - pythonInterpreter: testInterpreter - }); - - const config2 = await manager.createEnvironment({ - name: 'Config 2', - pythonInterpreter: testInterpreter - }); - - assert.notEqual(config1.id, config2.id); - }); - - test('should fire onDidChangeEnvironments event', async () => { - let eventFired = false; - manager.onDidChangeEnvironments(() => { - eventFired = true; - }); - - await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - assert.isTrue(eventFired); - }); - }); - - suite('listEnvironments', () => { - test('should return empty array initially', () => { - const configs = manager.listEnvironments(); - assert.deepStrictEqual(configs, []); - }); - - test('should return all created environments', async () => { - await manager.createEnvironment({ name: 'Config 1', pythonInterpreter: testInterpreter }); - await manager.createEnvironment({ name: 'Config 2', pythonInterpreter: testInterpreter }); - - const configs = manager.listEnvironments(); - assert.strictEqual(configs.length, 2); - }); - }); - - suite('getEnvironment', () => { - test('should return undefined for non-existent ID', () => { - const config = manager.getEnvironment('non-existent'); - assert.isUndefined(config); - }); - - test('should return environment by ID', async () => { - const created = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - const found = manager.getEnvironment(created.id); - assert.strictEqual(found?.id, created.id); - assert.strictEqual(found?.name, 'Test'); - }); - }); - - suite('updateEnvironment', () => { - test('should update environment name', async () => { - const config = await manager.createEnvironment({ - name: 'Original Name', - pythonInterpreter: testInterpreter - }); - - await manager.updateEnvironment(config.id, { name: 'Updated Name' }); - - const updated = manager.getEnvironment(config.id); - assert.strictEqual(updated?.name, 'Updated Name'); - verify(mockStorage.saveEnvironments(anything())).atLeast(1); - }); - - test('should update packages', async () => { - const config = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter, - packages: ['numpy'] - }); - - await manager.updateEnvironment(config.id, { packages: ['numpy', 'pandas'] }); - - const updated = manager.getEnvironment(config.id); - assert.deepStrictEqual(updated?.packages, ['numpy', 'pandas']); - verify(mockStorage.saveEnvironments(anything())).atLeast(1); - }); - - test('should throw error for non-existent environment', async () => { - await assert.isRejected( - manager.updateEnvironment('non-existent', { name: 'Test' }), - 'Environment not found: non-existent' - ); - }); - - test('should fire onDidChangeEnvironments event', async () => { - const config = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - let eventFired = false; - manager.onDidChangeEnvironments(() => { - eventFired = true; - }); - - await manager.updateEnvironment(config.id, { name: 'Updated' }); - - assert.isTrue(eventFired); - }); - }); - - suite('deleteEnvironment', () => { - test('should delete environment', async () => { - const config = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - await manager.deleteEnvironment(config.id); - - const deleted = manager.getEnvironment(config.id); - assert.isUndefined(deleted); - verify(mockStorage.saveEnvironments(anything())).atLeast(1); - }); - - test('should throw error for non-existent environment', async () => { - await assert.isRejected(manager.deleteEnvironment('non-existent'), 'Environment not found: non-existent'); - }); - - test('should delete virtual environment directory from disk', async () => { - const config = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - // Create the virtual environment directory to simulate it existing - const venvDirPath = config.venvPath.fsPath; - fs.mkdirSync(venvDirPath, { recursive: true }); - - // Create a dummy file inside to make it a "real" directory - fs.writeFileSync(`${venvDirPath}/test.txt`, 'test content'); - - // Verify directory and file exist before deletion - assert.isTrue(fs.existsSync(venvDirPath), 'Directory should exist before deletion'); - assert.isTrue(fs.existsSync(`${venvDirPath}/test.txt`), 'File should exist before deletion'); - - // Delete the environment - await manager.deleteEnvironment(config.id); - - // Verify directory no longer exists - assert.isFalse(fs.existsSync(venvDirPath), 'Directory should not exist after deletion'); - }); - }); - - suite('updateLastUsed', () => { - test('should update lastUsedAt timestamp', async () => { - const config = await manager.createEnvironment({ - name: 'Test', - pythonInterpreter: testInterpreter - }); - - const originalLastUsed = config.lastUsedAt; - await new Promise((resolve) => setTimeout(resolve, 10)); - await manager.updateLastUsed(config.id); - - const updated = manager.getEnvironment(config.id); - assert.isTrue(updated!.lastUsedAt > originalLastUsed); - }); - - test('should do nothing for non-existent environment', async () => { - await manager.updateLastUsed('non-existent'); - // Should not throw - }); - }); - - suite('dispose', () => { - test('should dispose event emitter', () => { - manager.dispose(); - // Should not throw - }); - }); - - suite('environment migration', () => { - test('should migrate hash-based venv paths to UUID-based paths', async () => { - const oldHashBasedConfig = { - id: 'abcd1234-5678-90ab-cdef-123456789012', - name: 'Old Hash Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/global/storage/deepnote-venvs/venv_7626587d-1.0.0'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - when(mockStorage.loadEnvironments()).thenResolve([oldHashBasedConfig]); - when(mockContext.globalStorageUri).thenReturn(Uri.file('/global/storage')); - - manager.activate(); - await manager.waitForInitialization(); - - const configs = manager.listEnvironments(); - assert.strictEqual(configs.length, 1); - - // Should have migrated to UUID-based path - assert.strictEqual( - configs[0].venvPath.fsPath, - '/global/storage/deepnote-venvs/abcd1234-5678-90ab-cdef-123456789012' - ); - - // Should clear toolkit version to force reinstallation - assert.isUndefined(configs[0].toolkitVersion); - - // Should have saved the migration - verify(mockStorage.saveEnvironments(anything())).once(); - }); - - test('should migrate VS Code storage paths to Cursor storage paths', async () => { - const vsCodeConfig = { - id: 'cursor-env-id', - name: 'VS Code Environment', - pythonInterpreter: testInterpreter, - venvPath: Uri.file( - '/Library/Application Support/Code/User/globalStorage/deepnote.vscode-deepnote/deepnote-venvs/cursor-env-id' - ), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date(), - toolkitVersion: '1.0.0' - }; - - when(mockStorage.loadEnvironments()).thenResolve([vsCodeConfig]); - when(mockContext.globalStorageUri).thenReturn( - Uri.file('/Library/Application Support/Cursor/User/globalStorage/deepnote.vscode-deepnote') - ); - - manager.activate(); - await manager.waitForInitialization(); - - const configs = manager.listEnvironments(); - assert.strictEqual(configs.length, 1); - - // Should have migrated to Cursor storage - assert.match(configs[0].venvPath.fsPath, /Cursor.*deepnote-venvs\/cursor-env-id$/); - - // Should clear toolkit version to force reinstallation - assert.isUndefined(configs[0].toolkitVersion); - - verify(mockStorage.saveEnvironments(anything())).once(); - }); - - test('should not migrate environments with correct ID-based paths in correct storage', async () => { - const testDate = new Date(); - const correctConfig = { - id: '12345678-1234-1234-1234-123456789abc', - name: 'Correct Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/global/storage/deepnote-venvs/12345678-1234-1234-1234-123456789abc'), - managedVenv: true, - createdAt: testDate, - lastUsedAt: testDate, - toolkitVersion: '1.0.0', - packages: [] - }; - - when(mockStorage.loadEnvironments()).thenResolve([correctConfig]); - when(mockContext.globalStorageUri).thenReturn(Uri.file('/global/storage')); - - manager.activate(); - await manager.waitForInitialization(); - - const configs = manager.listEnvironments(); - assert.strictEqual(configs.length, 1); - - // Path should remain unchanged - assert.strictEqual( - configs[0].venvPath.fsPath, - '/global/storage/deepnote-venvs/12345678-1234-1234-1234-123456789abc' - ); - - // ID and name should be preserved - assert.strictEqual(configs[0].id, '12345678-1234-1234-1234-123456789abc'); - assert.strictEqual(configs[0].name, 'Correct Config'); - - // Should NOT have saved (no migration needed) - verify(mockStorage.saveEnvironments(anything())).never(); - }); - - test('should not migrate environments with non-UUID IDs when path already matches', async () => { - const testDate = new Date(); - const customIdConfig = { - id: 'my-custom-env-id', - name: 'Custom ID Environment', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/global/storage/deepnote-venvs/my-custom-env-id'), - managedVenv: true, - createdAt: testDate, - lastUsedAt: testDate, - toolkitVersion: '1.0.0' - }; - - when(mockStorage.loadEnvironments()).thenResolve([customIdConfig]); - when(mockContext.globalStorageUri).thenReturn(Uri.file('/global/storage')); - - manager.activate(); - await manager.waitForInitialization(); - - const configs = manager.listEnvironments(); - assert.strictEqual(configs.length, 1); - - // Path should remain unchanged - assert.strictEqual(configs[0].venvPath.fsPath, '/global/storage/deepnote-venvs/my-custom-env-id'); - - // Toolkit version should NOT be cleared - assert.strictEqual(configs[0].toolkitVersion, '1.0.0'); - - // Should NOT have saved (no migration needed) - verify(mockStorage.saveEnvironments(anything())).never(); - }); - - test('should migrate multiple environments at once', async () => { - const configs = [ - { - id: 'uuid1', - name: 'Hash Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/global/storage/deepnote-venvs/venv_abc123-1.0.0'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }, - { - id: 'uuid2', - name: 'VS Code Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/Code/globalStorage/deepnote-venvs/uuid2'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }, - { - id: 'uuid3', - name: 'Correct Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/global/storage/deepnote-venvs/uuid3'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - } - ]; - - when(mockStorage.loadEnvironments()).thenResolve(configs); - when(mockContext.globalStorageUri).thenReturn(Uri.file('/global/storage')); - - manager.activate(); - await manager.waitForInitialization(); - - const loaded = manager.listEnvironments(); - assert.strictEqual(loaded.length, 3); - - // First two should be migrated - assert.strictEqual(loaded[0].venvPath.fsPath, '/global/storage/deepnote-venvs/uuid1'); - assert.strictEqual(loaded[1].venvPath.fsPath, '/global/storage/deepnote-venvs/uuid2'); - // Third should remain unchanged - assert.strictEqual(loaded[2].venvPath.fsPath, '/global/storage/deepnote-venvs/uuid3'); - - verify(mockStorage.saveEnvironments(anything())).once(); - }); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentStorage.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentStorage.node.ts deleted file mode 100644 index 887604df78..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentStorage.node.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { injectable, inject } from 'inversify'; -import { Memento, Uri } from 'vscode'; -import { IExtensionContext } from '../../../platform/common/types'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; -import { logger } from '../../../platform/logging'; -import { DeepnoteEnvironment, DeepnoteEnvironmentState } from './deepnoteEnvironment'; - -const STORAGE_KEY = 'deepnote.kernelEnvironments'; - -/** - * Service for persisting and loading environments from global storage. - */ -@injectable() -export class DeepnoteEnvironmentStorage { - private readonly globalState: Memento; - - constructor(@inject(IExtensionContext) context: IExtensionContext) { - this.globalState = context.globalState; - } - - /** - * Load all environments from storage - */ - public async loadEnvironments(): Promise { - try { - const states = this.globalState.get(STORAGE_KEY, []); - const environments: DeepnoteEnvironment[] = []; - - for (const state of states) { - const config = this.deserializeEnvironment(state); - if (config) { - environments.push(config); - } else { - logger.error(`Failed to deserialize environment: ${state.id}`); - } - } - - logger.info(`Loaded ${environments.length} environments from storage`); - return environments; - } catch (error) { - logger.error('Failed to load environments', error); - return []; - } - } - - /** - * Save all environments to storage - */ - public async saveEnvironments(environments: DeepnoteEnvironment[]): Promise { - try { - const states = environments.map((config) => this.serializeEnvironment(config)); - await this.globalState.update(STORAGE_KEY, states); - logger.info(`Saved ${environments.length} environments to storage`); - } catch (error) { - logger.error('Failed to save environments', error); - throw error; - } - } - - /** - * Serialize an environment to a storable state - */ - private serializeEnvironment(config: DeepnoteEnvironment): DeepnoteEnvironmentState { - return { - id: config.id, - name: config.name, - pythonInterpreterPath: { - id: config.pythonInterpreter.id, - uri: config.pythonInterpreter.uri.toString(true) - }, - venvPath: config.venvPath.toString(true), - managedVenv: config.managedVenv, - createdAt: config.createdAt.toISOString(), - lastUsedAt: config.lastUsedAt.toISOString(), - packages: config.packages, - toolkitVersion: config.toolkitVersion, - description: config.description - }; - } - - /** - * Deserialize a stored state back to an environment - */ - private deserializeEnvironment(state: DeepnoteEnvironmentState): DeepnoteEnvironment | undefined { - try { - // Create PythonEnvironment directly from stored path - // No need to resolve through interpreter service - we just need the path - const interpreter: PythonEnvironment = { - uri: Uri.parse(state.pythonInterpreterPath.uri), - id: state.pythonInterpreterPath.id - }; - - return { - id: state.id, - name: state.name, - pythonInterpreter: interpreter, - venvPath: Uri.parse(state.venvPath), - managedVenv: state.managedVenv ?? true, - createdAt: new Date(state.createdAt), - lastUsedAt: new Date(state.lastUsedAt), - packages: state.packages, - toolkitVersion: state.toolkitVersion, - description: state.description, - serverInfo: undefined // Don't persist server info across sessions - }; - } catch (error) { - logger.error(`Failed to deserialize environment ${state.id}`, error); - return undefined; - } - } - - /** - * Clear all environments from storage - */ - public async clearEnvironments(): Promise { - try { - await this.globalState.update(STORAGE_KEY, []); - logger.info('Cleared all environments from storage'); - } catch (error) { - logger.error('Failed to clear environments', error); - throw error; - } - } -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentStorage.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentStorage.unit.test.ts deleted file mode 100644 index 7230744b0e..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentStorage.unit.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { assert, use } from 'chai'; -import { anything, instance, mock, when, verify, deepEqual } from 'ts-mockito'; -import { Memento, Uri } from 'vscode'; -import { DeepnoteEnvironmentStorage } from './deepnoteEnvironmentStorage.node'; -import { IExtensionContext } from '../../../platform/common/types'; -import { IInterpreterService } from '../../../platform/interpreter/contracts'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; -import { DeepnoteEnvironmentState } from './deepnoteEnvironment'; -import chaiAsPromised from 'chai-as-promised'; - -use(chaiAsPromised); - -suite('DeepnoteEnvironmentStorage', () => { - let storage: DeepnoteEnvironmentStorage; - let mockContext: IExtensionContext; - let mockInterpreterService: IInterpreterService; - let mockGlobalState: Memento; - - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3') - }; - - setup(() => { - mockContext = mock(); - mockInterpreterService = mock(); - mockGlobalState = mock(); - - when(mockGlobalState.update(anything(), anything())).thenResolve(); - when(mockContext.globalState).thenReturn(instance(mockGlobalState) as any); - - storage = new DeepnoteEnvironmentStorage(instance(mockContext)); - }); - - suite('loadEnvironments', () => { - test('should return empty array when no environments are stored', async () => { - when(mockGlobalState.get('deepnote.kernelEnvironments', anything())).thenReturn([]); - - const configs = await storage.loadEnvironments(); - - assert.deepStrictEqual(configs, []); - }); - - test('should load and deserialize stored environments', async () => { - const storedState: DeepnoteEnvironmentState = { - id: 'config-1', - name: 'Test Config', - pythonInterpreterPath: { - id: 'test-python-id', - uri: '/usr/bin/python3' - }, - venvPath: '/path/to/venv', - createdAt: '2025-01-01T00:00:00.000Z', - lastUsedAt: '2025-01-01T00:00:00.000Z', - packages: ['numpy', 'pandas'], - toolkitVersion: '0.2.30', - description: 'Test environment' - }; - - when(mockGlobalState.get('deepnote.kernelEnvironments', anything())).thenReturn([storedState]); - when(mockInterpreterService.getInterpreterDetails(anything())).thenResolve(testInterpreter); - - const configs = await storage.loadEnvironments(); - - assert.strictEqual(configs.length, 1); - assert.strictEqual(configs[0].id, 'config-1'); - assert.strictEqual(configs[0].name, 'Test Config'); - const expectedInterpreterFsPath = Uri.file(storedState.pythonInterpreterPath.uri).fsPath; - const expectedVenvFsPath = Uri.file(storedState.venvPath).fsPath; - assert.strictEqual(configs[0].pythonInterpreter.uri.fsPath, expectedInterpreterFsPath); - assert.strictEqual(configs[0].venvPath.fsPath, expectedVenvFsPath); - assert.deepStrictEqual(configs[0].packages, ['numpy', 'pandas']); - assert.strictEqual(configs[0].toolkitVersion, '0.2.30'); - assert.strictEqual(configs[0].description, 'Test environment'); - }); - - test('should load all environments including those with potentially invalid paths', async () => { - const storedStates: DeepnoteEnvironmentState[] = [ - { - id: 'config-1', - name: 'Valid Config', - pythonInterpreterPath: { - id: 'test-python-id', - uri: '/usr/bin/python3' - }, - venvPath: '/path/to/venv1', - createdAt: '2025-01-01T00:00:00.000Z', - lastUsedAt: '2025-01-01T00:00:00.000Z' - }, - { - id: 'config-2', - name: 'Potentially Invalid Config', - pythonInterpreterPath: { - id: 'test-python-id', - uri: '/invalid/python' - }, - venvPath: '/path/to/venv2', - createdAt: '2025-01-01T00:00:00.000Z', - lastUsedAt: '2025-01-01T00:00:00.000Z' - } - ]; - - when(mockGlobalState.get('deepnote.kernelEnvironments', anything())).thenReturn(storedStates); - - const configs = await storage.loadEnvironments(); - - // All environments should be loaded - interpreter validation happens at usage time, not load time - assert.strictEqual(configs.length, 2); - assert.strictEqual(configs[0].id, 'config-1'); - assert.strictEqual(configs[1].id, 'config-2'); - }); - - test('should handle errors gracefully and return empty array', async () => { - when(mockGlobalState.get('deepnote.kernelEnvironments', anything())).thenThrow(new Error('Storage error')); - - const configs = await storage.loadEnvironments(); - - assert.deepStrictEqual(configs, []); - }); - }); - - suite('saveEnvironments', () => { - test('should serialize and save environments', async () => { - const config = { - id: 'config-1', - name: 'Test Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date('2025-01-01T00:00:00.000Z'), - lastUsedAt: new Date('2025-01-01T00:00:00.000Z'), - packages: ['numpy'], - toolkitVersion: '0.2.30', - description: 'Test' - }; - - await storage.saveEnvironments([config]); - - verify( - mockGlobalState.update( - 'deepnote.kernelEnvironments', - deepEqual([ - { - id: 'config-1', - name: 'Test Config', - pythonInterpreterPath: { - id: 'test-python-id', - uri: 'file:///usr/bin/python3' - }, - venvPath: 'file:///path/to/venv', - managedVenv: true, - createdAt: '2025-01-01T00:00:00.000Z', - lastUsedAt: '2025-01-01T00:00:00.000Z', - packages: ['numpy'], - toolkitVersion: '0.2.30', - description: 'Test' - } - ]) - ) - ).once(); - }); - - test('should save multiple environments', async () => { - const configs = [ - { - id: 'config-1', - name: 'Config 1', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv1'), - managedVenv: true, - createdAt: new Date('2025-01-01T00:00:00.000Z'), - lastUsedAt: new Date('2025-01-01T00:00:00.000Z') - }, - { - id: 'config-2', - name: 'Config 2', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv2'), - managedVenv: true, - createdAt: new Date('2025-01-02T00:00:00.000Z'), - lastUsedAt: new Date('2025-01-02T00:00:00.000Z') - } - ]; - - when(mockGlobalState.update(anything(), anything())).thenResolve(); - - await storage.saveEnvironments(configs); - - verify(mockGlobalState.update('deepnote.kernelEnvironments', anything())).once(); - }); - - test('should throw error if storage update fails', async () => { - const config = { - id: 'config-1', - name: 'Test Config', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - when(mockGlobalState.update(anything(), anything())).thenReject(new Error('Storage error')); - - await assert.isRejected(storage.saveEnvironments([config]), 'Storage error'); - }); - }); - - suite('clearEnvironments', () => { - test('should clear all stored environments', async () => { - when(mockGlobalState.update(anything(), anything())).thenResolve(); - - await storage.clearEnvironments(); - - verify(mockGlobalState.update('deepnote.kernelEnvironments', deepEqual([]))).once(); - }); - - test('should throw error if clear fails', async () => { - when(mockGlobalState.update(anything(), anything())).thenReject(new Error('Storage error')); - - await assert.isRejected(storage.clearEnvironments(), 'Storage error'); - }); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.node.ts deleted file mode 100644 index a63f05c34c..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.node.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Disposable, Event, EventEmitter, TreeDataProvider, TreeItem } from 'vscode'; -import { IDeepnoteEnvironmentManager } from '../types'; -import { EnvironmentTreeItemType, DeepnoteEnvironmentTreeItem } from './deepnoteEnvironmentTreeItem.node'; -import { inject, injectable } from 'inversify'; -import { IExtensionSyncActivationService } from '../../../platform/activation/types'; - -/** - * Tree data provider for the Deepnote kernel environments view - */ -@injectable() -export class DeepnoteEnvironmentTreeDataProvider - implements TreeDataProvider, IExtensionSyncActivationService, Disposable -{ - private readonly _onDidChangeTreeData = new EventEmitter(); - private readonly disposables: Disposable[] = []; - - constructor(@inject(IDeepnoteEnvironmentManager) private readonly environmentManager: IDeepnoteEnvironmentManager) { - // Listen to environment changes and refresh the tree - this.disposables.push( - this.environmentManager.onDidChangeEnvironments(() => { - this.refresh(); - }) - ); - } - - public activate(): void { - this.refresh(); - } - - public get onDidChangeTreeData(): Event { - return this._onDidChangeTreeData.event; - } - - public refresh(): void { - this._onDidChangeTreeData.fire(); - } - - public getTreeItem(element: DeepnoteEnvironmentTreeItem): TreeItem { - return element; - } - - public async getChildren(element?: DeepnoteEnvironmentTreeItem): Promise { - if (!element) { - // Root level: show all environments + create action - return this.getRootItems(); - } - - // Expanded environment: show info items - if (element.type === EnvironmentTreeItemType.Environment && element.environment) { - return this.getEnvironmentInfoItems(element); - } - - return []; - } - - private async getRootItems(): Promise { - const environments = this.environmentManager.listEnvironments(); - const items: DeepnoteEnvironmentTreeItem[] = []; - - // Add environment items - for (const config of environments) { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, config); - - items.push(item); - } - - // Add create action at the end - items.push(new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.CreateAction)); - - return items; - } - - private getEnvironmentInfoItems(element: DeepnoteEnvironmentTreeItem): DeepnoteEnvironmentTreeItem[] { - const config = element.environment; - if (!config) { - return []; - } - - const items: DeepnoteEnvironmentTreeItem[] = []; - - // Python interpreter - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem( - 'python', - config.id, - `Python: ${config.pythonInterpreter.uri.fsPath}`, - 'symbol-namespace' - ) - ); - - // Venv path - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem('venv', config.id, `Venv: ${config.venvPath.fsPath}`, 'folder') - ); - - // Managed status - const managedLabel = config.managedVenv ? 'Type: Managed' : 'Type: External'; - const managedIcon = config.managedVenv ? 'shield' : 'link-external'; - items.push(DeepnoteEnvironmentTreeItem.createInfoItem('managed', config.id, managedLabel, managedIcon)); - - // Packages - if (config.packages && config.packages.length > 0) { - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem( - 'packages', - config.id, - `Packages: ${config.packages.join(', ')}`, - 'package' - ) - ); - } else { - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem('packages', config.id, 'Packages: (none)', 'package') - ); - } - - // Toolkit version - if (config.toolkitVersion) { - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem( - 'toolkit', - config.id, - `Toolkit: ${config.toolkitVersion}`, - 'versions' - ) - ); - } - - // Timestamps - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem( - 'created', - config.id, - `Created: ${config.createdAt.toLocaleString()}`, - 'history' - ) - ); - - items.push( - DeepnoteEnvironmentTreeItem.createInfoItem( - 'lastUsed', - config.id, - `Last used: ${config.lastUsedAt.toLocaleString()}`, - 'clock' - ) - ); - - return items; - } - - public dispose(): void { - this._onDidChangeTreeData.dispose(); - this.disposables.forEach((d) => d.dispose()); - } -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.unit.test.ts deleted file mode 100644 index 05b690f3b7..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.unit.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { assert } from 'chai'; -import { instance, mock, when } from 'ts-mockito'; -import { Uri, EventEmitter } from 'vscode'; -import { createMockChildProcess } from '../deepnoteTestHelpers.node'; -import { DeepnoteEnvironmentTreeDataProvider } from './deepnoteEnvironmentTreeDataProvider.node'; -import { IDeepnoteEnvironmentManager } from '../types'; -import { DeepnoteEnvironment } from './deepnoteEnvironment'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; -import { EnvironmentTreeItemType } from './deepnoteEnvironmentTreeItem.node'; - -suite('DeepnoteEnvironmentTreeDataProvider', () => { - let provider: DeepnoteEnvironmentTreeDataProvider; - let mockConfigManager: IDeepnoteEnvironmentManager; - let configChangeEmitter: EventEmitter; - - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3') - }; - - const testConfig1: DeepnoteEnvironment = { - id: 'config-1', - name: 'Config 1', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv1'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const testConfig2: DeepnoteEnvironment = { - id: 'config-2', - name: 'Config 2', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv2'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date(), - packages: ['numpy'], - serverInfo: { - url: 'http://localhost:8888', - jupyterPort: 8888, - lspPort: 8889, - token: 'test-token', - process: createMockChildProcess() - } - }; - - setup(() => { - mockConfigManager = mock(); - configChangeEmitter = new EventEmitter(); - - when(mockConfigManager.onDidChangeEnvironments).thenReturn(configChangeEmitter.event); - when(mockConfigManager.listEnvironments()).thenReturn([]); - - provider = new DeepnoteEnvironmentTreeDataProvider(instance(mockConfigManager)); - }); - - suite('getChildren - Root Level', () => { - test('should return create action when no environments exist', async () => { - when(mockConfigManager.listEnvironments()).thenReturn([]); - - const children = await provider.getChildren(); - - assert.strictEqual(children.length, 1); - assert.strictEqual(children[0].type, EnvironmentTreeItemType.CreateAction); - }); - - test('should return environments and create action', async () => { - when(mockConfigManager.listEnvironments()).thenReturn([testConfig1, testConfig2]); - when(mockConfigManager.getEnvironment('config-1')).thenReturn(testConfig1); - when(mockConfigManager.getEnvironment('config-2')).thenReturn(testConfig2); - - const children = await provider.getChildren(); - - assert.strictEqual(children.length, 3); // 2 configs + create action - assert.strictEqual(children[0].type, EnvironmentTreeItemType.Environment); - assert.strictEqual(children[1].type, EnvironmentTreeItemType.Environment); - assert.strictEqual(children[2].type, EnvironmentTreeItemType.CreateAction); - }); - }); - - suite('getChildren - Environment Children', () => { - test('should include packages when present', async () => { - when(mockConfigManager.listEnvironments()).thenReturn([testConfig2]); - when(mockConfigManager.getEnvironment('config-2')).thenReturn(testConfig2); - - const rootChildren = await provider.getChildren(); - const configItem = rootChildren[0]; - const infoItems = await provider.getChildren(configItem); - - const labels = infoItems.map((item) => item.label as string); - const hasPackages = labels.some((label) => label.includes('Packages:') && label.includes('numpy')); - - assert.isTrue(hasPackages); - }); - - test('should return empty array for non-environment items', async () => { - when(mockConfigManager.listEnvironments()).thenReturn([]); - - const rootChildren = await provider.getChildren(); - const createAction = rootChildren[0]; - const children = await provider.getChildren(createAction); - - assert.deepStrictEqual(children, []); - }); - - test('should return empty array for info items', async () => { - when(mockConfigManager.listEnvironments()).thenReturn([testConfig1]); - when(mockConfigManager.getEnvironment('config-1')).thenReturn(testConfig1); - - const rootChildren = await provider.getChildren(); - const configItem = rootChildren[0]; - const infoItems = await provider.getChildren(configItem); - const children = await provider.getChildren(infoItems[0]); - - assert.deepStrictEqual(children, []); - }); - }); - - suite('getTreeItem', () => { - test('should return the same tree item', async () => { - when(mockConfigManager.listEnvironments()).thenReturn([testConfig1]); - when(mockConfigManager.getEnvironment('config-1')).thenReturn(testConfig1); - - const children = await provider.getChildren(); - const item = children[0]; - const treeItem = provider.getTreeItem(item); - - assert.strictEqual(treeItem, item); - }); - }); - - suite('refresh', () => { - test('should fire onDidChangeTreeData event', (done) => { - provider.onDidChangeTreeData(() => { - done(); - }); - - provider.refresh(); - }); - }); - - suite('Auto-refresh on environment changes', () => { - test('should refresh when environments change', (done) => { - provider.onDidChangeTreeData(() => { - done(); - }); - - // Simulate environment change - configChangeEmitter.fire(); - }); - }); - - suite('dispose', () => { - test('should dispose without errors', () => { - provider.dispose(); - // Should not throw - }); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.node.ts deleted file mode 100644 index dcd24756ee..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.node.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { l10n, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; - -import { DeepnoteEnvironment } from './deepnoteEnvironment'; - -/** - * Type of tree item in the environments view - */ -export enum EnvironmentTreeItemType { - Environment = 'environment', - InfoItem = 'info', - CreateAction = 'create' -} - -export type DeepnoteEnvironmentTreeInfoItemId = - | 'python' - | 'venv' - | 'managed' - | 'packages' - | 'toolkit' - | 'created' - | 'lastUsed'; - -/** - * Tree item for displaying environments and related info - */ -export class DeepnoteEnvironmentTreeItem extends TreeItem { - constructor( - public readonly type: EnvironmentTreeItemType, - public readonly environment?: DeepnoteEnvironment, - label?: string, - collapsibleState?: TreeItemCollapsibleState - ) { - super(label || '', collapsibleState); - - // Setup inline to avoid method binding issues with ES modules and TreeItem proxy - if (type === EnvironmentTreeItemType.Environment && environment) { - // setupEnvironmentItem inline - this.id = environment.id; - this.label = environment.name; - this.collapsibleState = TreeItemCollapsibleState.Collapsed; - - // getRelativeTime inline - const now = new Date(); - const diff = now.getTime() - environment.lastUsedAt.getTime(); - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - let lastUsed: string; - if (seconds < 60) { - lastUsed = l10n.t('just now'); - } else if (minutes < 60) { - lastUsed = minutes === 1 ? l10n.t('1 minute ago') : l10n.t('{0} minutes ago', minutes); - } else if (hours < 24) { - lastUsed = hours === 1 ? l10n.t('1 hour ago') : l10n.t('{0} hours ago', hours); - } else if (days < 7) { - lastUsed = days === 1 ? l10n.t('1 day ago') : l10n.t('{0} days ago', days); - } else { - lastUsed = environment.lastUsedAt.toLocaleDateString(); - } - - this.description = l10n.t('Last used: {0}', lastUsed); - - // buildTooltip inline - const lines: string[] = []; - lines.push(`**${environment.name}**`); - lines.push(''); - lines.push(l10n.t('Python: {0}', environment.pythonInterpreter.uri.toString(true))); - lines.push(l10n.t('Venv: {0}', environment.venvPath.toString(true))); - lines.push( - environment.managedVenv - ? l10n.t('Type: Managed (created by extension)') - : l10n.t('Type: External (user-provided)') - ); - - if (environment.packages && environment.packages.length > 0) { - lines.push(l10n.t('Packages: {0}', environment.packages.join(', '))); - } - - if (environment.toolkitVersion) { - lines.push(l10n.t('Toolkit: {0}', environment.toolkitVersion)); - } - - lines.push(''); - lines.push(l10n.t('Created: {0}', environment.createdAt.toLocaleString())); - lines.push(l10n.t('Last used: {0}', environment.lastUsedAt.toLocaleString())); - - this.tooltip = lines.join('\n'); - } else if (type === EnvironmentTreeItemType.InfoItem) { - // setupInfoItem inline - this.contextValue = 'deepnoteEnvironment.info'; - this.collapsibleState = TreeItemCollapsibleState.None; - } else if (type === EnvironmentTreeItemType.CreateAction) { - // setupCreateAction inline - this.id = 'create'; - this.label = l10n.t('Create New Environment'); - this.iconPath = new ThemeIcon('add'); - this.contextValue = 'deepnoteEnvironment.create'; - this.collapsibleState = TreeItemCollapsibleState.None; - this.command = { - command: 'deepnote.environments.create', - title: l10n.t('Create Environment') - }; - } - } - - /** - * Create an info item to display under an environment - */ - public static createInfoItem( - id: DeepnoteEnvironmentTreeInfoItemId, - environmentId: string, - label: string, - icon?: string - ): DeepnoteEnvironmentTreeItem { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.InfoItem, undefined, label); - item.id = `info-${environmentId}-${id}`; - - if (icon) { - item.iconPath = new ThemeIcon(icon); - } - - return item; - } -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.unit.test.ts deleted file mode 100644 index a7f0a03de7..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentTreeItem.unit.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { assert } from 'chai'; -import { ThemeIcon, TreeItemCollapsibleState, Uri } from 'vscode'; - -import { DeepnoteEnvironmentTreeItem, EnvironmentTreeItemType } from './deepnoteEnvironmentTreeItem.node'; -import { DeepnoteEnvironment } from './deepnoteEnvironment'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; - -suite('DeepnoteEnvironmentTreeItem', () => { - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3') - }; - - const testEnvironment: DeepnoteEnvironment = { - id: 'test-config-id', - name: 'Test Environment', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date('2024-01-01T10:00:00Z'), - lastUsedAt: new Date('2024-01-01T12:00:00Z') - }; - - suite('Environment Item', () => { - test('should create environment item', () => { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, testEnvironment); - - assert.strictEqual(item.type, EnvironmentTreeItemType.Environment); - assert.strictEqual(item.environment, testEnvironment); - assert.strictEqual(item.label, 'Test Environment'); - assert.strictEqual(item.collapsibleState, TreeItemCollapsibleState.Collapsed); - }); - - test('should include last used time in description', () => { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, testEnvironment); - - assert.include(item.description as string, 'Last used:'); - }); - - test('should have tooltip with environment details', () => { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, testEnvironment); - - const tooltip = `${item.tooltip}`; - assert.include(tooltip, 'Test Environment'); - assert.include(tooltip, testInterpreter.uri.toString(true)); - }); - - test('should include packages in tooltip when present', () => { - const configWithPackages: DeepnoteEnvironment = { - ...testEnvironment, - packages: ['numpy', 'pandas'] - }; - - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, configWithPackages); - - const tooltip = item.tooltip as string; - assert.include(tooltip, 'numpy'); - assert.include(tooltip, 'pandas'); - }); - }); - - suite('Info Item', () => { - test('should create info item', () => { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.InfoItem, undefined, 'Info Label'); - - assert.strictEqual(item.type, EnvironmentTreeItemType.InfoItem); - assert.strictEqual(item.label, 'Info Label'); - assert.strictEqual(item.contextValue, 'deepnoteEnvironment.info'); - assert.strictEqual(item.collapsibleState, TreeItemCollapsibleState.None); - }); - - test('should create info item with icon', () => { - const item = DeepnoteEnvironmentTreeItem.createInfoItem( - 'python', - 'test-config-id', - 'Python: /usr/bin/python3', - 'circle-filled' - ); - - assert.strictEqual(item.label, 'Python: /usr/bin/python3'); - assert.instanceOf(item.iconPath, ThemeIcon); - assert.strictEqual((item.iconPath as ThemeIcon).id, 'circle-filled'); - }); - - test('should create info item without icon', () => { - const item = DeepnoteEnvironmentTreeItem.createInfoItem('venv', 'test-config-id', 'No icon'); - - assert.strictEqual(item.label, 'No icon'); - assert.isUndefined(item.iconPath); - }); - }); - - suite('Create Action Item', () => { - test('should create action item', () => { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.CreateAction); - - assert.strictEqual(item.type, EnvironmentTreeItemType.CreateAction); - assert.strictEqual(item.label, 'Create New Environment'); - assert.strictEqual(item.contextValue, 'deepnoteEnvironment.create'); - assert.strictEqual(item.collapsibleState, TreeItemCollapsibleState.None); - assert.instanceOf(item.iconPath, ThemeIcon); - assert.strictEqual((item.iconPath as ThemeIcon).id, 'add'); - }); - - test('should have command', () => { - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.CreateAction); - - assert.ok(item.command); - assert.strictEqual(item.command?.command, 'deepnote.environments.create'); - assert.strictEqual(item.command?.title, 'Create Environment'); - }); - }); - - suite('Relative Time Formatting', () => { - test('should show "just now" for recent times', () => { - const recentConfig: DeepnoteEnvironment = { - ...testEnvironment, - lastUsedAt: new Date() - }; - - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, recentConfig); - - assert.include(item.description as string, 'just now'); - }); - - test('should handle negative time (few seconds in the past)', () => { - const fewSecondsAgo = new Date(Date.now() - 5 * 1000); - const config: DeepnoteEnvironment = { - ...testEnvironment, - lastUsedAt: fewSecondsAgo - }; - - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, config); - - assert.include(item.description as string, 'just now'); - }); - - test('should show minutes ago', () => { - const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); - const config: DeepnoteEnvironment = { - ...testEnvironment, - lastUsedAt: fiveMinutesAgo - }; - - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, config); - - assert.include(item.description as string, 'minute'); - assert.include(item.description as string, 'ago'); - }); - - test('should show hours ago', () => { - const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); - const config: DeepnoteEnvironment = { - ...testEnvironment, - lastUsedAt: twoHoursAgo - }; - - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, config); - - assert.include(item.description as string, 'hour'); - assert.include(item.description as string, 'ago'); - }); - - test('should show days ago', () => { - const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); - const config: DeepnoteEnvironment = { - ...testEnvironment, - lastUsedAt: threeDaysAgo - }; - - const item = new DeepnoteEnvironmentTreeItem(EnvironmentTreeItemType.Environment, config); - - assert.include(item.description as string, 'day'); - assert.include(item.description as string, 'ago'); - }); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.ts deleted file mode 100644 index 4ccf8534e9..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { inject, injectable, named } from 'inversify'; -import { IExtensionSyncActivationService } from '../../../platform/activation/types'; -import { IDeepnoteEnvironmentManager } from '../types'; -import { DeepnoteEnvironmentsView } from './deepnoteEnvironmentsView.node'; -import { logger } from '../../../platform/logging'; -import { IOutputChannel } from '../../../platform/common/types'; -import { STANDARD_OUTPUT_CHANNEL } from '../../../platform/common/constants'; -import { l10n } from 'vscode'; - -/** - * Activation service for the Deepnote kernel environments view. - * Initializes the environment manager and registers the tree view. - */ -@injectable() -export class DeepnoteEnvironmentsActivationService implements IExtensionSyncActivationService { - constructor( - @inject(IDeepnoteEnvironmentManager) - private readonly environmentManager: IDeepnoteEnvironmentManager, - @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, - @inject(DeepnoteEnvironmentsView) - _environmentsView: DeepnoteEnvironmentsView - ) { - // _environmentsView is injected to ensure the view is created, - // but we don't need to store a reference to it - } - - public activate(): void { - logger.info('Activating Deepnote kernel environments view'); - - // Initialize the environment manager (loads environments from storage) - this.environmentManager.initialize().then( - () => { - logger.info('Deepnote kernel environments initialized'); - }, - (error: unknown) => { - logger.error('Failed to initialize Deepnote kernel environments', error); - const msg = error instanceof Error ? error.message : String(error); - this.outputChannel.appendLine(l10n.t('Failed to initialize Deepnote kernel environments: {0}', msg)); - } - ); - } -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.unit.test.ts deleted file mode 100644 index 03c2be0902..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsActivationService.unit.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { assert } from 'chai'; -import { instance, mock, when, verify } from 'ts-mockito'; -import { DeepnoteEnvironmentsActivationService } from './deepnoteEnvironmentsActivationService'; -import { IDeepnoteEnvironmentManager } from '../types'; -import { DeepnoteEnvironmentsView } from './deepnoteEnvironmentsView.node'; -import { IOutputChannel } from '../../../platform/common/types'; - -suite('DeepnoteEnvironmentsActivationService', () => { - let activationService: DeepnoteEnvironmentsActivationService; - let mockConfigManager: IDeepnoteEnvironmentManager; - let mockEnvironmentsView: DeepnoteEnvironmentsView; - let mockOutputChannel: IOutputChannel; - - setup(() => { - mockConfigManager = mock(); - mockEnvironmentsView = mock(); - mockOutputChannel = mock(); - - activationService = new DeepnoteEnvironmentsActivationService( - instance(mockConfigManager), - instance(mockOutputChannel), - instance(mockEnvironmentsView) - ); - }); - - suite('activate', () => { - test('should call initialize on environment manager', async () => { - when(mockConfigManager.initialize()).thenResolve(); - - activationService.activate(); - - // Wait for async initialization - await new Promise((resolve) => setTimeout(resolve, 100)); - - verify(mockConfigManager.initialize()).once(); - }); - - test('should handle initialization errors gracefully', async () => { - when(mockConfigManager.initialize()).thenReject(new Error('Initialization failed')); - - // Should not throw - activationService.activate(); - - // Wait for async initialization - await new Promise((resolve) => setTimeout(resolve, 100)); - - verify(mockConfigManager.initialize()).once(); - }); - - test('should not throw when activate is called', () => { - when(mockConfigManager.initialize()).thenResolve(); - - assert.doesNotThrow(() => { - activationService.activate(); - }); - }); - }); - - suite('constructor', () => { - test('should create service with dependencies', () => { - assert.ok(activationService); - }); - - test('should accept dependencies', () => { - const service = new DeepnoteEnvironmentsActivationService( - instance(mockConfigManager), - instance(mockOutputChannel), - instance(mockEnvironmentsView) - ); - - assert.ok(service); - }); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts deleted file mode 100644 index e04c9d4d59..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.node.ts +++ /dev/null @@ -1,631 +0,0 @@ -import { inject, injectable, named } from 'inversify'; -import { - commands, - Disposable, - l10n, - NotebookDocument, - ProgressLocation, - QuickPickItem, - TreeView, - window, - workspace -} from 'vscode'; -import { IPythonApiProvider } from '../../../platform/api/types'; -import { STANDARD_OUTPUT_CHANNEL } from '../../../platform/common/constants'; -import { getDisplayPath } from '../../../platform/common/platform/fs-paths.node'; -import { ITelemetryService } from '../../../platform/analytics/types'; -import { IDisposableRegistry, IOutputChannel } from '../../../platform/common/types'; -import { createDeepnoteServerConfigHandle } from '../../../platform/deepnote/deepnoteServerUtils.node'; -import { DeepnoteToolkitMissingError } from '../../../platform/errors/deepnoteKernelErrors'; -import { - getCachedEnvironment, - getPythonEnvironmentName, - resolvedPythonEnvToJupyterEnv -} from '../../../platform/interpreter/helpers'; -import { logger } from '../../../platform/logging'; -import { IKernelProvider } from '../../types'; -import { - DeepnoteKernelConnectionMetadata, - IDeepnoteEnvironmentManager, - IDeepnoteKernelAutoSelector, - IDeepnoteNotebookEnvironmentMapper, - IDeepnoteServerStarter -} from '../types'; -import { CreateDeepnoteEnvironmentOptions, DeepnoteEnvironment } from './deepnoteEnvironment'; -import { DeepnoteEnvironmentTreeDataProvider } from './deepnoteEnvironmentTreeDataProvider.node'; -import { DeepnoteEnvironmentTreeItem } from './deepnoteEnvironmentTreeItem.node'; - -/** - * View controller for the Deepnote kernel environments tree view. - * Manages the tree view and handles all environment-related commands. - */ -@injectable() -export class DeepnoteEnvironmentsView implements Disposable { - private readonly treeView: TreeView; - private readonly disposables: Disposable[] = []; - - constructor( - @inject(IDeepnoteEnvironmentManager) private readonly environmentManager: IDeepnoteEnvironmentManager, - @inject(DeepnoteEnvironmentTreeDataProvider) - private readonly treeDataProvider: DeepnoteEnvironmentTreeDataProvider, - @inject(IPythonApiProvider) private readonly pythonApiProvider: IPythonApiProvider, - @inject(IDisposableRegistry) disposableRegistry: IDisposableRegistry, - @inject(IDeepnoteKernelAutoSelector) private readonly kernelAutoSelector: IDeepnoteKernelAutoSelector, - @inject(IDeepnoteNotebookEnvironmentMapper) - private readonly notebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper, - @inject(IKernelProvider) private readonly kernelProvider: IKernelProvider, - @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, - @inject(IDeepnoteServerStarter) private readonly serverStarter: IDeepnoteServerStarter, - @inject(ITelemetryService) private readonly analytics: ITelemetryService - ) { - // Create tree data provider - - // Create tree view - this.treeView = window.createTreeView('deepnoteEnvironments', { - treeDataProvider: this.treeDataProvider, - showCollapseAll: true - }); - - this.disposables.push(this.treeView); - - // Register commands - this.registerCommands(); - - // Register for disposal - disposableRegistry.push(this); - } - - public async createEnvironmentCommand(): Promise { - try { - // Step 1: Select Python interpreter - const api = await this.pythonApiProvider.getNewApi(); - if (!api || !api.environments.known || api.environments.known.length === 0) { - void window.showErrorMessage(l10n.t('No Python interpreters found. Please install Python first.')); - return; - } - - const interpreterItems = api.environments.known - .map((env) => { - const interpreter = resolvedPythonEnvToJupyterEnv(getCachedEnvironment(env)); - if (!interpreter) { - return undefined; - } - return { - label: getPythonEnvironmentName(interpreter) || getDisplayPath(interpreter.uri), - description: getDisplayPath(interpreter.uri), - interpreter - }; - }) - .filter( - ( - item - ): item is { - label: string; - description: string; - interpreter: import('../../../platform/pythonEnvironments/info').PythonEnvironment; - } => item !== undefined - ); - - const selectedInterpreter = await window.showQuickPick(interpreterItems, { - placeHolder: l10n.t('Select a Python interpreter for this environment'), - matchOnDescription: true - }); - - if (!selectedInterpreter) { - return; - } - - // Step 2: Enter environment name - const name = await window.showInputBox({ - prompt: l10n.t('Enter a name for this environment'), - placeHolder: l10n.t('e.g., Python 3.11 (Data Science)'), - validateInput: (value: string) => { - if (!value || value.trim().length === 0) { - return l10n.t('Name cannot be empty'); - } - return undefined; - } - }); - - if (!name) { - return; - } - - // Check if name is already in use - const existingEnvironments = this.environmentManager.listEnvironments(); - if (existingEnvironments.some((env) => env.name === name)) { - void window.showErrorMessage(l10n.t('An environment with this name already exists')); - return; - } - - // Step 3: Enter packages (optional) - const packagesInput = await window.showInputBox({ - prompt: l10n.t('Enter additional packages to install (comma-separated, optional)'), - placeHolder: l10n.t('e.g., matplotlib, tensorflow'), - validateInput: (value: string) => { - if (!value || value.trim().length === 0) { - return undefined; // Empty is OK - } - // Basic validation: check for valid package names - const packages = value.split(',').map((p: string) => p.trim()); - for (const pkg of packages) { - const isValid = - /^[A-Za-z0-9._\-]+(\[[A-Za-z0-9_,.\-]+\])?(\s*(==|>=|<=|~=|>|<)\s*[A-Za-z0-9.*+!\-_.]+)?(?:\s*;.+)?$/.test( - pkg - ); - if (!isValid) { - return l10n.t('Invalid package name: {0}', pkg); - } - } - return undefined; - } - }); - - // Parse packages - const packages = - packagesInput && packagesInput.trim().length > 0 - ? packagesInput - .split(',') - .map((p: string) => p.trim()) - .filter((p: string) => p.length > 0) - : undefined; - - // Step 4: Enter description (optional) - const description = await window.showInputBox({ - prompt: l10n.t('Enter a description for this environment (optional)'), - placeHolder: l10n.t('e.g., Environment for data science projects') - }); - - // Create environment with progress - return await window.withProgress( - { - location: ProgressLocation.Notification, - title: l10n.t('Creating environment "{0}"...', name), - cancellable: true - }, - async (progress: { report: (value: { message?: string; increment?: number }) => void }, token) => { - progress.report({ message: l10n.t('Setting up virtual environment...') }); - - const options: CreateDeepnoteEnvironmentOptions = { - name: name.trim(), - pythonInterpreter: selectedInterpreter.interpreter, - packages, - description: description?.trim() - }; - - try { - const config = await this.environmentManager.createEnvironment(options, token); - logger.info(`Created environment: ${config.id} (${config.name})`); - - this.analytics.trackEvent({ - eventName: 'create_environment', - properties: { - hasDescription: !!options.description, - packageCount: options.packages?.length ?? 0 - } - }); - - void window.showInformationMessage( - l10n.t('Environment "{0}" created successfully!', config.name) - ); - - return config; - } catch (error) { - logger.error('Failed to create environment', error); - throw error; - } - } - ); - } catch (error) { - void window.showErrorMessage(l10n.t('Failed to create environment. See output for details.')); - } - } - - private registerCommands(): void { - // Refresh command - this.disposables.push( - commands.registerCommand('deepnote.environments.refresh', () => { - this.treeDataProvider.refresh(); - }) - ); - - // Create environment command - this.disposables.push( - commands.registerCommand('deepnote.environments.create', async () => { - await this.createEnvironmentCommand(); - }) - ); - - // Delete environment command - this.disposables.push( - commands.registerCommand('deepnote.environments.delete', async (item: DeepnoteEnvironmentTreeItem) => { - if (item?.environment) { - await this.deleteEnvironmentCommand(item.environment.id); - } - }) - ); - - // Edit name command - this.disposables.push( - commands.registerCommand('deepnote.environments.editName', async (item: DeepnoteEnvironmentTreeItem) => { - if (item?.environment) { - await this.editEnvironmentName(item.environment.id); - } - }) - ); - - // Manage packages command - this.disposables.push( - commands.registerCommand( - 'deepnote.environments.managePackages', - async (item: DeepnoteEnvironmentTreeItem) => { - if (item?.environment) { - await this.managePackages(item.environment.id); - } - } - ) - ); - - // Switch environment for notebook command - this.disposables.push( - commands.registerCommand( - 'deepnote.environments.selectForNotebook', - async (options?: { notebook?: NotebookDocument }) => { - // Get the active notebook - const activeNotebook = options?.notebook ?? window.activeNotebookEditor?.notebook; - if (!activeNotebook || activeNotebook.notebookType !== 'deepnote') { - void window.showWarningMessage(l10n.t('No active Deepnote notebook found')); - return; - } - - await this.selectEnvironmentForNotebook({ notebook: activeNotebook }); - } - ) - ); - } - - public async deleteEnvironmentCommand(environmentId: string): Promise { - const config = this.environmentManager.getEnvironment(environmentId); - if (!config) { - return; - } - - // Confirm deletion - const confirmation = await window.showWarningMessage( - l10n.t( - 'Are you sure you want to delete "{0}"? This will remove the virtual environment and cannot be undone.', - config.name - ), - { modal: true }, - l10n.t('Delete') - ); - - if (confirmation !== l10n.t('Delete')) { - return; - } - - try { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: l10n.t('Deleting environment "{0}"...', config.name), - cancellable: true - }, - async (_progress, token) => { - // Resolve every notebook that uses this environment from the persisted - // mapper state BEFORE any entries are removed, so the list is complete. - const uris = this.notebookEnvironmentMapper.getNotebooksUsingEnvironment(environmentId); - - // Stop each notebook's server (per-notebook keying reaches closed-but-running ones too). - // stopServer is a safe no-op when a notebook has no running server. - for (const uri of uris) { - try { - await this.serverStarter.stopServer(uri, token); - } catch (error) { - logger.error(`Failed to stop server for ${getDisplayPath(uri)}`, error); - } - } - - // Dispose kernels from any open notebooks using this environment - await this.disposeKernelsUsingEnvironment(environmentId); - - for (const uri of uris) { - await this.notebookEnvironmentMapper.removeEnvironmentForNotebook(uri); - } - - await this.environmentManager.deleteEnvironment(environmentId, token); - logger.info(`Deleted environment: ${environmentId}`); - } - ); - - this.analytics.trackEvent({ eventName: 'delete_environment' }); - void window.showInformationMessage(l10n.t('Environment "{0}" deleted', config.name)); - } catch (error) { - logger.error('Failed to delete environment', error); - void window.showErrorMessage(l10n.t('Failed to delete environment. See output for details.')); - } - } - - /** - * Dispose kernels from any open notebooks that are using the specified environment. - * This ensures the UI reflects that the kernel is no longer available. - */ - private async disposeKernelsUsingEnvironment(environmentId: string): Promise { - const openNotebooks = workspace.notebookDocuments; - - for (const notebook of openNotebooks) { - // Only check Deepnote notebooks - if (notebook.notebookType !== 'deepnote') { - continue; - } - - // Get the kernel for this notebook - const kernel = this.kernelProvider.get(notebook); - if (!kernel) { - continue; - } - - // Check if this kernel is using the environment being deleted - const connectionMetadata = kernel.kernelConnectionMetadata; - if (connectionMetadata.kind === 'startUsingDeepnoteKernel') { - const deepnoteMetadata = connectionMetadata as DeepnoteKernelConnectionMetadata; - const expectedHandle = createDeepnoteServerConfigHandle(environmentId, notebook.uri); - - if (deepnoteMetadata.serverProviderHandle.handle === expectedHandle) { - logger.info( - `Disposing kernel for notebook ${getDisplayPath( - notebook.uri - )} as it uses deleted environment ${environmentId}` - ); - - try { - // First, unselect the controller from the notebook UI - this.kernelAutoSelector.clearControllerForEnvironment(notebook, environmentId); - - // Then dispose the kernel - await kernel.dispose(); - } catch (error) { - logger.error(`Failed to dispose kernel for ${getDisplayPath(notebook.uri)}`, error); - } - } - } - } - } - - public async selectEnvironmentForNotebook({ notebook }: { notebook: NotebookDocument }): Promise { - logger.info('Selecting environment for notebook:', notebook); - - // Get current environment selection - const currentEnvironmentId = this.notebookEnvironmentMapper.getEnvironmentForNotebook(notebook.uri); - const currentEnvironment = currentEnvironmentId - ? this.environmentManager.getEnvironment(currentEnvironmentId) - : undefined; - - // Get all environments - const environments = this.environmentManager.listEnvironments(); - - // Build quick pick items - const items: (QuickPickItem & { environmentId?: string })[] = environments.map((env) => { - const isCurrent = currentEnvironment?.id === env.id; - - return { - label: `${env.name} ${isCurrent ? ' $(check)' : ''}`, - description: getDisplayPath(env.pythonInterpreter.uri), - detail: env.packages?.length - ? l10n.t('Packages: {0}', env.packages.join(', ')) - : l10n.t('No additional packages'), - environmentId: env.id - }; - }); - - const createNewLabel = l10n.t('$(add) Create New Environment'); - - // Add "Create new" option at the end - items.push({ - label: createNewLabel, - description: l10n.t('Set up a new kernel environment'), - alwaysShow: true - }); - - const selected = await window.showQuickPick(items, { - placeHolder: l10n.t('Select an environment for this notebook'), - matchOnDescription: true, - matchOnDetail: true - }); - - if (!selected) { - return; // User cancelled - } - - let selectedEnvironmentId: string | undefined; - - if (selected.label === createNewLabel) { - const newEnvironment = await this.createEnvironmentCommand(); - if (newEnvironment == null) { - return; - } - // return; - selectedEnvironmentId = newEnvironment.id; - } else { - selectedEnvironmentId = selected.environmentId; - } - - // Check if user selected the same environment - if (selectedEnvironmentId === currentEnvironmentId) { - logger.info(`User selected the same environment - no changes needed`); - return; - } else if (selectedEnvironmentId == null) { - logger.info('User cancelled environment selection'); - return; - } - - // Check if any cells are currently executing using the kernel execution state - // This is more reliable than checking executionSummary - const kernel = this.kernelProvider.get(notebook); - const hasExecutingCells = kernel - ? this.kernelProvider.getKernelExecution(kernel).pendingCells.length > 0 - : false; - - if (hasExecutingCells) { - const proceed = await window.showWarningMessage( - l10n.t( - 'Some cells are currently executing. Switching environments now may cause errors. Do you want to continue?' - ), - { modal: true }, - l10n.t('Yes, Switch Anyway'), - l10n.t('Cancel') - ); - - if (proceed !== l10n.t('Yes, Switch Anyway')) { - logger.info('User cancelled environment switch due to executing cells'); - return; - } - } - - // User selected a different environment - switch to it - logger.info(`Switching notebook ${getDisplayPath(notebook.uri)} to environment ${selectedEnvironmentId}`); - - try { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: l10n.t('Switching to environment...'), - cancellable: true - }, - async (progress, token) => { - // Update the notebook-to-environment mapping - await this.notebookEnvironmentMapper.setEnvironmentForNotebook(notebook.uri, selectedEnvironmentId); - - // Force rebuild the controller with the new environment - // This clears cached metadata and creates a fresh controller. - // await this.kernelAutoSelector.ensureKernelSelected(activeNotebook); - await this.kernelAutoSelector.rebuildController(notebook, progress, token); - - logger.info(`Successfully switched to environment ${selectedEnvironmentId}`); - } - ); - - this.analytics.trackEvent({ eventName: 'select_environment' }); - void window.showInformationMessage(l10n.t('Environment switched successfully')); - } catch (error) { - if (error instanceof DeepnoteToolkitMissingError) { - await this.kernelAutoSelector.handleKernelSelectionError(error, notebook); - return; - } - - logger.error('Failed to switch environment', error); - const showOutputAction = l10n.t('Show Output'); - const errorMessage = error instanceof Error ? error.message : String(error); - const selectedAction = await window.showErrorMessage( - l10n.t('Failed to switch environment: {0}', errorMessage), - { modal: false }, - showOutputAction - ); - - if (selectedAction === showOutputAction) { - this.outputChannel.show(); - } - } - } - - public async editEnvironmentName(environmentId: string): Promise { - const config = this.environmentManager.getEnvironment(environmentId); - if (!config) { - return; - } - - const newName = await window.showInputBox({ - prompt: l10n.t('Enter a new name for this environment'), - value: config.name, - validateInput: (value: string) => { - if (!value || value.trim().length === 0) { - return l10n.t('Name cannot be empty'); - } - return undefined; - } - }); - - if (!newName || newName === config.name) { - return; - } - - try { - await this.environmentManager.updateEnvironment(environmentId, { - name: newName.trim() - }); - - logger.info(`Renamed environment ${environmentId} to "${newName}"`); - void window.showInformationMessage(l10n.t('Environment renamed to "{0}"', newName)); - this.analytics.trackEvent({ eventName: 'update_environment', properties: { field: 'name' } }); - } catch (error) { - logger.error('Failed to rename environment', error); - void window.showErrorMessage(l10n.t('Failed to rename environment. See output for details.')); - } - } - - private async managePackages(environmentId: string): Promise { - const config = this.environmentManager.getEnvironment(environmentId); - if (!config) { - return; - } - - // Show input box for package names - const packagesInput = await window.showInputBox({ - prompt: l10n.t('Enter packages to install (comma-separated)'), - placeHolder: l10n.t('e.g., pandas, numpy, matplotlib'), - value: config.packages?.join(', ') || '', - validateInput: (value: string) => { - if (!value || value.trim().length === 0) { - return l10n.t('Please enter at least one package'); - } - const packages = value.split(',').map((p: string) => p.trim()); - for (const pkg of packages) { - const isValid = - /^[A-Za-z0-9._\-]+(\[[A-Za-z0-9_,.\-]+\])?(\s*(==|>=|<=|~=|>|<)\s*[A-Za-z0-9.*+!\-_.]+)?(?:\s*;.+)?$/.test( - pkg - ); - if (!isValid) { - return l10n.t('Invalid package name: {0}', pkg); - } - } - return undefined; - } - }); - - if (!packagesInput) { - return; - } - - const packages = packagesInput - .split(',') - .map((p: string) => p.trim()) - .filter((p: string) => p.length > 0); - - try { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: l10n.t('Updating packages for "{0}"...', config.name), - cancellable: false - }, - async () => { - await this.environmentManager.updateEnvironment(environmentId, { packages }); - logger.info(`Updated packages for environment ${environmentId}`); - } - ); - - void window.showInformationMessage(l10n.t('Packages updated for "{0}"', config.name)); - this.analytics.trackEvent({ - eventName: 'update_environment', - properties: { field: 'packages', packageCount: packages.length } - }); - } catch (error) { - logger.error('Failed to update packages', error); - void window.showErrorMessage(l10n.t('Failed to update packages. See output for details.')); - } - } - - public dispose(): void { - this.disposables.forEach((d) => d?.dispose()); - } -} diff --git a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts b/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts deleted file mode 100644 index 9d59edc279..0000000000 --- a/src/kernels/deepnote/environments/deepnoteEnvironmentsView.unit.test.ts +++ /dev/null @@ -1,1269 +0,0 @@ -import { assert } from 'chai'; -import * as sinon from 'sinon'; -import { anything, capture, instance, mock, when, verify, deepEqual, resetCalls } from 'ts-mockito'; -import { CancellationToken, Disposable, NotebookDocument, ProgressOptions, Uri } from 'vscode'; -import { DeepnoteEnvironmentsView } from './deepnoteEnvironmentsView.node'; -import { - IDeepnoteEnvironmentManager, - IDeepnoteKernelAutoSelector, - IDeepnoteNotebookEnvironmentMapper, - IDeepnoteServerStarter -} from '../types'; -import { IPythonApiProvider } from '../../../platform/api/types'; -import { ITelemetryService } from '../../../platform/analytics/types'; -import { IDisposableRegistry, IOutputChannel } from '../../../platform/common/types'; -import { IKernelProvider } from '../../../kernels/types'; -import { DeepnoteEnvironment } from './deepnoteEnvironment'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; -import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; -import { DeepnoteEnvironmentTreeDataProvider } from './deepnoteEnvironmentTreeDataProvider.node'; -import { crateMockedPythonApi, whenKnownEnvironments } from '../../helpers.unit.test'; -import type { PythonExtension } from '@vscode/python-extension'; -import { createDeepnoteServerConfigHandle } from '../../../platform/deepnote/deepnoteServerUtils.node'; - -suite('DeepnoteEnvironmentsView', () => { - let view: DeepnoteEnvironmentsView; - let mockConfigManager: IDeepnoteEnvironmentManager; - let mockTreeDataProvider: DeepnoteEnvironmentTreeDataProvider; - let mockPythonApiProvider: IPythonApiProvider; - let mockDisposableRegistry: IDisposableRegistry; - let mockKernelAutoSelector: IDeepnoteKernelAutoSelector; - let mockNotebookEnvironmentMapper: IDeepnoteNotebookEnvironmentMapper; - let mockKernelProvider: IKernelProvider; - let mockOutputChannel: IOutputChannel; - let mockTelemetryService: ITelemetryService; - let mockServerStarter: IDeepnoteServerStarter; - let disposables: Disposable[] = []; - let pythonEnvironments: PythonExtension['environments']; - - setup(() => { - resetVSCodeMocks(); - disposables.push(new Disposable(() => resetVSCodeMocks())); - - // Initialize Python API for helper functions - pythonEnvironments = crateMockedPythonApi(disposables).environments; - - mockConfigManager = mock(); - mockTreeDataProvider = mock(); - mockPythonApiProvider = mock(); - mockDisposableRegistry = mock(); - mockKernelAutoSelector = mock(); - mockNotebookEnvironmentMapper = mock(); - mockKernelProvider = mock(); - mockOutputChannel = mock(); - mockTelemetryService = mock(); - mockServerStarter = mock(); - - // stopServer is a safe no-op when a notebook has no running server - when(mockServerStarter.stopServer(anything(), anything())).thenResolve(); - - // Mock onDidChangeEnvironments to return a disposable event - when(mockConfigManager.onDidChangeEnvironments).thenReturn((_listener: () => void) => { - return { - dispose: () => { - /* noop */ - } - }; - }); - - view = new DeepnoteEnvironmentsView( - instance(mockConfigManager), - instance(mockTreeDataProvider), - instance(mockPythonApiProvider), - instance(mockDisposableRegistry), - instance(mockKernelAutoSelector), - instance(mockNotebookEnvironmentMapper), - instance(mockKernelProvider), - instance(mockOutputChannel), - instance(mockServerStarter), - instance(mockTelemetryService) - ); - }); - - teardown(() => { - if (view) { - view.dispose(); - } - disposables.forEach((d) => d.dispose()); - disposables = []; - }); - - suite('constructor', () => { - test('should create tree view', () => { - // View should be created without errors - assert.ok(view); - }); - - test('should register with disposable registry', () => { - verify(mockDisposableRegistry.push(anything())).atLeast(1); - }); - }); - - suite('dispose', () => { - test('should dispose all resources', () => { - view.dispose(); - // Should not throw - }); - - test('should dispose tree view', () => { - view.dispose(); - // Tree view should be disposed - // In a real test, we would verify the tree view's dispose was called - }); - }); - - suite('editEnvironmentName', () => { - const testEnvironmentId = 'test-env-id'; - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3'), - version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } - } as PythonEnvironment; - - const testEnvironment: DeepnoteEnvironment = { - id: testEnvironmentId, - name: 'Original Name', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const testEnvironmentExternal: DeepnoteEnvironment = { - id: testEnvironmentId, - name: 'Original Name', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/external/venv'), - managedVenv: false, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - setup(() => { - // Reset mocks between tests - resetCalls(mockConfigManager); - resetCalls(mockedVSCodeNamespaces.window); - }); - - test('should return early if environment not found', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(undefined); - - await view.editEnvironmentName(testEnvironmentId); - - // Should not call showInputBox or updateEnvironment - verify(mockedVSCodeNamespaces.window.showInputBox(anything())).never(); - verify(mockConfigManager.updateEnvironment(anything(), anything())).never(); - }); - - test('should return early if user cancels input', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(undefined)); - - await view.editEnvironmentName(testEnvironmentId); - - verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); - verify(mockConfigManager.updateEnvironment(anything(), anything())).never(); - }); - - test('should return early if user provides same name', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('Original Name')); - - await view.editEnvironmentName(testEnvironmentId); - - verify(mockedVSCodeNamespaces.window.showInputBox(anything())).once(); - verify(mockConfigManager.updateEnvironment(anything(), anything())).never(); - }); - - test('should validate that name cannot be empty', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - - // Capture the validator function - let validatorFn: ((value: string) => string | undefined) | undefined; - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options) => { - validatorFn = options.validateInput; - return Promise.resolve(undefined); - }); - - await view.editEnvironmentName(testEnvironmentId); - - assert.ok(validatorFn, 'Validator function should be provided'); - assert.strictEqual(validatorFn!(''), 'Name cannot be empty'); - assert.strictEqual(validatorFn!(' '), 'Name cannot be empty'); - assert.strictEqual(validatorFn!('Valid Name'), undefined); - }); - - test('should successfully rename environment with trimmed name', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(' New Name ')); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenResolve(); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(); - - await view.editEnvironmentName(testEnvironmentId); - - verify(mockConfigManager.updateEnvironment(testEnvironmentId, deepEqual({ name: 'New Name' }))).once(); - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - - test('should show error message if update fails', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('New Name')); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenReject(new Error('Update failed')); - when(mockedVSCodeNamespaces.window.showErrorMessage(anything())).thenResolve(); - - await view.editEnvironmentName(testEnvironmentId); - - verify(mockConfigManager.updateEnvironment(anything(), anything())).once(); - verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).once(); - }); - - test('should call updateEnvironment with correct parameters', async () => { - const newName = 'Updated Environment Name'; - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve(newName)); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenResolve(); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(); - - await view.editEnvironmentName(testEnvironmentId); - - verify(mockConfigManager.updateEnvironment(testEnvironmentId, deepEqual({ name: newName }))).once(); - }); - - test('should preserve existing environment configuration except name', async () => { - const envWithPackages: DeepnoteEnvironment = { - ...testEnvironment, - packages: ['numpy', 'pandas'], - description: 'Test description' - }; - - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(envWithPackages); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn(Promise.resolve('New Name')); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenResolve(); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(); - - await view.editEnvironmentName(testEnvironmentId); - - // Should only update the name, not other properties - verify(mockConfigManager.updateEnvironment(testEnvironmentId, deepEqual({ name: 'New Name' }))).once(); - }); - - test('should show input box with current name as default value', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - - let capturedOptions: any; - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall((options) => { - capturedOptions = options; - return Promise.resolve(undefined); - }); - - await view.editEnvironmentName(testEnvironmentId); - - assert.ok(capturedOptions, 'Options should be provided'); - assert.strictEqual(capturedOptions.value, 'Original Name'); - }); - - test('should successfully rename external environment (managedVenv: false)', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironmentExternal); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn( - Promise.resolve('New External Name') - ); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenResolve(); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(); - - await view.editEnvironmentName(testEnvironmentId); - - verify( - mockConfigManager.updateEnvironment(testEnvironmentId, deepEqual({ name: 'New External Name' })) - ).once(); - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - }); - - suite('createEnvironmentCommand', () => { - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3.11'), - version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } - } as PythonEnvironment; - - const createdEnvironment: DeepnoteEnvironment = { - id: 'new-env-id', - name: 'My Data Science Environment', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/new/venv'), - managedVenv: true, - packages: ['pandas', 'numpy', 'matplotlib'], - description: 'Environment for data science work', - createdAt: new Date(), - lastUsedAt: new Date() - }; - - setup(() => { - resetCalls(mockConfigManager); - resetCalls(mockPythonApiProvider); - resetCalls(mockedVSCodeNamespaces.window); - }); - - test('should successfully create environment with all inputs', async () => { - // Set up Python environments for helper functions to use - const mockResolvedEnvironment = { - id: testInterpreter.id, - path: testInterpreter.uri.fsPath, - version: { - major: 3, - minor: 11, - micro: 0 - }, - environment: { - name: 'test-env', - folderUri: testInterpreter.uri - }, - tools: [], - executable: { - uri: testInterpreter.uri - } - }; - - // Configure the Python API that was initialized in setup() - whenKnownEnvironments(pythonEnvironments).thenReturn([mockResolvedEnvironment]); - - // Mock the Python API provider to return the same environments - const mockPythonApi = { - environments: { - known: [mockResolvedEnvironment] - } - }; - when(mockPythonApiProvider.getNewApi()).thenResolve(mockPythonApi as any); - - // Mock interpreter selection - return the first item - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: any[]) => { - return Promise.resolve(items[0]); - }); - - // Mock input boxes for name, packages, and description - let inputBoxCallCount = 0; - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenCall(() => { - inputBoxCallCount++; - if (inputBoxCallCount === 1) { - // First call: environment name - return Promise.resolve('My Data Science Environment'); - } else if (inputBoxCallCount === 2) { - // Second call: packages - return Promise.resolve('pandas, numpy, matplotlib'); - } else { - // Third call: description - return Promise.resolve('Environment for data science work'); - } - }); - - // Mock list environments to return empty (no duplicates) - when(mockConfigManager.listEnvironments()).thenReturn([]); - - // Mock window.withProgress to execute the callback - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - const mockProgress = { - report: (_value: { message?: string; increment?: number }) => { - // Mock progress reporting - } - }; - const mockToken = { - isCancellationRequested: false, - onCancellationRequested: (_listener: any) => { - return { - dispose: () => { - // Mock disposable - } - }; - } - }; - return callback(mockProgress, mockToken); - } - ); - - // Mock environment creation - when(mockConfigManager.createEnvironment(anything(), anything())).thenResolve(createdEnvironment); - - // Mock success message - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.createEnvironmentCommand(); - - // Verify API calls - verify(mockPythonApiProvider.getNewApi()).once(); - verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).once(); - verify(mockedVSCodeNamespaces.window.showInputBox(anything())).times(3); - verify(mockConfigManager.listEnvironments()).once(); - verify(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).once(); - - // Verify createEnvironment was called with correct options - verify(mockConfigManager.createEnvironment(anything(), anything())).once(); - const [capturedOptions, capturedToken] = capture(mockConfigManager.createEnvironment).last(); - assert.strictEqual(capturedOptions.name, 'My Data Science Environment'); - assert.deepStrictEqual(capturedOptions.packages, ['pandas', 'numpy', 'matplotlib']); - assert.strictEqual(capturedOptions.description, 'Environment for data science work'); - // Don't assert on pythonInterpreter.id as the helper functions transform it - assert.ok(capturedOptions.pythonInterpreter, 'Python interpreter should be provided'); - assert.ok(capturedOptions.pythonInterpreter.uri, 'Python interpreter uri should be present'); - assert.ok(capturedOptions.pythonInterpreter.id, 'Python interpreter id should be present'); - assert.ok(capturedToken, 'Cancellation token should be provided'); - - // Verify success message was shown - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - }); - - suite('deleteEnvironmentCommand', () => { - const testEnvironmentId = 'test-env-id-to-delete'; - const testExternalEnvironmentId = 'test-external-env-id-to-delete'; - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3.11'), - version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } - } as PythonEnvironment; - - const testEnvironment: DeepnoteEnvironment = { - id: testEnvironmentId, - name: 'Environment to Delete', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const testExternalEnvironment: DeepnoteEnvironment = { - id: testExternalEnvironmentId, - name: 'External Environment to Delete', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/external/venv'), - managedVenv: false, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - setup(() => { - resetCalls(mockConfigManager); - resetCalls(mockNotebookEnvironmentMapper); - resetCalls(mockedVSCodeNamespaces.window); - }); - - test('should successfully delete environment with notebooks using it', async () => { - // Mock environment exists - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - - // Mock user confirmation - user clicks "Delete" button - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - - // Mock notebooks using this environment - const notebook1Uri = Uri.file('/workspace/notebook1.deepnote'); - const notebook2Uri = Uri.file('/workspace/notebook2.deepnote'); - when(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(testEnvironmentId)).thenReturn([ - notebook1Uri, - notebook2Uri - ]); - - // Mock removing environment mappings - when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenResolve(); - - // Mock window.withProgress to execute the callback - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - const mockProgress = { - report: (_value: { message?: string; increment?: number }) => { - // Mock progress reporting - } - }; - const mockToken: CancellationToken = { - isCancellationRequested: false, - onCancellationRequested: (_listener: any) => { - return { - dispose: () => { - // Mock disposable - } - }; - } - }; - return callback(mockProgress, mockToken); - } - ); - - // Mock environment deletion - when(mockConfigManager.deleteEnvironment(testEnvironmentId, anything())).thenResolve(); - - // Mock success message - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.deleteEnvironmentCommand(testEnvironmentId); - - // Verify API calls - verify(mockConfigManager.getEnvironment(testEnvironmentId)).once(); - verify(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).once(); - verify(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(testEnvironmentId)).once(); - - // Verify environment mappings were removed for both notebooks - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(notebook1Uri)).once(); - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(notebook2Uri)).once(); - - // Verify environment deletion - verify(mockConfigManager.deleteEnvironment(testEnvironmentId, anything())).once(); - - // Verify success message was shown - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - - test('should dispose kernels from open notebooks using the deleted environment', async () => { - // Mock environment exists - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - - // Mock user confirmation - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - - // Mock notebooks using this environment - when(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(testEnvironmentId)).thenReturn([]); - when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenResolve(); - - // Mock open notebooks with kernels - const openNotebook1 = { - uri: Uri.file('/workspace/open-notebook1.deepnote'), - notebookType: 'deepnote', - isClosed: false - } as any; - - const openNotebook2 = { - uri: Uri.file('/workspace/open-notebook2.deepnote'), - notebookType: 'jupyter-notebook', - isClosed: false - } as any; - - const openNotebook3 = { - uri: Uri.file('/workspace/open-notebook3.deepnote'), - notebookType: 'deepnote', - isClosed: false - } as any; - - // Mock workspace.notebookDocuments - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([ - openNotebook1, - openNotebook2, - openNotebook3 - ]); - - // Mock kernels - const mockKernel1 = { - kernelConnectionMetadata: { - kind: 'startUsingDeepnoteKernel', - serverProviderHandle: { - handle: createDeepnoteServerConfigHandle(testEnvironmentId, openNotebook1.uri) - } - }, - dispose: sinon.stub().resolves() - }; - - const mockKernel3 = { - kernelConnectionMetadata: { - kind: 'startUsingDeepnoteKernel', - serverProviderHandle: { - handle: createDeepnoteServerConfigHandle('different-env-id', openNotebook3.uri) - } - }, - dispose: sinon.stub().resolves() - }; - - // Mock kernelProvider.get() - when(mockKernelProvider.get(openNotebook1)).thenReturn(mockKernel1 as any); - when(mockKernelProvider.get(openNotebook2)).thenReturn(undefined); // No kernel for jupyter notebook - when(mockKernelProvider.get(openNotebook3)).thenReturn(mockKernel3 as any); - - // Mock window.withProgress - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - const mockProgress = { - report: () => { - // Mock progress reporting - } - }; - const mockToken: CancellationToken = { - isCancellationRequested: false, - onCancellationRequested: () => ({ - dispose: () => { - // Mock disposable - } - }) - }; - return callback(mockProgress, mockToken); - } - ); - - // Mock environment deletion - when(mockConfigManager.deleteEnvironment(testEnvironmentId, anything())).thenResolve(); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.deleteEnvironmentCommand(testEnvironmentId); - - // Verify that only kernel1 (using the deleted environment) was disposed - assert.strictEqual(mockKernel1.dispose.callCount, 1, 'Kernel using deleted environment should be disposed'); - assert.strictEqual( - mockKernel3.dispose.callCount, - 0, - 'Kernel using different environment should not be disposed' - ); - }); - - test('should successfully delete external environment (managedVenv: false) with same side effects', async () => { - // Mock environment exists - external environment - when(mockConfigManager.getEnvironment(testExternalEnvironmentId)).thenReturn(testExternalEnvironment); - - // Mock user confirmation - user clicks "Delete" button - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - - // Mock notebooks using this environment - const notebook1Uri = Uri.file('/workspace/notebook1.deepnote'); - const notebook2Uri = Uri.file('/workspace/notebook2.deepnote'); - when(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(testExternalEnvironmentId)).thenReturn([ - notebook1Uri, - notebook2Uri - ]); - - // Mock removing environment mappings - when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenResolve(); - - // Mock open notebooks - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); - - // Mock window.withProgress to execute the callback - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - const mockProgress = { - report: (_value: { message?: string; increment?: number }) => { - // Mock progress reporting - } - }; - const mockToken: CancellationToken = { - isCancellationRequested: false, - onCancellationRequested: (_listener: any) => { - return { - dispose: () => { - // Mock disposable - } - }; - } - }; - return callback(mockProgress, mockToken); - } - ); - - // Mock environment deletion - the manager handles managedVenv check internally - when(mockConfigManager.deleteEnvironment(testExternalEnvironmentId, anything())).thenResolve(); - - // Mock success message - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.deleteEnvironmentCommand(testExternalEnvironmentId); - - // Verify API calls - same as for managed venv - verify(mockConfigManager.getEnvironment(testExternalEnvironmentId)).once(); - verify(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).once(); - verify(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(testExternalEnvironmentId)).once(); - - // Verify environment mappings were removed for both notebooks - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(notebook1Uri)).once(); - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(notebook2Uri)).once(); - - // Verify environment deletion - the manager is responsible for checking managedVenv - verify(mockConfigManager.deleteEnvironment(testExternalEnvironmentId, anything())).once(); - - // Verify success message was shown - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - - test('should dispose kernels from open notebooks using deleted external environment (managedVenv: false)', async () => { - // Mock environment exists - external environment - when(mockConfigManager.getEnvironment(testExternalEnvironmentId)).thenReturn(testExternalEnvironment); - - // Mock user confirmation - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - - // Mock notebooks using this environment - when(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(testExternalEnvironmentId)).thenReturn([]); - when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenResolve(); - - // Mock open notebooks with kernels - const openNotebook1 = { - uri: Uri.file('/workspace/open-notebook1.deepnote'), - notebookType: 'deepnote', - isClosed: false - } as any; - - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([openNotebook1]); - - // Mock kernel using the external environment - const mockKernel1 = { - kernelConnectionMetadata: { - kind: 'startUsingDeepnoteKernel', - serverProviderHandle: { - handle: createDeepnoteServerConfigHandle(testExternalEnvironmentId, openNotebook1.uri) - } - }, - dispose: sinon.stub().resolves() - }; - - when(mockKernelProvider.get(openNotebook1)).thenReturn(mockKernel1 as any); - - // Mock window.withProgress - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - const mockProgress = { - report: () => { - // Mock progress reporting - } - }; - const mockToken: CancellationToken = { - isCancellationRequested: false, - onCancellationRequested: () => ({ - dispose: () => { - // Mock disposable - } - }) - }; - return callback(mockProgress, mockToken); - } - ); - - // Mock environment deletion - when(mockConfigManager.deleteEnvironment(testExternalEnvironmentId, anything())).thenResolve(); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.deleteEnvironmentCommand(testExternalEnvironmentId); - - // Verify that kernel was disposed even for external environment - assert.strictEqual( - mockKernel1.dispose.callCount, - 1, - 'Kernel using deleted external environment should be disposed' - ); - }); - }); - - suite('deleteEnvironmentCommand - stop servers before removing mappings (the load-bearing fix)', () => { - const envId = 'env-to-delete'; - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3.11') - }; - - const environment: DeepnoteEnvironment = { - id: envId, - name: 'Environment to Delete', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - // One notebook is OPEN; the other is CLOSED but its server is still running. - const openNotebookUri = Uri.file('/workspace/open.deepnote'); - const closedNotebookUri = Uri.file('/workspace/closed-but-running.deepnote'); - - setup(() => { - resetCalls(mockConfigManager); - resetCalls(mockNotebookEnvironmentMapper); - resetCalls(mockServerStarter); - resetCalls(mockedVSCodeNamespaces.window); - resetCalls(mockedVSCodeNamespaces.workspace); - }); - - // Wire a shared, ordered call log so we can assert that every stopServer happens BEFORE - // any removeEnvironmentForNotebook, and both happen before deleteEnvironment. - const wireOrderedDeletion = (callLog: string[], options?: { stopRejectsFor?: Uri }): void => { - when(mockConfigManager.getEnvironment(envId)).thenReturn(environment); - when(mockedVSCodeNamespaces.window.showWarningMessage(anything(), anything(), anything())).thenReturn( - Promise.resolve('Delete') - ); - when(mockNotebookEnvironmentMapper.getNotebooksUsingEnvironment(envId)).thenReturn([ - openNotebookUri, - closedNotebookUri - ]); - - // Only the OPEN notebook is present in workspace.notebookDocuments — the other is closed. - const mockOpenNotebookDoc = mock(); - when(mockOpenNotebookDoc.uri).thenReturn(openNotebookUri); - when(mockOpenNotebookDoc.notebookType).thenReturn('deepnote'); - when(mockOpenNotebookDoc.isClosed).thenReturn(false); - const openNotebookDoc = instance(mockOpenNotebookDoc); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([openNotebookDoc]); - when(mockKernelProvider.get(openNotebookDoc)).thenReturn(undefined); - - when(mockServerStarter.stopServer(anything(), anything())).thenCall((uri: Uri) => { - callLog.push(`stop:${uri.toString()}`); - if (options?.stopRejectsFor && uri.toString() === options.stopRejectsFor.toString()) { - return Promise.reject(new Error('stop failed')); - } - return Promise.resolve(); - }); - when(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(anything())).thenCall((uri: Uri) => { - callLog.push(`remove:${uri.toString()}`); - return Promise.resolve(); - }); - when(mockConfigManager.deleteEnvironment(envId, anything())).thenCall(() => { - callLog.push('deleteEnvironment'); - return Promise.resolve(); - }); - - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - const mockProgress = { report: () => undefined }; - const mockToken: CancellationToken = { - isCancellationRequested: false, - onCancellationRequested: () => ({ dispose: () => undefined }) - }; - return callback(mockProgress, mockToken); - } - ); - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - }; - - test('every stopServer precedes every removeEnvironmentForNotebook, and both precede deleteEnvironment (catches inverted order)', async () => { - const callLog: string[] = []; - wireOrderedDeletion(callLog); - - await view.deleteEnvironmentCommand(envId); - - const stopIndexes = callLog.map((entry, i) => (entry.startsWith('stop:') ? i : -1)).filter((i) => i >= 0); - const removeIndexes = callLog - .map((entry, i) => (entry.startsWith('remove:') ? i : -1)) - .filter((i) => i >= 0); - const deleteIndex = callLog.indexOf('deleteEnvironment'); - - assert.strictEqual(stopIndexes.length, 2, 'both servers stopped'); - assert.strictEqual(removeIndexes.length, 2, 'both mappings removed'); - assert.isAtLeast(deleteIndex, 0, 'environment deleted'); - - const lastStop = Math.max(...stopIndexes); - const firstRemove = Math.min(...removeIndexes); - const lastRemove = Math.max(...removeIndexes); - - assert.isBelow( - lastStop, - firstRemove, - 'all stopServer calls must come BEFORE any removeEnvironmentForNotebook (else the mapper list would already be empty when stopping)' - ); - assert.isBelow(lastRemove, deleteIndex, 'mappings must be removed before the environment is deleted'); - }); - - test('a stopServer rejection for one URI does NOT abort the deletion — the other still stops and deletion proceeds (per-iteration try/catch)', async () => { - const callLog: string[] = []; - wireOrderedDeletion(callLog, { stopRejectsFor: openNotebookUri }); - - await view.deleteEnvironmentCommand(envId); - - // The failing stop was attempted, the other still ran, and deletion completed. - verify(mockServerStarter.stopServer(openNotebookUri, anything())).once(); - verify(mockServerStarter.stopServer(closedNotebookUri, anything())).once(); - assert.include( - callLog, - `stop:${closedNotebookUri.toString()}`, - 'second server still stopped after first threw' - ); - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(openNotebookUri)).once(); - verify(mockNotebookEnvironmentMapper.removeEnvironmentForNotebook(closedNotebookUri)).once(); - verify(mockConfigManager.deleteEnvironment(envId, anything())).once(); - assert.include(callLog, 'deleteEnvironment', 'environment deletion still proceeds after a stop failure'); - }); - }); - - suite('selectEnvironmentForNotebook', () => { - const testInterpreter1: PythonEnvironment = { - id: 'python-1', - uri: Uri.file('/usr/bin/python3.11'), - version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } - } as PythonEnvironment; - - const testInterpreter2: PythonEnvironment = { - id: 'python-2', - uri: Uri.file('/usr/bin/python3.12'), - version: { major: 3, minor: 12, patch: 0, raw: '3.12.0' } - } as PythonEnvironment; - - const currentEnvironment: DeepnoteEnvironment = { - id: 'current-env-id', - name: 'Current Environment', - pythonInterpreter: testInterpreter1, - venvPath: Uri.file('/path/to/current/venv'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const currentExternalEnvironment: DeepnoteEnvironment = { - id: 'current-external-env-id', - name: 'Current External Environment', - pythonInterpreter: testInterpreter1, - venvPath: Uri.file('/path/to/external/current/venv'), - managedVenv: false, - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const newEnvironment: DeepnoteEnvironment = { - id: 'new-env-id', - name: 'New Environment', - pythonInterpreter: testInterpreter2, - venvPath: Uri.file('/path/to/new/venv'), - managedVenv: true, - packages: ['pandas', 'numpy'], - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const newExternalEnvironment: DeepnoteEnvironment = { - id: 'new-external-env-id', - name: 'New External Environment', - pythonInterpreter: testInterpreter2, - venvPath: Uri.file('/path/to/external/new/venv'), - managedVenv: false, - packages: ['requests'], - createdAt: new Date(), - lastUsedAt: new Date() - }; - - setup(() => { - resetCalls(mockConfigManager); - resetCalls(mockNotebookEnvironmentMapper); - resetCalls(mockKernelAutoSelector); - resetCalls(mockKernelProvider); - resetCalls(mockedVSCodeNamespaces.window); - }); - - test('should successfully switch to a different environment', async () => { - // Mock active notebook - const notebookUri = Uri.file('/workspace/notebook.deepnote'); - const mockNotebook = { - uri: notebookUri, - notebookType: 'deepnote', - cellCount: 5 - }; - const mockNotebookEditor = { - notebook: mockNotebook - }; - - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(mockNotebookEditor as any); - - // Mock current environment mapping - const baseFileUri = notebookUri.with({ query: '', fragment: '' }); - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri)).thenReturn( - currentEnvironment.id - ); - when(mockConfigManager.getEnvironment(currentEnvironment.id)).thenReturn(currentEnvironment); - - // Mock available environments - when(mockConfigManager.listEnvironments()).thenReturn([currentEnvironment, newEnvironment]); - - // Mock environment status - when(mockConfigManager.getEnvironment(currentEnvironment.id)).thenReturn(currentEnvironment); - when(mockConfigManager.getEnvironment(newEnvironment.id)).thenReturn(newEnvironment); - - // Mock user selecting the new environment - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: any[]) => { - // Find the item for the new environment - const selectedItem = items.find((item) => item.environmentId === newEnvironment.id); - return Promise.resolve(selectedItem); - }); - - // Mock no executing cells - const mockKernel = { id: 'test-kernel' }; - const mockKernelExecution = { - pendingCells: [] - }; - when(mockKernelProvider.get(mockNotebook as any)).thenReturn(mockKernel as any); - when(mockKernelProvider.getKernelExecution(mockKernel as any)).thenReturn(mockKernelExecution as any); - - // Mock window.withProgress to execute the callback - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - return callback(); - } - ); - - // Mock environment mapping update - when(mockNotebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, newEnvironment.id)).thenResolve(); - - // Mock controller rebuild - when(mockKernelAutoSelector.rebuildController(mockNotebook as any, anything(), anything())).thenResolve(); - - // Mock success message - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.selectEnvironmentForNotebook({ notebook: mockNotebook as NotebookDocument }); - - // Verify API calls - verify(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri)).once(); - verify(mockConfigManager.getEnvironment(currentEnvironment.id)).once(); - verify(mockConfigManager.listEnvironments()).once(); - verify(mockConfigManager.getEnvironment(currentEnvironment.id)).once(); - verify(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).once(); - verify(mockKernelProvider.get(mockNotebook as any)).once(); - verify(mockKernelProvider.getKernelExecution(mockKernel as any)).once(); - - // Verify environment switch - verify(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).once(); - verify(mockNotebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, newEnvironment.id)).once(); - verify(mockKernelAutoSelector.rebuildController(mockNotebook as any, anything(), anything())).once(); - - // Verify success message was shown - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - - test('should successfully switch from managed to external environment (managedVenv: false)', async () => { - // Mock active notebook - const notebookUri = Uri.file('/workspace/notebook.deepnote'); - const mockNotebook = { - uri: notebookUri, - notebookType: 'deepnote', - cellCount: 5 - }; - const mockNotebookEditor = { - notebook: mockNotebook - }; - - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(mockNotebookEditor as any); - - // Mock current environment mapping (managed) - const baseFileUri = notebookUri.with({ query: '', fragment: '' }); - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri)).thenReturn( - currentEnvironment.id - ); - when(mockConfigManager.getEnvironment(currentEnvironment.id)).thenReturn(currentEnvironment); - - // Mock available environments (mix of managed and external) - when(mockConfigManager.listEnvironments()).thenReturn([ - currentEnvironment, - newEnvironment, - newExternalEnvironment - ]); - - // Mock environment status - when(mockConfigManager.getEnvironment(newExternalEnvironment.id)).thenReturn(newExternalEnvironment); - - // Mock user selecting the new external environment - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: any[]) => { - // Find the item for the new external environment - const selectedItem = items.find((item) => item.environmentId === newExternalEnvironment.id); - return Promise.resolve(selectedItem); - }); - - // Mock no executing cells - const mockKernel = { id: 'test-kernel' }; - const mockKernelExecution = { - pendingCells: [] - }; - when(mockKernelProvider.get(mockNotebook as any)).thenReturn(mockKernel as any); - when(mockKernelProvider.getKernelExecution(mockKernel as any)).thenReturn(mockKernelExecution as any); - - // Mock window.withProgress to execute the callback - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - return callback(); - } - ); - - // Mock environment mapping update - when( - mockNotebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, newExternalEnvironment.id) - ).thenResolve(); - - // Mock controller rebuild - when(mockKernelAutoSelector.rebuildController(mockNotebook as any, anything(), anything())).thenResolve(); - - // Mock success message - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.selectEnvironmentForNotebook({ notebook: mockNotebook as NotebookDocument }); - - // Verify environment switch to external environment - verify(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).once(); - verify( - mockNotebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, newExternalEnvironment.id) - ).once(); - verify(mockKernelAutoSelector.rebuildController(mockNotebook as any, anything(), anything())).once(); - - // Verify success message was shown - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - - test('should successfully switch from external to managed environment', async () => { - // Mock active notebook - const notebookUri = Uri.file('/workspace/notebook.deepnote'); - const mockNotebook = { - uri: notebookUri, - notebookType: 'deepnote', - cellCount: 5 - }; - const mockNotebookEditor = { - notebook: mockNotebook - }; - - when(mockedVSCodeNamespaces.window.activeNotebookEditor).thenReturn(mockNotebookEditor as any); - - // Mock current environment mapping (external) - const baseFileUri = notebookUri.with({ query: '', fragment: '' }); - when(mockNotebookEnvironmentMapper.getEnvironmentForNotebook(baseFileUri)).thenReturn( - currentExternalEnvironment.id - ); - when(mockConfigManager.getEnvironment(currentExternalEnvironment.id)).thenReturn( - currentExternalEnvironment - ); - - // Mock available environments - when(mockConfigManager.listEnvironments()).thenReturn([currentExternalEnvironment, newEnvironment]); - - // Mock environment status - when(mockConfigManager.getEnvironment(newEnvironment.id)).thenReturn(newEnvironment); - - // Mock user selecting the new managed environment - when(mockedVSCodeNamespaces.window.showQuickPick(anything(), anything())).thenCall((items: any[]) => { - const selectedItem = items.find((item) => item.environmentId === newEnvironment.id); - return Promise.resolve(selectedItem); - }); - - // Mock no executing cells - const mockKernel = { id: 'test-kernel' }; - const mockKernelExecution = { - pendingCells: [] - }; - when(mockKernelProvider.get(mockNotebook as any)).thenReturn(mockKernel as any); - when(mockKernelProvider.getKernelExecution(mockKernel as any)).thenReturn(mockKernelExecution as any); - - // Mock window.withProgress to execute the callback - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - return callback(); - } - ); - - // Mock environment mapping update - when(mockNotebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, newEnvironment.id)).thenResolve(); - - // Mock controller rebuild - when(mockKernelAutoSelector.rebuildController(mockNotebook as any, anything(), anything())).thenResolve(); - - // Mock success message - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - // Execute the command - await view.selectEnvironmentForNotebook({ notebook: mockNotebook as NotebookDocument }); - - // Verify environment switch from external to managed - verify(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).once(); - verify(mockNotebookEnvironmentMapper.setEnvironmentForNotebook(baseFileUri, newEnvironment.id)).once(); - verify(mockKernelAutoSelector.rebuildController(mockNotebook as any, anything(), anything())).once(); - - // Verify success message was shown - verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); - }); - }); - - suite('managePackages', () => { - const testEnvironmentId = 'test-env-id'; - const testExternalEnvironmentId = 'test-external-env-id'; - const testInterpreter: PythonEnvironment = { - id: 'test-python-id', - uri: Uri.file('/usr/bin/python3'), - version: { major: 3, minor: 11, patch: 0, raw: '3.11.0' } - } as PythonEnvironment; - - const testEnvironment: DeepnoteEnvironment = { - id: testEnvironmentId, - name: 'Test Environment', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/venv'), - managedVenv: true, - packages: ['numpy', 'pandas'], - createdAt: new Date(), - lastUsedAt: new Date() - }; - - const testExternalEnvironment: DeepnoteEnvironment = { - id: testExternalEnvironmentId, - name: 'Test External Environment', - pythonInterpreter: testInterpreter, - venvPath: Uri.file('/path/to/external/venv'), - managedVenv: false, - packages: ['requests'], - createdAt: new Date(), - lastUsedAt: new Date() - }; - - setup(() => { - resetCalls(mockConfigManager); - resetCalls(mockedVSCodeNamespaces.window); - }); - - test('should call environmentManager.updateEnvironment with parsed packages', async () => { - when(mockConfigManager.getEnvironment(testEnvironmentId)).thenReturn(testEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn( - Promise.resolve('matplotlib, scipy, sklearn') - ); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenResolve(); - - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - return callback(); - } - ); - - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - await (view as any).managePackages(testEnvironmentId); - - verify( - mockConfigManager.updateEnvironment( - testEnvironmentId, - deepEqual({ packages: ['matplotlib', 'scipy', 'sklearn'] }) - ) - ).once(); - }); - - test('should update packages for external environment (managedVenv: false)', async () => { - when(mockConfigManager.getEnvironment(testExternalEnvironmentId)).thenReturn(testExternalEnvironment); - when(mockedVSCodeNamespaces.window.showInputBox(anything())).thenReturn( - Promise.resolve('flask, sqlalchemy') - ); - when(mockConfigManager.updateEnvironment(anything(), anything())).thenResolve(); - - when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall( - (_options: ProgressOptions, callback: Function) => { - return callback(); - } - ); - - when(mockedVSCodeNamespaces.window.showInformationMessage(anything())).thenResolve(undefined); - - await (view as any).managePackages(testExternalEnvironmentId); - - verify( - mockConfigManager.updateEnvironment( - testExternalEnvironmentId, - deepEqual({ packages: ['flask', 'sqlalchemy'] }) - ) - ).once(); - }); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts deleted file mode 100644 index 4f2038d8e1..0000000000 --- a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { inject, injectable } from 'inversify'; -import * as YAML from 'yaml'; -import { env, NotebookDocument, Uri, workspace } from 'vscode'; - -import { IExtensionSyncActivationService } from '../../../platform/activation/types'; -import { IDisposableRegistry } from '../../../platform/common/types'; -import { logger } from '../../../platform/logging'; -import { IDeepnoteEnvironmentManager, IDeepnoteNotebookEnvironmentMapper } from '../types'; - -const SIDECAR_FILENAME = 'deepnote.json'; - -/** - * Returns the editor-specific settings folder name based on the current app. - * - VS Code → `.vscode` - * - Cursor → `.cursor` - * - Antigravity → `.antigravity` - * - Unknown → `.vscode` (safe default) - */ -function getEditorSettingsFolder(): string { - const appName = env.appName.toLowerCase(); - if (appName.includes('cursor')) { - return '.cursor'; - } - if (appName.includes('antigravity')) { - return '.antigravity'; - } - return '.vscode'; -} - -interface SidecarEntry { - environmentId: string; - venvPath: string; - pythonInterpreter: string; -} - -interface SidecarFile { - mappings: Record; -} - -/** - * Writes a `deepnote.json` sidecar file in the editor settings - * folder (e.g. `.vscode/`, `.cursor/`, `.antigravity/`) so that external - * tools (e.g. the Deepnote CLI) can discover the selected venv path for - * each project without reading VS Code workspace state. - */ -@injectable() -export class DeepnoteExtensionSidecarWriter implements IExtensionSyncActivationService { - /** Reverse map: notebookUri.fsPath → projectId (populated from sidecar + set calls). */ - private readonly fsPathToProjectId = new Map(); - /** Serializes sidecar writes to avoid read-modify-write races. */ - private writeQueue: Promise = Promise.resolve(); - - constructor( - @inject(IDeepnoteNotebookEnvironmentMapper) private readonly mapper: IDeepnoteNotebookEnvironmentMapper, - @inject(IDeepnoteEnvironmentManager) private readonly environmentManager: IDeepnoteEnvironmentManager, - @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry - ) {} - - public activate(): void { - this.disposables.push( - this.mapper.onDidSetEnvironment((e) => this.handleSetEnvironment(e)), - this.mapper.onDidRemoveEnvironment((e) => this.handleRemoveEnvironment(e)), - this.environmentManager.onDidChangeEnvironments(() => this.handleEnvironmentsChanged()), - workspace.onDidOpenNotebookDocument((doc) => this.handleNotebookOpened(doc)) - ); - - // Sync existing mappings so the sidecar is up-to-date for existing users - // who already have workspace-state mappings but no sidecar file yet. - void this.syncExistingMappings(); - } - - private async handleEnvironmentsChanged(): Promise { - try { - await this.enqueueWrite(async (sidecar) => { - if (Object.keys(sidecar.mappings).length === 0) { - return false; - } - - let changed = false; - for (const [projectId, entry] of Object.entries(sidecar.mappings)) { - try { - const environment = this.environmentManager.getEnvironment(entry.environmentId); - if (!environment) { - delete sidecar.mappings[projectId]; - changed = true; - } else if ( - environment.venvPath.fsPath !== entry.venvPath || - environment.pythonInterpreter.uri.fsPath !== entry.pythonInterpreter - ) { - sidecar.mappings[projectId] = { - environmentId: entry.environmentId, - venvPath: environment.venvPath.fsPath, - pythonInterpreter: environment.pythonInterpreter.uri.fsPath - }; - changed = true; - } - } catch (entryError) { - logger.warn(`[SidecarWriter] Failed to process mapping for project ${projectId}`, entryError); - } - } - - return changed; - }); - } catch (error) { - logger.warn('[SidecarWriter] Failed to handle environments changed', error); - } - } - - /** - * When a notebook is opened after activation, check if it already has - * a mapping and add it to the sidecar. - */ - private async handleNotebookOpened(doc: NotebookDocument): Promise { - if (doc.notebookType !== 'deepnote') { - return; - } - - try { - const notebookUri = doc.uri; - const projectId = doc.metadata?.deepnoteProjectId as string | undefined; - if (!projectId) { - return; - } - - const environmentId = this.mapper.getEnvironmentForNotebook(notebookUri); - if (!environmentId) { - return; - } - - const environment = this.environmentManager.getEnvironment(environmentId); - if (!environment) { - return; - } - - this.fsPathToProjectId.set(notebookUri.fsPath, projectId); - - await this.enqueueWrite(async (sidecar) => { - const existing = sidecar.mappings[projectId]; - if ( - existing?.environmentId === environmentId && - existing?.venvPath === environment.venvPath.fsPath && - existing?.pythonInterpreter === environment.pythonInterpreter.uri.fsPath - ) { - return false; - } - sidecar.mappings[projectId] = { - environmentId, - venvPath: environment.venvPath.fsPath, - pythonInterpreter: environment.pythonInterpreter.uri.fsPath - }; - return true; - }); - } catch (error) { - logger.warn('[SidecarWriter] Failed to handle notebook opened', error); - } - } - - private async handleRemoveEnvironment({ notebookUri }: { notebookUri: Uri }): Promise { - try { - const projectId = this.fsPathToProjectId.get(notebookUri.fsPath) ?? this.resolveProjectId(notebookUri); - if (!projectId) { - return; - } - - this.fsPathToProjectId.delete(notebookUri.fsPath); - - await this.enqueueWrite(async (sidecar) => { - if (!(projectId in sidecar.mappings)) { - return false; - } - delete sidecar.mappings[projectId]; - return true; - }); - } catch (error) { - logger.warn('[SidecarWriter] Failed to handle remove environment', error); - } - } - - private async handleSetEnvironment({ - notebookUri, - environmentId - }: { - notebookUri: Uri; - environmentId: string; - }): Promise { - try { - const projectId = this.resolveProjectId(notebookUri); - if (!projectId) { - return; - } - - const environment = this.environmentManager.getEnvironment(environmentId); - if (!environment) { - return; - } - - this.fsPathToProjectId.set(notebookUri.fsPath, projectId); - - await this.enqueueWrite(async (sidecar) => { - sidecar.mappings[projectId] = { - environmentId, - venvPath: environment.venvPath.fsPath, - pythonInterpreter: environment.pythonInterpreter.uri.fsPath - }; - return true; - }); - } catch (error) { - logger.warn('[SidecarWriter] Failed to handle set environment', error); - } - } - - /** - * On activation, iterate all persisted mapper entries (not just open - * notebooks) and write their mappings to the sidecar. For each entry - * we read the `.deepnote` file to extract the project ID. - */ - private async syncExistingMappings(): Promise { - try { - await this.environmentManager.waitForInitialization(); - - const allMappings = this.mapper.getAllMappings(); - if (allMappings.size === 0) { - return; - } - - // Collect all project IDs outside the write queue to avoid holding the lock during I/O. - const entries: Array<{ - fsPath: string; - projectId: string; - environmentId: string; - venvPath: string; - pythonInterpreter: string; - }> = []; - for (const [fsPath, environmentId] of allMappings) { - try { - const environment = this.environmentManager.getEnvironment(environmentId); - if (!environment) { - continue; - } - - const projectId = await this.readProjectIdFromFile(Uri.file(fsPath)); - if (!projectId) { - continue; - } - - this.fsPathToProjectId.set(fsPath, projectId); - entries.push({ - fsPath, - projectId, - environmentId, - venvPath: environment.venvPath.fsPath, - pythonInterpreter: environment.pythonInterpreter.uri.fsPath - }); - } catch (entryError) { - logger.warn(`[SidecarWriter] Failed to process mapping for ${fsPath}`, entryError); - } - } - - if (entries.length === 0) { - return; - } - - await this.enqueueWrite(async (sidecar) => { - for (const entry of entries) { - sidecar.mappings[entry.projectId] = { - environmentId: entry.environmentId, - venvPath: entry.venvPath, - pythonInterpreter: entry.pythonInterpreter - }; - } - return true; - }); - } catch (error) { - logger.warn('[SidecarWriter] Failed to sync existing mappings', error); - } - } - - // ── Helpers ────────────────────────────────────────────────────────── - - /** - * Serializes all sidecar mutations through a queue so that concurrent - * read-modify-write cycles don't clobber each other. The `mutate` callback - * receives the current sidecar contents and returns `true` if it modified - * the object (i.e. should be written back). - */ - private enqueueWrite(mutate: (sidecar: SidecarFile) => Promise): Promise { - const op = this.writeQueue.then(async () => { - const sidecar = await this.readSidecar(); - const changed = await mutate(sidecar); - if (changed) { - await this.writeSidecar(sidecar); - } - }); - // eslint-disable-next-line @typescript-eslint/no-empty-function -- keep queue chain alive after rejection - this.writeQueue = op.catch(() => {}); - return op; - } - - private getSidecarUri(): Uri | undefined { - const folder = workspace.workspaceFolders?.[0]; - if (!folder) { - return undefined; - } - return Uri.joinPath(folder.uri, getEditorSettingsFolder(), SIDECAR_FILENAME); - } - - private async readProjectIdFromFile(fileUri: Uri): Promise { - try { - const raw = await workspace.fs.readFile(fileUri); - const parsed = YAML.parse(Buffer.from(raw).toString('utf-8')) as { project?: { id?: string } } | undefined; - return parsed?.project?.id; - } catch { - return undefined; - } - } - - private async readSidecar(): Promise { - const uri = this.getSidecarUri(); - if (!uri) { - return { mappings: {} }; - } - - try { - const raw = await workspace.fs.readFile(uri); - const parsed = JSON.parse(Buffer.from(raw).toString('utf-8')) as SidecarFile; - if (parsed?.mappings && typeof parsed.mappings === 'object') { - return parsed; - } - } catch { - // File doesn't exist or is invalid — start fresh. - } - - return { mappings: {} }; - } - - private resolveProjectId(notebookUri: Uri): string | undefined { - const doc = workspace.notebookDocuments.find( - (d) => d.notebookType === 'deepnote' && d.uri.fsPath === notebookUri.fsPath - ); - return doc?.metadata?.deepnoteProjectId as string | undefined; - } - - private async writeSidecar(sidecar: SidecarFile): Promise { - const uri = this.getSidecarUri(); - if (!uri) { - return; - } - - // Ensure the editor settings folder exists (e.g. .vscode/). - const folderUri = Uri.joinPath(uri, '..'); - await workspace.fs.createDirectory(folderUri); - - const content = JSON.stringify(sidecar, undefined, 2) + '\n'; - await workspace.fs.writeFile(uri, Buffer.from(content, 'utf-8')); - } -} diff --git a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts b/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts deleted file mode 100644 index 5db130e25d..0000000000 --- a/src/kernels/deepnote/environments/deepnoteExtensionSidecarWriter.unit.test.ts +++ /dev/null @@ -1,519 +0,0 @@ -import { assert } from 'chai'; -import * as sinon from 'sinon'; -import { anything, instance, mock, when } from 'ts-mockito'; -import { EventEmitter, NotebookDocument, Uri, WorkspaceFolder } from 'vscode'; - -import type { IDisposableRegistry } from '../../../platform/common/types'; -import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; -import { IDeepnoteEnvironmentManager, IDeepnoteNotebookEnvironmentMapper } from '../types'; -import { DeepnoteEnvironment } from './deepnoteEnvironment'; -import { DeepnoteExtensionSidecarWriter } from './deepnoteExtensionSidecarWriter.node'; - -const waitForTimeoutMs = 5000; -const waitForIntervalMs = 50; - -async function waitFor( - condition: () => boolean, - timeoutMs = waitForTimeoutMs, - intervalMs = waitForIntervalMs -): Promise { - const start = Date.now(); - while (!condition()) { - if (Date.now() - start > timeoutMs) { - throw new Error(`waitFor timed out after ${timeoutMs}ms`); - } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } -} - -function makeEnvironment(overrides: Partial & { id: string }): DeepnoteEnvironment { - return { - name: 'Test Env', - pythonInterpreter: { id: 'python3', uri: Uri.file('/usr/bin/python3') } as any, - venvPath: Uri.file('/home/user/.venvs/test'), - managedVenv: true, - createdAt: new Date(), - lastUsedAt: new Date(), - ...overrides - }; -} - -function makeDeepnoteYaml(projectId: string): string { - return `version: '1.0'\nproject:\n id: ${projectId}\n name: Test\n notebooks: []\n`; -} - -function createMockNotebook(opts: { uri: Uri; projectId?: string; notebookType?: string }): NotebookDocument { - return { - uri: opts.uri, - notebookType: opts.notebookType ?? 'deepnote', - metadata: { deepnoteProjectId: opts.projectId ?? 'project-1' }, - isDirty: false, - isUntitled: false, - isClosed: false, - version: 1, - cellCount: 0, - cellAt: () => { - throw new Error('Not implemented'); - }, - getCells: () => [], - save: async () => true - } as unknown as NotebookDocument; -} - -suite('DeepnoteExtensionSidecarWriter', () => { - let writer: DeepnoteExtensionSidecarWriter; - let disposables: IDisposableRegistry; - let mockMapper: IDeepnoteNotebookEnvironmentMapper; - let mockEnvironmentManager: IDeepnoteEnvironmentManager; - - let onDidSetEnvironment: EventEmitter<{ notebookUri: Uri; environmentId: string }>; - let onDidRemoveEnvironment: EventEmitter<{ notebookUri: Uri }>; - let onDidChangeEnvironments: EventEmitter; - let onDidOpenNotebookDocument: EventEmitter; - - let writtenContent: string | undefined; - let writeFileCallCount: number; - let createDirectoryUris: Uri[]; - let readFileContent: string; - /** Per-file read responses keyed by fsPath — used for .deepnote YAML files. */ - let fileContents: Map; - - const workspaceUri = Uri.file('/workspace'); - - setup(() => { - resetVSCodeMocks(); - writtenContent = undefined; - writeFileCallCount = 0; - createDirectoryUris = []; - readFileContent = ''; - fileContents = new Map(); - - disposables = []; - - // Set up event emitters - onDidSetEnvironment = new EventEmitter<{ notebookUri: Uri; environmentId: string }>(); - onDidRemoveEnvironment = new EventEmitter<{ notebookUri: Uri }>(); - onDidChangeEnvironments = new EventEmitter(); - onDidOpenNotebookDocument = new EventEmitter(); - disposables.push( - onDidSetEnvironment, - onDidRemoveEnvironment, - onDidChangeEnvironments, - onDidOpenNotebookDocument - ); - - // Set up mapper mock - mockMapper = mock(); - when(mockMapper.onDidSetEnvironment).thenReturn(onDidSetEnvironment.event); - when(mockMapper.onDidRemoveEnvironment).thenReturn(onDidRemoveEnvironment.event); - when(mockMapper.getAllMappings()).thenReturn(new Map()); - - // Set up environment manager mock - mockEnvironmentManager = mock(); - when(mockEnvironmentManager.onDidChangeEnvironments).thenReturn(onDidChangeEnvironments.event); - when(mockEnvironmentManager.waitForInitialization()).thenResolve(); - - // Set up workspace folder and onDidOpenNotebookDocument - const workspaceFolder = { uri: workspaceUri, name: 'workspace', index: 0 } as WorkspaceFolder; - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn([workspaceFolder]); - when(mockedVSCodeNamespaces.workspace.onDidOpenNotebookDocument).thenReturn(onDidOpenNotebookDocument.event); - - // Set up workspace.fs mock - setupMockFs(); - - writer = new DeepnoteExtensionSidecarWriter( - instance(mockMapper), - instance(mockEnvironmentManager), - disposables - ); - }); - - teardown(() => { - sinon.restore(); - for (const d of disposables) { - d.dispose(); - } - }); - - function setupMockFs() { - const mockFs = mock(); - when(mockFs.readFile(anything())).thenCall((uri: Uri) => { - // Check per-file map first (for .deepnote YAML files) - const perFile = fileContents.get(uri.fsPath); - if (perFile !== undefined) { - return Promise.resolve(Buffer.from(perFile, 'utf-8')); - } - // Fall back to global sidecar content - if (!readFileContent) { - return Promise.reject(new Error('File not found')); - } - return Promise.resolve(Buffer.from(readFileContent, 'utf-8')); - }); - when(mockFs.createDirectory(anything())).thenCall((uri: Uri) => { - createDirectoryUris.push(uri); - return Promise.resolve(); - }); - when(mockFs.writeFile(anything(), anything())).thenCall((_uri: Uri, content: Uint8Array) => { - writtenContent = Buffer.from(content).toString('utf-8'); - writeFileCallCount++; - return Promise.resolve(); - }); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - } - - function parseSidecar(): { - mappings: Record; - } { - assert.isDefined(writtenContent, 'Expected sidecar to be written'); - return JSON.parse(writtenContent!); - } - - test('set mapping writes sidecar with correct projectId, environmentId, and venvPath', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ - id: 'env-1', - venvPath: Uri.file('/home/user/.venvs/my-env') - }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - - await waitFor(() => writeFileCallCount > 0); - - const sidecar = parseSidecar(); - assert.deepStrictEqual(sidecar.mappings['proj-abc'], { - environmentId: 'env-1', - venvPath: '/home/user/.venvs/my-env', - pythonInterpreter: '/usr/bin/python3' - }); - }); - - test('remove mapping removes entry from sidecar', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - // First set, then remove - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - await waitFor(() => writeFileCallCount >= 1); - - // Set the sidecar content so the next read picks it up - readFileContent = writtenContent!; - - onDidRemoveEnvironment.fire({ notebookUri }); - await waitFor(() => writeFileCallCount >= 2); - - const sidecar = parseSidecar(); - assert.isUndefined(sidecar.mappings['proj-abc']); - assert.deepStrictEqual(sidecar.mappings, {}); - }); - - test('multiple projects accumulate entries in a single sidecar', async () => { - const uri1 = Uri.file('/workspace/project1.deepnote'); - const uri2 = Uri.file('/workspace/project2.deepnote'); - const nb1 = createMockNotebook({ uri: uri1, projectId: 'proj-1' }); - const nb2 = createMockNotebook({ uri: uri2, projectId: 'proj-2' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([nb1, nb2]); - - const env1 = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/env1') }); - const env2 = makeEnvironment({ id: 'env-2', venvPath: Uri.file('/venvs/env2') }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env1); - when(mockEnvironmentManager.getEnvironment('env-2')).thenReturn(env2); - - writer.activate(); - - onDidSetEnvironment.fire({ notebookUri: uri1, environmentId: 'env-1' }); - await waitFor(() => writeFileCallCount >= 1); - readFileContent = writtenContent!; - - onDidSetEnvironment.fire({ notebookUri: uri2, environmentId: 'env-2' }); - await waitFor(() => writeFileCallCount >= 2); - - const sidecar = parseSidecar(); - assert.deepStrictEqual(sidecar.mappings, { - 'proj-1': { environmentId: 'env-1', venvPath: '/venvs/env1', pythonInterpreter: '/usr/bin/python3' }, - 'proj-2': { environmentId: 'env-2', venvPath: '/venvs/env2', pythonInterpreter: '/usr/bin/python3' } - }); - }); - - test('error reading sidecar does not throw', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - // readFile will reject (default behavior when readFileContent is empty) - writer.activate(); - - // Should not throw even though readFile fails - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - await waitFor(() => writeFileCallCount >= 1); - - // Still writes successfully with a fresh sidecar - const sidecar = parseSidecar(); - assert.isDefined(sidecar.mappings['proj-abc']); - }); - - test('error writing sidecar does not throw', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - // Make writeFile throw - const mockFs = mock(); - when(mockFs.readFile(anything())).thenReject(new Error('File not found')); - when(mockFs.writeFile(anything(), anything())).thenReject(new Error('Permission denied')); - when(mockedVSCodeNamespaces.workspace.fs).thenReturn(instance(mockFs)); - - writer.activate(); - - // Should not throw - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - - // Give time for the async operation to complete - await new Promise((resolve) => setTimeout(resolve, 200)); - - // No crash — test passes if we get here - }); - - test('environment changed refreshes sidecar with updated venvPath', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/old-path') }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - // Set initial mapping - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - await waitFor(() => writeFileCallCount >= 1); - readFileContent = writtenContent!; - - // Now change the env venvPath - const updatedEnv = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/new-path') }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(updatedEnv); - - onDidChangeEnvironments.fire(); - await waitFor(() => writeFileCallCount >= 2); - - const sidecar = parseSidecar(); - assert.strictEqual(sidecar.mappings['proj-abc'].venvPath, '/venvs/new-path'); - }); - - test('environment deleted removes entry on environments changed', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - await waitFor(() => writeFileCallCount >= 1); - readFileContent = writtenContent!; - - // Now the environment no longer exists - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(undefined); - - onDidChangeEnvironments.fire(); - await waitFor(() => writeFileCallCount >= 2); - - const sidecar = parseSidecar(); - assert.isUndefined(sidecar.mappings['proj-abc']); - }); - - test('no-op when no workspace folder is open', async () => { - when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn(undefined); - - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - assert.strictEqual(writeFileCallCount, 0, 'Should not write when no workspace folder'); - }); - - test('activation syncs existing mappings to sidecar by reading .deepnote files', async () => { - const fsPath = '/workspace/project.deepnote'; - - // Mapper has a persisted entry — notebook is NOT open - when(mockMapper.getAllMappings()).thenReturn(new Map([[fsPath, 'env-existing']])); - fileContents.set(fsPath, makeDeepnoteYaml('proj-existing')); - - const env = makeEnvironment({ id: 'env-existing', venvPath: Uri.file('/venvs/existing') }); - when(mockEnvironmentManager.getEnvironment('env-existing')).thenReturn(env); - - writer.activate(); - - await waitFor(() => writeFileCallCount >= 1); - - const sidecar = parseSidecar(); - assert.deepStrictEqual(sidecar.mappings['proj-existing'], { - environmentId: 'env-existing', - venvPath: '/venvs/existing', - pythonInterpreter: '/usr/bin/python3' - }); - }); - - test('activation syncs multiple projects including closed notebooks', async () => { - const path1 = '/workspace/proj1.deepnote'; - const path2 = '/workspace/proj2.deepnote'; - - when(mockMapper.getAllMappings()).thenReturn( - new Map([ - [path1, 'env-1'], - [path2, 'env-2'] - ]) - ); - fileContents.set(path1, makeDeepnoteYaml('proj-1')); - fileContents.set(path2, makeDeepnoteYaml('proj-2')); - - const env1 = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/env1') }); - const env2 = makeEnvironment({ id: 'env-2', venvPath: Uri.file('/venvs/env2') }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env1); - when(mockEnvironmentManager.getEnvironment('env-2')).thenReturn(env2); - - writer.activate(); - - await waitFor(() => writeFileCallCount >= 1); - - const sidecar = parseSidecar(); - assert.deepStrictEqual(sidecar.mappings, { - 'proj-1': { environmentId: 'env-1', venvPath: '/venvs/env1', pythonInterpreter: '/usr/bin/python3' }, - 'proj-2': { environmentId: 'env-2', venvPath: '/venvs/env2', pythonInterpreter: '/usr/bin/python3' } - }); - }); - - test('activation skips entries whose .deepnote file cannot be read', async () => { - const goodPath = '/workspace/good.deepnote'; - const badPath = '/workspace/missing.deepnote'; - - when(mockMapper.getAllMappings()).thenReturn( - new Map([ - [goodPath, 'env-1'], - [badPath, 'env-2'] - ]) - ); - fileContents.set(goodPath, makeDeepnoteYaml('proj-good')); - // badPath not in fileContents → readFile will reject - - const env1 = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/env1') }); - const env2 = makeEnvironment({ id: 'env-2', venvPath: Uri.file('/venvs/env2') }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env1); - when(mockEnvironmentManager.getEnvironment('env-2')).thenReturn(env2); - - writer.activate(); - - await waitFor(() => writeFileCallCount >= 1); - - const sidecar = parseSidecar(); - assert.strictEqual(Object.keys(sidecar.mappings).length, 1); - assert.isDefined(sidecar.mappings['proj-good']); - assert.isUndefined(sidecar.mappings['proj-missing']); - }); - - test('opening a notebook with existing mapping writes to sidecar', async () => { - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([]); - - writer.activate(); - - // Wait for initial sync (no-op since no notebooks open) - await new Promise((resolve) => setTimeout(resolve, 100)); - - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-opened' }); - - const env = makeEnvironment({ id: 'env-1', venvPath: Uri.file('/venvs/env1') }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - when(mockMapper.getEnvironmentForNotebook(anything())).thenReturn('env-1'); - - onDidOpenNotebookDocument.fire(notebook); - - await waitFor(() => writeFileCallCount >= 1); - - const sidecar = parseSidecar(); - assert.deepStrictEqual(sidecar.mappings['proj-opened'], { - environmentId: 'env-1', - venvPath: '/venvs/env1', - pythonInterpreter: '/usr/bin/python3' - }); - }); - - test('no-op when notebook has no projectId in metadata', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - // Notebook without projectId - const notebook = { - uri: notebookUri, - notebookType: 'deepnote', - metadata: {}, - isDirty: false, - isUntitled: false, - isClosed: false, - version: 1, - cellCount: 0, - cellAt: () => { - throw new Error('Not implemented'); - }, - getCells: () => [], - save: async () => true - } as unknown as NotebookDocument; - - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - assert.strictEqual(writeFileCallCount, 0, 'Should not write when no projectId'); - }); - - test('creates the editor settings folder before writing', async () => { - const notebookUri = Uri.file('/workspace/project.deepnote'); - const notebook = createMockNotebook({ uri: notebookUri, projectId: 'proj-abc' }); - when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); - - const env = makeEnvironment({ id: 'env-1' }); - when(mockEnvironmentManager.getEnvironment('env-1')).thenReturn(env); - - writer.activate(); - - onDidSetEnvironment.fire({ notebookUri, environmentId: 'env-1' }); - - await waitFor(() => writeFileCallCount > 0); - - assert.strictEqual(createDirectoryUris.length, 1); - assert.strictEqual(createDirectoryUris[0].fsPath, Uri.file('/workspace/.vscode').fsPath); - }); -}); diff --git a/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts b/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts deleted file mode 100644 index 61070f8217..0000000000 --- a/src/kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node.ts +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { injectable, inject } from 'inversify'; -import { EventEmitter, Uri, Memento } from 'vscode'; - -import { IDisposableRegistry, IExtensionContext } from '../../../platform/common/types'; -import { logger } from '../../../platform/logging'; - -/** - * Manages the mapping between notebooks and their selected environments - * Stores selections in workspace state for persistence across sessions - */ -@injectable() -export class DeepnoteNotebookEnvironmentMapper { - private static readonly STORAGE_KEY = 'deepnote.notebookEnvironmentMappings'; - private readonly workspaceState: Memento; - private mappings: Map; // notebookUri.fsPath -> environmentId - - private readonly _onDidRemoveEnvironment = new EventEmitter<{ notebookUri: Uri }>(); - private readonly _onDidSetEnvironment = new EventEmitter<{ notebookUri: Uri; environmentId: string }>(); - public readonly onDidRemoveEnvironment = this._onDidRemoveEnvironment.event; - public readonly onDidSetEnvironment = this._onDidSetEnvironment.event; - - constructor( - @inject(IExtensionContext) context: IExtensionContext, - @inject(IDisposableRegistry) disposables: IDisposableRegistry - ) { - this.workspaceState = context.workspaceState; - this.mappings = new Map(); - this.loadMappings(); - disposables.push(this._onDidSetEnvironment, this._onDidRemoveEnvironment); - } - - /** - * Get the environment ID selected for a notebook - * @param notebookUri The notebook URI (without query/fragment) - * @returns Environment ID, or undefined if not set - */ - public getEnvironmentForNotebook(notebookUri: Uri): string | undefined { - const key = notebookUri.fsPath; - return this.mappings.get(key); - } - - /** - * Set the environment for a notebook - * @param notebookUri The notebook URI (without query/fragment) - * @param environmentId The environment ID - */ - public async setEnvironmentForNotebook(notebookUri: Uri, environmentId: string): Promise { - const key = notebookUri.fsPath; - this.mappings.set(key, environmentId); - await this.saveMappings(); - logger.info(`Mapped notebook ${notebookUri.fsPath} to environment ${environmentId}`); - this._onDidSetEnvironment.fire({ notebookUri, environmentId }); - } - - /** - * Remove the environment mapping for a notebook - * @param notebookUri The notebook URI (without query/fragment) - */ - public async removeEnvironmentForNotebook(notebookUri: Uri): Promise { - const key = notebookUri.fsPath; - this.mappings.delete(key); - await this.saveMappings(); - logger.info(`Removed environment mapping for notebook ${notebookUri.fsPath}`); - this._onDidRemoveEnvironment.fire({ notebookUri }); - } - - /** - * Get all notebooks using a specific environment - * @param environmentId The environment ID - * @returns Array of notebook URIs - */ - public getNotebooksUsingEnvironment(environmentId: string): Uri[] { - const notebooks: Uri[] = []; - for (const [notebookPath, configId] of this.mappings.entries()) { - if (configId === environmentId) { - notebooks.push(Uri.file(notebookPath)); - } - } - return notebooks; - } - - /** - * Get all notebook-to-environment mappings - */ - public getAllMappings(): ReadonlyMap { - return new Map(this.mappings); - } - - /** - * Load mappings from workspace state - */ - private loadMappings(): void { - const stored = this.workspaceState.get>(DeepnoteNotebookEnvironmentMapper.STORAGE_KEY); - if (stored) { - this.mappings = new Map(Object.entries(stored)); - logger.info(`Loaded ${this.mappings.size} notebook-environment mappings`); - } - } - - /** - * Save mappings to workspace state - */ - private async saveMappings(): Promise { - const obj = Object.fromEntries(this.mappings.entries()); - await this.workspaceState.update(DeepnoteNotebookEnvironmentMapper.STORAGE_KEY, obj); - } -} diff --git a/src/kernels/deepnote/types.ts b/src/kernels/deepnote/types.ts index d605de52ae..dcf1e65bc4 100644 --- a/src/kernels/deepnote/types.ts +++ b/src/kernels/deepnote/types.ts @@ -9,7 +9,6 @@ import { PythonEnvironment } from '../../platform/pythonEnvironments/info'; import { getTelemetrySafeHashedString } from '../../platform/telemetry/helpers'; import { JupyterServerProviderHandle } from '../jupyter/types'; import { IJupyterKernelSpec } from '../types'; -import { CreateDeepnoteEnvironmentOptions, DeepnoteEnvironment } from './environments/deepnoteEnvironment'; export interface VenvAndToolkitInstallation { pythonInterpreter: PythonEnvironment; @@ -239,14 +238,6 @@ export interface IServerHandleRegistry { export const IDeepnoteKernelAutoSelector = Symbol('IDeepnoteKernelAutoSelector'); export interface IDeepnoteKernelAutoSelector { - /** - * Clear the controller selection for a notebook using a specific environment. - * This is used when deleting an environment to unselect its controller from any open notebooks. - * @param notebook The notebook document - * @param environmentId The environment ID - */ - clearControllerForEnvironment(notebook: vscode.NotebookDocument, environmentId: string): void; - /** * Ensure an environment is configured for the notebook before execution. * If not configured, shows picker and sets up the kernel. @@ -288,115 +279,6 @@ export interface IDeepnoteKernelAutoSelector { ): Promise; } -export const IDeepnoteEnvironmentManager = Symbol('IDeepnoteEnvironmentManager'); -export interface IDeepnoteEnvironmentManager { - /** - * Initialize the manager by loading environments from storage - */ - initialize(): Promise; - - /** - * Wait for initialization to complete - */ - waitForInitialization(): Promise; - - /** - * Create a new kernel environment - * @param options Environment creation options - * @param token Cancellation token to cancel the operation - */ - createEnvironment( - options: CreateDeepnoteEnvironmentOptions, - token?: vscode.CancellationToken - ): Promise; - - /** - * Get all environments - */ - listEnvironments(): DeepnoteEnvironment[]; - - /** - * Get a specific environment by ID - */ - getEnvironment(id: string): DeepnoteEnvironment | undefined; - - /** - * Update an environment's metadata - */ - updateEnvironment( - id: string, - updates: Partial> - ): Promise; - - /** - * Delete an environment - * @param id The environment ID - * @param token Cancellation token to cancel the operation - */ - deleteEnvironment(id: string, token?: vscode.CancellationToken): Promise; - - /** - * Update the last used timestamp for an environment - */ - updateLastUsed(id: string): Promise; - - /** - * Event fired when environments change - */ - onDidChangeEnvironments: vscode.Event; - - /** - * Dispose of all resources - */ - dispose(): void; -} - -export const IDeepnoteNotebookEnvironmentMapper = Symbol('IDeepnoteNotebookEnvironmentMapper'); -export interface IDeepnoteNotebookEnvironmentMapper { - /** - * Get the environment ID selected for a notebook - * @param notebookUri The notebook URI (without query/fragment) - * @returns Environment ID, or undefined if not set - */ - getEnvironmentForNotebook(notebookUri: vscode.Uri): string | undefined; - - /** - * Set the environment for a notebook - * @param notebookUri The notebook URI (without query/fragment) - * @param environmentId The environment ID - */ - setEnvironmentForNotebook(notebookUri: vscode.Uri, environmentId: string): Promise; - - /** - * Remove the environment mapping for a notebook - * @param notebookUri The notebook URI (without query/fragment) - */ - removeEnvironmentForNotebook(notebookUri: vscode.Uri): Promise; - - /** - * Get all notebooks using a specific environment - * @param environmentId The environment ID - * @returns Array of notebook URIs - */ - getNotebooksUsingEnvironment(environmentId: string): vscode.Uri[]; - - /** - * Get all notebook-to-environment mappings - * @returns Map of notebookUri.fsPath → environmentId - */ - getAllMappings(): ReadonlyMap; - - /** - * Event fired when an environment mapping is removed for a notebook - */ - onDidRemoveEnvironment: vscode.Event<{ notebookUri: vscode.Uri }>; - - /** - * Event fired when an environment is set for a notebook - */ - onDidSetEnvironment: vscode.Event<{ notebookUri: vscode.Uri; environmentId: string }>; -} - export const IDeepnoteLspClientManager = Symbol('IDeepnoteLspClientManager'); export interface IDeepnoteLspClientManager { /** diff --git a/src/notebooks/deepnote/deepnoteActivationService.ts b/src/notebooks/deepnote/deepnoteActivationService.ts index cb2144b9fd..3ad5534d9b 100644 --- a/src/notebooks/deepnote/deepnoteActivationService.ts +++ b/src/notebooks/deepnote/deepnoteActivationService.ts @@ -5,7 +5,6 @@ import { IExtensionSyncActivationService } from '../../platform/activation/types import { ITelemetryService } from '../../platform/analytics/types'; import { IExtensionContext } from '../../platform/common/types'; import { ILogger } from '../../platform/logging/types'; -import { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; import { IDeepnoteNotebookManager } from '../types'; import { DeepnoteNotebookSerializer } from './deepnoteSerializer'; import { DeepnoteExplorerView } from './deepnoteExplorerView'; @@ -42,10 +41,7 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic @inject(IIntegrationManager) integrationManager: IIntegrationManager, @inject(ILogger) private readonly logger: ILogger, @inject(ITelemetryService) private readonly analytics: ITelemetryService, - @inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService, - @inject(IDeepnoteNotebookEnvironmentMapper) - @optional() - private readonly environmentMapper?: IDeepnoteNotebookEnvironmentMapper + @inject(SnapshotService) @optional() private readonly snapshotService?: SnapshotService ) { this.integrationManager = integrationManager; } @@ -86,7 +82,6 @@ export class DeepnoteActivationService implements IExtensionSyncActivationServic this.integrationManager.activate(); this.multiNotebookSplitter = new DeepnoteMultiNotebookSplitter( - this.environmentMapper, () => this.explorerView.refresh(), this.logger, deepnoteFileExists, diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts index b5b89361f9..41414d882a 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.ts @@ -593,44 +593,6 @@ export class DeepnoteKernelAutoSelector implements IDeepnoteKernelAutoSelector, return !!this.notebookControllers.get(notebookKey); } - /** - * Clear the controller selection for a notebook if it was set up by this selector - * for the given environment. - * - * The caller passes an `environmentId` (UUID), but the auto-selector now tracks - * notebooks by interpreter.id. We match by comparing the notebook's tracked - * controller instance against the currently selected controller, so we only - * clear controllers we own — never an unrelated Deepnote kernel. - */ - public clearControllerForEnvironment(notebook: NotebookDocument, environmentId: string): void { - const notebookKey = getNotebookKey(notebook.uri); - const trackedController = this.notebookControllers.get(notebookKey); - - if (!trackedController) { - return; // We didn't set up a controller for this notebook - } - - const selectedController = this.controllerRegistration.getSelected(notebook); - if (!selectedController || selectedController.id !== trackedController.id) { - return; // Selected controller isn't the one we own - } - - if (selectedController.connection.kind !== 'startUsingDeepnoteKernel') { - return; - } - - selectedController.controller.updateNotebookAffinity(notebook, NotebookControllerAffinity.Default); - - // Clean up our tracking state for this notebook - this.notebookControllers.delete(notebookKey); - this.notebookConnectionMetadata.delete(notebookKey); - this.notebookInterpreterIds.delete(notebookKey); - - logger.info( - `Cleared Deepnote controller for notebook ${getDisplayPath(notebook.uri)} (environment ${environmentId})` - ); - } - /** * True when the notebook's controller is bound to a running server for this interpreter. A * controller registered at open time carries no baseUrl and is deliberately not "ready". diff --git a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts index 953eb575b6..b06b9681b9 100644 --- a/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteKernelAutoSelector.node.unit.test.ts @@ -1095,118 +1095,6 @@ suite('DeepnoteKernelAutoSelector - rebuildController', () => { }); }); - suite('clearControllerForEnvironment', () => { - test('should unselect and clean up when tracked controller matches selected controller', () => { - const notebookKey = mockNotebook.uri.toString(); - - // Set up a tracked controller in the internal map - const trackedController = mock(); - when(trackedController.id).thenReturn('deepnote-notebook-123'); - when(trackedController.connection).thenReturn({ - kind: 'startUsingDeepnoteKernel' - } as any); - const mockNativeController = { - updateNotebookAffinity: sandbox.stub() - } as unknown as NotebookController; - when(trackedController.controller).thenReturn(mockNativeController); - - const selectorAny = selector as any; - selectorAny.notebookControllers.set(notebookKey, instance(trackedController)); - selectorAny.notebookInterpreterIds.set(notebookKey, '/usr/bin/python3'); - selectorAny.notebookConnectionMetadata.set(notebookKey, {} as any); - - // Selected controller is the same one we tracked - when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(instance(trackedController)); - - selector.clearControllerForEnvironment(mockNotebook, 'env-uuid-123'); - - assert.isTrue( - (mockNativeController.updateNotebookAffinity as sinon.SinonStub).calledOnce, - 'Should have called updateNotebookAffinity' - ); - // Verify tracking state is cleaned up - assert.isFalse(selectorAny.notebookControllers.has(notebookKey), 'Should remove from notebookControllers'); - assert.isFalse( - selectorAny.notebookInterpreterIds.has(notebookKey), - 'Should remove from notebookInterpreterIds' - ); - assert.isFalse( - selectorAny.notebookConnectionMetadata.has(notebookKey), - 'Should remove from notebookConnectionMetadata' - ); - }); - - test('should NOT unselect when notebook has no tracked controller', () => { - // notebookControllers map is empty — we didn't set up this notebook - const trackedController = mock(); - const mockNativeController = { - updateNotebookAffinity: sandbox.stub() - } as unknown as NotebookController; - when(trackedController.controller).thenReturn(mockNativeController); - when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(instance(trackedController)); - - selector.clearControllerForEnvironment(mockNotebook, 'env-uuid-123'); - - assert.isFalse( - (mockNativeController.updateNotebookAffinity as sinon.SinonStub).called, - 'Should NOT have called updateNotebookAffinity when we have no tracked controller' - ); - }); - - test('should NOT unselect when selected controller differs from tracked controller', () => { - const notebookKey = mockNotebook.uri.toString(); - - // Track controller A - const controllerA = mock(); - when(controllerA.id).thenReturn('deepnote-notebook-A'); - const selectorAny = selector as any; - selectorAny.notebookControllers.set(notebookKey, instance(controllerA)); - - // But VS Code has controller B selected (different id) - const controllerB = mock(); - when(controllerB.id).thenReturn('deepnote-notebook-B'); - const mockNativeController = { - updateNotebookAffinity: sandbox.stub() - } as unknown as NotebookController; - when(controllerB.controller).thenReturn(mockNativeController); - when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(instance(controllerB)); - - selector.clearControllerForEnvironment(mockNotebook, 'env-uuid-123'); - - assert.isFalse( - (mockNativeController.updateNotebookAffinity as sinon.SinonStub).called, - 'Should NOT unselect a controller we do not own' - ); - }); - - test('should NOT unselect when selected controller is not a Deepnote kernel', () => { - const notebookKey = mockNotebook.uri.toString(); - - // Track a controller - const trackedController = mock(); - when(trackedController.id).thenReturn('deepnote-notebook-123'); - when(trackedController.connection).thenReturn({ - kind: 'startUsingLocalKernelSpec' - } as any); - const mockNativeController = { - updateNotebookAffinity: sandbox.stub() - } as unknown as NotebookController; - when(trackedController.controller).thenReturn(mockNativeController); - - const selectorAny = selector as any; - selectorAny.notebookControllers.set(notebookKey, instance(trackedController)); - - when(mockControllerRegistration.getSelected(mockNotebook)).thenReturn(instance(trackedController)); - - selector.clearControllerForEnvironment(mockNotebook, 'env-uuid-123'); - - assert.isFalse( - (mockNativeController.updateNotebookAffinity as sinon.SinonStub).called, - 'Should NOT unselect a non-Deepnote kernel' - ); - }); - }); - suite('cancellation is not reported as a failure', () => { test('a cancelled kernel selection does not show an error message', async () => { await selector.handleKernelSelectionError(new CancellationError(), mockNotebook); diff --git a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts index d80b20ae3f..bc1e2cc64c 100644 --- a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.ts @@ -4,7 +4,6 @@ import { isSingleNotebookDeepnoteFile, splitByNotebooks } from '@deepnote/conver import { ITelemetryService } from '../../platform/analytics/types'; import { ILogger } from '../../platform/logging/types'; -import type { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; import { DEEPNOTE_NOTEBOOK_TYPE } from '../../kernels/deepnote/types'; import { readDeepnoteProjectFile } from '../../platform/deepnote/deepnoteProjectFileReader'; import { allocateSiblingUri } from './deepnoteSiblingFileAllocator'; @@ -28,8 +27,6 @@ export class DeepnoteMultiNotebookSplitter { private readonly disposables: Disposable[] = []; - private readonly envMapper: IDeepnoteNotebookEnvironmentMapper | undefined; - private readonly exists: (uri: Uri) => Promise; private readonly logger: ILogger; @@ -39,13 +36,11 @@ export class DeepnoteMultiNotebookSplitter { private readonly refreshTree: () => void; constructor( - envMapper: IDeepnoteNotebookEnvironmentMapper | undefined, refreshTree: () => void, logger: ILogger, exists: (uri: Uri) => Promise, analytics: ITelemetryService ) { - this.envMapper = envMapper; this.refreshTree = refreshTree; this.logger = logger; this.exists = exists; @@ -158,8 +153,6 @@ export class DeepnoteMultiNotebookSplitter { const deepnoteFile = await readDeepnoteProjectFile(fileUri); const parentDir = Uri.joinPath(fileUri, '..'); - const envMapper = this.envMapper; - const originalEnv = envMapper?.getEnvironmentForNotebook(fileUri); // Write all children before retiring the original (see step below). const entries = splitByNotebooks(deepnoteFile, getFileStem(fileUri)); @@ -187,15 +180,6 @@ export class DeepnoteMultiNotebookSplitter { newUris.push(targetUri); } - // Migrate the environment selection onto each new file (desktop-only). - if (envMapper && originalEnv) { - for (const newUri of newUris) { - // Register the revert BEFORE the set: the mapper mutates memory before the persist that can reject. - rollbacks.push(() => envMapper.removeEnvironmentForNotebook(newUri)); - await envMapper.setEnvironmentForNotebook(newUri, originalEnv); - } - } - // Abort before retiring the original if its tab won't close, else a later save recreates it. if (!(await this.closeNotebookTab(fileUri))) { throw new Error(l10n.t('The file is still open in an editor and could not be closed.')); @@ -206,15 +190,6 @@ export class DeepnoteMultiNotebookSplitter { renamed = true; rollbacks.push(() => workspace.fs.rename(legacyUri, fileUri, { overwrite: false })); - if (envMapper) { - // Restore the original mapping on rollback before removing it here. - if (originalEnv) { - rollbacks.push(() => envMapper.setEnvironmentForNotebook(fileUri, originalEnv)); - } - - await envMapper.removeEnvironmentForNotebook(fileUri); - } - this.refreshTree(); await window.showInformationMessage(l10n.t('Split into {0} files.', newUris.length)); diff --git a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts index 460fa01e6c..74ec00cd96 100644 --- a/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts +++ b/src/notebooks/deepnote/deepnoteMultiNotebookSplitter.unit.test.ts @@ -4,7 +4,6 @@ import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; import { EventEmitter, FileType, NotebookDocument, TabGroups, TabInputNotebook, Uri } from 'vscode'; import { ITelemetryService } from '../../platform/analytics/types'; -import type { IDeepnoteNotebookEnvironmentMapper } from '../../kernels/deepnote/types'; import type { ILogger } from '../../platform/logging/types'; import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock'; import { DeepnoteMultiNotebookSplitter } from './deepnoteMultiNotebookSplitter'; @@ -49,7 +48,6 @@ suite('DeepnoteMultiNotebookSplitter', () => { let splitter: DeepnoteMultiNotebookSplitter; let onDidOpen: EventEmitter; let refreshTreeCount: number; - let envMapper: IDeepnoteNotebookEnvironmentMapper; // Ordered log of side-effecting fs operations, so we can assert write-before-rename ORDER. let callLog: Array<{ op: 'write' | 'rename'; name: string }>; @@ -192,15 +190,7 @@ suite('DeepnoteMultiNotebookSplitter', () => { return Promise.resolve(undefined); }); - // Environment mapper: per-notebook env, recorded via real-ish maps. - const envMock = mock(); - when(envMock.getEnvironmentForNotebook(anything())).thenReturn(undefined); - when(envMock.setEnvironmentForNotebook(anything(), anything())).thenResolve(); - when(envMock.removeEnvironmentForNotebook(anything())).thenResolve(); - envMapper = instance(envMock); - splitter = new DeepnoteMultiNotebookSplitter( - envMapper, () => { refreshTreeCount++; }, @@ -388,56 +378,6 @@ suite('DeepnoteMultiNotebookSplitter', () => { ); }); - test('copies the original env mapping onto each new file and removes the original mapping (regression: split-time env migration)', async () => { - const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); - stubReadFile(file); - acceptSplit(); - - const setCalls: string[] = []; - const removeCalls: string[] = []; - const envMock = mock(); - when(envMock.getEnvironmentForNotebook(anything())).thenReturn('env-xyz'); - when(envMock.setEnvironmentForNotebook(anything(), anything())).thenCall((uri: Uri, env: string) => { - setCalls.push(`${basename(uri)}=${env}`); - return Promise.resolve(); - }); - when(envMock.removeEnvironmentForNotebook(anything())).thenCall((uri: Uri) => { - removeCalls.push(basename(uri)); - return Promise.resolve(); - }); - - // Point the open event at a fresh local emitter BEFORE constructing/activating the - // env-returning splitter, so the new splitter subscribes to the emitter we fire below. - const localEmitter = new EventEmitter(); - when(mockedVSCodeNamespaces.workspace.onDidOpenNotebookDocument).thenReturn(localEmitter.event); - - const splitterWithEnv = new DeepnoteMultiNotebookSplitter( - instance(envMock), - () => { - refreshTreeCount++; - }, - logger, - (uri: Uri) => Promise.resolve(existingOnDisk.has(basename(uri))), - instance(mock()) - ); - splitterWithEnv.activate(); - - localEmitter.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); - - await waitFor(() => removeCalls.length >= 1); - await settle(); - - assert.deepStrictEqual( - setCalls.sort(), - ['multi-alpha.deepnote=env-xyz', 'multi-beta.deepnote=env-xyz'], - 'the original env must be copied onto every new sibling' - ); - assert.deepStrictEqual(removeCalls, ['multi.deepnote'], 'the original mapping must be removed'); - - splitterWithEnv.dispose(); - localEmitter.dispose(); - }); - test('refreshes the tree after a successful split', async () => { const file = makeFile([makeNotebook('n1', 'Alpha', 'a'), makeNotebook('n2', 'Beta', 'b')]); stubReadFile(file); @@ -558,51 +498,28 @@ suite('DeepnoteMultiNotebookSplitter', () => { } /** - * Build and activate a splitter wired to a fresh env mapper (so a test can inject env-set/remove - * failures) and a private open-event emitter (so the fire below targets exactly this splitter). + * Build and activate a splitter whose refreshTree throws, plus a private open-event emitter (so + * the fire below targets exactly this splitter). refreshTree is the last step after the rename, + * which makes it the injection point for "the split failed once the original was already gone". */ - function makeEnvSplitter(opts: { env: string | undefined; failSetFor?: string; failRemoveFor?: string }): { + function makeFailingSplitter(): { emitter: EventEmitter; splitter: DeepnoteMultiNotebookSplitter; - setCalls: string[]; - removeCalls: string[]; } { - const setCalls: string[] = []; - const removeCalls: string[] = []; - const envMock = mock(); - when(envMock.getEnvironmentForNotebook(anything())).thenReturn(opts.env); - when(envMock.setEnvironmentForNotebook(anything(), anything())).thenCall((uri: Uri, env: string) => { - const name = basename(uri); - if (opts.failSetFor && name === opts.failSetFor) { - return Promise.reject(new Error(`set env failed for ${name}`)); - } - setCalls.push(`${name}=${env}`); - return Promise.resolve(); - }); - when(envMock.removeEnvironmentForNotebook(anything())).thenCall((uri: Uri) => { - const name = basename(uri); - if (opts.failRemoveFor && name === opts.failRemoveFor) { - return Promise.reject(new Error(`remove env failed for ${name}`)); - } - removeCalls.push(name); - return Promise.resolve(); - }); - const emitter = new EventEmitter(); when(mockedVSCodeNamespaces.workspace.onDidOpenNotebookDocument).thenReturn(emitter.event); - const envSplitter = new DeepnoteMultiNotebookSplitter( - instance(envMock), + const failingSplitter = new DeepnoteMultiNotebookSplitter( () => { - refreshTreeCount++; + throw new Error('refresh failed after the rename'); }, logger, (uri: Uri) => Promise.resolve(existingOnDisk.has(basename(uri))), instance(mock()) ); - envSplitter.activate(); + failingSplitter.activate(); - return { emitter, splitter: envSplitter, setCalls, removeCalls }; + return { emitter, splitter: failingSplitter }; } test('a failure after the rename rolls the original back into place and reports it restored', async () => { @@ -611,16 +528,8 @@ suite('DeepnoteMultiNotebookSplitter', () => { acceptSplit(); const errors = captureErrorMessage(); - // The rename succeeds, then removing the original's env mapping (the post-rename step) fails. - const { - emitter, - splitter: envSplitter, - setCalls, - removeCalls - } = makeEnvSplitter({ - env: 'env-xyz', - failRemoveFor: 'multi.deepnote' - }); + // The rename succeeds, then the post-rename refresh fails. + const { emitter, splitter: failingSplitter } = makeFailingSplitter(); emitter.fire(notebookDoc(Uri.file('/ws/multi.deepnote'))); @@ -637,19 +546,12 @@ suite('DeepnoteMultiNotebookSplitter', () => { ['multi-alpha.deepnote', 'multi-beta.deepnote'], 'the new siblings are still cleaned up' ); - // The original mapping is restored via a compensating set (its forward removal had failed). - assert.include(setCalls, 'multi.deepnote=env-xyz', 'the original env mapping must be restored on rollback'); - assert.deepStrictEqual( - removeCalls.slice().sort(), - ['multi-alpha.deepnote', 'multi-beta.deepnote'], - 'the sibling env sets are reverted' - ); assert.strictEqual( errors.get(), - 'Failed to split file: remove env failed for multi.deepnote. The original file was restored.' + 'Failed to split file: refresh failed after the rename. The original file was restored.' ); - envSplitter.dispose(); + failingSplitter.dispose(); emitter.dispose(); }); }); diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 52d0e09230..b470dc3b00 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -75,8 +75,6 @@ import { IDeepnoteServerStarter, IDeepnoteKernelAutoSelector, IDeepnoteServerProvider, - IDeepnoteEnvironmentManager, - IDeepnoteNotebookEnvironmentMapper, IDeepnoteLspClientManager, IDeepnoteToolkitDependencyService, IServerHandleRegistry @@ -91,12 +89,6 @@ import { DeepnoteToolkitDependencyService } from '../kernels/deepnote/deepnoteTo import { DeepnoteLspClientManager } from '../kernels/deepnote/deepnoteLspClientManager.node'; import { DeepnoteInitNotebookRunner } from './deepnote/deepnoteInitNotebookRunner.node'; import { DeepnoteRequirementsHelper, IDeepnoteRequirementsHelper } from './deepnote/deepnoteRequirementsHelper.node'; -import { DeepnoteEnvironmentManager } from '../kernels/deepnote/environments/deepnoteEnvironmentManager.node'; -import { DeepnoteEnvironmentStorage } from '../kernels/deepnote/environments/deepnoteEnvironmentStorage.node'; -import { DeepnoteEnvironmentsView } from '../kernels/deepnote/environments/deepnoteEnvironmentsView.node'; -import { DeepnoteEnvironmentsActivationService } from '../kernels/deepnote/environments/deepnoteEnvironmentsActivationService'; -import { DeepnoteExtensionSidecarWriter } from '../kernels/deepnote/environments/deepnoteExtensionSidecarWriter.node'; -import { DeepnoteNotebookEnvironmentMapper } from '../kernels/deepnote/environments/deepnoteNotebookEnvironmentMapper.node'; import { DeepnoteCellExecutionAnalytics } from './deepnote/deepnoteCellExecutionAnalytics'; import { DeepnoteNotebookCommandListener } from './deepnote/deepnoteNotebookCommandListener'; import { DeepnoteInputBlockCellStatusBarItemProvider } from './deepnote/deepnoteInputBlockCellStatusBarProvider'; @@ -107,7 +99,6 @@ import { EphemeralCellStatusBarProvider } from './deepnote/ephemeralCellStatusBa import { OrphanedEphemeralCellCleaner } from './deepnote/orphanedEphemeralCellCleaner'; import { DeepnoteNewCellLanguageService } from './deepnote/deepnoteNewCellLanguageService'; import { DeepnoteCellCopyHandler } from './deepnote/deepnoteCellCopyHandler'; -import { DeepnoteEnvironmentTreeDataProvider } from '../kernels/deepnote/environments/deepnoteEnvironmentTreeDataProvider.node'; import { OpenInDeepnoteHandler } from './deepnote/openInDeepnoteHandler.node'; import { IntegrationEnvRefreshHandler } from './deepnote/integrations/integrationEnvRefreshHandler'; import { IntegrationsEnvFileWatcher } from './deepnote/integrations/integrationsEnvFileWatcher.node'; @@ -312,33 +303,6 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea DeepnoteNotebookInfoStatusBar ); - // Deepnote configuration services - serviceManager.addSingleton(DeepnoteEnvironmentStorage, DeepnoteEnvironmentStorage); - serviceManager.addSingleton(IDeepnoteEnvironmentManager, DeepnoteEnvironmentManager); - serviceManager.addSingleton( - DeepnoteEnvironmentTreeDataProvider, - DeepnoteEnvironmentTreeDataProvider - ); - - // Deepnote configuration view - serviceManager.addSingleton(DeepnoteEnvironmentsView, DeepnoteEnvironmentsView); - serviceManager.addSingleton( - IExtensionSyncActivationService, - DeepnoteEnvironmentsActivationService - ); - - // Deepnote configuration selection - serviceManager.addSingleton( - IDeepnoteNotebookEnvironmentMapper, - DeepnoteNotebookEnvironmentMapper - ); - - // Sidecar file writer (exposes env mappings for external tools) - serviceManager.addSingleton( - IExtensionSyncActivationService, - DeepnoteExtensionSidecarWriter - ); - // Snapshot service serviceManager.addSingleton(IEnvironmentCapture, EnvironmentCapture); serviceManager.addSingleton(SnapshotService, SnapshotService);