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(); public readonly onDidChangeEnvironments = this._onDidChangeEnvironments.event; - constructor(private readonly nativeFinder: NativePythonFinder, private readonly api: PythonEnvironmentApi) { + + 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.preferredPackageManagerId = 'ms-python.python:pipenv'; this.tooltip = new MarkdownString(PipenvStrings.pipenvManager, true); } - name: string; - displayName: string; - preferredPackageManagerId: string; - description?: string; - tooltip: string | MarkdownString; - iconPath?: IconPath; - 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 + ); } quickCreateConfig?(): QuickCreateConfig | undefined { @@ -64,30 +137,162 @@ export class PipenvManager implements EnvironmentManager { // To be implemented } - async refresh(_scope: RefreshEnvironmentsScope): Promise { - // To be implemented + 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 { - // To be implemented + 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 { - // To be implemented + 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 { - // To be implemented + 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 { - // To be implemented - return undefined; + async resolve(context: ResolveEnvironmentContext): Promise { + await this.initialize(); + return resolvePipenvPath(context.fsPath, this.nativeFinder, this.api, this); } async clearCache?(): Promise { - // To be implemented + await clearPipenvCache(); + this.collection = []; + this.fsPathToEnv.clear(); + this.globalEnv = undefined; + this._initialized = undefined; } } diff --git a/src/managers/pipenv/pipenvPackageManager.ts b/src/managers/pipenv/pipenvPackageManager.ts new file mode 100644 index 00000000..4c7e15f7 --- /dev/null +++ b/src/managers/pipenv/pipenvPackageManager.ts @@ -0,0 +1,73 @@ +import { EventEmitter, LogOutputChannel, MarkdownString } from 'vscode'; +import { + DidChangePackagesEventArgs, + IconPath, + Package, + PackageManager, + PackageManagementOptions, + PythonEnvironment, + PythonEnvironmentApi, +} from '../../api'; +import { traceInfo } from '../../common/logging'; + +export class PipenvPackageManager implements PackageManager { + public readonly name: string; + public readonly displayName?: string; + public readonly description?: string; + public readonly tooltip?: string | MarkdownString; + public readonly iconPath?: IconPath; + public readonly log?: LogOutputChannel; + + private readonly _onDidChangePackages = new EventEmitter(); + public readonly onDidChangePackages = this._onDidChangePackages.event; + + constructor( + public readonly api: PythonEnvironmentApi, + log?: LogOutputChannel + ) { + this.name = 'pipenv'; + this.displayName = 'Pipenv'; + this.description = 'Manages packages using Pipenv'; + this.tooltip = new MarkdownString('Install and manage packages using Pipenv package manager'); + this.log = log; + } + + async manage(environment: PythonEnvironment, options: PackageManagementOptions): Promise { + // TODO: Implement pipenv package management + // This would run commands like: + // - pipenv install for installation + // - pipenv uninstall for uninstallation + // - pipenv install for installing from Pipfile + + traceInfo(`Pipenv package management not yet implemented for environment: ${environment.name}`); + traceInfo(`Options: ${JSON.stringify(options)}`); + + // For now, just log the operation + if (options.install && options.install.length > 0) { + traceInfo(`Would install packages: ${options.install.join(', ')}`); + } + if (options.uninstall && options.uninstall.length > 0) { + traceInfo(`Would uninstall packages: ${options.uninstall.join(', ')}`); + } + + // Fire change event (though packages haven't actually changed) + // this._onDidChangePackages.fire({ changes: [] }); + } + + async refresh(environment: PythonEnvironment): Promise { + // TODO: Implement package list refresh + // This would run 'pipenv graph' or similar to get package list + traceInfo(`Pipenv package refresh not yet implemented for environment: ${environment.name}`); + } + + async getPackages(environment: PythonEnvironment): Promise { + // TODO: Implement package listing + // This would parse output from 'pipenv graph' or 'pip list' in the pipenv environment + traceInfo(`Pipenv package listing not yet implemented for environment: ${environment.name}`); + return []; + } + + public dispose() { + this._onDidChangePackages.dispose(); + } +} \ No newline at end of file diff --git a/src/managers/pipenv/pipenvUtils.ts b/src/managers/pipenv/pipenvUtils.ts index 48997450..2a16cf6d 100644 --- a/src/managers/pipenv/pipenvUtils.ts +++ b/src/managers/pipenv/pipenvUtils.ts @@ -1,11 +1,246 @@ // Utility functions for Pipenv environment management -import { NativePythonFinder } from '../common/nativePythonFinder'; +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 class PipenvUtils { - // Add static helper methods for pipenv operations here +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; + } } -export async function getPipenv(_native?: NativePythonFinder): Promise { - // Implementation to find and return the pipenv path + +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); +} + +export class PipenvUtils { + // Add static helper methods for pipenv operations here +}