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
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@
"--ui=tdd",
"--recursive",
"--colors",
//"--grep", "<suite name>",
"--grep", "Hatch Locator",
"--timeout=300000"
],
"outFiles": ["${workspaceFolder}/out/**/*.js", "!${workspaceFolder}/**/node_modules**/*"],
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,12 @@
"scope": "machine-overridable",
"type": "string"
},
"python.hatchPath": {
"default": "hatch",
"description": "%python.hatchPath.description%",
"scope": "machine-overridable",
"type": "string"
},
"python.tensorBoard.logDirectory": {
"default": "",
"description": "%python.tensorBoard.logDirectory.description%",
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"python.missingPackage.severity.description": "Set severity of missing packages in requirements.txt or pyproject.toml",
"python.pipenvPath.description": "Path to the pipenv executable to use for activation.",
"python.poetryPath.description": "Path to the poetry executable.",
"python.hatchPath.description": "Path to the Hatch executable.",
"python.EnableREPLSmartSend.description": "Toggle Smart Send for the Python REPL. Smart Send enables sending the smallest runnable block of code to the REPL on Shift+Enter and moves the cursor accordingly.",
"python.tensorBoard.logDirectory.description": "Set this setting to your preferred TensorBoard log directory to skip log directory prompt when starting TensorBoard.",
"python.tensorBoard.logDirectory.markdownDeprecationMessage": "Tensorboard support has been moved to the extension [Tensorboard extension](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.tensorboard). Instead use the setting `tensorBoard.logDirectory`.",
Expand Down
1 change: 1 addition & 0 deletions resources/report_issue_user_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"condaPath": "placeholder",
"pipenvPath": "placeholder",
"poetryPath": "placeholder",
"hatchPath": "placeholder",
"devOptions": false,
"globalModuleInstallation": false,
"languageServer": true,
Expand Down
4 changes: 4 additions & 0 deletions src/client/common/configSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export class PythonSettings implements IPythonSettings {

public poetryPath = '';

public hatchPath = '';

public devOptions: string[] = [];

public autoComplete!: IAutoCompleteSettings;
Expand Down Expand Up @@ -260,6 +262,8 @@ export class PythonSettings implements IPythonSettings {
this.pipenvPath = pipenvPath && pipenvPath.length > 0 ? getAbsolutePath(pipenvPath, workspaceRoot) : pipenvPath;
const poetryPath = systemVariables.resolveAny(pythonSettings.get<string>('poetryPath'))!;
this.poetryPath = poetryPath && poetryPath.length > 0 ? getAbsolutePath(poetryPath, workspaceRoot) : poetryPath;
const hatchPath = systemVariables.resolveAny(pythonSettings.get<string>('hatchPath'))!;
this.hatchPath = hatchPath && hatchPath.length > 0 ? getAbsolutePath(hatchPath, workspaceRoot) : hatchPath;

this.interpreter = pythonSettings.get<IInterpreterSettings>('interpreter') ?? {
infoVisibility: 'onPythonRelated',
Expand Down
1 change: 1 addition & 0 deletions src/client/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export interface IPythonSettings {
readonly condaPath: string;
readonly pipenvPath: string;
readonly poetryPath: string;
readonly hatchPath: string;
readonly devOptions: string[];
readonly testing: ITestingSettings;
readonly autoComplete: IAutoCompleteSettings;
Expand Down
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);
}
}
112 changes: 112 additions & 0 deletions src/client/pythonEnvironments/common/environmentManagers/hatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { isTestExecution } from '../../../common/constants';
import { exec, getPythonSetting, pathExists } 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.
*
* 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.

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] : []));
}
}
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
2 changes: 2 additions & 0 deletions src/test/common/configSettings/configSettings.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ suite('Python Settings', async () => {
'pipenvPath',
'envFile',
'poetryPath',
'hatchPath',
'defaultInterpreterPath',
]) {
config
Expand Down Expand Up @@ -141,6 +142,7 @@ suite('Python Settings', async () => {
'pipenvPath',
'envFile',
'poetryPath',
'hatchPath',
'defaultInterpreterPath',
].forEach(async (settingName) => {
testIfValueIsUpdated(settingName, 'stringValue');
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, { hatchPath: 'hatch', 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, { hatchPath: 'hatch', 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