diff --git a/CHANGELOG.md b/CHANGELOG.md index 5402731..1efaf7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ 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.8.0] - 2026-06-25 + +### Added + +- **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 + `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 ### Added diff --git a/README.md b/README.md index 85b47ed..1fd34ed 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 configuration + +To restart the language server whenever packages are installed or removed, +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) +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 | Runtime | Minimum Version | diff --git a/VERSION b/VERSION index faef31a..a3df0a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.0 +0.8.0 diff --git a/python/pyproject.toml b/python/pyproject.toml index 1777daf..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.0" +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 957776c..3c549eb 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.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/common-python-lsp", - "version": "0.7.0", + "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 2a3f42e..d2b536b 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/common-python-lsp", - "version": "0.7.0", + "version": "0.8.0", "description": "Shared TypeScript utilities for VS Code Python tool extensions", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/typescript/src/activation.ts b/typescript/src/activation.ts index 65ea8e5..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[] = []; @@ -290,6 +291,23 @@ export function createToolContext(options: CreateToolContextOptions): ToolExtens } 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}`); } @@ -301,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(); @@ -313,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 8e1c4a4..8d9ee97 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(); }, @@ -263,6 +283,8 @@ export class PythonEnvironmentsProvider { /** * Set up event listeners for Python interpreter changes and resolve * the initial interpreter. + * + * @param disposables - Collected disposables for the registered listeners. */ async initializePython(disposables: Disposable[]): Promise { try { @@ -288,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/src/types.ts b/typescript/src/types.ts index 049dbff..2097a92 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -54,6 +54,22 @@ 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). + * + * 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. + */ + refreshExtensionOnPackagesChange?: 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 1b15a5a..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,6 +235,87 @@ suite('createToolContext', () => { ); }); + 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, + toolConfig: makeToolConfig({ refreshExtensionOnPackagesChange: true }), + }), + ); + await ctx.initialize([]); + + assert.isFalse( + (provider.initializePython as sinon.SinonStub).called, + 'pinned interpreter path should skip initializePython', + ); + assert.isTrue( + (provider.subscribeToPackageChanges as sinon.SinonStub).calledOnce, + 'should still wire the package-change subscription when pinned', + ); + }); + + 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([]); + + assert.isFalse( + (provider.subscribeToPackageChanges as sinon.SinonStub).called, + 'should not subscribe when the option is disabled', + ); + }); + test('dispose prevents further runServer calls', async () => { const ctx = createToolContext(makeOptions()); ctx.dispose(); diff --git a/typescript/tests/python.test.ts b/typescript/tests/python.test.ts index 9112e4c..ea28c25 100644 --- a/typescript/tests/python.test.ts +++ b/typescript/tests/python.test.ts @@ -127,6 +127,63 @@ suite('PythonEnvironmentsProvider', () => { }); }); + suite('initializePython', () => { + 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. + const disposables: { dispose: () => void }[] = []; + 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();