8000 Add support for Hatch environments (microsoft/vscode-python#22779) · posit-dev/positron@c03cd44 · GitHub
[go: up one dir, main page]

Skip to content

Commit c03cd44

Browse files
authored
Add support for Hatch environments (microsoft/vscode-python#22779)
Fixes microsoft/vscode-python#22810 TODO - [x] check if it actually works already or if more things need to be registered - [x] add config val - [x] add tests
1 parent 7e725ad commit c03cd44

File tree

17 files changed

+353
-1
lines changed

17 files changed

+353
-1
lines changed

extensions/positron-python/src/client/pythonEnvironments/base/info/envKind.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export function getKindDisplayName(kind: PythonEnvKind): string {
1515
[PythonEnvKind.MicrosoftStore, 'Microsoft Store'],
1616
[PythonEnvKind.Pyenv, 'pyenv'],
1717
[PythonEnvKind.Poetry, 'Poetry'],
18+
[PythonEnvKind.Hatch, 'Hatch'],
1819
[PythonEnvKind.Custom, 'custom'],
1920
// For now we treat OtherGlobal like Unknown.
2021
[PythonEnvKind.Venv, 'venv'],
@@ -39,12 +40,13 @@ export function getKindDisplayName(kind: PythonEnvKind): string {
3940
* Remarks: This is the order of detection based on how the various distributions and tools
4041
* configure the environment, and the fall back for identification.
4142
* Top level we have the following environment types, since they leave a unique signature
42-
* in the environment or * use a unique path for the environments they create.
43+
* in the environment or use a unique path for the environments they create.
4344
* 1. Conda
4445
* 2. Microsoft Store
4546
* 3. PipEnv
4647
* 4. Pyenv
4748
* 5. Poetry
49+
* 6. Hatch
4850
*
4951
* Next level we have the following virtual environment tools. The are here because they
5052
* are consumed by the tools above, and can also be used independently.
@@ -61,6 +63,7 @@ export function getPrioritizedEnvKinds(): PythonEnvKind[] {
6163
PythonEnvKind.MicrosoftStore,
6264
PythonEnvKind.Pipenv,
6365
PythonEnvKind.Poetry,
66+
PythonEnvKind.Hatch,
6467
PythonEnvKind.Venv,
6568
PythonEnvKind.VirtualEnvWrapper,
6669
PythonEnvKind.VirtualEnv,

extensions/positron-python/src/client/pythonEnvironments/base/info/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export enum PythonEnvKind {
1515
MicrosoftStore = 'global-microsoft-store',
1616
Pyenv = 'global-pyenv',
1717
Poetry = 'poetry',
18+
Hatch = 'hatch',
1819
ActiveState = 'activestate',
1920
Custom = 'global-custom',
2021
OtherGlobal = 'global-other',
@@ -44,6 +45,7 @@ export interface EnvPathType {
4445

4546
export const virtualEnvKinds = [
4647
PythonEnvKind.Poetry,
48+
PythonEnvKind.Hatch,
4749
PythonEnvKind.Pipenv,
4850
PythonEnvKind.Venv,
4951
PythonEnvKind.VirtualEnvWrapper,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
'use strict';
2+
3+
import { PythonEnvKind } from '../../info';
4+
import { BasicEnvInfo, IPythonEnvsIterator } from '../../locator';
5+
import { LazyResourceBasedLocator } from '../common/resourceBasedLocator';
6+
import { Hatch } from '../../../common/environmentManagers/hatch';
7+
import { asyncFilter } from '../../../../common/utils/arrayUtils';
8+
import { pathExists } from '../../../common/externalDependencies';
9+
import { traceError, traceVerbose } from '../../../../logging';
10+
import { chain, iterable } from '../../../../common/utils/async';
11+
import { getInterpreterPathFromDir } from '../../../common/commonUtils';
12+
13+
/**
14+
* Gets all default virtual environment locations to look for in a workspace.
15+
*/
16+
async function getVirtualEnvDirs(root: string): Promise<string[]> {
17+
const hatch = await Hatch.getHatch(root);
18+
const envDirs = (await hatch?.getEnvList()) ?? [];
19+
return asyncFilter(envDirs, pathExists);
20+
}
21+
22+
/**
23+
* Finds and resolves virtual environments created using Hatch.
24+
*/
25+
export class HatchLocator extends LazyResourceBasedLocator {
26+
public readonly providerId: string = 'hatch';
27+
28+
public constructor(private readonly root: string) {
29+
super();
30+
}
31+
32+
protected doIterEnvs(): IPythonEnvsIterator<BasicEnvInfo> {
33+
async function* iterator(root: string) {
34+
const envDirs = await getVirtualEnvDirs(root);
35+
const envGenerators = envDirs.map((envDir) => {
36+
async function* generator() {
37+
traceVerbose(`Searching for Hatch virtual envs in: ${envDir}`);
38+
const filename = await getInterpreterPathFromDir(envDir);
39+
if (filename !== undefined) {
40+
try {
41+
yield { executablePath: filename, kind: PythonEnvKind.Hatch };
42+
traceVerbose(`Hatch Virtual Environment: [added] ${filename}`);
43+
} catch (ex) {
44+
traceError(`Failed to process environment: ${filename}`, ex);
45+
}
46+
}
47+
}
48+
return generator();
49+
});
50+
51+
yield* iterable(chain(envGenerators));
52+
traceVerbose(`Finished searching for Hatch envs`);
53+
}
54+
55+
return iterator(this.root);
56+
}
57+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { isTestExecution } from '../../../common/constants';
2+
import { exec, pathExists } from '../externalDependencies';
3+
import { traceVerbose } from '../../../logging';
4+
import { cache } from '../../../common/utils/decorators';
5+
6+
/** Wraps the "Hatch" utility, and exposes its functionality.
7+
*/
8+
export class Hatch {
9+
/**
10+
* Locating Hatch binary can be expensive, since it potentially involves spawning or
11+
* trying to spawn processes; so we only do it once per session.
12+
*/
13+
private static hatchPromise: Map<string, Promise<Hatch | undefined>> = new Map<
14+
string,
15+
Promise<Hatch | undefined>
16+
>();
17+
18+
/**
19+
* Creates a Hatch service corresponding to the corresponding "hatch" command.
20+
*
21+
* @param command - Command used to run hatch. This has the same meaning as the
22+
* first argument of spawn() - i.e. it can be a full path, or just a binary name.
23+
* @param cwd - The working directory to use as cwd when running hatch.
24+
*/
25+
constructor(public readonly command: string, private cwd: string) {}
26+
27+
/**
28+
* Returns a Hatch instance corresponding to the binary which can be used to run commands for the cwd.
29+
*
30+
* Every directory is a valid Hatch project, so this should always return a Hatch instance.
31+
*/
32+
public static async getHatch(cwd: string): Promise<Hatch | undefined> {
33+
if (Hatch.hatchPromise.get(cwd) === undefined || isTestExecution()) {
34+
Hatch.hatchPromise.set(cwd, Hatch.locate(cwd));
35+
}
36+
return Hatch.hatchPromise.get(cwd);
37+
}
38+
39+
private static async locate(cwd: string): Promise<Hatch | undefined> {
40+
// First thing this method awaits on should be hatch command execution,
41+
// hence perform all operations before that synchronously.
42+
const hatchPath = 'hatch';
43+
traceVerbose(`Probing Hatch binary ${hatchPath}`);
44+
const hatch = new Hatch(hatchPath, cwd);
45+
const virtualenvs = await hatch.getEnvList();
46+
if (virtualenvs !== undefined) {
47+
traceVerbose(`Found hatch binary ${hatchPath}`);
48+
return hatch;
49+
}
50+
traceVerbose(`Failed to find Hatch binary ${hatchPath}`);
51+
52+
// Didn't find anything.
53+
traceVerbose(`No Hatch binary found`);
54+
return undefined;
55+
}
56+
57+
/**
58+
* Retrieves list of Python environments known to Hatch for this working directory.
59+
* Returns `undefined` if we failed to spawn in some way.
60+
*
61+
* Corresponds to "hatch env show --json". Swallows errors if any.
62+
*/
63+
public async getEnvList(): Promise<string[] | undefined> {
64+
return this.getEnvListCached(this.cwd);
65+
}
66+
67+
/**
68+
* Method created to facilitate caching. The caching decorator uses function arguments as cache key,
69+
* so pass in cwd on which we need to cache.
70+
*/
71+
@cache(30_000, true, 10_000)
72+
private async getEnvListCached(_cwd: string): Promise<string[] | undefined> {
73+
const envInfoOutput = await exec(this.command, ['env', 'show', '--json'], {
74+
cwd: this.cwd,
75+
throwOnStdErr: true,
76+
}).catch(traceVerbose);
77+
if (!envInfoOutput) {
78+
return undefined;
79+
}
80+
const envPaths = await Promise.all(
81+
Object.keys(JSON.parse(envInfoOutput.stdout)).map(async (name) => {
82+
const envPathOutput = await exec(this.command, ['env', 'find', name], {
83+
cwd: this.cwd,
84+
throwOnStdErr: true,
85+
}).catch(traceVerbose);
86+
if (!envPathOutput) return undefined;
87+
const dir = envPathOutput.stdout.trim();
88+
return (await pathExists(dir)) ? dir : undefined;
89+
}),
90+
);
91+
return envPaths.flatMap((r) => (r ? [r] : []));
92+
}
93+
}

extensions/positron-python/src/client/pythonEnvironments/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { MicrosoftStoreLocator } from './base/locators/lowLevel/microsoftStoreLo
2828
import { getEnvironmentInfoService } from './base/info/environmentInfoService';
2929
import { registerNewDiscoveryForIOC } from './legacyIOC';
3030
import { PoetryLocator } from './base/locators/lowLevel/poetryLocator';
31+
import { HatchLocator } from './base/locators/lowLevel/hatchLocator';
3132
import { createPythonEnvironments } from './api';
3233
import {
3334
createCollectionCache as createCache,
@@ -188,6 +189,7 @@ function createWorkspaceLocator(ext: ExtensionState): WorkspaceLocators {
188189
(root: vscode.Uri) => [
189190
new WorkspaceVirtualEnvironmentLocator(root.fsPath),
190191
new PoetryLocator(root.fsPath),
192+
new HatchLocator(root.fsPath),
191193
new CustomWorkspaceLocator(root.fsPath),
192194
],
193195
// Add an ILocator factory func here for each kind of workspace-rooted locator.

extensions/positron-python/src/test/pythonEnvironments/base/info/envKind.unit.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const KIND_NAMES: [PythonEnvKind, string][] = [
1313
[PythonEnvKind.MicrosoftStore, 'winStore'],
1414
[PythonEnvKind.Pyenv, 'pyenv'],
1515
[PythonEnvKind.Poetry, 'poetry'],
16+
[PythonEnvKind.Hatch, 'hatch'],
1617
[PythonEnvKind.Custom, 'customGlobal'],
1718
[PythonEnvKind.OtherGlobal, 'otherGlobal'],
1819
[PythonEnvKind.Venv, 'venv'],
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as sinon from 'sinon';
5+
import * as path from 'path';
6+
import { PythonEnvKind } from '../../../../../client/pythonEnvironments/base/info';
7+
import * as externalDependencies from '../../../../../client/pythonEnvironments/common/externalDependencies';
8+
import * as platformUtils from '../../../../../client/common/utils/platform';
9+
import { getEnvs } from '../../../../../client/pythonEnvironments/base/locatorUtils';
10+
import { HatchLocator } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/hatchLocator';
11+
import { assertBasicEnvsEqual } from '../envTestUtils';
12+
import { createBasicEnv } from '../../common';
13+
import { makeExecHandler, projectDirs, venvDirs } from '../../../common/environmentManagers/hatch.unit.test';
14+
15+
suite('Hatch Locator', () => {
16+
let exec: sinon.SinonStub;
17+
let getPythonSetting: sinon.SinonStub;
18+
let getOSType: sinon.SinonStub;
19+
let locator: HatchLocator;
20+
21+
suiteSetup(() => {
22+
getPythonSetting = sinon.stub(externalDependencies, 'getPythonSetting');
23+
getPythonSetting.returns('hatch');
24+
getOSType = sinon.stub(platformUtils, 'getOSType');
25+
exec = sinon.stub(externalDependencies, 'exec');
26+
});
27+
28+
suiteTeardown(() => sinon.restore());
29+
30+
suite('iterEnvs()', () => {
31+
setup(() => {
32+
getOSType.returns(platformUtils.OSType.Linux);
33+
});
34+
35+
interface TestArgs {
36+
osType?: platformUtils.OSType;
37+
pythonBin?: string;
38+
}
39+
40+
const testProj1 = async ({ osType, pythonBin = 'bin/python' }: TestArgs = {}) => {
41+
if (osType) {
42+
getOSType.returns(osType);
43+
}
44+
45+
locator = new HatchLocator(projectDirs.project1);
46+
exec.callsFake(makeExecHandler(venvDirs.project1, { path: true, cwd: projectDirs.project1 }));
47+
48+
const iterator = locator.iterEnvs();
49+
const actualEnvs = await getEnvs(iterator);
50+
51+
const expectedEnvs = [createBasicEnv(PythonEnvKind.Hatch, path.join(venvDirs.project1.default, pythonBin))];
52+
assertBasicEnvsEqual(actualEnvs, expectedEnvs);
53+
};
54+
55+
test('project with only the default env', () => testProj1());
56+
test('project with only the default env on Windows', () =>
57+
testProj1({
58+
osType: platformUtils.OSType.Windows,
59+
pythonBin: 'Scripts/python.exe',
60+
}));
61+
62+
test('project with multiple defined envs', async () => {
63+
locator = new HatchLocator(projectDirs.project2);
64+
exec.callsFake(makeExecHandler(venvDirs.project2, { path: true, cwd: projectDirs.project2 }));
65+
66+
const iterator = locator.iterEnvs();
67+
const actualEnvs = await getEnvs(iterator);
68+
69+
const expectedEnvs = [
70+
createBasicEnv(PythonEnvKind.Hatch, path.join(venvDirs.project2.default, 'bin/python')),
71+
createBasicEnv(PythonEnvKind.Hatch, path.join(venvDirs.project2.test, 'bin/python')),
72+
];
73+
assertBasicEnvsEqual(actualEnvs, expectedEnvs);
74+
});
75+
});
76+
});

0 commit comments

Comments
 (0)
0