Skip to content

Commit ed5d479

Browse files
Copiloteleanorjboyd
andcommitted
Implement site-packages watcher service with automatic package refresh
Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com>
1 parent 5c3dd90 commit ed5d479

File tree

5 files changed

+460
-0
lines changed

5 files changed

+460
-0
lines changed

src/extension.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { ProjectView } from './features/views/projectView';
6363
import { PythonStatusBarImpl } from './features/views/pythonStatusBar';
6464
import { updateViewsAndStatus } from './features/views/revealHandler';
6565
import { ProjectItem } from './features/views/treeViewItems';
66+
import { SitePackagesWatcherService } from './features/packageWatcher';
6667
import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api';
6768
import { registerSystemPythonFeatures } from './managers/builtin/main';
6869
import { SysPythonManager } from './managers/builtin/sysPythonManager';
@@ -191,6 +192,10 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
191192
createManagerReady(envManagers, projectManager, context.subscriptions);
192193
context.subscriptions.push(envManagers);
193194

195+
// Initialize automatic package refresh service
196+
const sitePackagesWatcher = new SitePackagesWatcherService(envManagers);
197+
context.subscriptions.push(sitePackagesWatcher);
198+
194199
const terminalActivation = new TerminalActivationImpl();
195200
const shellEnvsProviders = createShellEnvProviders();
196201
const shellStartupProviders = createShellStartupProviders();

src/features/packageWatcher/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { SitePackagesWatcherService } from './sitePackagesWatcherService';
2+
export { resolveSitePackagesPath, isSitePackagesDirectory } from './sitePackagesUtils';
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import * as path from 'path';
2+
import * as fs from 'fs-extra';
3+
import { Uri } from 'vscode';
4+
import { PythonEnvironment } from '../../api';
5+
import { traceVerbose, traceWarn } from '../../common/logging';
6+
7+
/**
8+
* Resolves the site-packages directory path for a given Python environment.
9+
* This function handles different platforms and Python versions.
10+
*
11+
* @param environment The Python environment to resolve site-packages for
12+
* @returns Promise<Uri | undefined> The Uri to the site-packages directory, or undefined if not found
13+
*/
14+
export async function resolveSitePackagesPath(environment: PythonEnvironment): Promise<Uri | undefined> {
15+
const sysPrefix = environment.sysPrefix;
16+
if (!sysPrefix) {
17+
traceWarn(`No sysPrefix available for environment: ${environment.displayName}`);
18+
return undefined;
19+
}
20+
21+
traceVerbose(`Resolving site-packages for environment: ${environment.displayName}, sysPrefix: ${sysPrefix}`);
22+
23+
// Common site-packages locations to check
24+
const candidates = getSitePackagesCandidates(sysPrefix);
25+
26+
// Check each candidate path
27+
for (const candidate of candidates) {
28+
try {
29+
if (await fs.pathExists(candidate)) {
30+
const uri = Uri.file(candidate);
31+
traceVerbose(`Found site-packages at: ${candidate}`);
32+
return uri;
33+
}
34+
} catch (error) {
35+
traceVerbose(`Error checking site-packages candidate ${candidate}: ${error}`);
36+
}
37+
}
38+
39+
traceWarn(`Could not find site-packages directory for environment: ${environment.displayName}`);
40+
return undefined;
41+
}
42+
43+
/**
44+
* Gets candidate site-packages paths for different platforms and Python versions.
45+
*
46+
* @param sysPrefix The sys.prefix of the Python environment
47+
* @returns Array of candidate paths to check
48+
*/
49+
function getSitePackagesCandidates(sysPrefix: string): string[] {
50+
const candidates: string[] = [];
51+
52+
// Windows: typically in Lib/site-packages
53+
if (process.platform === 'win32') {
54+
candidates.push(path.join(sysPrefix, 'Lib', 'site-packages'));
55+
}
56+
57+
// Unix-like systems: typically in lib/python*/site-packages
58+
// We'll check common Python version patterns
59+
const pythonVersions = [
60+
'python3.12', 'python3.11', 'python3.10', 'python3.9', 'python3.8', 'python3.7',
61+
'python3', // fallback
62+
];
63+
64+
for (const pyVer of pythonVersions) {
65+
candidates.push(path.join(sysPrefix, 'lib', pyVer, 'site-packages'));
66+
}
67+
68+
// Additional locations for conda environments
69+
candidates.push(path.join(sysPrefix, 'site-packages')); // Some minimal environments
70+
71+
return candidates;
72+
}
73+
74+
/**
75+
* Checks if a path is likely a site-packages directory by looking for common markers.
76+
*
77+
* @param sitePkgPath Path to check
78+
* @returns Promise<boolean> True if the path appears to be a site-packages directory
79+
*/
80+
export async function isSitePackagesDirectory(sitePkgPath: string): Promise<boolean> {
81+
try {
82+
const stat = await fs.stat(sitePkgPath);
83+
if (!stat.isDirectory()) {
84+
return false;
85+
}
86+
87+
// Check for common site-packages markers
88+
const contents = await fs.readdir(sitePkgPath);
89+
90+
// Look for common packages or pip-related files
91+
const markers = [
92+
'pip', 'setuptools', 'wheel', // Common packages
93+
'__pycache__', // Python cache directory
94+
];
95+
96+
return markers.some(marker => contents.includes(marker)) || contents.length > 0;
97+
} catch {
98+
return false;
99+
}
100+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { Disposable, FileSystemWatcher } from 'vscode';
2+
import { PythonEnvironment } from '../../api';
3+
import { traceError, traceInfo, traceVerbose } from '../../common/logging';
4+
import { createFileSystemWatcher } from '../../common/workspace.apis';
5+
import { EnvironmentManagers, InternalDidChangeEnvironmentsEventArgs, InternalPackageManager } from '../../internal.api';
6+
import { resolveSitePackagesPath } from './sitePackagesUtils';
7+
8+
/**
9+
* Manages file system watchers for site-packages directories across all Python environments.
10+
* Automatically refreshes package lists when packages are installed or uninstalled.
11+
*/
12+
export class SitePackagesWatcherService implements Disposable {
13+
private readonly watchers = new Map<string, FileSystemWatcher>();
14+
private readonly disposables: Disposable[] = [];
15+
16+
constructor(private readonly environmentManagers: EnvironmentManagers) {
17+
this.initializeService();
18+
}
19+
20+
/**
21+
* Initializes the service by setting up event listeners and creating watchers for existing environments.
22+
*/
23+
private initializeService(): void {
24+
traceInfo('SitePackagesWatcherService: Initializing automatic package refresh service');
25+
26+
// Listen for environment changes
27+
this.disposables.push(
28+
this.environmentManagers.onDidChangeEnvironments(this.handleEnvironmentChanges.bind(this))
29+
);
30+
31+
// Set up watchers for existing environments
32+
this.setupWatchersForExistingEnvironments();
33+
}
34+
35+
/**
36+
* Sets up watchers for all existing environments.
37+
*/
38+
private async setupWatchersForExistingEnvironments(): Promise<void> {
39+
try {
40+
const managers = this.environmentManagers.managers;
41+
for (const manager of managers) {
42+
try {
43+
const environments = await manager.getEnvironments('all');
44+
for (const environment of environments) {
45+
await this.addWatcherForEnvironment(environment);
46+
}
47+
} catch (error) {
48+
traceError(`Failed to get environments from manager ${manager.id}:`, error);
49+
}
50+
}
51+
} catch (error) {
52+
traceError('Failed to setup watchers for existing environments:', error);
53+
}
54+
}
55+
56+
/**
57+
* Handles environment changes by adding or removing watchers as needed.
58+
*/
59+
private async handleEnvironmentChanges(event: InternalDidChangeEnvironmentsEventArgs): Promise<void> {
60+
for (const change of event.changes) {
61+
try {
62+
switch (change.kind) {
63+
case 'add':
64+
await this.addWatcherForEnvironment(change.environment);
65+
break;
66+
case 'remove':
67+
this.removeWatcherForEnvironment(change.environment);
68+
break;
69+
}
70+
} catch (error) {
71+
traceError(`Error handling environment change for ${change.environment.displayName}:`, error);
72+
}
73+
}
74+
}
75+
76+
/**
77+
* Adds a file system watcher for the given environment's site-packages directory.
78+
*/
79+
private async addWatcherForEnvironment(environment: PythonEnvironment): Promise<void> {
80+
const envId = environment.envId.id;
81+
82+
// Check if we already have a watcher for this environment
83+
if (this.watchers.has(envId)) {
84+
traceVerbose(`Watcher already exists for environment: ${environment.displayName}`);
85+
return;
86+
}
87+
88+
try {
89+
const sitePackagesUri = await resolveSitePackagesPath(environment);
90+
if (!sitePackagesUri) {
91+
traceVerbose(`Could not resolve site-packages path for environment: ${environment.displayName}`);
92+
return;
93+
}
94+
95+
const pattern = `${sitePackagesUri.fsPath}/**`;
96+
const watcher = createFileSystemWatcher(
97+
pattern,
98+
false, // don't ignore create events
99+
false, // don't ignore change events
100+
false // don't ignore delete events
101+
);
102+
103+
// Set up event handlers
104+
watcher.onDidCreate(() => this.onSitePackagesChange(environment));
105+
watcher.onDidChange(() => this.onSitePackagesChange(environment));
106+
watcher.onDidDelete(() => this.onSitePackagesChange(environment));
107+
108+
this.watchers.set(envId, watcher);
109+
traceInfo(`Created site-packages watcher for environment: ${environment.displayName} at ${sitePackagesUri.fsPath}`);
110+
111+
} catch (error) {
112+
traceError(`Failed to create watcher for environment ${environment.displayName}:`, error);
113+
}
114+
}
115+
116+
/**
117+
* Removes the file system watcher for the given environment.
118+
*/
119+
private removeWatcherForEnvironment(environment: PythonEnvironment): void {
120+
const envId = environment.envId.id;
121+
const watcher = this.watchers.get(envId);
122+
123+
if (watcher) {
124+
watcher.dispose();
125+
this.watchers.delete(envId);
126+
traceInfo(`Removed site-packages watcher for environment: ${environment.displayName}`);
127+
}
128+
}
129+
130+
/**
131+
* Handles site-packages changes by triggering a package refresh.
132+
*/
133+
private async onSitePackagesChange(environment: PythonEnvironment): Promise<void> {
134+
try {
135+
traceVerbose(`Site-packages changed for environment: ${environment.displayName}, triggering package refresh`);
136+
137+
// Get the package manager for this environment
138+
const packageManager = this.getPackageManagerForEnvironment(environment);
139+
if (packageManager) {
140+
// Trigger refresh asynchronously to avoid blocking file system events
141+
setImmediate(async () => {
142+
try {
143+
await packageManager.refresh(environment);
144+
traceInfo(`Package list refreshed automatically for environment: ${environment.displayName}`);
145+
} catch (error) {
146+
traceError(`Failed to refresh packages for environment ${environment.displayName}:`, error);
147+
}
148+
});
149+
} else {
150+
traceVerbose(`No package manager found for environment: ${environment.displayName}`);
151+
}
152+
} catch (error) {
153+
traceError(`Error handling site-packages change for environment ${environment.displayName}:`, error);
154+
}
155+
}
156+
157+
/**
158+
* Gets the appropriate package manager for the given environment.
159+
*/
160+
private getPackageManagerForEnvironment(environment: PythonEnvironment): InternalPackageManager | undefined {
161+
try {
162+
// Try to get package manager by environment manager's preferred package manager
163+
const envManager = this.environmentManagers.managers.find(m =>
164+
m.id === environment.envId.managerId
165+
);
166+
167+
if (envManager) {
168+
return this.environmentManagers.getPackageManager(envManager.preferredPackageManagerId);
169+
}
170+
171+
// Fallback to default package manager
172+
return this.environmentManagers.getPackageManager(environment);
173+
} catch (error) {
174+
traceError(`Error getting package manager for environment ${environment.displayName}:`, error);
175+
return undefined;
176+
}
177+
}
178+
179+
/**
180+
* Disposes all watchers and cleans up resources.
181+
*/
182+
dispose(): void {
183+
traceInfo('SitePackagesWatcherService: Disposing automatic package refresh service');
184+
185+
// Dispose all watchers
186+
for (const watcher of this.watchers.values()) {
187+
watcher.dispose();
188+
}
189+
this.watchers.clear();
190+
191+
// Dispose event listeners
192+
for (const disposable of this.disposables) {
193+
disposable.dispose();
194+
}
195+
this.disposables.length = 0;
196+
}
197+
}

0 commit comments

Comments
 (0)