diff --git a/src/common/localize.ts b/src/common/localize.ts index 3caa8a9b..1674a397 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -156,6 +156,12 @@ export namespace PyenvStrings { export const pyenvRefreshing = l10n.t('Refreshing Pyenv Python versions'); } +export namespace PipenvStrings { + export const pipenvManager = l10n.t('Manages Pipenv environments'); + export const pipenvDiscovering = l10n.t('Discovering Pipenv environments'); + export const pipenvRefreshing = l10n.t('Refreshing Pipenv environments'); +} + export namespace PoetryStrings { export const poetryManager = l10n.t('Manages Poetry environments'); export const poetryDiscovering = l10n.t('Discovering Poetry environments'); diff --git a/src/extension.ts b/src/extension.ts index 22b2f658..159ee260 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -76,6 +76,7 @@ import { } from './managers/common/nativePythonFinder'; import { IDisposable } from './managers/common/types'; import { registerCondaFeatures } from './managers/conda/main'; +import { registerPipenvFeatures } from './managers/pipenv/main'; import { registerPoetryFeatures } from './managers/poetry/main'; import { registerPyenvFeatures } from './managers/pyenv/main'; @@ -562,6 +563,7 @@ export async function activate(context: ExtensionContext): Promise { + const api: PythonEnvironmentApi = await getPythonApi(); + + try { + const pipenv = await getPipenv(nativeFinder); + + if (pipenv) { + const mgr = new PipenvManager(nativeFinder, api); + + disposables.push(mgr, api.registerEnvironmentManager(mgr)); + } else { + traceInfo('Pipenv not found, turning off pipenv features.'); + } + } catch (ex) { + traceInfo('Pipenv not found, turning off pipenv features.', ex); + } +} diff --git a/src/managers/pipenv/pipenvManager.ts b/src/managers/pipenv/pipenvManager.ts new file mode 100644 index 00000000..951a8dcd --- /dev/null +++ b/src/managers/pipenv/pipenvManager.ts @@ -0,0 +1,284 @@ +import { EventEmitter, MarkdownString, ProgressLocation, Uri } from 'vscode'; +import { + CreateEnvironmentOptions, + CreateEnvironmentScope, + DidChangeEnvironmentEventArgs, + DidChangeEnvironmentsEventArgs, + EnvironmentChangeKind, + EnvironmentManager, + GetEnvironmentScope, + GetEnvironmentsScope, + IconPath, + PythonEnvironment, + PythonEnvironmentApi, + PythonProject, + RefreshEnvironmentsScope, + ResolveEnvironmentContext, + SetEnvironmentScope, +} from '../../api'; +import { PipenvStrings } from '../../common/localize'; +import { createDeferred, Deferred } from '../../common/utils/deferred'; +import { withProgress } from '../../common/window.apis'; +import { NativePythonFinder } from '../common/nativePythonFinder'; +import { + clearPipenvCache, + getPipenvForGlobal, + getPipenvForWorkspace, + refreshPipenv, + resolvePipenvPath, + setPipenvForGlobal, + setPipenvForWorkspace, + setPipenvForWorkspaces, +} from './pipenvUtils'; + +export class PipenvManager implements EnvironmentManager { + private collection: PythonEnvironment[] = []; + private fsPathToEnv: Map = new Map(); + private globalEnv: PythonEnvironment | undefined; + + private readonly _onDidChangeEnvironment = new EventEmitter(); + public readonly onDidChangeEnvironment = this._onDidChangeEnvironment.event; + + private readonly _onDidChangeEnvironments = new EventEmitter(); + public readonly onDidChangeEnvironments = this._onDidChangeEnvironments.event; + + public readonly name: string; + public readonly displayName: string; + public readonly preferredPackageManagerId: string; + public readonly description?: string; + public readonly tooltip: string | MarkdownString; + public readonly iconPath?: IconPath; + + private _initialized: Deferred | undefined; + + constructor(public readonly nativeFinder: NativePythonFinder, public readonly api: PythonEnvironmentApi) { + this.name = 'pipenv'; + this.displayName = 'Pipenv'; + this.preferredPackageManagerId = 'ms-python.python:pip'; + this.tooltip = new MarkdownString(PipenvStrings.pipenvManager, true); + } + + public dispose() { + this.collection = []; + this.fsPathToEnv.clear(); + this._onDidChangeEnvironment.dispose(); + this._onDidChangeEnvironments.dispose(); + } + + async initialize(): Promise { + if (this._initialized) { + return this._initialized.promise; + } + + this._initialized = createDeferred(); + + await withProgress( + { + location: ProgressLocation.Window, + title: PipenvStrings.pipenvDiscovering, + }, + async () => { + this.collection = await refreshPipenv(false, this.nativeFinder, this.api, this); + await this.loadEnvMap(); + + this._onDidChangeEnvironments.fire( + this.collection.map((e) => ({ environment: e, kind: EnvironmentChangeKind.add })), + ); + }, + ); + this._initialized.resolve(); + } + + private async loadEnvMap() { + // Load environment mappings for projects + const projects = this.api.getPythonProjects(); + for (const project of projects) { + const envPath = await getPipenvForWorkspace(project.uri.fsPath); + if (envPath) { + const env = this.findEnvironmentByPath(envPath); + if (env) { + this.fsPathToEnv.set(project.uri.fsPath, env); + } + } + } + + // Load global environment + const globalEnvPath = await getPipenvForGlobal(); + if (globalEnvPath) { + this.globalEnv = this.findEnvironmentByPath(globalEnvPath); + } + } + + private findEnvironmentByPath(fsPath: string): PythonEnvironment | undefined { + return this.collection.find( + (env) => env.environmentPath.fsPath === fsPath || env.execInfo?.run.executable === fsPath, + ); + } + + async create?( + _scope: CreateEnvironmentScope, + _options?: CreateEnvironmentOptions, + ): Promise { + // To be implemented + return undefined; + } + + async refresh(scope: RefreshEnvironmentsScope): Promise { + const hardRefresh = scope === undefined; // hard refresh when scope is undefined + + await withProgress( + { + location: ProgressLocation.Window, + title: PipenvStrings.pipenvRefreshing, + }, + async () => { + const oldCollection = [...this.collection]; + this.collection = await refreshPipenv(hardRefresh, this.nativeFinder, this.api, this); + await this.loadEnvMap(); + + // Fire change events for environments that were added or removed + const changes: { environment: PythonEnvironment; kind: EnvironmentChangeKind }[] = []; + + // Find removed environments + oldCollection.forEach((oldEnv) => { + if (!this.collection.find((newEnv) => newEnv.envId.id === oldEnv.envId.id)) { + changes.push({ environment: oldEnv, kind: EnvironmentChangeKind.remove }); + } + }); + + // Find added environments + this.collection.forEach((newEnv) => { + if (!oldCollection.find((oldEnv) => oldEnv.envId.id === newEnv.envId.id)) { + changes.push({ environment: newEnv, kind: EnvironmentChangeKind.add }); + } + }); + + if (changes.length > 0) { + this._onDidChangeEnvironments.fire(changes); + } + }, + ); + } + + async getEnvironments(scope: GetEnvironmentsScope): Promise { + await this.initialize(); + + if (scope === 'all') { + return Array.from(this.collection); + } + + if (scope === 'global') { + // Return all environments for global scope + return Array.from(this.collection); + } + + if (scope instanceof Uri) { + const project = this.api.getPythonProject(scope); + if (project) { + const env = this.fsPathToEnv.get(project.uri.fsPath); + return env ? [env] : []; + } + } + + return []; + } + + async set(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise { + if (scope === undefined) { + // Global scope + const before = this.globalEnv; + this.globalEnv = environment; + await setPipenvForGlobal(environment?.environmentPath.fsPath); + + if (before?.envId.id !== this.globalEnv?.envId.id) { + this._onDidChangeEnvironment.fire({ uri: undefined, old: before, new: this.globalEnv }); + } + return; + } + + if (scope instanceof Uri) { + // Single project scope + const project = this.api.getPythonProject(scope); + if (!project) { + return; + } + + const before = this.fsPathToEnv.get(project.uri.fsPath); + if (environment) { + this.fsPathToEnv.set(project.uri.fsPath, environment); + } else { + this.fsPathToEnv.delete(project.uri.fsPath); + } + + await setPipenvForWorkspace(project.uri.fsPath, environment?.environmentPath.fsPath); + + if (before?.envId.id !== environment?.envId.id) { + this._onDidChangeEnvironment.fire({ uri: scope, old: before, new: environment }); + } + } + + if (Array.isArray(scope) && scope.every((u) => u instanceof Uri)) { + // Multiple projects scope + const projects: PythonProject[] = []; + scope + .map((s) => this.api.getPythonProject(s)) + .forEach((p) => { + if (p) { + projects.push(p); + } + }); + + const before: Map = new Map(); + projects.forEach((p) => { + before.set(p.uri.fsPath, this.fsPathToEnv.get(p.uri.fsPath)); + if (environment) { + this.fsPathToEnv.set(p.uri.fsPath, environment); + } else { + this.fsPathToEnv.delete(p.uri.fsPath); + } + }); + + await setPipenvForWorkspaces( + projects.map((p) => p.uri.fsPath), + environment?.environmentPath.fsPath, + ); + + projects.forEach((p) => { + const b = before.get(p.uri.fsPath); + if (b?.envId.id !== environment?.envId.id) { + this._onDidChangeEnvironment.fire({ uri: p.uri, old: b, new: environment }); + } + }); + } + } + + async get(scope: GetEnvironmentScope): Promise { + await this.initialize(); + + if (scope === undefined) { + return this.globalEnv; + } + + if (scope instanceof Uri) { + const project = this.api.getPythonProject(scope); + if (project) { + return this.fsPathToEnv.get(project.uri.fsPath); + } + } + + return undefined; + } + + async resolve(context: ResolveEnvironmentContext): Promise { + await this.initialize(); + return resolvePipenvPath(context.fsPath, this.nativeFinder, this.api, this); + } + + async clearCache?(): Promise { + await clearPipenvCache(); + this.collection = []; + this.fsPathToEnv.clear(); + this.globalEnv = undefined; + this._initialized = undefined; + } +} diff --git a/src/managers/pipenv/pipenvUtils.ts b/src/managers/pipenv/pipenvUtils.ts new file mode 100644 index 00000000..3c271d44 --- /dev/null +++ b/src/managers/pipenv/pipenvUtils.ts @@ -0,0 +1,242 @@ +// Utility functions for Pipenv environment management + +import * as path from 'path'; +import { Uri } from 'vscode'; +import which from 'which'; +import { + EnvironmentManager, + PythonCommandRunConfiguration, + PythonEnvironment, + PythonEnvironmentApi, + PythonEnvironmentInfo, +} from '../../api'; +import { ENVS_EXTENSION_ID } from '../../common/constants'; +import { traceError, traceInfo } from '../../common/logging'; +import { getWorkspacePersistentState } from '../../common/persistentState'; +import { + isNativeEnvInfo, + NativeEnvInfo, + NativeEnvManagerInfo, + NativePythonEnvironmentKind, + NativePythonFinder, +} from '../common/nativePythonFinder'; +import { getShellActivationCommands, shortVersion } from '../common/utils'; + +export const PIPENV_PATH_KEY = `${ENVS_EXTENSION_ID}:pipenv:PIPENV_PATH`; +export const PIPENV_WORKSPACE_KEY = `${ENVS_EXTENSION_ID}:pipenv:WORKSPACE_SELECTED`; +export const PIPENV_GLOBAL_KEY = `${ENVS_EXTENSION_ID}:pipenv:GLOBAL_SELECTED`; + +let pipenvPath: string | undefined; + +async function findPipenv(): Promise { + try { + return await which('pipenv'); + } catch { + return undefined; + } +} + +async function setPipenv(pipenv: string): Promise { + pipenvPath = pipenv; + const state = await getWorkspacePersistentState(); + await state.set(PIPENV_PATH_KEY, pipenv); +} + +export async function clearPipenvCache(): Promise { + pipenvPath = undefined; +} + +export async function getPipenv(native?: NativePythonFinder): Promise { + if (pipenvPath) { + return pipenvPath; + } + + const state = await getWorkspacePersistentState(); + pipenvPath = await state.get(PIPENV_PATH_KEY); + if (pipenvPath) { + traceInfo(`Using pipenv from persistent state: ${pipenvPath}`); + return pipenvPath; + } + + // Try to find pipenv in PATH + const foundPipenv = await findPipenv(); + if (foundPipenv) { + pipenvPath = foundPipenv; + traceInfo(`Found pipenv in PATH: ${foundPipenv}`); + return foundPipenv; + } + + // Use native finder as fallback + if (native) { + const data = await native.refresh(false); + const managers = data + .filter((e) => !isNativeEnvInfo(e)) + .map((e) => e as NativeEnvManagerInfo) + .filter((e) => e.tool.toLowerCase() === 'pipenv'); + if (managers.length > 0) { + pipenvPath = managers[0].executable; + traceInfo(`Using pipenv from native finder: ${pipenvPath}`); + await state.set(PIPENV_PATH_KEY, pipenvPath); + return pipenvPath; + } + } + + traceInfo('Pipenv not found'); + return undefined; +} + +async function nativeToPythonEnv( + info: NativeEnvInfo, + api: PythonEnvironmentApi, + manager: EnvironmentManager, +): Promise { + if (!(info.prefix && info.executable && info.version)) { + traceError(`Incomplete pipenv environment info: ${JSON.stringify(info)}`); + return undefined; + } + + const sv = shortVersion(info.version); + const name = info.name || info.displayName || path.basename(info.prefix); + const displayName = info.displayName || `pipenv (${sv})`; + + // Derive the environment's bin/scripts directory from the python executable + const binDir = path.dirname(info.executable); + let shellActivation: Map = new Map(); + let shellDeactivation: Map = new Map(); + + try { + const maps = await getShellActivationCommands(binDir); + shellActivation = maps.shellActivation; + shellDeactivation = maps.shellDeactivation; + } catch (ex) { + traceError(`Failed to compute shell activation commands for pipenv at ${binDir}: ${ex}`); + } + + const environment: PythonEnvironmentInfo = { + name: name, + displayName: displayName, + shortDisplayName: displayName, + displayPath: info.prefix, + version: info.version, + environmentPath: Uri.file(info.prefix), + description: undefined, + tooltip: info.prefix, + execInfo: { + run: { executable: info.executable }, + shellActivation, + shellDeactivation, + }, + sysPrefix: info.prefix, + }; + + return api.createPythonEnvironmentItem(environment, manager); +} + +export async function refreshPipenv( + hardRefresh: boolean, + nativeFinder: NativePythonFinder, + api: PythonEnvironmentApi, + manager: EnvironmentManager, +): Promise { + traceInfo('Refreshing pipenv environments'); + const data = await nativeFinder.refresh(hardRefresh); + + let pipenv = await getPipenv(); + + if (pipenv === undefined) { + const managers = data + .filter((e) => !isNativeEnvInfo(e)) + .map((e) => e as NativeEnvManagerInfo) + .filter((e) => e.tool.toLowerCase() === 'pipenv'); + + if (managers.length > 0) { + pipenv = managers[0].executable; + await setPipenv(pipenv); + } + } + + const envs = data + .filter((e) => isNativeEnvInfo(e)) + .map((e) => e as NativeEnvInfo) + .filter((e) => e.kind === NativePythonEnvironmentKind.pipenv); + + const collection: PythonEnvironment[] = []; + + for (const e of envs) { + if (pipenv) { + const environment = await nativeToPythonEnv(e, api, manager); + if (environment) { + collection.push(environment); + } + } + } + + traceInfo(`Found ${collection.length} pipenv environments`); + return collection; +} + +export async function resolvePipenvPath( + fsPath: string, + nativeFinder: NativePythonFinder, + api: PythonEnvironmentApi, + manager: EnvironmentManager, +): Promise { + const resolved = await nativeFinder.resolve(fsPath); + + if (resolved.kind === NativePythonEnvironmentKind.pipenv) { + const pipenv = await getPipenv(nativeFinder); + if (pipenv) { + return await nativeToPythonEnv(resolved, api, manager); + } + } + + return undefined; +} + +// Persistence functions for workspace/global environment selection +export async function getPipenvForGlobal(): Promise { + const state = await getWorkspacePersistentState(); + return await state.get(PIPENV_GLOBAL_KEY); +} + +export async function setPipenvForGlobal(pipenvPath: string | undefined): Promise { + const state = await getWorkspacePersistentState(); + await state.set(PIPENV_GLOBAL_KEY, pipenvPath); +} + +export async function getPipenvForWorkspace(fsPath: string): Promise { + const state = await getWorkspacePersistentState(); + const data: { [key: string]: string } | undefined = await state.get(PIPENV_WORKSPACE_KEY); + if (data) { + try { + return data[fsPath]; + } catch { + return undefined; + } + } + return undefined; +} + +export async function setPipenvForWorkspace(fsPath: string, envPath: string | undefined): Promise { + const state = await getWorkspacePersistentState(); + const data: { [key: string]: string } = (await state.get(PIPENV_WORKSPACE_KEY)) ?? {}; + if (envPath) { + data[fsPath] = envPath; + } else { + delete data[fsPath]; + } + await state.set(PIPENV_WORKSPACE_KEY, data); +} + +export async function setPipenvForWorkspaces(fsPath: string[], envPath: string | undefined): Promise { + const state = await getWorkspacePersistentState(); + const data: { [key: string]: string } = (await state.get(PIPENV_WORKSPACE_KEY)) ?? {}; + fsPath.forEach((s) => { + if (envPath) { + data[s] = envPath; + } else { + delete data[s]; + } + }); + await state.set(PIPENV_WORKSPACE_KEY, data); +}