Skip to content

Commit 03176cd

Browse files
Harden uv Python compatibility for inline scripts (#1733)
## Context Inline-script environment creation selects an installed Python when possible and falls back to installing a compatible Python with uv. The fallback has to use the same PEP 440 semantics before and after installation; otherwise setup can download Python successfully and then reject it. This remains behind the undeclared, default-off inline-script feature flag. ## Why this change is needed Two compatibility paths were inconsistent: - A directly resolved Python was checked by the release-segment-only helper before the manager's strict PEP 440 check. This could reject a compatible final release, such as Python 3.15.0 for `!=3.15.0rc2`. - A short exact requirement such as `==3.13` could be passed to uv as the broad selector `3.13`. uv may install a later 3.13 patch, while strict PEP 440 equality requires 3.13.0. The default uv catalog can also omit older patch releases. A strict short-exact lookup therefore needs an all-versions catalog to find 3.13.0 after newer 3.13 releases exist. ## What changed - Direct post-install resolution now keeps the existing Python 3, error, executable, and canonical-path guards while using strict PEP 440 compatibility. - Short exact equality resolves through a concrete uv catalog result instead of forwarding a broad minor selector. - Only the short-exact path requests `uv python list --all-versions`; existing catalog callers retain their previous command arguments. - Fully specified exact versions, simple lower bounds, bounded ranges, exclusions, prerelease/dev requirements, no-requirement behavior, and quick-create prompt suppression remain unchanged. ## Behavior and compatibility - No setting, command, menu, view, or status-bar contribution is added. - The inline manager remains unregistered while the feature flag is off. - Non-inline uv and package-management flows keep their existing behavior. - Failures remain fail-closed: no incompatible cache environment is created. ## Reviewer guide 1. Review the strict direct-resolution guard in `inlineScript/envManager.ts`. 2. Review short-exact catalog selection and the `allVersions` option. 3. Review uv command-argument tests, then the manager compatibility regressions. ## Validation - `npm run compile-tests --silent` - Targeted inline manager, uv installer, and interpreter suites: 311 passing - ESLint on changed TypeScript files - `git diff --check` Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c6b1e27 commit 03176cd

4 files changed

Lines changed: 119 additions & 12 deletions

File tree

src/managers/builtin/inlineScript/envManager.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2363,16 +2363,19 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
23632363
this.baseManager,
23642364
);
23652365
const executable = resolved?.execInfo?.run.executable;
2366-
if (resolved && executable && pickCompatibleInterpreter([resolved], metadata.requiresPython)) {
2366+
if (
2367+
resolved &&
2368+
executable &&
2369+
pickCompatibleInterpreter([resolved], undefined) &&
2370+
(!requiresPython || this.matchesInstallConstraint(requiresPython, resolved.version))
2371+
) {
23672372
try {
23682373
const canonicalPath = await fs.realpath(executable);
2369-
if (!requiresPython || this.matchesInstallConstraint(requiresPython, resolved.version)) {
2370-
this.directlyResolvedBaseInterpreters.set(canonicalPath, resolved);
2371-
selected = {
2372-
environment: resolved,
2373-
canonicalPath,
2374-
};
2375-
}
2374+
this.directlyResolvedBaseInterpreters.set(canonicalPath, resolved);
2375+
selected = {
2376+
environment: resolved,
2377+
canonicalPath,
2378+
};
23762379
} catch (error) {
23772380
this.log.warn(
23782381
`Unable to resolve the Python installed for an inline script at ${executable}: ${getErrorMessage(error)}`,
@@ -2403,12 +2406,17 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
24032406
return { version: prereleaseLowerBound };
24042407
}
24052408
const lowerBoundRelease = lowerBound ? parseReleaseSegments(lowerBound) : undefined;
2409+
let needsCompleteCatalog = false;
24062410
if (lowerBound && lowerBoundRelease?.[0] === 3) {
24072411
if (/^>=\s*[^,]+$/.test(requiresPython) && this.matchesInstallConstraint(requiresPython, lowerBound)) {
24082412
return { version: lowerBound };
24092413
}
2414+
// PEP 440 `==3.13` is exact, while uv treats `3.13` as a broad minor selector.
24102415
if (/^==\s*[^,*]+$/.test(requiresPython) && this.matchesInstallConstraint(requiresPython, lowerBound)) {
2411-
return { version: lowerBound };
2416+
if (lowerBoundRelease.length >= 3) {
2417+
return { version: lowerBound };
2418+
}
2419+
needsCompleteCatalog = true;
24122420
}
24132421
}
24142422

@@ -2424,7 +2432,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
24242432
uvLookupResult === 'declined' ? 'compatible-python-declined' : 'install-failure',
24252433
};
24262434
}
2427-
available = await uvPythonInstaller.getAvailablePythonVersions();
2435+
available = needsCompleteCatalog
2436+
? await uvPythonInstaller.getAvailablePythonVersions({ allVersions: true })
2437+
: await uvPythonInstaller.getAvailablePythonVersions();
24282438
} catch (error) {
24292439
this.log.warn(`Unable to query Python versions available from uv: ${getErrorMessage(error)}`);
24302440
return { errorCategory: 'install-failure' };

src/managers/builtin/uvPythonInstaller.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ export interface UvPythonVersion {
7676
arch: string;
7777
}
7878

79+
export interface GetAvailablePythonVersionsOptions {
80+
readonly allVersions?: boolean;
81+
}
82+
7983
/**
8084
* Checks if a command is available on the system.
8185
*/
@@ -276,12 +280,20 @@ export async function getUvPythonPath(version?: string): Promise<string | undefi
276280

277281
/**
278282
* Gets available Python versions from uv.
283+
* @param options Set `allVersions` only when older patch releases are needed.
279284
* @returns Promise that resolves to an array of Python versions
280285
*/
281-
export async function getAvailablePythonVersions(): Promise<UvPythonVersion[]> {
286+
export async function getAvailablePythonVersions(
287+
options?: GetAvailablePythonVersionsOptions,
288+
): Promise<UvPythonVersion[]> {
282289
return new Promise((resolve) => {
283290
const chunks: string[] = [];
284-
const proc = spawnProcess('uv', ['python', 'list', '--output-format', 'json']);
291+
const args = ['python', 'list'];
292+
if (options?.allVersions) {
293+
args.push('--all-versions');
294+
}
295+
args.push('--output-format', 'json');
296+
const proc = spawnProcess('uv', args);
285297
proc.stdout?.on('data', (data) => chunks.push(data.toString()));
286298
proc.on('error', () => resolve([]));
287299
proc.on('exit', (code) => {

src/test/managers/builtin/inlineScript/envManager.unit.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,25 @@ suite('InlineScriptEnvManager', () => {
744744
assert.strictEqual(createWithProgressStub.callCount, 1);
745745
});
746746

747+
test('uses strict PEP 440 matching for a directly resolved final release', async () => {
748+
const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python');
749+
await fs.outputFile(uvExecutable, '');
750+
const uvBase = makeEnvironment('ms-python.python:system', '3.15.0', uvExecutable);
751+
readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '!=3.15.0rc2' });
752+
apiGetEnvironmentsStub.resolves([]);
753+
getAvailablePythonVersionsStub.resolves([makeUvPythonVersion('3.15.0')]);
754+
promptInstallPythonViaUvStub.resolves({ kind: 'installed', pythonPath: uvExecutable });
755+
resolveSystemPythonStub.resolves(uvBase);
756+
757+
assert.ok(await manager.create(scriptUri()));
758+
759+
sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, {
760+
requiresPython: '!=3.15.0rc2',
761+
version: '3.15.0',
762+
});
763+
assert.strictEqual(createWithProgressStub.firstCall.args[4], uvBase);
764+
});
765+
747766
test('selects an available uv release that satisfies exclusion clauses', async () => {
748767
const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python');
749768
await fs.outputFile(uvExecutable, '');
@@ -834,6 +853,46 @@ suite('InlineScriptEnvManager', () => {
834853
requiresPython: '>=3.11,<3.12',
835854
version: '3.11.14',
836855
});
856+
sinon.assert.calledOnceWithExactly(getAvailablePythonVersionsStub);
857+
});
858+
859+
test('resolves a short exact requirement to an advertised concrete release', async () => {
860+
const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python');
861+
await fs.outputFile(uvExecutable, '');
862+
const uvBase = makeEnvironment('ms-python.python:system', '3.13.0', uvExecutable);
863+
readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '==3.13' });
864+
apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]);
865+
apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]);
866+
apiGetEnvironmentsStub.onThirdCall().resolves([uvBase]);
867+
const defaultCatalog = [makeUvPythonVersion('3.13.2')];
868+
const completeCatalog = [...defaultCatalog, makeUvPythonVersion('3.13.0')];
869+
getAvailablePythonVersionsStub.callsFake(async (options?: { allVersions?: boolean }) =>
870+
options?.allVersions ? completeCatalog : defaultCatalog,
871+
);
872+
promptInstallPythonViaUvStub.resolves({ kind: 'installed', pythonPath: uvExecutable });
873+
874+
assert.ok(await manager.create(scriptUri()));
875+
876+
sinon.assert.calledOnceWithExactly(ensureUvForVersionLookupStub, '==3.13', manager.log);
877+
sinon.assert.calledOnceWithExactly(getAvailablePythonVersionsStub, { allVersions: true });
878+
sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, {
879+
requiresPython: '==3.13',
880+
version: '3.13.0',
881+
});
882+
});
883+
884+
test('does not install a short exact requirement without an exact catalog candidate', async () => {
885+
readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '==3.13' });
886+
apiGetEnvironmentsStub.resolves([baseEnvironment]);
887+
getAvailablePythonVersionsStub.resolves([makeUvPythonVersion('3.13.2')]);
888+
889+
assert.strictEqual(await manager.create(scriptUri()), undefined);
890+
891+
sinon.assert.calledOnceWithExactly(ensureUvForVersionLookupStub, '==3.13', manager.log);
892+
sinon.assert.calledOnceWithExactly(getAvailablePythonVersionsStub, { allVersions: true });
893+
assert.strictEqual(promptInstallPythonViaUvStub.callCount, 0);
894+
assert.strictEqual(apiRefreshEnvironmentsStub.callCount, 0);
895+
assert.strictEqual(createWithProgressStub.callCount, 0);
837896
});
838897

839898
test('uses an exact requirement without needing an existing uv catalog', async () => {

src/test/managers/builtin/uvPythonInstaller.unit.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,32 @@ suite('uvPythonInstaller - getAvailablePythonVersions', () => {
835835
assert.strictEqual(result.length, 2, 'Should return all versions');
836836
assert.strictEqual(result[0].version, '3.13.1');
837837
assert.strictEqual(result[1].version, '3.12.8');
838+
sinon.assert.calledOnceWithExactly(spawnStub, 'uv', ['python', 'list', '--output-format', 'json']);
839+
});
840+
841+
test('should request older patch releases only when all versions are requested', async () => {
842+
const versions: UvPythonVersion[] = [
843+
makeUvPythonVersion({ version: '3.13.2', path: null }),
844+
makeUvPythonVersion({ version: '3.13.0', path: null }),
845+
];
846+
const args = ['python', 'list', '--all-versions', '--output-format', 'json'];
847+
const mockProcess = new MockChildProcess('uv', args);
848+
spawnStub.returns(mockProcess);
849+
850+
const resultPromise = getAvailablePythonVersions({ allVersions: true });
851+
852+
setTimeout(() => {
853+
mockProcess.stdout?.emit('data', JSON.stringify(versions));
854+
mockProcess.emit('exit', 0, null);
855+
}, 10);
856+
857+
const result = await resultPromise;
858+
859+
assert.deepStrictEqual(
860+
result.map((version) => version.version),
861+
['3.13.2', '3.13.0'],
862+
);
863+
sinon.assert.calledOnceWithExactly(spawnStub, 'uv', args);
838864
});
839865

840866
test('should return empty array on process error', async () => {

0 commit comments

Comments
 (0)