Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.7.0
0.8.0
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions typescript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion typescript/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
43 changes: 43 additions & 0 deletions typescript/src/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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
// `<serverId>.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}`);
}
Expand All @@ -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();
Expand All @@ -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;
}

Expand Down
52 changes: 52 additions & 0 deletions typescript/src/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
},
Expand Down Expand Up @@ -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<void> {
try {
Expand All @@ -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 `<serverId>.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<Disposable | undefined> {
try {
const api = await this.getApi();
if (!api || typeof api.onDidChangePackages !== 'function') {
Comment thread
edvilme marked this conversation as resolved.
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.
*/
Expand Down
16 changes: 16 additions & 0 deletions typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ export interface ToolConfig {
// Environment
extraEnvVars?: Record<string, string>;

/**
* 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).
Expand Down
82 changes: 82 additions & 0 deletions typescript/tests/activation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<void>((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',
);
});

Comment thread
edvilme marked this conversation as resolved.
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();
Expand Down
Loading