Skip to content
Open
22 changes: 12 additions & 10 deletions src/common/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.',
);
Expand All @@ -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,
)
Expand Down
119 changes: 119 additions & 0 deletions src/common/workspaceSearch.ts
Original file line number Diff line number Diff line change
@@ -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<Uri[]> {
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<Uri[]> {
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<boolean> {
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<Uri[]> {
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;
}
38 changes: 30 additions & 8 deletions src/features/creators/autoFindProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Uri[] | undefined> {
const items = getUniqueUri(uris);

Expand Down Expand Up @@ -91,9 +107,14 @@ export class AutoFindProjects implements PythonProjectCreator {
}

async create(_options?: PythonProjectCreatorOptions): Promise<PythonProject | PythonProject[] | undefined> {
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) =>
Expand All @@ -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;
}
Expand Down
39 changes: 27 additions & 12 deletions src/features/inlineScript/setupEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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, () =>
Expand Down
Loading