Skip to content

Implement copying python import path from opened file #25026

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
17 changes: 17 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -1126,6 +1131,11 @@
}
],
"keybindings": [
{
"command": "python.copyImportPath",
"key": "ctrl+alt+shift+i",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've set Ctrl+Alt+Shift+I as the default keybinding.
If you find it inconvenient or have a better suggestion, please let me know!

"when": "editorTextFocus && resourceLangId == python"
},
{
"command": "python.execSelectionInTerminal",
"key": "shift+enter",
Expand Down Expand Up @@ -1406,6 +1416,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",
Expand Down
11 changes: 6 additions & 5 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -86,7 +87,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)",
Expand All @@ -98,7 +99,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"
Expand Down Expand Up @@ -128,7 +129,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'}",
Expand All @@ -140,8 +141,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",
Expand Down
70 changes: 70 additions & 0 deletions src/client/application/importPath/copyImportPathCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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';

@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,
) {}

async activate(): Promise<void> {
this.commands.registerCommand(Commands.CopyImportPath, this.execute, this);
}

private async execute(fileUri?: vscode.Uri): Promise<void> {
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);
await this.clipboard.writeText(importPath);
void vscode.window.showInformationMessage(`Copied: ${importPath}`);
}

/**
* Resolves a Python import-style dotted path from an absolute file path.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As described here, the strategy for resolving the Python import path is as follows:

  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).

I believe this approach is reasonable, but if you have any suggestions for improvement, I’d love to hear them!

*
* 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('.');
}
}
10 changes: 10 additions & 0 deletions src/client/application/importPath/serviceRegistry.ts
Original file line number Diff line number Diff line change
@@ -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>(
IExtensionSingleActivationService,
CopyImportPathCommand,
);
}
2 changes: 2 additions & 0 deletions src/client/application/serviceRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions src/client/common/application/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface ICommandNameWithoutArgumentTypeMapping {
[Commands.ClearStorage]: [];
[Commands.CreateNewFile]: [];
[Commands.ReportIssue]: [];
[Commands.CopyImportPath]: [];
[LSCommands.RestartLS]: [];
}

Expand Down
1 change: 1 addition & 0 deletions src/client/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 13 additions & 0 deletions src/client/common/utils/pythonUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { execFileSync } from 'child_process';

export function getSysPath(pythonCmd = 'python3'): string[] {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't safe if there's a json.py file in the user's workspace (people sometimes open folders without checking). You might want to copy this code here:

https://github.com/microsoft/pyright/blob/5d79dbbbcd0e683b07b4bd4a7f1910a835564249/packages/pyright-internal/src/common/fullAccessHost.ts#L25

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your insightful comment.
I learned a lot from it.
If we adopt the approach used in Pylance, this method will no longer be necessary.
After this discussion, I’ll address this issue.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix it on 1e33952

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 [];
}
}
92 changes: 92 additions & 0 deletions src/test/application/importPath/copyImportPathCommand.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// 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 { 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(CommandManager);
workspaceService = mock(WorkspaceService);
clipboard = mock(ClipboardService);
command = new CopyImportPathCommand(instance(commandManager), instance(workspaceService), instance(clipboard));
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 absPath = '/home/user/project/src/pkg/module.py';
const uri = vscode.Uri.file(absPath);
((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<void> }).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<void> }).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<void> }).execute(uri);
expect(clipboardText).to.equal('standalone');
});
});