Skip to content

Commit 3c21a4e

Browse files
edvilmeCopilot
andauthored
Add site-packages watchers (microsoft#1597)
https://github.com/user-attachments/assets/9e3a8d01-01c2-4e28-b20b-843bb6ff3076 This pull request refactors and centralizes the logic for watching Python package changes across different environment managers (system, conda, poetry). The new approach introduces a shared utility to handle file system watching for package metadata, ensuring more robust and consistent package refresh behavior when environments change. Key improvements and changes: **Centralized Package Watcher Logic:** - Added a new module, `packageWatcher.ts`, which provides `watchPackageChangesForEnvironment` and `registerPackageWatcherForManager` to handle file watching and package refresh for any environment manager. This replaces duplicated logic in each manager. **Refactoring of Environment Managers:** - Updated `main.ts` files for system, conda, and poetry managers to use the new `registerPackageWatcherForManager` function, removing their own package watcher and debounce logic. This simplifies each manager and ensures consistency. [[1]](diffhunk://#diff-450a068335039323c00b342cbfbff615ecf1cda7bd9d73aaed6b8e13d2d9d7f5R7) [[2]](diffhunk://#diff-450a068335039323c00b342cbfbff615ecf1cda7bd9d73aaed6b8e13d2d9d7f5L44-R47) [[3]](diffhunk://#diff-c55f6a95cb5974ab8c7d3e66c62f818b1cb2c044168113aaf8ca4a5378925e8eR9) [[4]](diffhunk://#diff-c55f6a95cb5974ab8c7d3e66c62f818b1cb2c044168113aaf8ca4a5378925e8eR59) [[5]](diffhunk://#diff-27854fc91d92524ea1d6cecfec07dcb12f64204246b5d45097156ddc3a1867ecR7) [[6]](diffhunk://#diff-27854fc91d92524ea1d6cecfec07dcb12f64204246b5d45097156ddc3a1867ecR28) **Improved Robustness and Maintainability:** - The new watcher logic automatically tracks the active environment, ensuring watchers are always up-to-date and resources are cleaned up appropriately. **Platform and Environment Awareness:** - The watcher targets are now tailored to different platforms (Windows, Unix) and environment types (including conda), ensuring accurate detection of package changes. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5d919c1 commit 3c21a4e

10 files changed

Lines changed: 670 additions & 33 deletions

File tree

api/src/main.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
FileChangeType,
99
LogOutputChannel,
1010
MarkdownString,
11+
RelativePattern,
1112
TaskExecution,
1213
Terminal,
1314
TerminalOptions,
@@ -689,6 +690,16 @@ export interface PackageManager {
689690
*/
690691
getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise<Package[] | undefined>;
691692

693+
/**
694+
* Returns additional filesystem patterns to watch for package install/uninstall changes.
695+
*
696+
* These patterns are appended to the default site-packages metadata locations.
697+
* Implement this for manager-specific locations (for example, conda-meta).
698+
*
699+
* @param environment - The Python environment whose package paths should be watched.
700+
* @returns Relative patterns to watch for package changes.
701+
*/
702+
getPackageWatchTargets?(environment: PythonEnvironment): RelativePattern[];
692703
/**
693704
* Event that is fired when packages change.
694705
*/

examples/sample1/src/api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
FileChangeType,
88
LogOutputChannel,
99
MarkdownString,
10+
RelativePattern,
1011
TaskExecution,
1112
Terminal,
1213
TerminalOptions,
@@ -616,6 +617,17 @@ export interface PackageManager {
616617
*/
617618
getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise<Package[] | undefined>;
618619

620+
/**
621+
* Returns additional filesystem patterns to watch for package install/uninstall changes.
622+
*
623+
* These patterns are appended to the default site-packages metadata locations.
624+
* Implement this for manager-specific locations (for example, conda-meta).
625+
*
626+
* @param environment - The Python environment whose package paths should be watched.
627+
* @returns Relative patterns to watch for package changes.
628+
*/
629+
getPackageWatchTargets?(environment: PythonEnvironment): RelativePattern[];
630+
619631
/**
620632
* Event that is fired when packages change.
621633
*/

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@
138138
"description": "%python-envs.alwaysUseUv.description%",
139139
"default": true,
140140
"scope": "machine"
141+
},
142+
"python-envs.packageWatchers": {
143+
"type": "boolean",
144+
"description": "%python-envs.packageWatchers.description%",
145+
"default": true,
146+
"scope": "machine"
141147
}
142148
}
143149
},

package.nls.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,6 @@
4646
"python-envs.revealEnvInManagerView.title": "Reveal in Environment Managers View",
4747
"python-envs.runPetInTerminal.title": "Run Python Environment Tool (PET) in Terminal...",
4848
"python-envs.managePackageVersion.title": "Manage Package Version",
49-
"python-envs.alwaysUseUv.description": "When set to true, uv will be used to manage all virtual environments if available. When set to false, uv will only manage virtual environments explicitly created by uv."
49+
"python-envs.alwaysUseUv.description": "When set to true, uv will be used to manage all virtual environments if available. When set to false, uv will only manage virtual environments explicitly created by uv.",
50+
"python-envs.packageWatchers.description": "When enabled, file system watchers monitor site-packages for install/uninstall changes and automatically refresh the package list. Disable this if you experience performance issues in large workspaces."
5051
}

src/api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
FileChangeType,
99
LogOutputChannel,
1010
MarkdownString,
11+
RelativePattern,
1112
TaskExecution,
1213
Terminal,
1314
TerminalOptions,
@@ -684,6 +685,17 @@ export interface PackageManager {
684685
*/
685686
getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise<Package[] | undefined>;
686687

688+
/**
689+
* Returns additional filesystem patterns to watch for package install/uninstall changes.
690+
*
691+
* These patterns are appended to the default site-packages metadata locations.
692+
* Implement this for manager-specific locations (for example, conda-meta).
693+
*
694+
* @param environment - The Python environment whose package paths should be watched.
695+
* @returns Relative patterns to watch for package changes.
696+
*/
697+
getPackageWatchTargets?(environment: PythonEnvironment): RelativePattern[];
698+
687699
/**
688700
* Event that is fired when packages change.
689701
*/

src/managers/builtin/main.ts

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createSimpleDebounce } from '../../common/utils/debounce';
44
import { createFileSystemWatcher, onDidDeleteFiles } from '../../common/workspace.apis';
55
import { getPythonApi } from '../../features/pythonApi';
66
import { NativePythonFinder } from '../common/nativePythonFinder';
7+
import { registerPackageWatcherForManager } from '../common/packageWatcher';
78
import { PipPackageManager } from './pipPackageManager';
89
import { SysPythonManager } from './sysPythonManager';
910
import { VenvManager } from './venvManager';
@@ -41,38 +42,8 @@ export async function registerSystemPythonFeatures(
4142
}),
4243
);
4344

44-
const packageDebouncedRefresh = createSimpleDebounce(500, async () => {
45-
const projects = await api.getPythonProjects();
46-
await Promise.all(
47-
projects.map(async (project) => {
48-
const env = await api.getEnvironment(project.uri);
49-
if (!env) {
50-
return;
51-
}
52-
try {
53-
await api.refreshPackages(env);
54-
} catch (ex) {
55-
log.error(
56-
`Failed to refresh packages for environment ${env.envId}: ${ex instanceof Error ? ex.message : String(ex)}`,
57-
);
58-
}
59-
}),
60-
);
61-
});
62-
const packageWatcher = createFileSystemWatcher(
63-
'**/site-packages/*.dist-info/METADATA',
64-
false, // don't ignore create events (pip install)
65-
true, // ignore change events (content changes in METADATA don't affect package list)
66-
false, // don't ignore delete events (pip uninstall)
67-
);
6845
disposables.push(
69-
packageDebouncedRefresh,
70-
packageWatcher,
71-
packageWatcher.onDidCreate(() => {
72-
packageDebouncedRefresh.trigger();
73-
}),
74-
packageWatcher.onDidDelete(() => {
75-
packageDebouncedRefresh.trigger();
76-
}),
46+
registerPackageWatcherForManager(envManager, pkgManager, log),
47+
registerPackageWatcherForManager(venvManager, pkgManager, log),
7748
);
7849
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import * as path from 'path';
2+
import { Disposable, LogOutputChannel, RelativePattern } from 'vscode';
3+
import { EnvironmentManager, PackageManager, PythonEnvironment } from '../../api';
4+
import { createSimpleDebounce } from '../../common/utils/debounce';
5+
import { createFileSystemWatcher, getConfiguration } from '../../common/workspace.apis';
6+
7+
/**
8+
* Derives the file system watch targets for a given Python environment.
9+
*
10+
* Targets include site-packages `.dist-info/METADATA` files for pip-style installs.
11+
*
12+
* @param env - The Python environment to derive watch targets for.
13+
* @returns An array of RelativePattern objects, one per discoverable package location.
14+
* Empty if the environment has no `sysPrefix` or discoverable paths.
15+
*/
16+
function getDefaultPackageWatchTargets(env: PythonEnvironment): RelativePattern[] {
17+
if (!env.sysPrefix) {
18+
return [];
19+
}
20+
return process.platform === 'win32'
21+
? [new RelativePattern(path.join(env.sysPrefix, 'Lib'), 'site-packages/**/*.dist-info/METADATA')] // Windows
22+
: [new RelativePattern(path.join(env.sysPrefix, 'lib'), 'python*/site-packages/**/*.dist-info/METADATA')]; // Unix-like
23+
}
24+
25+
/**
26+
* Creates a file system watcher for package changes in a single environment.
27+
*
28+
* Monitors default site-packages locations and any manager-specific extra locations
29+
* for install/uninstall operations.
30+
* and triggers a debounced package refresh when changes are detected.
31+
*
32+
* @param env - The Python environment to watch.
33+
* @param packageManager - The package manager to call refresh on when changes occur.
34+
* @param log - Logger for diagnostic messages.
35+
* @returns A disposable that removes the watcher when disposed.
36+
*/
37+
export function watchPackageChangesForEnvironment(
38+
env: PythonEnvironment,
39+
packageManager: PackageManager,
40+
log: LogOutputChannel,
41+
): Disposable {
42+
// Watch targets
43+
const watchTargets = [
44+
...getDefaultPackageWatchTargets(env),
45+
...(packageManager.getPackageWatchTargets?.(env) ?? []),
46+
];
47+
if (watchTargets.length === 0) {
48+
log.debug(`No watch targets for environment ${env.envId.id}`);
49+
return new Disposable(() => undefined);
50+
}
51+
// Debounced refresh function
52+
const debouncedRefresh = createSimpleDebounce(500, async () => {
53+
log.debug(`Package change detected for environment ${env.envId.id}, refreshing packages.`);
54+
packageManager.refresh(env).catch((ex) => {
55+
log.error(
56+
`Failed to refresh packages for environment ${env.envId.id}: ${ex instanceof Error ? ex.message : String(ex)}`,
57+
);
58+
});
59+
});
60+
// Create watchers
61+
const disposables: Disposable[] = [debouncedRefresh];
62+
const trigger = debouncedRefresh.trigger.bind(debouncedRefresh);
63+
for (const target of watchTargets) {
64+
const watcher = createFileSystemWatcher(
65+
target,
66+
false, // create -> install
67+
true, // change -> ignore
68+
false, // delete -> uninstall
69+
);
70+
disposables.push(
71+
watcher,
72+
watcher.onDidCreate(trigger),
73+
watcher.onDidDelete(trigger),
74+
);
75+
}
76+
77+
return new Disposable(() => disposables.forEach((d) => d.dispose()));
78+
}
79+
80+
/**
81+
* Registers automatic file system watchers for the active environment managed by a given manager.
82+
*
83+
* Creates per-environment watchers that are attached when the active environment changes
84+
* and detached when it changes to a different environment. Ensures package changes
85+
* (installs/uninstalls) in the active environment are detected and trigger a refresh.
86+
*
87+
* @param envManager - The environment manager whose active environment should be watched.
88+
* @param packageManager - The package manager to call refresh on when changes occur.
89+
* @param log - Logger for diagnostic and error messages.
90+
* @returns A disposable that removes all watchers and subscriptions when disposed.
91+
*/
92+
export function registerPackageWatcherForManager(
93+
envManager: EnvironmentManager,
94+
packageManager: PackageManager,
95+
log: LogOutputChannel,
96+
): Disposable {
97+
const packageWatchersEnabled = getConfiguration('python-envs').get<boolean>('packageWatchers', true);
98+
if (!packageWatchersEnabled) {
99+
return new Disposable(() => undefined);
100+
}
101+
102+
// One watcher per environment id.
103+
const watchers = new Map<string, Disposable>();
104+
105+
const addWatcher = (env: PythonEnvironment): void => {
106+
if (!watchers.has(env.envId.id)) {
107+
watchers.set(env.envId.id, watchPackageChangesForEnvironment(env, packageManager, log));
108+
}
109+
};
110+
111+
const removeWatcher = (envId: string): void => {
112+
watchers.get(envId)?.dispose();
113+
watchers.delete(envId);
114+
};
115+
116+
const envChangeDisposable = envManager.onDidChangeEnvironment?.((changes) => {
117+
if (changes.new) {
118+
addWatcher(changes.new);
119+
}
120+
if (changes.old && changes.old.envId.id !== changes.new?.envId.id) {
121+
removeWatcher(changes.old.envId.id);
122+
}
123+
});
124+
125+
return new Disposable(() => {
126+
envChangeDisposable?.dispose();
127+
watchers.forEach((watcher) => watcher.dispose());
128+
watchers.clear();
129+
});
130+
}

src/managers/conda/condaPackageManager.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Pep440Version } from '@renovatebot/pep440';
22
import { explain as parse, rcompare } from '@renovatebot/pep440';
3+
import * as path from 'path';
34
import {
45
CancellationError,
56
Disposable,
@@ -8,6 +9,7 @@ import {
89
LogOutputChannel,
910
MarkdownString,
1011
ProgressLocation,
12+
RelativePattern,
1113
} from 'vscode';
1214
import {
1315
DidChangePackagesEventArgs,
@@ -197,6 +199,14 @@ export class CondaPackageManager implements PackageManager, Disposable {
197199
}
198200
}
199201

202+
getPackageWatchTargets(environment: PythonEnvironment): RelativePattern[] {
203+
if (!environment.sysPrefix) {
204+
return [];
205+
}
206+
207+
return [new RelativePattern(path.join(environment.sysPrefix, 'conda-meta'), '**/*.json')];
208+
}
209+
200210
dispose() {
201211
this._onDidChangePackages.dispose();
202212
this.packages.clear();

src/managers/poetry/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { traceInfo } from '../../common/logging';
44
import { getPythonApi } from '../../features/pythonApi';
55
import { PythonProjectManager } from '../../internal.api';
66
import { NativePythonFinder } from '../common/nativePythonFinder';
7+
import { registerPackageWatcherForManager } from '../common/packageWatcher';
78
import { PoetryManager } from './poetryManager';
89
import { PoetryPackageManager } from './poetryPackageManager';
910

@@ -24,5 +25,6 @@ export async function registerPoetryFeatures(
2425
pkgManager,
2526
api.registerEnvironmentManager(envManager),
2627
api.registerPackageManager(pkgManager),
28+
registerPackageWatcherForManager(envManager, pkgManager, outputChannel),
2729
);
2830
}

0 commit comments

Comments
 (0)