From 0ed64d4071e012c5858b075c85d74d778373a81f Mon Sep 17 00:00:00 2001 From: Mohammad Malik <80257039+mohammad-malik@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:02:04 +0000 Subject: [PATCH] Honor requires-python when creating a virtual environment Read project.requires-python from the pyproject.toml that owns the creation root and offer only interpreters that satisfy it. Quick Create keeps a compatible default and otherwise uses the newest match. Co-authored-by: Mohammad Malik --- src/common/localize.ts | 16 ++ .../builtin/builtInEnvironmentManager.ts | 10 +- src/managers/builtin/requiresPython.ts | 247 ++++++++++++++++++ src/managers/builtin/venvStepBasedFlow.ts | 32 ++- .../builtin/requiresPython.unit.test.ts | 183 +++++++++++++ .../venvManager.createRemove.unit.test.ts | 60 ++++- .../builtin/venvStepBasedFlow.unit.test.ts | 131 ++++++++++ 7 files changed, 673 insertions(+), 6 deletions(-) create mode 100644 src/managers/builtin/requiresPython.ts create mode 100644 src/test/managers/builtin/requiresPython.unit.test.ts diff --git a/src/common/localize.ts b/src/common/localize.ts index 19a3bcd84..8f67478c4 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -205,6 +205,22 @@ export namespace VenvManagerStrings { export const quickCreateDescription = l10n.t('Create a virtual environment in the workspace root'); export const customize = l10n.t('Custom'); export const customizeDescription = l10n.t('Choose python version, location, packages, name, etc.'); + + export function quickCreateDetail(version: string): string { + return l10n.t('Uses Python version {0} and installs workspace dependencies.', version); + } + + export function quickCreateDetailRequiresPython(version: string, requiresPython: string): string { + return l10n.t( + 'Uses Python version {0} to satisfy requires-python {1} and installs workspace dependencies.', + version, + requiresPython, + ); + } + + export function selectEnvironmentRequiresPython(requiresPython: string): string { + return l10n.t('Select a Python interpreter that satisfies requires-python {0}', requiresPython); + } } export namespace SysManagerStrings { diff --git a/src/managers/builtin/builtInEnvironmentManager.ts b/src/managers/builtin/builtInEnvironmentManager.ts index 7a2a61fef..70f44c55e 100644 --- a/src/managers/builtin/builtInEnvironmentManager.ts +++ b/src/managers/builtin/builtInEnvironmentManager.ts @@ -52,6 +52,7 @@ import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { getLatest, shortenVersionString, sortEnvironments } from '../common/utils'; import { promptInstallPython } from './pythonInstaller'; +import { chooseQuickCreateInterpreter, resolveProjectRequiresPython } from './requiresPython'; import { getDefaultGlobalPython, resolveSystemPythonEnvironmentPath } from './utils'; import { UvEnvironmentManager } from './uvEnvironmentManager'; import { VenvEnvironmentManager } from './venvEnvironmentManager'; @@ -353,12 +354,19 @@ export class BuiltInEnvironmentManager implements EnvironmentManager { } if (this.globalEnv && PythonVersion.tryParse(this.globalEnv.version)?.major === 3) { // quick create given correct information + const requiresPython = await resolveProjectRequiresPython(venvRoot); + const baseEnv = chooseQuickCreateInterpreter(this.globalEnv, globals, requiresPython); + if (baseEnv !== this.globalEnv && requiresPython) { + this.log.info( + `Quick Create selected Python ${baseEnv.version} to satisfy requires-python ${requiresPython}.`, + ); + } result = await quickCreateVenv( this.nativeFinder, this.api, this.log, this, - this.globalEnv, + baseEnv, venvRoot, options?.additionalPackages, undefined, diff --git a/src/managers/builtin/requiresPython.ts b/src/managers/builtin/requiresPython.ts new file mode 100644 index 000000000..07a3b96d7 --- /dev/null +++ b/src/managers/builtin/requiresPython.ts @@ -0,0 +1,247 @@ +import * as tomljs from '@iarna/toml'; +import * as fse from 'fs-extra'; +import * as path from 'path'; +import { Uri } from 'vscode'; +import { PythonEnvironment } from '../../api'; +import { traceWarn } from '../../common/logging'; +import { PythonVersion } from '../../common/pythonVersion'; +import { PythonVersionSpecifier } from '../../common/pythonVersionSpecifier'; +import { isSameOrParentPath, normalizePath } from '../../common/utils/pathUtils'; +import { findFiles } from '../../common/workspace.apis'; +import { sortEnvironments } from '../common/utils'; + +/** Directories whose `pyproject.toml` does not describe the project being created. */ +const IGNORED_DIRECTORY = /^(?:\.venv.*|\.git|node_modules|\.tox|\.nox|\.conda|site-packages|__pypackages__)$/i; + +const PYPROJECT_EXCLUDE = '**/{.venv*,.git,.nox,.tox,.conda,site-packages,__pypackages__,node_modules}/**'; + +export type RequiresPythonSelectionOutcome = 'unconstrained' | 'applied' | 'unparseable' | 'none-compatible'; + +export interface RequiresPythonInterpreterSelection { + /** Interpreters to offer. Order matches the input when no extra sorting is applied. */ + readonly interpreters: PythonEnvironment[]; + /** Set only when incompatible interpreters were removed. */ + readonly requiresPython?: string; + readonly outcome: RequiresPythonSelectionOutcome; +} + +/** + * Reads `[project].requires-python` from a `pyproject.toml` file. + * + * @returns The trimmed specifier, or `undefined` when the file is missing, unreadable, or has no specifier. + */ +export async function readRequiresPython(fsPath: string): Promise { + let content: string; + try { + content = await fse.readFile(fsPath, 'utf-8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + traceWarn(`Unable to read ${fsPath}:`, error instanceof Error ? error.message : String(error)); + } + return undefined; + } + + try { + return requiresPythonFromToml(tomljs.parse(content)); + } catch (error) { + traceWarn( + `Unable to parse requires-python from ${fsPath}:`, + error instanceof Error ? error.message : String(error), + ); + return undefined; + } +} + +/** + * Resolves the `requires-python` specifier that should guide environment creation for `root`. + * + * The `pyproject.toml` in the creation root wins. Otherwise the closest related file is used: + * an ancestor project before a nested package. Conflicting specifiers at the same distance are ignored. + */ +export async function resolveProjectRequiresPython(root: Uri): Promise { + const rootFile = path.join(root.fsPath, 'pyproject.toml'); + if (await fse.pathExists(rootFile)) { + const rootSpecifier = await readRequiresPython(rootFile); + if (rootSpecifier) { + return rootSpecifier; + } + } + + const candidates = await findRelatedPyprojects(root); + const declared: { rank: number; specifier: string }[] = []; + for (const fsPath of candidates) { + if (normalizePath(fsPath) === normalizePath(rootFile) || isIgnoredPyproject(fsPath)) { + continue; + } + const rank = rankPyproject(root.fsPath, fsPath); + if (rank === undefined) { + continue; + } + const specifier = await readRequiresPython(fsPath); + if (!specifier) { + continue; + } + declared.push({ rank, specifier }); + } + + if (declared.length === 0) { + return undefined; + } + + declared.sort((left, right) => left.rank - right.rank); + const bestRank = declared[0].rank; + const specifiers = new Set(declared.filter((item) => item.rank === bestRank).map((item) => item.specifier)); + if (specifiers.size !== 1) { + traceWarn( + `Conflicting requires-python values for ${root.fsPath}; Create Environment will not filter interpreters.`, + ); + return undefined; + } + return declared[0].specifier; +} + +/** + * Keeps interpreters that satisfy `requiresPython` when at least one does. + * + * An empty result is avoided: when nothing matches, or the specifier cannot be parsed, the original + * list is returned so creation can still continue. + */ +export function selectInterpretersForRequiresPython( + interpreters: readonly PythonEnvironment[], + requiresPython: string | undefined, +): RequiresPythonInterpreterSelection { + const specifier = requiresPython?.trim(); + if (!specifier) { + return { interpreters: [...interpreters], outcome: 'unconstrained' }; + } + + const parsed = PythonVersionSpecifier.tryParse(specifier); + if (!parsed) { + return { interpreters: [...interpreters], outcome: 'unparseable' }; + } + + const matching = interpreters.filter((interpreter) => { + const version = PythonVersion.tryParse(interpreter.version); + return version !== undefined && parsed.matches(version); + }); + if (matching.length === 0) { + return { interpreters: [...interpreters], outcome: 'none-compatible' }; + } + + return { interpreters: matching, requiresPython: specifier, outcome: 'applied' }; +} + +/** + * Picks the base interpreter for Quick Create. + * + * The preferred interpreter is kept when it already satisfies `requires-python`. Otherwise the newest + * compatible Python 3 interpreter is used. The preferred interpreter is kept when the constraint cannot + * be applied. + */ +export function chooseQuickCreateInterpreter( + preferred: PythonEnvironment, + candidates: readonly PythonEnvironment[], + requiresPython: string | undefined, +): PythonEnvironment { + const python3 = candidates.filter((interpreter) => PythonVersion.tryParse(interpreter.version)?.major === 3); + const selection = selectInterpretersForRequiresPython(sortEnvironments([...python3]), requiresPython); + if (selection.outcome !== 'applied' || !selection.requiresPython) { + return preferred; + } + + const preferredVersion = PythonVersion.tryParse(preferred.version); + const parsed = PythonVersionSpecifier.tryParse(selection.requiresPython); + if (preferredVersion && parsed?.matches(preferredVersion)) { + return preferred; + } + return selection.interpreters[0] ?? preferred; +} + +function requiresPythonFromToml(toml: tomljs.JsonMap): string | undefined { + const project = toml.project; + if (!isTomlTable(project)) { + return undefined; + } + const requiresPython = project['requires-python']; + if (typeof requiresPython !== 'string') { + return undefined; + } + const trimmed = requiresPython.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function isTomlTable(value: unknown): value is tomljs.JsonMap { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +async function findRelatedPyprojects(root: Uri): Promise { + let found: readonly Uri[] = []; + try { + const result = await findFiles('**/pyproject.toml', PYPROJECT_EXCLUDE); + if (Array.isArray(result)) { + found = result; + } + } catch (error) { + traceWarn( + 'Failed to search for pyproject.toml while resolving requires-python:', + error instanceof Error ? error.message : String(error), + ); + return []; + } + + const related = new Map(); + for (const uri of found) { + if (isIgnoredPyproject(uri.fsPath) || !isRelatedPyproject(root.fsPath, uri.fsPath)) { + continue; + } + related.set(normalizePath(uri.fsPath), uri.fsPath); + } + return [...related.values()]; +} + +function isIgnoredPyproject(fsPath: string): boolean { + return path + .normalize(fsPath) + .split(path.sep) + .some((segment) => IGNORED_DIRECTORY.test(segment)); +} + +function isRelatedPyproject(rootFsPath: string, pyprojectFsPath: string): boolean { + const directory = path.dirname(pyprojectFsPath); + return isSameOrParentPath(rootFsPath, directory) || isSameOrParentPath(directory, rootFsPath); +} + +/** + * Ranks a `pyproject.toml` by how directly it owns the creation root. + * + * `0` is the creation root. Ancestors use their distance (`1` is the parent). Nested packages start at `1000` + * so an enclosing project is preferred over a nested one. + */ +function rankPyproject(rootFsPath: string, pyprojectFsPath: string): number | undefined { + const directory = path.resolve(path.dirname(pyprojectFsPath)); + const root = path.resolve(rootFsPath); + if (normalizePath(directory) === normalizePath(root)) { + return 0; + } + if (isSameOrParentPath(root, directory)) { + const depth = relativeDepth(root, directory); + return depth === undefined ? undefined : 1000 + depth; + } + if (isSameOrParentPath(directory, root)) { + return relativeDepth(directory, root); + } + return undefined; +} + +function relativeDepth(parent: string, candidate: string): number | undefined { + const relative = path.relative(parent, candidate); + if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return undefined; + } + return relative.split(path.sep).filter((segment) => segment.length > 0).length; +} diff --git a/src/managers/builtin/venvStepBasedFlow.ts b/src/managers/builtin/venvStepBasedFlow.ts index 4d1f9496d..82d0622b9 100644 --- a/src/managers/builtin/venvStepBasedFlow.ts +++ b/src/managers/builtin/venvStepBasedFlow.ts @@ -13,6 +13,7 @@ import { PipPackages, shouldProceedAfterPyprojectValidation, } from './pipUtils'; +import { resolveProjectRequiresPython, selectInterpretersForRequiresPython } from './requiresPython'; import { CreateEnvironmentResult, createWithProgress, @@ -54,6 +55,9 @@ interface VenvCreationState { // Root directory where venv will be created (used for path validation) venvRoot?: Uri; + + // requires-python from the project's pyproject.toml, when it filtered the interpreter list + requiresPython?: string; } /** * Type definition for step functions in the wizard-like flow. @@ -83,7 +87,12 @@ async function selectCreateType(state: VenvCreationState): Promise { // Sort and filter available Python environments - const sortedEnvs = ensureGlobalEnv(basePythons, log); + const python3 = ensureGlobalEnv(basePythons, log); + const declaredRequiresPython = await resolveProjectRequiresPython(venvRoot); + const selection = selectInterpretersForRequiresPython(python3, declaredRequiresPython); + const sortedEnvs = selection.interpreters; + if (selection.outcome === 'applied' && selection.requiresPython) { + log.info( + `Create Environment will offer ${sortedEnvs.length} interpreter(s) that satisfy requires-python ${selection.requiresPython}.`, + ); + } else if (selection.outcome === 'none-compatible' && declaredRequiresPython) { + log.warn( + `No Python interpreter satisfies requires-python ${declaredRequiresPython}. Offering every Python 3 interpreter.`, + ); + } else if (selection.outcome === 'unparseable' && declaredRequiresPython) { + log.warn(`Ignoring unparseable requires-python '${declaredRequiresPython}' during environment creation.`); + } if (sortedEnvs.length === 0) { return { envCreationErr: 'No suitable Python environments found', @@ -321,6 +346,7 @@ export async function createStepBasedVenvFlow( // Initialize the state object that will track user selections const state: VenvCreationState = { sortedEnvs, // Store sorted environments in state to avoid re-sorting + requiresPython: selection.requiresPython, api, // Store API reference for package selection project: [api.getPythonProject(venvRoot)].filter(Boolean) as PythonProject[], // Get project for venvRoot venvRoot, // Store venvRoot for path validation diff --git a/src/test/managers/builtin/requiresPython.unit.test.ts b/src/test/managers/builtin/requiresPython.unit.test.ts new file mode 100644 index 000000000..82b684352 --- /dev/null +++ b/src/test/managers/builtin/requiresPython.unit.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import { PythonEnvironment } from '../../../api'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { + chooseQuickCreateInterpreter, + readRequiresPython, + resolveProjectRequiresPython, + selectInterpretersForRequiresPython, +} from '../../../managers/builtin/requiresPython'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +suite('requires-python for environment creation', () => { + let root: string; + + setup(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'requires-python-')); + }); + + teardown(async () => { + sinon.restore(); + await fs.remove(root); + }); + + function interpreter(version: string): PythonEnvironment { + return createMockPythonEnvironment({ + name: `Python ${version}`, + envPath: path.join(root, 'interpreters', version, 'python'), + version, + }); + } + + async function writePyproject(directory: string, requiresPython?: string): Promise { + const specifier = requiresPython === undefined ? '' : `\nrequires-python = "${requiresPython}"`; + const fsPath = path.join(directory, 'pyproject.toml'); + await fs.outputFile(fsPath, `[project]\nname = "demo"\nversion = "0.0.1"${specifier}\n`); + return fsPath; + } + + test('reads a trimmed requires-python specifier', async () => { + const fsPath = await writePyproject(root, ' >=3.10 '); + + assert.strictEqual(await readRequiresPython(fsPath), '>=3.10'); + }); + + test('returns undefined when the project table has no requires-python', async () => { + const fsPath = await writePyproject(root); + + assert.strictEqual(await readRequiresPython(fsPath), undefined); + }); + + test('returns undefined for an unreadable or invalid pyproject.toml', async () => { + assert.strictEqual(await readRequiresPython(path.join(root, 'missing.toml')), undefined); + + const invalid = path.join(root, 'pyproject.toml'); + await fs.writeFile(invalid, '[[[\n'); + assert.strictEqual(await readRequiresPython(invalid), undefined); + }); + + test('uses the creation-root specifier from the issue example', async () => { + await writePyproject(root, '>3.10'); + const search = sinon.stub(workspaceApis, 'findFiles').rejects(new Error('search should not run')); + + assert.strictEqual(await resolveProjectRequiresPython(Uri.file(root)), '>3.10'); + assert.ok(search.notCalled); + }); + + test('prefers an ancestor project over a nested package', async () => { + const parent = root; + const project = path.join(parent, 'service'); + const nested = path.join(project, 'experiments'); + await writePyproject(parent, '>=3.10'); + await writePyproject(nested, '>=3.13'); + sinon + .stub(workspaceApis, 'findFiles') + .resolves([Uri.file(path.join(parent, 'pyproject.toml')), Uri.file(path.join(nested, 'pyproject.toml'))]); + + assert.strictEqual(await resolveProjectRequiresPython(Uri.file(project)), '>=3.10'); + }); + + test('uses the only nested specifier when the creation root has no pyproject.toml', async () => { + const nested = path.join(root, 'pkg'); + const fsPath = await writePyproject(nested, '>=3.11'); + sinon.stub(workspaceApis, 'findFiles').resolves([Uri.file(fsPath)]); + + assert.strictEqual(await resolveProjectRequiresPython(Uri.file(root)), '>=3.11'); + }); + + test('ignores conflicting nested specifiers and pyproject files inside virtual environments', async () => { + const first = await writePyproject(path.join(root, 'a'), '>=3.11'); + const second = await writePyproject(path.join(root, 'b'), '>=3.9'); + const venvFile = await writePyproject(path.join(root, '.venv'), '>=3.8'); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'requires-python-outside-')); + try { + const unrelated = await writePyproject(outside, '>=3.13'); + sinon + .stub(workspaceApis, 'findFiles') + .resolves([Uri.file(first), Uri.file(second), Uri.file(venvFile), Uri.file(unrelated)]); + + assert.strictEqual(await resolveProjectRequiresPython(Uri.file(root)), undefined); + } finally { + await fs.remove(outside); + } + }); + + test('accepts nested files that declare the same specifier', async () => { + const first = await writePyproject(path.join(root, 'a'), '>=3.11'); + const second = await writePyproject(path.join(root, 'b'), '>=3.11'); + sinon.stub(workspaceApis, 'findFiles').resolves([Uri.file(first), Uri.file(second)]); + + assert.strictEqual(await resolveProjectRequiresPython(Uri.file(root)), '>=3.11'); + }); + + test('filters to interpreters that satisfy >3.10 and keeps newest-first order', () => { + const interpreters = [ + interpreter('3.12.1'), + interpreter('3.10.1'), + interpreter('3.10.0'), + interpreter('3.9.18'), + ]; + + const selection = selectInterpretersForRequiresPython(interpreters, '>3.10'); + + assert.strictEqual(selection.outcome, 'applied'); + assert.strictEqual(selection.requiresPython, '>3.10'); + assert.deepStrictEqual( + selection.interpreters.map((item) => item.version), + ['3.12.1', '3.10.1'], + ); + }); + + test('supports an upper bound and rejects prereleases the specifier does not name', () => { + const selection = selectInterpretersForRequiresPython( + [interpreter('3.12.0'), interpreter('3.11.0rc1'), interpreter('3.11.4'), interpreter('3.9.0')], + '>=3.10,<3.12', + ); + + assert.deepStrictEqual( + selection.interpreters.map((item) => item.version), + ['3.11.4'], + ); + }); + + test('keeps every interpreter when nothing matches or the specifier is invalid', () => { + const interpreters = [interpreter('3.9.0'), interpreter('not-a-version')]; + + const none = selectInterpretersForRequiresPython(interpreters, '>=3.13'); + assert.strictEqual(none.outcome, 'none-compatible'); + assert.strictEqual(none.requiresPython, undefined); + assert.deepStrictEqual(none.interpreters, interpreters); + + const invalid = selectInterpretersForRequiresPython(interpreters, '3.10'); + assert.strictEqual(invalid.outcome, 'unparseable'); + assert.deepStrictEqual(invalid.interpreters, interpreters); + + const unconstrained = selectInterpretersForRequiresPython(interpreters, ' '); + assert.strictEqual(unconstrained.outcome, 'unconstrained'); + }); + + test('Quick Create keeps a compatible preferred interpreter', () => { + const preferred = interpreter('3.11.2'); + const newer = interpreter('3.13.0'); + + assert.strictEqual(chooseQuickCreateInterpreter(preferred, [newer, preferred], '>=3.10'), preferred); + }); + + test('Quick Create replaces an incompatible preferred interpreter with the newest match', () => { + const preferred = interpreter('3.9.1'); + const newer = interpreter('3.13.0'); + const olderMatch = interpreter('3.11.8'); + + assert.strictEqual(chooseQuickCreateInterpreter(preferred, [olderMatch, preferred, newer], '>=3.10'), newer); + assert.strictEqual(chooseQuickCreateInterpreter(preferred, [preferred], '>=3.10'), preferred); + assert.strictEqual(chooseQuickCreateInterpreter(preferred, [newer, preferred], undefined), preferred); + }); +}); diff --git a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts index fbc7b7681..ae659e4d0 100644 --- a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts +++ b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts @@ -158,6 +158,56 @@ suite('VenvManager.create - orchestration', () => { assert.deepStrictEqual(quickCreateVenvStub.firstCall.args[6], ['pytest']); }); + test('quick create keeps a default interpreter that already satisfies requires-python', async () => { + const globalEnv = createMockPythonEnvironment({ + name: 'global', + envPath: testPath('global', 'python3.11'), + version: '3.11.2', + }); + const newer = createMockPythonEnvironment({ + name: 'newer', + envPath: testPath('global', 'python3.13'), + version: '3.13.0', + }); + const manager = createManager({ getEnvironments: sinon.stub().resolves([globalEnv, newer]) }); + (manager as any).globalEnv = globalEnv; + quickCreateVenvStub.resolves({ environment: createdEnvironment() }); + const scope = Uri.file(path.join(tmpRoot, 'compatible-project')); + await fse.outputFile( + path.join(scope.fsPath, 'pyproject.toml'), + '[project]\nname = "demo"\nversion = "0.0.1"\nrequires-python = ">=3.10"\n', + ); + + await manager.create(scope, { quickCreate: true }); + + assert.strictEqual(quickCreateVenvStub.firstCall.args[4], globalEnv); + }); + + test('quick create selects an interpreter that satisfies requires-python', async () => { + const globalEnv = createMockPythonEnvironment({ + name: 'global', + envPath: testPath('global', 'python3.9'), + version: '3.9.1', + }); + const compatible = createMockPythonEnvironment({ + name: 'compatible', + envPath: testPath('global', 'python3.12'), + version: '3.12.2', + }); + const manager = createManager({ getEnvironments: sinon.stub().resolves([globalEnv, compatible]) }); + (manager as any).globalEnv = globalEnv; + quickCreateVenvStub.resolves({ environment: createdEnvironment() }); + const scope = Uri.file(path.join(tmpRoot, 'constrained-project')); + await fse.outputFile( + path.join(scope.fsPath, 'pyproject.toml'), + '[project]\nname = "demo"\nversion = "0.0.1"\nrequires-python = ">3.10"\n', + ); + + await manager.create(scope, { quickCreate: true }); + + assert.strictEqual(quickCreateVenvStub.firstCall.args[4], compatible); + }); + test('dependency-search cancellation returns quietly without environment side effects', async () => { const globalEnv = createMockPythonEnvironment({ name: 'global', @@ -218,7 +268,10 @@ suite('VenvManager.create - orchestration', () => { test('stops without an extra error or environment creation when Python installation is declined', async () => { const manager = createManager(); - assert.strictEqual(await manager.create(Uri.file(path.join(tmpRoot, 'project')), { quickCreate: true }), undefined); + assert.strictEqual( + await manager.create(Uri.file(path.join(tmpRoot, 'project')), { quickCreate: true }), + undefined, + ); sinon.assert.calledOnce(installPythonStub); sinon.assert.notCalled(createPythonVenvStub); @@ -275,7 +328,10 @@ suite('VenvManager.create - orchestration', () => { const manager = createManager(); installPythonStub.resolves(path.join(tmpRoot, 'missing', 'python.exe')); - assert.strictEqual(await manager.create(Uri.file(path.join(tmpRoot, 'project')), { quickCreate: true }), undefined); + assert.strictEqual( + await manager.create(Uri.file(path.join(tmpRoot, 'project')), { quickCreate: true }), + undefined, + ); sinon.assert.calledOnce(showErrorStub); sinon.assert.notCalled(quickCreateVenvStub); diff --git a/src/test/managers/builtin/venvStepBasedFlow.unit.test.ts b/src/test/managers/builtin/venvStepBasedFlow.unit.test.ts index f34a80490..4e29a1ecf 100644 --- a/src/test/managers/builtin/venvStepBasedFlow.unit.test.ts +++ b/src/test/managers/builtin/venvStepBasedFlow.unit.test.ts @@ -196,4 +196,135 @@ suite('step-based venv creation pyproject preflight', () => { assert.ok(createWithProgress.calledOnce); assert.deepStrictEqual(createWithProgress.firstCall.args[7], { install: [], uninstall: [] }); }); + + test('Quick Create uses an interpreter that satisfies pyproject requires-python', async () => { + await fs.outputFile( + path.join(tempRoot, 'pyproject.toml'), + '[project]\nname = "demo"\nversion = "0.0.1"\nrequires-python = ">=3.10,<3.12"\n', + ); + const incompatible = createMockPythonEnvironment({ + name: 'Python 3.13', + envPath: path.join(tempRoot, 'base', 'python3.13'), + version: '3.13.1', + }); + const compatible = createMockPythonEnvironment({ + name: 'Python 3.11', + envPath: path.join(tempRoot, 'base', 'python3.11'), + version: '3.11.5', + }); + const quickPick = sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items) => + items.find((item) => item.label === VenvManagerStrings.quickCreate), + ); + sinon.stub(pipUtils, 'getProjectInstallable').resolves({ installables: [], validationErrors: [] }); + sinon.stub(pipUtils, 'shouldProceedAfterPyprojectValidation').resolves(true); + createWithProgress.resolves({ environment: compatible }); + + const result = await createStepBasedVenvFlow( + {} as NativePythonFinder, + api, + log, + manager, + [incompatible, baseEnvironment, compatible], + Uri.file(tempRoot), + { showQuickAndCustomOptions: true }, + ); + + assert.strictEqual(result?.environment, compatible); + assert.strictEqual(createWithProgress.firstCall.args[4], compatible); + const quickCreateItem = quickPick.firstCall.args[0].find( + (item: { label: string }) => item.label === VenvManagerStrings.quickCreate, + ); + assert.ok(quickCreateItem); + const detail = quickCreateItem.detail; + assert.strictEqual( + detail, + 'Uses Python version 3.11.5 to satisfy requires-python >=3.10,<3.12 and installs workspace dependencies.', + ); + }); + + test('custom creation offers only interpreters that satisfy requires-python >3.10', async () => { + await fs.outputFile( + path.join(tempRoot, 'pyproject.toml'), + '[project]\nname = "demo"\nversion = "0.0.1"\nrequires-python = ">3.10"\n', + ); + const tooOld = createMockPythonEnvironment({ + name: 'Python 3.10.0', + envPath: path.join(tempRoot, 'base', 'python3.10.0'), + version: '3.10.0', + }); + const patch = createMockPythonEnvironment({ + name: 'Python 3.10.1', + envPath: path.join(tempRoot, 'base', 'python3.10.1'), + version: '3.10.1', + }); + const newest = createMockPythonEnvironment({ + name: 'Python 3.12', + envPath: path.join(tempRoot, 'base', 'python3.12'), + version: '3.12.0', + }); + const quickPick = sinon.stub(windowApis, 'showQuickPickWithButtons'); + quickPick.onFirstCall().callsFake(async (items) => + items.find((item) => item.label === VenvManagerStrings.customize), + ); + let offered: string[] = []; + quickPick.onSecondCall().callsFake(async (items, options) => { + offered = items.map((item) => (item as unknown as { e: { version: string } }).e.version); + assert.strictEqual( + options?.placeHolder, + 'Select a Python interpreter that satisfies requires-python >3.10', + ); + return items[0]; + }); + sinon.stub(windowApis, 'showInputBoxWithButtons').resolves('.custom-venv'); + sinon.stub(pipUtils, 'getWorkspacePackagesToInstall').resolves({ install: [], uninstall: [] }); + createWithProgress.resolves({ environment: newest }); + + await createStepBasedVenvFlow( + {} as NativePythonFinder, + api, + log, + manager, + [tooOld, patch, newest], + Uri.file(tempRoot), + { showQuickAndCustomOptions: true }, + ); + + assert.deepStrictEqual(offered, ['3.12.0', '3.10.1']); + assert.strictEqual(createWithProgress.firstCall.args[4], newest); + }); + + test('creation still offers every Python 3 interpreter when none satisfy requires-python', async () => { + await fs.outputFile( + path.join(tempRoot, 'pyproject.toml'), + '[project]\nname = "demo"\nversion = "0.0.1"\nrequires-python = ">=3.13"\n', + ); + const older = createMockPythonEnvironment({ + name: 'Python 3.9', + envPath: path.join(tempRoot, 'base', 'python3.9'), + version: '3.9.7', + }); + sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items) => + items.find((item) => item.label === VenvManagerStrings.quickCreate), + ); + sinon.stub(pipUtils, 'getProjectInstallable').resolves({ installables: [], validationErrors: [] }); + sinon.stub(pipUtils, 'shouldProceedAfterPyprojectValidation').resolves(true); + createWithProgress.resolves({ environment: baseEnvironment }); + + await createStepBasedVenvFlow( + {} as NativePythonFinder, + api, + log, + manager, + [older, baseEnvironment], + Uri.file(tempRoot), + { showQuickAndCustomOptions: true }, + ); + + assert.strictEqual(createWithProgress.firstCall.args[4], baseEnvironment); + assert.ok( + (log.warn as sinon.SinonStub).calledWith( + 'No Python interpreter satisfies requires-python >=3.13. Offering every Python 3 interpreter.', + ), + ); + }); });