Skip to content

Commit a30900f

Browse files
author
Kartik Raj
authored
Make poetry discovery faster (#15860)
* Add caching to command output in discovery component * Add doc comments * Fix tests * Cache for lesser expiry duration after startup * Get python executables using `getInterpreterPathFromDir` instead of finding interpreters in all directories * Update doc comment
1 parent 848bf2c commit a30900f

File tree

6 files changed

+96
-50
lines changed

6 files changed

+96
-50
lines changed

src/client/common/utils/decorators.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { traceError, traceVerbose } from '../logger';
44
import { createDeferred, Deferred } from './async';
55
import { getCacheKeyFromFunctionArgs, getGlobalCacheStore } from './cacheUtils';
66
import { TraceInfo, tracing } from './misc';
7+
import { StopWatch } from './stopWatch';
78

89
const _debounce = require('lodash/debounce') as typeof import('lodash/debounce');
910

@@ -121,7 +122,25 @@ export function makeDebounceAsyncDecorator(wait?: number) {
121122

122123
type PromiseFunctionWithAnyArgs = (...any: any) => Promise<any>;
123124
const cacheStoreForMethods = getGlobalCacheStore();
124-
export function cache(expiryDurationMs: number) {
125+
126+
/**
127+
* Extension start up time is considered the duration until extension is likely to keep running commands in background.
128+
* It is observed on CI it can take upto 3 minutes, so this is an intelligent guess.
129+
*/
130+
const extensionStartUpTime = 200_000;
131+
/**
132+
* Tracks the time since the module was loaded. For caching purposes, we consider this time to approximately signify
133+
* how long extension has been active.
134+
*/
135+
const moduleLoadWatch = new StopWatch();
136+
/**
137+
* Caches function value until a specific duration.
138+
* @param expiryDurationMs Duration to cache the result for. If set as '-1', the cache will never expire for the session.
139+
* @param cachePromise If true, cache the promise instead of the promise result.
140+
* @param expiryDurationAfterStartUpMs If specified, this is the duration to cache the result for after extension startup (until extension is likely to
141+
* keep running commands in background)
142+
*/
143+
export function cache(expiryDurationMs: number, cachePromise = false, expiryDurationAfterStartUpMs?: number) {
125144
return function (
126145
target: Object,
127146
propertyName: string,
@@ -136,16 +155,22 @@ export function cache(expiryDurationMs: number) {
136155
}
137156
const key = getCacheKeyFromFunctionArgs(keyPrefix, args);
138157
const cachedItem = cacheStoreForMethods.get(key);
139-
if (cachedItem && cachedItem.expiry > Date.now()) {
158+
if (cachedItem && (cachedItem.expiry > Date.now() || expiryDurationMs === -1)) {
140159
traceVerbose(`Cached data exists ${key}`);
141160
return Promise.resolve(cachedItem.data);
142161
}
162+
const expiryMs =
163+
expiryDurationAfterStartUpMs && moduleLoadWatch.elapsedTime > extensionStartUpTime
164+
? expiryDurationAfterStartUpMs
165+
: expiryDurationMs;
143166
const promise = originalMethod.apply(this, args) as Promise<any>;
144-
promise
145-
.then((result) =>
146-
cacheStoreForMethods.set(key, { data: result, expiry: Date.now() + expiryDurationMs }),
147-
)
148-
.ignoreErrors();
167+
if (cachePromise) {
168+
cacheStoreForMethods.set(key, { data: promise, expiry: Date.now() + expiryMs });
169+
} else {
170+
promise
171+
.then((result) => cacheStoreForMethods.set(key, { data: result, expiry: Date.now() + expiryMs }))
172+
.ignoreErrors();
173+
}
149174
return promise;
150175
};
151176
};

src/client/pythonEnvironments/common/externalDependencies.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,18 @@ export function pathExists(absPath: string): Promise<boolean> {
6161
return fsapi.pathExists(absPath);
6262
}
6363

64+
export function pathExistsSync(absPath: string): boolean {
65+
return fsapi.pathExistsSync(absPath);
66+
}
67+
6468
export function readFile(filePath: string): Promise<string> {
6569
return fsapi.readFile(filePath, 'utf-8');
6670
}
6771

72+
export function readFileSync(filePath: string): string {
73+
return fsapi.readFileSync(filePath, 'utf-8');
74+
}
75+
6876
/**
6977
* Returns true if given file path exists within the given parent directory, false otherwise.
7078
* @param filePath File path to check for

src/client/pythonEnvironments/discovery/locators/services/conda.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { parseVersion } from '../../../base/info/pythonVersion';
1010
import { getRegistryInterpreters } from '../../../common/windowsUtils';
1111
import { EnvironmentType, PythonEnvironment } from '../../../info';
1212
import { IDisposable } from '../../../../common/types';
13+
import { cache } from '../../../../common/utils/decorators';
1314

1415
export const AnacondaCompanyNames = ['Anaconda, Inc.', 'Continuum Analytics, Inc.'];
1516

@@ -347,6 +348,7 @@ export class Conda {
347348
* Retrieves list of Python environments known to this conda.
348349
* Corresponds to "conda env list --json", but also computes environment names.
349350
*/
351+
@cache(30_000, true, 10_000)
350352
public async getEnvList(): Promise<CondaEnvInfo[]> {
351353
const info = await this.getInfo();
352354
const { envs } = info;

src/client/pythonEnvironments/discovery/locators/services/poetry.ts

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import { getOSType, getUserHomeDir, OSType } from '../../../../common/utils/plat
99
import {
1010
getPythonSetting,
1111
isParentPath,
12-
pathExists,
13-
readFile,
12+
pathExistsSync,
13+
readFileSync,
1414
shellExecute,
1515
} from '../../../common/externalDependencies';
1616
import { getEnvironmentDirFromPath } from '../../../common/commonUtils';
1717
import { isVirtualenvEnvironment } from './virtualEnvironmentIdentifier';
1818
import { StopWatch } from '../../../../common/utils/stopWatch';
19+
import { cache } from '../../../../common/utils/decorators';
1920

2021
/**
2122
* Global virtual env dir for a project is named as:
@@ -59,7 +60,7 @@ async function isLocalPoetryEnvironment(interpreterPath: string): Promise<boolea
5960
return false;
6061
}
6162
const project = path.dirname(envDir);
62-
if (!(await hasValidPyprojectToml(project))) {
63+
if (!hasValidPyprojectToml(project)) {
6364
return false;
6465
}
6566
// The assumption is that we need to be able to run poetry CLI for an environment in order to mark it as poetry.
@@ -111,8 +112,16 @@ export class Poetry {
111112
this.fixCwd();
112113
}
113114

115+
/**
116+
* Returns a Poetry instance corresponding to the binary which can be used to run commands for the cwd.
117+
*
118+
* Poetry commands can be slow and so can be bottleneck to overall discovery time. So trigger command
119+
* execution as soon as possible. To do that we need to ensure the operations before the command are
120+
* performed synchronously.
121+
*/
114122
public static async getPoetry(cwd: string): Promise<Poetry | undefined> {
115-
if (!(await hasValidPyprojectToml(cwd))) {
123+
// Following check should be performed synchronously so we trigger poetry execution as soon as possible.
124+
if (!hasValidPyprojectToml(cwd)) {
116125
// This check is not expensive and may change during a session, so we need not cache it.
117126
return undefined;
118127
}
@@ -123,12 +132,12 @@ export class Poetry {
123132
return Poetry._poetryPromise.get(cwd);
124133
}
125134

126-
/**
127-
* Returns a Poetry instance corresponding to the binary.
128-
*/
129135
private static async locate(cwd: string): Promise<Poetry | undefined> {
136+
// First thing this method awaits on should be poetry command execution, hence perform all operations
137+
// before that synchronously.
138+
130139
// Produce a list of candidate binaries to be probed by exec'ing them.
131-
async function* getCandidates() {
140+
function* getCandidates() {
132141
const customPoetryPath = getPythonSetting<string>('poetryPath');
133142
if (customPoetryPath && customPoetryPath !== 'poetry') {
134143
// If user has specified a custom poetry path, use it first.
@@ -139,26 +148,21 @@ export class Poetry {
139148
const home = getUserHomeDir();
140149
if (home) {
141150
const defaultPoetryPath = path.join(home, '.poetry', 'bin', 'poetry');
142-
if (await pathExists(defaultPoetryPath)) {
151+
if (pathExistsSync(defaultPoetryPath)) {
143152
yield defaultPoetryPath;
144153
}
145154
}
146155
}
147156

148157
// Probe the candidates, and pick the first one that exists and does what we need.
149-
for await (const poetryPath of getCandidates()) {
158+
for (const poetryPath of getCandidates()) {
150159
traceVerbose(`Probing poetry binary for ${cwd}: ${poetryPath}`);
151160
const poetry = new Poetry(poetryPath, cwd);
152-
const stopWatch = new StopWatch();
153161
const virtualenvs = await poetry.getEnvList();
154162
if (virtualenvs !== undefined) {
155163
traceVerbose(`Found poetry via filesystem probing for ${cwd}: ${poetryPath}`);
156164
return poetry;
157165
}
158-
traceVerbose(
159-
`Time taken to run ${poetryPath} env list --full-path in ms for ${cwd}`,
160-
stopWatch.elapsedTime,
161-
);
162166
}
163167

164168
// Didn't find anything.
@@ -176,6 +180,15 @@ export class Poetry {
176180
* Corresponds to "poetry env list --full-path". Swallows errors if any.
177181
*/
178182
public async getEnvList(): Promise<string[] | undefined> {
183+
return this.getEnvListCached(this.cwd);
184+
}
185+
186+
/**
187+
* Method created to faciliate caching. The caching decorator uses function arguments as cache key,
188+
* so pass in cwd on which we need to cache.
189+
*/
190+
@cache(30_000, true, 10_000)
191+
private async getEnvListCached(_cwd: string): Promise<string[] | undefined> {
179192
const result = await this.safeShellExecute(`${this._command} env list --full-path`);
180193
if (!result) {
181194
return undefined;
@@ -203,6 +216,15 @@ export class Poetry {
203216
* Corresponds to "poetry env info -p". Swallows errors if any.
204217
*/
205218
public async getActiveEnvPath(): Promise<string | undefined> {
219+
return this.getActiveEnvPathCached(this.cwd);
220+
}
221+
222+
/**
223+
* Method created to faciliate caching. The caching decorator uses function arguments as cache key,
224+
* so pass in cwd on which we need to cache.
225+
*/
226+
@cache(20_000, true, 10_000)
227+
private async getActiveEnvPathCached(_cwd: string): Promise<string | undefined> {
206228
const result = await this.safeShellExecute(`${this._command} env info -p`, true);
207229
if (!result) {
208230
return undefined;
@@ -288,12 +310,12 @@ export async function isPoetryEnvironmentRelatedToFolder(
288310
*
289311
* @param folder Folder to look for pyproject.toml file in.
290312
*/
291-
async function hasValidPyprojectToml(folder: string): Promise<boolean> {
313+
function hasValidPyprojectToml(folder: string): boolean {
292314
const pyprojectToml = path.join(folder, 'pyproject.toml');
293-
if (!(await pathExists(pyprojectToml))) {
315+
if (!pathExistsSync(pyprojectToml)) {
294316
return false;
295317
}
296-
const content = await readFile(pyprojectToml);
318+
const content = readFileSync(pyprojectToml);
297319
if (!content.includes('[tool.poetry]')) {
298320
return false;
299321
}

src/client/pythonEnvironments/discovery/locators/services/poetryLocator.ts

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,9 @@ import { buildEnvInfo } from '../../../base/info/env';
1313
import { IPythonEnvsIterator } from '../../../base/locator';
1414
import { FSWatchingLocator } from '../../../base/locators/lowLevel/fsWatchingLocator';
1515
import {
16-
findInterpretersInDir,
1716
getEnvironmentDirFromPath,
17+
getInterpreterPathFromDir,
1818
getPythonVersionFromPath,
19-
looksLikeBasicVirtualPython,
2019
} from '../../../common/commonUtils';
2120
import { getFileInfo, isParentPath, pathExists } from '../../../common/externalDependencies';
2221
import { isPoetryEnvironment, localPoetryEnvDirName, Poetry } from './poetry';
@@ -114,29 +113,20 @@ export class PoetryLocator extends FSWatchingLocator {
114113
traceVerbose(`Searching for poetry virtual envs in: ${envDir}`);
115114

116115
const isLocal = isParentPath(envDir, root);
117-
const executables = findInterpretersInDir(envDir, 1);
118-
119-
for await (const entry of executables) {
120-
const { filename } = entry;
121-
// We only care about python.exe (on windows) and python (on linux/mac)
122-
// Other version like python3.exe or python3.8 are often symlinks to
123-
// python.exe or python in the same directory in the case of virtual
124-
// environments.
125-
if (await looksLikeBasicVirtualPython(entry)) {
116+
const filename = await getInterpreterPathFromDir(envDir);
117+
if (filename !== undefined) {
118+
const kind = PythonEnvKind.Poetry;
119+
if (isLocal && !(await isPoetryEnvironment(filename))) {
120+
// This is not a poetry env.
121+
traceVerbose(
122+
`Poetry Virtual Environment: [skipped] ${filename} (reason: Not poetry environment)`,
123+
);
124+
} else {
126125
// We should extract the kind here to avoid doing is*Environment()
127126
// check multiple times. Those checks are file system heavy and
128127
// we can use the kind to determine this anyway.
129-
yield buildVirtualEnvInfo(
130-
filename,
131-
// Global environments are fetched using 'poetry env list' so we already
132-
// know they're poetry environments, no need to get kind for them.
133-
isLocal ? await getVirtualEnvKind(filename) : PythonEnvKind.Poetry,
134-
undefined,
135-
isLocal,
136-
);
128+
yield buildVirtualEnvInfo(filename, kind, undefined, isLocal);
137129
traceVerbose(`Poetry Virtual Environment: [added] ${filename}`);
138-
} else {
139-
traceVerbose(`Poetry Virtual Environment: [skipped] ${filename}`);
140130
}
141131
}
142132
}

src/test/pythonEnvironments/discovery/locators/lowLevel/poetry.unit.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ suite('isPoetryEnvironment Tests', () => {
9494
suite('Poetry binary is located correctly', async () => {
9595
let shellExecute: sinon.SinonStub;
9696
let getPythonSetting: sinon.SinonStub;
97-
let pathExists: sinon.SinonStub;
9897

9998
suiteSetup(() => {
10099
Poetry._poetryPromise = new Map();
@@ -175,9 +174,9 @@ suite('Poetry binary is located correctly', async () => {
175174
return;
176175
}
177176
const defaultPoetry = path.join(home, '.poetry', 'bin', 'poetry');
178-
pathExists = sinon.stub(externalDependencies, 'pathExists');
179-
pathExists.withArgs(defaultPoetry).resolves(true);
180-
pathExists.callThrough();
177+
const pathExistsSync = sinon.stub(externalDependencies, 'pathExistsSync');
178+
pathExistsSync.withArgs(defaultPoetry).returns(true);
179+
pathExistsSync.callThrough();
181180
getPythonSetting.returns('poetry');
182181
shellExecute.callsFake((command: string, options: ShellOptions) => {
183182
if (

0 commit comments

Comments
 (0)