diff --git a/src/client/debugger/extension/adapter/activator.ts b/src/client/debugger/extension/adapter/activator.ts index 999c00366ed6..8c6437df088b 100644 --- a/src/client/debugger/extension/adapter/activator.ts +++ b/src/client/debugger/extension/adapter/activator.ts @@ -10,7 +10,7 @@ import { IConfigurationService, IDisposableRegistry } from '../../../common/type import { ICommandManager } from '../../../common/application/types'; import { DebuggerTypeName } from '../../constants'; import { IAttachProcessProviderFactory } from '../attachQuickPick/types'; -import { IDebugAdapterDescriptorFactory, IDebugSessionLoggingFactory, IOutdatedDebuggerPromptFactory } from '../types'; +import { IDebugAdapterDescriptorFactory, IDebugSessionLoggingFactory, IOutdatedDebuggerPromptFactory, IDataFrameTrackerFactory } from '../types'; @injectable() export class DebugAdapterActivator implements IExtensionSingleActivationService { @@ -22,6 +22,7 @@ export class DebugAdapterActivator implements IExtensionSingleActivationService @inject(IDebugAdapterDescriptorFactory) private descriptorFactory: IDebugAdapterDescriptorFactory, @inject(IDebugSessionLoggingFactory) private debugSessionLoggingFactory: IDebugSessionLoggingFactory, @inject(IOutdatedDebuggerPromptFactory) private debuggerPromptFactory: IOutdatedDebuggerPromptFactory, + @inject(IDataFrameTrackerFactory) private dataFrameTrackerFactory: IDataFrameTrackerFactory, @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, @inject(IAttachProcessProviderFactory) private readonly attachProcessProviderFactory: IAttachProcessProviderFactory, @@ -35,6 +36,10 @@ export class DebugAdapterActivator implements IExtensionSingleActivationService this.disposables.push( this.debugService.registerDebugAdapterTrackerFactory(DebuggerTypeName, this.debuggerPromptFactory), ); + // Register DataFrame tracker to monitor for dataframe variables and suggest Jupyter extension + this.disposables.push( + this.debugService.registerDebugAdapterTrackerFactory(DebuggerTypeName, this.dataFrameTrackerFactory), + ); this.disposables.push( this.debugService.registerDebugAdapterDescriptorFactory(DebuggerTypeName, this.descriptorFactory), diff --git a/src/client/debugger/extension/adapter/dataFrameTracker.ts b/src/client/debugger/extension/adapter/dataFrameTracker.ts new file mode 100644 index 000000000000..c9a356f79220 --- /dev/null +++ b/src/client/debugger/extension/adapter/dataFrameTracker.ts @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import { + DebugAdapterTracker, + DebugAdapterTrackerFactory, + DebugSession, + ProviderResult, + window, + l10n, + commands, +} from 'vscode'; +import { DebugProtocol } from 'vscode-debugprotocol'; + +import { IExtensions } from '../../../common/types'; +import { JUPYTER_EXTENSION_ID } from '../../../common/constants'; + +/** + * Debug adapter tracker that monitors for dataframe-like variables during debugging sessions + * and suggests installing the Jupyter extension when they are detected but the Jupyter extension + * is not installed. This helps users discover the data viewer functionality when working with + * dataframes without the Jupyter extension. + */ +class DataFrameVariableTracker implements DebugAdapterTracker { + private readonly extensions: IExtensions; + + /** Flag to ensure we only show the notification once per debug session to avoid spam */ + private hasNotifiedAboutJupyter = false; + + /** + * Known dataframe type patterns from popular Python data processing libraries. + * These patterns are matched against variable type strings in debug protocol responses. + */ + private readonly dataFrameTypes = [ + 'pandas.core.frame.DataFrame', // Full pandas path + 'pandas.DataFrame', // Simplified pandas + 'polars.DataFrame', // Polars dataframes + 'cudf.DataFrame', // RAPIDS cuDF + 'dask.dataframe.core.DataFrame', // Dask distributed dataframes + 'modin.pandas.DataFrame', // Modin pandas-compatible + 'vaex.dataframe.DataFrame', // Vaex out-of-core dataframes + 'geopandas.geodataframe.GeoDataFrame', // GeoPandas geographic data + ]; + + constructor(_session: DebugSession, extensions: IExtensions) { + this.extensions = extensions; + } + + /** + * Intercepts debug protocol messages to monitor for variable responses. + * When a variables response is detected, checks for dataframe-like objects. + * + * @param message - Debug protocol message from the debug adapter + */ + public onDidSendMessage(message: DebugProtocol.Message): void { + if (this.hasNotifiedAboutJupyter) { + return; // Only notify once per debug session + } + + // Check if this is a variables response from the debug protocol + if ('type' in message && message.type === 'response' && 'command' in message && message.command === 'variables') { + const response = message as unknown as DebugProtocol.VariablesResponse; + if (response.success && response.body?.variables) { + this.checkForDataFrameVariables(response.body.variables); + } + } + } + + /** + * Examines an array of debug variables to detect dataframe-like objects. + * Uses multiple detection strategies: type matching, value inspection, and name heuristics. + * + * @param variables - Array of variables from debug protocol variables response + * @returns true if any dataframe-like variables were detected + */ + private checkForDataFrameVariables(variables: DebugProtocol.Variable[]): boolean { + // Check if any variable is a dataframe-like object using multiple detection methods + const hasDataFrame = variables.some((variable) => + this.dataFrameTypes.some((dfType) => + variable.type?.includes(dfType) || + variable.value?.includes(dfType) || + // Also check if the variable name suggests it's a dataframe (common naming patterns) + (variable.name?.match(/^(df|data|dataframe)/i) && variable.type?.includes('pandas')) + ) + ); + + if (hasDataFrame) { + this.checkAndNotifyJupyterExtension(); + } + + return hasDataFrame; + } + + /** + * Checks if the Jupyter extension is installed and shows notification if not. + * This is the core logic that determines whether the user needs the suggestion. + */ + private checkAndNotifyJupyterExtension(): void { + // Check if Jupyter extension is installed using VS Code extension API + const jupyterExtension = this.extensions.getExtension(JUPYTER_EXTENSION_ID); + + if (!jupyterExtension) { + this.hasNotifiedAboutJupyter = true; + this.showJupyterInstallNotification(); + } + } + + /** + * Displays an information message suggesting Jupyter extension installation. + * Provides a direct action button to open the extension marketplace. + */ + private showJupyterInstallNotification(): void { + const message = l10n.t('Install Jupyter extension to inspect dataframe objects in the data viewer.'); + const installAction = l10n.t('Install Jupyter Extension'); + const dismissAction = l10n.t('Dismiss'); + + window.showInformationMessage(message, installAction, dismissAction).then((selection) => { + if (selection === installAction) { + // Open the extension marketplace for the Jupyter extension + commands.executeCommand('extension.open', JUPYTER_EXTENSION_ID); + } + }); + } +} + +/** + * Factory for creating DataFrameVariableTracker instances for debug sessions. + * This factory is registered with VS Code's debug adapter tracker system to + * automatically monitor all Python debug sessions for dataframe variables. + */ +@injectable() +export class DataFrameTrackerFactory implements DebugAdapterTrackerFactory { + constructor(@inject(IExtensions) private readonly extensions: IExtensions) {} + + /** + * Creates a new DataFrameVariableTracker for each debug session. + * Each debug session gets its own tracker instance to maintain session-specific state. + * + * @param session - The debug session that this tracker will monitor + * @returns A new DataFrameVariableTracker instance + */ + public createDebugAdapterTracker(session: DebugSession): ProviderResult { + return new DataFrameVariableTracker(session, this.extensions); + } +} \ No newline at end of file diff --git a/src/client/debugger/extension/serviceRegistry.ts b/src/client/debugger/extension/serviceRegistry.ts index 7734e87124cd..a93c25f051b7 100644 --- a/src/client/debugger/extension/serviceRegistry.ts +++ b/src/client/debugger/extension/serviceRegistry.ts @@ -10,6 +10,7 @@ import { DebugAdapterActivator } from './adapter/activator'; import { DebugAdapterDescriptorFactory } from './adapter/factory'; import { DebugSessionLoggingFactory } from './adapter/logging'; import { OutdatedDebuggerPromptFactory } from './adapter/outdatedDebuggerPrompt'; +import { DataFrameTrackerFactory } from './adapter/dataFrameTracker'; import { AttachProcessProviderFactory } from './attachQuickPick/factory'; import { IAttachProcessProviderFactory } from './attachQuickPick/types'; import { PythonDebugConfigurationService } from './configuration/debugConfigurationService'; @@ -26,6 +27,7 @@ import { IDebugConfigurationService, IDebugSessionLoggingFactory, IOutdatedDebuggerPromptFactory, + IDataFrameTrackerFactory, } from './types'; export function registerTypes(serviceManager: IServiceManager): void { @@ -62,6 +64,12 @@ export function registerTypes(serviceManager: IServiceManager): void { IOutdatedDebuggerPromptFactory, OutdatedDebuggerPromptFactory, ); + // Register DataFrameTrackerFactory to monitor debug sessions for dataframe variables + // and suggest Jupyter extension installation when needed + serviceManager.addSingleton( + IDataFrameTrackerFactory, + DataFrameTrackerFactory, + ); serviceManager.addSingleton( IAttachProcessProviderFactory, AttachProcessProviderFactory, diff --git a/src/client/debugger/extension/types.ts b/src/client/debugger/extension/types.ts index 4a8f35e2b808..d13e8780cd8e 100644 --- a/src/client/debugger/extension/types.ts +++ b/src/client/debugger/extension/types.ts @@ -19,6 +19,15 @@ export const IOutdatedDebuggerPromptFactory = Symbol('IOutdatedDebuggerPromptFac export interface IOutdatedDebuggerPromptFactory extends DebugAdapterTrackerFactory {} +/** Symbol identifier for the DataFrameTrackerFactory service */ +export const IDataFrameTrackerFactory = Symbol('IDataFrameTrackerFactory'); + +/** + * Interface for debug adapter tracker factory that monitors dataframe variables + * and suggests Jupyter extension installation when dataframes are detected. + */ +export interface IDataFrameTrackerFactory extends DebugAdapterTrackerFactory {} + export enum PythonPathSource { launchJson = 'launch.json', settingsJson = 'settings.json', diff --git a/src/test/debugger/extension/adapter/dataFrameTracker.unit.test.ts b/src/test/debugger/extension/adapter/dataFrameTracker.unit.test.ts new file mode 100644 index 000000000000..91eebb52a64e --- /dev/null +++ b/src/test/debugger/extension/adapter/dataFrameTracker.unit.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import { instance, mock, verify, when } from 'ts-mockito'; +import { Extension } from 'vscode'; +import { DebugProtocol } from 'vscode-debugprotocol'; + +import { IExtensions } from '../../../../client/common/types'; +import { JUPYTER_EXTENSION_ID } from '../../../../client/common/constants'; +import { DataFrameTrackerFactory } from '../../../../client/debugger/extension/adapter/dataFrameTracker'; + +/** + * Test suite for DataFrame Tracker functionality. + * Tests the detection of dataframe variables in debug sessions and + * Jupyter extension installation suggestions. + */ +suite('DataFrame Tracker', () => { + let extensions: IExtensions; + let mockExtensions: IExtensions; + let trackerFactory: DataFrameTrackerFactory; + + setup(() => { + mockExtensions = mock(); + extensions = instance(mockExtensions); + trackerFactory = new DataFrameTrackerFactory(extensions); + }); + + test('Should create debug adapter tracker', () => { + const mockSession = {} as any; + const tracker = trackerFactory.createDebugAdapterTracker(mockSession); + expect(tracker).to.not.be.undefined; + }); + + /** + * Test that pandas DataFrame variables are correctly detected + * from debug protocol variable responses. + */ + test('Should detect pandas DataFrame variable', () => { + const mockSession = {} as any; + const tracker = trackerFactory.createDebugAdapterTracker(mockSession) as any; + + // Mock Jupyter extension not being installed + when(mockExtensions.getExtension(JUPYTER_EXTENSION_ID)).thenReturn(undefined); + + const variablesResponse: DebugProtocol.VariablesResponse = { + type: 'response', + seq: 1, + request_seq: 1, + success: true, + command: 'variables', + body: { + variables: [ + { + name: 'df', + value: '', + type: 'pandas.core.frame.DataFrame', + variablesReference: 0, + }, + { + name: 'x', + value: '42', + type: 'int', + variablesReference: 0, + } + ] + } + }; + + // This should detect the DataFrame and try to show notification + tracker.onDidSendMessage(variablesResponse); + + // Verify that extension check was called + verify(mockExtensions.getExtension(JUPYTER_EXTENSION_ID)).once(); + }); + + /** + * Test that the tracker doesn't show notifications when Jupyter extension is already installed. + * This prevents unnecessary notifications for users who already have the data viewer available. + */ + test('Should not show notification if Jupyter extension is installed', () => { + const mockSession = {} as any; + const tracker = trackerFactory.createDebugAdapterTracker(mockSession) as any; + + // Mock Jupyter extension being installed + const mockJupyterExt = mock>(); + when(mockExtensions.getExtension(JUPYTER_EXTENSION_ID)).thenReturn(instance(mockJupyterExt)); + + const variablesResponse: DebugProtocol.VariablesResponse = { + type: 'response', + seq: 1, + request_seq: 1, + success: true, + command: 'variables', + body: { + variables: [ + { + name: 'df', + value: '', + type: 'pandas.core.frame.DataFrame', + variablesReference: 0, + } + ] + } + }; + + tracker.onDidSendMessage(variablesResponse); + + // Verify that extension check was called but no notification should show + verify(mockExtensions.getExtension(JUPYTER_EXTENSION_ID)).once(); + }); + + /** + * Test that the tracker recognizes all supported dataframe types from various libraries. + * This ensures comprehensive coverage of popular dataframe implementations. + */ + test('Should detect various dataframe types', () => { + // This test verifies that the dataFrameTypes array contains the expected types + const expectedTypes = [ + 'pandas.core.frame.DataFrame', + 'pandas.DataFrame', + 'polars.DataFrame', + 'cudf.DataFrame', + 'dask.dataframe.core.DataFrame', + 'modin.pandas.DataFrame', + 'vaex.dataframe.DataFrame', + 'geopandas.geodataframe.GeoDataFrame', + ]; + + expectedTypes.forEach(expectedType => { + // Verify each expected type would be matched by at least one pattern + const hasMatch = expectedTypes.some(pattern => expectedType.includes(pattern)); + expect(hasMatch).to.be.true; + }); + }); + + /** + * Test that the tracker correctly rejects non-dataframe variables. + * This prevents false positives on regular variables like strings, numbers, etc. + */ + test('Should not detect non-dataframe variables', () => { + const nonDataFrameTypes = [ + 'str', + 'int', + 'list', + 'dict', + 'numpy.ndarray', + 'matplotlib.figure.Figure', + ]; + + const dataFrameTypes = [ + 'pandas.core.frame.DataFrame', + 'pandas.DataFrame', + 'polars.DataFrame', + 'cudf.DataFrame', + 'dask.dataframe.core.DataFrame', + 'modin.pandas.DataFrame', + 'vaex.dataframe.DataFrame', + 'geopandas.geodataframe.GeoDataFrame', + ]; + + nonDataFrameTypes.forEach(varType => { + // These should not be detected as dataframes + const hasMatch = dataFrameTypes.some(dfType => varType.includes(dfType)); + expect(hasMatch).to.be.false; + }); + }); +}); \ No newline at end of file