diff --git a/build/azure-pipeline.pre-release.yml b/build/azure-pipeline.pre-release.yml index b4e4e951..3b6a9180 100644 --- a/build/azure-pipeline.pre-release.yml +++ b/build/azure-pipeline.pre-release.yml @@ -90,6 +90,32 @@ extends: chmod +x $(Build.SourcesDirectory)/python-env-tools/bin displayName: Make Directory for python-env-tool binary + - bash: | + if [ "$(vsceTarget)" == "win32-x64" ]; then + echo "##vso[task.setvariable variable=buildTarget]x86_64-pc-windows-msvc" + elif [ "$(vsceTarget)" == "win32-arm64" ]; then + echo "##vso[task.setvariable variable=buildTarget]aarch64-pc-windows-msvc" + elif [ "$(vsceTarget)" == "linux-x64" ]; then + echo "##vso[task.setvariable variable=buildTarget]x86_64-unknown-linux-musl" + elif [ "$(vsceTarget)" == "linux-arm64" ]; then + echo "##vso[task.setvariable variable=buildTarget]aarch64-unknown-linux-gnu" + elif [ "$(vsceTarget)" == "linux-armhf" ]; then + echo "##vso[task.setvariable variable=buildTarget]armv7-unknown-linux-gnueabihf" + elif [ "$(vsceTarget)" == "darwin-x64" ]; then + echo "##vso[task.setvariable variable=buildTarget]x86_64-apple-darwin" + elif [ "$(vsceTarget)" == "darwin-arm64" ]; then + echo "##vso[task.setvariable variable=buildTarget]aarch64-apple-darwin" + elif [ "$(vsceTarget)" == "alpine-x64" ]; then + echo "##vso[task.setvariable variable=buildTarget]x86_64-unknown-linux-musl" + elif [ "$(vsceTarget)" == "alpine-arm64" ]; then + echo "##vso[task.setvariable variable=buildTarget]aarch64-unknown-linux-gnu" + elif [ "$(vsceTarget)" == "web" ]; then + echo "##vso[task.setvariable variable=buildTarget]x86_64-unknown-linux-musl" + else + echo "##vso[task.setvariable variable=buildTarget]x86_64-unknown-linux-musl" + fi + displayName: Set buildTarget variable + - task: DownloadPipelineArtifact@2 inputs: buildType: 'specific' @@ -98,7 +124,7 @@ extends: buildVersionToDownload: 'latest' branchName: 'refs/heads/main' targetPath: '$(Build.SourcesDirectory)/python-env-tools/bin' - artifactName: 'bin-$(vsceTarget)' + artifactName: 'bin-$(buildTarget)' itemPattern: | pet.exe pet diff --git a/build/azure-pipeline.stable.yml b/build/azure-pipeline.stable.yml index 517be175..6d880681 100644 --- a/build/azure-pipeline.stable.yml +++ b/build/azure-pipeline.stable.yml @@ -80,6 +80,32 @@ extends: chmod +x $(Build.SourcesDirectory)/python-env-tools/bin displayName: Make Directory for python-env-tool binary + - bash: | + if [ "$(vsceTarget)" == "win32-x64" ]; then + echo "##vso[task.setvariable variable=buildTarget]x86_64-pc-windows-msvc" + elif [ "$(vsceTarget)" == "win32-arm64" ]; then + echo "##vso[task.setvariable variable=buildTarget]aarch64-pc-windows-msvc" + elif [ "$(vsceTarget)" == "linux-x64" ]; then + echo "##vso[task.setvariable variable=buildTarget]x86_64-unknown-linux-musl" + elif [ "$(vsceTarget)" == "linux-arm64" ]; then + echo "##vso[task.setvariable variable=buildTarget]aarch64-unknown-linux-gnu" + elif [ "$(vsceTarget)" == "linux-armhf" ]; then + echo "##vso[task.setvariable variable=buildTarget]armv7-unknown-linux-gnueabihf" + elif [ "$(vsceTarget)" == "darwin-x64" ]; then + echo "##vso[task.setvariable variable=buildTarget]x86_64-apple-darwin" + elif [ "$(vsceTarget)" == "darwin-arm64" ]; then + echo "##vso[task.setvariable variable=buildTarget]aarch64-apple-darwin" + elif [ "$(vsceTarget)" == "alpine-x64" ]; then + echo "##vso[task.setvariable variable=buildTarget]x86_64-unknown-linux-musl" + elif [ "$(vsceTarget)" == "alpine-arm64" ]; then + echo "##vso[task.setvariable variable=buildTarget]aarch64-unknown-linux-gnu" + elif [ "$(vsceTarget)" == "web" ]; then + echo "##vso[task.setvariable variable=buildTarget]x86_64-unknown-linux-musl" + else + echo "##vso[task.setvariable variable=buildTarget]x86_64-unknown-linux-musl" + fi + displayName: Set buildTarget variable + - task: DownloadPipelineArtifact@2 inputs: buildType: 'specific' @@ -88,7 +114,7 @@ extends: buildVersionToDownload: 'latestFromBranch' branchName: 'refs/heads/release/2024.18' targetPath: '$(Build.SourcesDirectory)/python-env-tools/bin' - artifactName: 'bin-$(vsceTarget)' + artifactName: 'bin-$(buildTarget)' itemPattern: | pet.exe pet diff --git a/package-lock.json b/package-lock.json index 315b2d8c..65755ba0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4810,10 +4810,11 @@ } }, "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "chownr": "^1.1.1", @@ -8930,9 +8931,9 @@ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" }, "tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", "dev": true, "optional": true, "requires": { diff --git a/package.json b/package.json index ce1be620..31f8eaa3 100644 --- a/package.json +++ b/package.json @@ -231,6 +231,18 @@ "title": "%python-envs.copyProjectPath.title%", "category": "Python Envs", "icon": "$(copy)" + }, + { + "command": "python-envs.permissions", + "title": "%python-envs.permissions.title%", + "category": "Python Envs", + "icon": "$(shield)" + }, + { + "command": "python-envs.resetPermissions", + "title": "%python-envs.resetPermissions.title%", + "category": "Python Envs", + "icon": "$(sync)" } ], "menus": { @@ -418,9 +430,17 @@ }, { "command": "python-envs.refreshAllManagers", + "when": "view == env-managers" + }, + { + "command": "python-envs.permissions", "group": "navigation", "when": "view == env-managers" }, + { + "command": "python-envs.resetPermissions", + "when": "view == env-managers" + }, { "command": "python-envs.terminal.activate", "group": "navigation", diff --git a/package.nls.json b/package.nls.json index f7547a6e..aa99a384 100644 --- a/package.nls.json +++ b/package.nls.json @@ -28,5 +28,7 @@ "python-envs.runAsTask.title": "Run as Task", "python-envs.terminal.activate.title": "Activate Environment in Current Terminal", "python-envs.terminal.deactivate.title": "Deactivate Environment in Current Terminal", - "python-envs.uninstallPackage.title": "Uninstall Package" + "python-envs.uninstallPackage.title": "Uninstall Package", + "python-envs.permissions.title": "Package Manager Permissions", + "python-envs.resetPermissions.title": "Reset Package Manager Permissions" } diff --git a/src/api.ts b/src/api.ts index e7d2245d..9e4b35cd 100644 --- a/src/api.ts +++ b/src/api.ts @@ -721,6 +721,11 @@ export interface PackageInstallOptions { * Upgrade the packages if it is already installed. */ upgrade?: boolean; + + /** + * Show option to skip package installation + */ + showSkipOption?: boolean; } export interface PythonProcess { @@ -918,7 +923,7 @@ export interface PythonPackageManagementApi { * @param environment The Python Environment from which packages are to be uninstalled. * @param packages The packages to uninstall. */ - uninstallPackages(environment: PythonEnvironment, packages: PackageInfo[] | string[]): Promise; + uninstallPackages(environment: PythonEnvironment, packages: string[]): Promise; } export interface PythonPackageManagerApi diff --git a/src/common/command.api.ts b/src/common/command.api.ts index 3fece44c..f107b78f 100644 --- a/src/common/command.api.ts +++ b/src/common/command.api.ts @@ -1,6 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { commands } from 'vscode'; +import { Disposable } from 'vscode-jsonrpc'; export function executeCommand(command: string, ...rest: any[]): Thenable { return commands.executeCommand(command, ...rest); } + +export function registerCommand(command: string, callback: (...args: any[]) => any, thisArg?: any): Disposable { + return commands.registerCommand(command, callback, thisArg); +} diff --git a/src/common/extension.apis.ts b/src/common/extension.apis.ts index c77594be..15af19a3 100644 --- a/src/common/extension.apis.ts +++ b/src/common/extension.apis.ts @@ -8,3 +8,14 @@ export function getExtension(extensionId: string): Extension | undef export function allExtensions(): readonly Extension[] { return extensions.all; } + +export function allExternalExtensions(): readonly Extension[] { + return allExtensions().filter((extension) => { + try { + return extension.packageJSON.publisher !== 'vscode'; + } catch { + // No publisher + return false; + } + }); +} diff --git a/src/common/localize.ts b/src/common/localize.ts index f9864f7c..0cd0a353 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -144,3 +144,10 @@ export namespace EnvViewStrings { export const selectedGlobalTooltip = l10n.t('This environment is selected for non-workspace files'); export const selectedWorkspaceTooltip = l10n.t('This environment is selected for workspace files'); } + +export namespace PermissionsCommon { + export const allow = l10n.t('Allow'); + export const deny = l10n.t('Deny'); + export const ask = l10n.t('Ask'); + export const setPermissions = l10n.t('Set Permissions'); +} diff --git a/src/common/utils/frameUtils.ts b/src/common/utils/frameUtils.ts index f0b32d4d..057fc5b2 100644 --- a/src/common/utils/frameUtils.ts +++ b/src/common/utils/frameUtils.ts @@ -1,7 +1,8 @@ +import { Uri } from 'vscode'; import { ENVS_EXTENSION_ID, PYTHON_EXTENSION_ID } from '../constants'; import { parseStack } from '../errors/utils'; import { allExtensions, getExtension } from '../extension.apis'; - +import { normalizePath } from './pathUtils'; interface FrameData { filePath: string; functionName: string; @@ -15,38 +16,63 @@ function getFrameData(): FrameData[] { })); } +function getPathFromFrame(frame: FrameData): string { + if (frame.filePath && frame.filePath.startsWith('file://')) { + return Uri.parse(frame.filePath).fsPath; + } + return frame.filePath; +} + export function getCallingExtension(): string { const pythonExts = [ENVS_EXTENSION_ID, PYTHON_EXTENSION_ID]; - const extensions = allExtensions(); const otherExts = extensions.filter((ext) => !pythonExts.includes(ext.id)); - const frames = getFrameData().filter((frame) => !!frame.filePath); + const frames = getFrameData(); + const filePaths: string[] = []; for (const frame of frames) { - const filename = frame.filePath; - if (filename) { - const ext = otherExts.find((ext) => filename.includes(ext.id)); - if (ext) { - return ext.id; - } + if (!frame || !frame.filePath) { + continue; + } + const filePath = normalizePath(getPathFromFrame(frame)); + if (!filePath) { + continue; + } + + if (filePath.toLowerCase().endsWith('extensionhostprocess.js')) { + continue; + } + + if (filePath.startsWith('node:')) { + continue; + } + + filePaths.push(filePath); + + const ext = otherExts.find((ext) => filePath.includes(ext.id)); + if (ext) { + return ext.id; } } // `ms-python.vscode-python-envs` extension in Development mode - const candidates = frames.filter((frame) => otherExts.some((s) => frame.filePath.includes(s.extensionPath))); - const envsExtPath = getExtension(ENVS_EXTENSION_ID)?.extensionPath; - if (!envsExtPath) { + const candidates = filePaths.filter((filePath) => + otherExts.some((s) => filePath.includes(normalizePath(s.extensionPath))), + ); + const envExt = getExtension(ENVS_EXTENSION_ID); + + if (!envExt) { throw new Error('Something went wrong with feature registration'); } - - if (candidates.length === 0 && frames.every((frame) => frame.filePath.startsWith(envsExtPath))) { + const envsExtPath = normalizePath(envExt.extensionPath); + if (candidates.length === 0 && filePaths.every((filePath) => filePath.startsWith(envsExtPath))) { return PYTHON_EXTENSION_ID; - } - - // 3rd party extension in Development mode - const candidateExt = otherExts.find((ext) => candidates[0].filePath.includes(ext.extensionPath)); - if (candidateExt) { - return candidateExt.id; + } else if (candidates.length > 0) { + // 3rd party extension in Development mode + const candidateExt = otherExts.find((ext) => candidates[0].includes(ext.extensionPath)); + if (candidateExt) { + return candidateExt.id; + } } throw new Error('Unable to determine calling extension id, registration failed'); diff --git a/src/common/utils/pathUtils.ts b/src/common/utils/pathUtils.ts index ac9f4cc0..09a3ef29 100644 --- a/src/common/utils/pathUtils.ts +++ b/src/common/utils/pathUtils.ts @@ -1,10 +1,9 @@ -import * as path from 'path'; +import { isWindows } from '../../managers/common/utils'; -export function areSamePaths(a: string, b: string): boolean { - return path.resolve(a) === path.resolve(b); -} - -export function isParentPath(parent: string, child: string): boolean { - const relative = path.relative(path.resolve(parent), path.resolve(child)); - return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative); +export function normalizePath(path: string): string { + const path1 = path.replace(/\\/g, '/'); + if (isWindows()) { + return path1.toLowerCase(); + } + return path1; } diff --git a/src/common/window.apis.ts b/src/common/window.apis.ts index bdbfa8a9..888d2f46 100644 --- a/src/common/window.apis.ts +++ b/src/common/window.apis.ts @@ -7,6 +7,8 @@ import { InputBox, InputBoxOptions, LogOutputChannel, + MessageItem, + MessageOptions, OpenDialogOptions, OutputChannel, Progress, @@ -284,10 +286,38 @@ export async function showInputBoxWithButtons( } } -export function showWarningMessage(message: string, ...items: string[]): Thenable { +export function showWarningMessage(message: string, ...items: T[]): Thenable; +export function showWarningMessage( + message: string, + options: MessageOptions, + ...items: T[] +): Thenable; +export function showWarningMessage(message: string, ...items: T[]): Thenable; +export function showWarningMessage( + message: string, + options: MessageOptions, + ...items: T[] +): Thenable; +export function showWarningMessage(message: string, ...items: any[]): Thenable { return window.showWarningMessage(message, ...items); } +export function showInformationMessage(message: string, ...items: T[]): Thenable; +export function showInformationMessage( + message: string, + options: MessageOptions, + ...items: T[] +): Thenable; +export function showInformationMessage(message: string, ...items: T[]): Thenable; +export function showInformationMessage( + message: string, + options: MessageOptions, + ...items: T[] +): Thenable; +export function showInformationMessage(message: string, ...items: any[]): Thenable { + return window.showInformationMessage(message, ...items); +} + export function showInputBox(options?: InputBoxOptions, token?: CancellationToken): Thenable { return window.showInputBox(options, token); } diff --git a/src/extension.ts b/src/extension.ts index ac5a1b34..231289dc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,6 +23,7 @@ import { runInDedicatedTerminalCommand, handlePackageUninstall, copyPathToClipboard, + getUninstallPackages, } from './features/envCommands'; import { registerCondaFeatures } from './managers/conda/main'; import { registerSystemPythonFeatures } from './managers/builtin/main'; @@ -57,6 +58,11 @@ import { registerTools } from './common/lm.apis'; import { GetPackagesTool } from './features/copilotTools'; import { TerminalActivationImpl } from './features/terminal/terminalActivationState'; import { getEnvironmentForTerminal } from './features/terminal/utils'; +import { + checkPackageManagementPermissions, + handlePermissionsCommand, + PackageManagerPermissionsImpl, +} from './features/permissions/packageManagerPermissions'; export async function activate(context: ExtensionContext): Promise { const start = new StopWatch(); @@ -93,7 +99,9 @@ export async function activate(context: ExtensionContext): Promise { + await handlePermissionsCommand(pkgPerm); + }), + commands.registerCommand('python-envs.resetPermissions', async () => { + await pkgPerm.resetPermissions(); + }), commands.registerCommand('python-envs.viewLogs', () => outputChannel.show()), commands.registerCommand('python-envs.refreshManager', async (item) => { await refreshManagerCommand(item); @@ -138,9 +152,20 @@ export async function activate(context: ExtensionContext): Promise { + const result = await checkPackageManagementPermissions(pkgPerm, 'uninstall', getUninstallPackages(context)); + if (!result) { + return; + } + await handlePackageUninstall(context, envManagers); }), commands.registerCommand('python-envs.set', async (item) => { diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 6d0c4e4c..950f38b1 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -181,25 +181,34 @@ export async function removeEnvironmentCommand(context: unknown, managers: Envir } export async function handlePackagesCommand( - packageManager: InternalPackageManager, environment: PythonEnvironment, + packageManager: InternalPackageManager, ): Promise { const action = await pickPackageOptions(); try { if (action === Common.install) { - await packageManager.install(environment); + await packageManager.install(environment, undefined, { showSkipOption: false }); } else if (action === Common.uninstall) { await packageManager.uninstall(environment); } } catch (ex) { if (ex === QuickInputButtons.Back) { - return handlePackagesCommand(packageManager, environment); + return handlePackagesCommand(environment, packageManager); } throw ex; } } +export function getUninstallPackages(context: unknown): string[] | undefined { + if (context instanceof PackageTreeItem) { + return [(context as PackageTreeItem).pkg.name]; + } else if (context instanceof ProjectPackage) { + return [(context as ProjectPackage).pkg.name]; + } + return undefined; +} + export async function handlePackageUninstall(context: unknown, em: EnvironmentManagers) { if (context instanceof PackageTreeItem || context instanceof ProjectPackage) { const moduleName = context.pkg.name; diff --git a/src/features/permissions/packageManagerPermissions.ts b/src/features/permissions/packageManagerPermissions.ts new file mode 100644 index 00000000..b321a044 --- /dev/null +++ b/src/features/permissions/packageManagerPermissions.ts @@ -0,0 +1,193 @@ +import { l10n, SecretStorage } from 'vscode'; +import { pickExtension } from './pickers'; +import { showInformationMessage, showWarningMessage } from '../../common/window.apis'; +import { PermissionsCommon } from '../../common/localize'; +import { traceLog } from '../../common/logging'; +import { allExtensions } from '../../common/extension.apis'; +import { getCallingExtension } from '../../common/utils/frameUtils'; + +type PermissionType = 'Ask' | 'Allow' | 'Deny'; +function validatePermissionType(value: string): value is PermissionType { + return ['Ask', 'Allow', 'Deny'].includes(value); +} + +export interface PermissionsManager { + getPermissions(extensionId: string): Promise; + setPermissions(extensionId: string, permissions: T | undefined): Promise; + resetPermissions(): Promise; +} + +export interface PackageManagerPermissions extends PermissionsManager {} +export class PackageManagerPermissionsImpl implements PackageManagerPermissions { + constructor(private readonly secretStore: SecretStorage) {} + + async getPermissions(extensionId: string): Promise { + const permission: string | undefined = await this.secretStore.get( + `python-envs.permissions.packageManagement.${extensionId}`, + ); + if (permission) { + if (validatePermissionType(permission)) { + return permission as PermissionType; + } + } + // else if (extensionId === PYTHON_EXTENSION_ID || extensionId === ENVS_EXTENSION_ID) { + // // Default to allow for the Python extension and the Envs extension + // return 'Allow'; + // } + return undefined; + } + + async setPermissions(extensionId: string, permissions: PermissionType): Promise { + await this.secretStore.store(`python-envs.permissions.packageManagement.${extensionId}`, permissions); + } + + async resetPermissions(): Promise { + const ids = allExtensions().map((e) => `python-envs.permissions.packageManagement.${e.id}`); + await Promise.all(ids.map((id) => this.secretStore.delete(id))); + traceLog('All package management permissions have been reset.'); + } +} + +function getPackageListAsString(packages: string[]): string { + const maxStrLength = 100; + let result = ''; + let count = 0; + + for (const pkg of packages) { + if (result.length + pkg.length + (result ? 2 : 0) > maxStrLength) { + break; + } + result += (result ? ', ' : '') + pkg; + count++; + } + + const remaining = packages.length - count; + if (remaining > 0) { + result += l10n.t('... and {0} others', remaining); + } + + return result; +} + +async function configureFirstTimePermissions(extensionId: string, pm: PackageManagerPermissions) { + const response = await showInformationMessage( + l10n.t( + 'The extension {0} wants to install, upgrade, or uninstall packages from your Python environments', + extensionId, + ), + { modal: true }, + { + title: PermissionsCommon.ask, + isCloseAffordance: true, + }, + { title: PermissionsCommon.allow }, + { title: PermissionsCommon.deny }, + ); + if (response?.title === PermissionsCommon.ask) { + await pm.setPermissions(extensionId, 'Ask'); + traceLog('Package management permissions set to "ask" for extension: ', extensionId); + return true; + } else if (response?.title === PermissionsCommon.allow) { + await pm.setPermissions(extensionId, 'Allow'); + traceLog('Package management permissions set to "allow" for extension: ', extensionId); + return true; + } else if (response?.title === PermissionsCommon.deny) { + await pm.setPermissions(extensionId, 'Deny'); + traceLog('Package management permissions set to "deny" for extension: ', extensionId); + return false; + } else { + traceLog('Package management permissions not changed for extension: ', extensionId); + return false; + } +} + +export async function checkPackageManagementPermissions( + pm: PackageManagerPermissions, + mode: 'install' | 'uninstall' | 'changes', + packages?: string[], +): Promise { + const extensionId = getCallingExtension(); + + const currentPermission = await pm.getPermissions(extensionId); + if (currentPermission === 'Allow') { + return true; + } else if (currentPermission === 'Deny') { + traceLog(`Package management permissions denied for extension: ${extensionId}`); + setImmediate(async () => { + const response = await showWarningMessage( + l10n.t( + 'The extension `{0}` is not allowed to {1} packages into your Python environment.', + extensionId, + mode, + ), + PermissionsCommon.setPermissions, + ); + if (response === PermissionsCommon.setPermissions) { + handlePermissionsCommand(pm, extensionId); + } + }); + return false; + } else if (currentPermission === undefined) { + return await configureFirstTimePermissions(extensionId, pm); + } + + // Below handles Permission level is 'Ask' + let message = l10n.t('The extension `{0}` wants to install packages into your Python environment.', extensionId); + if (mode === 'uninstall') { + message = l10n.t('The extension `{0}` wants to uninstall packages from your Python environment.', extensionId); + } else if (mode === 'changes') { + message = l10n.t('The extension `{0}` wants to make changes to your Python environment.', extensionId); + } + + const response = await showInformationMessage( + message, + { + modal: true, + detail: packages ? l10n.t('Packages: {0}', getPackageListAsString(packages)) : undefined, + }, + { title: PermissionsCommon.allow }, + { title: PermissionsCommon.deny, isCloseAffordance: true }, + ); + if (response?.title === PermissionsCommon.allow) { + traceLog(`Package management permissions granted for extension: ${extensionId}`); + return true; + } + traceLog(`Package management permissions denied for extension: ${extensionId}`); + return false; +} + +export async function handlePermissionsCommand(pm: PermissionsManager, extensionId?: string) { + extensionId = extensionId ?? (await pickExtension()); + if (!extensionId) { + return; + } + + const currentPermission = await pm.getPermissions(extensionId); + + const response = await showInformationMessage( + l10n.t( + 'Set permissions for the extension {0} to install, upgrade, or uninstall packages from your Python environments', + extensionId, + ), + { + modal: true, + detail: currentPermission ? l10n.t('Current permission: {0}', currentPermission) : undefined, + }, + PermissionsCommon.ask, + PermissionsCommon.allow, + PermissionsCommon.deny, + ); + + if (response === PermissionsCommon.ask) { + await pm.setPermissions(extensionId, 'Ask'); + traceLog('Package management permissions set to "ask" for extension: ', extensionId); + } else if (response === PermissionsCommon.allow) { + await pm.setPermissions(extensionId, 'Allow'); + traceLog('Package management permissions set to "allow" for extension: ', extensionId); + } else if (response === PermissionsCommon.deny) { + await pm.setPermissions(extensionId, 'Deny'); + traceLog('Package management permissions set to "deny" for extension: ', extensionId); + } else { + traceLog('Package management permissions not changed for extension: ', extensionId); + } +} diff --git a/src/features/permissions/pickers.ts b/src/features/permissions/pickers.ts new file mode 100644 index 00000000..9a202097 --- /dev/null +++ b/src/features/permissions/pickers.ts @@ -0,0 +1,31 @@ +import { Extension, QuickPickItem } from 'vscode'; +import { allExtensions } from '../../common/extension.apis'; +import { showQuickPick } from '../../common/window.apis'; + +function getExtensionName(ext: Extension): string { + try { + return ext.packageJSON.name; + } catch { + return ''; + } +} + +function getExtensionItems(): QuickPickItem[] { + const extensions = allExtensions(); + return extensions.map((ext) => { + return { + description: ext.id, + label: getExtensionName(ext), + }; + }); +} + +export async function pickExtension(): Promise { + const items = getExtensionItems(); + + const result = await showQuickPick(items, { + ignoreFocusOut: true, + }); + + return result?.description; +} diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index 780413ef..c64a7b72 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -46,6 +46,7 @@ import { runAsTask } from './execution/runAsTask'; import { runInTerminal } from './terminal/runInTerminal'; import { runInBackground } from './execution/runInBackground'; import { EnvVarManager } from './execution/envVariableManager'; +import { checkPackageManagementPermissions, PackageManagerPermissions } from './permissions/packageManagerPermissions'; class PythonEnvironmentApiImpl implements PythonEnvironmentApi { private readonly _onDidChangeEnvironments = new EventEmitter(); @@ -60,6 +61,7 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi { private readonly projectCreators: ProjectCreators, private readonly terminalManager: TerminalManager, private readonly envVarManager: EnvVarManager, + private readonly pkgPerm: PackageManagerPermissions, private readonly disposables: Disposable[] = [], ) { this.disposables.push( @@ -216,14 +218,28 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi { } return new Disposable(() => disposables.forEach((d) => d.dispose())); } - installPackages(context: PythonEnvironment, packages: string[], options: PackageInstallOptions): Promise { + async installPackages( + context: PythonEnvironment, + packages: string[], + options: PackageInstallOptions, + ): Promise { + const result = await checkPackageManagementPermissions(this.pkgPerm, 'install', packages); + if (!result) { + return Promise.reject(new Error('Permission denied')); + } + const manager = this.envManagers.getPackageManager(context); if (!manager) { return Promise.reject(new Error('No package manager found')); } return manager.install(context, packages, options); } - uninstallPackages(context: PythonEnvironment, packages: Package[] | string[]): Promise { + async uninstallPackages(context: PythonEnvironment, packages: string[]): Promise { + const result = await checkPackageManagementPermissions(this.pkgPerm, 'uninstall', packages); + if (!result) { + return Promise.reject(new Error('Permission denied')); + } + const manager = this.envManagers.getPackageManager(context); if (!manager) { return Promise.reject(new Error('No package manager found')); @@ -324,9 +340,10 @@ export function setPythonApi( projectCreators: ProjectCreators, terminalManager: TerminalManager, envVarManager: EnvVarManager, + pkgPerm: PackageManagerPermissions, ) { _deferred.resolve( - new PythonEnvironmentApiImpl(envMgr, projectMgr, projectCreators, terminalManager, envVarManager), + new PythonEnvironmentApiImpl(envMgr, projectMgr, projectCreators, terminalManager, envVarManager, pkgPerm), ); } diff --git a/src/managers/builtin/pipManager.ts b/src/managers/builtin/pipManager.ts index d4c5a736..986299df 100644 --- a/src/managers/builtin/pipManager.ts +++ b/src/managers/builtin/pipManager.ts @@ -54,7 +54,7 @@ export class PipPackageManager implements PackageManager, Disposable { if (selected.length === 0) { const projects = this.venv.getProjectsByEnvironment(environment); - selected = (await getWorkspacePackagesToInstall(this.api, projects)) ?? []; + selected = (await getWorkspacePackagesToInstall(this.api, options, projects)) ?? []; } if (selected.length === 0) { @@ -135,7 +135,13 @@ export class PipPackageManager implements PackageManager, Disposable { title: 'Refreshing packages', }, async () => { - this.packages.set(environment.envId.id, await refreshPackages(environment, this.api, this)); + const before = this.packages.get(environment.envId.id) ?? []; + const after = await refreshPackages(environment, this.api, this); + const changes = getChanges(before, after); + this.packages.set(environment.envId.id, after); + if (changes.length > 0) { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + } }, ); } diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index 259e5ceb..c3d1175f 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -4,7 +4,7 @@ import * as tomljs from '@iarna/toml'; import { LogOutputChannel, ProgressLocation, QuickInputButtons, Uri } from 'vscode'; import { showQuickPickWithButtons, withProgress } from '../../common/window.apis'; import { PackageManagement, Pickers, VenvManagerStrings } from '../../common/localize'; -import { PythonEnvironmentApi, PythonProject } from '../../api'; +import { PackageInstallOptions, PythonEnvironmentApi, PythonProject } from '../../api'; import { findFiles } from '../../common/workspace.apis'; import { EXTENSION_ROOT_DIR } from '../../common/constants'; import { Installable, selectFromCommonPackagesToInstall, selectFromInstallableToInstall } from '../common/pickers'; @@ -75,6 +75,7 @@ async function getCommonPackages(): Promise { async function selectWorkspaceOrCommon( installable: Installable[], common: Installable[], + showSkipOption: boolean, ): Promise { if (installable.length === 0 && common.length === 0) { return undefined; @@ -95,19 +96,20 @@ async function selectWorkspaceOrCommon( }); } - if (items.length > 0) { + if (showSkipOption && items.length > 0) { items.push({ label: PackageManagement.skipPackageInstallation }); - } else { - return undefined; } - const selected = await showQuickPickWithButtons(items, { - placeHolder: Pickers.Packages.selectOption, - ignoreFocusOut: true, - showBackButton: true, - matchOnDescription: false, - matchOnDetail: false, - }); + const selected = + items.length === 1 + ? items[0] + : await showQuickPickWithButtons(items, { + placeHolder: Pickers.Packages.selectOption, + ignoreFocusOut: true, + showBackButton: true, + matchOnDescription: false, + matchOnDetail: false, + }); if (selected && !Array.isArray(selected)) { try { @@ -122,7 +124,7 @@ async function selectWorkspaceOrCommon( // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (ex: any) { if (ex === QuickInputButtons.Back) { - return selectWorkspaceOrCommon(installable, common); + return selectWorkspaceOrCommon(installable, common, showSkipOption); } } } @@ -131,11 +133,12 @@ async function selectWorkspaceOrCommon( export async function getWorkspacePackagesToInstall( api: PythonEnvironmentApi, + options?: PackageInstallOptions, project?: PythonProject[], ): Promise { const installable = (await getProjectInstallable(api, project)) ?? []; const common = await getCommonPackages(); - return selectWorkspaceOrCommon(installable, common); + return selectWorkspaceOrCommon(installable, common, !!options?.showSkipOption); } export async function getProjectInstallable( diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index 5e43d54c..e3460746 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -400,7 +400,11 @@ export async function createPythonVenv( os.platform() === 'win32' ? path.join(envPath, 'Scripts', 'python.exe') : path.join(envPath, 'bin', 'python'); const project = api.getPythonProject(venvRoot); - const packages = await getWorkspacePackagesToInstall(api, project ? [project] : undefined); + const packages = await getWorkspacePackagesToInstall( + api, + { showSkipOption: true }, + project ? [project] : undefined, + ); return await withProgress( { @@ -455,10 +459,13 @@ export async function removeVenv(environment: PythonEnvironment, log: LogOutputC const confirm = await showWarningMessage( l10n.t('Are you sure you want to remove {0}?', envPath), - Common.yes, - Common.no, + { + modal: true, + }, + { title: Common.yes }, + { title: Common.no, isCloseAffordance: true }, ); - if (confirm === Common.yes) { + if (confirm?.title === Common.yes) { await withProgress( { location: ProgressLocation.Notification, diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index fe775081..7125dfe5 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -56,7 +56,7 @@ export class CondaPackageManager implements PackageManager, Disposable { let selected: string[] = packages ?? []; if (selected.length === 0) { - selected = (await getCommonCondaPackagesToInstall()) ?? []; + selected = (await getCommonCondaPackagesToInstall(options)) ?? []; } if (selected.length === 0) { @@ -131,14 +131,20 @@ export class CondaPackageManager implements PackageManager, Disposable { }, ); } - async refresh(context: PythonEnvironment): Promise { + async refresh(environment: PythonEnvironment): Promise { await withProgress( { location: ProgressLocation.Window, title: CondaStrings.condaRefreshingPackages, }, async () => { - this.packages.set(context.envId.id, await refreshPackages(context, this.api, this)); + const before = this.packages.get(environment.envId.id) ?? []; + const after = await refreshPackages(environment, this.api, this); + const changes = getChanges(before, after); + this.packages.set(environment.envId.id, after); + if (changes.length > 0) { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + } }, ); } diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 49d822b8..c240fbf8 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -761,7 +761,10 @@ async function getCommonPackages(): Promise { } } -async function selectCommonPackagesOrSkip(common: Installable[]): Promise { +async function selectCommonPackagesOrSkip( + common: Installable[], + showSkipOption: boolean, +): Promise { if (common.length === 0) { return undefined; } @@ -774,19 +777,20 @@ async function selectCommonPackagesOrSkip(common: Installable[]): Promise 0) { + if (showSkipOption && items.length > 0) { items.push({ label: PackageManagement.skipPackageInstallation }); - } else { - return undefined; } - const selected = await showQuickPickWithButtons(items, { - placeHolder: Pickers.Packages.selectOption, - ignoreFocusOut: true, - showBackButton: true, - matchOnDescription: false, - matchOnDetail: false, - }); + const selected = + items.length === 1 + ? items[0] + : await showQuickPickWithButtons(items, { + placeHolder: Pickers.Packages.selectOption, + ignoreFocusOut: true, + showBackButton: true, + matchOnDescription: false, + matchOnDetail: false, + }); if (selected && !Array.isArray(selected)) { try { @@ -799,15 +803,15 @@ async function selectCommonPackagesOrSkip(common: Installable[]): Promise { +export async function getCommonCondaPackagesToInstall(options?: PackageInstallOptions): Promise { const common = await getCommonPackages(); - const selected = await selectCommonPackagesOrSkip(common); + const selected = await selectCommonPackagesOrSkip(common, !!options?.showSkipOption); return selected; }