Skip to content
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

Add support for Hatch environments #22779

Merged
merged 16 commits into from
Mar 15, 2024
Merged
5 changes: 4 additions & 1 deletion src/client/pythonEnvironments/base/info/envKind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function getKindDisplayName(kind: PythonEnvKind): string {
[PythonEnvKind.MicrosoftStore, 'Microsoft Store'],
[PythonEnvKind.Pyenv, 'pyenv'],
[PythonEnvKind.Poetry, 'Poetry'],
[PythonEnvKind.Hatch, 'Hatch'],
[PythonEnvKind.Custom, 'custom'],
// For now we treat OtherGlobal like Unknown.
[PythonEnvKind.Venv, 'venv'],
Expand All @@ -39,12 +40,13 @@ export function getKindDisplayName(kind: PythonEnvKind): string {
* Remarks: This is the order of detection based on how the various distributions and tools
* configure the environment, and the fall back for identification.
* Top level we have the following environment types, since they leave a unique signature
* in the environment or * use a unique path for the environments they create.
* in the environment or use a unique path for the environments they create.
* 1. Conda
* 2. Microsoft Store
* 3. PipEnv
* 4. Pyenv
* 5. Poetry
* 6. Hatch
*
* Next level we have the following virtual environment tools. The are here because they
* are consumed by the tools above, and can also be used independently.
Expand All @@ -61,6 +63,7 @@ export function getPrioritizedEnvKinds(): PythonEnvKind[] {
PythonEnvKind.MicrosoftStore,
PythonEnvKind.Pipenv,
PythonEnvKind.Poetry,
PythonEnvKind.Hatch,
PythonEnvKind.Venv,
PythonEnvKind.VirtualEnvWrapper,
PythonEnvKind.VirtualEnv,
Expand Down
2 changes: 2 additions & 0 deletions src/client/pythonEnvironments/base/info/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export enum PythonEnvKind {
MicrosoftStore = 'global-microsoft-store',
Pyenv = 'global-pyenv',
Poetry = 'poetry',
Hatch = 'hatch',
ActiveState = 'activestate',
Custom = 'global-custom',
OtherGlobal = 'global-other',
Expand Down Expand Up @@ -44,6 +45,7 @@ export interface EnvPathType {

export const virtualEnvKinds = [
PythonEnvKind.Poetry,
PythonEnvKind.Hatch,
PythonEnvKind.Pipenv,
PythonEnvKind.Venv,
PythonEnvKind.VirtualEnvWrapper,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use strict';

import { PythonEnvKind } from '../../info';
import { BasicEnvInfo, IPythonEnvsIterator } from '../../locator';
import { LazyResourceBasedLocator } from '../common/resourceBasedLocator';
import { Hatch } from '../../../common/environmentManagers/hatch';
import { asyncFilter } from '../../../../common/utils/arrayUtils';
import { pathExists } from '../../../common/externalDependencies';
import { traceError, traceVerbose } from '../../../../logging';
import { chain, iterable } from '../../../../common/utils/async';
import { getInterpreterPathFromDir } from '../../../common/commonUtils';

/**
* Gets all default virtual environment locations to look for in a workspace.
*/
async function getVirtualEnvDirs(root: string): Promise<string[]> {
const hatch = await Hatch.getHatch(root);
const envDirs = (await hatch?.getEnvList()) ?? [];
return asyncFilter(envDirs, pathExists);
}

/**
* Finds and resolves virtual environments created using Hatch.
*/
export class HatchLocator extends LazyResourceBasedLocator {
public readonly providerId: string = 'hatch';

public constructor(private readonly root: string) {
super();
}

protected doIterEnvs(): IPythonEnvsIterator<BasicEnvInfo> {
async function* iterator(root: string) {
const envDirs = await getVirtualEnvDirs(root);
const envGenerators = envDirs.map((envDir) => {
async function* generator() {
traceVerbose(`Searching for Hatch virtual envs in: ${envDir}`);
const filename = await getInterpreterPathFromDir(envDir);
if (filename !== undefined) {
try {
yield { executablePath: filename, kind: PythonEnvKind.Hatch };
karrtikr marked this conversation as resolved.
Show resolved Hide resolved
traceVerbose(`Hatch Virtual Environment: [added] ${filename}`);
} catch (ex) {
traceError(`Failed to process environment: ${filename}`, ex);
}
}
}
return generator();
});

yield* iterable(chain(envGenerators));
traceVerbose(`Finished searching for Hatch envs`);
}

return iterator(this.root);
}
}
93 changes: 93 additions & 0 deletions src/client/pythonEnvironments/common/environmentManagers/hatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { isTestExecution } from '../../../common/constants';
import { exec, pathExists } from '../externalDependencies';
import { traceVerbose } from '../../../logging';
import { cache } from '../../../common/utils/decorators';

/** Wraps the "Hatch" utility, and exposes its functionality.
*/
export class Hatch {
/**
* Locating Hatch binary can be expensive, since it potentially involves spawning or
* trying to spawn processes; so we only do it once per session.
*/
private static hatchPromise: Map<string, Promise<Hatch | undefined>> = new Map<
string,
Promise<Hatch | undefined>
>();

/**
* Creates a Hatch service corresponding to the corresponding "hatch" command.
*
* @param command - Command used to run hatch. This has the same meaning as the
* first argument of spawn() - i.e. it can be a full path, or just a binary name.
* @param cwd - The working directory to use as cwd when running hatch.
*/
constructor(public readonly command: string, private cwd: string) {}

/**
* Returns a Hatch instance corresponding to the binary which can be used to run commands for the cwd.
*
* Every directory is a valid Hatch project, so this should always return a Hatch instance.
*/
public static async getHatch(cwd: string): Promise<Hatch | undefined> {
if (Hatch.hatchPromise.get(cwd) === undefined || isTestExecution()) {
Hatch.hatchPromise.set(cwd, Hatch.locate(cwd));
}
return Hatch.hatchPromise.get(cwd);
}

private static async locate(cwd: string): Promise<Hatch | undefined> {
// First thing this method awaits on should be hatch command execution,
// hence perform all operations before that synchronously.
const hatchPath = 'hatch';
traceVerbose(`Probing Hatch binary ${hatchPath}`);
const hatch = new Hatch(hatchPath, cwd);
const virtualenvs = await hatch.getEnvList();
if (virtualenvs !== undefined) {
traceVerbose(`Found hatch binary ${hatchPath}`);
return hatch;
}
traceVerbose(`Failed to find Hatch binary ${hatchPath}`);

// Didn't find anything.
traceVerbose(`No Hatch binary found`);
return undefined;
}

/**
* Retrieves list of Python environments known to Hatch for this working directory.
* Returns `undefined` if we failed to spawn in some way.
*
* Corresponds to "hatch env show --json". Swallows errors if any.
*/
public async getEnvList(): Promise<string[] | undefined> {
return this.getEnvListCached(this.cwd);
}

/**
* Method created to facilitate caching. The caching decorator uses function arguments as cache key,
* so pass in cwd on which we need to cache.
*/
@cache(30_000, true, 10_000)
private async getEnvListCached(_cwd: string): Promise<string[] | undefined> {
const envInfoOutput = await exec(this.command, ['env', 'show', '--json'], {
cwd: this.cwd,
throwOnStdErr: true,
}).catch(traceVerbose);
if (!envInfoOutput) {
return undefined;
}
const envPaths = await Promise.all(
Object.keys(JSON.parse(envInfoOutput.stdout)).map(async (name) => {
const envPathOutput = await exec(this.command, ['env', 'find', name], {
cwd: this.cwd,
throwOnStdErr: true,
}).catch(traceVerbose);
if (!envPathOutput) return undefined;
const dir = envPathOutput.stdout.trim();
return (await pathExists(dir)) ? dir : undefined;
}),
);
return envPaths.flatMap((r) => (r ? [r] : []));
}
}
2 changes: 2 additions & 0 deletions src/client/pythonEnvironments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { MicrosoftStoreLocator } from './base/locators/lowLevel/microsoftStoreLo
import { getEnvironmentInfoService } from './base/info/environmentInfoService';
import { registerNewDiscoveryForIOC } from './legacyIOC';
import { PoetryLocator } from './base/locators/lowLevel/poetryLocator';
import { HatchLocator } from './base/locators/lowLevel/hatchLocator';
import { createPythonEnvironments } from './api';
import {
createCollectionCache as createCache,
Expand Down Expand Up @@ -186,6 +187,7 @@ function createWorkspaceLocator(ext: ExtensionState): WorkspaceLocators {
(root: vscode.Uri) => [
new WorkspaceVirtualEnvironmentLocator(root.fsPath),
new PoetryLocator(root.fsPath),
new HatchLocator(root.fsPath),
new CustomWorkspaceLocator(root.fsPath),
],
// Add an ILocator factory func here for each kind of workspace-rooted locator.
Expand Down
1 change: 1 addition & 0 deletions src/test/pythonEnvironments/base/info/envKind.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const KIND_NAMES: [PythonEnvKind, string][] = [
[PythonEnvKind.MicrosoftStore, 'winStore'],
[PythonEnvKind.Pyenv, 'pyenv'],
[PythonEnvKind.Poetry, 'poetry'],
[PythonEnvKind.Hatch, 'hatch'],
[PythonEnvKind.Custom, 'customGlobal'],
[PythonEnvKind.OtherGlobal, 'otherGlobal'],
[PythonEnvKind.Venv, 'venv'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import * as sinon from 'sinon';
import * as path from 'path';
import { PythonEnvKind } from '../../../../../client/pythonEnvironments/base/info';
import * as externalDependencies from '../../../../../client/pythonEnvironments/common/externalDependencies';
import * as platformUtils from '../../../../../client/common/utils/platform';
import { getEnvs } from '../../../../../client/pythonEnvironments/base/locatorUtils';
import { HatchLocator } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/hatchLocator';
import { assertBasicEnvsEqual } from '../envTestUtils';
import { createBasicEnv } from '../../common';
import { makeExecHandler, projectDirs, venvDirs } from '../../../common/environmentManagers/hatch.unit.test';

suite('Hatch Locator', () => {
let exec: sinon.SinonStub;
let getPythonSetting: sinon.SinonStub;
let getOSType: sinon.SinonStub;
let locator: HatchLocator;

suiteSetup(() => {
getPythonSetting = sinon.stub(externalDependencies, 'getPythonSetting');
getPythonSetting.returns('hatch');
getOSType = sinon.stub(platformUtils, 'getOSType');
exec = sinon.stub(externalDependencies, 'exec');
});

suiteTeardown(() => sinon.restore());

suite('iterEnvs()', () => {
setup(() => {
getOSType.returns(platformUtils.OSType.Linux);
flying-sheep marked this conversation as resolved.
Show resolved Hide resolved
});

interface TestArgs {
osType?: platformUtils.OSType;
pythonBin?: string;
}

const testProj1 = async ({ osType, pythonBin = 'bin/python' }: TestArgs = {}) => {
if (osType) {
getOSType.returns(osType);
}

locator = new HatchLocator(projectDirs.project1);
exec.callsFake(makeExecHandler(venvDirs.project1, { path: true, cwd: projectDirs.project1 }));

const iterator = locator.iterEnvs();
const actualEnvs = await getEnvs(iterator);

const expectedEnvs = [createBasicEnv(PythonEnvKind.Hatch, path.join(venvDirs.project1.default, pythonBin))];
assertBasicEnvsEqual(actualEnvs, expectedEnvs);
};

test('project with only the default env', () => testProj1());
test('project with only the default env on Windows', () =>
testProj1({
osType: platformUtils.OSType.Windows,
pythonBin: 'Scripts/python.exe',
}));

test('project with multiple defined envs', async () => {
locator = new HatchLocator(projectDirs.project2);
exec.callsFake(makeExecHandler(venvDirs.project2, { path: true, cwd: projectDirs.project2 }));

const iterator = locator.iterEnvs();
const actualEnvs = await getEnvs(iterator);

const expectedEnvs = [
createBasicEnv(PythonEnvKind.Hatch, path.join(venvDirs.project2.default, 'bin/python')),
createBasicEnv(PythonEnvKind.Hatch, path.join(venvDirs.project2.test, 'bin/python')),
];
assertBasicEnvsEqual(actualEnvs, expectedEnvs);
});
});
});
Loading
Loading