Skip to content

Commit b2649a7

Browse files
authored
feat: implement init notebook behavior (#27)
* feat: implement init notebook behavior * fix: coderabbit feedback * fix: address code review feedback * feat: ask about overriding requirements.txt first
1 parent 338e054 commit b2649a7

12 files changed

+953
-75
lines changed

DEEPNOTE_KERNEL_IMPLEMENTATION.md

Lines changed: 356 additions & 63 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ This extension allows you to work with Deepnote notebooks in VS Code. Deepnote n
99
- **More block types** - Choose from SQL blocks, chart blocks, and more specialized data science blocks
1010
- **Seamless language switching** - Switch between Python and SQL seamlessly within the same notebook
1111
- **Database integrations** - Connect directly to Postgres, Snowflake, BigQuery and more data sources
12+
- **Init notebooks** - Automatically runs initialization code (like dependency installation) before your notebooks execute
13+
- **Project requirements** - Automatically creates `requirements.txt` from your project settings for easy dependency management
1214

1315
## Useful commands
1416

src/extension.common.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import { Common } from './platform/common/utils/localize';
3838
import { IServiceContainer, IServiceManager } from './platform/ioc/types';
3939
import { initializeLoggers as init, logger } from './platform/logging';
40+
import { ILogger } from './platform/logging/types';
4041
import { getJupyterOutputChannel } from './standalone/devTools/jupyterOutputChannel';
4142
import { isUsingPylance } from './standalone/intellisense/notebookPythonPathService';
4243
import { noop } from './platform/common/utils/misc';
@@ -127,6 +128,7 @@ export function initializeGlobals(
127128
getJupyterOutputChannel(context.subscriptions),
128129
JUPYTER_OUTPUT_CHANNEL
129130
);
131+
serviceManager.addSingletonInstance<ILogger>(ILogger, logger);
130132

131133
return [serviceManager, serviceContainer];
132134
}

src/kernels/deepnote/deepnoteToolkitInstaller.node.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ export class DeepnoteToolkitInstaller implements IDeepnoteToolkitInstaller {
262262
}
263263
}
264264

265-
private getVenvHash(deepnoteFileUri: Uri): string {
265+
public getVenvHash(deepnoteFileUri: Uri): string {
266266
// Create a short hash from the file path for kernel naming and venv directory
267267
// This provides better uniqueness and prevents directory structure leakage
268268
const path = deepnoteFileUri.fsPath;

src/kernels/deepnote/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ export interface IDeepnoteToolkitInstaller {
8585
* @param deepnoteFileUri The URI of the .deepnote file
8686
*/
8787
getVenvInterpreter(deepnoteFileUri: vscode.Uri): Promise<PythonEnvironment | undefined>;
88+
89+
/**
90+
* Gets the hash for the venv directory/kernel spec name based on file path.
91+
* @param deepnoteFileUri The URI of the .deepnote file
92+
* @returns The hash string used for venv directory and kernel spec naming
93+
*/
94+
getVenvHash(deepnoteFileUri: vscode.Uri): string;
8895
}
8996

9097
export const IDeepnoteServerStarter = Symbol('IDeepnoteServerStarter');
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { inject, injectable } from 'inversify';
2+
import { NotebookDocument, ProgressLocation, window, CancellationTokenSource, CancellationToken } from 'vscode';
3+
import { logger } from '../../platform/logging';
4+
import { IDeepnoteNotebookManager } from '../types';
5+
import { DeepnoteProject, DeepnoteNotebook } from './deepnoteTypes';
6+
import { IKernelProvider } from '../../kernels/types';
7+
import { getDisplayPath } from '../../platform/common/platform/fs-paths';
8+
9+
/**
10+
* Service responsible for running init notebooks before the main notebook starts.
11+
* Init notebooks typically contain setup code like pip installs.
12+
*/
13+
@injectable()
14+
export class DeepnoteInitNotebookRunner {
15+
constructor(
16+
@inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager,
17+
@inject(IKernelProvider) private readonly kernelProvider: IKernelProvider
18+
) {}
19+
20+
/**
21+
* Runs the init notebook if it exists and hasn't been run yet for this project.
22+
* This should be called after the kernel is started but before user code executes.
23+
* @param notebook The notebook document
24+
* @param projectId The Deepnote project ID
25+
* @param token Optional cancellation token to stop execution if notebook is closed
26+
*/
27+
async runInitNotebookIfNeeded(
28+
projectId: string,
29+
notebook: NotebookDocument,
30+
token?: CancellationToken
31+
): Promise<void> {
32+
try {
33+
// Check for cancellation before starting
34+
if (token?.isCancellationRequested) {
35+
logger.info(`Init notebook cancelled before start for project ${projectId}`);
36+
return;
37+
}
38+
39+
// Check if init notebook has already run for this project
40+
if (this.notebookManager.hasInitNotebookBeenRun(projectId)) {
41+
logger.info(`Init notebook already ran for project ${projectId}, skipping`);
42+
return;
43+
}
44+
45+
if (token?.isCancellationRequested) {
46+
logger.info(`Init notebook cancelled for project ${projectId}`);
47+
return;
48+
}
49+
50+
// Get the project data
51+
const project = this.notebookManager.getOriginalProject(projectId) as DeepnoteProject | undefined;
52+
if (!project) {
53+
logger.warn(`Project ${projectId} not found, cannot run init notebook`);
54+
return;
55+
}
56+
57+
// Check if project has an init notebook ID
58+
const initNotebookId = (project.project as { initNotebookId?: string }).initNotebookId;
59+
if (!initNotebookId) {
60+
logger.info(`No init notebook configured for project ${projectId}`);
61+
// Mark as run so we don't check again
62+
this.notebookManager.markInitNotebookAsRun(projectId);
63+
return;
64+
}
65+
66+
// Find the init notebook
67+
const initNotebook = project.project.notebooks.find((nb) => nb.id === initNotebookId);
68+
if (!initNotebook) {
69+
logger.warn(
70+
`Init notebook ${initNotebookId} not found in project ${projectId}, skipping initialization`
71+
);
72+
this.notebookManager.markInitNotebookAsRun(projectId);
73+
return;
74+
}
75+
76+
if (token?.isCancellationRequested) {
77+
logger.info(`Init notebook cancelled before execution for project ${projectId}`);
78+
return;
79+
}
80+
81+
logger.info(`Running init notebook "${initNotebook.name}" (${initNotebookId}) for project ${projectId}`);
82+
83+
// Execute the init notebook with progress
84+
const success = await this.executeInitNotebook(notebook, initNotebook, token);
85+
86+
if (success) {
87+
// Mark as run so we don't run it again
88+
this.notebookManager.markInitNotebookAsRun(projectId);
89+
logger.info(`Init notebook completed successfully for project ${projectId}`);
90+
} else {
91+
logger.warn(`Init notebook did not execute for project ${projectId} - kernel not available`);
92+
}
93+
} catch (error) {
94+
// Check if this is a cancellation error
95+
if (error instanceof Error && error.message === 'Cancelled') {
96+
logger.info(`Init notebook cancelled for project ${projectId}`);
97+
return;
98+
}
99+
// Log error but don't throw - we want to let user continue anyway
100+
logger.error(`Error running init notebook for project ${projectId}:`, error);
101+
// Still mark as run to avoid retrying on every notebook open
102+
this.notebookManager.markInitNotebookAsRun(projectId);
103+
}
104+
}
105+
106+
/**
107+
* Executes the init notebook's code blocks in the kernel.
108+
* @param notebook The notebook document (for kernel context)
109+
* @param initNotebook The init notebook to execute
110+
* @param token Optional cancellation token from parent operation
111+
* @returns True if execution completed, false if kernel was not available
112+
*/
113+
private async executeInitNotebook(
114+
notebook: NotebookDocument,
115+
initNotebook: DeepnoteNotebook,
116+
token?: CancellationToken
117+
): Promise<boolean> {
118+
// Check for cancellation before starting
119+
if (token?.isCancellationRequested) {
120+
logger.info(`Init notebook execution cancelled before start`);
121+
return false;
122+
}
123+
124+
// Show progress in both notification AND window for maximum visibility
125+
const cancellationTokenSource = new CancellationTokenSource();
126+
127+
// Link parent token to our local token if provided
128+
const tokenDisposable = token?.onCancellationRequested(() => {
129+
cancellationTokenSource.cancel();
130+
});
131+
132+
// Create a wrapper that reports to both progress locations
133+
const executeWithDualProgress = async () => {
134+
return window.withProgress(
135+
{
136+
location: ProgressLocation.Notification,
137+
title: `🚀 Initializing project environment`,
138+
cancellable: false
139+
},
140+
async (notificationProgress) => {
141+
return window.withProgress(
142+
{
143+
location: ProgressLocation.Window,
144+
title: `Init: "${initNotebook.name}"`,
145+
cancellable: false
146+
},
147+
async (windowProgress) => {
148+
// Helper to report to both progress bars
149+
const reportProgress = (message: string, increment: number) => {
150+
notificationProgress.report({ message, increment });
151+
windowProgress.report({ message, increment });
152+
};
153+
154+
return this.executeInitNotebookImpl(
155+
notebook,
156+
initNotebook,
157+
reportProgress,
158+
cancellationTokenSource.token
159+
);
160+
}
161+
);
162+
}
163+
);
164+
};
165+
166+
try {
167+
return await executeWithDualProgress();
168+
} finally {
169+
tokenDisposable?.dispose();
170+
cancellationTokenSource.dispose();
171+
}
172+
}
173+
174+
private async executeInitNotebookImpl(
175+
notebook: NotebookDocument,
176+
initNotebook: DeepnoteNotebook,
177+
progress: (message: string, increment: number) => void,
178+
token: CancellationToken
179+
): Promise<boolean> {
180+
try {
181+
// Check for cancellation
182+
if (token.isCancellationRequested) {
183+
logger.info(`Init notebook execution cancelled`);
184+
return false;
185+
}
186+
187+
progress(`Running init notebook "${initNotebook.name}"...`, 0);
188+
189+
// Get the kernel for this notebook
190+
// Note: This should always exist because onKernelStarted already fired
191+
const kernel = this.kernelProvider.get(notebook);
192+
if (!kernel) {
193+
logger.error(
194+
`No kernel found for ${getDisplayPath(
195+
notebook.uri
196+
)} even after onDidStartKernel fired - this should not happen`
197+
);
198+
return false;
199+
}
200+
201+
logger.info(`Kernel found for ${getDisplayPath(notebook.uri)}, starting init notebook execution`);
202+
203+
// Filter out non-code blocks
204+
const codeBlocks = initNotebook.blocks.filter((block) => block.type === 'code');
205+
206+
if (codeBlocks.length === 0) {
207+
logger.info(`Init notebook has no code blocks, skipping execution`);
208+
return true; // Not an error - just nothing to execute
209+
}
210+
211+
logger.info(`Executing ${codeBlocks.length} code blocks from init notebook`);
212+
progress(
213+
`Preparing to execute ${codeBlocks.length} initialization ${
214+
codeBlocks.length === 1 ? 'block' : 'blocks'
215+
}...`,
216+
5
217+
);
218+
219+
// Check for cancellation
220+
if (token.isCancellationRequested) {
221+
logger.info(`Init notebook execution cancelled before starting blocks`);
222+
return false;
223+
}
224+
225+
// Get kernel execution
226+
const kernelExecution = this.kernelProvider.getKernelExecution(kernel);
227+
228+
// Execute each code block sequentially
229+
for (let i = 0; i < codeBlocks.length; i++) {
230+
// Check for cancellation between blocks
231+
if (token.isCancellationRequested) {
232+
logger.info(`Init notebook execution cancelled after block ${i}`);
233+
return false;
234+
}
235+
236+
const block = codeBlocks[i];
237+
const percentComplete = Math.floor((i / codeBlocks.length) * 100);
238+
239+
// Show more detailed progress with percentage
240+
progress(
241+
`[${percentComplete}%] Executing block ${i + 1} of ${codeBlocks.length}...`,
242+
90 / codeBlocks.length // Reserve 5% for start, 5% for finish
243+
);
244+
245+
logger.info(`Executing init notebook block ${i + 1}/${codeBlocks.length}`);
246+
247+
try {
248+
// Execute the code silently in the background
249+
const outputs = await kernelExecution.executeHidden(block.content ?? '');
250+
251+
// Log outputs for debugging
252+
if (outputs && outputs.length > 0) {
253+
logger.info(`Init notebook block ${i + 1} produced ${outputs.length} outputs`);
254+
255+
// Check for errors in outputs
256+
const errors = outputs.filter(
257+
(output: { output_type?: string }) => output.output_type === 'error'
258+
);
259+
if (errors.length > 0) {
260+
logger.warn(`Init notebook block ${i + 1} produced errors:`, errors);
261+
}
262+
}
263+
} catch (blockError) {
264+
// Log error but continue with next block
265+
logger.error(`Error executing init notebook block ${i + 1}:`, blockError);
266+
}
267+
}
268+
269+
logger.info(`Completed executing all init notebook blocks`);
270+
progress(`✓ Initialization complete! Environment ready.`, 5);
271+
272+
// Give user a moment to see the completion message
273+
await new Promise((resolve) => setTimeout(resolve, 1000));
274+
275+
return true;
276+
} catch (error) {
277+
logger.error(`Error in executeInitNotebook:`, error);
278+
throw error;
279+
}
280+
}
281+
}
282+
283+
export const IDeepnoteInitNotebookRunner = Symbol('IDeepnoteInitNotebookRunner');
284+
export interface IDeepnoteInitNotebookRunner {
285+
runInitNotebookIfNeeded(projectId: string, notebook: NotebookDocument, token?: CancellationToken): Promise<void>;
286+
}

0 commit comments

Comments
 (0)