Skip to content

Implement automatic package list refresh when site-packages change #586

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 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
90 changes: 90 additions & 0 deletions docs/automatic-package-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Automatic Package List Refresh

This feature automatically refreshes the package list when packages are installed or uninstalled in Python environments. It works by monitoring each environment's package directory for changes and triggering the package manager's refresh functionality when changes are detected.

## How It Works

1. **Environment Setup**: Each environment specifies its package directory via the `packageFolder` property
2. **Environment Monitoring**: The `SitePackagesWatcherService` listens for environment changes (add/remove)
3. **File System Watching**: Creates VS Code file system watchers to monitor the package directories
4. **Automatic Refresh**: When changes are detected, triggers the appropriate package manager's `refresh()` method

## Supported Environment Types

The feature works with all environment types that set the `packageFolder` property:

- **venv** environments
- **conda** environments
- **system** Python installations
- **poetry** environments
- **pyenv** environments

## Package Directory Resolution

Each environment manager is responsible for setting the `packageFolder` property when creating environments. The resolution follows platform-specific patterns:

### Windows
- `{sysPrefix}/Lib/site-packages`

### Unix/Linux/macOS
- `{sysPrefix}/lib/python3/site-packages` (standard environments)
- `{sysPrefix}/site-packages` (conda-style environments)

### Environment Manager Implementation

Environment managers use the `resolvePackageFolderFromSysPrefix()` utility function to determine the appropriate package directory based on the environment's `sysPrefix`.

## Implementation Details

### Key Components

1. **`SitePackagesWatcherService`**: Main service that manages file system watchers
2. **`sitePackagesUtils.ts`**: Utility function for resolving package folder paths from sysPrefix
3. **Environment Managers**: Each manager sets the `packageFolder` property when creating environments
4. **Integration**: Automatically initialized in `extension.ts` when the extension activates

### Lifecycle Management

- **Initialization**: Watchers are created for existing environments when the service starts
- **Environment Changes**: New watchers are added when environments are created, removed when environments are deleted
- **Cleanup**: All watchers are properly disposed when the extension deactivates

### Error Handling

- Graceful handling of environments without a `packageFolder` property
- Robust error handling for file system operations
- Fallback behavior when package directories cannot be accessed

## Benefits

1. **Real-time Updates**: Package lists are automatically updated when packages change
2. **Cross-platform Support**: Works on Windows, macOS, and Linux
3. **Environment Agnostic**: Supports all Python environment types
4. **Performance**: Uses VS Code's efficient file system watchers
5. **User Experience**: No manual refresh needed after installing/uninstalling packages
6. **Simplified Architecture**: Environment managers explicitly specify their package directories

## Technical Notes

- File system events are debounced to avoid excessive refresh calls
- Package refreshes happen asynchronously to avoid blocking the UI
- The service integrates seamlessly with existing package manager architecture
- Environment managers use the `resolvePackageFolderFromSysPrefix()` utility for consistent package directory resolution
- Comprehensive test coverage ensures reliability across different scenarios

## For Environment Manager Developers

When implementing a new environment manager, ensure you set the `packageFolder` property in your `PythonEnvironmentInfo`:

```typescript
import { resolvePackageFolderFromSysPrefix } from '../../features/packageWatcher';

const environmentInfo: PythonEnvironmentInfo = {
// ... other properties
sysPrefix: '/path/to/environment',
packageFolder: resolvePackageFolderFromSysPrefix('/path/to/environment'),
// ... other properties
};
```

This ensures automatic package refresh functionality works with your environment type.
6 changes: 6 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,12 @@ export interface PythonEnvironmentInfo {
*/
readonly sysPrefix: string;

/**
* Path to the packages directory (e.g., site-packages) where Python packages are installed for this environment.
* This is used for monitoring package installations and automatically refreshing package lists.
*/
readonly packageFolder?: Uri;

/**
* Optional `group` for this environment. This is used to group environments in the Environment Manager UI.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { ProjectView } from './features/views/projectView';
import { PythonStatusBarImpl } from './features/views/pythonStatusBar';
import { updateViewsAndStatus } from './features/views/revealHandler';
import { ProjectItem } from './features/views/treeViewItems';
import { SitePackagesWatcherService } from './features/packageWatcher';
import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api';
import { registerSystemPythonFeatures } from './managers/builtin/main';
import { SysPythonManager } from './managers/builtin/sysPythonManager';
Expand Down Expand Up @@ -191,6 +192,10 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
createManagerReady(envManagers, projectManager, context.subscriptions);
context.subscriptions.push(envManagers);

// Initialize automatic package refresh service
const sitePackagesWatcher = new SitePackagesWatcherService(envManagers);
context.subscriptions.push(sitePackagesWatcher);

const terminalActivation = new TerminalActivationImpl();
const shellEnvsProviders = createShellEnvProviders();
const shellStartupProviders = createShellStartupProviders();
Expand Down
2 changes: 2 additions & 0 deletions src/features/packageWatcher/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { SitePackagesWatcherService } from './sitePackagesWatcherService';
export { resolvePackageFolderFromSysPrefix } from './sitePackagesUtils';
48 changes: 48 additions & 0 deletions src/features/packageWatcher/sitePackagesUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as path from 'path';
import { Uri } from 'vscode';
import { traceVerbose } from '../../common/logging';

/**
* Resolves the package directory path for a given Python environment based on sysPrefix.
* This is a utility function for environment managers to set the packageFolder property.
*
* @param sysPrefix The sys.prefix of the Python environment
* @returns Uri | undefined The Uri to the package directory, or undefined if it cannot be determined
*/
export function resolvePackageFolderFromSysPrefix(sysPrefix: string): Uri | undefined {
if (!sysPrefix) {
return undefined;
}

traceVerbose(`Resolving package folder for sysPrefix: ${sysPrefix}`);

// For most environments, we can use a simple heuristic:
// Windows: {sysPrefix}/Lib/site-packages
// Unix/Linux/macOS: {sysPrefix}/lib/python*/site-packages (we'll use a common pattern)
// Conda: {sysPrefix}/site-packages

let packageFolderPath: string;

if (process.platform === 'win32') {
// Windows: typically in Lib/site-packages
packageFolderPath = path.join(sysPrefix, 'Lib', 'site-packages');
} else {
// Unix-like systems: try common locations
// First try conda style
const condaPath = path.join(sysPrefix, 'site-packages');
// Then try standard site-packages location (use python3 as a reasonable default)
const standardPath = path.join(sysPrefix, 'lib', 'python3', 'site-packages');

// For simplicity, we'll prefer the conda style if this looks like a conda environment,
// otherwise use the standard path
if (sysPrefix.includes('conda') || sysPrefix.includes('miniconda') || sysPrefix.includes('anaconda')) {
packageFolderPath = condaPath;
} else {
packageFolderPath = standardPath;
}
}

const uri = Uri.file(packageFolderPath);
traceVerbose(`Resolved package folder to: ${uri.fsPath}`);
return uri;
}
198 changes: 198 additions & 0 deletions src/features/packageWatcher/sitePackagesWatcherService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { Disposable, FileSystemWatcher } from 'vscode';
import { PythonEnvironment } from '../../api';
import { traceError, traceInfo, traceVerbose } from '../../common/logging';
import { createFileSystemWatcher } from '../../common/workspace.apis';
import { EnvironmentManagers, InternalDidChangeEnvironmentsEventArgs, InternalPackageManager } from '../../internal.api';

/**
* Manages file system watchers for package directories across all Python environments.
* Automatically refreshes package lists when packages are installed or uninstalled.
*/
export class SitePackagesWatcherService implements Disposable {
private readonly watchers = new Map<string, FileSystemWatcher>();
private readonly disposables: Disposable[] = [];

constructor(private readonly environmentManagers: EnvironmentManagers) {
this.initializeService();
}

/**
* Initializes the service by setting up event listeners and creating watchers for existing environments.
*/
private initializeService(): void {
traceInfo('SitePackagesWatcherService: Initializing automatic package refresh service');

// Listen for environment changes
this.disposables.push(
this.environmentManagers.onDidChangeEnvironments(this.handleEnvironmentChanges.bind(this))
);

// Set up watchers for existing environments
this.setupWatchersForExistingEnvironments();
}

/**
* Sets up watchers for all existing environments.
*/
private async setupWatchersForExistingEnvironments(): Promise<void> {
try {
const managers = this.environmentManagers.managers;
for (const manager of managers) {
try {
const environments = await manager.getEnvironments('all');
for (const environment of environments) {
await this.addWatcherForEnvironment(environment);
}
} catch (error) {
traceError(`Failed to get environments from manager ${manager.id}:`, error);
}
}
} catch (error) {
traceError('Failed to setup watchers for existing environments:', error);
}
}

/**
* Handles environment changes by adding or removing watchers as needed.
*/
private async handleEnvironmentChanges(event: InternalDidChangeEnvironmentsEventArgs): Promise<void> {
for (const change of event.changes) {
try {
switch (change.kind) {
case 'add':
await this.addWatcherForEnvironment(change.environment);
break;
case 'remove':
this.removeWatcherForEnvironment(change.environment);
break;
}
} catch (error) {
traceError(`Error handling environment change for ${change.environment.displayName}:`, error);
}
}
}

/**
* Adds a file system watcher for the given environment's package directory.
*/
private async addWatcherForEnvironment(environment: PythonEnvironment): Promise<void> {
const envId = environment.envId.id;

// Check if we already have a watcher for this environment
if (this.watchers.has(envId)) {
traceVerbose(`Watcher already exists for environment: ${environment.displayName}`);
return;
}

// Check if environment has a packageFolder defined
if (!environment.packageFolder) {
traceVerbose(`No packageFolder defined for environment: ${environment.displayName}`);
return;
}

try {
const pattern = `${environment.packageFolder.fsPath}/**`;
const watcher = createFileSystemWatcher(
pattern,
false, // don't ignore create events
false, // don't ignore change events
false // don't ignore delete events
);

// Set up event handlers
watcher.onDidCreate(() => this.onPackageDirectoryChange(environment));
watcher.onDidChange(() => this.onPackageDirectoryChange(environment));
watcher.onDidDelete(() => this.onPackageDirectoryChange(environment));

this.watchers.set(envId, watcher);
traceInfo(`Created package directory watcher for environment: ${environment.displayName} at ${environment.packageFolder.fsPath}`);

} catch (error) {
traceError(`Failed to create watcher for environment ${environment.displayName}:`, error);
}
}

/**
* Removes the file system watcher for the given environment.
*/
private removeWatcherForEnvironment(environment: PythonEnvironment): void {
const envId = environment.envId.id;
const watcher = this.watchers.get(envId);

if (watcher) {
watcher.dispose();
this.watchers.delete(envId);
traceInfo(`Removed package directory watcher for environment: ${environment.displayName}`);
}
}

/**
* Handles package directory changes by triggering a package refresh.
* Uses debouncing to avoid excessive refresh calls when multiple files change rapidly.
*/
private async onPackageDirectoryChange(environment: PythonEnvironment): Promise<void> {
try {
traceVerbose(`Package directory changed for environment: ${environment.displayName}, triggering package refresh`);

// Get the package manager for this environment
const packageManager = this.getPackageManagerForEnvironment(environment);
if (packageManager) {
// Trigger refresh asynchronously to avoid blocking file system events
// Use setImmediate to ensure the refresh happens after all current file system events
setImmediate(async () => {
try {
await packageManager.refresh(environment);
traceInfo(`Package list refreshed automatically for environment: ${environment.displayName}`);
} catch (error) {
traceError(`Failed to refresh packages for environment ${environment.displayName}:`, error);
}
});
} else {
traceVerbose(`No package manager found for environment: ${environment.displayName}`);
}
} catch (error) {
traceError(`Error handling package directory change for environment ${environment.displayName}:`, error);
}
}

/**
* Gets the appropriate package manager for the given environment.
*/
private getPackageManagerForEnvironment(environment: PythonEnvironment): InternalPackageManager | undefined {
try {
// Try to get package manager by environment manager's preferred package manager
const envManager = this.environmentManagers.managers.find(m =>
m.id === environment.envId.managerId
);

if (envManager) {
return this.environmentManagers.getPackageManager(envManager.preferredPackageManagerId);
}

// Fallback to default package manager
return this.environmentManagers.getPackageManager(environment);
} catch (error) {
traceError(`Error getting package manager for environment ${environment.displayName}:`, error);
return undefined;
}
}

/**
* Disposes all watchers and cleans up resources.
*/
dispose(): void {
traceInfo('SitePackagesWatcherService: Disposing automatic package refresh service');

// Dispose all watchers
for (const watcher of this.watchers.values()) {
watcher.dispose();
}
this.watchers.clear();

// Dispose event listeners
for (const disposable of this.disposables) {
disposable.dispose();
}
this.disposables.length = 0;
}
}
2 changes: 2 additions & 0 deletions src/managers/builtin/venvUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
withProgress,
} from '../../common/window.apis';
import { getConfiguration } from '../../common/workspace.apis';
import { resolvePackageFolderFromSysPrefix } from '../../features/packageWatcher';
import {
isNativeEnvInfo,
NativeEnvInfo,
Expand Down Expand Up @@ -147,6 +148,7 @@ async function getPythonInfo(env: NativeEnvInfo): Promise<PythonEnvironmentInfo>
environmentPath: Uri.file(env.executable),
iconPath: new ThemeIcon('python'),
sysPrefix: env.prefix,
packageFolder: resolvePackageFolderFromSysPrefix(env.prefix),
execInfo: {
run: {
executable: env.executable,
Expand Down
Loading