From 5a9041aab80808069539ae23a47491d9fc4b9571 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Mon, 20 Jul 2026 23:01:51 +0200 Subject: [PATCH] Fix conda named environment creation after conda 25.11 --- src/managers/conda/condaUtils.ts | 73 ++++-------- .../conda/condaUtils.createNamed.unit.test.ts | 112 ++++++++++++++++++ .../conda/condaUtils.getPrefixes.unit.test.ts | 85 +++++++++++++ 3 files changed, 221 insertions(+), 49 deletions(-) create mode 100644 src/test/managers/conda/condaUtils.createNamed.unit.test.ts create mode 100644 src/test/managers/conda/condaUtils.getPrefixes.unit.test.ts diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 49dffd63..06d3ec54 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -262,57 +262,28 @@ export async function runCondaExecutable( return await _runConda(conda, args, log, token); } -interface CondaInfo { - envs_dirs: string[]; -} - -/** - * Runs `conda info --envs --json` and parses the result. - * Validates the JSON response structure at the parsing boundary. - * @returns Validated CondaInfo object - * @throws Error if conda command fails or returns invalid JSON structure - */ -async function getCondaInfo(): Promise { - const raw = await runConda(['info', '--envs', '--json']); - const parsed = JSON.parse(raw); - - // Validate at the JSON→TypeScript boundary - if (!parsed || typeof parsed !== 'object') { - traceWarn(`conda info returned invalid data: ${typeof parsed}`); - throw new Error(`conda info returned invalid data type: ${typeof parsed}`); - } - - const envsDirs = parsed['envs_dirs']; - if (envsDirs === undefined || envsDirs === null) { - traceWarn('conda info envs_dirs is undefined/null'); - return { envs_dirs: [] }; - } - if (!Array.isArray(envsDirs)) { - traceWarn(`conda info envs_dirs is not an array (type: ${typeof envsDirs})`); - return { envs_dirs: [] }; - } - - traceVerbose(`conda info returned ${envsDirs.length} environment directories`); - return { envs_dirs: envsDirs }; -} - let prefixes: string[] | undefined; export async function getPrefixes(): Promise { - if (prefixes) { + if (prefixes?.length) { return prefixes; } const state = await getWorkspacePersistentState(); const storedPrefixes = await state.get(CONDA_PREFIXES_KEY); - if (storedPrefixes && Array.isArray(storedPrefixes)) { + if (Array.isArray(storedPrefixes) && storedPrefixes.length > 0) { prefixes = storedPrefixes; return prefixes; } try { - const data = await getCondaInfo(); - prefixes = data.envs_dirs; - await state.set(CONDA_PREFIXES_KEY, prefixes); + const { envs_dirs: envsDirs } = JSON.parse(await runConda(['info', '--json'])); + if (!Array.isArray(envsDirs)) { + throw new Error('conda info returned invalid envs_dirs'); + } + prefixes = envsDirs; + if (prefixes.length > 0) { + await state.set(CONDA_PREFIXES_KEY, prefixes); + } } catch (error) { traceError('Failed to get conda environment prefixes', error); prefixes = []; @@ -1041,6 +1012,18 @@ export async function createCondaEnvironment( return createStepBasedCondaFlow(api, log, manager, uris); } +function getCondaCreatePrefix(output: string): string { + const parsed = JSON.parse(output) as { + prefix?: unknown; + actions?: { PREFIX?: unknown }; + }; + const prefix = parsed?.prefix ?? parsed?.actions?.PREFIX; + if (typeof prefix !== 'string' || prefix.trim().length === 0) { + throw new Error('conda create did not return an environment prefix'); + } + return prefix; +} + export async function createNamedCondaEnvironment( api: PythonEnvironmentApi, log: LogOutputChannel, @@ -1067,7 +1050,7 @@ export async function createNamedCondaEnvironment( } const envName: string = name; - const runArgs = ['create', '--yes', '--name', envName]; + const runArgs = ['create', '--yes', '--quiet', '--json', '--name', envName]; if (pythonVersion) { runArgs.push(`python=${pythonVersion}`); } else { @@ -1084,15 +1067,7 @@ export async function createNamedCondaEnvironment( const bin = os.platform() === 'win32' ? 'python.exe' : path.join('bin', 'python'); const output = await runCondaExecutable(runArgs); log.info(output); - - const prefixes = await getPrefixes(); - let envPath = ''; - for (let prefix of prefixes) { - if (await fse.pathExists(path.join(prefix, envName))) { - envPath = path.join(prefix, envName); - break; - } - } + const envPath = getCondaCreatePrefix(output); const version = await getVersion(envPath); const environment = api.createPythonEnvironmentItem( diff --git a/src/test/managers/conda/condaUtils.createNamed.unit.test.ts b/src/test/managers/conda/condaUtils.createNamed.unit.test.ts new file mode 100644 index 00000000..40501716 --- /dev/null +++ b/src/test/managers/conda/condaUtils.createNamed.unit.test.ts @@ -0,0 +1,112 @@ +import assert from 'assert'; +import * as fse from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { CancellationToken, Progress, Uri, WorkspaceConfiguration } from 'vscode'; +import { + EnvironmentManager, + PythonEnvironment, + PythonEnvironmentApi, + PythonEnvironmentInfo, +} from '../../../api'; +import * as childProcessApis from '../../../common/childProcess.apis'; +import * as persistentState from '../../../common/persistentState'; +import * as windowApis from '../../../common/window.apis'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { + clearCondaCache, + CONDA_PATH_KEY, + CONDA_PREFIXES_KEY, + createNamedCondaEnvironment, +} from '../../../managers/conda/condaUtils'; +import { createMockLogOutputChannel } from '../../mocks/helper'; +import { MockChildProcess } from '../../mocks/mockChildProcess'; + +suite('Conda Utils - createNamedCondaEnvironment', () => { + let tempRoot: string; + let condaPath: string; + let envPrefix: string; + let mockState: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub }; + let spawnStub: sinon.SinonStub; + + setup(async () => { + await clearCondaCache(); + tempRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'vscode-python-envs-conda-')); + condaPath = path.join(tempRoot, os.platform() === 'win32' ? 'conda.exe' : 'conda'); + envPrefix = path.join(tempRoot, 'returned-prefix'); + await fse.outputFile(condaPath, ''); + await fse.outputJson(path.join(envPrefix, 'conda-meta', 'python-3.12.0-0.json'), { version: '3.12.0' }); + + mockState = { + get: sinon.stub(), + set: sinon.stub().resolves(), + clear: sinon.stub().resolves(), + }; + mockState.get.withArgs(CONDA_PATH_KEY).resolves(condaPath); + mockState.get.withArgs(CONDA_PREFIXES_KEY).resolves([]); + sinon.stub(persistentState, 'getWorkspacePersistentState').resolves(mockState); + + const config = { get: sinon.stub() }; + config.get.withArgs('condaPath').returns(condaPath); + sinon + .stub(workspaceApis, 'getConfiguration') + .withArgs('python') + .returns(config as unknown as WorkspaceConfiguration); + sinon.stub(windowApis, 'showInputBoxWithButtons').resolves('test-env'); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => { + return await task( + { report: sinon.stub() } as unknown as Progress<{ message?: string; increment?: number }>, + { isCancellationRequested: false } as CancellationToken, + ); + }); + spawnStub = sinon.stub(childProcessApis, 'spawnProcess'); + }); + + teardown(async () => { + sinon.restore(); + await fse.remove(tempRoot); + }); + + const createResults: { description: string; output: (prefix: string) => object }[] = [ + { description: 'current top-level prefix', output: (prefix) => ({ success: true, prefix }) }, + { + description: 'legacy actions.PREFIX', + output: (prefix) => ({ success: true, actions: { PREFIX: prefix } }), + }, + ]; + const createArgs = ['create', '--yes', '--quiet', '--json', '--name', 'test-env', 'python=3.12']; + createResults.forEach(({ description, output }) => { + test(`uses the ${description} returned by conda create --json`, async () => { + const mockProcess = new MockChildProcess(condaPath, createArgs); + spawnStub.callsFake(() => { + setImmediate(() => { + mockProcess.stdout?.emit('data', Buffer.from(JSON.stringify(output(envPrefix)))); + mockProcess.emit('exit', 0, null); + mockProcess.emit('close', 0, null); + }); + return mockProcess; + }); + + const createdEnvironment = {} as PythonEnvironment; + const createEnvironmentItem = sinon.stub().returns(createdEnvironment); + const api = { createPythonEnvironmentItem: createEnvironmentItem } as unknown as PythonEnvironmentApi; + const manager = {} as EnvironmentManager; + + const resultPromise = createNamedCondaEnvironment( + api, + createMockLogOutputChannel(), + manager, + 'test-env', + '3.12', + ); + + assert.strictEqual(await resultPromise, createdEnvironment); + assert.deepStrictEqual(spawnStub.firstCall.args[1], createArgs); + assert.ok(!mockState.get.calledWith(CONDA_PREFIXES_KEY)); + + const info = createEnvironmentItem.firstCall.args[0] as PythonEnvironmentInfo; + assert.strictEqual(info.environmentPath.fsPath, Uri.file(envPrefix).fsPath); + }); + }); +}); diff --git a/src/test/managers/conda/condaUtils.getPrefixes.unit.test.ts b/src/test/managers/conda/condaUtils.getPrefixes.unit.test.ts new file mode 100644 index 00000000..b95d5d85 --- /dev/null +++ b/src/test/managers/conda/condaUtils.getPrefixes.unit.test.ts @@ -0,0 +1,85 @@ +import assert from 'assert'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { WorkspaceConfiguration } from 'vscode'; +import * as childProcessApis from '../../../common/childProcess.apis'; +import * as persistentState from '../../../common/persistentState'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { + clearCondaCache, + CONDA_PREFIXES_KEY, + getPrefixes, +} from '../../../managers/conda/condaUtils'; +import { MockChildProcess } from '../../mocks/mockChildProcess'; + +suite('Conda Utils - getPrefixes', () => { + let mockState: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub }; + let spawnStub: sinon.SinonStub; + + setup(async () => { + await clearCondaCache(); + + mockState = { + get: sinon.stub(), + set: sinon.stub().resolves(), + clear: sinon.stub().resolves(), + }; + sinon.stub(persistentState, 'getWorkspacePersistentState').resolves(mockState); + + const config = { get: sinon.stub() }; + config.get.withArgs('condaPath').returns('conda'); + sinon + .stub(workspaceApis, 'getConfiguration') + .withArgs('python') + .returns(config as unknown as WorkspaceConfiguration); + spawnStub = sinon.stub(childProcessApis, 'spawnProcess'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('refreshes an empty persisted cache with conda info', async () => { + const envsDir = path.join(os.tmpdir(), 'conda-envs'); + mockState.get.withArgs(CONDA_PREFIXES_KEY).resolves([]); + + const mockProcess = new MockChildProcess('conda', ['info', '--json']); + spawnStub.returns(mockProcess); + + const resultPromise = getPrefixes(); + setImmediate(() => { + mockProcess.stdout?.emit('data', Buffer.from(JSON.stringify({ envs_dirs: [envsDir] }))); + mockProcess.emit('exit', 0, null); + mockProcess.emit('close', 0, null); + }); + + assert.deepStrictEqual(await resultPromise, [envsDir]); + assert.ok(spawnStub.calledOnceWithExactly('conda', ['info', '--json'], { shell: true })); + assert.ok(mockState.set.calledOnceWithExactly(CONDA_PREFIXES_KEY, [envsDir])); + }); + + test('retries after conda returns an empty prefix list', async () => { + const envsDir = path.join(os.tmpdir(), 'recovered-conda-envs'); + mockState.get.withArgs(CONDA_PREFIXES_KEY).resolves(undefined); + + const outputs = [{ envs_dirs: [] }, { envs_dirs: [envsDir] }]; + spawnStub.callsFake(() => { + const mockProcess = new MockChildProcess('conda', ['info', '--json']); + const output = outputs.shift(); + setImmediate(() => { + mockProcess.stdout?.emit('data', Buffer.from(JSON.stringify(output))); + mockProcess.emit('exit', 0, null); + mockProcess.emit('close', 0, null); + }); + return mockProcess; + }); + + assert.deepStrictEqual(await getPrefixes(), []); + assert.ok(mockState.set.notCalled); + + assert.deepStrictEqual(await getPrefixes(), [envsDir]); + assert.strictEqual(spawnStub.callCount, 2); + assert.ok(mockState.set.calledOnceWithExactly(CONDA_PREFIXES_KEY, [envsDir])); + }); +});