Skip to content

Commit

Permalink
Add support for Hatch environments (#22779)
Browse files Browse the repository at this point in the history
Fixes #22810

TODO

- [x] check if it actually works already or if more things need to be
registered
- [x] add config val
- [x] add tests
  • Loading branch information
flying-sheep committed Mar 15, 2024
1 parent 028398e commit cd3ea27
Show file tree
Hide file tree
Showing 17 changed files with 353 additions and 1 deletion.
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 };
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 @@ -188,6 +189,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);
});

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

0 comments on commit cd3ea27

Please sign in to comment.