-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
base: main
Are you sure you want to change the base?
Changes from all commits
0fe3beb
16c9419
59df3ad
ebf0c20
a13ad19
b540d9d
6fb135d
6156876
c23849d
8179c18
63abf49
7607eed
2855a30
3eeaf87
ac66899
1e33952
aabb1d3
f72b005
42178a3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<void> { | ||
this.commands.registerCommand(Commands.CopyImportPath, this.execute, this); | ||
} | ||
|
||
private async execute(fileUri?: vscode.Uri): Promise<void> { | ||
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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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 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('.'); | ||
} | ||
} |
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, | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import { execFileSync } from 'child_process'; | ||
import { traceWarn } from '../../logging'; | ||
|
||
export function getSysPath(pythonCmd = 'python3'): string[] { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for your insightful comment. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix it on 1e33952 |
||
// 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 []; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<void> }).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<void> }).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<void> }).execute(uri); | ||
expect(clipboardText).to.equal('standalone'); | ||
}); | ||
}); |
There was a problem hiding this comment.
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!