Skip to content
Open
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
73 changes: 24 additions & 49 deletions src/managers/conda/condaUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CondaInfo> {
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<string[]> {
if (prefixes) {
if (prefixes?.length) {
return prefixes;
}

const state = await getWorkspacePersistentState();
const storedPrefixes = await state.get<string[]>(CONDA_PREFIXES_KEY);
if (storedPrefixes && Array.isArray(storedPrefixes)) {
if (Array.isArray(storedPrefixes) && storedPrefixes.length > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (Array.isArray(storedPrefixes) && storedPrefixes.length > 0) {
if (storedPrefixes?.length > 0) {

@jezdez jezdez Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't compile with strict TypeScript since storedPrefixes can still be undefined. I'd keep the current check.

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 = [];
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe normalize the path here since the tests are failing due to casing differences with the drive

@jezdez jezdez Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The drive letter changes in Uri.file(prefix), not here. I'll update the test to compare with Uri.file(envPrefix).fsPath.

}

export async function createNamedCondaEnvironment(
api: PythonEnvironmentApi,
log: LogOutputChannel,
Expand All @@ -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 {
Expand All @@ -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(
Expand Down
112 changes: 112 additions & 0 deletions src/test/managers/conda/condaUtils.createNamed.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
85 changes: 85 additions & 0 deletions src/test/managers/conda/condaUtils.getPrefixes.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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]));
});
});
Loading