-
Notifications
You must be signed in to change notification settings - Fork 54
Fix conda named environment creation after conda 25.11 #1643
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The drive letter changes in |
||
| } | ||
|
|
||
| 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( | ||
|
|
||
| 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); | ||
| }); | ||
| }); | ||
| }); |
| 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])); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
storedPrefixescan still be undefined. I'd keep the current check.