Skip to content

Commit

Permalink
add Project Wizard related Python extension tests (#4540)
Browse files Browse the repository at this point in the history
### Description

- Addresses: #3433

#### 🆕 Test Changes
- `extensions/positron-python/src/test/positron/manager.unit.test.ts`
    - added a test to validate a metadata fragment
    - updated an existing test to check the result of
-
`extensions/positron-python/src/test/positron/createEnvApi.unit.test.ts`
    - create new tests for the `createEnvironmentAndRegister` api
-
`extensions/positron-python/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts`
- added a test to create a conda env with the code path used by the
project wizard
-
`extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts`
- added a test to create a venv with the code path used by the project
wizard

#### Code Changes
- moved the Positron createEnvironment command implementations to
separate functions in
`extensions/positron-python/src/client/positron/createEnvApi.ts` to make
them more easily testable (without having to call the commands)
-
`extensions/positron-python/src/client/pythonEnvironments/creation/proposed.createEnvApis.ts`
    - added/fixed types for createEnvironment options and return type

### QA Notes

- python extension unit tests should pass
- project wizard smoke tests in particular should continue to pass

---------

Signed-off-by: sharon <[email protected]>
Co-authored-by: Wasim Lorgat <[email protected]>
  • Loading branch information
sharon-wang and seeM authored Sep 5, 2024
1 parent 3bacb87 commit a88819e
Show file tree
Hide file tree
Showing 7 changed files with 371 additions and 23 deletions.
72 changes: 72 additions & 0 deletions extensions/positron-python/src/client/positron/createEnvApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2024 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

// eslint-disable-next-line import/no-unresolved
import * as positron from 'positron';
import { CreateEnvironmentOptionsInternal } from '../pythonEnvironments/creation/types';
import {
CreateEnvironmentOptions,
CreateEnvironmentProvider,
CreateEnvironmentResult,
} from '../pythonEnvironments/creation/proposed.createEnvApis';
import { handleCreateEnvironmentCommand } from '../pythonEnvironments/creation/createEnvironment';
import { IPythonRuntimeManager } from './manager';

/**
* A simplified version of an environment provider that can be used in the Positron Project Wizard
*/
interface WizardEnvironmentProviders {
id: string;
name: string;
description: string;
}

/**
* Result of creating a Python environment and registering it with the language runtime manager.
*/
type CreateEnvironmentAndRegisterResult = CreateEnvironmentResult & { metadata?: positron.LanguageRuntimeMetadata };

/**
* Get the list of providers that can be used in the Positron Project Wizard
* @param providers The available environment creation providers
* @returns A list of providers that can be used in the Positron Project Wizard
*/
export async function getCreateEnvironmentProviders(
providers: readonly CreateEnvironmentProvider[],
): Promise<WizardEnvironmentProviders[]> {
const providersForWizard = providers.map((provider) => ({
id: provider.id,
name: provider.name,
description: provider.description,
}));
return providersForWizard;
}

/**
* Create an environment and register it with the Python runtime manager
* @param providers The available environment creation providers
* @param pythonRuntimeManager The manager for the Python runtimes
* @param options Options for creating the environment
* @returns The result of creating the environment and registering it, including the metadata for the environment
*/
export async function createEnvironmentAndRegister(
providers: readonly CreateEnvironmentProvider[],
pythonRuntimeManager: IPythonRuntimeManager,
options: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal,
): Promise<CreateEnvironmentAndRegisterResult | undefined> {
if (!options.providerId || (!options.interpreterPath && !options.condaPythonVersion)) {
return {
error: new Error(
'Missing required options for creating an environment. Please specify a provider ID and a Python interpreter path or a Conda Python version.',
),
};
}
const result = await handleCreateEnvironmentCommand(providers, options);
if (result?.path) {
const metadata = await pythonRuntimeManager.registerLanguageRuntimeFromPath(result.path);
return { ...result, metadata };
}
return result;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

// --- Start Positron ---
// eslint-disable-next-line import/no-unresolved
import { LanguageRuntimeMetadata } from 'positron';
// --- End Positron ---

import { ConfigurationTarget, Disposable } from 'vscode';
import { Commands } from '../../common/constants';
import { IDisposableRegistry, IInterpreterPathService, IPathUtils } from '../../common/types';
Expand All @@ -31,6 +26,7 @@ import { CreateEnvironmentOptionsInternal } from './types';
import { getCondaPythonVersions } from './provider/condaUtils';
import { IPythonRuntimeManager } from '../../positron/manager';
import { Conda } from '../common/environmentManagers/conda';
import { createEnvironmentAndRegister, getCreateEnvironmentProviders } from '../../positron/createEnvApi';
// --- End Positron ---

class CreateEnvironmentProviders {
Expand Down Expand Up @@ -96,25 +92,13 @@ export function registerCreateEnvironmentFeatures(
// --- Start Positron ---
registerCommand(Commands.Get_Create_Environment_Providers, () => {
const providers = _createEnvironmentProviders.getAll();
const providersForWizard = providers.map((provider) => ({
id: provider.id,
name: provider.name,
description: provider.description,
}));
return providersForWizard;
return getCreateEnvironmentProviders(providers);
}),
registerCommand(
Commands.Create_Environment_And_Register,
async (
options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal,
): Promise<(CreateEnvironmentResult & { metadata?: LanguageRuntimeMetadata }) | undefined> => {
(options: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal) => {
const providers = _createEnvironmentProviders.getAll();
const result = await handleCreateEnvironmentCommand(providers, options);
if (result?.path) {
const metadata = await pythonRuntimeManager.registerLanguageRuntimeFromPath(result.path);
return { ...result, metadata };
}
return result;
return createEnvironmentAndRegister(providers, pythonRuntimeManager, options);
},
),
registerCommand(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

import { Event, Disposable, WorkspaceFolder } from 'vscode';
import { EnvironmentTools } from '../../api/types';
// --- Start Positron ---
import { CreateEnvironmentOptionsInternal } from './types';
// --- End Positron ---

export type CreateEnvironmentUserActions = 'Back' | 'Cancel';
export type EnvironmentProviderId = string;
Expand Down Expand Up @@ -128,12 +131,18 @@ export interface CreateEnvironmentProvider {
* user wants. This API is expected to show a QuickPick or QuickInput to get the user input and return
* the path to the Python executable in the environment.
*
* @param {CreateEnvironmentOptions} [options] Options used to create a Python environment.
* // --- Start Positron ---
* @param {CreateEnvironmentOptions & CreateEnvironmentOptionsInternal} [options] Options used to create a Python environment.
* // --- End Positron ---
*
* @returns a promise that resolves to the path to the
* Python executable in the environment. Or any action taken by the user, such as back or cancel.
*/
createEnvironment(options?: CreateEnvironmentOptions): Promise<CreateEnvironmentResult | undefined>;
// --- Start Positron ---
createEnvironment(
options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal,
): Promise<CreateEnvironmentResult | undefined>;
// --- End Positron ---

/**
* Unique ID for the creation provider, typically <ExtensionId>:<environment-type | guid>
Expand Down
133 changes: 133 additions & 0 deletions extensions/positron-python/src/test/positron/createEnvApi.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import * as chaiAsPromised from 'chai-as-promised';
import * as sinon from 'sinon';
import * as typemoq from 'typemoq';
import { assert, use as chaiUse } from 'chai';
import { Uri } from 'vscode';
// eslint-disable-next-line import/no-unresolved
import * as positron from 'positron';
import * as path from 'path';
import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../constants';
import * as commandApis from '../../client/common/vscodeApis/commandApis';
import * as createEnvironmentApis from '../../client/pythonEnvironments/creation/createEnvironment';
import { IDisposableRegistry, IInterpreterPathService, IPathUtils } from '../../client/common/types';
import { registerCreateEnvironmentFeatures } from '../../client/pythonEnvironments/creation/createEnvApi';
import {
CreateEnvironmentOptions,
CreateEnvironmentProvider,
} from '../../client/pythonEnvironments/creation/proposed.createEnvApis';
import { CreateEnvironmentOptionsInternal } from '../../client/pythonEnvironments/creation/types';
import { IPythonRuntimeManager } from '../../client/positron/manager';
import { IInterpreterQuickPick } from '../../client/interpreter/configuration/types';
import { createEnvironmentAndRegister } from '../../client/positron/createEnvApi';

chaiUse(chaiAsPromised);

suite('Positron Create Environment APIs', () => {
let registerCommandStub: sinon.SinonStub;
let handleCreateEnvironmentCommandStub: sinon.SinonStub;

const disposables: IDisposableRegistry = [];
const mockProvider = typemoq.Mock.ofType<CreateEnvironmentProvider>();
const mockProviders = [mockProvider.object];

let pythonRuntimeManager: typemoq.IMock<IPythonRuntimeManager>;
let pathUtils: typemoq.IMock<IPathUtils>;
let interpreterQuickPick: typemoq.IMock<IInterpreterQuickPick>;
let interpreterPathService: typemoq.IMock<IInterpreterPathService>;

// Test workspace
const workspace1 = {
uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')),
name: 'workspace1',
index: 0,
};

// Environment options
const envOptions: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal = {
providerId: 'envProvider-id',
interpreterPath: '/path/to/venv/python',
workspaceFolder: workspace1,
};
const envOptionsWithInfo = {
withInterpreterPath: { ...envOptions },
withCondaPythonVersion: { ...envOptions, interpreterPath: undefined, condaPythonVersion: '3.12' },
};
const envOptionsMissingInfo = {
noProviderId: { ...envOptions, providerId: undefined },
noPythonSpecified: { ...envOptions, interpreterPath: undefined, condaPythonVersion: undefined },
};

setup(() => {
registerCommandStub = sinon.stub(commandApis, 'registerCommand');
handleCreateEnvironmentCommandStub = sinon.stub(createEnvironmentApis, 'handleCreateEnvironmentCommand');

pythonRuntimeManager = typemoq.Mock.ofType<IPythonRuntimeManager>();
pathUtils = typemoq.Mock.ofType<IPathUtils>();
interpreterQuickPick = typemoq.Mock.ofType<IInterpreterQuickPick>();
interpreterPathService = typemoq.Mock.ofType<IInterpreterPathService>();

registerCommandStub.callsFake((_command: string, _callback: (...args: any[]) => any) => ({
dispose: () => {
// Do nothing
},
}));
pathUtils.setup((p) => p.getDisplayName(typemoq.It.isAny())).returns(() => 'test');

registerCreateEnvironmentFeatures(
disposables,
interpreterQuickPick.object,
interpreterPathService.object,
pathUtils.object,
pythonRuntimeManager.object,
);
});

teardown(() => {
disposables.forEach((d) => d.dispose());
sinon.restore();
});

Object.entries(envOptionsWithInfo).forEach(([optionsName, options]) => {
test(`Environment creation succeeds when required options specified: ${optionsName}`, async () => {
const resultPath = '/path/to/created/env';
pythonRuntimeManager
.setup((p) => p.registerLanguageRuntimeFromPath(resultPath))
.returns(() => Promise.resolve(typemoq.Mock.ofType<positron.LanguageRuntimeMetadata>().object))
.verifiable(typemoq.Times.once());
handleCreateEnvironmentCommandStub.returns(Promise.resolve({ path: resultPath }));

const result = await createEnvironmentAndRegister(mockProviders, pythonRuntimeManager.object, options);

assert.isDefined(result);
assert.isDefined(result?.path);
assert.isDefined(result?.metadata);
assert.isUndefined(result?.error);
assert.isTrue(handleCreateEnvironmentCommandStub.calledOnce);
pythonRuntimeManager.verifyAll();
});
});

Object.entries(envOptionsMissingInfo).forEach(([optionsName, options]) => {
test(`Environment creation fails when options are missing: ${optionsName} `, async () => {
pythonRuntimeManager
.setup((p) => p.registerLanguageRuntimeFromPath(typemoq.It.isAny()))
.returns(() => Promise.resolve(typemoq.Mock.ofType<positron.LanguageRuntimeMetadata>().object))
.verifiable(typemoq.Times.never());

const result = await createEnvironmentAndRegister(mockProviders, pythonRuntimeManager.object, options);

assert.isDefined(result);
assert.isUndefined(result?.path);
assert.isUndefined(result?.metadata);
assert.isDefined(result?.error);
assert.isTrue(handleCreateEnvironmentCommandStub.notCalled);
pythonRuntimeManager.verifyAll();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,22 @@ suite('Python runtime manager', () => {
assert.deepStrictEqual(validated, runtimeMetadata.object);
});

test('validateMetadata: returns the full metadata when a metadata fragment is provided', async () => {
// Set the full runtime metadata in the manager.
pythonRuntimeManager.registeredPythonRuntimes.set(pythonPath, runtimeMetadata.object);

// Create a metadata fragment (only contains extra data python path).
const runtimeMetadataFragment = TypeMoq.Mock.ofType<positron.LanguageRuntimeMetadata>();
runtimeMetadataFragment.setup((r) => r.extraRuntimeData).returns(() => ({ pythonPath }));

// Override the pathExists stub to return true and validate the metadata.
sinon.stub(fs, 'pathExists').resolves(true);
const validated = await pythonRuntimeManager.validateMetadata(runtimeMetadataFragment.object);

// The validated metadata should be the full metadata.
assert.deepStrictEqual(validated, runtimeMetadata.object);
});

test('validateMetadata: throws if extra data is missing', async () => {
const invalidRuntimeMetadata = TypeMoq.Mock.ofType<positron.LanguageRuntimeMetadata>();
assert.rejects(() => pythonRuntimeManager.validateMetadata(invalidRuntimeMetadata.object));
Expand All @@ -141,7 +157,8 @@ suite('Python runtime manager', () => {
.resolves(runtimeMetadata.object);

await assertRegisterLanguageRuntime(async () => {
await pythonRuntimeManager.registerLanguageRuntimeFromPath(pythonPath);
const registeredRuntime = await pythonRuntimeManager.registerLanguageRuntimeFromPath(pythonPath);
assert.equal(registeredRuntime?.extraRuntimeData.pythonPath, pythonPath);
});

sinon.assert.calledOnceWithExactly(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,4 +277,72 @@ suite('Conda Creation provider tests', () => {

assert.deepStrictEqual(result, { path: 'existing_environment', workspaceFolder: workspace1 });
});

// --- Start Positron ---
test('Create conda environment with options and pre-selected python version', async () => {
getCondaBaseEnvStub.resolves('/usr/bin/conda');
const newProjectWorkspace = {
uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'newProjectWorkspace')),
name: 'newProjectWorkspace',
index: 0,
};
pickWorkspaceFolderStub.resolves(newProjectWorkspace);
pickPythonVersionStub.resolves('3.12');

const deferred = createDeferred();
let _next: undefined | ((value: Output<string>) => void);
let _complete: undefined | (() => void);
execObservableStub.callsFake(() => {
deferred.resolve();
return {
proc: {
exitCode: 0,
},
out: {
subscribe: (
next?: (value: Output<string>) => void,
_error?: (error: unknown) => void,
complete?: () => void,
) => {
_next = next;
_complete = complete;
},
},
dispose: () => undefined,
};
});

progressMock.setup((p) => p.report({ message: CreateEnv.statusStarting })).verifiable(typemoq.Times.once());

withProgressStub.callsFake(
(
_options: ProgressOptions,
task: (
progress: CreateEnvironmentProgress,
token?: CancellationToken,
) => Thenable<CreateEnvironmentResult>,
) => task(progressMock.object),
);

// Options for createEnvironment (based on what we send via positronNewProjectService)
const options = {
workspaceFolder: newProjectWorkspace,
condaPythonVersion: '3.12',
};

const promise = condaProvider.createEnvironment(options);
await deferred.promise;
assert.isDefined(_next);
assert.isDefined(_complete);

_next!({ out: `${CONDA_ENV_CREATED_MARKER}new_environment`, source: 'stdout' });
_complete!();
assert.deepStrictEqual(await promise, {
path: 'new_environment',
workspaceFolder: newProjectWorkspace,
});
assert.isTrue(showErrorMessageWithLogsStub.notCalled);
assert.isTrue(pickExistingCondaActionStub.calledOnce);
});
// --- End Positron ---
});
Loading

0 comments on commit a88819e

Please sign in to comment.