From 98edae0b36665426bf1bb2c5aec20808bcb74cc5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:50:29 +0000 Subject: [PATCH 01/11] Initial plan From 8ffcbb885411ae8d7a5bae6b1b2fff182cb3b6af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:59:47 +0000 Subject: [PATCH 02/11] Add refresh-on-package-change option --- CHANGELOG.md | 16 ++++++++++ README.md | 11 +++++++ typescript/src/activation.ts | 24 ++++++++++++++- typescript/src/python.ts | 37 ++++++++++++++++++++++++ typescript/tests/activation.test.ts | 45 +++++++++++++++++++++++++++++ typescript/tests/python.test.ts | 8 +++++ 6 files changed, 140 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5402731..f724e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **TypeScript** `PythonEnvironmentsProvider.onDidChangePackages` — event that + fires when the active environment's package managers report a package change + (install/uninstall), surfaced from the Python Environments extension's + `onDidChangePackages` API. The legacy `ms-python.python` extension does not + expose package events, so the event never fires when only that extension is + available. + +- **TypeScript** `registerCommonSubscriptions` now restarts the language server + on package-change events when the extension's `.refreshOnPackageChange` + setting is `true`. The setting defaults to `false`, so existing extensions are + unaffected until they opt in. + ## [0.7.0] - 2026-06-17 ### Added diff --git a/README.md b/README.md index 85b47ed..b84c994 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,17 @@ git submodule add https://github.com/microsoft/vscode-common-python-lsp.git subm **Python side** — install into `bundled/libs/` via noxfile. **TypeScript side** — `file:` dependency in `package.json`. +### Optional settings + +`registerCommonSubscriptions` listens for package changes reported by the +[Python Environments extension](https://github.com/microsoft/vscode-python-environments) +(`onDidChangePackages`). To restart the language server whenever packages are +installed or removed, an extension can expose a boolean +`.refreshOnPackageChange` setting (e.g. `flake8.refreshOnPackageChange`) +in its `package.json` `contributes.configuration`. The setting defaults to +`false`; when set to `true`, the shared activation logic restarts the server on +each package-change event. + ## Version Requirements | Runtime | Minimum Version | diff --git a/typescript/src/activation.ts b/typescript/src/activation.ts index 65ea8e5..589f734 100644 --- a/typescript/src/activation.ts +++ b/typescript/src/activation.ts @@ -22,7 +22,7 @@ import { checkIfConfigurationChanged, getWorkspaceSettings } from './settings'; import { registerLanguageStatusItem, updateStatus } from './status'; import { IServerInfo, ToolConfig } from './types'; import { getInterpreterFromSetting, getLSClientTraceLevel, getProjectRoot } from './utilities'; -import { onDidChangeConfiguration, registerCommand } from './vscodeapi'; +import { getConfiguration, onDidChangeConfiguration, registerCommand } from './vscodeapi'; // --------------------------------------------------------------------------- // Default restart delay @@ -31,6 +31,13 @@ import { onDidChangeConfiguration, registerCommand } from './vscodeapi'; /** Fallback when {@link ToolConfig.restartDelay} is not set. */ const DEFAULT_RESTART_DELAY = 1000; +/** + * Setting key (relative to the tool namespace) that opts in to restarting the + * server when the Python environment's package managers report a package + * change. Defaults to `false`. + */ +const REFRESH_ON_PACKAGE_CHANGE_SETTING = 'refreshOnPackageChange'; + // --------------------------------------------------------------------------- // ToolExtensionContext // --------------------------------------------------------------------------- @@ -380,6 +387,21 @@ export function registerCommonSubscriptions( }), ); + // Package change — opt-in via the `.refreshOnPackageChange` + // setting. When enabled, restart the server whenever the Python + // environment's package managers report a package install/uninstall. + context.subscriptions.push( + pythonProvider.onDidChangePackages(async () => { + const refreshOnPackageChange = getConfiguration(serverId).get( + REFRESH_ON_PACKAGE_CHANGE_SETTING, + false, + ); + if (refreshOnPackageChange) { + await safeRunServer(toolContext, 'package change'); + } + }), + ); + // Commands context.subscriptions.push( registerCommand(`${serverId}.showLogs`, async () => { diff --git a/typescript/src/python.ts b/typescript/src/python.ts index 8e1c4a4..0214942 100644 --- a/typescript/src/python.ts +++ b/typescript/src/python.ts @@ -50,6 +50,16 @@ export interface IPythonApi { /** Subscribe to interpreter/environment changes. */ onDidChangeEnvironment(handler: () => void): Disposable; + /** + * Subscribe to package changes detected by the environment's package + * managers. + * + * Only fired by the newer `ms-python.python-environments` extension. + * The legacy `ms-python.python` extension does not expose package + * change events, so its adapter returns a no-op {@link Disposable}. + */ + onDidChangePackages(handler: () => void): Disposable; + /** * Get the debugger package path. * @@ -113,6 +123,10 @@ function wrapEnvironmentsApi(api: PythonEnvironmentApi): IPythonApi { return api.onDidChangeEnvironment(handler); }, + onDidChangePackages(handler: () => void) { + return api.onDidChangePackages(handler); + }, + async getDebuggerPath() { // TODO: Not yet supported by the environments extension. Implement when it is. return undefined; @@ -167,6 +181,12 @@ function wrapLegacyApi(api: PythonExtension): IPythonApi { return api.environments.onDidChangeActiveEnvironmentPath(handler); }, + onDidChangePackages() { + // The legacy ms-python.python API does not expose package change + // events, so there is nothing to subscribe to. + return { dispose: () => undefined }; + }, + async getDebuggerPath() { return api.debug.getDebuggerPackagePath(); }, @@ -189,6 +209,16 @@ export class PythonEnvironmentsProvider { /** Fires when the active Python interpreter changes. */ public readonly onDidChangeInterpreter: Event = this._onDidChangeInterpreter.event; + private readonly _onDidChangePackages = new EventEmitter(); + /** + * Fires when the active environment's package managers report a package + * change (install/uninstall). + * + * Only emitted when the newer `ms-python.python-environments` extension + * is providing the API. + */ + public readonly onDidChangePackages: Event = this._onDidChangePackages.event; + private _api: IPythonApi | undefined; private _apiResolved = false; private _serverPython: string[] | undefined; @@ -281,6 +311,12 @@ export class PythonEnvironmentsProvider { }), ); + disposables.push( + api.onDidChangePackages(() => { + this._onDidChangePackages.fire(); + }), + ); + traceLog(`Waiting for interpreter from ${api.extension} extension.`); await this.refreshServerPython(); } catch (error) { @@ -360,6 +396,7 @@ export class PythonEnvironmentsProvider { /** Dispose internal resources. */ dispose(): void { this._onDidChangeInterpreter.dispose(); + this._onDidChangePackages.dispose(); } } diff --git a/typescript/tests/activation.test.ts b/typescript/tests/activation.test.ts index 1b15a5a..918125b 100644 --- a/typescript/tests/activation.test.ts +++ b/typescript/tests/activation.test.ts @@ -59,6 +59,7 @@ function makeMockProvider(sandbox: sinon.SinonSandbox): PythonEnvironmentsProvid getDebuggerPath: sandbox.stub().resolves(undefined), initializePython: sandbox.stub().resolves(), onDidChangeInterpreter: sinon.stub().returns({ dispose: sinon.stub() }), + onDidChangePackages: sinon.stub().returns({ dispose: sinon.stub() }), } as unknown as PythonEnvironmentsProvider; } @@ -324,6 +325,49 @@ suite('registerCommonSubscriptions', () => { (vscodeapi.onDidChangeConfiguration as sinon.SinonStub).calledOnce, ); }); + + test('subscribes to package change events', () => { + const options = makeRegisterOptions(); + registerCommonSubscriptions(context, options); + const onDidChangePackages = options.pythonProvider.onDidChangePackages as unknown as sinon.SinonStub; + assert.isTrue(onDidChangePackages.calledOnce, 'should subscribe to package change events'); + }); + + test('restarts server on package change when refreshOnPackageChange is enabled', async () => { + sandbox.stub(vscodeapi, 'getConfiguration').returns({ + get: () => true, + } as unknown as vscode.WorkspaceConfiguration); + + const options = makeRegisterOptions(); + registerCommonSubscriptions(context, options); + + const onDidChangePackages = options.pythonProvider.onDidChangePackages as unknown as sinon.SinonStub; + const handler = onDidChangePackages.firstCall.args[0] as () => Promise; + await handler(); + + assert.isTrue( + (options.toolContext.runServer as sinon.SinonStub).called, + 'runServer should be called on package change when enabled', + ); + }); + + test('does not restart server on package change when refreshOnPackageChange is disabled', async () => { + sandbox.stub(vscodeapi, 'getConfiguration').returns({ + get: () => false, + } as unknown as vscode.WorkspaceConfiguration); + + const options = makeRegisterOptions(); + registerCommonSubscriptions(context, options); + + const onDidChangePackages = options.pythonProvider.onDidChangePackages as unknown as sinon.SinonStub; + const handler = onDidChangePackages.firstCall.args[0] as () => Promise; + await handler(); + + assert.isFalse( + (options.toolContext.runServer as sinon.SinonStub).called, + 'runServer should not be called on package change when disabled', + ); + }); }); // --------------------------------------------------------------------------- @@ -435,6 +479,7 @@ suite('createToolContext – NullFormatter lifecycle', () => { getDebuggerPath: sandbox.stub().resolves(undefined), initializePython: sandbox.stub().resolves(), onDidChangeInterpreter: sinon.stub().returns({ dispose: sinon.stub() }), + onDidChangePackages: sinon.stub().returns({ dispose: sinon.stub() }), } as unknown as PythonEnvironmentsProvider, ...overrides, }; diff --git a/typescript/tests/python.test.ts b/typescript/tests/python.test.ts index 9112e4c..1d53c65 100644 --- a/typescript/tests/python.test.ts +++ b/typescript/tests/python.test.ts @@ -127,6 +127,14 @@ suite('PythonEnvironmentsProvider', () => { }); }); + suite('onDidChangePackages', () => { + test('event is defined', () => { + const config = makeToolConfig(); + const provider = new PythonEnvironmentsProvider(config); + assert.isDefined(provider.onDidChangePackages); + }); + }); + suite('dispose', () => { test('does not throw', () => { const config = makeToolConfig(); From fc34a890c96309d1028d627735f39897dea7a1bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:06:47 +0000 Subject: [PATCH 03/11] Move refreshOnPackageChange opt-in to ToolConfig key --- CHANGELOG.md | 4 ++-- README.md | 13 ++++++------ typescript/src/activation.ts | 31 ++++++++++------------------- typescript/src/types.ts | 14 +++++++++++++ typescript/tests/activation.test.ts | 31 ++++++++--------------------- 5 files changed, 40 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f724e79..2dde2ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 available. - **TypeScript** `registerCommonSubscriptions` now restarts the language server - on package-change events when the extension's `.refreshOnPackageChange` - setting is `true`. The setting defaults to `false`, so existing extensions are + on package-change events when the extension's `ToolConfig.refreshOnPackageChange` + key is `true`. The key defaults to `false`, so existing extensions are unaffected until they opt in. ## [0.7.0] - 2026-06-17 diff --git a/README.md b/README.md index b84c994..2ec7867 100644 --- a/README.md +++ b/README.md @@ -70,16 +70,15 @@ git submodule add https://github.com/microsoft/vscode-common-python-lsp.git subm **Python side** — install into `bundled/libs/` via noxfile. **TypeScript side** — `file:` dependency in `package.json`. -### Optional settings +### Optional configuration -`registerCommonSubscriptions` listens for package changes reported by the +`registerCommonSubscriptions` can listen for package changes reported by the [Python Environments extension](https://github.com/microsoft/vscode-python-environments) (`onDidChangePackages`). To restart the language server whenever packages are -installed or removed, an extension can expose a boolean -`.refreshOnPackageChange` setting (e.g. `flake8.refreshOnPackageChange`) -in its `package.json` `contributes.configuration`. The setting defaults to -`false`; when set to `true`, the shared activation logic restarts the server on -each package-change event. +installed or removed, an extension sets `refreshOnPackageChange: true` on the +`ToolConfig` it passes in. The key defaults to `false`; when set to `true`, the +shared activation logic subscribes to package-change events and restarts the +server on each one. ## Version Requirements diff --git a/typescript/src/activation.ts b/typescript/src/activation.ts index 589f734..35f46e0 100644 --- a/typescript/src/activation.ts +++ b/typescript/src/activation.ts @@ -22,7 +22,7 @@ import { checkIfConfigurationChanged, getWorkspaceSettings } from './settings'; import { registerLanguageStatusItem, updateStatus } from './status'; import { IServerInfo, ToolConfig } from './types'; import { getInterpreterFromSetting, getLSClientTraceLevel, getProjectRoot } from './utilities'; -import { getConfiguration, onDidChangeConfiguration, registerCommand } from './vscodeapi'; +import { onDidChangeConfiguration, registerCommand } from './vscodeapi'; // --------------------------------------------------------------------------- // Default restart delay @@ -31,13 +31,6 @@ import { getConfiguration, onDidChangeConfiguration, registerCommand } from './v /** Fallback when {@link ToolConfig.restartDelay} is not set. */ const DEFAULT_RESTART_DELAY = 1000; -/** - * Setting key (relative to the tool namespace) that opts in to restarting the - * server when the Python environment's package managers report a package - * change. Defaults to `false`. - */ -const REFRESH_ON_PACKAGE_CHANGE_SETTING = 'refreshOnPackageChange'; - // --------------------------------------------------------------------------- // ToolExtensionContext // --------------------------------------------------------------------------- @@ -387,20 +380,16 @@ export function registerCommonSubscriptions( }), ); - // Package change — opt-in via the `.refreshOnPackageChange` - // setting. When enabled, restart the server whenever the Python - // environment's package managers report a package install/uninstall. - context.subscriptions.push( - pythonProvider.onDidChangePackages(async () => { - const refreshOnPackageChange = getConfiguration(serverId).get( - REFRESH_ON_PACKAGE_CHANGE_SETTING, - false, - ); - if (refreshOnPackageChange) { + // Package change — opt-in via the `refreshOnPackageChange` key on the + // extension's ToolConfig. When enabled, restart the server whenever the + // Python environment's package managers report a package install/uninstall. + if (toolConfig.refreshOnPackageChange) { + context.subscriptions.push( + pythonProvider.onDidChangePackages(async () => { await safeRunServer(toolContext, 'package change'); - } - }), - ); + }), + ); + } // Commands context.subscriptions.push( diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 049dbff..82eb650 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -54,6 +54,20 @@ export interface ToolConfig { // Environment extraEnvVars?: Record; + /** + * Set to `true` to restart the language server whenever the active Python + * environment's package managers report a package change (install or + * uninstall), as surfaced by {@link PythonEnvironmentsProvider.onDidChangePackages}. + * + * The legacy `ms-python.python` extension does not expose package events, + * so this has no effect unless the Python Environments extension is + * available. + * + * Defaults to `false`, so existing extensions are unaffected until they + * opt in. + */ + refreshOnPackageChange?: boolean; + /** * Set to `true` for tools that provide LSP formatting (textDocument/formatting, * rangeFormatting, rangesFormatting). diff --git a/typescript/tests/activation.test.ts b/typescript/tests/activation.test.ts index 918125b..20aa8a7 100644 --- a/typescript/tests/activation.test.ts +++ b/typescript/tests/activation.test.ts @@ -326,46 +326,31 @@ suite('registerCommonSubscriptions', () => { ); }); - test('subscribes to package change events', () => { - const options = makeRegisterOptions(); + test('subscribes to package change events when refreshOnPackageChange is enabled', () => { + const options = makeRegisterOptions({ toolConfig: makeToolConfig({ refreshOnPackageChange: true }) }); registerCommonSubscriptions(context, options); const onDidChangePackages = options.pythonProvider.onDidChangePackages as unknown as sinon.SinonStub; assert.isTrue(onDidChangePackages.calledOnce, 'should subscribe to package change events'); }); - test('restarts server on package change when refreshOnPackageChange is enabled', async () => { - sandbox.stub(vscodeapi, 'getConfiguration').returns({ - get: () => true, - } as unknown as vscode.WorkspaceConfiguration); - + test('does not subscribe to package change events when refreshOnPackageChange is disabled', () => { const options = makeRegisterOptions(); registerCommonSubscriptions(context, options); - const onDidChangePackages = options.pythonProvider.onDidChangePackages as unknown as sinon.SinonStub; - const handler = onDidChangePackages.firstCall.args[0] as () => Promise; - await handler(); - - assert.isTrue( - (options.toolContext.runServer as sinon.SinonStub).called, - 'runServer should be called on package change when enabled', - ); + assert.isFalse(onDidChangePackages.called, 'should not subscribe to package change events'); }); - test('does not restart server on package change when refreshOnPackageChange is disabled', async () => { - sandbox.stub(vscodeapi, 'getConfiguration').returns({ - get: () => false, - } as unknown as vscode.WorkspaceConfiguration); - - const options = makeRegisterOptions(); + test('restarts server on package change when refreshOnPackageChange is enabled', async () => { + const options = makeRegisterOptions({ toolConfig: makeToolConfig({ refreshOnPackageChange: true }) }); registerCommonSubscriptions(context, options); const onDidChangePackages = options.pythonProvider.onDidChangePackages as unknown as sinon.SinonStub; const handler = onDidChangePackages.firstCall.args[0] as () => Promise; await handler(); - assert.isFalse( + assert.isTrue( (options.toolContext.runServer as sinon.SinonStub).called, - 'runServer should not be called on package change when disabled', + 'runServer should be called on package change when enabled', ); }); }); From bde5b67017f2a1d9a4418e94e4f18b83d8de5017 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:20:40 +0000 Subject: [PATCH 04/11] Consolidate package-change handling internally; drop public onDidChangePackages --- CHANGELOG.md | 20 ++++----- README.md | 12 +++--- typescript/src/activation.ts | 21 ++++----- typescript/src/python.ts | 30 ++++++------- typescript/src/types.ts | 10 +++-- typescript/tests/activation.test.ts | 67 ++++++++++++++++------------- typescript/tests/python.test.ts | 11 +++-- 7 files changed, 88 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dde2ca..0c56ab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,17 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **TypeScript** `PythonEnvironmentsProvider.onDidChangePackages` — event that - fires when the active environment's package managers report a package change - (install/uninstall), surfaced from the Python Environments extension's - `onDidChangePackages` API. The legacy `ms-python.python` extension does not - expose package events, so the event never fires when only that extension is - available. - -- **TypeScript** `registerCommonSubscriptions` now restarts the language server - on package-change events when the extension's `ToolConfig.refreshOnPackageChange` - key is `true`. The key defaults to `false`, so existing extensions are - unaffected until they opt in. +- **TypeScript** `ToolConfig.refreshOnPackageChange` — opt-in key that, when + set to `true`, makes the shared activation logic restart the language server + whenever the active environment's package managers report a package change + (install/uninstall). The provider subscribes to the underlying package-change + event once during initialization; the event is handled entirely inside the + package and is not exposed as public API. The key defaults to `false`, so + existing extensions are unaffected until they opt in. The legacy + `ms-python.python` extension does not expose package events, so this has no + effect unless the Python Environments extension is available. ## [0.7.0] - 2026-06-17 diff --git a/README.md b/README.md index 2ec7867..f3adcd2 100644 --- a/README.md +++ b/README.md @@ -72,13 +72,13 @@ git submodule add https://github.com/microsoft/vscode-common-python-lsp.git subm ### Optional configuration -`registerCommonSubscriptions` can listen for package changes reported by the +To restart the language server whenever packages are installed or removed, +an extension sets `refreshOnPackageChange: true` on the `ToolConfig` it passes +in. The key defaults to `false`; when set to `true`, the shared activation logic +subscribes once to the package-change events reported by the [Python Environments extension](https://github.com/microsoft/vscode-python-environments) -(`onDidChangePackages`). To restart the language server whenever packages are -installed or removed, an extension sets `refreshOnPackageChange: true` on the -`ToolConfig` it passes in. The key defaults to `false`; when set to `true`, the -shared activation logic subscribes to package-change events and restarts the -server on each one. +during initialization and restarts the server on each one. The subscription is +handled internally — it is not exposed as public API. ## Version Requirements diff --git a/typescript/src/activation.ts b/typescript/src/activation.ts index 35f46e0..1051509 100644 --- a/typescript/src/activation.ts +++ b/typescript/src/activation.ts @@ -285,7 +285,15 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens const interpreter = getInterpreterFromSetting(serverId); if (interpreter === undefined || interpreter.length === 0) { traceLog('Python extension loading'); - await pythonProvider.initializePython(subscriptions); + // Opt-in via the `refreshOnPackageChange` key on the + // extension's ToolConfig: when enabled, restart the server + // whenever the Python environment's package managers report + // a package install/uninstall. The provider subscribes to + // the underlying event once and invokes this callback. + const onPackageChange = toolConfig.refreshOnPackageChange + ? () => void safeRunServer(ctx, 'package change') + : undefined; + await pythonProvider.initializePython(subscriptions, onPackageChange); traceLog('Python extension loaded'); } else { await ctx.runServer(); @@ -380,17 +388,6 @@ export function registerCommonSubscriptions( }), ); - // Package change — opt-in via the `refreshOnPackageChange` key on the - // extension's ToolConfig. When enabled, restart the server whenever the - // Python environment's package managers report a package install/uninstall. - if (toolConfig.refreshOnPackageChange) { - context.subscriptions.push( - pythonProvider.onDidChangePackages(async () => { - await safeRunServer(toolContext, 'package change'); - }), - ); - } - // Commands context.subscriptions.push( registerCommand(`${serverId}.showLogs`, async () => { diff --git a/typescript/src/python.ts b/typescript/src/python.ts index 0214942..d35033c 100644 --- a/typescript/src/python.ts +++ b/typescript/src/python.ts @@ -209,16 +209,6 @@ export class PythonEnvironmentsProvider { /** Fires when the active Python interpreter changes. */ public readonly onDidChangeInterpreter: Event = this._onDidChangeInterpreter.event; - private readonly _onDidChangePackages = new EventEmitter(); - /** - * Fires when the active environment's package managers report a package - * change (install/uninstall). - * - * Only emitted when the newer `ms-python.python-environments` extension - * is providing the API. - */ - public readonly onDidChangePackages: Event = this._onDidChangePackages.event; - private _api: IPythonApi | undefined; private _apiResolved = false; private _serverPython: string[] | undefined; @@ -293,8 +283,17 @@ export class PythonEnvironmentsProvider { /** * Set up event listeners for Python interpreter changes and resolve * the initial interpreter. + * + * @param disposables - Collected disposables for the registered listeners. + * @param onPackageChange - Optional callback invoked whenever the active + * environment's package managers report a package change + * (install/uninstall). When provided, this method subscribes to the + * underlying package-change event once and forwards each notification to + * the callback — typically used to refresh the language server. Only the + * newer `ms-python.python-environments` extension emits these events; the + * legacy `ms-python.python` adapter is a no-op. */ - async initializePython(disposables: Disposable[]): Promise { + async initializePython(disposables: Disposable[], onPackageChange?: () => void): Promise { try { const api = await this.getApi(); if (!api) { @@ -311,11 +310,9 @@ export class PythonEnvironmentsProvider { }), ); - disposables.push( - api.onDidChangePackages(() => { - this._onDidChangePackages.fire(); - }), - ); + if (onPackageChange) { + disposables.push(api.onDidChangePackages(() => onPackageChange())); + } traceLog(`Waiting for interpreter from ${api.extension} extension.`); await this.refreshServerPython(); @@ -396,7 +393,6 @@ export class PythonEnvironmentsProvider { /** Dispose internal resources. */ dispose(): void { this._onDidChangeInterpreter.dispose(); - this._onDidChangePackages.dispose(); } } diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 82eb650..71178ac 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -57,11 +57,13 @@ export interface ToolConfig { /** * Set to `true` to restart the language server whenever the active Python * environment's package managers report a package change (install or - * uninstall), as surfaced by {@link PythonEnvironmentsProvider.onDidChangePackages}. + * uninstall). * - * The legacy `ms-python.python` extension does not expose package events, - * so this has no effect unless the Python Environments extension is - * available. + * When enabled, the shared activation logic subscribes to the underlying + * package-change event during initialization and restarts the server on + * each notification. The legacy `ms-python.python` extension does not + * expose package events, so this has no effect unless the Python + * Environments extension is available. * * Defaults to `false`, so existing extensions are unaffected until they * opt in. diff --git a/typescript/tests/activation.test.ts b/typescript/tests/activation.test.ts index 20aa8a7..c1b29ff 100644 --- a/typescript/tests/activation.test.ts +++ b/typescript/tests/activation.test.ts @@ -59,7 +59,6 @@ function makeMockProvider(sandbox: sinon.SinonSandbox): PythonEnvironmentsProvid getDebuggerPath: sandbox.stub().resolves(undefined), initializePython: sandbox.stub().resolves(), onDidChangeInterpreter: sinon.stub().returns({ dispose: sinon.stub() }), - onDidChangePackages: sinon.stub().returns({ dispose: sinon.stub() }), } as unknown as PythonEnvironmentsProvider; } @@ -235,6 +234,43 @@ suite('createToolContext', () => { ); }); + test('initialize passes a package-change callback when refreshOnPackageChange is enabled', async () => { + (utilities.getInterpreterFromSetting as sinon.SinonStub).returns(undefined); + const provider = makeMockProvider(sandbox); + const ctx = createToolContext( + makeOptions({ + pythonProvider: provider, + toolConfig: makeToolConfig({ refreshOnPackageChange: true }), + }), + ); + await ctx.initialize([]); + + const initializePython = provider.initializePython as sinon.SinonStub; + const onPackageChange = initializePython.firstCall.args[1] as (() => void) | undefined; + assert.isFunction(onPackageChange, 'should pass a package-change callback'); + + // Invoking the callback restarts the server. + onPackageChange?.(); + // Flush the async runServer chain (all stubbed promises resolve). + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.isTrue( + (serverModule.restartServer as sinon.SinonStub).called, + 'package-change callback should restart the server', + ); + }); + + test('initialize omits the package-change callback when refreshOnPackageChange is disabled', async () => { + (utilities.getInterpreterFromSetting as sinon.SinonStub).returns(undefined); + const provider = makeMockProvider(sandbox); + const ctx = createToolContext(makeOptions({ pythonProvider: provider })); + await ctx.initialize([]); + + const initializePython = provider.initializePython as sinon.SinonStub; + const onPackageChange = initializePython.firstCall.args[1] as (() => void) | undefined; + assert.isUndefined(onPackageChange, 'should not pass a package-change callback'); + }); + test('dispose prevents further runServer calls', async () => { const ctx = createToolContext(makeOptions()); ctx.dispose(); @@ -325,34 +361,6 @@ suite('registerCommonSubscriptions', () => { (vscodeapi.onDidChangeConfiguration as sinon.SinonStub).calledOnce, ); }); - - test('subscribes to package change events when refreshOnPackageChange is enabled', () => { - const options = makeRegisterOptions({ toolConfig: makeToolConfig({ refreshOnPackageChange: true }) }); - registerCommonSubscriptions(context, options); - const onDidChangePackages = options.pythonProvider.onDidChangePackages as unknown as sinon.SinonStub; - assert.isTrue(onDidChangePackages.calledOnce, 'should subscribe to package change events'); - }); - - test('does not subscribe to package change events when refreshOnPackageChange is disabled', () => { - const options = makeRegisterOptions(); - registerCommonSubscriptions(context, options); - const onDidChangePackages = options.pythonProvider.onDidChangePackages as unknown as sinon.SinonStub; - assert.isFalse(onDidChangePackages.called, 'should not subscribe to package change events'); - }); - - test('restarts server on package change when refreshOnPackageChange is enabled', async () => { - const options = makeRegisterOptions({ toolConfig: makeToolConfig({ refreshOnPackageChange: true }) }); - registerCommonSubscriptions(context, options); - - const onDidChangePackages = options.pythonProvider.onDidChangePackages as unknown as sinon.SinonStub; - const handler = onDidChangePackages.firstCall.args[0] as () => Promise; - await handler(); - - assert.isTrue( - (options.toolContext.runServer as sinon.SinonStub).called, - 'runServer should be called on package change when enabled', - ); - }); }); // --------------------------------------------------------------------------- @@ -464,7 +472,6 @@ suite('createToolContext – NullFormatter lifecycle', () => { getDebuggerPath: sandbox.stub().resolves(undefined), initializePython: sandbox.stub().resolves(), onDidChangeInterpreter: sinon.stub().returns({ dispose: sinon.stub() }), - onDidChangePackages: sinon.stub().returns({ dispose: sinon.stub() }), } as unknown as PythonEnvironmentsProvider, ...overrides, }; diff --git a/typescript/tests/python.test.ts b/typescript/tests/python.test.ts index 1d53c65..7593b9d 100644 --- a/typescript/tests/python.test.ts +++ b/typescript/tests/python.test.ts @@ -127,11 +127,16 @@ suite('PythonEnvironmentsProvider', () => { }); }); - suite('onDidChangePackages', () => { - test('event is defined', () => { + suite('initializePython', () => { + test('accepts an optional package-change callback without throwing', async () => { const config = makeToolConfig(); const provider = new PythonEnvironmentsProvider(config); - assert.isDefined(provider.onDidChangePackages); + // No Python extension is available in the test environment, so + // getApi() resolves to undefined and initializePython returns + // early — the call must not throw with or without the callback. + const disposables: { dispose: () => void }[] = []; + await provider.initializePython(disposables, () => undefined); + assert.isArray(disposables); }); }); From 9c8467f7bb74eecdedf2f3316f0d9566f1f644a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:21:37 +0000 Subject: [PATCH 05/11] Clarify async-flush comment in package-change test --- typescript/tests/activation.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/typescript/tests/activation.test.ts b/typescript/tests/activation.test.ts index c1b29ff..2379ed9 100644 --- a/typescript/tests/activation.test.ts +++ b/typescript/tests/activation.test.ts @@ -249,9 +249,11 @@ suite('createToolContext', () => { const onPackageChange = initializePython.firstCall.args[1] as (() => void) | undefined; assert.isFunction(onPackageChange, 'should pass a package-change callback'); - // Invoking the callback restarts the server. + // Invoking the callback restarts the server. runServer awaits three + // pre-resolved stubs (getProjectRoot, getWorkspaceSettings, + // restartServer) before restartServer is reached, so flush the + // macrotask queue twice to let the whole chain settle. onPackageChange?.(); - // Flush the async runServer chain (all stubbed promises resolve). await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); assert.isTrue( From b3f4d2567ac87d3ee79aca99e950619fc7efcf0d Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 25 Jun 2026 16:47:55 -0700 Subject: [PATCH 06/11] Rename opt-in key to refreshExtensionOnPackageChange and keep package event internal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 14 ++++++------ README.md | 4 ++-- typescript/package-lock.json | 4 ++++ typescript/src/activation.ts | 13 ++++++----- typescript/src/python.ts | 35 +++++++++++++++++++---------- typescript/src/types.ts | 2 +- typescript/tests/activation.test.ts | 6 ++--- 7 files changed, 47 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c56ab5..6fe012b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **TypeScript** `ToolConfig.refreshOnPackageChange` — opt-in key that, when - set to `true`, makes the shared activation logic restart the language server - whenever the active environment's package managers report a package change - (install/uninstall). The provider subscribes to the underlying package-change - event once during initialization; the event is handled entirely inside the - package and is not exposed as public API. The key defaults to `false`, so - existing extensions are unaffected until they opt in. The legacy +- **TypeScript** `ToolConfig.refreshExtensionOnPackageChange` — opt-in key that, + when set to `true`, makes the shared activation logic restart the language + server whenever the active environment's package managers report a package + change (install/uninstall). The provider subscribes to the underlying + package-change event once during initialization; the event is handled entirely + inside the package and is not exposed as public API. The key defaults to + `false`, so existing extensions are unaffected until they opt in. The legacy `ms-python.python` extension does not expose package events, so this has no effect unless the Python Environments extension is available. diff --git a/README.md b/README.md index f3adcd2..2c52e5e 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ git submodule add https://github.com/microsoft/vscode-common-python-lsp.git subm ### Optional configuration To restart the language server whenever packages are installed or removed, -an extension sets `refreshOnPackageChange: true` on the `ToolConfig` it passes -in. The key defaults to `false`; when set to `true`, the shared activation logic +an extension sets `refreshExtensionOnPackageChange: true` on the `ToolConfig` it +passes in. The key defaults to `false`; when set to `true`, the shared activation logic subscribes once to the package-change events reported by the [Python Environments extension](https://github.com/microsoft/vscode-python-environments) during initialization and restarts the server on each one. The subscription is diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 957776c..366a030 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -451,6 +451,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -637,6 +638,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1052,6 +1054,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2578,6 +2581,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/typescript/src/activation.ts b/typescript/src/activation.ts index 1051509..93a1a0e 100644 --- a/typescript/src/activation.ts +++ b/typescript/src/activation.ts @@ -285,12 +285,13 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens const interpreter = getInterpreterFromSetting(serverId); if (interpreter === undefined || interpreter.length === 0) { traceLog('Python extension loading'); - // Opt-in via the `refreshOnPackageChange` key on the - // extension's ToolConfig: when enabled, restart the server - // whenever the Python environment's package managers report - // a package install/uninstall. The provider subscribes to - // the underlying event once and invokes this callback. - const onPackageChange = toolConfig.refreshOnPackageChange + // Opt-in via the `refreshExtensionOnPackageChange` key on + // the extension's ToolConfig: when enabled, restart the + // server whenever the Python environment's package managers + // report a package install/uninstall. The provider + // subscribes to the underlying event once and invokes this + // callback. + const onPackageChange = toolConfig.refreshExtensionOnPackageChange ? () => void safeRunServer(ctx, 'package change') : undefined; await pythonProvider.initializePython(subscriptions, onPackageChange); diff --git a/typescript/src/python.ts b/typescript/src/python.ts index d35033c..4536cbe 100644 --- a/typescript/src/python.ts +++ b/typescript/src/python.ts @@ -50,6 +50,25 @@ export interface IPythonApi { /** Subscribe to interpreter/environment changes. */ onDidChangeEnvironment(handler: () => void): Disposable; + /** + * Get the debugger package path. + * + * Only available via the legacy `ms-python.python` extension. + * Returns `undefined` when provided by `ms-python.python-environments`. + */ + getDebuggerPath(): Promise; +} + +/** + * Internal extension of {@link IPythonApi} that adds the package-change + * subscription. + * + * This is intentionally **not** exported from the package: the package-change + * event is an internal implementation detail used to refresh the language + * server (see {@link PythonEnvironmentsProvider.initializePython}) and must not + * become part of the public API surface. + */ +interface IPythonApiInternal extends IPythonApi { /** * Subscribe to package changes detected by the environment's package * managers. @@ -59,14 +78,6 @@ export interface IPythonApi { * change events, so its adapter returns a no-op {@link Disposable}. */ onDidChangePackages(handler: () => void): Disposable; - - /** - * Get the debugger package path. - * - * Only available via the legacy `ms-python.python` extension. - * Returns `undefined` when provided by `ms-python.python-environments`. - */ - getDebuggerPath(): Promise; } // --------------------------------------------------------------------------- @@ -74,7 +85,7 @@ export interface IPythonApi { // --------------------------------------------------------------------------- /** Wrap the newer `@vscode/python-environments` API. */ -function wrapEnvironmentsApi(api: PythonEnvironmentApi): IPythonApi { +function wrapEnvironmentsApi(api: PythonEnvironmentApi): IPythonApiInternal { return { extension: 'ms-python.python-environments', @@ -135,7 +146,7 @@ function wrapEnvironmentsApi(api: PythonEnvironmentApi): IPythonApi { } /** Wrap the legacy `@vscode/python-extension` API. */ -function wrapLegacyApi(api: PythonExtension): IPythonApi { +function wrapLegacyApi(api: PythonExtension): IPythonApiInternal { return { extension: 'ms-python.python', @@ -209,7 +220,7 @@ export class PythonEnvironmentsProvider { /** Fires when the active Python interpreter changes. */ public readonly onDidChangeInterpreter: Event = this._onDidChangeInterpreter.event; - private _api: IPythonApi | undefined; + private _api: IPythonApiInternal | undefined; private _apiResolved = false; private _serverPython: string[] | undefined; @@ -227,7 +238,7 @@ export class PythonEnvironmentsProvider { // API acquisition (cached, envs preferred → legacy fallback) // ----------------------------------------------------------------- - private async getApi(useCache: boolean = true): Promise { + private async getApi(useCache: boolean = true): Promise { if (useCache && this._apiResolved) { return this._api; } diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 71178ac..d066780 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -68,7 +68,7 @@ export interface ToolConfig { * Defaults to `false`, so existing extensions are unaffected until they * opt in. */ - refreshOnPackageChange?: boolean; + refreshExtensionOnPackageChange?: boolean; /** * Set to `true` for tools that provide LSP formatting (textDocument/formatting, diff --git a/typescript/tests/activation.test.ts b/typescript/tests/activation.test.ts index 2379ed9..f9aa44f 100644 --- a/typescript/tests/activation.test.ts +++ b/typescript/tests/activation.test.ts @@ -234,13 +234,13 @@ suite('createToolContext', () => { ); }); - test('initialize passes a package-change callback when refreshOnPackageChange is enabled', async () => { + test('initialize passes a package-change callback when refreshExtensionOnPackageChange is enabled', async () => { (utilities.getInterpreterFromSetting as sinon.SinonStub).returns(undefined); const provider = makeMockProvider(sandbox); const ctx = createToolContext( makeOptions({ pythonProvider: provider, - toolConfig: makeToolConfig({ refreshOnPackageChange: true }), + toolConfig: makeToolConfig({ refreshExtensionOnPackageChange: true }), }), ); await ctx.initialize([]); @@ -262,7 +262,7 @@ suite('createToolContext', () => { ); }); - test('initialize omits the package-change callback when refreshOnPackageChange is disabled', async () => { + test('initialize omits the package-change callback when refreshExtensionOnPackageChange is disabled', async () => { (utilities.getInterpreterFromSetting as sinon.SinonStub).returns(undefined); const provider = makeMockProvider(sandbox); const ctx = createToolContext(makeOptions({ pythonProvider: provider })); From 36745558cd8a3f3db3ba22960829ead020c604a5 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 25 Jun 2026 16:51:08 -0700 Subject: [PATCH 07/11] Expose onDidChangePackages on public IPythonApi; drop internal interface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 9 ++++----- README.md | 5 +++-- typescript/src/python.ts | 35 ++++++++++++----------------------- 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe012b..241f4ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 when set to `true`, makes the shared activation logic restart the language server whenever the active environment's package managers report a package change (install/uninstall). The provider subscribes to the underlying - package-change event once during initialization; the event is handled entirely - inside the package and is not exposed as public API. The key defaults to - `false`, so existing extensions are unaffected until they opt in. The legacy - `ms-python.python` extension does not expose package events, so this has no - effect unless the Python Environments extension is available. + `IPythonApi.onDidChangePackages` event once during initialization. The key + defaults to `false`, so existing extensions are unaffected until they opt in. + The legacy `ms-python.python` extension does not expose package events, so this + has no effect unless the Python Environments extension is available. ## [0.7.0] - 2026-06-17 diff --git a/README.md b/README.md index 2c52e5e..b313e83 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,9 @@ an extension sets `refreshExtensionOnPackageChange: true` on the `ToolConfig` it passes in. The key defaults to `false`; when set to `true`, the shared activation logic subscribes once to the package-change events reported by the [Python Environments extension](https://github.com/microsoft/vscode-python-environments) -during initialization and restarts the server on each one. The subscription is -handled internally — it is not exposed as public API. +during initialization and restarts the server on each one. The automatic refresh +wiring is internal; the underlying `IPythonApi.onDidChangePackages` event remains +available for consumers that need it. ## Version Requirements diff --git a/typescript/src/python.ts b/typescript/src/python.ts index 4536cbe..d35033c 100644 --- a/typescript/src/python.ts +++ b/typescript/src/python.ts @@ -50,25 +50,6 @@ export interface IPythonApi { /** Subscribe to interpreter/environment changes. */ onDidChangeEnvironment(handler: () => void): Disposable; - /** - * Get the debugger package path. - * - * Only available via the legacy `ms-python.python` extension. - * Returns `undefined` when provided by `ms-python.python-environments`. - */ - getDebuggerPath(): Promise; -} - -/** - * Internal extension of {@link IPythonApi} that adds the package-change - * subscription. - * - * This is intentionally **not** exported from the package: the package-change - * event is an internal implementation detail used to refresh the language - * server (see {@link PythonEnvironmentsProvider.initializePython}) and must not - * become part of the public API surface. - */ -interface IPythonApiInternal extends IPythonApi { /** * Subscribe to package changes detected by the environment's package * managers. @@ -78,6 +59,14 @@ interface IPythonApiInternal extends IPythonApi { * change events, so its adapter returns a no-op {@link Disposable}. */ onDidChangePackages(handler: () => void): Disposable; + + /** + * Get the debugger package path. + * + * Only available via the legacy `ms-python.python` extension. + * Returns `undefined` when provided by `ms-python.python-environments`. + */ + getDebuggerPath(): Promise; } // --------------------------------------------------------------------------- @@ -85,7 +74,7 @@ interface IPythonApiInternal extends IPythonApi { // --------------------------------------------------------------------------- /** Wrap the newer `@vscode/python-environments` API. */ -function wrapEnvironmentsApi(api: PythonEnvironmentApi): IPythonApiInternal { +function wrapEnvironmentsApi(api: PythonEnvironmentApi): IPythonApi { return { extension: 'ms-python.python-environments', @@ -146,7 +135,7 @@ function wrapEnvironmentsApi(api: PythonEnvironmentApi): IPythonApiInternal { } /** Wrap the legacy `@vscode/python-extension` API. */ -function wrapLegacyApi(api: PythonExtension): IPythonApiInternal { +function wrapLegacyApi(api: PythonExtension): IPythonApi { return { extension: 'ms-python.python', @@ -220,7 +209,7 @@ export class PythonEnvironmentsProvider { /** Fires when the active Python interpreter changes. */ public readonly onDidChangeInterpreter: Event = this._onDidChangeInterpreter.event; - private _api: IPythonApiInternal | undefined; + private _api: IPythonApi | undefined; private _apiResolved = false; private _serverPython: string[] | undefined; @@ -238,7 +227,7 @@ export class PythonEnvironmentsProvider { // API acquisition (cached, envs preferred → legacy fallback) // ----------------------------------------------------------------- - private async getApi(useCache: boolean = true): Promise { + private async getApi(useCache: boolean = true): Promise { if (useCache && this._apiResolved) { return this._api; } From c1c584e32260c03c3a9cf84be45322d476de1e9d Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 25 Jun 2026 16:53:19 -0700 Subject: [PATCH 08/11] Bump version to 0.7.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- VERSION | 2 +- python/pyproject.toml | 2 +- typescript/package-lock.json | 4 ++-- typescript/package.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 241f4ba..9930b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.7.1] - 2026-06-25 ### Added diff --git a/VERSION b/VERSION index faef31a..39e898a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.0 +0.7.1 diff --git a/python/pyproject.toml b/python/pyproject.toml index 1777daf..3926b75 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vscode-common-python-lsp" -version = "0.7.0" +version = "0.7.1" description = "Shared Python utilities for VS Code Python tool extensions" readme = "README.md" license = "MIT" diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 366a030..1643a03 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/common-python-lsp", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/common-python-lsp", - "version": "0.7.0", + "version": "0.7.1", "license": "MIT", "dependencies": { "@vscode/python-environments": "https://pkgs.dev.azure.com/azure-public/vside/_packaging/msft_consumption/npm/registry/@vscode/python-environments/-/python-environments-1.0.0.tgz", diff --git a/typescript/package.json b/typescript/package.json index 2a3f42e..49f4977 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/common-python-lsp", - "version": "0.7.0", + "version": "0.7.1", "description": "Shared TypeScript utilities for VS Code Python tool extensions", "main": "dist/index.js", "types": "dist/index.d.ts", From 91742f3e4dc184d86fdaa8d6fc6429c474bd92f6 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 25 Jun 2026 16:55:35 -0700 Subject: [PATCH 09/11] Bump version to 0.8.0 for new feature Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- VERSION | 2 +- python/pyproject.toml | 2 +- typescript/package-lock.json | 4 ++-- typescript/package.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9930b46..2a20735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.7.1] - 2026-06-25 +## [0.8.0] - 2026-06-25 ### Added diff --git a/VERSION b/VERSION index 39e898a..a3df0a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.1 +0.8.0 diff --git a/python/pyproject.toml b/python/pyproject.toml index 3926b75..f3bc4d8 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vscode-common-python-lsp" -version = "0.7.1" +version = "0.8.0" description = "Shared Python utilities for VS Code Python tool extensions" readme = "README.md" license = "MIT" diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 1643a03..9c04494 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/common-python-lsp", - "version": "0.7.1", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/common-python-lsp", - "version": "0.7.1", + "version": "0.8.0", "license": "MIT", "dependencies": { "@vscode/python-environments": "https://pkgs.dev.azure.com/azure-public/vside/_packaging/msft_consumption/npm/registry/@vscode/python-environments/-/python-environments-1.0.0.tgz", diff --git a/typescript/package.json b/typescript/package.json index 49f4977..d2b536b 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/common-python-lsp", - "version": "0.7.1", + "version": "0.8.0", "description": "Shared TypeScript utilities for VS Code Python tool extensions", "main": "dist/index.js", "types": "dist/index.d.ts", From 6f8a33a40a5be4379c6bd6dd47fc96950e4d5ed2 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 25 Jun 2026 16:57:27 -0700 Subject: [PATCH 10/11] Rename opt-in key to refreshExtensionOnPackagesChange Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- README.md | 2 +- typescript/src/activation.ts | 4 ++-- typescript/src/types.ts | 2 +- typescript/tests/activation.test.ts | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a20735..1efaf7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **TypeScript** `ToolConfig.refreshExtensionOnPackageChange` — opt-in key that, +- **TypeScript** `ToolConfig.refreshExtensionOnPackagesChange` — opt-in key that, when set to `true`, makes the shared activation logic restart the language server whenever the active environment's package managers report a package change (install/uninstall). The provider subscribes to the underlying diff --git a/README.md b/README.md index b313e83..1fd34ed 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ git submodule add https://github.com/microsoft/vscode-common-python-lsp.git subm ### Optional configuration To restart the language server whenever packages are installed or removed, -an extension sets `refreshExtensionOnPackageChange: true` on the `ToolConfig` it +an extension sets `refreshExtensionOnPackagesChange: true` on the `ToolConfig` it passes in. The key defaults to `false`; when set to `true`, the shared activation logic subscribes once to the package-change events reported by the [Python Environments extension](https://github.com/microsoft/vscode-python-environments) diff --git a/typescript/src/activation.ts b/typescript/src/activation.ts index 93a1a0e..a52602b 100644 --- a/typescript/src/activation.ts +++ b/typescript/src/activation.ts @@ -285,13 +285,13 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens const interpreter = getInterpreterFromSetting(serverId); if (interpreter === undefined || interpreter.length === 0) { traceLog('Python extension loading'); - // Opt-in via the `refreshExtensionOnPackageChange` key on + // Opt-in via the `refreshExtensionOnPackagesChange` key on // the extension's ToolConfig: when enabled, restart the // server whenever the Python environment's package managers // report a package install/uninstall. The provider // subscribes to the underlying event once and invokes this // callback. - const onPackageChange = toolConfig.refreshExtensionOnPackageChange + const onPackageChange = toolConfig.refreshExtensionOnPackagesChange ? () => void safeRunServer(ctx, 'package change') : undefined; await pythonProvider.initializePython(subscriptions, onPackageChange); diff --git a/typescript/src/types.ts b/typescript/src/types.ts index d066780..2097a92 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -68,7 +68,7 @@ export interface ToolConfig { * Defaults to `false`, so existing extensions are unaffected until they * opt in. */ - refreshExtensionOnPackageChange?: boolean; + refreshExtensionOnPackagesChange?: boolean; /** * Set to `true` for tools that provide LSP formatting (textDocument/formatting, diff --git a/typescript/tests/activation.test.ts b/typescript/tests/activation.test.ts index f9aa44f..ac4865c 100644 --- a/typescript/tests/activation.test.ts +++ b/typescript/tests/activation.test.ts @@ -234,13 +234,13 @@ suite('createToolContext', () => { ); }); - test('initialize passes a package-change callback when refreshExtensionOnPackageChange is enabled', async () => { + test('initialize passes a package-change callback when refreshExtensionOnPackagesChange is enabled', async () => { (utilities.getInterpreterFromSetting as sinon.SinonStub).returns(undefined); const provider = makeMockProvider(sandbox); const ctx = createToolContext( makeOptions({ pythonProvider: provider, - toolConfig: makeToolConfig({ refreshExtensionOnPackageChange: true }), + toolConfig: makeToolConfig({ refreshExtensionOnPackagesChange: true }), }), ); await ctx.initialize([]); @@ -262,7 +262,7 @@ suite('createToolContext', () => { ); }); - test('initialize omits the package-change callback when refreshExtensionOnPackageChange is disabled', async () => { + test('initialize omits the package-change callback when refreshExtensionOnPackagesChange is disabled', async () => { (utilities.getInterpreterFromSetting as sinon.SinonStub).returns(undefined); const provider = makeMockProvider(sandbox); const ctx = createToolContext(makeOptions({ pythonProvider: provider })); From 4cb83cb6515b2ab44a553e475c4f37ed52f150ab Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 25 Jun 2026 17:16:14 -0700 Subject: [PATCH 11/11] Address PR review: wire package refresh in both interpreter paths, harden subscription - Wire onDidChangePackages refresh regardless of how the interpreter was chosen (resolved by the Python extension or pinned via setting), so refreshExtensionOnPackagesChange is never silently inert. - Move the subscription into a dedicated, non-fatal PythonEnvironmentsProvider.subscribeToPackageChanges (guards typeof + try/catch, returns undefined on failure) so it can never block server startup. - Add a trailing-edge debounce around the package trigger to collapse bursty multi-package installs into one restart. - Add real subscription test (fake IPythonApi + fired event) and rewrite activation tests to await an observable restart signal instead of counting macrotasks; cover the pinned-interpreter path. - Revert incidental package-lock.json peer-drift, keeping only the version bump. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- typescript/package-lock.json | 4 -- typescript/src/activation.ts | 54 ++++++++++++++++---- typescript/src/python.ts | 43 +++++++++++----- typescript/tests/activation.test.ts | 79 ++++++++++++++++++++++------- typescript/tests/python.test.ts | 52 +++++++++++++++++-- 5 files changed, 184 insertions(+), 48 deletions(-) diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 9c04494..3c549eb 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -451,7 +451,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -638,7 +637,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1054,7 +1052,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2581,7 +2578,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/typescript/src/activation.ts b/typescript/src/activation.ts index a52602b..e833d1f 100644 --- a/typescript/src/activation.ts +++ b/typescript/src/activation.ts @@ -99,6 +99,7 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens let isRestarting = false; let restartTimer: NodeJS.Timeout | undefined; + let packageChangeTimer: NodeJS.Timeout | undefined; let disposed = false; let serverDisposables: vscode.Disposable[] = []; @@ -285,20 +286,28 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens const interpreter = getInterpreterFromSetting(serverId); if (interpreter === undefined || interpreter.length === 0) { traceLog('Python extension loading'); - // Opt-in via the `refreshExtensionOnPackagesChange` key on - // the extension's ToolConfig: when enabled, restart the - // server whenever the Python environment's package managers - // report a package install/uninstall. The provider - // subscribes to the underlying event once and invokes this - // callback. - const onPackageChange = toolConfig.refreshExtensionOnPackagesChange - ? () => void safeRunServer(ctx, 'package change') - : undefined; - await pythonProvider.initializePython(subscriptions, onPackageChange); + await pythonProvider.initializePython(subscriptions); traceLog('Python extension loaded'); } else { await ctx.runServer(); } + + // Opt-in via the `refreshExtensionOnPackagesChange` key on the + // extension's ToolConfig: restart the server whenever the active + // environment's package managers report a package + // install/uninstall. Wired here — after the interpreter is + // resolved — regardless of *how* the interpreter was chosen + // (resolved by the Python extension or pinned via the + // `.interpreter` setting), so the option is never + // silently inert. Subscription is best-effort: a missing or + // version-skewed API resolves to `undefined` and never blocks + // startup. + if (toolConfig.refreshExtensionOnPackagesChange) { + const disposable = await pythonProvider.subscribeToPackageChanges(triggerPackageRefresh); + if (disposable) { + subscriptions.push(disposable); + } + } } catch (ex) { traceError(`Extension initialization failed: ${ex}`); } @@ -310,6 +319,10 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens clearTimeout(restartTimer); restartTimer = undefined; } + if (packageChangeTimer) { + clearTimeout(packageChangeTimer); + packageChangeTimer = undefined; + } for (const d of serverDisposables) { try { d.dispose(); @@ -322,6 +335,27 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens }, }; + /** + * Trailing-edge debounce for package-change refreshes. + * + * A single install can emit several package-change events in quick + * succession, and slow multi-package installs may space them out past the + * time a single restart takes — `runServer`'s in-flight coalescing only + * collapses the former. Debouncing here collapses bursts into one restart. + */ + function triggerPackageRefresh(): void { + if (disposed) { + return; + } + if (packageChangeTimer) { + clearTimeout(packageChangeTimer); + } + packageChangeTimer = setTimeout(() => { + packageChangeTimer = undefined; + void safeRunServer(ctx, 'package change'); + }, restartDelay); + } + return ctx; } diff --git a/typescript/src/python.ts b/typescript/src/python.ts index d35033c..8d9ee97 100644 --- a/typescript/src/python.ts +++ b/typescript/src/python.ts @@ -285,15 +285,8 @@ export class PythonEnvironmentsProvider { * the initial interpreter. * * @param disposables - Collected disposables for the registered listeners. - * @param onPackageChange - Optional callback invoked whenever the active - * environment's package managers report a package change - * (install/uninstall). When provided, this method subscribes to the - * underlying package-change event once and forwards each notification to - * the callback — typically used to refresh the language server. Only the - * newer `ms-python.python-environments` extension emits these events; the - * legacy `ms-python.python` adapter is a no-op. */ - async initializePython(disposables: Disposable[], onPackageChange?: () => void): Promise { + async initializePython(disposables: Disposable[]): Promise { try { const api = await this.getApi(); if (!api) { @@ -310,10 +303,6 @@ export class PythonEnvironmentsProvider { }), ); - if (onPackageChange) { - disposables.push(api.onDidChangePackages(() => onPackageChange())); - } - traceLog(`Waiting for interpreter from ${api.extension} extension.`); await this.refreshServerPython(); } catch (error) { @@ -321,6 +310,36 @@ export class PythonEnvironmentsProvider { } } + /** + * Subscribe to package changes reported by the active environment's package + * managers and invoke {@link handler} on each one. + * + * This is intentionally decoupled from {@link initializePython} so it can be + * wired regardless of how the interpreter was selected (resolved by the + * Python extension *or* pinned via the `.interpreter` setting). + * + * Subscription failures are non-fatal: if no API is available, the runtime + * does not expose `onDidChangePackages` (e.g. the legacy `ms-python.python` + * extension or a version-skewed runtime), or subscribing throws, this + * resolves to `undefined` and logs rather than propagating — a refresh + * feature must never block or break activation. + * + * @returns A {@link Disposable} for the subscription, or `undefined` when no + * package-change event is available. + */ + async subscribeToPackageChanges(handler: () => void): Promise { + try { + const api = await this.getApi(); + if (!api || typeof api.onDidChangePackages !== 'function') { + return undefined; + } + return api.onDidChangePackages(() => handler()); + } catch (error) { + traceError('Error subscribing to Python package changes: ', error); + return undefined; + } + } + /** * Resolve the Python interpreter for a workspace/resource. */ diff --git a/typescript/tests/activation.test.ts b/typescript/tests/activation.test.ts index ac4865c..db20190 100644 --- a/typescript/tests/activation.test.ts +++ b/typescript/tests/activation.test.ts @@ -58,6 +58,7 @@ function makeMockProvider(sandbox: sinon.SinonSandbox): PythonEnvironmentsProvid getInterpreterDetails: sandbox.stub().resolves({ path: ['/usr/bin/python3'] }), getDebuggerPath: sandbox.stub().resolves(undefined), initializePython: sandbox.stub().resolves(), + subscribeToPackageChanges: sandbox.stub().resolves(undefined), onDidChangeInterpreter: sinon.stub().returns({ dispose: sinon.stub() }), } as unknown as PythonEnvironmentsProvider; } @@ -234,9 +235,57 @@ suite('createToolContext', () => { ); }); - test('initialize passes a package-change callback when refreshExtensionOnPackagesChange is enabled', async () => { + test('subscribes to package changes and restarts the server when refreshExtensionOnPackagesChange is enabled', async () => { (utilities.getInterpreterFromSetting as sinon.SinonStub).returns(undefined); const provider = makeMockProvider(sandbox); + + // Capture the handler the activation logic registers so we can fire it. + let capturedHandler: (() => void) | undefined; + const disposeStub = sinon.stub(); + (provider.subscribeToPackageChanges as sinon.SinonStub).callsFake((handler: () => void) => { + capturedHandler = handler; + return Promise.resolve({ dispose: disposeStub }); + }); + + // Resolve a deferred when restartServer is reached instead of counting + // macrotasks — decouples the test from runServer's internal await chain. + let signalRestart: () => void = () => undefined; + const restarted = new Promise((resolve) => { + signalRestart = resolve; + }); + (serverModule.restartServer as sinon.SinonStub).callsFake(() => { + signalRestart(); + return Promise.resolve({ client: undefined, disposables: [] }); + }); + + const subscriptions: vscode.Disposable[] = []; + const ctx = createToolContext( + makeOptions({ + pythonProvider: provider, + // restartDelay: 0 keeps the trailing-edge debounce on the next tick. + toolConfig: makeToolConfig({ refreshExtensionOnPackagesChange: true, restartDelay: 0 }), + }), + ); + await ctx.initialize(subscriptions); + + assert.isTrue( + (provider.subscribeToPackageChanges as sinon.SinonStub).calledOnce, + 'should subscribe to package changes', + ); + assert.isFunction(capturedHandler, 'should register a package-change handler'); + assert.lengthOf(subscriptions, 1, 'should register the subscription disposable'); + + capturedHandler?.(); + await restarted; + assert.isTrue( + (serverModule.restartServer as sinon.SinonStub).called, + 'package-change handler should restart the server', + ); + }); + + test('subscribes to package changes even when the interpreter is pinned via setting', async () => { + (utilities.getInterpreterFromSetting as sinon.SinonStub).returns(['/usr/bin/python3']); + const provider = makeMockProvider(sandbox); const ctx = createToolContext( makeOptions({ pythonProvider: provider, @@ -245,32 +294,26 @@ suite('createToolContext', () => { ); await ctx.initialize([]); - const initializePython = provider.initializePython as sinon.SinonStub; - const onPackageChange = initializePython.firstCall.args[1] as (() => void) | undefined; - assert.isFunction(onPackageChange, 'should pass a package-change callback'); - - // Invoking the callback restarts the server. runServer awaits three - // pre-resolved stubs (getProjectRoot, getWorkspaceSettings, - // restartServer) before restartServer is reached, so flush the - // macrotask queue twice to let the whole chain settle. - onPackageChange?.(); - await new Promise((resolve) => setTimeout(resolve, 0)); - await new Promise((resolve) => setTimeout(resolve, 0)); + assert.isFalse( + (provider.initializePython as sinon.SinonStub).called, + 'pinned interpreter path should skip initializePython', + ); assert.isTrue( - (serverModule.restartServer as sinon.SinonStub).called, - 'package-change callback should restart the server', + (provider.subscribeToPackageChanges as sinon.SinonStub).calledOnce, + 'should still wire the package-change subscription when pinned', ); }); - test('initialize omits the package-change callback when refreshExtensionOnPackagesChange is disabled', async () => { + test('does not subscribe to package changes when refreshExtensionOnPackagesChange is disabled', async () => { (utilities.getInterpreterFromSetting as sinon.SinonStub).returns(undefined); const provider = makeMockProvider(sandbox); const ctx = createToolContext(makeOptions({ pythonProvider: provider })); await ctx.initialize([]); - const initializePython = provider.initializePython as sinon.SinonStub; - const onPackageChange = initializePython.firstCall.args[1] as (() => void) | undefined; - assert.isUndefined(onPackageChange, 'should not pass a package-change callback'); + assert.isFalse( + (provider.subscribeToPackageChanges as sinon.SinonStub).called, + 'should not subscribe when the option is disabled', + ); }); test('dispose prevents further runServer calls', async () => { diff --git a/typescript/tests/python.test.ts b/typescript/tests/python.test.ts index 7593b9d..ea28c25 100644 --- a/typescript/tests/python.test.ts +++ b/typescript/tests/python.test.ts @@ -128,18 +128,62 @@ suite('PythonEnvironmentsProvider', () => { }); suite('initializePython', () => { - test('accepts an optional package-change callback without throwing', async () => { + test('returns without throwing when no API is available', async () => { const config = makeToolConfig(); const provider = new PythonEnvironmentsProvider(config); // No Python extension is available in the test environment, so - // getApi() resolves to undefined and initializePython returns - // early — the call must not throw with or without the callback. + // getApi() resolves to undefined and initializePython returns early. const disposables: { dispose: () => void }[] = []; - await provider.initializePython(disposables, () => undefined); + await provider.initializePython(disposables); assert.isArray(disposables); }); }); + suite('subscribeToPackageChanges', () => { + function injectApi(provider: PythonEnvironmentsProvider, api: unknown): void { + const internal = provider as unknown as { _api: unknown; _apiResolved: boolean }; + internal._api = api; + internal._apiResolved = true; + } + + test('subscribes to onDidChangePackages and forwards events to the handler', async () => { + const provider = new PythonEnvironmentsProvider(makeToolConfig()); + + let firePackages: (() => void) | undefined; + const disposeStub = sinon.stub(); + injectApi(provider, { + extension: 'ms-python.python-environments', + onDidChangePackages: (handler: () => void) => { + firePackages = handler; + return { dispose: disposeStub }; + }, + }); + + const handler = sinon.stub(); + const disposable = await provider.subscribeToPackageChanges(handler); + + assert.isDefined(disposable, 'should return a disposable'); + assert.isFunction(firePackages, 'should subscribe to the event'); + + firePackages?.(); + assert.isTrue(handler.calledOnce, 'should forward the event to the handler'); + }); + + test('returns undefined when the API does not expose onDidChangePackages', async () => { + const provider = new PythonEnvironmentsProvider(makeToolConfig()); + injectApi(provider, { extension: 'ms-python.python' }); + + const disposable = await provider.subscribeToPackageChanges(sinon.stub()); + assert.isUndefined(disposable); + }); + + test('returns undefined when no API is available', async () => { + const provider = new PythonEnvironmentsProvider(makeToolConfig()); + const disposable = await provider.subscribeToPackageChanges(sinon.stub()); + assert.isUndefined(disposable); + }); + }); + suite('dispose', () => { test('does not throw', () => { const config = makeToolConfig();