diff --git a/package.json b/package.json index 5d4c3dc4d0bb..f5d5394d3b59 100644 --- a/package.json +++ b/package.json @@ -384,6 +384,11 @@ "category": "Python", "command": "python.installJupyter", "title": "%python.command.python.installJupyter.title%" + }, + { + "category": "Python", + "command": "python.copyImportPath", + "title": "%python.command.python.copyImportPath.title%" } ], "configuration": { @@ -1133,6 +1138,11 @@ } ], "keybindings": [ + { + "command": "python.copyImportPath", + "key": "ctrl+alt+shift+i", + "when": "editorTextFocus && resourceLangId == python" + }, { "command": "python.execSelectionInTerminal", "key": "shift+enter", @@ -1413,6 +1423,13 @@ "when": "resourceLangId == python && !isInDiffEditor && !virtualWorkspace && shellExecutionSupported" } ], + "editor/title/context": [ + { + "command": "python.copyImportPath", + "group": "1_cutcopypaste@1060", + "when": "resourceLangId == python" + } + ], "explorer/context": [ { "command": "python.execInTerminal", diff --git a/package.nls.json b/package.nls.json index b6ba75b332f2..c1ed9001a284 100644 --- a/package.nls.json +++ b/package.nls.json @@ -27,6 +27,7 @@ "python.command.python.analysis.restartLanguageServer.title": "Restart Language Server", "python.command.python.launchTensorBoard.title": "Launch TensorBoard", "python.command.python.refreshTensorBoard.title": "Refresh TensorBoard", + "python.command.python.copyImportPath.title": "Copy Import Path", "python.createEnvironment.contentButton.description": "Show or hide Create Environment button in the editor for `requirements.txt` or other dependency files.", "python.createEnvironment.trigger.description": "Detect if environment creation is required for the current project", "python.menu.createNewFile.title": "Python File", @@ -91,7 +92,7 @@ "python.venvPath.description": "Path to folder with a list of Virtual Environments (e.g. ~/.pyenv, ~/Envs, ~/.virtualenvs).", "walkthrough.pythonWelcome.title": "Get Started with Python Development", "walkthrough.pythonWelcome.description": "Your first steps to set up a Python project with all the powerful tools and features that the Python extension has to offer!", - "walkthrough.step.python.createPythonFile.title": "Create a Python file", + "walkthrough.step.python.createPythonFile.title": "Create a Python file", "walkthrough.step.python.createPythonFolder.title": "Open a Python project folder", "walkthrough.step.python.createPythonFile.description": { "message": "[Open](command:toSide:workbench.action.files.openFile) or [create](command:toSide:workbench.action.files.newUntitledFile?%7B%22languageId%22%3A%22python%22%7D) a Python file - make sure to save it as \".py\".\n[Create Python File](command:toSide:workbench.action.files.newUntitledFile?%7B%22languageId%22%3A%22python%22%7D)", @@ -103,7 +104,7 @@ }, "walkthrough.step.python.createPythonFolder.description": { "message": "[Open](command:workbench.action.files.openFolder) or create a project folder.\n[Open Project Folder](command:workbench.action.files.openFolder)", - "comment": [ + "comment": [ "{Locked='](command:workbench.action.files.openFolder'}", "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" @@ -133,7 +134,7 @@ "walkthrough.step.python.createEnvironment.title": "Select or create a Python environment", "walkthrough.step.python.createEnvironment.description": { "message": "Create an environment for your Python project or use [Select Python Interpreter](command:python.setInterpreter) to select an existing one.\n[Create Environment](command:python.createEnvironment)\n**Tip**: Run the ``Python: Create Environment`` command in the [Command Palette](command:workbench.action.showCommands).", - "comment": [ + "comment": [ "{Locked='](command:python.createEnvironment'}", "{Locked='](command:workbench.action.showCommands'}", "{Locked='](command:python.setInterpreter'}", @@ -145,8 +146,8 @@ "walkthrough.step.python.runAndDebug.description": "Open your Python file and click on the play button on the top right of the editor, or press F5 when on the file and select \"Python File\" to run with the debugger. \n \n[Learn more](https://code.visualstudio.com/docs/python/python-tutorial#_run-hello-world)", "walkthrough.step.python.learnMoreWithDS.title": "Keep exploring!", "walkthrough.step.python.learnMoreWithDS.description": { - "message":"🎨 Explore all the features the Python extension has to offer by looking for \"Python\" in the [Command Palette](command:workbench.action.showCommands). \n 📈 Learn more about getting started with [data science](command:workbench.action.openWalkthrough?%7B%22category%22%3A%22ms-python.python%23pythonDataScienceWelcome%22%2C%22step%22%3A%22ms-python.python%23python.createNewNotebook%22%7D) in Python. \n ✨ Take a look at our [Release Notes](https://aka.ms/AA8dxtb) to learn more about the latest features. \n \n[Follow along with the Python Tutorial](https://aka.ms/AA8dqti)", - "comment":[ + "message": "🎨 Explore all the features the Python extension has to offer by looking for \"Python\" in the [Command Palette](command:workbench.action.showCommands). \n 📈 Learn more about getting started with [data science](command:workbench.action.openWalkthrough?%7B%22category%22%3A%22ms-python.python%23pythonDataScienceWelcome%22%2C%22step%22%3A%22ms-python.python%23python.createNewNotebook%22%7D) in Python. \n ✨ Take a look at our [Release Notes](https://aka.ms/AA8dxtb) to learn more about the latest features. \n \n[Follow along with the Python Tutorial](https://aka.ms/AA8dqti)", + "comment": [ "{Locked='](command:workbench.action.showCommands'}", "{Locked='](command:workbench.action.openWalkthrough?%7B%22category%22%3A%22ms-python.python%23pythonDataScienceWelcome%22%2C%22step%22%3A%22ms-python.python%23python.createNewNotebook%22%7D'}", "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", diff --git a/src/client/application/importPath/copyImportPathCommand.ts b/src/client/application/importPath/copyImportPathCommand.ts new file mode 100644 index 000000000000..825bd81205f5 --- /dev/null +++ b/src/client/application/importPath/copyImportPathCommand.ts @@ -0,0 +1,101 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { inject, injectable } from 'inversify'; + +import { IClipboard, ICommandManager, IWorkspaceService } from '../../common/application/types'; +import { IExtensionSingleActivationService } from '../../activation/types'; +import { Commands } from '../../common/constants'; +import { getSysPath } from '../../common/utils/pythonUtils'; +import { IInterpreterPathService } from '../../common/types'; +import { sendTelemetryEvent } from '../../telemetry'; +import { EventName } from '../../telemetry/constants'; + +@injectable() +export class CopyImportPathCommand implements IExtensionSingleActivationService { + public readonly supportedWorkspaceTypes = { untrustedWorkspace: true, virtualWorkspace: true }; + + constructor( + @inject(ICommandManager) private readonly commands: ICommandManager, + @inject(IWorkspaceService) private readonly workspace: IWorkspaceService, + @inject(IClipboard) private readonly clipboard: IClipboard, + @inject(IInterpreterPathService) private readonly interpreterPathService: IInterpreterPathService, + ) {} + + async activate(): Promise { + this.commands.registerCommand(Commands.CopyImportPath, this.execute, this); + } + + private async execute(fileUri?: vscode.Uri): Promise { + const trigger = fileUri ? 'api' : vscode.window.activeTextEditor ? 'contextMenu' : 'palette'; + let outcome: 'success' | 'noFile' | 'notPy' | 'error' = 'success'; + let strategy: 'sysPath' | 'workspace' | 'fallback' | undefined = undefined; + let exObj: Error | undefined = undefined; + + try { + const uri = fileUri ?? vscode.window.activeTextEditor?.document.uri; + if (!uri) { + outcome = 'noFile'; + return; + } + if (!uri.fsPath.endsWith('.py')) { + outcome = 'notPy'; + return; + } + const resource = uri ?? this.workspace.workspaceFolders?.[0]?.uri; + const pythonPath = this.interpreterPathService.get(resource); + const [importPath, strat] = this.resolveImportPath(uri.fsPath, pythonPath); + strategy = strat; + await this.clipboard.writeText(importPath); + void vscode.window.showInformationMessage(`Copied: ${importPath}`); + } catch (ex) { + outcome = 'error'; + exObj = ex as Error; + } finally { + sendTelemetryEvent( + EventName.COPY_IMPORT_PATH, + undefined, + { + trigger, + outcome, + strategy, + }, + exObj, + ); + } + } + + /** + * Resolves a Python import-style dotted path from an absolute file path. + * + * The resolution follows a 3-level fallback strategy: + * + * 1. If the file is located under any entry in `sys.path`, the path relative to that entry is used. + * 2. If the file is located under the current workspace folder, the path relative to the workspace root is used. + * 3. Otherwise, the import path falls back to the file name (without extension). + * + * @param absPath Absolute path to a `.py` file. + * @param pythonPath Optional Python interpreter path to determine `sys.path`. + * @returns A tuple: [import path in dotted notation, resolution source: 'sysPath' | 'workspace' | 'fallback'] + */ + private resolveImportPath(absPath: string, pythonPath?: string): [string, 'sysPath' | 'workspace' | 'fallback'] { + // ---------- ① sys.path ---------- + for (const sysRoot of getSysPath(pythonPath)) { + if (sysRoot && absPath.startsWith(sysRoot)) { + return [CopyImportPathCommand.toDotted(path.relative(sysRoot, absPath)), 'sysPath']; + } + } + + // ---------- ② workspace ---------- + const ws = this.workspace.getWorkspaceFolder(vscode.Uri.file(absPath)); + if (ws && absPath.startsWith(ws.uri.fsPath)) { + return [CopyImportPathCommand.toDotted(path.relative(ws.uri.fsPath, absPath)), 'workspace']; + } + + // ---------- ③ fallback ---------- + return [path.basename(absPath, '.py'), 'fallback']; + } + + private static toDotted(relPath: string): string { + return relPath.replace(/\.py$/i, '').split(path.sep).filter(Boolean).join('.'); + } +} diff --git a/src/client/application/importPath/serviceRegistry.ts b/src/client/application/importPath/serviceRegistry.ts new file mode 100644 index 000000000000..1732641b1990 --- /dev/null +++ b/src/client/application/importPath/serviceRegistry.ts @@ -0,0 +1,10 @@ +import { IServiceManager } from '../../ioc/types'; +import { IExtensionSingleActivationService } from '../../activation/types'; +import { CopyImportPathCommand } from './copyImportPathCommand'; + +export function registerTypes(serviceManager: IServiceManager): void { + serviceManager.addSingleton( + IExtensionSingleActivationService, + CopyImportPathCommand, + ); +} diff --git a/src/client/application/serviceRegistry.ts b/src/client/application/serviceRegistry.ts index ff5376d70b24..1f5d0d2be82f 100644 --- a/src/client/application/serviceRegistry.ts +++ b/src/client/application/serviceRegistry.ts @@ -5,7 +5,9 @@ import { IServiceManager } from '../ioc/types'; import { registerTypes as diagnosticsRegisterTypes } from './diagnostics/serviceRegistry'; +import { registerTypes as importPathRegisterTypes } from './importPath/serviceRegistry'; export function registerTypes(serviceManager: IServiceManager) { diagnosticsRegisterTypes(serviceManager); + importPathRegisterTypes(serviceManager); } diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index 98ea2669d773..2d064d91096a 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -40,6 +40,7 @@ interface ICommandNameWithoutArgumentTypeMapping { [Commands.ClearStorage]: []; [Commands.CreateNewFile]: []; [Commands.ReportIssue]: []; + [Commands.CopyImportPath]: []; [LSCommands.RestartLS]: []; } diff --git a/src/client/common/constants.ts b/src/client/common/constants.ts index 4a8962e86b58..8ef2a0408d36 100644 --- a/src/client/common/constants.ts +++ b/src/client/common/constants.ts @@ -36,6 +36,7 @@ export enum CommandSource { export namespace Commands { export const ClearStorage = 'python.clearCacheAndReload'; + export const CopyImportPath = 'python.copyImportPath'; export const CreateNewFile = 'python.createNewFile'; export const ClearWorkspaceInterpreter = 'python.clearWorkspaceInterpreter'; export const Create_Environment = 'python.createEnvironment'; diff --git a/src/client/common/utils/pythonUtils.ts b/src/client/common/utils/pythonUtils.ts new file mode 100644 index 000000000000..a628fe5827cb --- /dev/null +++ b/src/client/common/utils/pythonUtils.ts @@ -0,0 +1,26 @@ +import { execFileSync } from 'child_process'; +import { traceWarn } from '../../logging'; + +export function getSysPath(pythonCmd = 'python3'): string[] { + // cleanSysPathCommand removes the working directory from sys.path. + // The -c flag adds it automatically, which can allow some stdlib + // modules (like json) to be overridden by other files (like json.py). + const cleanSysPathCommand = [ + 'import os, os.path, sys', + 'normalize = lambda p: os.path.normcase(os.path.normpath(p))', + 'cwd = normalize(os.getcwd())', + 'orig_sys_path = [p for p in sys.path if p != ""]', + 'sys.path[:] = [p for p in sys.path if p != "" and normalize(p) != cwd]', + 'import sys, json', + 'print(json.dumps(sys.path))', + ].join('; '); + try { + const out = execFileSync(pythonCmd, ['-c', cleanSysPathCommand], { + encoding: 'utf-8', + }); + return JSON.parse(out); + } catch (err) { + traceWarn('[CopyImportPath] getSysPath failed:', err); + return []; + } +} diff --git a/src/client/telemetry/constants.ts b/src/client/telemetry/constants.ts index ecc44177338a..35aa3370aba0 100644 --- a/src/client/telemetry/constants.ts +++ b/src/client/telemetry/constants.ts @@ -37,6 +37,7 @@ export enum EventName { ENVFILE_WORKSPACE = 'ENVFILE_WORKSPACE', EXECUTION_CODE = 'EXECUTION_CODE', EXECUTION_DJANGO = 'EXECUTION_DJANGO', + COPY_IMPORT_PATH = 'COPY_IMPORT_PATH', // Python testing specific telemetry UNITTEST_CONFIGURING = 'UNITTEST.CONFIGURING', diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index 6c97bd083d96..5d5426fd70b8 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -2458,4 +2458,9 @@ export interface IEventNamePropertyMapping { "errortype": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Type of error when calling TAS (ServerError, NoResponse, etc.)"} } */ + [EventName.COPY_IMPORT_PATH]: { + trigger: 'api' | 'contextMenu' | 'palette'; + outcome: 'success' | 'noFile' | 'notPy' | 'error'; + strategy?: 'sysPath' | 'workspace' | 'fallback'; + }; } diff --git a/src/test/application/importPath/copyImportPathCommand.unit.test.ts b/src/test/application/importPath/copyImportPathCommand.unit.test.ts new file mode 100644 index 000000000000..43bb1fd1e559 --- /dev/null +++ b/src/test/application/importPath/copyImportPathCommand.unit.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { expect } from 'chai'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { CopyImportPathCommand } from '../../../client/application/importPath/copyImportPathCommand'; +import { IClipboard, ICommandManager, IWorkspaceService } from '../../../client/common/application/types'; +import * as pythonUtils from '../../../client/common/utils/pythonUtils'; +import { ClipboardService } from '../../../client/common/application/clipboard'; +import { CommandManager } from '../../../client/common/application/commandManager'; +import { WorkspaceService } from '../../../client/common/application/workspace'; +import { IInterpreterPathService } from '../../../client/common/types'; +import { InterpreterPathService } from '../../../client/common/interpreterPathService'; + +suite('Copy Import Path Command', () => { + let command: CopyImportPathCommand; + let commandManager: ICommandManager; + let workspaceService: IWorkspaceService; + let clipboard: IClipboard; + let interpreterPathService: IInterpreterPathService; + let originalGetSysPath: () => string[]; + + let clipboardText = ''; + + setup(() => { + commandManager = mock(CommandManager); + workspaceService = mock(WorkspaceService); + clipboard = mock(ClipboardService); + interpreterPathService = mock(InterpreterPathService); + command = new CopyImportPathCommand( + instance(commandManager), + instance(workspaceService), + instance(clipboard), + instance(interpreterPathService), + ); + originalGetSysPath = pythonUtils.getSysPath; + + clipboardText = ''; + when(clipboard.writeText(anything())).thenCall(async (text: string) => { + clipboardText = text; + }); + }); + + teardown(() => { + ((pythonUtils as unknown) as { getSysPath: () => string[] }).getSysPath = originalGetSysPath; + }); + + test('Confirm command handler is added', async () => { + await command.activate(); + verify(commandManager.registerCommand('python.copyImportPath', anything(), anything())).once(); + }); + + test('execute() – sys.path match takes precedence', async () => { + const projectRoot = path.join(path.sep, 'home', 'user', 'project'); + const absPath = path.join(projectRoot, 'src', 'pkg', 'module.py'); + const uri = vscode.Uri.file(absPath); + ((pythonUtils as unknown) as { getSysPath: () => string[] }).getSysPath = () => [path.join(projectRoot, 'src')]; + + when(workspaceService.getWorkspaceFolder(anything())).thenReturn(undefined); + ((vscode.window as unknown) as { activeTextEditor: { document: { uri: vscode.Uri } } }).activeTextEditor = { + document: { uri }, + }; + + await ((command as unknown) as { execute(u: vscode.Uri): Promise }).execute(uri); + expect(clipboardText).to.equal('pkg.module'); + }); + + test('execute() – workspaceFolder used when no sys.path match', async () => { + const projectRoot = path.join(path.sep, 'home', 'user', 'project'); + const absPath = path.join(projectRoot, 'tools', 'util.py'); + const uri = vscode.Uri.file(absPath); + ((pythonUtils as unknown) as { getSysPath: () => string[] }).getSysPath = () => []; + + const wsFolder = { + uri: vscode.Uri.file(projectRoot), + name: 'project', + index: 0, + } as vscode.WorkspaceFolder; + when(workspaceService.getWorkspaceFolder(anything())).thenReturn(wsFolder); + + ((vscode.window as unknown) as { activeTextEditor: { document: { uri: vscode.Uri } } }).activeTextEditor = { + document: { uri }, + }; + await ((command as unknown) as { execute(u: vscode.Uri): Promise }).execute(uri); + expect(clipboardText).to.equal('tools.util'); + }); + + test('execute() – falls back to filename when no matches', async () => { + const absPath = path.join(path.sep, 'tmp', 'standalone.py'); + const uri = vscode.Uri.file(absPath); + ((pythonUtils as unknown) as { getSysPath: () => string[] }).getSysPath = () => []; + when(workspaceService.getWorkspaceFolder(anything())).thenReturn(undefined); + + ((vscode.window as unknown) as { activeTextEditor: { document: { uri: vscode.Uri } } }).activeTextEditor = { + document: { uri }, + }; + await ((command as unknown) as { execute(u: vscode.Uri): Promise }).execute(uri); + expect(clipboardText).to.equal('standalone'); + }); +});