diff --git a/src/common/localize.ts b/src/common/localize.ts index 19a3bcd84..316d2f678 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -55,9 +55,7 @@ export namespace InlineScriptStrings { export function environmentReady(version: string | undefined): string { const shown = version?.trim(); - return shown - ? l10n.t('Script environment ready (Python {0})', shown) - : l10n.t('Script environment ready'); + return shown ? l10n.t('Script environment ready (Python {0})', shown) : l10n.t('Script environment ready'); } export const unterminatedBlock = l10n.t( @@ -73,10 +71,7 @@ export namespace InlineScriptStrings { "Lines inside a '# /// script' block must be exactly '#' or start with '# '. This line is blank; use '#' for a blank metadata line.", ); } - return l10n.t( - "Lines inside a '# /// script' block must be exactly '#' or start with '# '. Found: {0}", - found, - ); + return l10n.t("Lines inside a '# /// script' block must be exactly '#' or start with '# '. Found: {0}", found); } export function invalidBlockMarker(marker: string): string { return l10n.t( @@ -290,6 +285,11 @@ export namespace ProjectCreatorString { ); export const noProjectsFound = l10n.t('No projects found'); + export const noProjectsFoundInHomeFolder = (folders: string) => + l10n.t( + 'No projects found. Auto Find does not search {0}, because it is your home folder or contains it. Open your project folder instead.', + folders, + ); } export namespace EnvViewStrings { @@ -321,14 +321,16 @@ export namespace PythonInstallStrings { export const fetchingVersions = l10n.t('Finding available Python versions...'); export const selectVersion = l10n.t('Select Python version to install'); export const installed = l10n.t('installed'); - export const installFailed = l10n.t('Python installation did not complete. See the Python Environments output for details.'); + export const installFailed = l10n.t( + 'Python installation did not complete. See the Python Environments output for details.', + ); export const managerUnusable = l10n.t( 'Python Install Manager is unavailable or could not be verified. Check its installation and configuration, then try again. uv was not started.', ); export const catalogueFailed = l10n.t( 'Could not find Python versions using Python Install Manager. Check its configured source or network access and try again. uv was not started.', ); - export const noCompatiblePython = l10n.t('No compatible Python was found in the selected installer\'s catalogue.'); + export const noCompatiblePython = l10n.t("No compatible Python was found in the selected installer's catalogue."); export const verificationFailed = l10n.t( 'A usable Python interpreter matching the request could not be verified. See the Python Environments output.', ); @@ -344,7 +346,7 @@ export namespace PythonInstallStrings { export function installPrompt(version: string, requirement?: string): string { return requirement ? l10n.t( - 'No installed Python satisfies this script\'s requirement ({0}). Install Python {1} using Python Install Manager? An older Python Install Manager runtime in the same version family may be updated, affecting environments that use it. Newer runtimes will not be downgraded.', + "No installed Python satisfies this script's requirement ({0}). Install Python {1} using Python Install Manager? An older Python Install Manager runtime in the same version family may be updated, affecting environments that use it. Newer runtimes will not be downgraded.", requirement, version, ) diff --git a/src/common/workspaceSearch.ts b/src/common/workspaceSearch.ts new file mode 100644 index 000000000..e35d49feb --- /dev/null +++ b/src/common/workspaceSearch.ts @@ -0,0 +1,119 @@ +import * as fsapi from 'fs-extra'; +import { CancellationToken, GlobPattern, RelativePattern, Uri, WorkspaceFolder } from 'vscode'; +import { traceLog, traceWarn } from './logging'; +import { getUserHomeDir, isSameOrParentPath } from './utils/pathUtils'; +import { findFiles, getWorkspaceFolders } from './workspace.apis'; + +export interface WorkspaceSearchOptions { + /** Names the feature in log messages. */ + readonly label: string; + readonly include: string; + readonly exclude: string; + /** The most matches to return; the search stops once it has this many. */ + readonly maxResults: number; + /** + * The folders whose files the caller uses. A workspace folder that is the user's home folder, + * or contains it, is searched only inside these folders. Without them it is not searched. + */ + readonly neededFolders?: readonly Uri[]; + /** Passed to `findFiles` unchanged. */ + readonly token?: CancellationToken; +} + +/** + * Returns whether a search of `folder` walks the user's home folder, because it is that folder or + * one of its parents. Paths are compared after normalization (separators, and case on Windows). + */ +function containsHomeFolder(folder: Uri): boolean { + const home = getUserHomeDir(); + return !!home && folder.scheme === 'file' && isSameOrParentPath(folder.fsPath, home); +} + +/** + * Runs `findFiles` with a result cap. A workspace folder that is the user's home folder, or + * contains it, is never searched whole: a search there walks the whole profile (AppData, caches, + * virtual environments) and can run for minutes. Only the caller's needed folders inside it are + * searched. The other workspace folders are searched whole. + * + * There is no time limit: VS Code resolves a cancelled `findFiles` to an empty list, not to the + * matches found so far, so a limit would drop every match. A caller that can wait long passes a + * token the user can cancel. + */ +export async function findFilesInWorkspace(options: WorkspaceSearchOptions): Promise { + const folders = getWorkspaceFolders() ?? []; + if (!folders.some((folder) => containsHomeFolder(folder.uri))) { + return search(options, [options.include]); + } + const bases = await Promise.all(folders.map((folder) => foldersToSearch(folder, options))); + const includes = outermostFolders(bases.flat()).map((base) => new RelativePattern(base, options.include)); + return includes.length > 0 ? search(options, includes) : []; +} + +/** + * Returns the workspace folders that `findFilesInWorkspace` never searches whole, because each is + * the user's home folder or contains it. A caller that finds nothing names them in its message, so + * the user learns the folder was skipped rather than searched. + */ +export function getHomeWorkspaceFolders(): WorkspaceFolder[] { + return (getWorkspaceFolders() ?? []).filter((folder) => containsHomeFolder(folder.uri)); +} + +/** + * Returns the folders to search in place of one workspace folder: the folder itself, or, when it + * is the home folder or contains it, the needed folders inside it that do not contain home. + */ +async function foldersToSearch(folder: WorkspaceFolder, options: WorkspaceSearchOptions): Promise { + if (!containsHomeFolder(folder.uri)) { + return [folder.uri]; + } + const candidates = (options.neededFolders ?? []).filter( + (uri) => isSameOrParentPath(folder.uri.fsPath, uri.fsPath) && !containsHomeFolder(uri), + ); + const isFolder = await Promise.all(candidates.map(isDirectory)); + const inside = candidates.filter((_uri, index) => isFolder[index]); + traceLog( + `${options.label}: not searching all of workspace folder ${folder.uri.fsPath} for ${options.include}, ` + + "because it is the user's home folder or contains it, and a search there walks the whole profile." + + (inside.length > 0 ? ` Searching only ${inside.map((uri) => uri.fsPath).join(', ')}.` : ''), + ); + return inside; +} + +/** + * Returns whether `uri` is a folder. ripgrep cannot start in a file or a missing folder, and a + * failed start fails the whole `findFiles` call. Projects can be single files. + */ +async function isDirectory(uri: Uri): Promise { + try { + return (await fsapi.stat(uri.fsPath)).isDirectory(); + } catch { + return false; + } +} + +/** Drops each folder that sits inside another folder of the list, and each repeat. */ +function outermostFolders(folders: readonly Uri[]): Uri[] { + return folders.filter((folder, index) => + folders.every( + (other, otherIndex) => + otherIndex === index || + !isSameOrParentPath(other.fsPath, folder.fsPath) || + (index < otherIndex && isSameOrParentPath(folder.fsPath, other.fsPath)), + ), + ); +} + +async function search(options: WorkspaceSearchOptions, includes: GlobPattern[]): Promise { + const resultSets = await Promise.all( + includes.map((include) => findFiles(include, options.exclude, options.maxResults, options.token)), + ); + // `?? []` keeps the callers' existing handling of a search that resolves to undefined. + const results = resultSets.flatMap((resultSet) => resultSet ?? []).slice(0, options.maxResults); + if (results.length >= options.maxResults) { + traceWarn( + `${options.label}: the search for ${options.include} stopped at ${options.maxResults} ` + + 'results; matching files past that are left out.', + ); + } + return results; +} diff --git a/src/features/creators/autoFindProjects.ts b/src/features/creators/autoFindProjects.ts index 94c732a03..911aeccb6 100644 --- a/src/features/creators/autoFindProjects.ts +++ b/src/features/creators/autoFindProjects.ts @@ -4,14 +4,18 @@ import { PythonProject, PythonProjectCreator, PythonProjectCreatorOptions } from import { ProjectCreatorString } from '../../common/localize'; import { traceInfo, traceWarn } from '../../common/logging'; import { showErrorMessage, showQuickPickWithButtons, showWarningMessage } from '../../common/window.apis'; -import { findFiles, getWorkspaceFolders } from '../../common/workspace.apis'; +import { getWorkspaceFolders } from '../../common/workspace.apis'; +import { findFilesInWorkspace, getHomeWorkspaceFolders } from '../../common/workspaceSearch'; import type { EnvironmentManagers } from '../envManagers'; -import { - PythonProjectManager, - PythonProjectsImpl, -} from '../projectManager'; +import { PythonProjectManager, PythonProjectsImpl } from '../projectManager'; import { isSameOrParentPath, normalizePath } from '../../common/utils/pathUtils'; +/** + * The most project marker files the search returns. Each folder with a marker becomes one item in + * a multi-select picker, and a picker with a thousand items is already too long to use. + */ +const MAX_PROJECT_MARKERS = 1_000; + function getUniqueUri(uris: Uri[]): { label: string; description: string; @@ -35,6 +39,18 @@ function getUniqueUri(uris: Uri[]): { .sort((a, b) => a.label.localeCompare(b.label)); } +/** + * Returns the message for a search that found no project. When the search skipped a workspace + * folder because it is the home folder, the message names the folder and says to open the project + * folder, because no project there was looked for. + */ +function noProjectsFoundMessage(): string { + const skipped = getHomeWorkspaceFolders().map((folder) => folder.uri.fsPath); + return skipped.length > 0 + ? ProjectCreatorString.noProjectsFoundInHomeFolder(skipped.join(', ')) + : ProjectCreatorString.noProjectsFound; +} + async function pickProjects(uris: Uri[]): Promise { const items = getUniqueUri(uris); @@ -91,9 +107,14 @@ export class AutoFindProjects implements PythonProjectCreator { } async create(_options?: PythonProjectCreatorOptions): Promise { - const found = await findFiles('**/{pyproject.toml,setup.py}', '**/.venv/**'); + const found = await findFilesInWorkspace({ + label: 'Auto Find', + include: '**/{pyproject.toml,setup.py}', + exclude: '**/.venv/**', + maxResults: MAX_PROJECT_MARKERS, + }); // Exclude markers inside selected environments (e.g. installed packages in a custom-named venv). - const folders = found && found.length > 0 ? getWorkspaceFolders() ?? [] : []; + const folders = found && found.length > 0 ? (getWorkspaceFolders() ?? []) : []; const prefixes = await this.getSelectedEnvironmentPrefixes(folders); const files = found?.filter( (uri) => @@ -108,8 +129,9 @@ export class AutoFindProjects implements PythonProjectCreator { ), ); if (!files || files.length === 0) { + const message = noProjectsFoundMessage(); setImmediate(() => { - showErrorMessage('No projects found'); + showErrorMessage(message); }); return; } diff --git a/src/features/inlineScript/setupEnvironment.ts b/src/features/inlineScript/setupEnvironment.ts index aff36cd36..016c22f00 100644 --- a/src/features/inlineScript/setupEnvironment.ts +++ b/src/features/inlineScript/setupEnvironment.ts @@ -22,7 +22,8 @@ import { showQuickPickWithButtons, showWarningMessage, } from '../../common/window.apis'; -import { asRelativePath, findFiles, getOpenTextDocuments } from '../../common/workspace.apis'; +import { asRelativePath, getOpenTextDocuments } from '../../common/workspace.apis'; +import { findFilesInWorkspace, getHomeWorkspaceFolders } from '../../common/workspaceSearch'; import type { EnvironmentManagers } from '../envManagers'; import { registerInlineScriptCodeLens } from './codeLens'; import { promptUpdateExtensionsForInlineScripts } from './extensionVersionCheck'; @@ -277,6 +278,22 @@ interface InlineScriptQuickPickItem extends QuickPickItem { readonly configured: boolean; } +/** + * Returns the message for a workspace search that found no Python file. When the search skipped a + * workspace folder because it is the home folder, the message names the folder and says to open + * the folder that holds the scripts, because no script there was looked for. + */ +function noPythonFilesMessage(): string { + const skipped = getHomeWorkspaceFolders().map((folder) => folder.uri.fsPath); + if (skipped.length === 0) { + return l10n.t('No Python files were found in the workspace.'); + } + return l10n.t( + 'No Python files were found in the workspace. {0} is not searched, because it is your home folder or contains it. Open the folder that holds your scripts instead.', + skipped.join(', '), + ); +} + export async function setUpInlineScriptEnvironmentsInWorkspace( em: EnvironmentManagers, routing: InlineScriptRoutingRegistry, @@ -285,13 +302,14 @@ export async function setUpInlineScriptEnvironmentsInWorkspace( showErrorMessage(l10n.t('The inline script environment manager is not available yet. Try again shortly.')); return; } - const files = await findFiles( - '**/*.{py,Py,pY,PY}', - '{**/.venv/**,**/node_modules/**}', - MAX_INLINE_SCRIPT_FILES, - ); - if (!files || files.length === 0) { - showInformationMessage(l10n.t('No Python files were found in the workspace.')); + const files = await findFilesInWorkspace({ + label: 'Inline script setup', + include: '**/*.{py,Py,pY,PY}', + exclude: '{**/.venv/**,**/node_modules/**}', + maxResults: MAX_INLINE_SCRIPT_FILES, + }); + if (files.length === 0) { + showInformationMessage(noPythonFilesMessage()); return; } const candidates = await filterInlineScriptFiles(files); @@ -421,10 +439,7 @@ export function registerInlineScriptUx(em: EnvironmentManagers, routing: InlineS disposables.push(codeLens.disposable); disposables.push(registerInlineScriptSetupCodeAction(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND)); disposables.push( - registerCommand( - SETUP_INLINE_SCRIPT_ENV_COMMAND, - setupInlineScriptEnvironmentHandler(em, routing), - ), + registerCommand(SETUP_INLINE_SCRIPT_ENV_COMMAND, setupInlineScriptEnvironmentHandler(em, routing)), ); disposables.push( registerCommand(SETUP_INLINE_SCRIPT_ENVS_COMMAND, () => diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index 29983060a..11c4f9e10 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -18,7 +18,7 @@ import { PackageManagement, Pickers, VenvManagerStrings } from '../../common/loc import { traceInfo, traceWarn } from '../../common/logging'; import { isSameOrParentPath, normalizePath } from '../../common/utils/pathUtils'; import { showErrorMessage, showQuickPickWithButtons, showTextDocument, withProgress } from '../../common/window.apis'; -import { findFiles } from '../../common/workspace.apis'; +import { findFilesInWorkspace } from '../../common/workspaceSearch'; import { throwIfCancelled, waitForToolRead } from '../../internal/pythonToolSupport'; import { ListCommand } from '../base/commands/index'; import { selectFromCommonPackagesToInstall, selectFromInstallableToInstall } from '../common/pickers'; @@ -64,9 +64,7 @@ export function validatePyprojectToml(toml: PyprojectToml): string | undefined { return l10n.t('Missing required field "requires" in [build-system] section of pyproject.toml.'); } if (!isStringArray(buildSystemValue.requires)) { - return l10n.t( - 'The "requires" field in [build-system] must be an array of strings in pyproject.toml.', - ); + return l10n.t('The "requires" field in [build-system] must be an array of strings in pyproject.toml.'); } } @@ -311,13 +309,10 @@ async function selectWorkspaceOrCommon( return undefined; } - const shouldProceed = await shouldProceedAfterPyprojectValidation( - installableResult.validationErrors, - { - allPyprojectTomlsSelected: installable.length === 0, - selectedPyprojectTomls: selectedInstallables.selectedUris, - }, - ); + const shouldProceed = await shouldProceedAfterPyprojectValidation(installableResult.validationErrors, { + allPyprojectTomlsSelected: installable.length === 0, + selectedPyprojectTomls: selectedInstallables.selectedUris, + }); if (!shouldProceed) { return undefined; } @@ -422,6 +417,14 @@ export async function getWorkspacePackagesToInstall( return selectWorkspaceOrCommon(installableResult, common, !!options.showSkipOption, installed ?? []); } +/** + * The most files one dependency search returns. Quick Create installs from every match under the + * project, so the cap sits far above what large monorepos hold. The home folder check in + * `findFilesInWorkspace` bounds the walk, and the progress notification or the caller's token can + * stop it. + */ +const MAX_DEPENDENCY_FILES = 10_000; + export async function getProjectInstallable( api: PythonEnvironmentApi, projects?: PythonProject[], @@ -450,16 +453,28 @@ export async function getProjectInstallable( throwIfSearchCancelled(); }; const cancellationBatchSize = 64; - const shouldYieldForSearchCancellation = (index: number) => - index > 0 && index % cancellationBatchSize === 0; + const shouldYieldForSearchCancellation = (index: number) => index > 0 && index % cancellationBatchSize === 0; + + // Only files under one of `projects` are kept below, so in a home folder workspace the + // search can stay inside the project folders. + const neededFolders = projects.map((project) => project.uri); + const searchDependencyFiles = (include: string) => + findFilesInWorkspace({ + label: 'Dependency search', + include, + exclude, + maxResults: MAX_DEPENDENCY_FILES, + neededFolders, + token: cancellationToken, + }); throwIfSearchCancelled(); const resultSets = await waitForSearch( Promise.all([ - findFiles('**/*requirements*.txt', exclude, undefined, cancellationToken), - findFiles('*requirements*.txt', exclude, undefined, cancellationToken), - findFiles('**/requirements/*.txt', exclude, undefined, cancellationToken), - findFiles('**/pyproject.toml', exclude, undefined, cancellationToken), + searchDependencyFiles('**/*requirements*.txt'), + searchDependencyFiles('*requirements*.txt'), + searchDependencyFiles('**/requirements/*.txt'), + searchDependencyFiles('**/pyproject.toml'), ]), ); await yieldForSearchCancellation(); @@ -511,9 +526,7 @@ export async function getProjectInstallable( continue; } const relative = path.relative(projectRoot, uri.fsPath); - const depth = relative - ? relative.split(path.sep).filter((segment) => segment.length > 0).length - : 0; + const depth = relative ? relative.split(path.sep).filter((segment) => segment.length > 0).length : 0; sortKeys.set(uri.fsPath, { depth, normalizedPath: normalizePath(uri.fsPath), @@ -614,8 +627,7 @@ export async function getProjectInstallable( await yieldForSearchCancellation(); const shouldSkipDisjointToml = (uri: Uri): boolean => - !!options?.deduplicateProjectPackages && - !isTomlRelevantToPreferredRoot(uri, options.preferredRoot); + !!options?.deduplicateProjectPackages && !isTomlRelevantToPreferredRoot(uri, options.preferredRoot); const selectedTomls = new Map(); const ambiguousProjectNames = new Set(); @@ -672,13 +684,10 @@ export async function getProjectInstallable( const relative = path.relative(projectDir, preferredRoot); const containsPreferredRoot = relative === '' || - (relative !== '..' && - !relative.startsWith(`..${path.sep}`) && - !path.isAbsolute(relative)); + (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); if ( containsPreferredRoot && - (!preferredCandidate || - projectDir.length > path.dirname(preferredCandidate.fsPath).length) + (!preferredCandidate || projectDir.length > path.dirname(preferredCandidate.fsPath).length) ) { preferredCandidate = candidate; } @@ -790,9 +799,7 @@ function validationMatchesSelectedToml( selectedPyprojectTomls: readonly Uri[] | undefined, ): boolean { const validationPath = normalizePath(path.resolve(validationError.fileUri.fsPath)); - return (selectedPyprojectTomls ?? []).some( - (uri) => normalizePath(path.resolve(uri.fsPath)) === validationPath, - ); + return (selectedPyprojectTomls ?? []).some((uri) => normalizePath(path.resolve(uri.fsPath)) === validationPath); } /** @@ -805,9 +812,7 @@ export async function shouldProceedAfterPyprojectValidation( ): Promise { const validationError = options?.allPyprojectTomlsSelected ? validationErrors[0] - : validationErrors.find((error) => - validationMatchesSelectedToml(error, options?.selectedPyprojectTomls), - ); + : validationErrors.find((error) => validationMatchesSelectedToml(error, options?.selectedPyprojectTomls)); if (!validationError) { return true; } diff --git a/src/test/common/workspaceSearch.unit.test.ts b/src/test/common/workspaceSearch.unit.test.ts new file mode 100644 index 000000000..a066cae4e --- /dev/null +++ b/src/test/common/workspaceSearch.unit.test.ts @@ -0,0 +1,269 @@ +import assert from 'assert'; +import * as fse from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { CancellationToken, CancellationTokenSource, RelativePattern, Uri, WorkspaceFolder } from 'vscode'; +import * as logging from '../../common/logging'; +import * as pathUtils from '../../common/utils/pathUtils'; +import { isWindows } from '../../common/utils/platformUtils'; +import * as wapi from '../../common/workspace.apis'; +import { findFilesInWorkspace, getHomeWorkspaceFolders } from '../../common/workspaceSearch'; + +suite('findFilesInWorkspace', () => { + const root = path.parse(process.cwd()).root; + const home = path.join(root, 'home', 'tester'); + const work = path.join(root, 'work', 'repo'); + const options = { label: 'Test search', include: '**/pyproject.toml', exclude: '**/.venv/**', maxResults: 3 }; + let findFilesStub: sinon.SinonStub; + let getWorkspaceFoldersStub: sinon.SinonStub; + let traceLogStub: sinon.SinonStub; + let traceWarnStub: sinon.SinonStub; + let getUserHomeDirStub: sinon.SinonStub; + + function folder(fsPath: string, index = 0): WorkspaceFolder { + return { uri: Uri.file(fsPath), name: path.basename(fsPath), index }; + } + + /** + * Makes a findFiles fake that returns `uris` after `delayMs`. Like VS Code, it returns an empty + * list as soon as its token is cancelled, not the matches found so far. + */ + function resolvesUnlessCancelled(delayMs: number, uris: Uri[]) { + return (_include: unknown, _exclude: unknown, _maxResults: unknown, token?: CancellationToken) => + new Promise((resolve) => { + token?.onCancellationRequested(() => resolve([])); + setTimeout(() => resolve(uris), delayMs); + }); + } + + setup(() => { + findFilesStub = sinon.stub(wapi, 'findFiles').resolves([]); + getWorkspaceFoldersStub = sinon.stub(wapi, 'getWorkspaceFolders').returns([folder(work)]); + getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir').returns(home); + traceLogStub = sinon.stub(logging, 'traceLog'); + traceWarnStub = sinon.stub(logging, 'traceWarn'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('runs one search with the given pattern, cap and token when no folder is the home folder', async () => { + const marker = Uri.file(path.join(work, 'pyproject.toml')); + findFilesStub.resolves([marker]); + const source = new CancellationTokenSource(); + + try { + const result = await findFilesInWorkspace({ ...options, token: source.token }); + + assert.deepStrictEqual(result, [marker]); + assert.strictEqual(findFilesStub.callCount, 1); + const [include, exclude, maxResults, token] = findFilesStub.firstCall.args; + assert.strictEqual(include, options.include); + assert.strictEqual(exclude, options.exclude); + assert.strictEqual(maxResults, options.maxResults); + assert.strictEqual(token, source.token); + assert.ok(traceLogStub.notCalled); + } finally { + source.dispose(); + } + }); + + [ + { name: 'is the home folder', fsPath: home }, + { name: 'contains the home folder', fsPath: path.dirname(home) }, + { + name: 'is the home folder written with a trailing separator or another case', + fsPath: (isWindows() ? home.toUpperCase() : home) + path.sep, + }, + ].forEach(({ name, fsPath }) => { + test(`skips a workspace folder that ${name} and logs why`, async () => { + getWorkspaceFoldersStub.returns([folder(fsPath)]); + + const result = await findFilesInWorkspace(options); + + assert.deepStrictEqual(result, []); + assert.ok(findFilesStub.notCalled, 'the folder should not be searched'); + assert.strictEqual(traceLogStub.callCount, 1); + const message = String(traceLogStub.firstCall.args[0]); + assert.ok(message.includes(Uri.file(fsPath).fsPath), message); + assert.ok(message.includes('home folder'), message); + }); + }); + + test('names the workspace folders it does not search whole', () => { + getWorkspaceFoldersStub.returns([folder(home, 0), folder(work, 1), folder(path.dirname(home), 2)]); + + const skipped = getHomeWorkspaceFolders(); + + assert.deepStrictEqual( + skipped.map((workspaceFolder) => workspaceFolder.uri.fsPath), + [Uri.file(home).fsPath, Uri.file(path.dirname(home)).fsPath], + ); + }); + + test('names no folder when no workspace folder is open', () => { + getWorkspaceFoldersStub.returns(undefined); + + assert.deepStrictEqual(getHomeWorkspaceFolders(), []); + }); + + test('searches a folder inside the home folder', async () => { + getWorkspaceFoldersStub.returns([folder(path.join(home, 'code', 'app'))]); + + await findFilesInWorkspace(options); + + assert.strictEqual(findFilesStub.callCount, 1); + assert.ok(traceLogStub.notCalled); + }); + + test('searches each other workspace folder on its own and caps the merged results', async () => { + const other = path.join(root, 'work', 'other'); + getWorkspaceFoldersStub.returns([folder(home, 0), folder(work, 1), folder(other, 2)]); + findFilesStub.callsFake((include: RelativePattern) => + Promise.resolve([ + Uri.file(path.join(include.baseUri.fsPath, 'a', 'pyproject.toml')), + Uri.file(path.join(include.baseUri.fsPath, 'b', 'pyproject.toml')), + ]), + ); + + const result = await findFilesInWorkspace(options); + + const bases = findFilesStub.getCalls().map((call) => { + const include = call.args[0]; + assert.ok(include instanceof RelativePattern, 'each search should be scoped to one folder'); + assert.strictEqual(include.pattern, options.include); + return pathUtils.normalizePath(include.baseUri.fsPath); + }); + assert.deepStrictEqual(bases, [pathUtils.normalizePath(work), pathUtils.normalizePath(other)]); + assert.strictEqual(result.length, options.maxResults); + assert.ok(traceWarnStub.calledOnce, 'reaching the cap should be logged'); + }); + + test('returns every match of a search that runs for minutes', async () => { + const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + const marker = Uri.file(path.join(work, 'pyproject.toml')); + findFilesStub.callsFake(resolvesUnlessCancelled(10 * 60_000, [marker])); + const pending = findFilesInWorkspace(options); + + await clock.tickAsync(10 * 60_000); + + assert.deepStrictEqual(await pending, [marker]); + assert.ok(traceWarnStub.notCalled); + } finally { + clock.restore(); + } + }); + + test('passes the caller token to each search', async () => { + const source = new CancellationTokenSource(); + try { + getWorkspaceFoldersStub.returns([folder(home, 0), folder(work, 1), folder(path.join(root, 'other'), 2)]); + + await findFilesInWorkspace({ ...options, token: source.token }); + + assert.strictEqual(findFilesStub.callCount, 2); + for (const call of findFilesStub.getCalls()) { + assert.strictEqual(call.args[3], source.token); + } + } finally { + source.dispose(); + } + }); + + test('passes a cancelled token when the caller token is already cancelled', async () => { + const source = new CancellationTokenSource(); + source.cancel(); + try { + await findFilesInWorkspace({ ...options, token: source.token }); + + assert.strictEqual(findFilesStub.firstCall.args[3].isCancellationRequested, true); + } finally { + source.dispose(); + } + }); + + suite('needed folders inside a home folder workspace', () => { + let diskHome: string; + let app: string; + + /** Returns the normalized base folder of each findFiles call. */ + function searchedBases(): string[] { + return findFilesStub.getCalls().map((call) => { + const include = call.args[0]; + assert.ok(include instanceof RelativePattern, 'each search should be scoped to one folder'); + assert.strictEqual(include.pattern, options.include); + return pathUtils.normalizePath(include.baseUri.fsPath); + }); + } + + setup(async () => { + diskHome = await fse.mkdtemp(path.join(os.tmpdir(), 'workspace-search-home-')); + app = path.join(diskHome, 'code', 'app'); + await fse.ensureDir(path.join(app, 'sub')); + getUserHomeDirStub.returns(diskHome); + getWorkspaceFoldersStub.returns([folder(diskHome)]); + }); + + teardown(async () => { + await fse.remove(diskHome); + }); + + test('searches only the needed folders inside it and logs why', async () => { + const marker = Uri.file(path.join(app, 'pyproject.toml')); + findFilesStub.resolves([marker]); + + const result = await findFilesInWorkspace({ ...options, neededFolders: [Uri.file(app)] }); + + assert.deepStrictEqual(result, [marker]); + assert.deepStrictEqual(searchedBases(), [pathUtils.normalizePath(app)]); + assert.strictEqual(traceLogStub.callCount, 1); + const message = String(traceLogStub.firstCall.args[0]); + assert.ok(message.includes(Uri.file(diskHome).fsPath), message); + assert.ok(message.includes(Uri.file(app).fsPath), message); + }); + + test('does not search a needed folder that is the home folder or contains it', async () => { + const result = await findFilesInWorkspace({ + ...options, + neededFolders: [Uri.file(diskHome), Uri.file(path.dirname(diskHome))], + }); + + assert.deepStrictEqual(result, []); + assert.ok(findFilesStub.notCalled); + }); + + test('does not search a needed path that is a file or does not exist', async () => { + const script = path.join(diskHome, 'script.py'); + await fse.outputFile(script, 'print(1)\n'); + + await findFilesInWorkspace({ + ...options, + neededFolders: [Uri.file(script), Uri.file(path.join(diskHome, 'missing'))], + }); + + assert.ok(findFilesStub.notCalled); + }); + + test('searches a folder once when it sits inside another needed folder or two home folders', async () => { + getWorkspaceFoldersStub.returns([folder(path.dirname(diskHome), 0), folder(diskHome, 1)]); + + await findFilesInWorkspace({ + ...options, + neededFolders: [Uri.file(path.join(app, 'sub')), Uri.file(app), Uri.file(app)], + }); + + assert.deepStrictEqual(searchedBases(), [pathUtils.normalizePath(app)]); + }); + + test('searches the other workspace folders whole', async () => { + getWorkspaceFoldersStub.returns([folder(diskHome, 0), folder(work, 1)]); + + await findFilesInWorkspace({ ...options, neededFolders: [Uri.file(path.join(work, 'service'))] }); + + assert.deepStrictEqual(searchedBases(), [pathUtils.normalizePath(work)]); + }); + }); +}); diff --git a/src/test/features/creators/autoFindProjects.unit.test.ts b/src/test/features/creators/autoFindProjects.unit.test.ts index 1e4cdc6ee..fb25e2e58 100644 --- a/src/test/features/creators/autoFindProjects.unit.test.ts +++ b/src/test/features/creators/autoFindProjects.unit.test.ts @@ -2,11 +2,12 @@ import assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; import * as typmoq from 'typemoq'; -import { Uri, WorkspaceFolder } from 'vscode'; +import { CancellationToken, Uri, WorkspaceFolder } from 'vscode'; import { PythonEnvironment, PythonProject } from '../../../api'; import { SYSTEM_MANAGER_ID, VENV_MANAGER_ID } from '../../../common/constants'; import { timeout } from '../../../common/utils/asyncUtils'; import { createDeferred } from '../../../common/utils/deferred'; +import * as pathUtils from '../../../common/utils/pathUtils'; import * as winapi from '../../../common/window.apis'; import * as wapi from '../../../common/workspace.apis'; import { AutoFindProjects } from '../../../features/creators/autoFindProjects'; @@ -288,7 +289,10 @@ suite('Auto Find Project tests', () => { return { uri: Uri.file(fsPath), name: path.basename(fsPath), index }; } - function setupEnvironments(prefixes: Map, managerId = VENV_MANAGER_ID): void { + function setupEnvironments( + prefixes: Map, + managerId = VENV_MANAGER_ID, + ): void { getWorkspaceFoldersStub.returns(Array.from(prefixes.keys()).map((p, i) => folder(p, i))); envManagers .setup((em) => em.getEnvironment(typmoq.It.isAny())) @@ -355,7 +359,12 @@ suite('Auto Find Project tests', () => { }); test('Keeps workspace projects when a selected prefix contains or equals the workspace', async () => { - setupEnvironments(new Map([[root, broadPrefix], [root2, root2]])); + setupEnvironments( + new Map([ + [root, broadPrefix], + [root2, root2], + ]), + ); findFilesStub.resolves([ Uri.file(path.join(root, 'app', 'pyproject.toml')), Uri.file(path.join(root2, 'setup.py')), @@ -369,7 +378,12 @@ suite('Auto Find Project tests', () => { test('Keeps a nested workspace root selected as the parent workspace environment prefix', async () => { const nested = path.join(root, 'nested'); const nestedEnv = path.join(nested, 'custom-env'); - setupEnvironments(new Map([[root, nested], [nested, nestedEnv]])); + setupEnvironments( + new Map([ + [root, nested], + [nested, nestedEnv], + ]), + ); findFilesStub.resolves([ Uri.file(path.join(root, 'app', 'setup.py')), Uri.file(path.join(nested, 'pyproject.toml')), @@ -384,7 +398,12 @@ suite('Auto Find Project tests', () => { test('Still excludes packages outside a nested workspace under the selected prefix', async () => { const envPrefix = path.join(root, 'custom-env'); const nested = path.join(envPrefix, 'docs'); - setupEnvironments(new Map([[root, envPrefix], [nested, undefined]])); + setupEnvironments( + new Map([ + [root, envPrefix], + [nested, undefined], + ]), + ); findFilesStub.resolves([ Uri.file(path.join(nested, 'pyproject.toml')), Uri.file(path.join(envPrefix, 'lib', 'site-packages', 'pkg', 'setup.py')), @@ -421,7 +440,12 @@ suite('Auto Find Project tests', () => { test('Excludes a selected environment inside a different workspace folder', async () => { const envPrefix = path.join(root2, 'shared-env'); - setupEnvironments(new Map([[root, envPrefix], [root2, undefined]])); + setupEnvironments( + new Map([ + [root, envPrefix], + [root2, undefined], + ]), + ); findFilesStub.resolves([ Uri.file(path.join(root, 'app', 'pyproject.toml')), Uri.file(path.join(root2, 'other', 'pyproject.toml')), @@ -477,4 +501,82 @@ suite('Auto Find Project tests', () => { projectManager.verify((pm) => pm.add(typmoq.It.isAny()), typmoq.Times.never()); }); }); + + suite('Search bounds', () => { + const home = path.join(path.parse(process.cwd()).root, 'home', 'tester'); + + setup(() => { + sinon.stub(pathUtils, 'getUserHomeDir').returns(home); + }); + + test('caps the marker search at 1,000 files', async () => { + findFilesStub.resolves([]); + + await new AutoFindProjects(projectManager.object, envManagers.object).create(); + + assert.strictEqual(findFilesStub.callCount, 1); + const [include, exclude, maxResults] = findFilesStub.firstCall.args; + assert.strictEqual(include, '**/{pyproject.toml,setup.py}'); + assert.strictEqual(exclude, '**/.venv/**'); + assert.strictEqual(maxResults, 1_000); + }); + + test('offers the projects of a marker search that runs for minutes', async () => { + const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const marker = Uri.file(path.join(path.parse(process.cwd()).root, 'work', 'app', 'pyproject.toml')); + // Like VS Code, a cancelled search returns an empty list, not the matches found so far. + findFilesStub.callsFake( + (_include: string, _exclude: string, _maxResults: number, token?: CancellationToken) => + new Promise((resolve) => { + token?.onCancellationRequested(() => resolve([])); + setTimeout(() => resolve([marker]), 10 * 60_000); + }), + ); + projectManager.setup((pm) => pm.get(typmoq.It.isAny())).returns(() => undefined); + try { + const pending = new AutoFindProjects(projectManager.object, envManagers.object).create(); + + await clock.tickAsync(10 * 60_000); + + const result = await pending; + assert.ok(Array.isArray(result), 'the found project should be offered'); + assert.deepStrictEqual( + result.map((project) => project.uri.fsPath), + [path.dirname(marker.fsPath)], + ); + } finally { + clock.restore(); + } + }); + + test('does not search a workspace folder that is the home folder and tells the user why', async () => { + getWorkspaceFoldersStub.returns([{ uri: Uri.file(home), name: 'tester', index: 0 }]); + const shown = createDeferred(); + showErrorMessageStub.callsFake(() => shown.resolve()); + + const result = await new AutoFindProjects(projectManager.object, envManagers.object).create(); + + assert.strictEqual(result, undefined); + assert.ok(findFilesStub.notCalled, 'the home folder should not be searched'); + await Promise.race([shown.promise, timeout(100)]); + sinon.assert.calledOnceWithExactly( + showErrorMessageStub, + `No projects found. Auto Find does not search ${Uri.file(home).fsPath}, because it is your ` + + 'home folder or contains it. Open your project folder instead.', + ); + }); + + test('shows the plain message when it searched every workspace folder', async () => { + const app = path.join(path.parse(process.cwd()).root, 'work', 'app'); + getWorkspaceFoldersStub.returns([{ uri: Uri.file(app), name: 'app', index: 0 }]); + findFilesStub.resolves([]); + const shown = createDeferred(); + showErrorMessageStub.callsFake(() => shown.resolve()); + + await new AutoFindProjects(projectManager.object, envManagers.object).create(); + + await Promise.race([shown.promise, timeout(100)]); + sinon.assert.calledOnceWithExactly(showErrorMessageStub, 'No projects found'); + }); + }); }); diff --git a/src/test/features/inlineScript/setupEnvironment.unit.test.ts b/src/test/features/inlineScript/setupEnvironment.unit.test.ts index d08be4bf5..80a5d7ae2 100644 --- a/src/test/features/inlineScript/setupEnvironment.unit.test.ts +++ b/src/test/features/inlineScript/setupEnvironment.unit.test.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import assert from 'assert'; +import * as path from 'path'; import * as sinon from 'sinon'; import * as typemoq from 'typemoq'; import { Disposable, Uri } from 'vscode'; @@ -15,6 +16,7 @@ import { } from '../../../common/inlineScript/routingRegistry'; import { InlineScriptStrings, PythonInstallStrings } from '../../../common/localize'; import { createDeferred } from '../../../common/utils/deferred'; +import * as pathUtils from '../../../common/utils/pathUtils'; import * as winapi from '../../../common/window.apis'; import * as wapi from '../../../common/workspace.apis'; import { @@ -184,6 +186,7 @@ suite('setUpInlineScriptEnvironmentsInWorkspace', () => { let manager: typemoq.IMock; let routing: InlineScriptRoutingRegistry; let findFilesStub: sinon.SinonStub; + let getWorkspaceFoldersStub: sinon.SinonStub; let readMetadataStub: sinon.SinonStub; let quickPickStub: sinon.SinonStub; let infoStub: sinon.SinonStub; @@ -195,6 +198,7 @@ suite('setUpInlineScriptEnvironmentsInWorkspace', () => { em.setup((m) => m.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID)).returns(() => manager.object); findFilesStub = sinon.stub(wapi, 'findFiles'); + getWorkspaceFoldersStub = sinon.stub(wapi, 'getWorkspaceFolders').returns(undefined); sinon.stub(wapi, 'asRelativePath').callsFake((p) => (p instanceof Uri ? p.fsPath : String(p))); sinon.stub(wapi, 'getOpenTextDocuments').returns([]); readMetadataStub = sinon.stub(metadataApi, 'readInlineScriptMetadataFromFile'); @@ -222,11 +226,26 @@ suite('setUpInlineScriptEnvironmentsInWorkspace', () => { await setUpInlineScriptEnvironmentsInWorkspace(em.object, routing); + sinon.assert.calledOnce(findFilesStub); + const [include, exclude, maxResults] = findFilesStub.firstCall.args; + assert.strictEqual(include, '**/*.{py,Py,pY,PY}'); + assert.strictEqual(exclude, '{**/.venv/**,**/node_modules/**}'); + assert.strictEqual(maxResults, 1000); + }); + + test('does not search a workspace folder that is the home folder and tells the user why', async () => { + const home = path.join(path.parse(process.cwd()).root, 'home', 'tester'); + sinon.stub(pathUtils, 'getUserHomeDir').returns(home); + getWorkspaceFoldersStub.returns([{ uri: Uri.file(home), name: 'tester', index: 0 }]); + findFilesStub.resolves([]); + + await setUpInlineScriptEnvironmentsInWorkspace(em.object, routing); + + sinon.assert.notCalled(findFilesStub); sinon.assert.calledOnceWithExactly( - findFilesStub, - '**/*.{py,Py,pY,PY}', - '{**/.venv/**,**/node_modules/**}', - 1000, + infoStub, + `No Python files were found in the workspace. ${Uri.file(home).fsPath} is not searched, because it ` + + 'is your home folder or contains it. Open the folder that holds your scripts instead.', ); }); @@ -439,7 +458,8 @@ suite('setupInlineScriptEnvironmentHandler', () => { return env; } - test('saves a dirty document before setup, because setup reads the block from disk', async () => { openDirtyDocument(); + test('saves a dirty document before setup, because setup reads the block from disk', async () => { + openDirtyDocument(); expectEnvironmentCreated(); await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); @@ -497,7 +517,8 @@ suite('setupInlineScriptEnvironmentHandler', () => { sinon.assert.calledOnce(errorStub); }); - test('does not report a failing companion-extension prompt as a setup failure', async () => { expectEnvironmentCreated(); + test('does not report a failing companion-extension prompt as a setup failure', async () => { + expectEnvironmentCreated(); promptStub.rejects(new Error('boom')); await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); diff --git a/src/test/managers/builtin/pipUtils.unit.test.ts b/src/test/managers/builtin/pipUtils.unit.test.ts index 6c9a6df70..89d815c1b 100644 --- a/src/test/managers/builtin/pipUtils.unit.test.ts +++ b/src/test/managers/builtin/pipUtils.unit.test.ts @@ -1,11 +1,23 @@ import assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; -import { CancellationError, CancellationToken, CancellationTokenSource, LogOutputChannel, Progress, ProgressLocation, ProgressOptions, Uri } from 'vscode'; +import { + CancellationError, + CancellationToken, + CancellationTokenSource, + LogOutputChannel, + Progress, + ProgressLocation, + ProgressOptions, + RelativePattern, + Uri, + WorkspaceFolder, +} from 'vscode'; import * as fse from 'fs-extra'; import * as os from 'os'; import { PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../../api'; import { PackageManagement } from '../../../common/localize'; +import * as pathUtils from '../../../common/utils/pathUtils'; import { normalizePath } from '../../../common/utils/pathUtils'; import * as winapi from '../../../common/window.apis'; import * as wapi from '../../../common/workspace.apis'; @@ -22,6 +34,7 @@ suite('Pip Utils - getProjectInstallable', () => { setup(() => { findFilesStub = sinon.stub(wapi, 'findFiles'); + sinon.stub(wapi, 'getWorkspaceFolders').returns(undefined); // Stub withProgress to immediately execute the callback withProgressStub = sinon.stub(winapi, 'withProgress'); withProgressStub.callsFake( @@ -322,9 +335,7 @@ suite('Pip Utils - getProjectInstallable', () => { const workspacePath = Uri.file('/test/path/root').fsPath; findFilesStub.callsFake((pattern: string) => Promise.resolve( - pattern === '*requirements*.txt' - ? [Uri.file(path.join(workspacePath, 'requirements.txt'))] - : [], + pattern === '*requirements*.txt' ? [Uri.file(path.join(workspacePath, 'requirements.txt'))] : [], ), ); @@ -363,9 +374,11 @@ suite('Pip Utils - getProjectInstallable', () => { return Promise.resolve([]); }); const picker = sinon.stub(winapi, 'showQuickPickWithButtons'); - picker.onFirstCall().callsFake(async (items) => - items.find((item) => item.label === PackageManagement.workspaceDependencies), - ); + picker + .onFirstCall() + .callsFake(async (items) => + items.find((item) => item.label === PackageManagement.workspaceDependencies), + ); picker.onSecondCall().callsFake(async (items) => { const requirement = items.find((item) => (item as { args?: string[] }).args?.[0] === '-r'); assert.ok(requirement); @@ -393,13 +406,13 @@ suite('Pip Utils - getProjectInstallable', () => { await fse.writeFile(invalidToml, '[project]\nname = "unterminated\n'); mockApi.getPythonProject = () => ({ name: 'workspace', uri: Uri.file(tempRoot) }); findFilesStub.callsFake((pattern: string) => - pattern === '**/pyproject.toml' - ? Promise.resolve([Uri.file(invalidToml)]) - : Promise.resolve([]), - ); - sinon.stub(winapi, 'showQuickPickWithButtons').callsFake(async (items) => - items.find((item) => item.label === PackageManagement.workspaceDependencies), + pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(invalidToml)]) : Promise.resolve([]), ); + sinon + .stub(winapi, 'showQuickPickWithButtons') + .callsFake(async (items) => + items.find((item) => item.label === PackageManagement.workspaceDependencies), + ); const showError = sinon.stub(winapi, 'showErrorMessage'); showError.callsFake(async (...args: unknown[]) => args[3] as never); @@ -424,9 +437,7 @@ suite('Pip Utils - getProjectInstallable', () => { const workspacePath = Uri.file('/test/path/root').fsPath; findFilesStub.callsFake((pattern: string) => Promise.resolve( - pattern === '*requirements*.txt' - ? [Uri.file(path.join(workspacePath, 'requirements.txt'))] - : [], + pattern === '*requirements*.txt' ? [Uri.file(path.join(workspacePath, 'requirements.txt'))] : [], ), ); sinon.stub(helpers, 'shouldUseUv').resolves(false); @@ -583,17 +594,29 @@ suite('Pip Utils - getProjectInstallable', () => { const siblingRequirement = Uri.file(path.join(temp, 'sibling', 'requirements.txt')); const siblingToml = Uri.file(path.join(temp, 'sibling', 'pyproject.toml')); const invalidSiblingSyntax = Uri.file(path.join(temp, 'sibling-two', 'pyproject.toml')); - await fse.outputFile(siblingToml.fsPath, '[project]\nname = "sibling"\n[build-system]\nbuild-backend = "missing"\n'); + await fse.outputFile( + siblingToml.fsPath, + '[project]\nname = "sibling"\n[build-system]\nbuild-backend = "missing"\n', + ); await fse.outputFile(invalidSiblingSyntax.fsPath, '[invalid syntax'); mockApi.getPythonProject = () => project; - findFilesStub.resolves([rootRequirement, ownRequirement, siblingRequirement, siblingToml, invalidSiblingSyntax]); + findFilesStub.resolves([ + rootRequirement, + ownRequirement, + siblingRequirement, + siblingToml, + invalidSiblingSyntax, + ]); const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [project], { runHeadless: true, token: source.token, preferredRoot: nested, deduplicateProjectPackages: true, }); - assert.deepStrictEqual(result.installables.map((item) => item.uri?.fsPath), [ownRequirement.fsPath]); + assert.deepStrictEqual( + result.installables.map((item) => item.uri?.fsPath), + [ownRequirement.fsPath], + ); assert.strictEqual(result.validationErrors[0], undefined); assert.ok(withProgressStub.notCalled); } finally { @@ -669,15 +692,8 @@ suite('Pip Utils - getProjectInstallable', () => { test('preserves complete depth-then-path order across multiple merge rounds', async () => { const workspacePath = Uri.file('/test/path/root').fsPath; const dependencyPaths = Array.from({ length: 129 }, (_, index) => { - const directories = Array.from( - { length: index % 5 }, - (_value, level) => `group-${(index + level) % 11}`, - ); - return path.join( - workspacePath, - ...directories, - `requirements-${index.toString().padStart(3, '0')}.txt`, - ); + const directories = Array.from({ length: index % 5 }, (_value, level) => `group-${(index + level) % 11}`); + return path.join(workspacePath, ...directories, `requirements-${index.toString().padStart(3, '0')}.txt`); }); findFilesStub.callsFake((pattern: string) => Promise.resolve(pattern === '**/*requirements*.txt' ? [...dependencyPaths].reverse().map(Uri.file) : []), @@ -707,11 +723,7 @@ suite('Pip Utils - getProjectInstallable', () => { const workspacePath = Uri.file('/test/path/root').fsPath; const dependencyFiles = Array.from({ length: 129 }, (_, index) => Uri.file( - path.join( - workspacePath, - `group-${index % 7}`, - `requirements-${index.toString().padStart(3, '0')}.txt`, - ), + path.join(workspacePath, `group-${index % 7}`, `requirements-${index.toString().padStart(3, '0')}.txt`), ), ); findFilesStub.callsFake((pattern: string) => @@ -781,6 +793,7 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () setup(async () => { findFilesStub = sinon.stub(wapi, 'findFiles').resolves([]); + sinon.stub(wapi, 'getWorkspaceFolders').returns(undefined); withProgressStub = sinon.stub(winapi, 'withProgress'); withProgressStub.callsFake( async ( @@ -848,10 +861,9 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(missingToml)]) : Promise.resolve([]), ); - const result = await getProjectInstallable( - mockApi as PythonEnvironmentApi, - [{ name: 'myapp', uri: Uri.file(projectRoot) }], - ); + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [ + { name: 'myapp', uri: Uri.file(projectRoot) }, + ]); assert.strictEqual(result.installables.length, 0); assert.strictEqual(result.validationErrors[0]?.fileUri.fsPath, Uri.file(missingToml).fsPath); @@ -876,10 +888,9 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(invalidToml)]) : Promise.resolve([]), ); - const result = await getProjectInstallable( - mockApi as PythonEnvironmentApi, - [{ name: 'myapp', uri: Uri.file(projectRoot) }], - ); + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [ + { name: 'myapp', uri: Uri.file(projectRoot) }, + ]); assert.deepStrictEqual(result.installables[0]?.args, ['-e', projectRoot]); assert.match(result.validationErrors[0]?.message ?? '', /dependencies.*array of strings/i); @@ -903,10 +914,9 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(invalidToml)]) : Promise.resolve([]), ); - const result = await getProjectInstallable( - mockApi as PythonEnvironmentApi, - [{ name: 'myapp', uri: Uri.file(projectRoot) }], - ); + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [ + { name: 'myapp', uri: Uri.file(projectRoot) }, + ]); assert.deepStrictEqual(result.installables[0]?.args, ['-e', projectRoot]); assert.match(result.validationErrors[0]?.message ?? '', /optional-dependencies.*table/i); @@ -915,23 +925,17 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () test('reports invalid optional dependency group names before installation', async () => { const invalidToml = await writeTomlContent( '', - [ - '[project]', - 'name = "myapp"', - '', - '[project.optional-dependencies]', - '"bad extra" = ["pytest"]', - '', - ].join('\n'), + ['[project]', 'name = "myapp"', '', '[project.optional-dependencies]', '"bad extra" = ["pytest"]', ''].join( + '\n', + ), ); findFilesStub.callsFake((pattern: string) => pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(invalidToml)]) : Promise.resolve([]), ); - const result = await getProjectInstallable( - mockApi as PythonEnvironmentApi, - [{ name: 'myapp', uri: Uri.file(projectRoot) }], - ); + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [ + { name: 'myapp', uri: Uri.file(projectRoot) }, + ]); assert.deepStrictEqual(result.installables[0]?.args, ['-e', `${projectRoot}[bad extra]`]); assert.match(result.validationErrors[0]?.message ?? '', /invalid optional dependency group name/i); @@ -943,10 +947,9 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(validToml)]) : Promise.resolve([]), ); - const result = await getProjectInstallable( - mockApi as PythonEnvironmentApi, - [{ name: 'myapp', uri: Uri.file(projectRoot) }], - ); + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [ + { name: 'myapp', uri: Uri.file(projectRoot) }, + ]); assert.deepStrictEqual(result.installables[0]?.args, ['-e', projectRoot]); assert.deepStrictEqual(result.validationErrors, []); @@ -967,7 +970,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [path.join(projectRoot, 'main')]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1008,7 +1014,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [path.join(projectRoot, 'main')]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1059,7 +1068,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [path.join(projectRoot, 'main')]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1078,9 +1090,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ - path.join(projectRoot, 'main', 'pkg'), - ]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main', 'pkg')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1124,9 +1137,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main', 'pkg')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ - path.join(projectRoot, 'main', 'pkg'), - ]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main', 'pkg')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1158,9 +1172,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main', 'pkg')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ - path.join(projectRoot, 'main', 'pkg'), - ]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main', 'pkg')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1191,9 +1206,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ - path.join(projectRoot, 'main', 'pkg'), - ]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main', 'pkg')], + ); assert.strictEqual(result.validationErrors[0]?.fileUri.fsPath, owningToml); }); @@ -1224,9 +1240,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ - path.join(projectRoot, 'main', 'pkg'), - ]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main', 'pkg')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1244,9 +1261,8 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () }); const projects = [{ name: 'myapp', uri: Uri.file(projectRoot) }]; - const result = ( - await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions()) - ).installables; + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions())) + .installables; const editable = result.filter((r) => r.args?.[0] === '-e'); assert.strictEqual(editable.length, 0, 'Quick Create should not choose an arbitrary worktree'); @@ -1264,16 +1280,11 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () }); const projects = [{ name: 'myapp', uri: Uri.file(projectRoot) }]; - const result = ( - await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions()) - ).installables; + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions())) + .installables; const editable = result.filter((r) => r.args?.[0] === '-e'); - assert.strictEqual( - editable.length, - 0, - '"My_App" and "my-app" normalize to the same ambiguous package name', - ); + assert.strictEqual(editable.length, 0, '"My_App" and "my-app" normalize to the same ambiguous package name'); }); test('uses the candidate containing the creation root', async () => { @@ -1300,7 +1311,11 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () const editable = result.installables.filter((item) => item.args?.[0] === '-e'); assert.strictEqual(editable.length, 1); assert.strictEqual(editable[0].args?.[1], path.join(projectRoot, 'z-valid')); - assert.strictEqual(result.validationErrors[0], undefined, 'ignored duplicates should not surface validation errors'); + assert.strictEqual( + result.validationErrors[0], + undefined, + 'ignored duplicates should not surface validation errors', + ); }); test('reports validation from the preferred candidate instead of substituting another worktree', async () => { @@ -1392,9 +1407,8 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () }); const projects = [{ name: 'myapp', uri: Uri.file(projectRoot) }]; - const result = ( - await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions()) - ).installables; + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions())) + .installables; const editable = result.filter((item) => item.args?.[0] === '-e'); assert.strictEqual(editable.length, 1); @@ -1438,9 +1452,8 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () }); const projects = [{ name: 'myapp', uri: Uri.file(projectRoot) }]; - const result = ( - await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions()) - ).installables; + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions())) + .installables; const editableDirs = result.filter((r) => r.args?.[0] === '-e').map((r) => r.args?.[1]); assert.strictEqual(editableDirs.length, 2, 'Distinct package names should both be kept'); @@ -1467,9 +1480,8 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () }); const projects = [{ name: 'myapp', uri: Uri.file(projectRoot) }]; - const result = ( - await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions()) - ).installables; + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions())) + .installables; const reqs = result.filter((r) => r.args?.[0] === '-r'); assert.strictEqual(reqs.length, 2, 'Both requirements.txt files should be preserved'); @@ -1505,3 +1517,147 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () assert.strictEqual(result.filter((item) => item.args?.[0] === '-e').length, 2); }); }); + +suite('Pip Utils - dependency file search bounds', () => { + const root = path.parse(process.cwd()).root; + const home = path.join(root, 'home', 'tester'); + const project = { name: 'app', uri: Uri.file(path.join(home, 'code', 'app')) }; + let findFilesStub: sinon.SinonStub; + let getWorkspaceFoldersStub: sinon.SinonStub; + let getUserHomeDirStub: sinon.SinonStub; + let mockApi: { getPythonProject: (uri: Uri) => PythonProject | undefined }; + + function folder(fsPath: string, index = 0): WorkspaceFolder { + return { uri: Uri.file(fsPath), name: path.basename(fsPath), index }; + } + + setup(() => { + findFilesStub = sinon.stub(wapi, 'findFiles').resolves([]); + getWorkspaceFoldersStub = sinon.stub(wapi, 'getWorkspaceFolders').returns([folder(project.uri.fsPath)]); + getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir').returns(home); + mockApi = { getPythonProject: () => project }; + }); + + teardown(() => { + sinon.restore(); + }); + + test('caps each search at 10,000 files', async () => { + await getProjectInstallable(mockApi as PythonEnvironmentApi, [project], { runHeadless: true }); + + assert.deepStrictEqual( + findFilesStub.getCalls().map((call) => [call.args[0], call.args[2]]), + [ + ['**/*requirements*.txt', 10_000], + ['*requirements*.txt', 10_000], + ['**/requirements/*.txt', 10_000], + ['**/pyproject.toml', 10_000], + ], + ); + }); + + test('keeps the dependency files of a search that runs for minutes', async () => { + const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const requirement = Uri.file(path.join(project.uri.fsPath, 'requirements.txt')); + // Like VS Code, a cancelled search returns an empty list, not the matches found so far. + findFilesStub.callsFake( + (include: string, _exclude: string, _maxResults: number, token?: CancellationToken) => + new Promise((resolve) => { + token?.onCancellationRequested(() => resolve([])); + setTimeout(() => resolve(include === '**/*requirements*.txt' ? [requirement] : []), 10 * 60_000); + }), + ); + try { + const pending = getProjectInstallable(mockApi as PythonEnvironmentApi, [project], { runHeadless: true }); + + await clock.tickAsync(10 * 60_000); + + const result = await pending; + assert.deepStrictEqual( + result.installables.map((item) => item.uri?.fsPath), + [requirement.fsPath], + ); + } finally { + clock.restore(); + } + }); + + suite('in a home folder workspace', () => { + let diskHome: string; + let diskProject: { name: string; uri: Uri }; + + setup(async () => { + diskHome = await fse.mkdtemp(path.join(os.tmpdir(), 'piputils-home-')); + diskProject = { name: 'app', uri: Uri.file(path.join(diskHome, 'code', 'app')) }; + await fse.ensureDir(diskProject.uri.fsPath); + getUserHomeDirStub.returns(diskHome); + getWorkspaceFoldersStub.returns([folder(diskHome)]); + }); + + teardown(async () => { + await fse.remove(diskHome); + }); + + test('searches the project folder and keeps its dependency files', async () => { + const requirement = Uri.file(path.join(diskProject.uri.fsPath, 'requirements.txt')); + mockApi.getPythonProject = () => diskProject; + findFilesStub.callsFake((include: RelativePattern) => + Promise.resolve(include.pattern === '**/*requirements*.txt' ? [requirement] : []), + ); + + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [diskProject], { + runHeadless: true, + preferredRoot: diskProject.uri, + }); + + assert.strictEqual(findFilesStub.callCount, 4); + for (const call of findFilesStub.getCalls()) { + const include = call.args[0]; + assert.ok(include instanceof RelativePattern, 'each search should be scoped to the project'); + assert.strictEqual(normalizePath(include.baseUri.fsPath), normalizePath(diskProject.uri.fsPath)); + } + assert.deepStrictEqual( + result.installables.map((item) => item.uri?.fsPath), + [requirement.fsPath], + ); + }); + + test('does not search a project that is the home folder', async () => { + const homeProject = { name: 'home', uri: Uri.file(diskHome) }; + mockApi.getPythonProject = () => homeProject; + + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [homeProject], { + runHeadless: true, + }); + + assert.ok(findFilesStub.notCalled, 'the home folder should not be searched'); + assert.deepStrictEqual(result.installables, []); + }); + }); + + test('still searches the other workspace folders when one is the home folder', async () => { + const service = path.join(root, 'work', 'service'); + const serviceProject = { name: 'service', uri: Uri.file(service) }; + const requirement = Uri.file(path.join(service, 'requirements.txt')); + getWorkspaceFoldersStub.returns([folder(home, 0), folder(service, 1)]); + mockApi.getPythonProject = () => serviceProject; + findFilesStub.callsFake((include: RelativePattern) => + Promise.resolve(include.pattern === '*requirements*.txt' ? [requirement] : []), + ); + + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [serviceProject], { + runHeadless: true, + }); + + assert.strictEqual(findFilesStub.callCount, 4); + for (const call of findFilesStub.getCalls()) { + const include = call.args[0]; + assert.ok(include instanceof RelativePattern, 'each search should be scoped to one folder'); + assert.strictEqual(normalizePath(include.baseUri.fsPath), normalizePath(service)); + } + assert.deepStrictEqual( + result.installables.map((item) => item.uri?.fsPath), + [requirement.fsPath], + ); + }); +}); diff --git a/src/test/mocks/vsc/extHostedTypes.ts b/src/test/mocks/vsc/extHostedTypes.ts index c929f1db0..f61033879 100644 --- a/src/test/mocks/vsc/extHostedTypes.ts +++ b/src/test/mocks/vsc/extHostedTypes.ts @@ -2063,19 +2063,19 @@ export class RelativePattern implements IRelativePattern { pattern: string; - constructor(base: vscode.WorkspaceFolder | string, pattern: string) { - if (typeof base !== 'string') { - if (!base || !vscUri.URI.isUri(base.uri)) { - throw illegalArgument('base'); - } + constructor(base: vscode.WorkspaceFolder | vscode.Uri | string, pattern: string) { + // Like VS Code's RelativePattern, the base can be a Uri as well as a workspace folder. + const baseUri = typeof base === 'string' || !base || !('uri' in base) ? base : base.uri; + if (typeof baseUri !== 'string' && !vscUri.URI.isUri(baseUri)) { + throw illegalArgument('base'); } if (typeof pattern !== 'string') { throw illegalArgument('pattern'); } - this.baseUri = typeof base === 'string' ? vscUri.URI.parse(base) : base.uri; - this.base = typeof base === 'string' ? base : base.uri.fsPath; + this.baseUri = typeof baseUri === 'string' ? vscUri.URI.parse(baseUri) : baseUri; + this.base = typeof baseUri === 'string' ? baseUri : baseUri.fsPath; this.pattern = pattern; }