Skip to content

Commit db0a3b2

Browse files
Warn when companion extensions are too old for inline-script envs (#1777)
## Problem A PEP 723 inline-script environment is built correctly by this extension on its own, but it only reaches the language server once two companion changes ship: - **Python extension** resolves interpreters per file (`exactResource`, microsoft/vscode-python#26129) - **Pylance** handles the file-scoped `python/didChangeFilePythonPath` notification (PR #9302) Until both are present, setup reports success and the user is left without the full inline script experience, with nothing explaining why. ## Change Warn once, **after a successful setup** rather than before one. That is the moment the gap becomes visible, and the creation progress notification is already gone — prompting up front would be noise stacked on top of it. Both entry points prompt at the handler level (`setupInlineScriptEnvironmentHandler` and `setUpInlineScriptEnvironmentsInWorkspace`), so a bulk run over N scripts shows **one** notification rather than N. The prompt offers **Update Extension** and **Don't Show Again**; the latter persists in global state. Dismissing without choosing an action suppresses it for the session only. ## Version handling | | stable | pre-release | |---|---|---| | Python | `2026.4.0` | `2026.7.2026082601` | | Pylance | `2026.3.1` | `2026.3.101` | Values are the newest version on each channel that still **lacks** the change; anything newer passes. Notes on why this is more involved than a single `>=`: - **Per channel, because the two lines interleave numerically.** Pre-release `2026.5.x`/`2026.7.x` sort *above* stable `2026.4.0`, so one threshold cannot express both — a future stable such as `2026.6.0` would be wrongly rejected. - **Channel is inferred from the patch component.** VS Code does not populate `__metadata.preRelease` for installed extensions (verified empty on every installed extension locally, including a known pre-release build). Python pre-releases carry a build-date patch, Pylance pre-releases use a 100+ counter. Both rules were checked against every published version above the relevant floors. - **PEP 440 ordering**, since these extensions are not semver. - **Dev builds are skipped, not compared.** `2026.7.0-dev` is valid PEP 440 and normalizes to `2026.7.0.dev0`, which sorts below every real build of the same minor — comparing it would flag anyone running a local build of either extension. - **A missing extension is not outdated.** Pylance is optional. - **Pylance is skipped unless `python.languageServer` selects it**, so Jedi and None users are not asked to update an extension they do not use. Each outdated combination has its own complete l10n string rather than a joined extension list, since conjunctions and word order are locale-specific. ## Follow-up The thresholds are a snapshot of the newest version lacking the change. **If either extension ships another release before the change lands, the corresponding constant must be bumped**, or the warning silently stops firing for users on that release. Once the real shipping versions are known, consider inverting to "first version *with* the change" (`<` instead of `<=`): that framing fails toward an over-eager prompt, which is visible and reported, rather than toward silence. ## Testing 36 new unit tests in `src/test/features/inlineScript/extensionVersionCheck.unit.test.ts`, covering both channels for both extensions, the dev-build case, the language-server gate, and all dismissal paths. Full unit suite: **2047 passing, 0 failing** (clean `out/` rebuild). `tsc --noEmit`, `eslint src`, and `prettier --check` all clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent df05e69 commit db0a3b2

6 files changed

Lines changed: 485 additions & 5 deletions

File tree

src/common/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as path from 'path';
22

33
export const ENVS_EXTENSION_ID = 'ms-python.vscode-python-envs';
44
export const PYTHON_EXTENSION_ID = 'ms-python.python';
5+
export const PYLANCE_EXTENSION_ID = 'ms-python.vscode-pylance';
56
export const CONDA_MANAGER_ID = `${PYTHON_EXTENSION_ID}:conda`;
67
export const INLINE_SCRIPT_MANAGER_ID = `${PYTHON_EXTENSION_ID}:inline-script`;
78
export const INLINE_SCRIPT_ENVS_KEY = `${ENVS_EXTENSION_ID}:inline-script:SCRIPT_ENVIRONMENTS`;

src/common/extVersion.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,34 @@
1-
import { compare as pep440Compare, valid as pep440Valid } from '@renovatebot/pep440';
1+
import { compare as pep440Compare, explain as pep440Explain, valid as pep440Valid } from '@renovatebot/pep440';
22
import { PYTHON_EXTENSION_ID } from './constants';
33
import { getExtension } from './extension.apis';
4-
import { traceError } from './logging';
4+
import { traceError, traceWarn } from './logging';
5+
6+
export type ComparableExtensionVersion =
7+
| { readonly kind: 'version'; readonly version: string }
8+
| { readonly kind: 'not-installed' }
9+
| { readonly kind: 'unknown' };
10+
11+
export function getComparableExtensionVersion(extensionId: string): ComparableExtensionVersion {
12+
const extension = getExtension(extensionId);
13+
if (!extension) {
14+
return { kind: 'not-installed' };
15+
}
16+
const rawVersion = extension.packageJSON?.version;
17+
if (typeof rawVersion !== 'string') {
18+
traceWarn(`Extension ${extensionId} reported no version string; skipping version comparison.`);
19+
return { kind: 'unknown' };
20+
}
21+
const parsed = pep440Explain(rawVersion);
22+
if (!parsed) {
23+
traceWarn(`Extension ${extensionId} version "${rawVersion}" is not PEP 440 parseable; skipping comparison.`);
24+
return { kind: 'unknown' };
25+
}
26+
if (parsed.is_devrelease) {
27+
traceWarn(`Extension ${extensionId} version "${rawVersion}" is a dev build; skipping version comparison.`);
28+
return { kind: 'unknown' };
29+
}
30+
return { kind: 'version', version: rawVersion };
31+
}
532

633
export function ensureCorrectVersion() {
734
const extension = getExtension(PYTHON_EXTENSION_ID);

src/common/localize.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@ export namespace WorkbenchStrings {
2323
export const installExtension = l10n.t('Install Extension');
2424
}
2525

26+
export namespace InlineScriptStrings {
27+
export const updateExtension = l10n.t('Update Extension');
28+
29+
export const updatePythonExtension = l10n.t(
30+
'The environment for this script was created. Update the Python extension for the full inline script experience.',
31+
);
32+
export const updatePylanceExtension = l10n.t(
33+
'The environment for this script was created. Update Pylance for the full inline script experience.',
34+
);
35+
export const updatePythonAndPylanceExtensions = l10n.t(
36+
'The environment for this script was created. Update the Python and Pylance extensions for the full inline script experience.',
37+
);
38+
}
39+
2640
export namespace Interpreter {
2741
export const statusBarSelect = l10n.t('Select Interpreter');
2842
export const browsePath = l10n.t('Browse...');
@@ -249,7 +263,7 @@ export namespace UvInstallStrings {
249263
export function inlineScriptInstallPythonPrompt(requiresPython?: string, version?: string): string {
250264
if (requiresPython && version) {
251265
return l10n.t(
252-
'No installed Python satisfies this script\'s requirement ({0}). Would you like to install Python {1} using uv?',
266+
"No installed Python satisfies this script's requirement ({0}). Would you like to install Python {1} using uv?",
253267
requiresPython,
254268
version,
255269
);
@@ -267,7 +281,7 @@ export namespace UvInstallStrings {
267281
export function inlineScriptInstallPythonAndUvPrompt(requiresPython?: string, version?: string): string {
268282
if (requiresPython && version) {
269283
return l10n.t(
270-
'No installed Python satisfies this script\'s requirement ({0}). Would you like to install uv and use it to install Python {1}? This will download and run an installer from https://astral.sh.',
284+
"No installed Python satisfies this script's requirement ({0}). Would you like to install uv and use it to install Python {1}? This will download and run an installer from https://astral.sh.",
271285
requiresPython,
272286
version,
273287
);
@@ -284,7 +298,7 @@ export namespace UvInstallStrings {
284298
}
285299
export function inlineScriptInstallUvForVersionLookupPrompt(requiresPython: string): string {
286300
return l10n.t(
287-
'No installed Python satisfies this script\'s requirement ({0}). Install uv to find a compatible Python version? This will download and run an installer from https://astral.sh.',
301+
"No installed Python satisfies this script's requirement ({0}). Install uv to find a compatible Python version? This will download and run an installer from https://astral.sh.",
288302
requiresPython,
289303
);
290304
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { compare as pep440Compare } from '@renovatebot/pep440';
5+
import { PYLANCE_EXTENSION_ID, PYTHON_EXTENSION_ID } from '../../common/constants';
6+
import { getComparableExtensionVersion } from '../../common/extVersion';
7+
import { Common, InlineScriptStrings } from '../../common/localize';
8+
import { traceInfo, traceVerbose } from '../../common/logging';
9+
import { getGlobalPersistentState } from '../../common/persistentState';
10+
import { showWarningMessage } from '../../common/window.apis';
11+
import { openExtension } from '../../common/workbenchCommands';
12+
import { getConfiguration } from '../../common/workspace.apis';
13+
14+
export const INLINE_SCRIPT_UPDATE_EXTENSIONS_DONT_SHOW_KEY = 'python-envs:inline-script:UPDATE_EXTENSIONS_DONT_SHOW';
15+
16+
interface CompanionExtension {
17+
readonly id: string;
18+
/** Newest version on each channel that still LACKS the required change; anything newer is fine. */
19+
readonly lastUnsupportedStable: string;
20+
readonly lastUnsupportedPreRelease: string;
21+
/** Patch component at or above which a version is a pre-release build. */
22+
readonly preReleasePatchFloor: number;
23+
}
24+
25+
/**
26+
* Python needs per-file interpreter resolution (`exactResource`, PR #26129, merged 2026-08-31);
27+
* Pylance needs the `python/didChangeFilePythonPath` notification (PR #9302, merged 2026-09-01).
28+
*/
29+
const PYTHON_COMPANION: CompanionExtension = {
30+
id: PYTHON_EXTENSION_ID,
31+
lastUnsupportedStable: '2026.4.0',
32+
lastUnsupportedPreRelease: '2026.7.2026082601',
33+
preReleasePatchFloor: 1_000_000,
34+
};
35+
36+
const PYLANCE_COMPANION: CompanionExtension = {
37+
id: PYLANCE_EXTENSION_ID,
38+
lastUnsupportedStable: '2026.3.1',
39+
lastUnsupportedPreRelease: '2026.3.101',
40+
preReleasePatchFloor: 100,
41+
};
42+
43+
let promptShownThisSession = false;
44+
45+
export function resetInlineScriptExtensionPromptForTests(): void {
46+
promptShownThisSession = false;
47+
}
48+
49+
function isPylanceInUse(): boolean {
50+
const languageServer = getConfiguration('python').get<string>('languageServer', 'Default');
51+
return languageServer === 'Default' || languageServer === 'Pylance';
52+
}
53+
54+
function isPreReleaseBuild(version: string, preReleasePatchFloor: number): boolean {
55+
const patch = Number((version.split('.')[2] ?? '').replace(/\D.*$/, ''));
56+
return Number.isFinite(patch) && patch >= preReleasePatchFloor;
57+
}
58+
59+
function isOutdated(extension: CompanionExtension): boolean {
60+
const resolved = getComparableExtensionVersion(extension.id);
61+
if (resolved.kind !== 'version') {
62+
traceVerbose(`inline-script companion check: ${extension.id} -> ${resolved.kind}`);
63+
return false;
64+
}
65+
const lastUnsupported = isPreReleaseBuild(resolved.version, extension.preReleasePatchFloor)
66+
? extension.lastUnsupportedPreRelease
67+
: extension.lastUnsupportedStable;
68+
const outdated = pep440Compare(resolved.version, lastUnsupported) <= 0;
69+
if (outdated) {
70+
traceVerbose(`inline-script companion check: ${extension.id} ${resolved.version} <= ${lastUnsupported}`);
71+
}
72+
return outdated;
73+
}
74+
75+
export function getOutdatedInlineScriptExtensions(): CompanionExtension[] {
76+
const candidates = isPylanceInUse() ? [PYTHON_COMPANION, PYLANCE_COMPANION] : [PYTHON_COMPANION];
77+
return candidates.filter(isOutdated);
78+
}
79+
80+
function getOutdatedMessage(outdated: readonly CompanionExtension[]): string {
81+
const hasPython = outdated.some((extension) => extension.id === PYTHON_EXTENSION_ID);
82+
const hasPylance = outdated.some((extension) => extension.id === PYLANCE_EXTENSION_ID);
83+
if (hasPython && hasPylance) {
84+
return InlineScriptStrings.updatePythonAndPylanceExtensions;
85+
}
86+
return hasPython ? InlineScriptStrings.updatePythonExtension : InlineScriptStrings.updatePylanceExtension;
87+
}
88+
89+
export async function promptUpdateExtensionsForInlineScripts(): Promise<void> {
90+
if (promptShownThisSession) {
91+
return;
92+
}
93+
94+
const outdated = getOutdatedInlineScriptExtensions();
95+
if (outdated.length === 0) {
96+
return;
97+
}
98+
99+
// Latched here, not on entry, so an up-to-date run does not consume the session's one prompt.
100+
promptShownThisSession = true;
101+
102+
const state = await getGlobalPersistentState();
103+
if (await state.get<boolean>(INLINE_SCRIPT_UPDATE_EXTENSIONS_DONT_SHOW_KEY)) {
104+
traceInfo('Skipping inline-script companion extension prompt: user selected "Don\'t Show Again".');
105+
return;
106+
}
107+
108+
const names = outdated.map((extension) => extension.id).join(', ');
109+
traceInfo(`Inline-script companion extensions out of date: ${names}`);
110+
111+
const result = await showWarningMessage(
112+
getOutdatedMessage(outdated),
113+
InlineScriptStrings.updateExtension,
114+
Common.dontShowAgain,
115+
);
116+
117+
if (result === InlineScriptStrings.updateExtension) {
118+
await openExtension(outdated[0].id);
119+
} else if (result === Common.dontShowAgain) {
120+
await state.set(INLINE_SCRIPT_UPDATE_EXTENSIONS_DONT_SHOW_KEY, true);
121+
traceInfo('User selected "Don\'t Show Again" for the inline-script companion extension prompt.');
122+
}
123+
}

src/features/inlineScript/setupEnvironment.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { asRelativePath, findFiles, getOpenTextDocuments } from '../../common/workspace.apis';
1818
import { EnvironmentManagers } from '../../internal.api';
1919
import { registerInlineScriptCodeLens } from './codeLens';
20+
import { promptUpdateExtensionsForInlineScripts } from './extensionVersionCheck';
2021

2122
/**
2223
* Hidden command invoked by the inline-script CodeLens to set up the environment for one script.
@@ -115,7 +116,9 @@ function setupInlineScriptEnvironmentHandler(
115116
const environment = await setUpInlineScriptEnvironment(uri, em, routing);
116117
if (!environment) {
117118
notifyInlineScriptSetupOutcome(uri, routing);
119+
return;
118120
}
121+
await promptUpdateExtensionsForInlineScripts();
119122
} catch (error) {
120123
traceError(`Failed to set up the inline-script environment for ${uri.fsPath}:`, error);
121124
showErrorMessage(
@@ -259,6 +262,12 @@ export async function setUpInlineScriptEnvironmentsInWorkspace(
259262
`Inline-script bulk setup: created or reused ${succeeded} of ${picks.length} environment(s)` +
260263
`${cancelled ? ' (canceled)' : ''}.`,
261264
);
265+
if (succeeded > 0) {
266+
// Not awaited so the run summary below is not held behind this notification.
267+
void promptUpdateExtensionsForInlineScripts().catch((error) =>
268+
traceError('Failed to check companion extension versions for inline scripts:', error),
269+
);
270+
}
262271
if (cancelled) {
263272
showWarningMessage(
264273
l10n.t(

0 commit comments

Comments
 (0)