Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1de5d35
DH-21221_remote-python-controller-imports/task-01: Create PythonContr…
bmingles Mar 16, 2026
c977f1e
DH-21221_remote-python-controller-imports/task-02: Create comprehensi…
bmingles Mar 16, 2026
3d63acc
DH-21221_remote-python-controller-imports/task-03: Integrate PythonCo…
bmingles Mar 16, 2026
4b817bb
DH-21221_remote-python-controller-imports/task-05: Add unit tests for…
bmingles Mar 16, 2026
e6b9fb9
DH-21221_remote-python-controller-imports/task-06: Update user docume…
bmingles Mar 16, 2026
71bf735
DH-21221_remote-python-controller-imports/task-07: Verify end-to-end …
bmingles Mar 16, 2026
93523d0
Cleaned up python file change logic (#DH-21221)
bmingles Mar 18, 2026
67b1803
Fixed some issues with python file scanner (#DH-21221)
bmingles Mar 20, 2026
4cac7b0
Simplified parsing methodology (#DH-21221)
bmingles Apr 8, 2026
9b8a6a9
Reverted some earlier changes (#DH-21221)
bmingles Apr 8, 2026
cbf8d04
Simplified prefix code + tests (#DH-21221)
bmingles Apr 17, 2026
5761f06
Handle prefixes when sourcing (#DH-21221)
bmingles Apr 9, 2026
7f97553
Fixed a race condition bug caused by non-awaited setPythonServerExecu…
bmingles Apr 9, 2026
b824725
Evict controller prefixed modules from cache (#DH-21221)
bmingles Apr 17, 2026
f21e2dc
Updated comment (#DH-21221)
bmingles Apr 17, 2026
f4d8051
import prefix override
bmingles Apr 27, 2026
55f95a7
revised docs (#DH-21221)
bmingles Apr 29, 2026
76dad39
validate import prefix (#DH-21221)
bmingles Apr 29, 2026
73e9310
support multiple prefixes (#DH-21221)
bmingles Apr 30, 2026
4145340
Fixed a bug impacting prefix config changes (#DH-21221)
bmingles Apr 30, 2026
ff83f25
Updated docs (#DH-21221)
bmingles Apr 30, 2026
b9bf392
Cleaned up test (#DH-21221)
bmingles Apr 30, 2026
d4a5e61
docs:format (#DH-21221)
bmingles May 5, 2026
1efd1de
Unit tests (#DH-21221)
bmingles May 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions __mocks__/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,18 +129,28 @@ export class DiagnosticCollection
}

export class EventEmitter<T> {
listeners = new Set<(...args: any[]) => void>();
event = (listener: (e: T) => any) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
private listeners = new Set<(data: T) => any>();

event = vi
.fn()
.mockName('event')
.mockImplementation((listener: (e: T) => any) => {
this.listeners.add(listener);
return {
dispose: vi
.fn()
.mockName('dispose')
.mockImplementation(() => {
this.listeners.delete(listener);
}),
};
});

fire = vi
.fn()
.mockName('fire')
.mockImplementation((event: T) => {
this.listeners.forEach(listener => listener(event));
.mockImplementation((data: T): void => {
this.listeners.forEach(listener => listener(data));
});
}

Expand Down Expand Up @@ -354,7 +364,10 @@ export class Uri {
static joinPath = vi
.fn()
.mockName('joinPath')
.mockImplementation((...args) => Uri.parse(args.join('/')));
.mockImplementation((...args) => {
const filteredArgs = args.filter(a => a.toString().length > 0);
return Uri.parse(filteredArgs.join('/'));
});

static parse = vi
.fn()
Expand Down
71 changes: 71 additions & 0 deletions docs/python-remote-file-sourcing.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,74 @@ def dashboard_content(table):
2. In your VS Code workspace, use the Deephaven extension to run `main.py`. The imports will resolve because the `stock_ticker` folder is registered as a remote file source.

![Run main.py](assets/run-main-py.gif)

## Controller Import Prefix Support (Enterprise)

**Deephaven Enterprise** uses a controller import registration mechanism (`meta_import()`) that requires modules to be importable under a prefixed name (e.g., `controller.mymodule`) for the server to find them. The VS Code extension automatically detects this pattern in your Python code and registers both the unprefixed and prefixed names with the server for each folder marked as a remote file source, allowing prefixed imports to be sourced by the extension.

### Auto-Detection

When you run Python code, the extension scans for `meta_import()` calls and infers the prefix to use. No extra setup is required — if your code already calls `meta_import()`, the extension picks it up automatically.

**With default prefix (`controller`):**

```python
import deephaven_enterprise.controller_import

deephaven_enterprise.controller_import.meta_import()
```

**With a custom prefix:**

```python
import deephaven_enterprise.controller_import

deephaven_enterprise.controller_import.meta_import("myprefix")
```

**From-import style:**

```python
from deephaven_enterprise.controller_import import meta_import

meta_import("custom")
```

### Behavior

- For each folder marked as a remote file source, both the unprefixed and prefixed module names are registered with the Deephaven server.
- Example: If you mark a folder called `mymodule` and a prefix of `controller` is detected, the server will receive both `mymodule` and `controller.mymodule` as importable names for that folder.
- Without a detected or configured prefix, only the unprefixed name (`mymodule`) is registered for each marked folder.
- Prefixes are **updated when you run Python code**: running a full file replaces any previous prefixes; running a snippet updates prefixes only if a `meta_import()` call is found in that snippet.

### Manual Override

You can set one or more prefixes explicitly in your VS Code settings:

```json
"deephaven.importPrefixes": ["controller"]
```

When set, this array is used as the source of truth and auto-detection is skipped entirely. Multiple prefixes can be provided if needed:

```json
"deephaven.importPrefixes": ["controller", "custom"]
```

The main reasons to set this manually are:

- **Unrecognized imports** — the auto-detection doesn't recognize all possible import patterns such as aliasing:

```python
import deephaven_enterprise.controller_import as ci

ci.meta_import()

from deephaven_enterprise.controller_import import meta_import as m

m()
```

- **`meta_import()` in a dependency** — auto-detection only scans the code being run directly, not modules it imports. If the registration happens inside a dependency rather than the script itself, the prefix will not be detected.

If either case applies, set `deephaven.importPrefixes` manually.
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,17 @@
},
"default": []
},
"deephaven.importPrefixes": {
"type": [
"array",
"null"
],
"default": null,
"items": {
"type": "string"
},
"markdownDescription": "Optional controller import prefixes for remote file sourcing. When set, overrides automatic detection from `meta_import()` calls in Python code. Defaults to automatic detection if not specified."
},
"deephaven.mcp.enabled": {
"type": "boolean",
"default": false,
Expand Down
1 change: 1 addition & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const CONFIG_KEY = {
root: 'deephaven',
coreServers: 'coreServers',
enterpriseServers: 'enterpriseServers',
importPrefixes: 'importPrefixes',
mcpAutoUpdateConfig: 'mcp.autoUpdateConfig',
mcpDocsEnabled: 'mcp.docsEnabled',
mcpEnabled: 'mcp.enabled',
Expand Down
36 changes: 36 additions & 0 deletions src/services/ConfigService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,3 +353,39 @@ describe('updateWindsurfMcpConfig', () => {
expect(getEnsuredContent).toHaveBeenCalledWith(windowsConfigUri, '{}\n');
});
});

describe('getImportPrefixes', () => {
it.each([
['not configured', undefined, undefined],
['empty array', [], []],
['single valid prefix', ['controller'], ['controller']],
['multiple valid prefixes', ['controller', '_my_prefix'], ['controller', '_my_prefix']],
])('should return without errors when %s', (_label, given, expected) => {
configMap.set(CONFIG_KEY.importPrefixes, given);

const result = ConfigService.getImportPrefixes();

expect(result).toEqual(expected);
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled();
});

it.each([
['one invalid entry (empty string)', [''], [], ['']],
['one invalid entry (starts with digit)', ['1controller'], [], ['1controller']],
['one invalid entry (contains hyphen)', ['my-prefix'], [], ['my-prefix']],
['one invalid entry (dotted name)', ['my.prefix'], [], ['my.prefix']],
['mixed valid and invalid entries', ['valid', '1bad', 'also_valid', 'my-prefix'], ['valid', 'also_valid'], ['1bad', 'my-prefix']],
])('should filter invalid entries and show errors when %s', (_label, given, expectedResult, expectedInvalid) => {
configMap.set(CONFIG_KEY.importPrefixes, given);

const result = ConfigService.getImportPrefixes();

expect(result).toEqual(expectedResult);
expect(vscode.window.showErrorMessage).toHaveBeenCalledTimes(expectedInvalid.length);
for (const invalid of expectedInvalid) {
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
expect.stringContaining(`'${invalid}' is not a valid import prefix name`)
);
}
});
});
23 changes: 23 additions & 0 deletions src/services/ConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ function hasValidURL({ url }: { url: string }): boolean {
}
}

// ASCII subset of Python identifier rules (PEP 3131 / py3 lexical spec allows Unicode,
// but controller prefix names are expected to be ASCII in practice). A prefix is a
// single identifier, not a dotted module path.
const VALID_PYTHON_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;

function getImportPrefixes(): string[] | undefined {
const config = getConfig().get<string[] | null>(CONFIG_KEY.importPrefixes);
if (config == null) {
return undefined;
}

return config.filter(prefix => {
if (VALID_PYTHON_IDENTIFIER_RE.test(prefix)) {
return true;
}
vscode.window.showErrorMessage(
`Invalid 'deephaven.importPrefixes' setting: '${prefix}' is not a valid import prefix name. It will be ignored.`
);
return false;
});
}

async function toggleMcp(enable?: boolean): Promise<void> {
const currentState = isMcpEnabled();
const targetState = enable ?? !currentState;
Expand Down Expand Up @@ -233,6 +255,7 @@ export async function updateWindsurfMcpConfig(
export const ConfigService: IConfigService = {
getCoreServers,
getEnterpriseServers,
getImportPrefixes,
isElectronFetchEnabled,
isMcpDocsEnabled,
isMcpEnabled,
Expand Down
152 changes: 152 additions & 0 deletions src/services/DhcService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import * as vscode from 'vscode';
import type { dh as DhcType } from '@deephaven/jsapi-types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DhcService } from './DhcService';
import { ConfigService } from './ConfigService';
import type { RemoteFileSourceService } from './RemoteFileSourceService';
import type { UniqueID } from '../types';

vi.mock('vscode');

vi.mock('./ConfigService', () => {
const mockConfigService = {
getImportPrefixes: vi.fn(),
};
// eslint-disable-next-line @typescript-eslint/naming-convention
return { ConfigService: mockConfigService };
});

/** Build a minimal DhcService instance that has a session already set up. */
function createTestDhcService({
pythonRemoteFileSourcePlugin,
setControllerImportPrefixes = vi.fn(),
setPythonServerExecutionContext = vi.fn().mockResolvedValue(undefined),
sessionRunCode = vi.fn().mockResolvedValue({
error: '',
changes: { created: [], updated: [], removed: [] },
}),
}: {
pythonRemoteFileSourcePlugin?: DhcType.Widget | null;
setControllerImportPrefixes?: ReturnType<typeof vi.fn>;
setPythonServerExecutionContext?: ReturnType<typeof vi.fn>;
sessionRunCode?: ReturnType<typeof vi.fn>;
} = {}): DhcService {
const mockSession = {
runCode: sessionRunCode,
} as unknown as DhcType.IdeSession;

const mockCn = {
getConsoleTypes: vi.fn().mockResolvedValue(['python']),
} as unknown as DhcType.IdeConnection;

const mockRemoteFileSourceService = {
setControllerImportPrefixes,
setPythonServerExecutionContext,
} as unknown as RemoteFileSourceService;

const mockDiagnosticsCollection = {
set: vi.fn(),
clear: vi.fn(),
} as unknown as vscode.DiagnosticCollection;

const service = Object.assign(Object.create(DhcService.prototype), {
session: mockSession,
cn: mockCn,
cnId: 'test-cn-id' as UniqueID,
pythonRemoteFileSourcePlugin:
pythonRemoteFileSourcePlugin !== undefined
? pythonRemoteFileSourcePlugin
: ({} as DhcType.Widget),
groovyRemoteFileSourcePluginService: null,
remoteFileSourceService: mockRemoteFileSourceService,
diagnosticsCollection: mockDiagnosticsCollection,
groovyDiagnosticsCollection: mockDiagnosticsCollection,
outputChannel: {
appendLine: vi.fn(),
show: vi.fn(),
} as unknown as vscode.OutputChannel,
toaster: { error: vi.fn(), info: vi.fn() },
_isRunningCode: false,
_onDidChangeRunningCodeStatus: { fire: vi.fn() },
disposables: { add: vi.fn() },
serverUrl: new URL('http://localhost:10000/'),
});

return service;
}

function mockTextDoc(codeText: string): vscode.TextDocument {
return {
uri: vscode.Uri.file('/path/to/file.py'),
getText: vi.fn().mockReturnValue(codeText),
} as unknown as vscode.TextDocument;
}

describe('DhcService.runCode – importPrefixes setting', () => {
const mockSetControllerImportPrefixes = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
});

it.each([
{
label: 'uses setting prefixes when configured',
configPrefixes: ['myPrefix'],
input: 'x = 1',
expected: new Set(['myPrefix']),
},
{
label: 'uses all prefixes when multiple configured',
configPrefixes: ['prefix1', 'prefix2'],
input: 'x = 1',
expected: new Set(['prefix1', 'prefix2']),
},
{
label: 'falls back to extraction when undefined and code has prefixes',
configPrefixes: undefined,
input: 'deephaven_enterprise.controller_import.meta_import("myPrefix")\n',
expected: new Set(['myPrefix']),
},
{
label: 'skips call when undefined and no prefixes in snippet',
configPrefixes: undefined,
input: 'x = 1',
expected: null,
},
{
label: 'calls with empty set for full file runs even when no prefixes found',
configPrefixes: undefined,
input: mockTextDoc('x = 1'),
expected: new Set(),
},
{
label: 'setting takes precedence over meta_import in code',
configPrefixes: ['forced'],
input: 'deephaven_enterprise.controller_import.meta_import("otherPrefix")\n',
expected: new Set(['forced']),
},
{
label: 'setting takes precedence over meta_import in text doc',
configPrefixes: ['forced'],
input: mockTextDoc(
'deephaven_enterprise.controller_import.meta_import("otherPrefix")\n'
),
expected: new Set(['forced']),
},
])('$label', async ({ configPrefixes, input, expected }) => {
vi.mocked(ConfigService.getImportPrefixes).mockReturnValue(configPrefixes);

const service = createTestDhcService({
setControllerImportPrefixes: mockSetControllerImportPrefixes,
});

await service.runCode(input, 'python');

if (expected == null) {
expect(mockSetControllerImportPrefixes).not.toHaveBeenCalled();
} else {
expect(mockSetControllerImportPrefixes).toHaveBeenCalledWith(expected);
}
});
});
Loading
Loading