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 3 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
69 changes: 69 additions & 0 deletions docs/automatic-package-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Automatic Package List Refresh

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

## How It Works

1. **Environment Monitoring**: The `SitePackagesWatcherService` listens for environment changes (add/remove)
2. **Site-packages Resolution**: For each environment, the service resolves the site-packages path using the environment's `sysPrefix`
3. **File System Watching**: Creates VS Code file system watchers to monitor the site-packages 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 provide a valid `sysPrefix`:

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

## Site-packages Path Resolution

The service automatically detects site-packages directories on different platforms:

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

### Unix/Linux/macOS
- `{sysPrefix}/lib/python3.*/site-packages`
- `{sysPrefix}/lib/python3/site-packages` (fallback)

### Conda Environments
- `{sysPrefix}/site-packages` (for minimal environments)

## Implementation Details

### Key Components

1. **`SitePackagesWatcherService`**: Main service that manages file system watchers
2. **`sitePackagesUtils.ts`**: Utility functions for resolving site-packages paths
3. **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 valid `sysPrefix`
- Robust error handling for file system operations
- Fallback behavior when site-packages directories cannot be found

## 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

## 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
- Comprehensive test coverage ensures reliability across different scenarios
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 { resolveSitePackagesPath, isSitePackagesDirectory } from './sitePackagesUtils';
100 changes: 100 additions & 0 deletions src/features/packageWatcher/sitePackagesUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import * as path from 'path';
import * as fs from 'fs-extra';
import { Uri } from 'vscode';
import { PythonEnvironment } from '../../api';
import { traceVerbose, traceWarn } from '../../common/logging';

/**
* Resolves the site-packages directory path for a given Python environment.
* This function handles different platforms and Python versions.
*
* @param environment The Python environment to resolve site-packages for
* @returns Promise<Uri | undefined> The Uri to the site-packages directory, or undefined if not found
*/
export async function resolveSitePackagesPath(environment: PythonEnvironment): Promise<Uri | undefined> {
const sysPrefix = environment.sysPrefix;
if (!sysPrefix) {
traceWarn(`No sysPrefix available for environment: ${environment.displayName}`);
return undefined;
}

traceVerbose(`Resolving site-packages for environment: ${environment.displayName}, sysPrefix: ${sysPrefix}`);

// Common site-packages locations to check
const candidates = getSitePackagesCandidates(sysPrefix);

// Check each candidate path
for (const candidate of candidates) {
try {
if (await fs.pathExists(candidate)) {
const uri = Uri.file(candidate);
traceVerbose(`Found site-packages at: ${candidate}`);
return uri;
}
} catch (error) {
traceVerbose(`Error checking site-packages candidate ${candidate}: ${error}`);
}
}

traceWarn(`Could not find site-packages directory for environment: ${environment.displayName}`);
return undefined;
}

/**
* Gets candidate site-packages paths for different platforms and Python versions.
*
* @param sysPrefix The sys.prefix of the Python environment
* @returns Array of candidate paths to check
*/
function getSitePackagesCandidates(sysPrefix: string): string[] {
const candidates: string[] = [];

// Windows: typically in Lib/site-packages
if (process.platform === 'win32') {
candidates.push(path.join(sysPrefix, 'Lib', 'site-packages'));
}

// Unix-like systems: typically in lib/python*/site-packages
// We'll check common Python version patterns
const pythonVersions = [
'python3.12', 'python3.11', 'python3.10', 'python3.9', 'python3.8', 'python3.7',
'python3', // fallback
];

for (const pyVer of pythonVersions) {
candidates.push(path.join(sysPrefix, 'lib', pyVer, 'site-packages'));
}

// Additional locations for conda environments
candidates.push(path.join(sysPrefix, 'site-packages')); // Some minimal environments

return candidates;
}

/**
* Checks if a path is likely a site-packages directory by looking for common markers.
*
* @param sitePkgPath Path to check
* @returns Promise<boolean> True if the path appears to be a site-packages directory
*/
export async function isSitePackagesDirectory(sitePkgPath: string): Promise<boolean> {
try {
const stat = await fs.stat(sitePkgPath);
if (!stat.isDirectory()) {
return false;
}

// Check for common site-packages markers
const contents = await fs.readdir(sitePkgPath);

// Look for common packages or pip-related files
const markers = [
'pip', 'setuptools', 'wheel', // Common packages
'__pycache__', // Python cache directory
];

return markers.some(marker => contents.includes(marker)) || contents.length > 0;
} catch {
return false;
}
}
199 changes: 199 additions & 0 deletions src/features/packageWatcher/sitePackagesWatcherService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
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';
import { resolveSitePackagesPath } from './sitePackagesUtils';

/**
* Manages file system watchers for site-packages 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 site-packages 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;
}

try {
const sitePackagesUri = await resolveSitePackagesPath(environment);
if (!sitePackagesUri) {
traceVerbose(`Could not resolve site-packages path for environment: ${environment.displayName}`);
return;
}

const pattern = `${sitePackagesUri.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.onSitePackagesChange(environment));
watcher.onDidChange(() => this.onSitePackagesChange(environment));
watcher.onDidDelete(() => this.onSitePackagesChange(environment));

this.watchers.set(envId, watcher);
traceInfo(`Created site-packages watcher for environment: ${environment.displayName} at ${sitePackagesUri.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 site-packages watcher for environment: ${environment.displayName}`);
}
}

/**
* Handles site-packages changes by triggering a package refresh.
* Uses debouncing to avoid excessive refresh calls when multiple files change rapidly.
*/
private async onSitePackagesChange(environment: PythonEnvironment): Promise<void> {
try {
traceVerbose(`Site-packages 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 site-packages 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;
}
}
Loading