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
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
57 changes: 57 additions & 0 deletions src/client/pythonEnvironments/base/locators/hatchLocator.ts
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);
}
}
135 changes: 135 additions & 0 deletions src/client/pythonEnvironments/common/environmentManagers/hatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import * as path from 'path';
import { isTestExecution } from '../../../common/constants';
import { exec, getPythonSetting, pathExists, pathExistsSync, readFileSync } from '../externalDependencies';
import { traceError, 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.
*/
public static async getHatch(cwd: string): Promise<Hatch | undefined> {
// Following check should be performed synchronously so we trigger hatch execution as soon as possible.
if (!hasValidHatchConfig(cwd)) {
// This check is not expensive and may change during a session, so we need not cache it.
return 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.

traceVerbose(`Getting hatch for cwd ${cwd}`);
// Produce a list of candidate binaries to be probed by exec'ing them.
function* getCandidates() {
try {
const customHatchPath = getPythonSetting<string>('hatchPath');
if (customHatchPath && customHatchPath !== 'hatch') {
// If user has specified a custom Hatch path, use it first.
yield customHatchPath;
}
} catch (ex) {
traceError(`Failed to get Hatch setting`, ex);
}
// Check unqualified filename, in case it's on PATH.
yield 'hatch';
}

// Probe the candidates, and pick the first one that exists and does what we need.
for (const hatchPath of getCandidates()) {
traceVerbose(`Probing Hatch binary for ${cwd}: ${hatchPath}`);
const hatch = new Hatch(hatchPath, cwd);
const virtualenvs = await hatch.getEnvList();
if (virtualenvs !== undefined) {
traceVerbose(`Found hatch via filesystem probing for ${cwd}: ${hatchPath}`);
return hatch;
}
traceVerbose(`Failed to find Hatch for ${cwd}: ${hatchPath}`);
}

// Didn't find anything.
traceVerbose(`No Hatch binary found for ${cwd}`);
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] : []));
}
}

/**
* Does best effort to verify whether a folder has been setup for hatch.
*
* @param dir Directory to look for pyproject.toml file in.
*/
function hasValidHatchConfig(dir: string): boolean {
if (pathExistsSync(path.join(dir, 'hatch.toml'))) {
return true;
}
const pyprojectToml = path.join(dir, 'pyproject.toml');
if (pathExistsSync(pyprojectToml)) {
const content = readFileSync(pyprojectToml);
if (/^\[?tool.hatch\b/.test(content)) {
return true;
}
}
return false;
}
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/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
Loading