From 0fe3beb4b96dbb27784883624f119639b77bc209 Mon Sep 17 00:00:00 2001 From: skai273 Date: Mon, 24 Feb 2025 23:54:18 +0900 Subject: [PATCH 01/11] add context menu item of copy import path --- package.json | 12 ++++++++++++ package.nls.json | 11 ++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c94be0a12475..d43b640d0f11 100644 --- a/package.json +++ b/package.json @@ -377,6 +377,11 @@ "category": "Python", "command": "python.installJupyter", "title": "%python.command.python.installJupyter.title%" + }, + { + "category": "Python", + "command": "python.copyImportPath", + "title": "%python.command.python.copyImportPath.title%" } ], "configuration": { @@ -1414,6 +1419,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 8bff60a4b07d..9580bb1b5570 100644 --- a/package.nls.json +++ b/package.nls.json @@ -22,6 +22,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", @@ -87,7 +88,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)", @@ -99,7 +100,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" @@ -129,7 +130,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'}", @@ -141,8 +142,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", From 16c9419bf5cf9285a12e12b54768fb4f69cb1913 Mon Sep 17 00:00:00 2001 From: skai273 Date: Wed, 30 Apr 2025 23:48:25 +0900 Subject: [PATCH 02/11] implement copy import path command --- .../importPath/copyImportPathCommand.ts | 68 +++++++++++++++++++ .../application/importPath/serviceRegistry.ts | 10 +++ src/client/application/serviceRegistry.ts | 2 + src/client/common/application/commands.ts | 1 + src/client/common/constants.ts | 1 + src/client/common/utils/pythonUtils.ts | 13 ++++ 6 files changed, 95 insertions(+) create mode 100644 src/client/application/importPath/copyImportPathCommand.ts create mode 100644 src/client/application/importPath/serviceRegistry.ts create mode 100644 src/client/common/utils/pythonUtils.ts diff --git a/src/client/application/importPath/copyImportPathCommand.ts b/src/client/application/importPath/copyImportPathCommand.ts new file mode 100644 index 000000000000..a1f86ebba500 --- /dev/null +++ b/src/client/application/importPath/copyImportPathCommand.ts @@ -0,0 +1,68 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { inject, injectable } from 'inversify'; + +import { ICommandManager, IWorkspaceService } from '../../common/application/types'; +import { IExtensionSingleActivationService } from '../../activation/types'; +import { Commands } from '../../common/constants'; +import { getSysPath } from '../../common/utils/pythonUtils'; + +@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, + ) {} + + async activate(): Promise { + this.commands.registerCommand(Commands.CopyImportPath, this.execute, this); + } + + private async execute(fileUri?: vscode.Uri): Promise { + const uri = fileUri ?? vscode.window.activeTextEditor?.document.uri; + if (!uri || uri.scheme !== 'file' || !uri.fsPath.endsWith('.py')) { + void vscode.window.showWarningMessage('No Python file selected for import-path copy.'); + return; + } + + const importPath = this.resolveImportPath(uri.fsPath); + await vscode.env.clipboard.writeText(importPath); + void vscode.window.showInformationMessage(`Copied: ${importPath}`); + } + + /** + * 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 - The absolute path to a `.py` file. + * @returns The resolved import path in dotted notation (e.g., 'pkg.module'). + */ + private resolveImportPath(absPath: string): string { + // ---------- ① sys.path ---------- + for (const sysRoot of getSysPath()) { + if (sysRoot && absPath.startsWith(sysRoot)) { + return CopyImportPathCommand.toDotted(path.relative(sysRoot, absPath)); + } + } + + // ---------- ② workspaceFolder ---------- + 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)); + } + + // ---------- ③ fallback ---------- + return path.basename(absPath, '.py'); + } + + 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 2195fe09aabf..5de6859fa7f4 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 5ffa775bf04a..2ec0cae566e0 100644 --- a/src/client/common/constants.ts +++ b/src/client/common/constants.ts @@ -35,6 +35,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..a1e4979f399a --- /dev/null +++ b/src/client/common/utils/pythonUtils.ts @@ -0,0 +1,13 @@ +import { execFileSync } from 'child_process'; + +export function getSysPath(pythonCmd = 'python3'): string[] { + try { + const out = execFileSync(pythonCmd, ['-c', 'import sys, json; print(json.dumps(sys.path))'], { + encoding: 'utf-8', + }); + return JSON.parse(out); + } catch (err) { + console.warn('[CopyImportPath] getSysPath failed:', err); + return []; + } +} From 59df3ad2cc42c87039d45299fa6224f43dcfffa9 Mon Sep 17 00:00:00 2001 From: skai273 Date: Wed, 30 Apr 2025 23:58:21 +0900 Subject: [PATCH 03/11] add kiybindings --- package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package.json b/package.json index d43b640d0f11..050801a90d89 100644 --- a/package.json +++ b/package.json @@ -1139,6 +1139,11 @@ } ], "keybindings": [ + { + "command": "python.copyImportPath", + "key": "ctrl+alt+shift+i", + "when": "editorTextFocus && resourceLangId == python" + }, { "command": "python.execSelectionInTerminal", "key": "shift+enter", From ebf0c207a8eaa450331d43ef93c8b82ad9f213c5 Mon Sep 17 00:00:00 2001 From: skai273 Date: Thu, 1 May 2025 20:28:03 +0900 Subject: [PATCH 04/11] add test cases --- .../copyImportPathCommand.unit.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/test/application/importPath/copyImportPathCommand.unit.test.ts 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..c7ff89576ef7 --- /dev/null +++ b/src/test/application/importPath/copyImportPathCommand.unit.test.ts @@ -0,0 +1,89 @@ +// 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 vscode from 'vscode'; +import { CopyImportPathCommand } from '../../../client/application/importPath/copyImportPathCommand'; +import { ICommandManager, IWorkspaceService } from '../../../client/common/application/types'; +import * as pythonUtils from '../../../client/common/utils/pythonUtils'; + +suite('Copy Import Path Command', () => { + let command: CopyImportPathCommand; + let commandManager: ICommandManager; + let workspaceService: IWorkspaceService; + let originalGetSysPath: () => string[]; + + let clipboardText = ''; + + setup(() => { + commandManager = mock(); + workspaceService = mock(); + command = new CopyImportPathCommand(instance(commandManager), instance(workspaceService)); + originalGetSysPath = pythonUtils.getSysPath; + + clipboardText = ''; + + (vscode.env.clipboard as typeof vscode.env.clipboard).writeText = 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 absPath = '/home/user/project/src/pkg/module.py'; + const uri = vscode.Uri.file(absPath); + ((pythonUtils as unknown) as { getSysPath: () => string[] }).getSysPath = originalGetSysPath; + // ((pythonUtils as unknown) as { getSysPath: () => string[] }).getSysPath = () => ['/home/user/project/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 absPath = '/home/user/project/tools/util.py'; + const uri = vscode.Uri.file(absPath); + ((pythonUtils as unknown) as { getSysPath: () => string[] }).getSysPath = () => []; + + const wsFolder = { + uri: vscode.Uri.file('/home/user/project'), + 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 = '/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'); + }); +}); From a13ad197c703e10932ade9f800465d15e636da4f Mon Sep 17 00:00:00 2001 From: skai273 Date: Thu, 1 May 2025 21:05:06 +0900 Subject: [PATCH 05/11] modify to use clipboard object --- .../importPath/copyImportPathCommand.ts | 6 ++++-- .../copyImportPathCommand.unit.test.ts | 21 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/client/application/importPath/copyImportPathCommand.ts b/src/client/application/importPath/copyImportPathCommand.ts index a1f86ebba500..2cd94173f295 100644 --- a/src/client/application/importPath/copyImportPathCommand.ts +++ b/src/client/application/importPath/copyImportPathCommand.ts @@ -2,7 +2,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { inject, injectable } from 'inversify'; -import { ICommandManager, IWorkspaceService } from '../../common/application/types'; +import { IClipboard, ICommandManager, IWorkspaceService } from '../../common/application/types'; import { IExtensionSingleActivationService } from '../../activation/types'; import { Commands } from '../../common/constants'; import { getSysPath } from '../../common/utils/pythonUtils'; @@ -14,6 +14,7 @@ export class CopyImportPathCommand implements IExtensionSingleActivationService constructor( @inject(ICommandManager) private readonly commands: ICommandManager, @inject(IWorkspaceService) private readonly workspace: IWorkspaceService, + @inject(IClipboard) private readonly clipboard: IClipboard, ) {} async activate(): Promise { @@ -28,7 +29,8 @@ export class CopyImportPathCommand implements IExtensionSingleActivationService } const importPath = this.resolveImportPath(uri.fsPath); - await vscode.env.clipboard.writeText(importPath); + // await vscode.env.clipboard.writeText(importPath); + await this.clipboard.writeText(importPath); void vscode.window.showInformationMessage(`Copied: ${importPath}`); } diff --git a/src/test/application/importPath/copyImportPathCommand.unit.test.ts b/src/test/application/importPath/copyImportPathCommand.unit.test.ts index c7ff89576ef7..35c18faafb2d 100644 --- a/src/test/application/importPath/copyImportPathCommand.unit.test.ts +++ b/src/test/application/importPath/copyImportPathCommand.unit.test.ts @@ -7,28 +7,32 @@ import { anything, instance, mock, verify, when } from 'ts-mockito'; import { expect } from 'chai'; import * as vscode from 'vscode'; import { CopyImportPathCommand } from '../../../client/application/importPath/copyImportPathCommand'; -import { ICommandManager, IWorkspaceService } from '../../../client/common/application/types'; +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'; suite('Copy Import Path Command', () => { let command: CopyImportPathCommand; let commandManager: ICommandManager; let workspaceService: IWorkspaceService; + let clipboard: IClipboard; let originalGetSysPath: () => string[]; let clipboardText = ''; setup(() => { - commandManager = mock(); - workspaceService = mock(); - command = new CopyImportPathCommand(instance(commandManager), instance(workspaceService)); + commandManager = mock(CommandManager); + workspaceService = mock(WorkspaceService); + clipboard = mock(ClipboardService); + command = new CopyImportPathCommand(instance(commandManager), instance(workspaceService), instance(clipboard)); originalGetSysPath = pythonUtils.getSysPath; clipboardText = ''; - - (vscode.env.clipboard as typeof vscode.env.clipboard).writeText = async (text: string) => { + when(clipboard.writeText(anything())).thenCall(async (text: string) => { clipboardText = text; - }; + }); }); teardown(() => { @@ -43,8 +47,7 @@ suite('Copy Import Path Command', () => { test('execute() – sys.path match takes precedence', async () => { const absPath = '/home/user/project/src/pkg/module.py'; const uri = vscode.Uri.file(absPath); - ((pythonUtils as unknown) as { getSysPath: () => string[] }).getSysPath = originalGetSysPath; - // ((pythonUtils as unknown) as { getSysPath: () => string[] }).getSysPath = () => ['/home/user/project/src']; + ((pythonUtils as unknown) as { getSysPath: () => string[] }).getSysPath = () => ['/home/user/project/src']; when(workspaceService.getWorkspaceFolder(anything())).thenReturn(undefined); ((vscode.window as unknown) as { activeTextEditor: { document: { uri: vscode.Uri } } }).activeTextEditor = { From c23849dd11b4d5e0ddcc479a1029224a57d8e565 Mon Sep 17 00:00:00 2001 From: skai273 Date: Wed, 7 May 2025 21:02:09 +0900 Subject: [PATCH 06/11] modify method to resolve path --- .../importPath/copyImportPathCommand.unit.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/test/application/importPath/copyImportPathCommand.unit.test.ts b/src/test/application/importPath/copyImportPathCommand.unit.test.ts index 35c18faafb2d..462e14e3e6c8 100644 --- a/src/test/application/importPath/copyImportPathCommand.unit.test.ts +++ b/src/test/application/importPath/copyImportPathCommand.unit.test.ts @@ -5,6 +5,7 @@ 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'; @@ -45,9 +46,10 @@ suite('Copy Import Path Command', () => { }); test('execute() – sys.path match takes precedence', async () => { - const absPath = '/home/user/project/src/pkg/module.py'; + 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 = () => ['/home/user/project/src']; + ((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 = { @@ -59,12 +61,13 @@ suite('Copy Import Path Command', () => { }); test('execute() – workspaceFolder used when no sys.path match', async () => { - const absPath = '/home/user/project/tools/util.py'; + 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('/home/user/project'), + uri: vscode.Uri.file(projectRoot), name: 'project', index: 0, } as vscode.WorkspaceFolder; @@ -78,7 +81,7 @@ suite('Copy Import Path Command', () => { }); test('execute() – falls back to filename when no matches', async () => { - const absPath = '/tmp/standalone.py'; + 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); From 8179c18a872c8f15e1c9ae161fd98ef03c41f22e Mon Sep 17 00:00:00 2001 From: skai273 Date: Sat, 10 May 2025 22:09:52 +0900 Subject: [PATCH 07/11] delete unnecessary comment --- src/client/application/importPath/copyImportPathCommand.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/application/importPath/copyImportPathCommand.ts b/src/client/application/importPath/copyImportPathCommand.ts index 2cd94173f295..7a9cabed0c4f 100644 --- a/src/client/application/importPath/copyImportPathCommand.ts +++ b/src/client/application/importPath/copyImportPathCommand.ts @@ -29,7 +29,6 @@ export class CopyImportPathCommand implements IExtensionSingleActivationService } const importPath = this.resolveImportPath(uri.fsPath); - // await vscode.env.clipboard.writeText(importPath); await this.clipboard.writeText(importPath); void vscode.window.showInformationMessage(`Copied: ${importPath}`); } From 63abf491ca7098a3f6853ee465869b5ae42a7fb7 Mon Sep 17 00:00:00 2001 From: skai273 Date: Sat, 10 May 2025 22:46:47 +0900 Subject: [PATCH 08/11] modify to use current interpreter path for resolving sys.path --- .../application/importPath/copyImportPathCommand.ts | 10 +++++++--- .../importPath/copyImportPathCommand.unit.test.ts | 11 ++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/client/application/importPath/copyImportPathCommand.ts b/src/client/application/importPath/copyImportPathCommand.ts index 7a9cabed0c4f..273b2556d2f3 100644 --- a/src/client/application/importPath/copyImportPathCommand.ts +++ b/src/client/application/importPath/copyImportPathCommand.ts @@ -6,6 +6,7 @@ import { IClipboard, ICommandManager, IWorkspaceService } from '../../common/app import { IExtensionSingleActivationService } from '../../activation/types'; import { Commands } from '../../common/constants'; import { getSysPath } from '../../common/utils/pythonUtils'; +import { IInterpreterPathService } from '../../common/types'; @injectable() export class CopyImportPathCommand implements IExtensionSingleActivationService { @@ -15,6 +16,7 @@ export class CopyImportPathCommand implements IExtensionSingleActivationService @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 { @@ -28,7 +30,9 @@ export class CopyImportPathCommand implements IExtensionSingleActivationService return; } - const importPath = this.resolveImportPath(uri.fsPath); + const resource: vscode.Uri | undefined = uri ?? this.workspace.workspaceFolders?.[0]?.uri; + const pythonPath = this.interpreterPathService.get(resource); + const importPath = this.resolveImportPath(uri.fsPath, pythonPath); await this.clipboard.writeText(importPath); void vscode.window.showInformationMessage(`Copied: ${importPath}`); } @@ -45,9 +49,9 @@ export class CopyImportPathCommand implements IExtensionSingleActivationService * @param absPath - The absolute path to a `.py` file. * @returns The resolved import path in dotted notation (e.g., 'pkg.module'). */ - private resolveImportPath(absPath: string): string { + private resolveImportPath(absPath: string, pythonPath?: string): string { // ---------- ① sys.path ---------- - for (const sysRoot of getSysPath()) { + for (const sysRoot of getSysPath(pythonPath)) { if (sysRoot && absPath.startsWith(sysRoot)) { return CopyImportPathCommand.toDotted(path.relative(sysRoot, absPath)); } diff --git a/src/test/application/importPath/copyImportPathCommand.unit.test.ts b/src/test/application/importPath/copyImportPathCommand.unit.test.ts index 462e14e3e6c8..43bb1fd1e559 100644 --- a/src/test/application/importPath/copyImportPathCommand.unit.test.ts +++ b/src/test/application/importPath/copyImportPathCommand.unit.test.ts @@ -13,12 +13,15 @@ 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 = ''; @@ -27,7 +30,13 @@ suite('Copy Import Path Command', () => { commandManager = mock(CommandManager); workspaceService = mock(WorkspaceService); clipboard = mock(ClipboardService); - command = new CopyImportPathCommand(instance(commandManager), instance(workspaceService), instance(clipboard)); + interpreterPathService = mock(InterpreterPathService); + command = new CopyImportPathCommand( + instance(commandManager), + instance(workspaceService), + instance(clipboard), + instance(interpreterPathService), + ); originalGetSysPath = pythonUtils.getSysPath; clipboardText = ''; From 3eeaf87932bc89b175701908600ade9ab635dd10 Mon Sep 17 00:00:00 2001 From: skai273 Date: Wed, 21 May 2025 23:44:07 +0900 Subject: [PATCH 09/11] modify to work on remote environment --- src/client/application/importPath/copyImportPathCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/application/importPath/copyImportPathCommand.ts b/src/client/application/importPath/copyImportPathCommand.ts index 273b2556d2f3..793a0f735d4f 100644 --- a/src/client/application/importPath/copyImportPathCommand.ts +++ b/src/client/application/importPath/copyImportPathCommand.ts @@ -25,7 +25,7 @@ export class CopyImportPathCommand implements IExtensionSingleActivationService private async execute(fileUri?: vscode.Uri): Promise { const uri = fileUri ?? vscode.window.activeTextEditor?.document.uri; - if (!uri || uri.scheme !== 'file' || !uri.fsPath.endsWith('.py')) { + if (!uri || !uri.fsPath.endsWith('.py')) { void vscode.window.showWarningMessage('No Python file selected for import-path copy.'); return; } From 1e3395220b42634d56ccc00dd5c74277fbc3280e Mon Sep 17 00:00:00 2001 From: skai273 Date: Fri, 23 May 2025 23:08:43 +0900 Subject: [PATCH 10/11] improve command for getting sys path to resolve security issue --- src/client/common/utils/pythonUtils.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/client/common/utils/pythonUtils.ts b/src/client/common/utils/pythonUtils.ts index a1e4979f399a..bfb1726c63d3 100644 --- a/src/client/common/utils/pythonUtils.ts +++ b/src/client/common/utils/pythonUtils.ts @@ -1,8 +1,20 @@ import { execFileSync } from 'child_process'; 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', 'import sys, json; print(json.dumps(sys.path))'], { + const out = execFileSync(pythonCmd, ['-c', cleanSysPathCommand], { encoding: 'utf-8', }); return JSON.parse(out); From f72b00515ea13e95e74e2c209e4da3b86798fd7a Mon Sep 17 00:00:00 2001 From: skai273 Date: Fri, 23 May 2025 23:53:21 +0900 Subject: [PATCH 11/11] add telemetry for copy import path --- .../importPath/copyImportPathCommand.ts | 62 ++++++++++++++----- src/client/common/utils/pythonUtils.ts | 3 +- src/client/telemetry/constants.ts | 1 + src/client/telemetry/index.ts | 5 ++ 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/client/application/importPath/copyImportPathCommand.ts b/src/client/application/importPath/copyImportPathCommand.ts index 793a0f735d4f..825bd81205f5 100644 --- a/src/client/application/importPath/copyImportPathCommand.ts +++ b/src/client/application/importPath/copyImportPathCommand.ts @@ -7,6 +7,8 @@ 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 { @@ -24,17 +26,42 @@ export class CopyImportPathCommand implements IExtensionSingleActivationService } private async execute(fileUri?: vscode.Uri): Promise { - const uri = fileUri ?? vscode.window.activeTextEditor?.document.uri; - if (!uri || !uri.fsPath.endsWith('.py')) { - void vscode.window.showWarningMessage('No Python file selected for import-path copy.'); - return; - } + 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; - const resource: vscode.Uri | undefined = uri ?? this.workspace.workspaceFolders?.[0]?.uri; - const pythonPath = this.interpreterPathService.get(resource); - const importPath = this.resolveImportPath(uri.fsPath, pythonPath); - await this.clipboard.writeText(importPath); - void vscode.window.showInformationMessage(`Copied: ${importPath}`); + 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, + ); + } } /** @@ -46,25 +73,26 @@ export class CopyImportPathCommand implements IExtensionSingleActivationService * 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 - The absolute path to a `.py` file. - * @returns The resolved import path in dotted notation (e.g., 'pkg.module'). + * @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 { + 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)); + return [CopyImportPathCommand.toDotted(path.relative(sysRoot, absPath)), 'sysPath']; } } - // ---------- ② workspaceFolder ---------- + // ---------- ② 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)); + return [CopyImportPathCommand.toDotted(path.relative(ws.uri.fsPath, absPath)), 'workspace']; } // ---------- ③ fallback ---------- - return path.basename(absPath, '.py'); + return [path.basename(absPath, '.py'), 'fallback']; } private static toDotted(relPath: string): string { diff --git a/src/client/common/utils/pythonUtils.ts b/src/client/common/utils/pythonUtils.ts index bfb1726c63d3..a628fe5827cb 100644 --- a/src/client/common/utils/pythonUtils.ts +++ b/src/client/common/utils/pythonUtils.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'child_process'; +import { traceWarn } from '../../logging'; export function getSysPath(pythonCmd = 'python3'): string[] { // cleanSysPathCommand removes the working directory from sys.path. @@ -19,7 +20,7 @@ export function getSysPath(pythonCmd = 'python3'): string[] { }); return JSON.parse(out); } catch (err) { - console.warn('[CopyImportPath] getSysPath failed:', 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'; + }; }