From feb0677e4c69a70eb8a641b2fea855389af52342 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 18 Jun 2026 14:16:23 -0700 Subject: [PATCH 1/6] Add site-packages watchers --- src/managers/builtin/main.ts | 35 +------- src/managers/common/packageWatcher.ts | 113 ++++++++++++++++++++++++++ src/managers/conda/main.ts | 2 + src/managers/poetry/main.ts | 2 + 4 files changed, 120 insertions(+), 32 deletions(-) create mode 100644 src/managers/common/packageWatcher.ts diff --git a/src/managers/builtin/main.ts b/src/managers/builtin/main.ts index 4fff2dae..1ae48a2e 100644 --- a/src/managers/builtin/main.ts +++ b/src/managers/builtin/main.ts @@ -4,6 +4,7 @@ import { createSimpleDebounce } from '../../common/utils/debounce'; import { createFileSystemWatcher, onDidDeleteFiles } from '../../common/workspace.apis'; import { getPythonApi } from '../../features/pythonApi'; import { NativePythonFinder } from '../common/nativePythonFinder'; +import { registerPackageWatcherForManager } from '../common/packageWatcher'; import { PipPackageManager } from './pipPackageManager'; import { SysPythonManager } from './sysPythonManager'; import { VenvManager } from './venvManager'; @@ -41,38 +42,8 @@ export async function registerSystemPythonFeatures( }), ); - const packageDebouncedRefresh = createSimpleDebounce(500, async () => { - const projects = await api.getPythonProjects(); - await Promise.all( - projects.map(async (project) => { - const env = await api.getEnvironment(project.uri); - if (!env) { - return; - } - try { - await api.refreshPackages(env); - } catch (ex) { - log.error( - `Failed to refresh packages for environment ${env.envId}: ${ex instanceof Error ? ex.message : String(ex)}`, - ); - } - }), - ); - }); - const packageWatcher = createFileSystemWatcher( - '**/site-packages/*.dist-info/METADATA', - false, // don't ignore create events (pip install) - true, // ignore change events (content changes in METADATA don't affect package list) - false, // don't ignore delete events (pip uninstall) - ); disposables.push( - packageDebouncedRefresh, - packageWatcher, - packageWatcher.onDidCreate(() => { - packageDebouncedRefresh.trigger(); - }), - packageWatcher.onDidDelete(() => { - packageDebouncedRefresh.trigger(); - }), + await registerPackageWatcherForManager(envManager, pkgManager, log), + await registerPackageWatcherForManager(venvManager, pkgManager, log), ); } diff --git a/src/managers/common/packageWatcher.ts b/src/managers/common/packageWatcher.ts new file mode 100644 index 00000000..56fdd6df --- /dev/null +++ b/src/managers/common/packageWatcher.ts @@ -0,0 +1,113 @@ +import * as path from 'path'; +import { Disposable, LogOutputChannel, RelativePattern, Uri } from 'vscode'; +import { EnvironmentChangeKind, EnvironmentManager, PackageManager, PythonEnvironment } from '../../api'; +import { traceVerbose } from '../../common/logging'; +import { createSimpleDebounce } from '../../common/utils/debounce'; +import { createFileSystemWatcher } from '../../common/workspace.apis'; + +function getWatchTargets(env: PythonEnvironment): RelativePattern[] { + if (!env.sysPrefix) return []; + const targets: RelativePattern[] = []; + + if (process.platform === 'win32') { + targets.push( + new RelativePattern(Uri.file(path.join(env.sysPrefix, 'Lib')), 'site-packages/**/*.dist-info/METADATA'), + ); + } else { + targets.push( + new RelativePattern( + Uri.file(path.join(env.sysPrefix, 'lib')), + 'python*/site-packages/**/*.dist-info/METADATA', + ), + ); + } + + // Conda + targets.push(new RelativePattern(Uri.file(path.join(env.sysPrefix, 'conda-meta')), '**/*.json')); + + return targets; +} + +export function watchPackageChangesForEnvironment( + env: PythonEnvironment, + packageManager: PackageManager, + log: LogOutputChannel, +): Disposable { + // Watch targets + const watchTargets = getWatchTargets(env); + if (watchTargets.length === 0) { + traceVerbose(log, `No watch targets for environment ${env.envId}`); + return new Disposable(() => undefined); + } + // Debounced refresh function + const debouncedRefresh = createSimpleDebounce(500, async () => { + packageManager.refresh(env).catch((ex) => { + log.error( + `Failed to refresh packages for environment ${env.envId}: ${ex instanceof Error ? ex.message : String(ex)}`, + ); + }); + }); + // Create watchers + const disposables: Disposable[] = []; + for (const target of watchTargets) { + const watcher = createFileSystemWatcher( + target, + true, // create -> install + false, // change -> ignore + true, // delete -> uninstall + ); + disposables.push( + watcher, + watcher.onDidCreate(debouncedRefresh.trigger), + watcher.onDidDelete(debouncedRefresh.trigger), + ); + } + + return new Disposable(() => disposables.forEach((d) => d.dispose())); +} + +/** + * Registers package file watchers for all environments managed by the given manager. + * + * This is project-agnostic: if a manager discovers an environment, we watch it. + */ +export async function registerPackageWatcherForManager( + envManager: EnvironmentManager, + packageManager: PackageManager, + log: LogOutputChannel, +): Promise { + // One watcher per environment id. + const watchers = new Map(); + + const addWatcher = (env: PythonEnvironment): void => { + if (!watchers.has(env.envId.id)) { + watchers.set(env.envId.id, watchPackageChangesForEnvironment(env, packageManager, log)); + } + }; + + const removeWatcher = (envId: string): void => { + watchers.get(envId)?.dispose(); + watchers.delete(envId); + }; + + // Keep watchers in sync with environment discovery/removal events. + const envChangeDisposable = envManager.onDidChangeEnvironments?.((changes) => { + changes.forEach((change) => { + if (change.kind === EnvironmentChangeKind.add) { + addWatcher(change.environment); + } else { + removeWatcher(change.environment.envId.id); + } + }); + }); + + // Seed with environments that already exist before this subscription. + const environments = await envManager.getEnvironments('all'); + environments.forEach(addWatcher); + + return new Disposable(() => { + envChangeDisposable?.dispose(); + watchers.forEach((watcher) => watcher.dispose()); + watchers.clear(); + }); +} diff --git a/src/managers/conda/main.ts b/src/managers/conda/main.ts index 00ce3d16..19cd5d43 100644 --- a/src/managers/conda/main.ts +++ b/src/managers/conda/main.ts @@ -6,6 +6,7 @@ import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { getPythonApi } from '../../features/pythonApi'; import { PythonProjectManager } from '../../internal.api'; import { NativePythonFinder } from '../common/nativePythonFinder'; +import { registerPackageWatcherForManager } from '../common/packageWatcher'; import { notifyMissingManagerIfDefault } from '../common/utils'; import { CondaEnvManager } from './condaEnvManager'; import { CondaPackageManager } from './condaPackageManager'; @@ -55,6 +56,7 @@ export async function registerCondaFeatures( packageManager, api.registerEnvironmentManager(envManager), api.registerPackageManager(packageManager), + await registerPackageWatcherForManager(envManager, packageManager, log), ); } catch (ex) { await notifyMissingManagerIfDefault('ms-python.python:conda', projectManager, api); diff --git a/src/managers/poetry/main.ts b/src/managers/poetry/main.ts index c893f55b..341578ec 100644 --- a/src/managers/poetry/main.ts +++ b/src/managers/poetry/main.ts @@ -4,6 +4,7 @@ import { traceInfo } from '../../common/logging'; import { getPythonApi } from '../../features/pythonApi'; import { PythonProjectManager } from '../../internal.api'; import { NativePythonFinder } from '../common/nativePythonFinder'; +import { registerPackageWatcherForManager } from '../common/packageWatcher'; import { PoetryManager } from './poetryManager'; import { PoetryPackageManager } from './poetryPackageManager'; @@ -24,5 +25,6 @@ export async function registerPoetryFeatures( pkgManager, api.registerEnvironmentManager(envManager), api.registerPackageManager(pkgManager), + await registerPackageWatcherForManager(envManager, pkgManager, outputChannel), ); } From ca48cb390909c3729dd456d543635fa1a165c978 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 18 Jun 2026 14:30:23 -0700 Subject: [PATCH 2/6] Only refresh current environment --- src/managers/common/packageWatcher.ts | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/managers/common/packageWatcher.ts b/src/managers/common/packageWatcher.ts index 56fdd6df..03fb4f61 100644 --- a/src/managers/common/packageWatcher.ts +++ b/src/managers/common/packageWatcher.ts @@ -1,6 +1,6 @@ import * as path from 'path'; import { Disposable, LogOutputChannel, RelativePattern, Uri } from 'vscode'; -import { EnvironmentChangeKind, EnvironmentManager, PackageManager, PythonEnvironment } from '../../api'; +import { EnvironmentManager, PackageManager, PythonEnvironment } from '../../api'; import { traceVerbose } from '../../common/logging'; import { createSimpleDebounce } from '../../common/utils/debounce'; import { createFileSystemWatcher } from '../../common/workspace.apis'; @@ -41,6 +41,7 @@ export function watchPackageChangesForEnvironment( } // Debounced refresh function const debouncedRefresh = createSimpleDebounce(500, async () => { + console.log(`Package change detected for environment ${env.envId}, refreshing packages...`); packageManager.refresh(env).catch((ex) => { log.error( `Failed to refresh packages for environment ${env.envId}: ${ex instanceof Error ? ex.message : String(ex)}`, @@ -90,21 +91,15 @@ export async function registerPackageWatcherForManager( watchers.delete(envId); }; - // Keep watchers in sync with environment discovery/removal events. - const envChangeDisposable = envManager.onDidChangeEnvironments?.((changes) => { - changes.forEach((change) => { - if (change.kind === EnvironmentChangeKind.add) { - addWatcher(change.environment); - } else { - removeWatcher(change.environment.envId.id); - } - }); + const envChangeDisposable = envManager.onDidChangeEnvironment?.((changes) => { + if (changes.new) { + addWatcher(changes.new); + } + if (changes.old) { + removeWatcher(changes.old.envId.id); + } }); - // Seed with environments that already exist before this subscription. - const environments = await envManager.getEnvironments('all'); - environments.forEach(addWatcher); - return new Disposable(() => { envChangeDisposable?.dispose(); watchers.forEach((watcher) => watcher.dispose()); From 71afdce0dda32aac678c951d431a0dda95ad2e6b Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 18 Jun 2026 14:34:39 -0700 Subject: [PATCH 3/6] Linting and comments --- src/managers/common/packageWatcher.ts | 41 ++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/managers/common/packageWatcher.ts b/src/managers/common/packageWatcher.ts index 03fb4f61..8386a78e 100644 --- a/src/managers/common/packageWatcher.ts +++ b/src/managers/common/packageWatcher.ts @@ -5,10 +5,22 @@ import { traceVerbose } from '../../common/logging'; import { createSimpleDebounce } from '../../common/utils/debounce'; import { createFileSystemWatcher } from '../../common/workspace.apis'; +/** + * Derives the file system watch targets for a given Python environment. + * + * Targets include site-packages `.dist-info/METADATA` files (for pip installs/uninstalls) + * and conda-meta JSON files (for conda installs/uninstalls). + * + * @param env - The Python environment to derive watch targets for. + * @returns An array of RelativePattern objects, one per discoverable package location. + * Empty if the environment has no `sysPrefix` or discoverable paths. + */ function getWatchTargets(env: PythonEnvironment): RelativePattern[] { - if (!env.sysPrefix) return []; - const targets: RelativePattern[] = []; + if (!env.sysPrefix) { + return []; + } + const targets: RelativePattern[] = []; if (process.platform === 'win32') { targets.push( new RelativePattern(Uri.file(path.join(env.sysPrefix, 'Lib')), 'site-packages/**/*.dist-info/METADATA'), @@ -21,13 +33,21 @@ function getWatchTargets(env: PythonEnvironment): RelativePattern[] { ), ); } - - // Conda targets.push(new RelativePattern(Uri.file(path.join(env.sysPrefix, 'conda-meta')), '**/*.json')); - return targets; } +/** + * Creates a file system watcher for package changes in a single environment. + * + * Monitors site-packages and conda-meta locations for install/uninstall operations + * and triggers a debounced package refresh when changes are detected. + * + * @param env - The Python environment to watch. + * @param packageManager - The package manager to call refresh on when changes occur. + * @param log - Logger for diagnostic messages. + * @returns A disposable that removes the watcher when disposed. + */ export function watchPackageChangesForEnvironment( env: PythonEnvironment, packageManager: PackageManager, @@ -68,9 +88,16 @@ export function watchPackageChangesForEnvironment( } /** - * Registers package file watchers for all environments managed by the given manager. + * Registers automatic file system watchers for the active environment managed by a given manager. + * + * Creates per-environment watchers that are attached when the active environment changes + * and detached when it changes to a different environment. Ensures package changes + * (installs/uninstalls) in the active environment are detected and trigger a refresh. * - * This is project-agnostic: if a manager discovers an environment, we watch it. + * @param envManager - The environment manager whose active environment should be watched. + * @param packageManager - The package manager to call refresh on when changes occur. + * @param log - Logger for diagnostic and error messages. + * @returns A disposable that removes all watchers and subscriptions when disposed. */ export async function registerPackageWatcherForManager( envManager: EnvironmentManager, From a31b2c5f01ae8910332b8dd5c74f57cf30201092 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 18 Jun 2026 14:38:44 -0700 Subject: [PATCH 4/6] Add tests --- src/managers/common/packageWatcher.ts | 13 +- .../common/packageWatcher.unit.test.ts | 452 ++++++++++++++++++ 2 files changed, 456 insertions(+), 9 deletions(-) create mode 100644 src/test/managers/common/packageWatcher.unit.test.ts diff --git a/src/managers/common/packageWatcher.ts b/src/managers/common/packageWatcher.ts index 8386a78e..71ad66c9 100644 --- a/src/managers/common/packageWatcher.ts +++ b/src/managers/common/packageWatcher.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { Disposable, LogOutputChannel, RelativePattern, Uri } from 'vscode'; +import { Disposable, LogOutputChannel, RelativePattern } from 'vscode'; import { EnvironmentManager, PackageManager, PythonEnvironment } from '../../api'; import { traceVerbose } from '../../common/logging'; import { createSimpleDebounce } from '../../common/utils/debounce'; @@ -22,18 +22,13 @@ function getWatchTargets(env: PythonEnvironment): RelativePattern[] { const targets: RelativePattern[] = []; if (process.platform === 'win32') { - targets.push( - new RelativePattern(Uri.file(path.join(env.sysPrefix, 'Lib')), 'site-packages/**/*.dist-info/METADATA'), - ); + targets.push(new RelativePattern(path.join(env.sysPrefix, 'Lib'), 'site-packages/**/*.dist-info/METADATA')); } else { targets.push( - new RelativePattern( - Uri.file(path.join(env.sysPrefix, 'lib')), - 'python*/site-packages/**/*.dist-info/METADATA', - ), + new RelativePattern(path.join(env.sysPrefix, 'lib'), 'python*/site-packages/**/*.dist-info/METADATA'), ); } - targets.push(new RelativePattern(Uri.file(path.join(env.sysPrefix, 'conda-meta')), '**/*.json')); + targets.push(new RelativePattern(path.join(env.sysPrefix, 'conda-meta'), '**/*.json')); return targets; } diff --git a/src/test/managers/common/packageWatcher.unit.test.ts b/src/test/managers/common/packageWatcher.unit.test.ts new file mode 100644 index 00000000..dfcba120 --- /dev/null +++ b/src/test/managers/common/packageWatcher.unit.test.ts @@ -0,0 +1,452 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { EventEmitter, LogOutputChannel, RelativePattern, Uri } from 'vscode'; +import { + DidChangeEnvironmentEventArgs, + EnvironmentManager, + PackageManager, + PythonEnvironment, + PythonEnvironmentId, +} from '../../../api'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { + registerPackageWatcherForManager, + watchPackageChangesForEnvironment, +} from '../../../managers/common/packageWatcher'; + +suite('Package Watcher', () => { + let sandbox: sinon.SinonSandbox; + let createFileSystemWatcherStub: sinon.SinonStub; + let mockLogOutputChannel: Partial; + + setup(() => { + sandbox = sinon.createSandbox(); + mockLogOutputChannel = { + error: sandbox.stub(), + warn: sandbox.stub(), + info: sandbox.stub(), + debug: sandbox.stub(), + }; + createFileSystemWatcherStub = sandbox.stub(workspaceApis, 'createFileSystemWatcher'); + }); + + teardown(() => { + sandbox.restore(); + }); + + function createMockEnvironment(overrides?: Partial): PythonEnvironment { + const envId: PythonEnvironmentId = { + id: 'test-env-id', + managerId: 'test-manager', + ...overrides?.envId, + }; + + return { + envId, + name: 'test-env', + displayName: 'Test Environment', + displayPath: '/path/to/env', + environmentPath: Uri.file('/path/to/env'), + version: '3.11.0', + sysPrefix: '/path/to/env', + execInfo: { + run: { executable: '/path/to/env/bin/python' }, + }, + ...overrides, + } as unknown as PythonEnvironment; + } + + function createMockWatcher() { + const onDidCreateEmitter = new EventEmitter(); + const onDidDeleteEmitter = new EventEmitter(); + const onDidChangeEmitter = new EventEmitter(); + + return { + onDidCreate: onDidCreateEmitter.event, + onDidDelete: onDidDeleteEmitter.event, + onDidChange: onDidChangeEmitter.event, + dispose: sandbox.stub(), + _createEmitter: onDidCreateEmitter, + _deleteEmitter: onDidDeleteEmitter, + _changeEmitter: onDidChangeEmitter, + }; + } + + function createMockPackageManager(): Partial { + return { + refresh: sandbox.stub().resolves([]), + }; + } + + function createMockEnvironmentManager(overrides?: Partial): Partial { + const changeEmitter = new EventEmitter(); + + return { + onDidChangeEnvironment: changeEmitter.event, + ...overrides, + }; + } + + suite('watchPackageChangesForEnvironment', () => { + test('should create file system watchers for watch targets', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Should create watchers for site-packages and conda-meta + assert.strictEqual( + createFileSystemWatcherStub.callCount, + 2, + 'Should create 2 watchers (site-packages + conda-meta)', + ); + }); + + test('should create correct watch patterns on Windows', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const originalPlatform = (process as any).platform; + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + + try { + const env = createMockEnvironment({ sysPrefix: 'C:\\Users\\test\\env' }); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + const firstCall = createFileSystemWatcherStub.getCall(0); + const pattern = firstCall.args[0] as RelativePattern; + + assert.ok(pattern.baseUri.fsPath.includes('Lib'), 'Should use Lib for Windows'); + assert.strictEqual( + pattern.pattern, + 'site-packages/**/*.dist-info/METADATA', + 'Should watch .dist-info METADATA files', + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + } + }); + + test('should create correct watch patterns on POSIX', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const originalPlatform = (process as any).platform; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + + try { + const env = createMockEnvironment({ sysPrefix: '/home/test/env' }); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + const firstCall = createFileSystemWatcherStub.getCall(0); + const pattern = firstCall.args[0] as RelativePattern; + + assert.ok(pattern.baseUri.fsPath.includes('lib'), 'Should use lib for POSIX'); + assert.strictEqual( + pattern.pattern, + 'python*/site-packages/**/*.dist-info/METADATA', + 'Should watch .dist-info METADATA files with python* glob', + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + } + }); + + test('should watch conda-meta for conda packages', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment({ sysPrefix: '/path/to/env' }); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + const secondCall = createFileSystemWatcherStub.getCall(1); + const pattern = secondCall.args[0] as RelativePattern; + + assert.ok(pattern.baseUri.fsPath.includes('conda-meta'), 'Should watch conda-meta'); + assert.strictEqual(pattern.pattern, '**/*.json', 'Should watch JSON files in conda-meta'); + }); + + test('should call packageManager.refresh on file create', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Verify watchers are created which will have create event handlers + assert.strictEqual( + createFileSystemWatcherStub.callCount, + 2, + 'Should create watchers for site-packages and conda-meta', + ); + }); + + test('should call packageManager.refresh on file delete', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Verify watchers are created which will have delete event handlers + assert.strictEqual( + createFileSystemWatcherStub.callCount, + 2, + 'Should create watchers for site-packages and conda-meta', + ); + }); + + test('should debounce multiple rapid file events', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const packageManager = createMockPackageManager(); + + watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Verify watchers are created with event handlers for debouncing + assert.strictEqual( + createFileSystemWatcherStub.callCount, + 2, + 'Should create watchers with debounced event handlers', + ); + }); + + test('should dispose watchers when disposable is disposed', () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const packageManager = createMockPackageManager(); + + const disposable = watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + disposable.dispose(); + + // Should dispose all watchers + assert.ok((mockWatcher.dispose as sinon.SinonStub).called, 'Watcher should be disposed'); + }); + + test('should return empty disposable when environment has no sysPrefix', () => { + const env = createMockEnvironment({ sysPrefix: undefined }); + const packageManager = createMockPackageManager(); + + const disposable = watchPackageChangesForEnvironment( + env, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + assert.ok(disposable, 'Should return a disposable'); + // Should not create any watchers + assert.strictEqual( + createFileSystemWatcherStub.callCount, + 0, + 'Should not create watchers when sysPrefix is missing', + ); + }); + }); + + suite('registerPackageWatcherForManager', () => { + test('should create watcher for active environment on startup', async () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const changeEmitter = new EventEmitter(); + const envManager = createMockEnvironmentManager({ + onDidChangeEnvironment: changeEmitter.event, + }); + const packageManager = createMockPackageManager(); + + await registerPackageWatcherForManager( + envManager as EnvironmentManager, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Simulate environment change to active environment + changeEmitter.fire({ + uri: env.environmentPath, + new: env, + old: undefined, + }); + + // Should create watchers for the environment + assert.ok(createFileSystemWatcherStub.callCount > 0, 'Should create watchers when environment is set'); + }); + + test('should create new watcher when active environment changes', async () => { + const mockWatcher1 = createMockWatcher(); + const mockWatcher2 = createMockWatcher(); + createFileSystemWatcherStub.onFirstCall().returns(mockWatcher1); + createFileSystemWatcherStub.onSecondCall().returns(mockWatcher2); + createFileSystemWatcherStub.returns(mockWatcher2); + + const env1 = createMockEnvironment({ envId: { id: 'env-1', managerId: 'test' } }); + const env2 = createMockEnvironment({ envId: { id: 'env-2', managerId: 'test' } }); + + const changeEmitter = new EventEmitter(); + const envManager = createMockEnvironmentManager({ + onDidChangeEnvironment: changeEmitter.event, + }); + const packageManager = createMockPackageManager(); + + const disposable = await registerPackageWatcherForManager( + envManager as EnvironmentManager, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Create initial watcher for env1 + changeEmitter.fire({ + uri: env1.environmentPath, + new: env1, + old: undefined, + }); + + const initialCallCount = createFileSystemWatcherStub.callCount; + + // Simulate environment change to env2 + changeEmitter.fire({ + uri: env2.environmentPath, + new: env2, + old: env1, + }); + + // Should create new watchers for env2 + assert.ok( + createFileSystemWatcherStub.callCount > initialCallCount, + 'Should create new watchers for new environment', + ); + + // Old watcher should be disposed + assert.ok((mockWatcher1.dispose as sinon.SinonStub).called, 'Old watcher should be disposed'); + + disposable.dispose(); + }); + + test('should dispose all watchers when disposed', async () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment(); + const changeEmitter = new EventEmitter(); + const envManager = createMockEnvironmentManager({ + onDidChangeEnvironment: changeEmitter.event, + }); + const packageManager = createMockPackageManager(); + + const disposable = await registerPackageWatcherForManager( + envManager as EnvironmentManager, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Simulate environment change to setup watcher + changeEmitter.fire({ + uri: env.environmentPath, + new: env, + old: undefined, + }); + + disposable.dispose(); + + // Should dispose watchers + assert.ok((mockWatcher.dispose as sinon.SinonStub).called, 'Watchers should be disposed'); + }); + + test('should not create duplicate watchers for same environment', async () => { + const mockWatcher = createMockWatcher(); + createFileSystemWatcherStub.returns(mockWatcher); + + const env = createMockEnvironment({ envId: { id: 'env-1', managerId: 'test' } }); + + const changeEmitter = new EventEmitter(); + const envManager = createMockEnvironmentManager({ + onDidChangeEnvironment: changeEmitter.event, + }); + const packageManager = createMockPackageManager(); + + const disposable = await registerPackageWatcherForManager( + envManager as EnvironmentManager, + packageManager as PackageManager, + mockLogOutputChannel as LogOutputChannel, + ); + + // Set watcher for env1 + changeEmitter.fire({ + uri: env.environmentPath, + new: env, + old: undefined, + }); + + const initialCallCount = createFileSystemWatcherStub.callCount; + + // Fire another change for the same environment + changeEmitter.fire({ + uri: env.environmentPath, + new: env, + old: env, + }); + + // Should not create new watchers + assert.strictEqual( + createFileSystemWatcherStub.callCount, + initialCallCount, + 'Should not create duplicate watchers for same envId', + ); + + disposable.dispose(); + }); + }); +}); From 5d8b1b9ce74573c4828fd2962ff6e77964eef198 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 18 Jun 2026 16:00:59 -0700 Subject: [PATCH 5/6] Fix linting --- src/test/managers/common/packageWatcher.unit.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/managers/common/packageWatcher.unit.test.ts b/src/test/managers/common/packageWatcher.unit.test.ts index dfcba120..e7682fee 100644 --- a/src/test/managers/common/packageWatcher.unit.test.ts +++ b/src/test/managers/common/packageWatcher.unit.test.ts @@ -116,7 +116,7 @@ suite('Package Watcher', () => { const mockWatcher = createMockWatcher(); createFileSystemWatcherStub.returns(mockWatcher); - const originalPlatform = (process as any).platform; + const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); try { @@ -147,7 +147,7 @@ suite('Package Watcher', () => { const mockWatcher = createMockWatcher(); createFileSystemWatcherStub.returns(mockWatcher); - const originalPlatform = (process as any).platform; + const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); try { From 44f5a92390f75e3aea78a820943ff42852cbbc5e Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 18 Jun 2026 16:35:45 -0700 Subject: [PATCH 6/6] Delegate package locations to package manager --- api/src/main.ts | 11 ++++ examples/sample1/src/api.ts | 12 +++++ src/api.ts | 12 +++++ src/managers/common/packageWatcher.ts | 33 +++++------- src/managers/conda/condaPackageManager.ts | 10 ++++ .../common/packageWatcher.unit.test.ts | 51 ++++++++++--------- 6 files changed, 85 insertions(+), 44 deletions(-) diff --git a/api/src/main.ts b/api/src/main.ts index d66f6bdc..958755df 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -7,6 +7,7 @@ import { FileChangeType, LogOutputChannel, MarkdownString, + RelativePattern, TaskExecution, Terminal, TerminalOptions, @@ -687,6 +688,16 @@ export interface PackageManager { */ getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; + /** + * Returns additional filesystem patterns to watch for package install/uninstall changes. + * + * These patterns are appended to the default site-packages metadata locations. + * Implement this for manager-specific locations (for example, conda-meta). + * + * @param environment - The Python environment whose package paths should be watched. + * @returns Relative patterns to watch for package changes. + */ + getPackageWatchTargets?(environment: PythonEnvironment): RelativePattern[]; /** * Event that is fired when packages change. */ diff --git a/examples/sample1/src/api.ts b/examples/sample1/src/api.ts index b44e79b2..c45ae1cb 100644 --- a/examples/sample1/src/api.ts +++ b/examples/sample1/src/api.ts @@ -7,6 +7,7 @@ import { FileChangeType, LogOutputChannel, MarkdownString, + RelativePattern, TaskExecution, Terminal, TerminalOptions, @@ -616,6 +617,17 @@ export interface PackageManager { */ getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; + /** + * Returns additional filesystem patterns to watch for package install/uninstall changes. + * + * These patterns are appended to the default site-packages metadata locations. + * Implement this for manager-specific locations (for example, conda-meta). + * + * @param environment - The Python environment whose package paths should be watched. + * @returns Relative patterns to watch for package changes. + */ + getPackageWatchTargets?(environment: PythonEnvironment): RelativePattern[]; + /** * Event that is fired when packages change. */ diff --git a/src/api.ts b/src/api.ts index 6e07a505..6bd70e8b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -7,6 +7,7 @@ import type { FileChangeType, LogOutputChannel, MarkdownString, + RelativePattern, TaskExecution, Terminal, TerminalOptions, @@ -681,6 +682,17 @@ export interface PackageManager { */ getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; + /** + * Returns additional filesystem patterns to watch for package install/uninstall changes. + * + * These patterns are appended to the default site-packages metadata locations. + * Implement this for manager-specific locations (for example, conda-meta). + * + * @param environment - The Python environment whose package paths should be watched. + * @returns Relative patterns to watch for package changes. + */ + getPackageWatchTargets?(environment: PythonEnvironment): RelativePattern[]; + /** * Event that is fired when packages change. */ diff --git a/src/managers/common/packageWatcher.ts b/src/managers/common/packageWatcher.ts index 71ad66c9..413ce17b 100644 --- a/src/managers/common/packageWatcher.ts +++ b/src/managers/common/packageWatcher.ts @@ -8,34 +8,26 @@ import { createFileSystemWatcher } from '../../common/workspace.apis'; /** * Derives the file system watch targets for a given Python environment. * - * Targets include site-packages `.dist-info/METADATA` files (for pip installs/uninstalls) - * and conda-meta JSON files (for conda installs/uninstalls). + * Targets include site-packages `.dist-info/METADATA` files for pip-style installs. * * @param env - The Python environment to derive watch targets for. * @returns An array of RelativePattern objects, one per discoverable package location. * Empty if the environment has no `sysPrefix` or discoverable paths. */ -function getWatchTargets(env: PythonEnvironment): RelativePattern[] { +function getDefaultPackageWatchTargets(env: PythonEnvironment): RelativePattern[] { if (!env.sysPrefix) { return []; } - - const targets: RelativePattern[] = []; - if (process.platform === 'win32') { - targets.push(new RelativePattern(path.join(env.sysPrefix, 'Lib'), 'site-packages/**/*.dist-info/METADATA')); - } else { - targets.push( - new RelativePattern(path.join(env.sysPrefix, 'lib'), 'python*/site-packages/**/*.dist-info/METADATA'), - ); - } - targets.push(new RelativePattern(path.join(env.sysPrefix, 'conda-meta'), '**/*.json')); - return targets; + return process.platform === 'win32' + ? [new RelativePattern(path.join(env.sysPrefix, 'Lib'), 'site-packages/**/*.dist-info/METADATA')] // Windows + : [new RelativePattern(path.join(env.sysPrefix, 'lib'), 'python*/site-packages/**/*.dist-info/METADATA')]; // Unix-like } /** * Creates a file system watcher for package changes in a single environment. * - * Monitors site-packages and conda-meta locations for install/uninstall operations + * Monitors default site-packages locations and any manager-specific extra locations + * for install/uninstall operations. * and triggers a debounced package refresh when changes are detected. * * @param env - The Python environment to watch. @@ -49,14 +41,17 @@ export function watchPackageChangesForEnvironment( log: LogOutputChannel, ): Disposable { // Watch targets - const watchTargets = getWatchTargets(env); + const watchTargets = [ + ...getDefaultPackageWatchTargets(env), + ...(packageManager.getPackageWatchTargets?.(env) ?? []), + ]; if (watchTargets.length === 0) { traceVerbose(log, `No watch targets for environment ${env.envId}`); return new Disposable(() => undefined); } // Debounced refresh function const debouncedRefresh = createSimpleDebounce(500, async () => { - console.log(`Package change detected for environment ${env.envId}, refreshing packages...`); + traceVerbose(log, `Package change detected for environment ${env.envId.id}, refreshing packages.`); packageManager.refresh(env).catch((ex) => { log.error( `Failed to refresh packages for environment ${env.envId}: ${ex instanceof Error ? ex.message : String(ex)}`, @@ -68,9 +63,9 @@ export function watchPackageChangesForEnvironment( for (const target of watchTargets) { const watcher = createFileSystemWatcher( target, - true, // create -> install + false, // create -> install false, // change -> ignore - true, // delete -> uninstall + false, // delete -> uninstall ); disposables.push( watcher, diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index 27c26a4f..dc585480 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import { CancellationError, Disposable, @@ -6,6 +7,7 @@ import { LogOutputChannel, MarkdownString, ProgressLocation, + RelativePattern, } from 'vscode'; import { DidChangePackagesEventArgs, @@ -150,6 +152,14 @@ export class CondaPackageManager implements PackageManager, Disposable { return this.packages.get(environment.envId.id); } + getPackageWatchTargets(environment: PythonEnvironment): RelativePattern[] { + if (!environment.sysPrefix) { + return []; + } + + return [new RelativePattern(path.join(environment.sysPrefix, 'conda-meta'), '**/*.json')]; + } + dispose() { this._onDidChangePackages.dispose(); this.packages.clear(); diff --git a/src/test/managers/common/packageWatcher.unit.test.ts b/src/test/managers/common/packageWatcher.unit.test.ts index e7682fee..b28f08ac 100644 --- a/src/test/managers/common/packageWatcher.unit.test.ts +++ b/src/test/managers/common/packageWatcher.unit.test.ts @@ -104,12 +104,8 @@ suite('Package Watcher', () => { mockLogOutputChannel as LogOutputChannel, ); - // Should create watchers for site-packages and conda-meta - assert.strictEqual( - createFileSystemWatcherStub.callCount, - 2, - 'Should create 2 watchers (site-packages + conda-meta)', - ); + // Default should create watcher for site-packages metadata. + assert.strictEqual(createFileSystemWatcherStub.callCount, 1, 'Should create 1 watcher (site-packages)'); }); test('should create correct watch patterns on Windows', () => { @@ -174,12 +170,15 @@ suite('Package Watcher', () => { } }); - test('should watch conda-meta for conda packages', () => { + test('should append package-manager-provided watch targets to defaults', () => { const mockWatcher = createMockWatcher(); createFileSystemWatcherStub.returns(mockWatcher); const env = createMockEnvironment({ sysPrefix: '/path/to/env' }); const packageManager = createMockPackageManager(); + (packageManager as PackageManager).getPackageWatchTargets = () => [ + new RelativePattern('/path/to/env/conda-meta', '**/*.json'), + ]; watchPackageChangesForEnvironment( env, @@ -187,11 +186,19 @@ suite('Package Watcher', () => { mockLogOutputChannel as LogOutputChannel, ); + assert.strictEqual(createFileSystemWatcherStub.callCount, 2, 'Should watch default and custom targets'); + + const firstCall = createFileSystemWatcherStub.getCall(0); + const firstPattern = firstCall.args[0] as RelativePattern; const secondCall = createFileSystemWatcherStub.getCall(1); - const pattern = secondCall.args[0] as RelativePattern; + const secondPattern = secondCall.args[0] as RelativePattern; - assert.ok(pattern.baseUri.fsPath.includes('conda-meta'), 'Should watch conda-meta'); - assert.strictEqual(pattern.pattern, '**/*.json', 'Should watch JSON files in conda-meta'); + assert.ok( + firstPattern.pattern.endsWith('site-packages/**/*.dist-info/METADATA'), + 'Should keep default site-packages watcher', + ); + assert.ok(secondPattern.baseUri.fsPath.includes('conda-meta'), 'Should append conda-meta target'); + assert.strictEqual(secondPattern.pattern, '**/*.json', 'Should watch JSON files in conda-meta'); }); test('should call packageManager.refresh on file create', () => { @@ -207,12 +214,9 @@ suite('Package Watcher', () => { mockLogOutputChannel as LogOutputChannel, ); - // Verify watchers are created which will have create event handlers - assert.strictEqual( - createFileSystemWatcherStub.callCount, - 2, - 'Should create watchers for site-packages and conda-meta', - ); + // Verify watcher is created and create events are observed. + assert.strictEqual(createFileSystemWatcherStub.callCount, 1, 'Should create watcher for site-packages'); + assert.strictEqual(createFileSystemWatcherStub.getCall(0).args[1], false, 'Should watch create events'); }); test('should call packageManager.refresh on file delete', () => { @@ -228,12 +232,9 @@ suite('Package Watcher', () => { mockLogOutputChannel as LogOutputChannel, ); - // Verify watchers are created which will have delete event handlers - assert.strictEqual( - createFileSystemWatcherStub.callCount, - 2, - 'Should create watchers for site-packages and conda-meta', - ); + // Verify watcher is created and delete events are observed. + assert.strictEqual(createFileSystemWatcherStub.callCount, 1, 'Should create watcher for site-packages'); + assert.strictEqual(createFileSystemWatcherStub.getCall(0).args[3], false, 'Should watch delete events'); }); test('should debounce multiple rapid file events', () => { @@ -249,11 +250,11 @@ suite('Package Watcher', () => { mockLogOutputChannel as LogOutputChannel, ); - // Verify watchers are created with event handlers for debouncing + // Verify watcher is created with event handlers for debouncing. assert.strictEqual( createFileSystemWatcherStub.callCount, - 2, - 'Should create watchers with debounced event handlers', + 1, + 'Should create watcher with debounced event handlers', ); });