@deepnote/runtime-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -84,7 +84,7 @@ var KernelClient = class {
84
84
  while (this.kernel.status !== "idle") {
85
85
  if (Date.now() - startTime > timeoutMs) throw new Error(`Kernel failed to reach idle status within ${timeoutMs}ms. Current status: ${this.kernel.status}`);
86
86
  if (this.kernel.status === "dead") throw new Error("Kernel is dead");
87
- await new Promise((resolve) => setTimeout(resolve, 100));
87
+ await new Promise((resolve$1) => setTimeout(resolve$1, 100));
88
88
  }
89
89
  }
90
90
  /**
@@ -92,7 +92,7 @@ var KernelClient = class {
92
92
  */
93
93
  async execute(code, callbacks) {
94
94
  if (!this.kernel) throw new Error("Kernel not connected. Call connect() first.");
95
- return new Promise((resolve, reject) => {
95
+ return new Promise((resolve$1, reject) => {
96
96
  const outputs = [];
97
97
  let executionCount = null;
98
98
  const future = this.kernel?.requestExecute({ code });
@@ -122,7 +122,7 @@ var KernelClient = class {
122
122
  executionCount
123
123
  };
124
124
  callbacks?.onDone?.(result);
125
- resolve(result);
125
+ resolve$1(result);
126
126
  }).catch(reject).finally(() => future?.dispose());
127
127
  });
128
128
  }
@@ -188,8 +188,10 @@ var KernelClient = class {
188
188
 
189
189
  //#endregion
190
190
  //#region src/python-env.ts
191
- const PYTHON_EXECUTABLES_UNIX = ["python", "python3"];
192
- const PYTHON_EXECUTABLES_WIN = ["python.exe", "python3.exe"];
191
+ const IS_WINDOWS = process.platform === "win32";
192
+ const PYTHON_EXECUTABLES = IS_WINDOWS ? ["python.exe", "python3.exe"] : ["python", "python3"];
193
+ const VENV_BIN_DIR = IS_WINDOWS ? "Scripts" : "bin";
194
+ const BARE_PYTHON_COMMAND = /^python[0-9.]*$/;
193
195
  /**
194
196
  * Resolves the Python executable path using smart detection.
195
197
  *
@@ -204,7 +206,7 @@ const PYTHON_EXECUTABLES_WIN = ["python.exe", "python3.exe"];
204
206
  * @throws Error if the path doesn't exist or no Python executable is found
205
207
  */
206
208
  async function resolvePythonExecutable(pythonPath) {
207
- if (pythonPath === "python" || pythonPath === "python3") return pythonPath;
209
+ if (isBareSystemPython(pythonPath)) return pythonPath;
208
210
  let fileStat;
209
211
  try {
210
212
  fileStat = await (0, node_fs_promises.stat)(pythonPath);
@@ -218,15 +220,14 @@ async function resolvePythonExecutable(pythonPath) {
218
220
  throw new Error(`Path is a file but doesn't appear to be a Python executable: ${pythonPath}\nExpected a file named python, python3, python.exe, or similar.`);
219
221
  }
220
222
  if (!fileStat.isDirectory()) throw new Error(`Python path is neither a file nor a directory: ${pythonPath}`);
221
- const candidates = process.platform === "win32" ? PYTHON_EXECUTABLES_WIN : PYTHON_EXECUTABLES_UNIX;
222
- const directPython = await findPythonInDirectory(pythonPath, candidates);
223
+ const directPython = await findPythonInDirectory(pythonPath, PYTHON_EXECUTABLES);
223
224
  if (directPython) return directPython;
224
- const binDir = process.platform === "win32" ? (0, node_path.join)(pythonPath, "Scripts") : (0, node_path.join)(pythonPath, "bin");
225
+ const binDir = (0, node_path.join)(pythonPath, VENV_BIN_DIR);
225
226
  if ((await (0, node_fs_promises.stat)(binDir).catch(() => null))?.isDirectory()) {
226
- const venvPython = await findPythonInDirectory(binDir, candidates);
227
+ const venvPython = await findPythonInDirectory(binDir, PYTHON_EXECUTABLES);
227
228
  if (venvPython) return venvPython;
228
229
  }
229
- const searchedPaths = [...candidates.map((c) => (0, node_path.join)(pythonPath, c)), ...candidates.map((c) => (0, node_path.join)(binDir, c))];
230
+ const searchedPaths = [...PYTHON_EXECUTABLES.map((c) => (0, node_path.join)(pythonPath, c)), ...PYTHON_EXECUTABLES.map((c) => (0, node_path.join)(binDir, c))];
230
231
  throw new Error(`No Python executable found at: ${pythonPath}\n\nSearched for:\n${searchedPaths.map((p) => ` - ${p}`).join("\n")}\n\nYou can pass:
231
232
  - A Python executable: --python /path/to/venv/bin/python
232
233
  - A bin directory: --python /path/to/venv/bin
@@ -255,6 +256,13 @@ function detectDefaultPython() {
255
256
  throw new Error("No Python executable found.\n\nPlease ensure Python is installed and available in your PATH,\nor specify the path explicitly with --python <path>");
256
257
  }
257
258
  /**
259
+ * Checks if the given string is a bare system Python command (e.g. 'python', 'python3', 'python3.11')
260
+ * as opposed to an absolute/relative path to a Python executable.
261
+ */
262
+ function isBareSystemPython(pythonPath) {
263
+ return BARE_PYTHON_COMMAND.test(pythonPath);
264
+ }
265
+ /**
258
266
  * Checks if a Python command is available on the system.
259
267
  */
260
268
  function isPythonAvailable(command) {
@@ -265,6 +273,46 @@ function isPythonAvailable(command) {
265
273
  return false;
266
274
  }
267
275
  }
276
+ /**
277
+ * Detects the virtual environment root for a given Python executable path.
278
+ *
279
+ * Checks for `pyvenv.cfg` which is the standard marker for Python venvs.
280
+ * Handles both `/path/to/venv/bin/python` and `/path/to/venv/Scripts/python.exe`.
281
+ *
282
+ * @returns The venv root directory, or null if the Python is not in a venv
283
+ */
284
+ async function detectVenvRoot(pythonExecutable) {
285
+ const possibleVenvRoot = (0, node_path.dirname)((0, node_path.dirname)(pythonExecutable));
286
+ if ((await (0, node_fs_promises.stat)((0, node_path.join)(possibleVenvRoot, "pyvenv.cfg")).catch(() => null))?.isFile()) return possibleVenvRoot;
287
+ return null;
288
+ }
289
+ /**
290
+ * Builds environment variables appropriate for the resolved Python executable.
291
+ *
292
+ * When a specific Python path is provided (not just 'python' or 'python3'),
293
+ * this ensures the spawned process environment is consistent with the specified
294
+ * Python by:
295
+ * - Prepending the Python's directory to PATH so subprocesses find the right Python
296
+ * - Setting VIRTUAL_ENV if the Python is in a venv
297
+ * - Clearing VIRTUAL_ENV if the Python is NOT in a venv (to avoid inheriting
298
+ * the current shell's active venv)
299
+ *
300
+ * @param resolvedPythonPath - The resolved path from resolvePythonExecutable()
301
+ * @param baseEnv - The base environment to modify (defaults to process.env)
302
+ * @returns Environment variables object for use with child_process.spawn()
303
+ */
304
+ async function buildPythonEnv(resolvedPythonPath, baseEnv = process.env) {
305
+ const env = { ...baseEnv };
306
+ if (isBareSystemPython(resolvedPythonPath)) return env;
307
+ const pythonDir = (0, node_path.dirname)((0, node_path.resolve)(resolvedPythonPath));
308
+ const pathKey = Object.keys(env).find((k) => k.toLowerCase() === "path") || "PATH";
309
+ const currentPath = env[pathKey] || "";
310
+ env[pathKey] = currentPath ? `${pythonDir}${node_path.delimiter}${currentPath}` : pythonDir;
311
+ const venvRoot = await detectVenvRoot((0, node_path.resolve)(resolvedPythonPath));
312
+ if (venvRoot) env.VIRTUAL_ENV = venvRoot;
313
+ else delete env.VIRTUAL_ENV;
314
+ return env;
315
+ }
268
316
 
269
317
  //#endregion
270
318
  //#region src/server-starter.ts
@@ -280,7 +328,10 @@ async function startServer(options) {
280
328
  const pythonPath = await resolvePythonExecutable(pythonEnv);
281
329
  const jupyterPort = await findConsecutiveAvailablePorts(port ?? DEFAULT_PORT);
282
330
  const lspPort = jupyterPort + 1;
283
- const env = { ...process.env };
331
+ const env = await buildPythonEnv(pythonPath, {
332
+ ...process.env,
333
+ ...options.env
334
+ });
284
335
  env.DEEPNOTE_RUNTIME__RUNNING_IN_DETACHED_MODE = "true";
285
336
  env.DEEPNOTE_ENFORCE_PIP_CONSTRAINTS = "true";
286
337
  const serverProcess = (0, node_child_process.spawn)(pythonPath, [
@@ -335,14 +386,14 @@ async function startServer(options) {
335
386
  async function stopServer(info) {
336
387
  if (info.process.exitCode !== null) return;
337
388
  info.process.kill("SIGTERM");
338
- await new Promise((resolve) => {
389
+ await new Promise((resolve$1) => {
339
390
  const timeout = setTimeout(() => {
340
391
  if (info.process.exitCode === null) info.process.kill("SIGKILL");
341
- resolve();
392
+ resolve$1();
342
393
  }, 2e3);
343
394
  info.process.once("exit", () => {
344
395
  clearTimeout(timeout);
345
- resolve();
396
+ resolve$1();
346
397
  });
347
398
  });
348
399
  }
@@ -382,7 +433,7 @@ async function waitForServer(info, timeoutMs) {
382
433
  throw new Error(`Server failed to start within ${timeoutMs}ms at ${info.url}`);
383
434
  }
384
435
  function sleep(ms) {
385
- return new Promise((resolve) => setTimeout(resolve, ms));
436
+ return new Promise((resolve$1) => setTimeout(resolve$1, ms));
386
437
  }
387
438
 
388
439
  //#endregion
@@ -442,7 +493,8 @@ var ExecutionEngine = class {
442
493
  this.server = await startServer({
443
494
  pythonEnv: this.config.pythonEnv,
444
495
  workingDirectory: this.config.workingDirectory,
445
- port: this.config.serverPort
496
+ port: this.config.serverPort,
497
+ env: this.config.env
446
498
  });
447
499
  try {
448
500
  this.kernel = new KernelClient();
@@ -611,6 +663,7 @@ var ExecutionEngine = class {
611
663
  //#endregion
612
664
  exports.ExecutionEngine = ExecutionEngine;
613
665
  exports.KernelClient = KernelClient;
666
+ exports.buildPythonEnv = buildPythonEnv;
614
667
  exports.detectDefaultPython = detectDefaultPython;
615
668
  exports.executableBlockTypeSet = executableBlockTypeSet;
616
669
  exports.executableBlockTypes = executableBlockTypes;
package/dist/index.d.cts CHANGED
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { IDisplayData, IError, IExecuteResult, IOutput, IOutput as IOutput$1, IStream } from "@jupyterlab/nbformat";
3
3
  import { ChildProcess } from "node:child_process";
4
4
 
5
- //#region ../blocks/src/deserialize-file/deepnote-file-schema.d.ts
5
+ //#region ../blocks/src/deepnote-file/deepnote-file-schema.d.ts
6
6
 
7
7
  declare const codeBlockSchema: z.ZodObject<{
8
8
  type: z.ZodLiteral<"code">;
@@ -14689,6 +14689,8 @@ interface RuntimeConfig {
14689
14689
  workingDirectory: string;
14690
14690
  /** Optional port for the Jupyter server (auto-assigned if not provided) */
14691
14691
  serverPort?: number;
14692
+ /** Optional environment variables to pass to the server */
14693
+ env?: Record<string, string>;
14692
14694
  }
14693
14695
  interface BlockExecutionResult {
14694
14696
  blockId: string;
@@ -14862,6 +14864,22 @@ declare function resolvePythonExecutable(pythonPath: string): Promise<string>;
14862
14864
  * @throws Error if neither python nor python3 is found
14863
14865
  */
14864
14866
  declare function detectDefaultPython(): string;
14867
+ /**
14868
+ * Builds environment variables appropriate for the resolved Python executable.
14869
+ *
14870
+ * When a specific Python path is provided (not just 'python' or 'python3'),
14871
+ * this ensures the spawned process environment is consistent with the specified
14872
+ * Python by:
14873
+ * - Prepending the Python's directory to PATH so subprocesses find the right Python
14874
+ * - Setting VIRTUAL_ENV if the Python is in a venv
14875
+ * - Clearing VIRTUAL_ENV if the Python is NOT in a venv (to avoid inheriting
14876
+ * the current shell's active venv)
14877
+ *
14878
+ * @param resolvedPythonPath - The resolved path from resolvePythonExecutable()
14879
+ * @param baseEnv - The base environment to modify (defaults to process.env)
14880
+ * @returns Environment variables object for use with child_process.spawn()
14881
+ */
14882
+ declare function buildPythonEnv(resolvedPythonPath: string, baseEnv?: Record<string, string | undefined>): Promise<Record<string, string | undefined>>;
14865
14883
  //#endregion
14866
14884
  //#region src/server-starter.d.ts
14867
14885
  interface ServerInfo {
@@ -14879,6 +14897,8 @@ interface ServerOptions {
14879
14897
  port?: number;
14880
14898
  /** Optional timeout for server startup in milliseconds */
14881
14899
  startupTimeoutMs?: number;
14900
+ /** Optional environment variables to pass to the server */
14901
+ env?: Record<string, string>;
14882
14902
  }
14883
14903
  /**
14884
14904
  * Start the deepnote-toolkit Jupyter server.
@@ -14890,4 +14910,4 @@ declare function startServer(options: ServerOptions): Promise<ServerInfo>;
14890
14910
  */
14891
14911
  declare function stopServer(info: ServerInfo): Promise<void>;
14892
14912
  //#endregion
14893
- export { type BlockExecutionResult, type DeepnoteBlock, type DeepnoteFile, type ExecutionCallbacks, ExecutionEngine, type ExecutionOptions, type ExecutionResult, type ExecutionSummary, type IDisplayData, type IError, type IExecuteResult, type IOutput, type IStream, KernelClient, type RuntimeConfig, type ServerInfo, type ServerOptions, detectDefaultPython, executableBlockTypeSet, executableBlockTypes, resolvePythonExecutable, startServer, stopServer };
14913
+ export { type BlockExecutionResult, type DeepnoteBlock, type DeepnoteFile, type ExecutionCallbacks, ExecutionEngine, type ExecutionOptions, type ExecutionResult, type ExecutionSummary, type IDisplayData, type IError, type IExecuteResult, type IOutput, type IStream, KernelClient, type RuntimeConfig, type ServerInfo, type ServerOptions, buildPythonEnv, detectDefaultPython, executableBlockTypeSet, executableBlockTypes, resolvePythonExecutable, startServer, stopServer };
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import { ChildProcess } from "node:child_process";
2
2
  import { z } from "zod";
3
3
  import { IDisplayData, IError, IExecuteResult, IOutput, IOutput as IOutput$1, IStream } from "@jupyterlab/nbformat";
4
4
 
5
- //#region ../blocks/src/deserialize-file/deepnote-file-schema.d.ts
5
+ //#region ../blocks/src/deepnote-file/deepnote-file-schema.d.ts
6
6
 
7
7
  declare const codeBlockSchema: z.ZodObject<{
8
8
  type: z.ZodLiteral<"code">;
@@ -14689,6 +14689,8 @@ interface RuntimeConfig {
14689
14689
  workingDirectory: string;
14690
14690
  /** Optional port for the Jupyter server (auto-assigned if not provided) */
14691
14691
  serverPort?: number;
14692
+ /** Optional environment variables to pass to the server */
14693
+ env?: Record<string, string>;
14692
14694
  }
14693
14695
  interface BlockExecutionResult {
14694
14696
  blockId: string;
@@ -14862,6 +14864,22 @@ declare function resolvePythonExecutable(pythonPath: string): Promise<string>;
14862
14864
  * @throws Error if neither python nor python3 is found
14863
14865
  */
14864
14866
  declare function detectDefaultPython(): string;
14867
+ /**
14868
+ * Builds environment variables appropriate for the resolved Python executable.
14869
+ *
14870
+ * When a specific Python path is provided (not just 'python' or 'python3'),
14871
+ * this ensures the spawned process environment is consistent with the specified
14872
+ * Python by:
14873
+ * - Prepending the Python's directory to PATH so subprocesses find the right Python
14874
+ * - Setting VIRTUAL_ENV if the Python is in a venv
14875
+ * - Clearing VIRTUAL_ENV if the Python is NOT in a venv (to avoid inheriting
14876
+ * the current shell's active venv)
14877
+ *
14878
+ * @param resolvedPythonPath - The resolved path from resolvePythonExecutable()
14879
+ * @param baseEnv - The base environment to modify (defaults to process.env)
14880
+ * @returns Environment variables object for use with child_process.spawn()
14881
+ */
14882
+ declare function buildPythonEnv(resolvedPythonPath: string, baseEnv?: Record<string, string | undefined>): Promise<Record<string, string | undefined>>;
14865
14883
  //#endregion
14866
14884
  //#region src/server-starter.d.ts
14867
14885
  interface ServerInfo {
@@ -14879,6 +14897,8 @@ interface ServerOptions {
14879
14897
  port?: number;
14880
14898
  /** Optional timeout for server startup in milliseconds */
14881
14899
  startupTimeoutMs?: number;
14900
+ /** Optional environment variables to pass to the server */
14901
+ env?: Record<string, string>;
14882
14902
  }
14883
14903
  /**
14884
14904
  * Start the deepnote-toolkit Jupyter server.
@@ -14890,4 +14910,4 @@ declare function startServer(options: ServerOptions): Promise<ServerInfo>;
14890
14910
  */
14891
14911
  declare function stopServer(info: ServerInfo): Promise<void>;
14892
14912
  //#endregion
14893
- export { type BlockExecutionResult, type DeepnoteBlock, type DeepnoteFile, type ExecutionCallbacks, ExecutionEngine, type ExecutionOptions, type ExecutionResult, type ExecutionSummary, type IDisplayData, type IError, type IExecuteResult, type IOutput, type IStream, KernelClient, type RuntimeConfig, type ServerInfo, type ServerOptions, detectDefaultPython, executableBlockTypeSet, executableBlockTypes, resolvePythonExecutable, startServer, stopServer };
14913
+ export { type BlockExecutionResult, type DeepnoteBlock, type DeepnoteFile, type ExecutionCallbacks, ExecutionEngine, type ExecutionOptions, type ExecutionResult, type ExecutionSummary, type IDisplayData, type IError, type IExecuteResult, type IOutput, type IStream, KernelClient, type RuntimeConfig, type ServerInfo, type ServerOptions, buildPythonEnv, detectDefaultPython, executableBlockTypeSet, executableBlockTypes, resolvePythonExecutable, startServer, stopServer };
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { createPythonCode, decodeUtf8NoBom, deserializeDeepnoteFile, isExecutabl
3
3
  import { KernelManager, ServerConnection, SessionManager } from "@jupyterlab/services";
4
4
  import { execSync, spawn } from "node:child_process";
5
5
  import tcpPortUsed from "tcp-port-used";
6
- import { basename, join } from "node:path";
6
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
7
7
 
8
8
  //#region src/kernel-client.ts
9
9
  /**
@@ -55,7 +55,7 @@ var KernelClient = class {
55
55
  while (this.kernel.status !== "idle") {
56
56
  if (Date.now() - startTime > timeoutMs) throw new Error(`Kernel failed to reach idle status within ${timeoutMs}ms. Current status: ${this.kernel.status}`);
57
57
  if (this.kernel.status === "dead") throw new Error("Kernel is dead");
58
- await new Promise((resolve) => setTimeout(resolve, 100));
58
+ await new Promise((resolve$1) => setTimeout(resolve$1, 100));
59
59
  }
60
60
  }
61
61
  /**
@@ -63,7 +63,7 @@ var KernelClient = class {
63
63
  */
64
64
  async execute(code, callbacks) {
65
65
  if (!this.kernel) throw new Error("Kernel not connected. Call connect() first.");
66
- return new Promise((resolve, reject) => {
66
+ return new Promise((resolve$1, reject) => {
67
67
  const outputs = [];
68
68
  let executionCount = null;
69
69
  const future = this.kernel?.requestExecute({ code });
@@ -93,7 +93,7 @@ var KernelClient = class {
93
93
  executionCount
94
94
  };
95
95
  callbacks?.onDone?.(result);
96
- resolve(result);
96
+ resolve$1(result);
97
97
  }).catch(reject).finally(() => future?.dispose());
98
98
  });
99
99
  }
@@ -159,8 +159,10 @@ var KernelClient = class {
159
159
 
160
160
  //#endregion
161
161
  //#region src/python-env.ts
162
- const PYTHON_EXECUTABLES_UNIX = ["python", "python3"];
163
- const PYTHON_EXECUTABLES_WIN = ["python.exe", "python3.exe"];
162
+ const IS_WINDOWS = process.platform === "win32";
163
+ const PYTHON_EXECUTABLES = IS_WINDOWS ? ["python.exe", "python3.exe"] : ["python", "python3"];
164
+ const VENV_BIN_DIR = IS_WINDOWS ? "Scripts" : "bin";
165
+ const BARE_PYTHON_COMMAND = /^python[0-9.]*$/;
164
166
  /**
165
167
  * Resolves the Python executable path using smart detection.
166
168
  *
@@ -175,7 +177,7 @@ const PYTHON_EXECUTABLES_WIN = ["python.exe", "python3.exe"];
175
177
  * @throws Error if the path doesn't exist or no Python executable is found
176
178
  */
177
179
  async function resolvePythonExecutable(pythonPath) {
178
- if (pythonPath === "python" || pythonPath === "python3") return pythonPath;
180
+ if (isBareSystemPython(pythonPath)) return pythonPath;
179
181
  let fileStat;
180
182
  try {
181
183
  fileStat = await stat(pythonPath);
@@ -189,15 +191,14 @@ async function resolvePythonExecutable(pythonPath) {
189
191
  throw new Error(`Path is a file but doesn't appear to be a Python executable: ${pythonPath}\nExpected a file named python, python3, python.exe, or similar.`);
190
192
  }
191
193
  if (!fileStat.isDirectory()) throw new Error(`Python path is neither a file nor a directory: ${pythonPath}`);
192
- const candidates = process.platform === "win32" ? PYTHON_EXECUTABLES_WIN : PYTHON_EXECUTABLES_UNIX;
193
- const directPython = await findPythonInDirectory(pythonPath, candidates);
194
+ const directPython = await findPythonInDirectory(pythonPath, PYTHON_EXECUTABLES);
194
195
  if (directPython) return directPython;
195
- const binDir = process.platform === "win32" ? join(pythonPath, "Scripts") : join(pythonPath, "bin");
196
+ const binDir = join(pythonPath, VENV_BIN_DIR);
196
197
  if ((await stat(binDir).catch(() => null))?.isDirectory()) {
197
- const venvPython = await findPythonInDirectory(binDir, candidates);
198
+ const venvPython = await findPythonInDirectory(binDir, PYTHON_EXECUTABLES);
198
199
  if (venvPython) return venvPython;
199
200
  }
200
- const searchedPaths = [...candidates.map((c) => join(pythonPath, c)), ...candidates.map((c) => join(binDir, c))];
201
+ const searchedPaths = [...PYTHON_EXECUTABLES.map((c) => join(pythonPath, c)), ...PYTHON_EXECUTABLES.map((c) => join(binDir, c))];
201
202
  throw new Error(`No Python executable found at: ${pythonPath}\n\nSearched for:\n${searchedPaths.map((p) => ` - ${p}`).join("\n")}\n\nYou can pass:
202
203
  - A Python executable: --python /path/to/venv/bin/python
203
204
  - A bin directory: --python /path/to/venv/bin
@@ -226,6 +227,13 @@ function detectDefaultPython() {
226
227
  throw new Error("No Python executable found.\n\nPlease ensure Python is installed and available in your PATH,\nor specify the path explicitly with --python <path>");
227
228
  }
228
229
  /**
230
+ * Checks if the given string is a bare system Python command (e.g. 'python', 'python3', 'python3.11')
231
+ * as opposed to an absolute/relative path to a Python executable.
232
+ */
233
+ function isBareSystemPython(pythonPath) {
234
+ return BARE_PYTHON_COMMAND.test(pythonPath);
235
+ }
236
+ /**
229
237
  * Checks if a Python command is available on the system.
230
238
  */
231
239
  function isPythonAvailable(command) {
@@ -236,6 +244,46 @@ function isPythonAvailable(command) {
236
244
  return false;
237
245
  }
238
246
  }
247
+ /**
248
+ * Detects the virtual environment root for a given Python executable path.
249
+ *
250
+ * Checks for `pyvenv.cfg` which is the standard marker for Python venvs.
251
+ * Handles both `/path/to/venv/bin/python` and `/path/to/venv/Scripts/python.exe`.
252
+ *
253
+ * @returns The venv root directory, or null if the Python is not in a venv
254
+ */
255
+ async function detectVenvRoot(pythonExecutable) {
256
+ const possibleVenvRoot = dirname(dirname(pythonExecutable));
257
+ if ((await stat(join(possibleVenvRoot, "pyvenv.cfg")).catch(() => null))?.isFile()) return possibleVenvRoot;
258
+ return null;
259
+ }
260
+ /**
261
+ * Builds environment variables appropriate for the resolved Python executable.
262
+ *
263
+ * When a specific Python path is provided (not just 'python' or 'python3'),
264
+ * this ensures the spawned process environment is consistent with the specified
265
+ * Python by:
266
+ * - Prepending the Python's directory to PATH so subprocesses find the right Python
267
+ * - Setting VIRTUAL_ENV if the Python is in a venv
268
+ * - Clearing VIRTUAL_ENV if the Python is NOT in a venv (to avoid inheriting
269
+ * the current shell's active venv)
270
+ *
271
+ * @param resolvedPythonPath - The resolved path from resolvePythonExecutable()
272
+ * @param baseEnv - The base environment to modify (defaults to process.env)
273
+ * @returns Environment variables object for use with child_process.spawn()
274
+ */
275
+ async function buildPythonEnv(resolvedPythonPath, baseEnv = process.env) {
276
+ const env = { ...baseEnv };
277
+ if (isBareSystemPython(resolvedPythonPath)) return env;
278
+ const pythonDir = dirname(resolve(resolvedPythonPath));
279
+ const pathKey = Object.keys(env).find((k) => k.toLowerCase() === "path") || "PATH";
280
+ const currentPath = env[pathKey] || "";
281
+ env[pathKey] = currentPath ? `${pythonDir}${delimiter}${currentPath}` : pythonDir;
282
+ const venvRoot = await detectVenvRoot(resolve(resolvedPythonPath));
283
+ if (venvRoot) env.VIRTUAL_ENV = venvRoot;
284
+ else delete env.VIRTUAL_ENV;
285
+ return env;
286
+ }
239
287
 
240
288
  //#endregion
241
289
  //#region src/server-starter.ts
@@ -251,7 +299,10 @@ async function startServer(options) {
251
299
  const pythonPath = await resolvePythonExecutable(pythonEnv);
252
300
  const jupyterPort = await findConsecutiveAvailablePorts(port ?? DEFAULT_PORT);
253
301
  const lspPort = jupyterPort + 1;
254
- const env = { ...process.env };
302
+ const env = await buildPythonEnv(pythonPath, {
303
+ ...process.env,
304
+ ...options.env
305
+ });
255
306
  env.DEEPNOTE_RUNTIME__RUNNING_IN_DETACHED_MODE = "true";
256
307
  env.DEEPNOTE_ENFORCE_PIP_CONSTRAINTS = "true";
257
308
  const serverProcess = spawn(pythonPath, [
@@ -306,14 +357,14 @@ async function startServer(options) {
306
357
  async function stopServer(info) {
307
358
  if (info.process.exitCode !== null) return;
308
359
  info.process.kill("SIGTERM");
309
- await new Promise((resolve) => {
360
+ await new Promise((resolve$1) => {
310
361
  const timeout = setTimeout(() => {
311
362
  if (info.process.exitCode === null) info.process.kill("SIGKILL");
312
- resolve();
363
+ resolve$1();
313
364
  }, 2e3);
314
365
  info.process.once("exit", () => {
315
366
  clearTimeout(timeout);
316
- resolve();
367
+ resolve$1();
317
368
  });
318
369
  });
319
370
  }
@@ -353,7 +404,7 @@ async function waitForServer(info, timeoutMs) {
353
404
  throw new Error(`Server failed to start within ${timeoutMs}ms at ${info.url}`);
354
405
  }
355
406
  function sleep(ms) {
356
- return new Promise((resolve) => setTimeout(resolve, ms));
407
+ return new Promise((resolve$1) => setTimeout(resolve$1, ms));
357
408
  }
358
409
 
359
410
  //#endregion
@@ -413,7 +464,8 @@ var ExecutionEngine = class {
413
464
  this.server = await startServer({
414
465
  pythonEnv: this.config.pythonEnv,
415
466
  workingDirectory: this.config.workingDirectory,
416
- port: this.config.serverPort
467
+ port: this.config.serverPort,
468
+ env: this.config.env
417
469
  });
418
470
  try {
419
471
  this.kernel = new KernelClient();
@@ -580,4 +632,4 @@ var ExecutionEngine = class {
580
632
  };
581
633
 
582
634
  //#endregion
583
- export { ExecutionEngine, KernelClient, detectDefaultPython, executableBlockTypeSet, executableBlockTypes, resolvePythonExecutable, startServer, stopServer };
635
+ export { ExecutionEngine, KernelClient, buildPythonEnv, detectDefaultPython, executableBlockTypeSet, executableBlockTypes, resolvePythonExecutable, startServer, stopServer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepnote/runtime-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Core runtime for executing Deepnote projects",
5
5
  "keywords": [
6
6
  "deepnote",
@@ -35,7 +35,7 @@
35
35
  "@jupyterlab/services": "^7.3.2",
36
36
  "tcp-port-used": "^1.0.2",
37
37
  "ws": "^8.18.0",
38
- "@deepnote/blocks": "4.0.0"
38
+ "@deepnote/blocks": "4.3.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/tcp-port-used": "^1.0.4",