diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index b374c38d1..68958773b 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -606,3 +606,4 @@ envConfig.inspect - **Never skip tests to hide infrastructure problems**: If tests require native binaries (like `pet`), the CI workflow must build/download them. Skipping tests when infrastructure is missing gives false confidence. Build from source (like vscode-python does) rather than skipping. Tests should fail clearly when something is wrong (2) - **No retries for masking flakiness**: Mocha `retries` should not be used to mask test flakiness. If a test is flaky, fix the root cause. Retries hide real issues and slow down CI (1) - **pet binary is required for environment manager registration**: The smoke/E2E/integration tests require the `pet` binary from `microsoft/python-environment-tools` to be built and placed in `python-env-tools/bin/`. Without it, `waitForApiReady()` will timeout because managers never register. CI must build pet from source using `cargo build --release --package pet` (2) +- **Check exact project registration with `getPythonProjects()`**: `getPythonProject(uri)` can return a containing parent project, so it cannot prove that a nested project was registered or unregistered (1) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 1298ffa0b..9297f7df6 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -335,6 +335,14 @@ jobs: if: runner.os != 'Linux' run: npm run integration-test + - name: Run Package Manager Network Integration Tests + if: runner.os == 'Linux' && matrix.python-version == '3.12' + uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 + env: + VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' + with: + run: npm run integration-test -- --grep "Package Manager" + integration-tests-multiroot: name: Integration Tests (Multi-Root) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index 96867be26..23db9b117 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -335,3 +335,11 @@ jobs: - name: Run Integration Tests (non-Linux) if: runner.os != 'Linux' run: npm run integration-test + + - name: Run Package Manager Network Integration Tests + if: runner.os == 'Linux' && matrix.python-version == '3.12' + uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 + env: + VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' + with: + run: npm run integration-test -- --grep "Package Manager" diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 082eac300..616edca22 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the `@vscode/python-environments` API package are documen The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] + +### Added + +- Added `PackageManagementInteractionOptions` with an optional `runHeadless?: boolean` property, mixed into `PackageManagementOptions`. When `true`, package management operations run without any user prompts or interaction — steps that would normally require input, such as selecting packages to install when none are specified, are skipped instead of prompting — for automated or headless scenarios such as integration tests. +- Added `RemoveEnvironmentOptions` with an optional `runHeadless?: boolean` property to remove environments without a confirmation prompt in automated or headless scenarios. + ## [1.1.0] ### Added diff --git a/api/package-lock.json b/api/package-lock.json index 8363de4f9..7745eab9a 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/python-environments", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/python-environments", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "@renovatebot/pep440": "^3.1.0" diff --git a/api/package.json b/api/package.json index 7f68cf0b2..6b1c6e70e 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@vscode/python-environments", "description": "An API facade for the Python Environments extension in VS Code", - "version": "1.1.0", + "version": "1.2.0", "author": { "name": "Microsoft Corporation" }, diff --git a/examples/sample1/src/api.ts b/examples/sample1/src/api.ts index c45ae1cbd..00512e001 100644 --- a/examples/sample1/src/api.ts +++ b/examples/sample1/src/api.ts @@ -329,6 +329,17 @@ export interface QuickCreateConfig { readonly detail?: string; } +/** + * Options controlling environment removal. + */ +export interface RemoveEnvironmentOptions { + /** + * When `true`, removes the environment without prompting for confirmation. + * Intended for automated or headless scenarios. Defaults to `false`. + */ + runHeadless?: boolean; +} + /** * Interface representing an environment manager. */ @@ -392,7 +403,7 @@ export interface EnvironmentManager { * @param environment - The Python environment to remove. * @returns A promise that resolves when the environment is removed. */ - remove?(environment: PythonEnvironment): Promise; + remove?(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise; /** * Refreshes the list of Python environments within the specified scope. @@ -739,49 +750,62 @@ export interface GetPackagesOptions { } /** - * Options for package management. + * Options controlling user interaction during package management operations. */ -export type PackageManagementOptions = - | { - /** - * Upgrade the packages if it is already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install: string[]; - - /** - * The list of packages to uninstall. - */ - uninstall?: string[]; - } - | { - /** - * Upgrade the packages if it is already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install?: string[]; +export interface PackageManagementInteractionOptions { + /** + * When `true`, the package management operation runs without any user prompts or + * interaction and relies solely on the packages provided in the options. Any step + * that would normally require user input — such as selecting packages to install + * when none are specified — is skipped instead of prompting the user. Intended for + * automated or headless scenarios such as integration tests. Defaults to `false`. + */ + runHeadless?: boolean; +} - /** - * The list of packages to uninstall. - */ - uninstall: string[]; - }; +export type PackageManagementOptions = PackageManagementInteractionOptions & + ( + | { + /** + * Upgrade the packages if it is already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall?: string[]; + } + | { + /** + * Upgrade the packages if it is already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install?: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall: string[]; + } + ); /** * Options for creating a Python environment. @@ -881,9 +905,10 @@ export interface PythonEnvironmentManagementApi { * Remove a Python environment. * * @param environment The Python environment to remove. + * @param options Optional parameters controlling environment removal. * @returns A promise that resolves when the environment has been removed. */ - removeEnvironment(environment: PythonEnvironment): Promise; + removeEnvironment(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise; } export interface PythonEnvironmentsApi { diff --git a/files/logo.svg b/files/logo.svg index a999dbeac..f849d01e2 100644 --- a/files/logo.svg +++ b/files/logo.svg @@ -1,14 +1,12 @@ - - - + + + + + + - - - - - - - - + + + diff --git a/src/api.ts b/src/api.ts index 5d63a3aef..2779d27b0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -345,6 +345,17 @@ export interface QuickCreateConfig { readonly detail?: string; } +/** + * Options controlling environment removal. + */ +export interface RemoveEnvironmentOptions { + /** + * When `true`, removes the environment without prompting for confirmation. + * Intended for automated or headless scenarios. Defaults to `false`. + */ + runHeadless?: boolean; +} + /** * Interface representing an environment manager. * @@ -425,7 +436,7 @@ export interface EnvironmentManager { * Invoked to delete the given environment. Typical triggers include an explicit user * action (such as a "Delete Environment" command) and programmatic removal via the API. */ - remove?(environment: PythonEnvironment): Promise; + remove?(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise; /** * Refreshes the list of Python environments within the specified scope. @@ -872,47 +883,63 @@ export interface GetPackagesOptions { skipCache?: boolean; } -export type PackageManagementOptions = - | { - /** - * Upgrade the packages if they are already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation or uninstallation. - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install: string[]; - - /** - * The list of packages to uninstall. - */ - uninstall?: string[]; - } - | { - /** - * Upgrade the packages if they are already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation or uninstallation. - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install?: string[]; +/** + * Options controlling user interaction during package management operations. + */ +export interface PackageManagementInteractionOptions { + /** + * When `true`, the package management operation runs without any user prompts or + * interaction and relies solely on the packages provided in the options. Any step + * that would normally require user input — such as selecting packages to install + * when none are specified — is skipped instead of prompting the user. Intended for + * automated or headless scenarios such as integration tests. Defaults to `false`. + */ + runHeadless?: boolean; +} - /** - * The list of packages to uninstall. - */ - uninstall: string[]; - }; +export type PackageManagementOptions = PackageManagementInteractionOptions & + ( + | { + /** + * Upgrade the packages if they are already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall?: string[]; + } + | { + /** + * Upgrade the packages if they are already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install?: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall: string[]; + } + ); /** * Options for creating a Python environment. @@ -1011,9 +1038,10 @@ export interface PythonEnvironmentManagementApi { * Remove a Python environment. * * @param environment The Python environment to remove. + * @param options Optional parameters controlling environment removal. * @returns A promise that resolves when the environment has been removed. */ - removeEnvironment(environment: PythonEnvironment): Promise; + removeEnvironment(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise; } export interface PythonEnvironmentsApi { diff --git a/src/common/lockfile.apis.ts b/src/common/lockfile.apis.ts index 1d8409056..cb8ff8032 100644 --- a/src/common/lockfile.apis.ts +++ b/src/common/lockfile.apis.ts @@ -16,13 +16,33 @@ export interface AcquiredFileLock { readonly retain: () => Promise; } +export const FILE_LOCK_DIR_SUFFIX = '.lock'; +export const FILE_LOCK_OWNER_MARKER_PREFIX = 'owner-'; +export const FILE_LOCK_RETAINED_MARKER_PREFIX = 'retained-'; +/** Legacy retained marker. It remains recognizable but cannot be safely reclaimed. */ +export const FILE_LOCK_RETAINED_MARKER = 'retained'; + +export type ProcessLiveness = 'live' | 'dead' | 'unavailable'; +export type FileLockState = 'missing' | 'held' | 'retained' | 'stale' | 'orphaned' | 'malformed' | 'unavailable'; + +export interface InspectFileLockOptions { + readonly checkProcessLiveness?: (pid: number) => Promise; +} + type LockState = 'held' | 'released' | 'retained'; +export function getFileLockPath(filePath: string): string { + return `${path.resolve(filePath)}${FILE_LOCK_DIR_SUFFIX}`; +} + /** Acquire an atomic lock released only explicitly; interrupted operations remain locked. */ export async function acquireFileLock(filePath: string, options: AcquireFileLockOptions): Promise { - const lockPath = `${path.resolve(filePath)}.lock`; - const ownerMarker = path.join(lockPath, `owner-${process.pid}-${crypto.randomBytes(16).toString('hex')}`); - const retainedMarker = path.join(lockPath, 'retained'); + const lockPath = getFileLockPath(filePath); + const ownerMarker = path.join( + lockPath, + `${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-${crypto.randomBytes(16).toString('hex')}`, + ); + const retainedMarker = path.join(lockPath, getRetainedMarkerName(path.basename(ownerMarker))); const deadline = Date.now() + options.timeoutMs; while (true) { @@ -51,22 +71,9 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock } state = 'retained'; try { - await fsapi.writeFile(retainedMarker, '', { flag: 'wx' }); - } catch (error) { - if (hasErrorCode(error, 'EEXIST')) { - return; - } - try { - await fsapi.rename(ownerMarker, retainedMarker); - } catch (renameError) { - if (!hasErrorCode(renameError, 'EEXIST')) { - throw createLockError( - 'Failed to mark the lock as retained', - 'ERETAINFAILED', - lockPath, - ); - } - } + await fsapi.rename(ownerMarker, retainedMarker); + } catch (_error) { + throw createLockError('Failed to mark the lock as retained', 'ERETAINFAILED', lockPath); } }, release: async () => { @@ -100,10 +107,141 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock } } -async function isRetainedLock(lockPath: string): Promise { +export async function inspectFileLock(filePath: string, options?: InspectFileLockOptions): Promise { + return (await inspectFileLockSnapshot(filePath, options)).state; +} + +interface FileLockSnapshot { + readonly state: FileLockState; + readonly marker?: string; + readonly markerKind?: 'owner' | 'retained'; +} + +async function inspectFileLockSnapshot( + filePath: string, + options?: InspectFileLockOptions, +): Promise { + const lockPath = getFileLockPath(filePath); + + let stat; try { - await fsapi.lstat(path.join(lockPath, 'retained')); + stat = await fsapi.lstat(lockPath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return { state: 'missing' }; + } + throw error; + } + + if (!stat.isDirectory() || stat.isSymbolicLink()) { + return { state: 'malformed' }; + } + + const entries = await fsapi.readdir(lockPath); + const ownerEntries = entries.filter((entry) => entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX)); + const generationRetainedEntries = entries.filter((entry) => entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX)); + const retainedEntries = entries.filter((entry) => entry === FILE_LOCK_RETAINED_MARKER); + const unknownEntries = entries.filter( + (entry) => + !entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX) && + !entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX) && + entry !== FILE_LOCK_RETAINED_MARKER, + ); + + if ( + unknownEntries.length > 0 || + ownerEntries.length > 1 || + generationRetainedEntries.length > 1 || + retainedEntries.length > 1 || + generationRetainedEntries.length + retainedEntries.length > 1 || + generationRetainedEntries.length + ownerEntries.length > 1 + ) { + return { state: 'malformed' }; + } + if (retainedEntries.length === 1) { + return { state: 'retained' }; + } + if (generationRetainedEntries.length === 1) { + const retainedPid = parseMarkerPid(generationRetainedEntries[0], FILE_LOCK_RETAINED_MARKER_PREFIX); + if (retainedPid === undefined) { + return { state: 'malformed' }; + } + return { state: 'retained', marker: generationRetainedEntries[0], markerKind: 'retained' }; + } + if (ownerEntries.length === 1) { + const ownerPid = parseMarkerPid(ownerEntries[0], FILE_LOCK_OWNER_MARKER_PREFIX); + if (ownerPid === undefined) { + return { state: 'malformed' }; + } + const liveness = await (options?.checkProcessLiveness ?? getProcessLiveness)(ownerPid); + if (liveness === 'dead') { + return { state: 'stale', marker: ownerEntries[0], markerKind: 'owner' }; + } + return { state: liveness === 'live' ? 'held' : 'unavailable', marker: ownerEntries[0], markerKind: 'owner' }; + } + return { state: 'orphaned' }; +} + +/** + * Claim and remove the exact observed stale or retained generation without releasing the lock directory. + */ +export async function reclaimFileLock(filePath: string, options?: InspectFileLockOptions): Promise { + const lockPath = getFileLockPath(filePath); + const snapshot = await inspectFileLockSnapshot(filePath, options); + if ( + (snapshot.state !== 'stale' && snapshot.state !== 'retained') || + !snapshot.marker || + !snapshot.markerKind + ) { + return false; + } + + const claimedMarker = path.join( + lockPath, + `.reclaim-${process.pid}-${crypto.randomBytes(16).toString('hex')}-${snapshot.marker}`, + ); + try { + await fsapi.rename(path.join(lockPath, snapshot.marker), claimedMarker); + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'EEXIST')) { + return false; + } + throw error; + } + + try { + await fsapi.unlink(claimedMarker); + await fsapi.rmdir(lockPath); return true; + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTEMPTY')) { + return false; + } + throw error; + } +} + +export async function getProcessLiveness(pid: number): Promise { + try { + process.kill(pid, 0); + return 'live'; + } catch (error) { + if (hasErrorCode(error, 'ESRCH')) { + return 'dead'; + } + if (hasErrorCode(error, 'EPERM') || hasErrorCode(error, 'EACCES')) { + return 'unavailable'; + } + return 'unavailable'; + } +} + +async function isRetainedLock(lockPath: string): Promise { + try { + const entries = await fsapi.readdir(lockPath); + return entries.some( + (entry) => entry === FILE_LOCK_RETAINED_MARKER || entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX), + ); } catch (error) { if (hasErrorCode(error, 'ENOENT')) { return false; @@ -118,6 +256,23 @@ function hasErrorCode(error: unknown, code: string): boolean { ); } +function getRetainedMarkerName(ownerMarker: string): string { + return `${FILE_LOCK_RETAINED_MARKER_PREFIX}${ownerMarker.slice(FILE_LOCK_OWNER_MARKER_PREFIX.length)}`; +} + +function parseMarkerPid(entry: string, prefix: string): number | undefined { + const match = entry.match(new RegExp(`^${escapeRegExp(prefix)}(\\d+)-.+$`)); + if (!match) { + return undefined; + } + const pid = Number(match[1]); + return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function createLockError(message: string, code: string, lockPath: string): NodeJS.ErrnoException { return Object.assign(new Error(message), { code, path: lockPath }); } diff --git a/src/extension.ts b/src/extension.ts index 1d2d98aee..f45d46aa2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -45,6 +45,7 @@ import { ProjectCreatorsImpl } from './features/creators/projectCreators'; import { addPythonProjectCommand, copyPathToClipboard, + clearScriptEnvironmentCacheCommand, createAnyEnvironmentCommand, createEnvironmentCommand, createTerminalCommand, @@ -94,7 +95,12 @@ import { PythonStatusBarImpl } from './features/views/pythonStatusBar'; import { updateViewsAndStatus } from './features/views/revealHandler'; import { TemporaryStateManager } from './features/views/temporaryStateManager'; import { PythonEnvTreeItem } from './features/views/treeViewItems'; -import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, runPetInTerminalImpl } from './helpers'; +import { + collectEnvironmentInfo, + getEnvManagerAndPackageManagerConfigLevels, + isInlineScriptsFeatureEnabled, + runPetInTerminalImpl, +} from './helpers'; import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api'; import { registerInlineScriptFeatures } from './managers/builtin/inlineScript/main'; import { registerSystemPythonFeatures } from './managers/builtin/main'; @@ -258,6 +264,21 @@ export async function activate(context: ExtensionContext): Promise + envManagers.packageManagers.map((manager) => manager.id), + ), + commands.registerCommand( + 'python-envs.test.getDirectPackageNames', + async (environment: PythonEnvironment) => { + const manager = envManagers.getPackageManager(environment); + const names = await manager?.getDirectPackageNames?.(environment); + return names ? Array.from(names) : undefined; + }, + ), + ] + : []), commands.registerCommand('python-envs.searchSettings', async () => { await openSearchSettings(); }), @@ -371,6 +392,13 @@ export async function activate(context: ExtensionContext): Promise { + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + }), + ] + : []), commands.registerCommand('python-envs.runInTerminal', (item) => { return runInTerminalCommand(item, api, terminalManager); }), diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 1de8a13a6..fafb6fbcd 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -26,7 +26,12 @@ import { ProjectCreators, PythonProjectManager, } from '../internal.api'; -import { removePythonProjectSetting, setEnvironmentManager, setPackageManager } from './settings/settingHelpers'; +import { + removeInlineScriptPythonProjectSettings, + removePythonProjectSetting, + setEnvironmentManager, + setPackageManager, +} from './settings/settingHelpers'; import { valid as pep440Valid } from '@renovatebot/pep440'; import { executeCommand } from '../common/command.api'; @@ -50,8 +55,10 @@ import { showInputBox, showOpenDialog, showQuickPick, + showWarningMessage, withProgress, } from '../common/window.apis'; +import { INLINE_SCRIPT_MANAGER_ID } from '../common/constants'; import { runAsTask } from './execution/runAsTask'; import { runInTerminal } from './terminal/runInTerminal'; import { TerminalManager } from './terminal/terminalManager'; @@ -662,6 +669,36 @@ export async function removePythonProject( wm.remove(item.project); } +export async function clearScriptEnvironmentCacheCommand( + em: EnvironmentManagers, + wm: PythonProjectManager, +): Promise { + const manager = em.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID); + if (!manager || !manager.supportsClearCache()) { + throw new Error( + l10n.t('Inline-script environment cache is unavailable because the inline-script manager is not registered.'), + ); + } + + const clearLabel = l10n.t('Clear Cache'); + const confirmation = await showWarningMessage( + l10n.t( + 'This will delete all cached inline-script environments, forget their script associations, and remove inline-script project entries from settings.', + ), + { modal: true }, + clearLabel, + ); + if (confirmation !== clearLabel) { + return; + } + + await manager.clearCache(); + const loadedProjectsToRemove = await removeInlineScriptPythonProjectSettings(wm.getProjects()); + if (loadedProjectsToRemove.length > 0) { + wm.remove(loadedProjectsToRemove); + } +} + export async function getPackageCommandOptions( e: unknown, em: EnvironmentManagers, diff --git a/src/features/envManagers.ts b/src/features/envManagers.ts index 9fa545d3b..d68973c9c 100644 --- a/src/features/envManagers.ts +++ b/src/features/envManagers.ts @@ -320,12 +320,16 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { public async clearCache(scope: EnvironmentManagerScope): Promise { if (scope === undefined) { - await Promise.all(this.managers.map((m) => m.clearCache())); + await Promise.all( + this.managers + .filter((manager) => manager.id !== INLINE_SCRIPT_MANAGER_ID) + .map((manager) => manager.clearCache()), + ); return; } const manager = this.getEnvironmentManager(scope); - if (manager) { + if (manager && manager.id !== INLINE_SCRIPT_MANAGER_ID) { await manager.clearCache(); } } diff --git a/src/features/projectManager.ts b/src/features/projectManager.ts index 9c1cf7bb3..31dd86cd0 100644 --- a/src/features/projectManager.ts +++ b/src/features/projectManager.ts @@ -130,20 +130,17 @@ export class PythonProjectManagerImpl implements PythonProjectManager { // For each override, resolve its path and add as a project if not already present for (const o of overrides) { let uriFromWorkspace: Uri | undefined = undefined; - // if override has a workspace property, resolve the path relative to that workspace if (o.workspace) { - // const workspaceFolder = workspaces.find((ws) => ws.name === o.workspace); if (workspaceFolder) { if (workspaceFolder.uri.toString() !== w.uri.toString()) { - continue; // skip if the workspace is not the same as the current workspace + continue; } uriFromWorkspace = Uri.file(path.resolve(workspaceFolder.uri.fsPath, o.path)); } } const uri = uriFromWorkspace ? uriFromWorkspace : Uri.file(path.resolve(w.uri.fsPath, o.path)); - // Check if the project already exists in the newProjects array if (!newProjects.some((p) => p.uri.toString() === uri.toString())) { newProjects.push(new PythonProjectsImpl(o.path, uri)); } diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index 9c494b9eb..e93ed0cdb 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -29,6 +29,7 @@ import { PythonTerminalCreateOptions, PythonTerminalExecutionOptions, RefreshEnvironmentsScope, + RemoveEnvironmentOptions, ResolveEnvironmentContext, SetEnvironmentScope, } from '../api'; @@ -107,9 +108,7 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { this.previousProjects = current; if (added.length > 0 || removed.length > 0) { - traceInfo( - `Python API: Projects changed. Added: ${added.length}, Removed: ${removed.length}`, - ); + traceInfo(`Python API: Projects changed. Added: ${added.length}, Removed: ${removed.length}`); this._onDidChangePythonProjects.fire({ added, removed }); } }), @@ -197,13 +196,13 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { return result; } } - async removeEnvironment(environment: PythonEnvironment): Promise { + async removeEnvironment(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise { await waitForEnvManagerId([environment.envId.managerId]); const manager = this.envManagers.getEnvironmentManager(environment); if (!manager) { return Promise.reject(new Error('No environment manager found')); } - return manager.remove(environment); + return manager.remove(environment, options); } async refreshEnvironments(scope: RefreshEnvironmentsScope): Promise { const currentScope = checkUri(scope) as RefreshEnvironmentsScope; diff --git a/src/features/settings/settingHelpers.ts b/src/features/settings/settingHelpers.ts index 752a1c2c0..24189aacb 100644 --- a/src/features/settings/settingHelpers.ts +++ b/src/features/settings/settingHelpers.ts @@ -1,7 +1,12 @@ import * as path from 'path'; import { ConfigurationScope, ConfigurationTarget, Uri, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; import { PythonProject } from '../../api'; -import { DEFAULT_ENV_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID, SYSTEM_MANAGER_ID } from '../../common/constants'; +import { + DEFAULT_ENV_MANAGER_ID, + DEFAULT_PACKAGE_MANAGER_ID, + INLINE_SCRIPT_MANAGER_ID, + SYSTEM_MANAGER_ID, +} from '../../common/constants'; import { traceError, traceInfo, traceVerbose, traceWarn } from '../../common/logging'; import { getGlobalPersistentState } from '../../common/persistentState'; import { normalizePath } from '../../common/utils/pathUtils'; @@ -10,6 +15,123 @@ import { sendTelemetryEvent } from '../../common/telemetry/sender'; import * as workspaceApis from '../../common/workspace.apis'; import { PythonProjectManager, PythonProjectSettings } from '../../internal.api'; +interface ResolvedPythonProjectSettingSource { + readonly setting: PythonProjectSettings; + readonly uri: Uri; + readonly workspaceFolder: WorkspaceFolder; + readonly source: 'global' | 'workspace' | 'workspaceFolder'; + readonly target: ConfigurationTarget.Global | ConfigurationTarget.Workspace | ConfigurationTarget.WorkspaceFolder; +} + +interface ResolvedPythonProjectSetting { + readonly uri: Uri; + readonly workspaceFolder: WorkspaceFolder; + readonly effective: ResolvedPythonProjectSettingSource; + readonly sources: readonly ResolvedPythonProjectSettingSource[]; +} + +function resolvePythonProjectSettingSource( + setting: PythonProjectSettings, + workspaceFolder: WorkspaceFolder, + allWorkspaceFolders: readonly WorkspaceFolder[], + source: 'global' | 'workspace' | 'workspaceFolder', + target: ConfigurationTarget.Global | ConfigurationTarget.Workspace | ConfigurationTarget.WorkspaceFolder, +): ResolvedPythonProjectSettingSource | undefined { + const resolvedWorkspaceFolder = setting.workspace + ? allWorkspaceFolders.find((candidate) => candidate.name === setting.workspace) + : workspaceFolder; + if (!resolvedWorkspaceFolder || resolvedWorkspaceFolder.uri.toString() !== workspaceFolder.uri.toString()) { + return undefined; + } + return { + setting, + uri: Uri.file(path.resolve(resolvedWorkspaceFolder.uri.fsPath, setting.path)), + workspaceFolder, + source, + target, + }; +} + +function resolveProjectSettingUri( + setting: PythonProjectSettings, + workspaceFolder: WorkspaceFolder, + allWorkspaceFolders: readonly WorkspaceFolder[] = workspaceApis.getWorkspaceFolders() ?? [workspaceFolder], +): Uri | undefined { + const resolvedWorkspaceFolder = setting.workspace + ? allWorkspaceFolders.find((candidate) => candidate.name === setting.workspace) + : workspaceFolder; + return resolvedWorkspaceFolder + ? Uri.file(path.resolve(resolvedWorkspaceFolder.uri.fsPath, setting.path)) + : undefined; +} + +function getResolvedPythonProjectSettings( + workspaceFolder: WorkspaceFolder, + config: WorkspaceConfiguration = workspaceApis.getConfiguration('python-envs', workspaceFolder.uri), +): ResolvedPythonProjectSetting[] { + const allWorkspaceFolders = workspaceApis.getWorkspaceFolders() ?? [workspaceFolder]; + const projectsInspect = + typeof config.inspect === 'function' ? config.inspect('pythonProjects') : undefined; + const fallbackSettings = + projectsInspect === undefined ? config.get('pythonProjects', []) : undefined; + const orderedSources: ResolvedPythonProjectSettingSource[] = [ + ...(projectsInspect?.globalValue ?? []) + .map((setting) => + resolvePythonProjectSettingSource( + setting, + workspaceFolder, + allWorkspaceFolders, + 'global', + ConfigurationTarget.Global, + ), + ) + .filter((setting): setting is ResolvedPythonProjectSettingSource => setting !== undefined), + ...(projectsInspect?.workspaceValue ?? fallbackSettings ?? []) + .map((setting) => + resolvePythonProjectSettingSource( + setting, + workspaceFolder, + allWorkspaceFolders, + 'workspace', + ConfigurationTarget.Workspace, + ), + ) + .filter((setting): setting is ResolvedPythonProjectSettingSource => setting !== undefined), + ...(projectsInspect?.workspaceFolderValue ?? []) + .map((setting) => + resolvePythonProjectSettingSource( + setting, + workspaceFolder, + allWorkspaceFolders, + 'workspaceFolder', + ConfigurationTarget.WorkspaceFolder, + ), + ) + .filter((setting): setting is ResolvedPythonProjectSettingSource => setting !== undefined), + ]; + + const grouped = new Map(); + for (const source of orderedSources) { + const key = source.uri.toString(); + const existing = grouped.get(key); + if (existing) { + grouped.set(key, { + ...existing, + effective: source, + sources: [...existing.sources, source], + }); + } else { + grouped.set(key, { + uri: source.uri, + workspaceFolder, + effective: source, + sources: [source], + }); + } + } + return Array.from(grouped.values()); +} + function getSettings( wm: PythonProjectManager, config: WorkspaceConfiguration, @@ -349,6 +471,178 @@ export interface EditProjectSettings { workspace?: string; } +function matchesProjectSettingEdit( + setting: PythonProjectSettings, + edit: EditProjectSettings, + workspaceFolder: WorkspaceFolder, +): boolean { + const projectPath = normalizePath(edit.project.uri.fsPath); + const settingUri = resolveProjectSettingUri(setting, workspaceFolder); + if (!settingUri || normalizePath(settingUri.fsPath) !== projectPath) { + return false; + } + if (edit.workspace !== undefined && setting.workspace !== edit.workspace) { + return false; + } + if (edit.envManager !== undefined && setting.envManager !== edit.envManager) { + return false; + } + if (edit.packageManager !== undefined && setting.packageManager !== edit.packageManager) { + return false; + } + return true; +} + +function hasProjectSetting( + settings: readonly PythonProjectSettings[], + project: PythonProject, + workspaceFolder: WorkspaceFolder, +): boolean { + const projectPath = normalizePath(project.uri.fsPath); + return settings.some((setting) => { + const settingUri = resolveProjectSettingUri(setting, workspaceFolder); + return settingUri ? normalizePath(settingUri.fsPath) === projectPath : false; + }); +} + +function cloneProjectSettings( + settings: readonly PythonProjectSettings[] | undefined, +): PythonProjectSettings[] | undefined { + return settings?.map((setting) => ({ ...setting })); +} + +export async function removeInlineScriptPythonProjectSettings( + currentProjects: readonly PythonProject[], +): Promise { + const currentProjectsByUri = new Map(currentProjects.map((project) => [project.uri.toString(), project] as const)); + const workspaceEntries: Array = []; + for (const workspaceFolder of workspaceApis.getWorkspaceFolders() ?? []) { + const edits: EditProjectSettings[] = getResolvedPythonProjectSettings(workspaceFolder) + .filter((resolvedSetting) => + resolvedSetting.sources.some((source) => source.setting.envManager === INLINE_SCRIPT_MANAGER_ID), + ) + .map((resolvedSetting) => ({ + project: + currentProjectsByUri.get(resolvedSetting.uri.toString()) ?? { + name: path.basename(resolvedSetting.uri.fsPath) || resolvedSetting.effective.setting.path, + uri: resolvedSetting.uri, + }, + envManager: INLINE_SCRIPT_MANAGER_ID, + })); + + if (edits.length > 0) { + workspaceEntries.push([workspaceFolder, edits]); + } + } + + if (workspaceEntries.length === 0) { + return []; + } + + const removedProjects = new Map(); + const folderRemainingSettings = new Map(); + const folderExistingSettings = new Map(); + const promises: Thenable[] = []; + let globalConfig: WorkspaceConfiguration | undefined; + let globalValueOriginal: PythonProjectSettings[] | undefined; + let workspaceConfig: WorkspaceConfiguration | undefined; + let workspaceValueOriginal: PythonProjectSettings[] | undefined; + + workspaceEntries.forEach(([workspaceFolder, edits]) => { + const config = workspaceApis.getConfiguration('python-envs', workspaceFolder.uri); + const projectsInspect = config.inspect('pythonProjects'); + globalConfig ??= config; + globalValueOriginal ??= cloneProjectSettings(projectsInspect?.globalValue); + workspaceConfig ??= config; + workspaceValueOriginal ??= cloneProjectSettings(projectsInspect?.workspaceValue); + + const workspaceFolderOriginal = cloneProjectSettings(projectsInspect?.workspaceFolderValue) ?? []; + folderExistingSettings.set(workspaceFolder.uri.toString(), workspaceFolderOriginal); + const workspaceFolderRemaining = workspaceFolderOriginal.filter( + (projectSetting) => !edits.some((edit) => matchesProjectSettingEdit(projectSetting, edit, workspaceFolder)), + ); + folderRemainingSettings.set(workspaceFolder.uri.toString(), workspaceFolderRemaining); + + if (workspaceFolderRemaining.length !== workspaceFolderOriginal.length) { + promises.push( + config.update( + 'pythonProjects', + workspaceFolderRemaining.length > 0 ? workspaceFolderRemaining : undefined, + ConfigurationTarget.WorkspaceFolder, + ), + ); + } + }); + + const aggregatedEdits = workspaceEntries.flatMap(([workspaceFolder, edits]) => + edits.map((edit) => ({ workspaceFolder, edit })), + ); + const globalValueRemaining = + globalValueOriginal?.filter( + (projectSetting) => + !aggregatedEdits.some(({ workspaceFolder, edit }) => + matchesProjectSettingEdit(projectSetting, edit, workspaceFolder), + ), + ) ?? []; + const workspaceValueRemaining = + workspaceValueOriginal?.filter( + (projectSetting) => + !aggregatedEdits.some(({ workspaceFolder, edit }) => + matchesProjectSettingEdit(projectSetting, edit, workspaceFolder), + ), + ) ?? []; + + if (globalConfig && globalValueOriginal !== undefined && globalValueRemaining.length !== globalValueOriginal.length) { + promises.push( + globalConfig.update( + 'pythonProjects', + globalValueRemaining.length > 0 ? globalValueRemaining : undefined, + ConfigurationTarget.Global, + ), + ); + } + + if ( + workspaceConfig && + workspaceValueOriginal !== undefined && + workspaceValueRemaining.length !== workspaceValueOriginal.length + ) { + promises.push( + workspaceConfig.update( + 'pythonProjects', + workspaceValueRemaining.length > 0 ? workspaceValueRemaining : undefined, + ConfigurationTarget.Workspace, + ), + ); + } + + workspaceEntries.forEach(([workspaceFolder, edits]) => { + const existingSettings = [ + ...(globalValueOriginal ?? []), + ...(workspaceValueOriginal ?? []), + ...((folderExistingSettings.get(workspaceFolder.uri.toString()) ?? [])), + ]; + const remainingSettings = [ + ...globalValueRemaining, + ...workspaceValueRemaining, + ...((folderRemainingSettings.get(workspaceFolder.uri.toString()) ?? [])), + ]; + edits.filter( + (edit) => + existingSettings.some((projectSetting) => matchesProjectSettingEdit(projectSetting, edit, workspaceFolder)) && + !hasProjectSetting(remainingSettings, edit.project, workspaceFolder), + ).forEach((edit) => { + removedProjects.set(edit.project.uri.toString(), edit.project); + }); + }); + + await Promise.all(promises); + + return Array.from(removedProjects.values()) + .map((project) => currentProjectsByUri.get(project.uri.toString())) + .filter((project): project is PythonProject => project !== undefined); +} + export async function addPythonProjectSetting(edits: EditProjectSettings[]): Promise { const noWorkspace: EditProjectSettings[] = []; const workspaces = new Map(); diff --git a/src/internal.api.ts b/src/internal.api.ts index 9b09d5cf8..6d41cb5c3 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -26,6 +26,7 @@ import { PythonProjectCreator, QuickCreateConfig, RefreshEnvironmentsScope, + RemoveEnvironmentOptions, ResolveEnvironmentContext, SetEnvironmentScope, } from './api'; @@ -208,9 +209,9 @@ export class InternalEnvironmentManager implements EnvironmentManager { return this.manager.remove !== undefined; } - remove(scope: PythonEnvironment): Promise { + remove(scope: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise { return this.manager.remove - ? this.manager.remove(scope) + ? this.manager.remove(scope, options) : Promise.reject(new RemoveEnvironmentNotSupported(`Remove Environment not supported by: ${this.id}`)); } @@ -405,6 +406,12 @@ export class InternalPackageManager implements PackageManager { : Promise.resolve(undefined); } + getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { + return this.manager.getDirectPackageNames + ? this.manager.getDirectPackageNames(environment) + : Promise.resolve(undefined); + } + formatInstallSpec(packageName: string, version: string): string { return this.manager.formatInstallSpec ? this.manager.formatInstallSpec(packageName, version) diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index d68d9dda6..e230ce7e4 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -24,6 +24,7 @@ import { getErrorMessage } from '../../../common/errors/utils'; import { computeCacheKey, normalizeDependency } from '../../../common/inlineScript/cacheKey'; import { CacheEnvironmentInspection, + INLINE_SCRIPT_CACHE_DIR_NAME, META_SCHEMA_VERSION, getBaseInterpreterStatus, getScriptEnvCacheRoot, @@ -42,8 +43,16 @@ import { PYENV_MANAGER_ID, SYSTEM_MANAGER_ID, } from '../../../common/constants'; -import { acquireFileLock, AcquiredFileLock } from '../../../common/lockfile.apis'; +import { + acquireFileLock, + AcquiredFileLock, + FILE_LOCK_DIR_SUFFIX, + getFileLockPath, + inspectFileLock, + reclaimFileLock, +} from '../../../common/lockfile.apis'; import { getWorkspacePersistentState, PersistentState } from '../../../common/persistentState'; +import { createDeferred, Deferred } from '../../../common/utils/deferred'; import { isFileNotFoundError } from '../../../common/utils/filesystem'; import { normalizePath } from '../../../common/utils/pathUtils'; import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release'; @@ -51,7 +60,12 @@ import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; import { NativePythonFinder } from '../../common/nativePythonFinder'; import { resolveSystemPythonEnvironmentPath } from '../utils'; import * as uvPythonInstaller from '../uvPythonInstaller'; -import { createWithProgress, resolveVenvPythonEnvironmentPath } from '../venvUtils'; +import { + createWithProgress, + hasMinimumPathDepth, + isDriveRoot, + resolveVenvPythonEnvironmentPath, +} from '../venvUtils'; const BASE_INTERPRETER_MANAGER_IDS = new Set([ SYSTEM_MANAGER_ID, @@ -99,6 +113,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly associationRevisions = new Map(); private persistenceQueue: Promise = Promise.resolve(); private selectionQueue: Promise = Promise.resolve(); + private cacheMaintenanceQueue: Promise = Promise.resolve(); + private cacheMaintenanceBarrier: Deferred | undefined; + private pendingCacheMaintenances = 0; + private activeCreateOperations = 0; private readonly _onDidChangeEnvironments = new EventEmitter(); public readonly onDidChangeEnvironments: Event = @@ -129,46 +147,53 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { scope: CreateEnvironmentScope, options?: CreateEnvironmentOptions, ): Promise { + this.activeCreateOperations += 1; try { - const scriptUri = this.getScriptUri(scope); - if (!scriptUri) { - this.log.warn('Inline-script environment creation requires exactly one local file URI.'); - return undefined; - } + return await this.waitForCacheMaintenance(async () => { + try { + const scriptUri = this.getScriptUri(scope); + if (!scriptUri) { + this.log.warn('Inline-script environment creation requires exactly one local file URI.'); + return undefined; + } - const metadata = await readInlineScriptMetadataFromFile(scriptUri); - if (!metadata) { - this.log.warn(`No valid PEP 723 metadata found in ${scriptUri.fsPath}.`); - return undefined; - } + const metadata = await readInlineScriptMetadataFromFile(scriptUri); + if (!metadata) { + this.log.warn(`No valid PEP 723 metadata found in ${scriptUri.fsPath}.`); + return undefined; + } - const packages = [ - ...(metadata.dependencies ?? []), - ...(options?.additionalPackages ?? []), - ].map((value) => value.trim()); - if (packages.some((value) => value.length === 0)) { - this.log.warn(`Inline-script dependencies must not contain empty entries: ${scriptUri.fsPath}.`); - return undefined; - } + const packages = [ + ...(metadata.dependencies ?? []), + ...(options?.additionalPackages ?? []), + ].map((value) => value.trim()); + if (packages.some((value) => value.length === 0)) { + this.log.warn(`Inline-script dependencies must not contain empty entries: ${scriptUri.fsPath}.`); + return undefined; + } - const setupKey = this.getPendingSetupKey(scriptUri, metadata, packages, options); - const pending = this.pendingSetups.get(setupKey); - if (pending) { - return await pending; - } + const setupKey = this.getPendingSetupKey(scriptUri, metadata, packages, options); + const pending = this.pendingSetups.get(setupKey); + if (pending) { + return await pending; + } - const setup = this.createForScript(scriptUri, metadata, packages, options); - this.pendingSetups.set(setupKey, setup); - try { - return await setup; - } finally { - if (this.pendingSetups.get(setupKey) === setup) { - this.pendingSetups.delete(setupKey); + const setup = this.createForScript(scriptUri, metadata, packages, options); + this.pendingSetups.set(setupKey, setup); + try { + return await setup; + } finally { + if (this.pendingSetups.get(setupKey) === setup) { + this.pendingSetups.delete(setupKey); + } + } + } catch (error) { + this.log.error(`Failed to set up inline-script environment: ${getErrorMessage(error)}`); + return undefined; } - } - } catch (error) { - this.log.error(`Failed to set up inline-script environment: ${getErrorMessage(error)}`); - return undefined; + }); + } finally { + this.activeCreateOperations -= 1; } } @@ -236,17 +261,24 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } async set(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise { - return this.enqueueSelection(() => this.setInternal(scope, environment)); + return this.waitForCacheMaintenance(() => this.enqueueSelection(() => this.setInternal(scope, environment))); } async get(scope: GetEnvironmentScope): Promise { - return this.getInternal(scope); + return this.waitForCacheMaintenance(() => this.getInternal(scope)); } async resolve(_context: ResolveEnvironmentContext): Promise { return undefined; } + async clearCache(): Promise { + const activeCreatesAtStart = this.activeCreateOperations; + return this.enqueueCacheMaintenance(() => + this.enqueueSelection(() => this.clearCacheInternal(activeCreatesAtStart)), + ); + } + private getScriptUri(scope: CreateEnvironmentScope): Uri | undefined { const uri = scope instanceof Uri ? scope : Array.isArray(scope) && scope.length === 1 ? scope[0] : undefined; return uri?.scheme === 'file' ? uri : undefined; @@ -760,6 +792,33 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return run; } + private async waitForCacheMaintenance(operation: () => Promise): Promise { + const barrier = this.cacheMaintenanceBarrier; + if (barrier) { + await barrier.promise; + } + return operation(); + } + + private enqueueCacheMaintenance(operation: () => Promise): Promise { + if (!this.cacheMaintenanceBarrier) { + this.cacheMaintenanceBarrier = createDeferred(); + } + this.pendingCacheMaintenances += 1; + const run = this.cacheMaintenanceQueue.then(operation); + this.cacheMaintenanceQueue = run.then( + () => undefined, + () => undefined, + ); + return run.finally(() => { + this.pendingCacheMaintenances -= 1; + if (this.pendingCacheMaintenances === 0) { + this.cacheMaintenanceBarrier?.resolve(); + this.cacheMaintenanceBarrier = undefined; + } + }); + } + private enqueueSelection(operation: () => Promise): Promise { const run = this.selectionQueue.then(operation); this.selectionQueue = run.then( @@ -772,7 +831,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private async isCacheEntryBusy(envDirPath: string): Promise { return ( this.pendingCreations.has(path.basename(envDirPath)) || - (await fs.pathExists(`${path.resolve(envDirPath)}.lock`)) + (await fs.pathExists(getFileLockPath(envDirPath))) ); } @@ -1251,6 +1310,414 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return { environment: result.environment }; } + private async clearCacheInternal(activeCreatesAtStart: number): Promise { + if (activeCreatesAtStart > 0) { + const message = l10n.t( + 'Cannot clear the script environment cache while script environments are being created.', + ); + this.log.error(message); + throw new Error(message); + } + + const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); + const physicalCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot); + const persistedAssociations = await this.getPersistedAssociationSnapshot(); + const scriptPaths = new Set([ + ...Object.keys(persistedAssociations), + ...this.associationRevisions.keys(), + ...this.cachedAssociationValidatedAt.keys(), + ...this.fsPathToEnv.keys(), + ...this.fsPathToPersistedEnvPath.keys(), + ...this.pendingRehydrations.keys(), + ]); + const priorSelections = new Map(); + scriptPaths.forEach((scriptPath) => { + priorSelections.set(scriptPath, this.fsPathToEnv.get(scriptPath)); + }); + + const removedCacheEntries = new Set(); + const deletionErrors: unknown[] = []; + if (physicalCacheRootPath) { + let entryNames: string[]; + try { + entryNames = await fs.readdir(physicalCacheRootPath); + } catch (error) { + if (isFileNotFoundError(error)) { + entryNames = []; + } else { + throw error; + } + } + + const cacheEntryNames = new Set(); + for (const entryName of entryNames) { + if (entryName.endsWith(FILE_LOCK_DIR_SUFFIX)) { + const envName = entryName.slice(0, -FILE_LOCK_DIR_SUFFIX.length); + if (envName.length === 0) { + const message = l10n.t( + 'Refusing to clear the script environment cache because a lock entry is malformed.', + ); + this.log.error(`${message} (${path.join(physicalCacheRootPath, entryName)})`); + throw new Error(message); + } + cacheEntryNames.add(envName); + } else { + cacheEntryNames.add(entryName); + } + } + + for (const entryName of cacheEntryNames) { + try { + const removed = await this.removeCacheEntryForClear( + cacheRoot, + physicalCacheRootPath, + entryName, + ); + if (removed) { + removedCacheEntries.add(normalizePath(removed)); + } + } catch (error) { + deletionErrors.push(error); + this.log.error( + `Failed to remove inline-script cache entry ${path.join(physicalCacheRootPath, entryName)}: ${getErrorMessage(error)}`, + ); + } + } + } + + const invalidatedScriptPaths = await this.getInvalidatedAssociationPaths( + scriptPaths, + persistedAssociations, + removedCacheEntries, + ); + const persistenceError = await this.clearInvalidatedAssociations( + invalidatedScriptPaths, + persistedAssociations, + priorSelections, + ); + if (persistenceError) { + deletionErrors.push(persistenceError); + } + if (deletionErrors.length > 0) { + throw new Error( + `Failed to completely clear the inline-script environment cache: ${deletionErrors + .map((error) => getErrorMessage(error)) + .join('; ')}`, + ); + } + } + + private async removeCacheEntryForClear( + cacheRoot: Uri, + originalPhysicalCacheRootPath: string, + entryName: string, + ): Promise { + const envDirPath = path.join(originalPhysicalCacheRootPath, entryName); + let lock: AcquiredFileLock | undefined; + try { + lock = await this.acquireCacheEntryLockForClear(envDirPath); + const currentPhysicalCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot); + if (!currentPhysicalCacheRootPath) { + return undefined; + } + if ( + normalizePath(currentPhysicalCacheRootPath) !== normalizePath(originalPhysicalCacheRootPath) + ) { + const message = l10n.t( + 'Refusing to clear the script environment cache because its physical root changed during cleanup.', + ); + this.log.error( + `${message} (${originalPhysicalCacheRootPath} -> ${currentPhysicalCacheRootPath})`, + ); + throw new Error(message); + } + + const entryPath = await this.getClearableCacheEntryPath( + Uri.file(currentPhysicalCacheRootPath), + path.join(currentPhysicalCacheRootPath, entryName), + ); + if (!entryPath) { + return undefined; + } + await this.deleteCacheEntryForClear(entryPath); + return entryPath; + } finally { + if (lock) { + await lock.release(); + } + } + } + + private async acquireCacheEntryLockForClear(envDirPath: string): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await acquireFileLock(envDirPath, { timeoutMs: 0, retryIntervalMs: CACHE_LOCK_RETRY_MS }); + } catch (error) { + if (!this.isLockContentionError(error)) { + throw error; + } + const lockState = await inspectFileLock(envDirPath); + if (lockState === 'stale' || lockState === 'retained') { + await reclaimFileLock(envDirPath); + continue; + } + if (lockState === 'missing') { + continue; + } + this.throwClearCacheLockError(envDirPath, lockState); + } + } + + const lockState = await inspectFileLock(envDirPath); + this.throwClearCacheLockError(envDirPath, lockState); + } + + private isLockContentionError(error: unknown): boolean { + const code = typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + return code === 'ELOCKED' || code === 'ELOCKRETAINED'; + } + + private throwClearCacheLockError(envDirPath: string, lockState: string): never { + if (lockState === 'held') { + const message = l10n.t( + 'Cannot clear the script environment cache while a cached environment is being created.', + ); + this.log.error(`${message} (${getFileLockPath(envDirPath)})`); + throw new Error(message); + } + if (lockState === 'unavailable') { + const message = l10n.t( + 'Cannot clear the script environment cache because a cached environment lock could not be verified.', + ); + this.log.error(`${message} (${getFileLockPath(envDirPath)})`); + throw new Error(message); + } + + const message = l10n.t( + 'Refusing to clear the script environment cache because a lock entry is incomplete or malformed.', + ); + this.log.error(`${message} (${getFileLockPath(envDirPath)})`); + throw new Error(message); + } + + private async getPhysicalOwnedCacheRootPath(cacheRoot: Uri): Promise { + const globalStoragePath = path.resolve(this.globalStorageUri.fsPath); + const cacheRootPath = path.resolve(cacheRoot.fsPath); + if (path.basename(cacheRootPath) !== INLINE_SCRIPT_CACHE_DIR_NAME || normalizePath(path.dirname(cacheRootPath)) !== normalizePath(globalStoragePath)) { + this.log.error(`Refusing to clear inline-script cache from unsafe root: ${cacheRootPath}`); + throw new Error(l10n.t('Refusing to clear the script environment cache from an unsafe cache root.')); + } + if (isDriveRoot(globalStoragePath) || !hasMinimumPathDepth(cacheRootPath, 3)) { + this.log.error(`Refusing to clear inline-script cache from unsafe root: ${cacheRootPath}`); + throw new Error(l10n.t('Refusing to clear the script environment cache from an unsafe cache root.')); + } + + let globalStorageStat; + try { + globalStorageStat = await fs.lstat(globalStoragePath); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + + if (!globalStorageStat.isDirectory() || globalStorageStat.isSymbolicLink()) { + this.log.error(`Refusing to clear inline-script cache from redirected globalStorage root: ${globalStoragePath}`); + throw new Error( + l10n.t('Refusing to clear the script environment cache because the global storage root is not a normal directory.'), + ); + } + + let cacheRootStat; + try { + cacheRootStat = await fs.lstat(cacheRootPath); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + + if (!cacheRootStat.isDirectory() || cacheRootStat.isSymbolicLink()) { + this.log.error(`Refusing to clear inline-script cache from redirected cache root: ${cacheRootPath}`); + throw new Error( + l10n.t('Refusing to clear the script environment cache because the cache root is not a normal directory.'), + ); + } + + let resolvedGlobalStoragePath: string; + let resolvedCacheRootPath: string; + try { + [resolvedGlobalStoragePath, resolvedCacheRootPath] = await Promise.all([ + fs.realpath(globalStoragePath), + fs.realpath(cacheRootPath), + ]); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + this.log.error(`Failed to resolve inline-script cache root physically: ${getErrorMessage(error)}`); + throw new Error( + l10n.t('Refusing to clear the script environment cache because its physical location could not be verified.'), + ); + } + + const expectedResolvedCacheRootPath = path.join(resolvedGlobalStoragePath, INLINE_SCRIPT_CACHE_DIR_NAME); + if ( + normalizePath(resolvedCacheRootPath) !== normalizePath(expectedResolvedCacheRootPath) || + normalizePath(path.dirname(resolvedCacheRootPath)) !== normalizePath(resolvedGlobalStoragePath) + ) { + this.log.error( + `Refusing to clear inline-script cache from redirected physical root: ${resolvedCacheRootPath}`, + ); + throw new Error( + l10n.t('Refusing to clear the script environment cache because the cache root is redirected.'), + ); + } + return resolvedCacheRootPath; + } + + private async getClearableCacheEntryPath(cacheRoot: Uri, entryPath: string): Promise { + let stat; + try { + stat = await fs.lstat(entryPath); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + + if (!stat.isDirectory() || stat.isSymbolicLink()) { + this.log.error(`Refusing to clear inline-script cache entry from unsafe path: ${entryPath}`); + throw new Error( + l10n.t('Refusing to clear the script environment cache because a cache entry is not a normal directory.'), + ); + } + + const resolvedEntryPath = await resolveCacheEntryPath(cacheRoot, Uri.file(entryPath)); + if (!resolvedEntryPath) { + this.log.error(`Refusing to clear inline-script cache entry outside the expected root: ${entryPath}`); + throw new Error( + l10n.t('Refusing to clear the script environment cache because a cache entry is outside the expected root.'), + ); + } + + return resolvedEntryPath; + } + + private deleteCacheEntryForClear(entryPath: string): Promise { + return fs.remove(entryPath); + } + + private async getInvalidatedAssociationPaths( + scriptPaths: ReadonlySet, + persistedAssociations: PersistedInlineScriptEnvironments, + removedCacheEntries: ReadonlySet, + ): Promise> { + const invalidatedScriptPaths = new Set(); + for (const scriptPath of scriptPaths) { + const environmentPaths = [ + persistedAssociations[scriptPath], + this.fsPathToPersistedEnvPath.get(scriptPath), + this.fsPathToEnv.get(scriptPath)?.environmentPath.fsPath, + ].filter((value): value is string => value !== undefined); + const states = await Promise.all( + environmentPaths.map((environmentPath) => + this.isRemovedOrMissingCacheAssociation(environmentPath, removedCacheEntries), + ), + ); + if (states.some((state) => state)) { + invalidatedScriptPaths.add(scriptPath); + } + } + return invalidatedScriptPaths; + } + + private async isRemovedOrMissingCacheAssociation( + environmentPath: string, + removedCacheEntries: ReadonlySet, + ): Promise { + const envDirPath = path.dirname(path.dirname(environmentPath)); + if (removedCacheEntries.has(normalizePath(envDirPath))) { + return true; + } + try { + return !(await fs.pathExists(environmentPath)); + } catch (error) { + this.log.warn( + `Unable to verify inline-script environment association ${environmentPath}: ${getErrorMessage(error)}`, + ); + return false; + } + } + + private async clearInvalidatedAssociations( + invalidatedScriptPaths: ReadonlySet, + persistedAssociations: PersistedInlineScriptEnvironments, + priorSelections: ReadonlyMap, + ): Promise { + if (invalidatedScriptPaths.size === 0) { + if (Object.keys(persistedAssociations).length > 0) { + return undefined; + } + try { + await this.enqueuePersistence(async (state) => state.clear([INLINE_SCRIPT_ENVS_KEY])); + return undefined; + } catch (error) { + this.log.error(`Failed to clear inline-script environment associations: ${getErrorMessage(error)}`); + return error; + } + } + + let persistenceError: unknown; + const persistedPathsToClear = Array.from(invalidatedScriptPaths).filter( + (scriptPath) => persistedAssociations[scriptPath] !== undefined, + ); + try { + if (persistedPathsToClear.length === Object.keys(persistedAssociations).length) { + await this.enqueuePersistence(async (state) => state.clear([INLINE_SCRIPT_ENVS_KEY])); + } else if (persistedPathsToClear.length > 0) { + await this.updatePersistedAssociations( + persistedPathsToClear.map((scriptPath) => ({ + scriptPath, + expectedEnvironmentPath: persistedAssociations[scriptPath], + })), + ); + } + } catch (error) { + persistenceError = error; + this.log.error(`Failed to clear inline-script environment associations: ${getErrorMessage(error)}`); + } + + for (const scriptPath of invalidatedScriptPaths) { + this.bumpAssociationRevision(scriptPath); + this.pendingRehydrations.delete(scriptPath); + this.fsPathToEnv.delete(scriptPath); + this.fsPathToPersistedEnvPath.delete(scriptPath); + this.cachedAssociationValidatedAt.delete(scriptPath); + + const environment = priorSelections.get(scriptPath); + if (environment) { + this._onDidChangeEnvironment.fire({ + uri: Uri.file(scriptPath), + old: environment, + new: undefined, + }); + } + } + return persistenceError; + } + + private async getPersistedAssociationSnapshot(): Promise { + await this.persistenceQueue; + const state = await getWorkspacePersistentState(); + return this.asPersistedAssociations(await state.get(INLINE_SCRIPT_ENVS_KEY)) ?? {}; + } + private async removeCacheEntry(envDir: Uri): Promise { try { await fs.remove(envDir.fsPath); diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index bd3bbb761..836244bb7 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -55,6 +55,10 @@ export class PipPackageManager implements PackageManager, Disposable { let toUninstall: string[] = [...(options.uninstall ?? [])]; if (toInstall.length === 0 && toUninstall.length === 0) { + if (options.runHeadless) { + // Headless mode: skip the interactive package picker. + return; + } const projects = this.venv.getProjectsByEnvironment(environment); const result = await getWorkspacePackagesToInstall(this.api, options, projects, environment, this.log); if (result) { @@ -86,18 +90,21 @@ export class PipPackageManager implements PackageManager, Disposable { (changes) => { this._onDidChangePackages.fire({ environment, manager: this, changes }); }, + () => this.fetchPackages(environment, !manageOptions.runHeadless), ); } catch (e) { if (e instanceof CancellationError) { throw e; } this.log.error('Error managing packages', e); - setImmediate(async () => { - const result = await window.showErrorMessage('Error managing packages', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); + if (!manageOptions.runHeadless) { + setImmediate(async () => { + const result = await window.showErrorMessage('Error managing packages', 'View Output'); + if (result === 'View Output') { + this.log.show(); + } + }); + } throw e; } }, @@ -119,25 +126,31 @@ export class PipPackageManager implements PackageManager, Disposable { this._onDidChangePackages.fire({ environment, manager: this, changes }); }, ); - this.packages.set(environment.envId.id, packages ?? []); + if (packages !== undefined) { + this.packages.set(environment.envId.id, packages); + } }, ); } async getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise { if (options?.skipCache || !this.packages.has(environment.envId.id)) { - const data = await refreshPipPackages(environment, this.log); - if (data === undefined) { - return this.packages.get(environment.envId.id); - } - - const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this)); - this.packages.set(environment.envId.id, packages); - return packages; + return this.fetchPackages(environment); } return this.packages.get(environment.envId.id); } + private async fetchPackages(environment: PythonEnvironment, showErrors = true): Promise { + const data = await refreshPipPackages(environment, this.log, { showErrors }); + if (data === undefined) { + return this.packages.get(environment.envId.id); + } + + const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this)); + this.packages.set(environment.envId.id, packages); + return packages; + } + async getVersion(environment: PythonEnvironment): Promise { try { const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); @@ -186,9 +199,9 @@ export class PipPackageManager implements PackageManager, Disposable { return parsePipIndexVersionsJson(output); } - // pip >= 21.2.0 - use `pip index versions --json` to get available versions in a machine readable format. + // pip >= 25.1 - use `pip index versions --json` to get available versions in a machine readable format. const pipVersion = await this.getVersion(environment); - if (pipVersion && compare(pipVersion.public, '21.2.0') >= 0) { + if (pipVersion && compare(pipVersion.public, '25.1') >= 0) { const output = await runPython( python, ['-m', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], @@ -198,7 +211,17 @@ export class PipPackageManager implements PackageManager, Disposable { return parsePipIndexVersionsJson(output); } - // pip <= 20.3.4 - version picking is undefined; no reliable machine-readable API exists. + if (pipVersion && compare(pipVersion.public, '21.2') >= 0) { + const output = await runPython( + python, + ['-m', 'pip', 'index', 'versions', packageName, '--python-version', baseVersion], + undefined, + this.log, + ); + return parsePipIndexVersionsText(output); + } + + // pip < 21.2 - version picking is undefined; `pip index versions` is unavailable. } catch { return undefined; } @@ -245,3 +268,17 @@ export function parsePipIndexVersionsJson(output: string): Pep440Version[] | und return undefined; } } + +/** Parses the legacy text output from `pip index versions `. */ +export function parsePipIndexVersionsText(output: string): Pep440Version[] | undefined { + const match = output.match(/^Available versions:\s*(.+)$/im); + if (!match) { + return undefined; + } + const versions = match[1] + .split(',') + .map((version) => parse(version.trim())) + .filter((version): version is Pep440Version => version !== null) + .sort((a, b) => rcompare(a.public, b.public)); + return versions.length > 0 ? versions : undefined; +} diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index dc44fe759..f6ff2903a 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -218,7 +218,7 @@ async function execPipList(environment: PythonEnvironment, log?: LogOutputChanne export async function refreshPipPackages( environment: PythonEnvironment, log?: LogOutputChannel, - options?: { showProgress: boolean }, + options?: { showProgress?: boolean; showErrors?: boolean }, ): Promise { let data: string; try { @@ -238,7 +238,9 @@ export async function refreshPipPackages( return parsePipListJson(data, log); } catch (e) { log?.error('Error refreshing packages', e); - showErrorMessageWithLogs(SysManagerStrings.packageRefreshError, log); + if (options?.showErrors !== false) { + showErrorMessageWithLogs(SysManagerStrings.packageRefreshError, log); + } return undefined; } } diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 7af0f450a..6dcda4df8 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -1,14 +1,6 @@ import * as fs from 'fs/promises'; import * as path from 'path'; -import { - EventEmitter, - l10n, - LogOutputChannel, - MarkdownString, - ProgressLocation, - ThemeIcon, - Uri, -} from 'vscode'; +import { EventEmitter, l10n, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri } from 'vscode'; import { CreateEnvironmentOptions, CreateEnvironmentScope, @@ -24,6 +16,7 @@ import { PythonProject, QuickCreateConfig, RefreshEnvironmentsScope, + RemoveEnvironmentOptions, ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; @@ -265,11 +258,11 @@ export class VenvManager implements EnvironmentManager { /** * Removes the specified Python environment, updates internal collections, and fires change events as needed. */ - async remove(environment: PythonEnvironment): Promise { + async remove(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise { try { this.skipWatcherRefresh = true; - const isRemoved = await removeVenv(environment, this.log); + const isRemoved = await removeVenv(environment, this.log, options); if (!isRemoved) { return; } diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index c06146999..e35825866 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -11,7 +11,13 @@ import { ThemeIcon, Uri, } from 'vscode'; -import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../api'; +import { + EnvironmentManager, + PythonEnvironment, + PythonEnvironmentApi, + PythonEnvironmentInfo, + RemoveEnvironmentOptions, +} from '../../api'; import { ENVS_EXTENSION_ID } from '../../common/constants'; import { Common, VenvManagerStrings } from '../../common/localize'; import { traceInfo, traceVerbose } from '../../common/logging'; @@ -507,7 +513,7 @@ export async function createPythonVenv( return createStepBasedVenvFlow(nativeFinder, api, log, manager, basePythons, venvRoot, options); } -function isDriveRoot(fsPath: string): boolean { +export function isDriveRoot(fsPath: string): boolean { const normalized = path.normalize(fsPath); if (os.platform() === 'win32') { return /^[a-zA-Z]:[\\/]?$/.test(normalized); @@ -515,7 +521,7 @@ function isDriveRoot(fsPath: string): boolean { return normalized === '/'; } -function hasMinimumPathDepth(fsPath: string, minDepth: number = 2): boolean { +export function hasMinimumPathDepth(fsPath: string, minDepth: number = 2): boolean { const normalized = path.normalize(fsPath); const parts = normalized.split(path.sep).filter((p) => p.length > 0 && p !== '.'); @@ -553,7 +559,11 @@ async function validateVenvRemovalPath(envPath: string, log: LogOutputChannel): return undefined; } -export async function removeVenv(environment: PythonEnvironment, log: LogOutputChannel): Promise { +export async function removeVenv( + environment: PythonEnvironment, + log: LogOutputChannel, + options?: RemoveEnvironmentOptions, +): Promise { const pythonPath = os.platform() === 'win32' ? 'python.exe' : 'python'; const envFsPath = path.normalize(environment.environmentPath.fsPath); @@ -568,15 +578,19 @@ export async function removeVenv(environment: PythonEnvironment, log: LogOutputC // Normalize path for UI display - ensure forward slashes on Windows const displayPath = normalizePath(envPath); - const confirm = await showWarningMessage( - l10n.t('Are you sure you want to remove {0}?', displayPath), - { - modal: true, - }, - { title: Common.yes }, - { title: Common.no, isCloseAffordance: true }, - ); - if (confirm?.title === Common.yes) { + const confirmed = + options?.runHeadless === true || + ( + await showWarningMessage( + l10n.t('Are you sure you want to remove {0}?', displayPath), + { + modal: true, + }, + { title: Common.yes }, + { title: Common.no, isCloseAffordance: true }, + ) + )?.title === Common.yes; + if (confirmed) { const result = await withProgress( { location: ProgressLocation.Notification, diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index 3e16ae361..6c484fccd 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -9,6 +9,8 @@ import { normalizePackageName } from '../builtin/utils'; */ export type PackageChangesCallback = (changes: { kind: PackageChangeKind; pkg: Package }[]) => void; +type PackageFetcher = () => Promise; + /** * Computes the list of package changes between a before and after snapshot. * @param before - The previous list of packages. @@ -41,19 +43,30 @@ export function getPackageChanges(before: Package[], after: Package[]): { kind: * This function calls {@link PackageManager.getPackages} with `skipCache` to fetch * the latest snapshot. The caller should pass the previously cached packages * so changes can be computed against the pre-refresh state. + * + * @param packageManager The package manager whose packages changed. + * @param environment The environment whose packages should be refreshed. + * @param before The package snapshot from before the operation. + * @param onChanges Callback invoked when package changes are detected. + * @param fetchPackages Optional internal fetcher for operation-specific refresh behavior. */ export async function updatePackagesAndNotify( packageManager: PackageManager, environment: PythonEnvironment, before: Package[] | undefined, onChanges: PackageChangesCallback, + fetchPackages?: PackageFetcher, ): Promise { const [after, afterDirectDependenciesNames] = await Promise.all([ - packageManager.getPackages(environment, { skipCache: true }).then((pkgs) => pkgs ?? []), + fetchPackages?.() ?? packageManager.getPackages(environment, { skipCache: true }), // Handle transitive dependencies (best-effort, don't break package refresh on failure) packageManager.getDirectPackageNames?.(environment).catch(() => undefined), ]); + if (after === undefined) { + return undefined; + } + // Enrich packages with transitive dependency info (best-effort, creates new objects to respect readonly) const enriched = afterDirectDependenciesNames && afterDirectDependenciesNames.size > 0 ? after.map((pkg) => ({ diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index d4fb44be3..d395d0ce6 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -54,6 +54,10 @@ export class CondaPackageManager implements PackageManager, Disposable { let toUninstall: string[] = [...(options.uninstall ?? [])]; if (toInstall.length === 0 && toUninstall.length === 0) { + if (options.runHeadless) { + // Headless mode: skip the interactive package picker. + return; + } const result = await getCommonCondaPackagesToInstall(environment, options, this.api); if (result) { toInstall = result.install; @@ -91,9 +95,12 @@ export class CondaPackageManager implements PackageManager, Disposable { } this.log.error('Error installing packages', e); - setImmediate(async () => { - await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); - }); + if (!manageOptions.runHeadless) { + setImmediate(async () => { + await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); + }); + } + throw e; } }, ); diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 9525254cb..e946f0452 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -59,6 +59,10 @@ export class PoetryPackageManager implements PackageManager, Disposable { let toUninstall: string[] = [...(options.uninstall ?? [])]; if (toInstall.length === 0 && toUninstall.length === 0) { + if (options.runHeadless) { + // Headless mode: skip the interactive package input prompt. + return; + } // Show package input UI if no packages are specified const installInput = await showInputBox({ prompt: 'Enter packages to install (comma separated)', @@ -99,12 +103,14 @@ export class PoetryPackageManager implements PackageManager, Disposable { throw e; } this.log.error('Error managing packages with Poetry', e); - setImmediate(async () => { - const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); + if (!options.runHeadless) { + setImmediate(async () => { + const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); + if (result === 'View Output') { + this.log.show(); + } + }); + } throw e; } }, diff --git a/src/test/common/lockfile.apis.unit.test.ts b/src/test/common/lockfile.apis.unit.test.ts index a2d343a19..a0230a230 100644 --- a/src/test/common/lockfile.apis.unit.test.ts +++ b/src/test/common/lockfile.apis.unit.test.ts @@ -8,7 +8,16 @@ import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import { acquireFileLock, AcquireFileLockOptions } from '../../common/lockfile.apis'; +import { + acquireFileLock, + AcquireFileLockOptions, + FILE_LOCK_OWNER_MARKER_PREFIX, + FILE_LOCK_RETAINED_MARKER, + FILE_LOCK_RETAINED_MARKER_PREFIX, + getFileLockPath, + inspectFileLock, + reclaimFileLock, +} from '../../common/lockfile.apis'; const OPTIONS: AcquireFileLockOptions = { timeoutMs: 40, @@ -165,29 +174,29 @@ suite('lockfile APIs', () => { assert.ok(Date.now() - startedAt < 1_000); const lockPath = `${path.resolve(targetPath)}.lock`; const retainedEntries = await fs.readdir(lockPath); - assert.ok(retainedEntries.includes('retained')); - assert.strictEqual(retainedEntries.filter((entry) => entry.startsWith('owner-')).length, 1); + assert.strictEqual(retainedEntries.length, 1); + assert.ok(retainedEntries[0].startsWith(`${FILE_LOCK_RETAINED_MARKER_PREFIX}${process.pid}-`)); await lock.release(); assert.deepStrictEqual(await fs.readdir(lockPath), retainedEntries); }); - test('falls back to renaming the owner marker when the retained sentinel cannot be written', async () => { + test('atomically converts the owner marker into a generation-specific retained marker', async () => { const lock = await acquireFileLock(targetPath, OPTIONS); - sinon.stub(fsExtra, 'writeFile').rejects(Object.assign(new Error('write failed'), { code: 'EACCES' })); await lock.retain(); const lockPath = `${path.resolve(targetPath)}.lock`; - assert.deepStrictEqual(await fs.readdir(lockPath), ['retained']); + const entries = await fs.readdir(lockPath); + assert.strictEqual(entries.length, 1); + assert.ok(entries[0].startsWith(`${FILE_LOCK_RETAINED_MARKER_PREFIX}${process.pid}-`)); await assert.rejects(acquireFileLock(targetPath, OPTIONS), (error: NodeJS.ErrnoException) => { return error.code === 'ELOCKRETAINED'; }); }); - test('remains fail-closed when neither retained-marker strategy succeeds', async () => { + test('remains fail-closed when retaining the generation marker fails', async () => { const lock = await acquireFileLock(targetPath, OPTIONS); - sinon.stub(fsExtra, 'writeFile').rejects(Object.assign(new Error('write failed'), { code: 'EACCES' })); sinon.stub(fsExtra, 'rename').rejects(Object.assign(new Error('rename failed'), { code: 'EBUSY' })); await assert.rejects(lock.retain(), (error: NodeJS.ErrnoException) => error.code === 'ERETAINFAILED'); @@ -209,4 +218,127 @@ suite('lockfile APIs', () => { return error.code === 'ELOCKORPHANED' && error.path === `${path.resolve(targetPath)}.lock`; }); }); + + test('classifies a live owner marker as held using the liveness probe', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-live`), ''); + const checkProcessLiveness = sinon.stub().resolves('live'); + + assert.strictEqual(await inspectFileLock(targetPath, { checkProcessLiveness }), 'held'); + sinon.assert.calledOnceWithExactly(checkProcessLiveness, process.pid); + }); + + test('classifies a retained lock after retain()', async () => { + const lock = await acquireFileLock(targetPath, OPTIONS); + await lock.retain(); + + assert.strictEqual(await inspectFileLock(targetPath), 'retained'); + }); + + test('reclaims a generation-specific retained lock', async () => { + const lock = await acquireFileLock(targetPath, OPTIONS); + await lock.retain(); + + assert.strictEqual(await reclaimFileLock(targetPath), true); + assert.strictEqual(await inspectFileLock(targetPath), 'missing'); + const replacement = await acquireFileLock(targetPath, OPTIONS); + await replacement.release(); + }); + + test('refuses to reclaim the ambiguous legacy retained marker', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-legacy`), ''); + await fs.writeFile(path.join(lockPath, FILE_LOCK_RETAINED_MARKER), ''); + + assert.strictEqual(await inspectFileLock(targetPath), 'retained'); + assert.strictEqual(await reclaimFileLock(targetPath), false); + assert.strictEqual(await fs.pathExists(path.join(lockPath, FILE_LOCK_RETAINED_MARKER)), true); + await assert.rejects(acquireFileLock(targetPath, OPTIONS), (error: NodeJS.ErrnoException) => { + return error.code === 'ELOCKRETAINED'; + }); + }); + + test('does not touch a new generation when a delayed reclaimer loses its marker claim', async () => { + const lockPath = getFileLockPath(targetPath); + const staleMarker = `${FILE_LOCK_OWNER_MARKER_PREFIX}424242-dead`; + await fs.ensureDir(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, staleMarker), ''); + const rename = fsExtra.rename; + let releaseFirstClaim: (() => void) | undefined; + let firstClaimStarted: (() => void) | undefined; + const firstClaim = new Promise((resolve) => { + firstClaimStarted = resolve; + }); + const releaseClaim = new Promise((resolve) => { + releaseFirstClaim = resolve; + }); + let renameCount = 0; + sinon.stub(fsExtra, 'rename').callsFake(async (source, destination) => { + renameCount += 1; + if (renameCount === 1) { + firstClaimStarted!(); + await releaseClaim; + } + await rename(source, destination); + }); + + const staleInspection = { checkProcessLiveness: sinon.stub().resolves('dead') }; + const delayedReclaimer = reclaimFileLock(targetPath, staleInspection); + await firstClaim; + assert.strictEqual(await reclaimFileLock(targetPath, staleInspection), true); + const replacement = await acquireFileLock(targetPath, OPTIONS); + const replacementEntries = await fs.readdir(lockPath); + + releaseFirstClaim!(); + assert.strictEqual(await delayedReclaimer, false); + assert.deepStrictEqual(await fs.readdir(lockPath), replacementEntries); + assert.strictEqual(await fs.pathExists(targetPath), true); + + await replacement.release(); + }); + + test('classifies a dead owner marker as stale using the liveness probe', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `${FILE_LOCK_OWNER_MARKER_PREFIX}424242-dead`), ''); + const checkProcessLiveness = sinon.stub().resolves('dead'); + + assert.strictEqual(await inspectFileLock(targetPath, { checkProcessLiveness }), 'stale'); + sinon.assert.calledOnceWithExactly(checkProcessLiveness, 424242); + }); + + test('classifies an unavailable owner probe conservatively', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-busy`), ''); + const checkProcessLiveness = sinon.stub().resolves('unavailable'); + + assert.strictEqual(await inspectFileLock(targetPath, { checkProcessLiveness }), 'unavailable'); + sinon.assert.calledOnceWithExactly(checkProcessLiveness, process.pid); + }); + + test('classifies an owner-less lock directory as orphaned', async () => { + await fs.ensureDir(getFileLockPath(targetPath)); + + assert.strictEqual(await inspectFileLock(targetPath), 'orphaned'); + }); + + test('classifies a malformed owner marker as malformed', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `${FILE_LOCK_OWNER_MARKER_PREFIX}not-a-pid-live`), ''); + + assert.strictEqual(await inspectFileLock(targetPath), 'malformed'); + }); + + test('classifies a lock directory with unexpected entries as malformed', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, 'unexpected.txt'), ''); + + assert.strictEqual(await inspectFileLock(targetPath), 'malformed'); + }); }); diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index 079ca9d73..ae2a1c54f 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -4,9 +4,16 @@ import * as typeMoq from 'typemoq'; import { Uri } from 'vscode'; import { PythonEnvironment, PythonProject } from '../../api'; import * as commandApi from '../../common/command.api'; +import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants'; import * as managerApi from '../../common/pickers/managers'; import * as projectApi from '../../common/pickers/projects'; -import { createAnyEnvironmentCommand, removePythonProject, revealEnvInManagerView } from '../../features/envCommands'; +import * as windowApis from '../../common/window.apis'; +import { + clearScriptEnvironmentCacheCommand, + createAnyEnvironmentCommand, + removePythonProject, + revealEnvInManagerView, +} from '../../features/envCommands'; import * as settingHelpers from '../../features/settings/settingHelpers'; import { EnvManagerView } from '../../features/views/envManagersView'; import { ProjectEnvironment, ProjectItem } from '../../features/views/treeViewItems'; @@ -216,6 +223,123 @@ suite('Remove Python Project Command Tests', () => { }); }); +suite('Clear Script Environment Cache Command Tests', () => { + teardown(() => { + sinon.restore(); + }); + + test('cancels without clearing the cache or touching project settings', async () => { + const clearCache = sinon.stub().resolves(); + const envManagers = { + getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + supportsClearCache: () => true, + clearCache, + }), + } as unknown as EnvironmentManagers; + const projectManager = { + getProjects: sinon.stub().returns([]), + remove: sinon.stub(), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves(undefined); + const removeInlineSettings = sinon.stub(settingHelpers, 'removeInlineScriptPythonProjectSettings').resolves([]); + + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + + sinon.assert.notCalled(clearCache); + sinon.assert.notCalled(removeInlineSettings); + sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); + }); + + test('clears cache before inline settings cleanup and unloads removed projects', async () => { + const calls: string[] = []; + const inlineProject: PythonProject = { + uri: Uri.file('/workspace/script.py'), + name: 'script.py', + }; + const clearCache = sinon.stub().callsFake(async () => { + calls.push('clearCache'); + }); + const envManagers = { + getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + supportsClearCache: () => true, + clearCache, + }), + } as unknown as EnvironmentManagers; + const projectManager = { + getProjects: sinon.stub().callsFake(() => { + calls.push('getProjects'); + return [inlineProject]; + }), + remove: sinon.stub().callsFake(() => { + calls.push('removeProjects'); + }), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); + const removeInlineSettings = sinon + .stub(settingHelpers, 'removeInlineScriptPythonProjectSettings') + .callsFake(async (projects) => { + calls.push('removeInlineSettings'); + assert.deepStrictEqual(projects, [inlineProject]); + return [inlineProject]; + }); + + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + + sinon.assert.calledOnce(clearCache); + sinon.assert.calledOnce(removeInlineSettings); + sinon.assert.calledOnceWithExactly(projectManager.remove as sinon.SinonStub, [inlineProject]); + assert.deepStrictEqual(calls, ['clearCache', 'getProjects', 'removeInlineSettings', 'removeProjects']); + }); + + test('keeps loaded projects when inline settings cleanup leaves them configured', async () => { + const inlineProject: PythonProject = { + uri: Uri.file('/workspace/runner'), + name: 'runner', + }; + const clearCache = sinon.stub().resolves(); + const envManagers = { + getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + supportsClearCache: () => true, + clearCache, + }), + } as unknown as EnvironmentManagers; + const projectManager = { + getProjects: sinon.stub().returns([inlineProject]), + remove: sinon.stub(), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); + const removeInlineSettings = sinon.stub(settingHelpers, 'removeInlineScriptPythonProjectSettings').resolves([]); + + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + + sinon.assert.calledOnce(clearCache); + sinon.assert.calledOnceWithExactly(removeInlineSettings, [inlineProject]); + sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); + }); + + test('preserves project settings when cache cleanup reports a partial failure', async () => { + const clearCache = sinon.stub().rejects(new Error('one cache entry could not be deleted')); + const envManagers = { + getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + supportsClearCache: () => true, + clearCache, + }), + } as unknown as EnvironmentManagers; + const projectManager = { + getProjects: sinon.stub().returns([]), + remove: sinon.stub(), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); + const removeInlineSettings = sinon.stub(settingHelpers, 'removeInlineScriptPythonProjectSettings').resolves([]); + + await assert.rejects(clearScriptEnvironmentCacheCommand(envManagers, projectManager), /could not be deleted/); + + sinon.assert.calledOnce(clearCache); + sinon.assert.notCalled(removeInlineSettings); + sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); + }); +}); + suite('Reveal Env In Manager View Command Tests', () => { let managerView: typeMoq.IMock; let executeCommandStub: sinon.SinonStub; diff --git a/src/test/features/envManagers.unit.test.ts b/src/test/features/envManagers.unit.test.ts index d642fa948..aafe89f27 100644 --- a/src/test/features/envManagers.unit.test.ts +++ b/src/test/features/envManagers.unit.test.ts @@ -336,3 +336,61 @@ suite('PythonEnvironmentManagers - refreshEnvironment', () => { await envManagers.refreshEnvironment(Uri.file('/unknown/path')); }); }); + +suite('PythonEnvironmentManagers - clearCache', () => { + let sandbox: sinon.SinonSandbox; + let envManagers: PythonEnvironmentManagers; + + setup(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(frameUtils, 'getCallingExtension').returns('ms-python.python'); + envManagers = new PythonEnvironmentManagers({ + get: sandbox.stub().returns(undefined), + getProjects: sandbox.stub().returns([]), + } as unknown as PythonProjectManager); + }); + + teardown(() => { + sandbox.restore(); + }); + + function registerManager(name: string, clearCache: sinon.SinonStub): void { + envManagers.registerEnvironmentManager( + { + name, + displayName: name, + preferredPackageManagerId: 'ms-python.python:pip', + get: sandbox.stub().resolves(undefined), + set: sandbox.stub().resolves(), + resolve: sandbox.stub().resolves(undefined), + refresh: sandbox.stub().resolves(), + getEnvironments: sandbox.stub().resolves([]), + clearCache, + onDidChangeEnvironments: sandbox.stub().returns({ dispose: () => {} }), + onDidChangeEnvironment: sandbox.stub().returns({ dispose: () => {} }), + } as any, + { extensionId: 'ms-python.python' }, + ); + } + + test('clears every existing manager when the inline preview manager is absent', async () => { + const systemClearCache = sandbox.stub().resolves(); + registerManager('system', systemClearCache); + + await envManagers.clearCache(undefined); + + sinon.assert.calledOnce(systemClearCache); + }); + + test('does not clear the preview inline manager through the generic command path', async () => { + const systemClearCache = sandbox.stub().resolves(); + const inlineClearCache = sandbox.stub().resolves(); + registerManager('system', systemClearCache); + registerManager('inline-script', inlineClearCache); + + await envManagers.clearCache(undefined); + + sinon.assert.calledOnce(systemClearCache); + sinon.assert.notCalled(inlineClearCache); + }); +}); diff --git a/src/test/features/projectManager.initialize.unit.test.ts b/src/test/features/projectManager.initialize.unit.test.ts index 84e0c9fc7..f89a325ac 100644 --- a/src/test/features/projectManager.initialize.unit.test.ts +++ b/src/test/features/projectManager.initialize.unit.test.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as assert from 'assert'; +import * as path from 'path'; import * as sinon from 'sinon'; import { Disposable, EventEmitter, Uri, WorkspaceFolder } from 'vscode'; import * as workspaceApis from '../../common/workspace.apis'; +import { normalizePath } from '../../common/utils/pathUtils'; import { PythonProjectManagerImpl } from '../../features/projectManager'; import * as settingHelpers from '../../features/settings/settingHelpers'; import { PythonProjectSettings } from '../../internal.api'; @@ -304,6 +306,163 @@ suite('Project Manager Initialization - Settings Preservation', () => { pm.dispose(); }); + + test('config refresh drops only the project removed from workspaceValue and preserves workspaceFolder entries', async () => { + let workspaceValueProjects: PythonProjectSettings[] = [ + { + path: 'script.py', + envManager: 'ms-python.python:inline-script', + packageManager: 'ms-python.python:pip', + workspace: workspaceFolder.name, + }, + ]; + let workspaceFolderProjects: PythonProjectSettings[] = [ + { + path: 'keep.py', + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + }, + ]; + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + return [...workspaceValueProjects, ...workspaceFolderProjects] as unknown as T; + } + if (key === 'defaultEnvManager') { + return 'ms-python.python:venv' as T; + } + if (key === 'defaultPackageManager') { + return 'ms-python.python:pip' as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + assert.ok( + pm.getProjects().some( + (project) => + normalizePath(project.uri.fsPath) === normalizePath(path.join(workspacePath, 'script.py')), + ), + 'workspaceValue project should be loaded initially', + ); + assert.ok( + pm.getProjects().some( + (project) => normalizePath(project.uri.fsPath) === normalizePath(path.join(workspacePath, 'keep.py')), + ), + 'workspaceFolder project should be loaded initially', + ); + + workspaceValueProjects = []; + configChangeEmitter.fire({ + affectsConfiguration: (section: string) => section === 'python-envs.pythonProjects', + }); + await clock.tickAsync(150); + + assert.strictEqual( + pm.getProjects().some( + (project) => + normalizePath(project.uri.fsPath) === normalizePath(path.join(workspacePath, 'script.py')), + ), + false, + 'workspaceValue project should be removed after config refresh', + ); + assert.ok( + pm.getProjects().some( + (project) => normalizePath(project.uri.fsPath) === normalizePath(path.join(workspacePath, 'keep.py')), + ), + 'workspaceFolder project should remain after config refresh', + ); + assertNoSettingsWritten('Config refresh after project removal'); + + pm.dispose(); + }); + + test('shared workspaceValue removals do not resurrect projects after a multi-root refresh', async () => { + const secondWorkspacePath = process.platform === 'win32' ? 'C:\\workspace2' : '/workspace2'; + const secondWorkspaceFolder: WorkspaceFolder = { + uri: Uri.file(secondWorkspacePath), + name: 'workspace2', + index: 1, + }; + let sharedWorkspaceProjects: PythonProjectSettings[] = [ + { + path: 'first', + envManager: 'ms-python.python:inline-script', + packageManager: 'ms-python.python:pip', + workspace: workspaceFolder.name, + }, + { + path: 'second', + envManager: 'ms-python.python:inline-script', + packageManager: 'ms-python.python:pip', + workspace: secondWorkspaceFolder.name, + }, + ]; + (workspaceApis.getWorkspaceFolders as sinon.SinonStub).returns([workspaceFolder, secondWorkspaceFolder]); + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + return sharedWorkspaceProjects as unknown as T; + } + if (key === 'defaultEnvManager') { + return 'ms-python.python:venv' as T; + } + if (key === 'defaultPackageManager') { + return 'ms-python.python:pip' as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + assert.ok( + pm.getProjects().some( + (project) => normalizePath(project.uri.fsPath) === normalizePath(path.join(workspacePath, 'first')), + ), + 'first shared workspace project should be loaded initially', + ); + assert.ok( + pm.getProjects().some( + (project) => + normalizePath(project.uri.fsPath) === normalizePath(path.join(secondWorkspacePath, 'second')), + ), + 'second shared workspace project should be loaded initially', + ); + + sharedWorkspaceProjects = []; + configChangeEmitter.fire({ + affectsConfiguration: (section: string) => section === 'python-envs.pythonProjects', + }); + await clock.tickAsync(150); + + assert.strictEqual( + pm.getProjects().some( + (project) => normalizePath(project.uri.fsPath) === normalizePath(path.join(workspacePath, 'first')), + ), + false, + 'first shared workspace project should stay removed after refresh', + ); + assert.strictEqual( + pm.getProjects().some( + (project) => + normalizePath(project.uri.fsPath) === normalizePath(path.join(secondWorkspacePath, 'second')), + ), + false, + 'second shared workspace project should stay removed after refresh', + ); + assertNoSettingsWritten('Shared workspace refresh'); + + pm.dispose(); + }); }); suite('Workspace Folder Changes - No Settings Writes', () => { diff --git a/src/test/features/settings/settingHelpers.unit.test.ts b/src/test/features/settings/settingHelpers.unit.test.ts index ef195addd..f75bc5b3c 100644 --- a/src/test/features/settings/settingHelpers.unit.test.ts +++ b/src/test/features/settings/settingHelpers.unit.test.ts @@ -10,11 +10,13 @@ import * as workspaceApis from '../../../common/workspace.apis'; import { addPythonProjectSetting, migrateGlobalDefaultEnvManagerSetting, + removeInlineScriptPythonProjectSettings, + removePythonProjectSetting, setAllManagerSettings, setEnvironmentManager, setPackageManager, } from '../../../features/settings/settingHelpers'; -import { PythonProjectsImpl } from '../../../internal.api'; +import { PythonProjectSettings, PythonProjectsImpl } from '../../../internal.api'; import { MockWorkspaceConfiguration } from '../../mocks/mockWorkspaceConfig'; /** @@ -617,6 +619,546 @@ suite('Setting Helpers - Empty Path Migration', () => { }); }); +suite('Setting Helpers - Project Removal', () => { + const INLINE_MANAGER_ID = 'ms-python.python:inline-script'; + const VENV_MANAGER_ID = 'ms-python.python:venv'; + const PIP_MANAGER_ID = 'ms-python.python:pip'; + const firstWorkspacePath = getTestWorkspacePath(); + const firstWorkspaceUri = Uri.file(firstWorkspacePath); + const firstWorkspace: WorkspaceFolder = { + uri: firstWorkspaceUri, + name: 'workspace', + index: 0, + }; + const secondWorkspaceUri = Uri.file(process.platform === 'win32' ? 'C:\\workspace2' : '/workspace2'); + const secondWorkspace: WorkspaceFolder = { + uri: secondWorkspaceUri, + name: 'workspace2', + index: 1, + }; + + let updateCalls: Array<{ + workspace: string; + key: string; + value: unknown; + target: boolean | ConfigurationTarget | undefined; + }>; + + setup(() => { + updateCalls = []; + }); + + teardown(() => { + sinon.restore(); + }); + + function createProjectConfig(options: { + workspaceName: string; + globalValue?: PythonProjectSettings[]; + workspaceValue?: PythonProjectSettings[]; + workspaceFolderValue?: PythonProjectSettings[]; + }): MockWorkspaceConfiguration { + const mockConfig = new MockWorkspaceConfiguration(); + const mergedProjects = [ + ...(options.globalValue ?? []), + ...(options.workspaceValue ?? []), + ...(options.workspaceFolderValue ?? []), + ]; + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => + key === 'pythonProjects' ? (mergedProjects as unknown as T) : defaultValue; + (mockConfig as any).inspect = (key: string) => + key === 'pythonProjects' + ? { + globalValue: options.globalValue, + workspaceValue: options.workspaceValue, + workspaceFolderValue: options.workspaceFolderValue, + } + : undefined; + mockConfig.update = ( + section: string, + value: unknown, + configurationTarget?: boolean | ConfigurationTarget, + ): Promise => { + updateCalls.push({ + workspace: options.workspaceName, + key: section, + value, + target: configurationTarget, + }); + return Promise.resolve(); + }; + return mockConfig; + } + + function cloneSettings(settings: PythonProjectSettings[] | undefined): PythonProjectSettings[] { + return (settings ?? []).map((setting) => ({ ...setting })); + } + + function createSharedWorkspaceConfigs(options: { + workspaceValue: PythonProjectSettings[]; + firstWorkspaceFolderValue?: PythonProjectSettings[]; + secondWorkspaceFolderValue?: PythonProjectSettings[]; + }): { firstConfig: MockWorkspaceConfiguration; secondConfig: MockWorkspaceConfiguration; getWorkspaceValue: () => PythonProjectSettings[] } { + let sharedWorkspaceValue = cloneSettings(options.workspaceValue); + const workspaceFolderValues = new Map([ + [firstWorkspace.name, cloneSettings(options.firstWorkspaceFolderValue)], + [secondWorkspace.name, cloneSettings(options.secondWorkspaceFolderValue)], + ]); + + function createConfigForWorkspace(workspace: WorkspaceFolder): MockWorkspaceConfiguration { + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => + key === 'pythonProjects' + ? ([...sharedWorkspaceValue, ...workspaceFolderValues.get(workspace.name)!] as unknown as T) + : defaultValue; + (mockConfig as any).inspect = (key: string) => + key === 'pythonProjects' + ? { + workspaceValue: cloneSettings(sharedWorkspaceValue), + workspaceFolderValue: cloneSettings(workspaceFolderValues.get(workspace.name)), + } + : undefined; + mockConfig.update = ( + section: string, + value: unknown, + configurationTarget?: boolean | ConfigurationTarget, + ): Promise => { + updateCalls.push({ + workspace: workspace.name, + key: section, + value, + target: configurationTarget, + }); + const updatedSettings = cloneSettings(value as PythonProjectSettings[] | undefined); + if (configurationTarget === ConfigurationTarget.Workspace) { + sharedWorkspaceValue = updatedSettings; + } else if (configurationTarget === ConfigurationTarget.WorkspaceFolder) { + workspaceFolderValues.set(workspace.name, updatedSettings); + } + return Promise.resolve(); + }; + return mockConfig; + } + + return { + firstConfig: createConfigForWorkspace(firstWorkspace), + secondConfig: createConfigForWorkspace(secondWorkspace), + getWorkspaceValue: () => cloneSettings(sharedWorkspaceValue), + }; + } + + suite('removePythonProjectSetting (bde7cf8-equivalent generic behavior)', () => { + test('rewrites the merged effective array back to workspace scope', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + await removePythonProjectSetting([{ project }]); + + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.deepStrictEqual(updateCalls[0], { + workspace: firstWorkspace.name, + key: 'pythonProjects', + value: [{ path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }], + target: ConfigurationTarget.Workspace, + }); + }); + + test('ignores envManager metadata and removes the first same-path entry', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'other.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + await removePythonProjectSetting([{ project, envManager: VENV_MANAGER_ID }]); + + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.deepStrictEqual(updateCalls[0].value, [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'other.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); + }); + }); + + suite('removeInlineScriptPythonProjectSettings', () => { + test('removes all inline-script entries while preserving non-inline duplicates', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const otherProject = new PythonProjectsImpl('other.py', Uri.file(path.join(firstWorkspacePath, 'other.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'other.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + const removedProjects = await removeInlineScriptPythonProjectSettings([project, otherProject]); + + assert.deepStrictEqual( + removedProjects.map((entry) => entry.uri.fsPath), + [otherProject.uri.fsPath], + 'Only projects left without any non-inline setting should be removed from memory', + ); + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.strictEqual(updateCalls[0].workspace, firstWorkspace.name); + assert.strictEqual(updateCalls[0].key, 'pythonProjects'); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); + assert.deepStrictEqual(updateCalls[0].value, [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); + }); + + test('removes inline-script settings even when the project is not loaded', async () => { + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'runner', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'keep', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + const removedProjects = await removeInlineScriptPythonProjectSettings([]); + + assert.deepStrictEqual(removedProjects, [], 'No loaded project should be returned for memory cleanup'); + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.deepStrictEqual(updateCalls[0].value, [ + { path: 'keep', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); + }); + + test('removes only one of two roots that share the same relative path', async () => { + const firstProject = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const secondProject = new PythonProjectsImpl( + 'script.py', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'script.py')), + ); + const firstConfig = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceFolderValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + const secondConfig = createProjectConfig({ + workspaceName: secondWorkspace.name, + workspaceFolderValue: [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removeInlineScriptPythonProjectSettings([firstProject, secondProject]); + + assert.deepStrictEqual(removedProjects.map((project) => project.uri.fsPath), [firstProject.uri.fsPath]); + assert.strictEqual(updateCalls.length, 1, 'Only the matching workspace folder should be updated'); + assert.strictEqual(updateCalls[0].workspace, firstWorkspace.name); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.WorkspaceFolder); + assert.strictEqual(updateCalls[0].value, undefined); + assert.strictEqual(secondProject.uri.fsPath, path.join(secondWorkspaceUri.fsPath, 'script.py')); + }); + + test('removes a hidden shared inline entry while preserving a folder override for the same URI', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { + path: 'script.py', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: firstWorkspace.name, + }, + ], + workspaceFolderValue: [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + const removedProjects = await removeInlineScriptPythonProjectSettings([project]); + + assert.deepStrictEqual(removedProjects, [], 'Folder override should keep the project configured'); + const workspaceUpdate = updateCalls.find((call) => call.target === ConfigurationTarget.Workspace); + const folderUpdate = updateCalls.find((call) => call.target === ConfigurationTarget.WorkspaceFolder); + assert.ok(workspaceUpdate, 'WorkspaceValue source should be updated'); + assert.strictEqual(workspaceUpdate!.value, undefined); + assert.strictEqual(folderUpdate, undefined, 'Folder override should not be rewritten'); + }); + + test('aggregates shared workspaceValue removals across folders into one update', async () => { + const firstProject = new PythonProjectsImpl('first', Uri.file(path.join(firstWorkspacePath, 'first'))); + const secondProject = new PythonProjectsImpl('second', Uri.file(path.join(secondWorkspaceUri.fsPath, 'second'))); + const { firstConfig, secondConfig, getWorkspaceValue } = createSharedWorkspaceConfigs({ + workspaceValue: [ + { + path: 'first', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: firstWorkspace.name, + }, + { + path: 'second', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removeInlineScriptPythonProjectSettings([firstProject, secondProject]); + + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [firstProject.uri.fsPath, secondProject.uri.fsPath].sort(), + ); + assert.strictEqual( + updateCalls.filter((call) => call.target === ConfigurationTarget.Workspace).length, + 1, + 'Shared workspaceValue should be written once', + ); + assert.deepStrictEqual(getWorkspaceValue(), []); + }); + + test('removes every inline shared entry without resurrecting non-inline siblings', async () => { + const firstProject = new PythonProjectsImpl('first', Uri.file(path.join(firstWorkspacePath, 'first'))); + const secondProject = new PythonProjectsImpl( + 'second', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'second')), + ); + const { firstConfig, secondConfig, getWorkspaceValue } = createSharedWorkspaceConfigs({ + workspaceValue: [ + { + path: 'first', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: firstWorkspace.name, + }, + { + path: 'second', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + { + path: 'keep', + envManager: VENV_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removeInlineScriptPythonProjectSettings([firstProject, secondProject]); + + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [firstProject.uri.fsPath, secondProject.uri.fsPath].sort(), + ); + assert.strictEqual( + updateCalls.filter((call) => call.target === ConfigurationTarget.Workspace).length, + 1, + 'Shared workspaceValue should still be written once', + ); + assert.deepStrictEqual(getWorkspaceValue(), [ + { + path: 'keep', + envManager: VENV_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + ]); + assert.strictEqual(secondProject.uri.fsPath, path.join(secondWorkspaceUri.fsPath, 'second')); + }); + + test('removes matching inline-script projects independently in a multi-root workspace', async () => { + const firstProject = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const secondProject = new PythonProjectsImpl( + 'script.py', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'script.py')), + ); + const firstConfig = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'keep-folder.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + const secondConfig = createProjectConfig({ + workspaceName: secondWorkspace.name, + workspaceFolderValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removeInlineScriptPythonProjectSettings([firstProject, secondProject]); + + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [firstProject.uri.fsPath, secondProject.uri.fsPath].sort(), + ); + assert.strictEqual(updateCalls.length, 2, 'Should update each workspace independently'); + const firstWorkspaceUpdate = updateCalls.find((call) => call.workspace === firstWorkspace.name); + const secondWorkspaceUpdate = updateCalls.find((call) => call.workspace === secondWorkspace.name); + assert.ok(firstWorkspaceUpdate, 'First workspace should receive an update'); + assert.ok(secondWorkspaceUpdate, 'Second workspace should receive an update'); + assert.strictEqual(firstWorkspaceUpdate!.value, undefined); + assert.deepStrictEqual(secondWorkspaceUpdate!.value, [ + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); + assert.ok( + updateCalls.some((call) => call.workspace === firstWorkspace.name && call.target === ConfigurationTarget.Workspace) && + updateCalls.some( + (call) => + call.workspace === secondWorkspace.name && + call.target === ConfigurationTarget.WorkspaceFolder, + ), + 'Should update the same configuration scope that originally contained each project entry', + ); + }); + + test('removes global inline entries once while preserving higher-precedence non-inline entries', async () => { + const globalProject = new PythonProjectsImpl( + 'global.py', + Uri.file(path.join(firstWorkspacePath, 'global.py')), + ); + const workspaceProject = new PythonProjectsImpl( + 'workspace.py', + Uri.file(path.join(firstWorkspacePath, 'workspace.py')), + ); + const folderProject = new PythonProjectsImpl( + 'folder.py', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'folder.py')), + ); + const firstConfig = createProjectConfig({ + workspaceName: firstWorkspace.name, + globalValue: [ + { path: 'global.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceValue: [ + { path: 'workspace.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'global.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + const secondConfig = createProjectConfig({ + workspaceName: secondWorkspace.name, + globalValue: [ + { path: 'global.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'folder.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removeInlineScriptPythonProjectSettings([ + globalProject, + workspaceProject, + folderProject, + ]); + + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [workspaceProject.uri.fsPath, folderProject.uri.fsPath].sort(), + 'The folder-level non-inline entry keeps the global project loaded', + ); + const globalUpdates = updateCalls.filter((call) => call.target === ConfigurationTarget.Global); + assert.strictEqual(globalUpdates.length, 1, 'Global settings should be updated exactly once'); + assert.deepStrictEqual(globalUpdates[0].value, [ + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); + assert.ok( + updateCalls.some( + (call) => + call.workspace === firstWorkspace.name && + call.target === ConfigurationTarget.Workspace && + call.value === undefined, + ), + 'Workspace-scoped inline entry should be removed at its source', + ); + assert.ok( + updateCalls.some( + (call) => + call.workspace === secondWorkspace.name && + call.target === ConfigurationTarget.WorkspaceFolder && + call.value === undefined, + ), + 'Folder-scoped inline entry should be removed at its source', + ); + }); + }); +}); + suite('Setting Helpers - migrateGlobalDefaultEnvManagerSetting', () => { const SYSTEM_MANAGER_ID = 'ms-python.python:system'; const VENV_MANAGER_ID = 'ms-python.python:venv'; diff --git a/src/test/integration/packageManagement.integration.test.ts b/src/test/integration/packageManagement.integration.test.ts index 5998b6a17..7eb2a75c2 100644 --- a/src/test/integration/packageManagement.integration.test.ts +++ b/src/test/integration/packageManagement.integration.test.ts @@ -282,13 +282,13 @@ suite('Integration: Package Management', function () { try { if (wasInstalled) { // Uninstall first - await api.managePackages(targetEnv, { uninstall: [testPackage] }); + await api.managePackages(targetEnv, { uninstall: [testPackage], runHeadless: true }); packageInstalled = false; await sleep(2000); } // Install package - await api.managePackages(targetEnv, { install: [testPackage] }); + await api.managePackages(targetEnv, { install: [testPackage], runHeadless: true }); packageInstalled = true; // Refresh and verify @@ -299,7 +299,7 @@ suite('Integration: Package Management', function () { assert.ok(isNowInstalled, `${testPackage} should be installed after managePackages install`); // Uninstall - await api.managePackages(targetEnv, { uninstall: [testPackage] }); + await api.managePackages(targetEnv, { uninstall: [testPackage], runHeadless: true }); packageInstalled = false; // Refresh and verify @@ -312,7 +312,7 @@ suite('Integration: Package Management', function () { // Ensure cleanup even if assertions fail if (packageInstalled) { try { - await api.managePackages(targetEnv, { uninstall: [testPackage] }); + await api.managePackages(targetEnv, { uninstall: [testPackage], runHeadless: true }); } catch { console.log('Cleanup: failed to uninstall test package'); } diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts new file mode 100644 index 000000000..f42df8539 --- /dev/null +++ b/src/test/integration/packageManager.integration.test.ts @@ -0,0 +1,271 @@ +import * as vscode from 'vscode'; + +import { compare } from '@renovatebot/pep440'; +import assert from 'assert'; +import * as path from 'path'; +import { Package, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api'; +import { CONDA_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID, VENV_MANAGER_ID } from '../../common/constants'; +import { PythonProjectSettings } from '../../internal.api'; +import { getConda } from '../../managers/conda/condaUtils'; +import { ENVS_EXTENSION_ID } from '../constants'; +import { waitForCondition } from '../testUtils'; + +type PackageManagerId = `${string}:${string}`; + +interface PackageManagerProfile { + environmentManagerId: string; + name: string; + packageManagerId: PackageManagerId; + projectDirectory: string; + prerequisite(api: PythonEnvironmentApi): Promise; + supportsVersionLookup(packages: Package[]): boolean; +} + +const profiles: PackageManagerProfile[] = [ + { + environmentManagerId: VENV_MANAGER_ID, + name: 'Pip', + packageManagerId: DEFAULT_PACKAGE_MANAGER_ID, + projectDirectory: 'pip', + prerequisite: async (api) => + (await api.getEnvironments('global')).some((environment) => environment.version.startsWith('3.')), + supportsVersionLookup: (packages) => { + const pipVersion = packages.find((pkg) => pkg.name.toLowerCase() === 'pip')?.version; + return pipVersion !== undefined && compare(pipVersion, '21.2') >= 0; + }, + }, + { + environmentManagerId: CONDA_MANAGER_ID, + name: 'Conda', + packageManagerId: CONDA_MANAGER_ID, + projectDirectory: 'conda', + prerequisite: async () => { + try { + await getConda(); + return true; + } catch { + return false; + } + }, + supportsVersionLookup: () => true, + }, +]; + +const deferredPackageManagers: Readonly> = { + 'ms-python.python:poetry': 'Poetry lifecycle coverage requires a controlled Poetry installation.', +}; + +const deferredProfiles = { + pipWithUv: 'uv-backed Pip selection uses a machine-scoped setting and is unstable within one extension host.', +} as const; + +suite('Package Manager profile coverage', function () { + this.timeout(60_000); + + test('covers or explicitly defers every registered package manager', async () => { + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, 'Extension not found'); + const api: PythonEnvironmentApi = extension.isActive ? extension.exports : await extension.activate(); + await api.getEnvironments('global'); + + const registeredIds = await vscode.commands.executeCommand( + 'python-envs.test.getPackageManagerIds', + ); + assert.ok(registeredIds, 'Registered package-manager IDs are unavailable'); + + const coveredIds = new Set(profiles.map((profile) => profile.packageManagerId)); + const uncoveredIds = registeredIds.filter( + (managerId) => + !coveredIds.has(managerId as PackageManagerId) && + deferredPackageManagers[managerId as PackageManagerId] === undefined, + ); + assert.deepStrictEqual(uncoveredIds, [], `Package managers lack lifecycle coverage: ${uncoveredIds.join(', ')}`); + + for (const profile of profiles) { + assert.ok( + registeredIds.includes(profile.packageManagerId), + `Profile references an unregistered package manager: ${profile.packageManagerId}`, + ); + } + + for (const [profileName, reason] of Object.entries(deferredProfiles)) { + assert.ok(reason.length > 0, `Deferred profile lacks a reason: ${profileName}`); + } + }); +}); + +for (const profile of profiles) { + suite(`${profile.name} Package Manager`, function () { + this.timeout(300_000); + + let api: PythonEnvironmentApi; + let environment: PythonEnvironment | undefined; + let project: PythonProject | undefined; + let workspaceUri: vscode.Uri; + let previousPythonProjects: PythonProjectSettings[] | undefined; + let pythonProjectsUpdated = false; + suiteSetup(async function () { + if (process.env.VSC_PYTHON_PACKAGE_NETWORK_TEST !== '1') { + this.skip(); + return; + } + + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, 'Extension not found'); + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate in time'); + } + api = extension.exports; + assert.ok(api, 'API not available'); + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(workspaceFolder, 'Integration test workspace not found'); + workspaceUri = workspaceFolder.uri; + const config = vscode.workspace.getConfiguration('python-envs', workspaceUri); + + if (!(await profile.prerequisite(api))) { + this.skip(); + return; + } + + const projectUri = vscode.Uri.joinPath( + workspaceUri, + `.package-manager-test-${profile.projectDirectory}-${process.pid}`, + ); + await vscode.workspace.fs.createDirectory(projectUri); + project = { + name: `${profile.name} Package Manager Test`, + uri: projectUri, + }; + previousPythonProjects = config.inspect('pythonProjects')?.workspaceFolderValue; + const pythonProjects = config.get('pythonProjects', []); + const projectSetting: PythonProjectSettings = { + path: path.relative(workspaceUri.fsPath, projectUri.fsPath).replace(/\\/g, '/'), + envManager: profile.environmentManagerId, + packageManager: profile.packageManagerId, + workspace: workspaceFolder.name, + }; + await config.update( + 'pythonProjects', + [...pythonProjects, projectSetting], + vscode.ConfigurationTarget.WorkspaceFolder, + ); + pythonProjectsUpdated = true; + await waitForCondition( + () => + api + .getPythonProjects() + .some((registeredProject) => registeredProject.uri.toString() === projectUri.toString()), + 10_000, + `Python project was not registered: ${projectUri.fsPath}`, + ); + + await api.refreshEnvironments(projectUri); + + environment = await api.createEnvironment(projectUri, { quickCreate: true }); + assert.ok(environment, `${profile.name} failed to create an environment after prerequisites passed`); + assert.strictEqual( + environment.envId.managerId, + profile.environmentManagerId, + `Expected an environment created by ${profile.environmentManagerId}`, + ); + }); + + test(`${profile.name} Package Manager should install, list, and uninstall a package`, async () => { + const packageName = 'requests'; + const baseline = await api.getPackages(environment!, { skipCache: true }); + assert.ok(baseline, 'Unable to list packages before installation'); + const wasInstalled = baseline.some((pkg) => pkg.name.toLowerCase() === packageName); + + if (!wasInstalled) { + await api.managePackages(environment!, { install: [packageName], runHeadless: true }); + } + let packages = await api.getPackages(environment!, { skipCache: true }); + assert.ok(packages, 'Unable to list packages after installation'); + assert.ok( + packages.some((pkg) => pkg.name.toLowerCase() === packageName), + 'Package not installed', + ); + + const directPackageNames = await vscode.commands.executeCommand( + 'python-envs.test.getDirectPackageNames', + environment!, + ); + if (directPackageNames !== undefined) { + assert.ok(directPackageNames.includes(packageName), 'Installed package was not reported as direct'); + } + + if (!wasInstalled) { + await api.managePackages(environment!, { uninstall: [packageName], runHeadless: true }); + packages = await api.getPackages(environment!, { skipCache: true }); + assert.ok(packages, 'Unable to list packages after uninstallation'); + assert.ok( + !packages.some((pkg) => pkg.name.toLowerCase() === packageName), + 'Package not uninstalled', + ); + } + }); + + test(`${profile.name} Package Manager should list available package versions`, async function () { + const packages = await api.getPackages(environment!, { skipCache: true }); + assert.ok(packages, 'Unable to list packages before version lookup'); + if (!profile.supportsVersionLookup(packages)) { + this.skip(); + return; + } + + const versions = await api.getPackageAvailableVersions(environment!, 'requests'); + assert.ok(versions, `${profile.name} unexpectedly failed to retrieve package versions`); + assert.ok(versions.length > 0, 'No package versions available'); + }); + + suiteTeardown(async () => { + try { + if (environment) { + const environmentPath = environment.environmentPath; + await api.removeEnvironment(environment, { runHeadless: true }); + await assert.rejects( + async () => vscode.workspace.fs.stat(environmentPath), + (error: unknown) => + error instanceof vscode.FileSystemError && error.code === 'FileNotFound', + `Environment was not removed: ${environmentPath.fsPath}`, + ); + } + } finally { + const config = vscode.workspace.getConfiguration('python-envs', workspaceUri); + if (project) { + try { + await api.setEnvironment(project.uri, undefined); + } finally { + try { + if (pythonProjectsUpdated) { + await config.update( + 'pythonProjects', + previousPythonProjects, + vscode.ConfigurationTarget.WorkspaceFolder, + ); + await waitForCondition( + () => + !api + .getPythonProjects() + .some( + (registeredProject) => + registeredProject.uri.toString() === project!.uri.toString(), + ), + 10_000, + `Python project was not unregistered: ${project.uri.fsPath}`, + ); + } + } finally { + await vscode.workspace.fs.delete(project.uri, { + recursive: true, + useTrash: false, + }); + } + } + } + } + }); + }); +} diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 3d0488cae..e47ca99fe 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -144,7 +144,11 @@ suite('InlineScriptEnvManager', () => { persistedAssociations = value; } }), - clear: sinon.stub(), + clear: sinon.stub().callsFake(async (keys?: string[]) => { + if (!keys || keys.includes(INLINE_SCRIPT_ENVS_KEY)) { + persistedAssociations = undefined; + } + }), }; sinon.stub(persistentState, 'getWorkspacePersistentState').resolves(workspaceState); @@ -2159,4 +2163,424 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.get(Uri.parse('untitled:script.py')), undefined); }); }); + + suite('clear cache', () => { + test('clears cached environments, persisted associations, and in-memory selections', async () => { + const first = scriptUri('first.py'); + const second = scriptUri('second.py'); + const environment = await createOwnedEnvironment(); + await manager.set([first, second], environment); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + await manager.clearCache(); + + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.strictEqual(persistedAssociations, undefined); + assert.strictEqual(await manager.get(first), undefined); + assert.strictEqual(await manager.get(second), undefined); + assert.deepStrictEqual( + listener.getCalls().map((call) => normalizePath(call.args[0].uri.fsPath)).sort(), + [first.fsPath, second.fsPath].map((value) => normalizePath(value)).sort(), + ); + assert.deepStrictEqual( + listener.getCalls().map((call) => call.args[0].old), + [environment, environment], + ); + assert.ok(listener.getCalls().every((call) => call.args[0].new === undefined)); + }); + + test('clears associations even when the cache directory is already missing', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + await fs.remove(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath); + + await manager.clearCache(); + + assert.strictEqual(persistedAssociations, undefined); + assert.strictEqual(await manager.get(uri), undefined); + assert.strictEqual(listener.callCount, 1); + assert.strictEqual(normalizePath(listener.firstCall.args[0].uri.fsPath), normalizePath(uri.fsPath)); + assert.strictEqual(listener.firstCall.args[0].old, environment); + assert.strictEqual(listener.firstCall.args[0].new, undefined); + }); + + test('is idempotent when the cache and associations are already absent', async () => { + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + await manager.clearCache(); + await manager.clearCache(); + + assert.strictEqual(persistedAssociations, undefined); + assert.strictEqual(listener.callCount, 0); + }); + + test('refuses to clear from an unsafe cache root', async function () { + if (isWindows() && !process.env.SystemDrive) { + this.skip(); + } + const unsafeManager = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + Uri.file(process.platform === 'win32' ? `${process.env.SystemDrive ?? 'C:'}\\` : '/'), + makeFakeLog(), + ); + + await assert.rejects( + unsafeManager.clearCache(), + /unsafe cache root/, + ); + + unsafeManager.dispose(); + }); + + test('refuses to clear a symlinked cache root', async function () { + const symlinkStorageUri = Uri.file(path.join(tempRoot, 'symlink-storage')); + const symlinkManager = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + symlinkStorageUri, + makeFakeLog(), + ); + const realCacheRoot = cacheLayout.getScriptEnvCacheRoot(symlinkStorageUri).fsPath; + const externalCacheRoot = path.join(tempRoot, 'external-cache-root'); + await fs.ensureDir(symlinkStorageUri.fsPath); + await fs.ensureDir(externalCacheRoot); + try { + await fs.symlink(externalCacheRoot, realCacheRoot, process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + this.skip(); + } + throw error; + } + + await assert.rejects( + symlinkManager.clearCache(), + /not a normal directory/, + ); + + symlinkManager.dispose(); + }); + + test('refuses to clear when globalStorage is redirected through a symlink or junction', async function () { + const physicalStoragePath = path.join(tempRoot, 'physical-storage'); + const redirectedStoragePath = path.join(tempRoot, 'redirected-storage'); + await fs.ensureDir(physicalStoragePath); + await fs.ensureDir(redirectedStoragePath); + const redirectedManager = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + Uri.file(redirectedStoragePath), + makeFakeLog(), + ); + try { + await fs.remove(redirectedStoragePath); + await fs.symlink( + physicalStoragePath, + redirectedStoragePath, + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + this.skip(); + } + throw error; + } + + await assert.rejects(redirectedManager.clearCache(), /global storage root is not a normal directory/); + + redirectedManager.dispose(); + }); + + test('fails closed when physical cache verification reports a redirected root', async () => { + const internalManager = manager as unknown as { + getPhysicalOwnedCacheRootPath(cacheRoot: Uri): Promise; + }; + const original = internalManager.getPhysicalOwnedCacheRootPath.bind(manager); + internalManager.getPhysicalOwnedCacheRootPath = async () => { + throw new Error('Refusing to clear the script environment cache because the cache root is redirected.'); + }; + try { + await assert.rejects(manager.clearCache(), /cache root is redirected/); + } finally { + internalManager.getPhysicalOwnedCacheRootPath = original; + } + }); + + test('refuses to clear while a cached environment is locked', async () => { + lockStub.restore(); + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const lockPath = lockfileApis.getFileLockPath(environment.sysPrefix); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `owner-${process.pid}-test`), ''); + + await assert.rejects(manager.clearCache(), /being created/); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + }); + assert.strictEqual(await manager.get(uri), environment); + }); + + test('clears a generation-specific retained lock and its corresponding cache entry', async () => { + lockStub.restore(); + const retainedCacheDir = envDir().fsPath; + await fs.outputFile(venvPythonPath(retainedCacheDir), ''); + const lock = await lockfileApis.acquireFileLock(retainedCacheDir, { + timeoutMs: 0, + retryIntervalMs: 1, + }); + await lock.retain(); + + await manager.clearCache(); + + assert.strictEqual(await fs.pathExists(retainedCacheDir), false); + assert.strictEqual(await fs.pathExists(lockfileApis.getFileLockPath(retainedCacheDir)), false); + }); + + test('refuses to clear a legacy retained lock conservatively', async () => { + lockStub.restore(); + const retainedCacheDir = envDir().fsPath; + const retainedLockPath = lockfileApis.getFileLockPath(retainedCacheDir); + await fs.outputFile(venvPythonPath(retainedCacheDir), ''); + await fs.ensureDir(retainedLockPath); + await fs.writeFile(path.join(retainedLockPath, 'retained'), ''); + + await assert.rejects(manager.clearCache(), /incomplete or malformed/); + + assert.strictEqual(await fs.pathExists(retainedCacheDir), true); + assert.strictEqual(await fs.pathExists(retainedLockPath), true); + }); + + test('clears a stale owner lock and its corresponding cache entry', async () => { + lockStub.restore(); + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const staleLockPath = lockfileApis.getFileLockPath(environment.sysPrefix); + await fs.ensureDir(staleLockPath); + await fs.writeFile(path.join(staleLockPath, 'owner-424242-dead'), ''); + const originalInspectFileLock = lockfileApis.inspectFileLock; + sinon.stub(lockfileApis, 'inspectFileLock').callsFake(async (filePath, options) => { + if (normalizePath(filePath) === normalizePath(environment.sysPrefix)) { + return 'stale'; + } + return originalInspectFileLock(filePath, options); + }); + + await manager.clearCache(); + + assert.strictEqual(await fs.pathExists(environment.sysPrefix), false); + assert.strictEqual(await fs.pathExists(staleLockPath), false); + assert.strictEqual(await manager.get(uri), undefined); + }); + + test('does not delete an entry when another host acquires a new lock after stale lock reclamation', async () => { + lockStub.restore(); + const environment = await createOwnedEnvironment(); + const lockPath = lockfileApis.getFileLockPath(environment.sysPrefix); + const quarantinedLockPath = `${lockPath}.reclaimed-for-test`; + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, 'owner-424242-dead'), ''); + sinon.stub(lockfileApis, 'inspectFileLock').onFirstCall().resolves('stale').onSecondCall().resolves('held'); + sinon.stub(lockfileApis, 'reclaimFileLock').callsFake(async () => { + await fs.rename(lockPath, quarantinedLockPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `owner-${process.pid}-live`), ''); + return true; + }); + + await assert.rejects(manager.clearCache(), /being created/); + + assert.strictEqual(await fs.pathExists(environment.sysPrefix), true); + assert.strictEqual(await fs.pathExists(lockPath), true); + }); + + test('holds the entry lock through deletion', async () => { + lockStub.restore(); + const environment = await createOwnedEnvironment(); + const lockPath = lockfileApis.getFileLockPath(environment.sysPrefix); + const internalManager = manager as unknown as { + deleteCacheEntryForClear(entryPath: string): Promise; + }; + const removeStub = sinon.stub(internalManager, 'deleteCacheEntryForClear').callThrough(); + removeStub.callsFake(async (target) => { + if (normalizePath(target) === normalizePath(environment.sysPrefix)) { + assert.strictEqual(await fs.pathExists(lockPath), true, 'entry lock must protect deletion'); + } + await fs.remove(target); + }); + + await manager.clearCache(); + + sinon.assert.calledWith(removeStub, environment.sysPrefix); + assert.strictEqual(await fs.pathExists(environment.sysPrefix), false); + }); + + test('rejects an orphaned lock directory conservatively', async () => { + lockStub.restore(); + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + await fs.ensureDir(lockfileApis.getFileLockPath(environment.sysPrefix)); + + await assert.rejects(manager.clearCache(), /incomplete or malformed/); + + assert.strictEqual(await fs.pathExists(environment.sysPrefix), true); + assert.strictEqual(await manager.get(uri), environment); + }); + + test('surfaces a persistence failure after clearing disk and memory state', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + workspaceState.clear.onFirstCall().rejects(new Error('Memento unavailable')); + + await assert.rejects(manager.clearCache(), /Memento unavailable/); + + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.strictEqual(await manager.get(uri), undefined); + assert.strictEqual(listener.callCount, 1); + assert.strictEqual(normalizePath(listener.firstCall.args[0].uri.fsPath), normalizePath(uri.fsPath)); + assert.strictEqual(listener.firstCall.args[0].old, environment); + assert.strictEqual(listener.firstCall.args[0].new, undefined); + }); + + test('preserves associations and emits events only for entries removed before a partial failure', async () => { + const firstUri = scriptUri('first.py'); + const secondUri = scriptUri('second.py'); + const firstEnvironment = await createOwnedEnvironment(); + const secondEnvironment = await createOwnedEnvironment('fedcba9876543210'); + await manager.set(firstUri, firstEnvironment); + await manager.set(secondUri, secondEnvironment); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + const internalManager = manager as unknown as { + deleteCacheEntryForClear(entryPath: string): Promise; + }; + sinon.stub(internalManager, 'deleteCacheEntryForClear').callsFake(async (target) => { + if (normalizePath(target) === normalizePath(secondEnvironment.sysPrefix)) { + throw new Error('second entry is busy'); + } + await fs.remove(target); + }); + + await assert.rejects(manager.clearCache(), /Failed to completely clear/); + + assert.strictEqual(await fs.pathExists(firstEnvironment.sysPrefix), false); + assert.strictEqual(await fs.pathExists(secondEnvironment.sysPrefix), true); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(secondUri.fsPath)]: secondEnvironment.environmentPath.fsPath, + }); + assert.strictEqual(await manager.get(firstUri), undefined); + assert.strictEqual(await manager.get(secondUri), secondEnvironment); + sinon.assert.calledOnce(listener); + assert.strictEqual(normalizePath(listener.firstCall.args[0].uri.fsPath), normalizePath(firstUri.fsPath)); + assert.strictEqual(listener.firstCall.args[0].old, firstEnvironment); + assert.strictEqual(listener.firstCall.args[0].new, undefined); + }); + + test('stops before deletion when the physical cache root changes', async () => { + const environment = await createOwnedEnvironment(); + const otherPhysicalRoot = path.join(tempRoot, 'other-cache-root'); + await fs.ensureDir(otherPhysicalRoot); + const internalManager = manager as unknown as { + getPhysicalOwnedCacheRootPath(cacheRoot: Uri): Promise; + }; + const rootStub = sinon.stub(internalManager, 'getPhysicalOwnedCacheRootPath').callThrough(); + rootStub.onSecondCall().resolves(otherPhysicalRoot); + + await assert.rejects(manager.clearCache(), /physical root changed/); + + assert.strictEqual(await fs.pathExists(environment.sysPrefix), true); + }); + + test('does not let a pending rehydration restore an association after clear cache', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; + let resolveRehydration: ((value: PythonEnvironment | undefined) => void) | undefined; + resolveVenvStub.callsFake( + () => + new Promise((resolve) => { + resolveRehydration = resolve; + }), + ); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + const pendingGet = manager.get(uri); + await waitForStubCall(resolveVenvStub); + await manager.clearCache(); + resolveRehydration!(environment); + + assert.strictEqual(await pendingGet, undefined); + assert.strictEqual(await manager.get(uri), undefined); + assert.strictEqual(listener.callCount, 0); + }); + + test('rejects clear when creation started before the clear request', async () => { + const uri = scriptUri(); + let resolveMetadata: ((value: metadataReader.InlineScriptMetadata | undefined) => void) | undefined; + readMetadataStub.callsFake( + () => + new Promise((resolve) => { + resolveMetadata = resolve; + }), + ); + + const createPromise = manager.create(uri); + + await assert.rejects(manager.clearCache(), /being created/); + resolveMetadata!(VALID_METADATA); + assert.ok(await createPromise); + assert.strictEqual(await fs.pathExists(envDir().fsPath), true); + }); + + test('queues create behind a clear request that started first', async () => { + const uri = scriptUri(); + let releaseClear: (() => void) | undefined; + let signalClearStarted: (() => void) | undefined; + const clearStarted = new Promise((resolve) => { + signalClearStarted = resolve; + }); + workspaceState.clear.callsFake( + async (keys?: string[]) => + new Promise((resolve) => { + signalClearStarted!(); + releaseClear = () => { + if (!keys || keys.includes(INLINE_SCRIPT_ENVS_KEY)) { + persistedAssociations = undefined; + } + resolve(); + }; + }), + ); + + const clearPromise = manager.clearCache(); + const createPromise = manager.create(uri); + + await clearStarted; + assert.strictEqual(readMetadataStub.callCount, 0); + releaseClear!(); + await clearPromise; + + assert.ok(await createPromise); + assert.ok(readMetadataStub.calledOnce); + }); + }); }); diff --git a/src/test/managers/builtin/pipPackageManager.unit.test.ts b/src/test/managers/builtin/pipPackageManager.unit.test.ts index 8a64abf1b..549bdd2de 100644 --- a/src/test/managers/builtin/pipPackageManager.unit.test.ts +++ b/src/test/managers/builtin/pipPackageManager.unit.test.ts @@ -40,4 +40,24 @@ suite('PipPackageManager', () => { assert.deepStrictEqual(initial, [cachedPackage]); assert.deepStrictEqual(afterFailedRefresh, [cachedPackage]); }); + + test('preserves undefined when an uncached refresh fails', async () => { + const environment = { + envId: { id: 'test-environment', managerId: 'test-manager' }, + environmentPath: Uri.file('/path/to/environment'), + } as PythonEnvironment; + const manager = new PipPackageManager( + { createPackageItem: sinon.stub() } as unknown as PythonEnvironmentApi, + { error: sinon.stub(), info: sinon.stub() } as unknown as LogOutputChannel, + {} as VenvManager, + ); + const refreshPackages = sinon.stub(builtinUtils, 'refreshPipPackages').resolves(undefined); + + const firstResult = await manager.getPackages(environment); + const secondResult = await manager.getPackages(environment); + + assert.strictEqual(firstResult, undefined); + assert.strictEqual(secondResult, undefined); + assert.strictEqual(refreshPackages.callCount, 2, 'A failed refresh should not populate the package cache'); + }); }); diff --git a/src/test/managers/builtin/pipPackageRefresh.unit.test.ts b/src/test/managers/builtin/pipPackageRefresh.unit.test.ts new file mode 100644 index 000000000..dff10003c --- /dev/null +++ b/src/test/managers/builtin/pipPackageRefresh.unit.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import { PythonEnvironment } from '../../../api'; +import * as errorUtils from '../../../common/errors/utils'; +import * as helpers from '../../../managers/builtin/helpers'; +import { refreshPipPackages } from '../../../managers/builtin/utils'; + +suite('Pip package refresh', () => { + let environment: PythonEnvironment; + let log: LogOutputChannel; + let showErrorMessageWithLogsStub: sinon.SinonStub; + + setup(() => { + environment = { + environmentPath: Uri.file('.'), + execInfo: { + run: { + executable: 'python', + }, + }, + } as PythonEnvironment; + log = { + error: sinon.stub(), + info: sinon.stub(), + } as unknown as LogOutputChannel; + + sinon.stub(helpers, 'shouldUseUv').resolves(false); + sinon.stub(helpers, 'runPython').rejects(new Error('pip list failed')); + showErrorMessageWithLogsStub = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('shows an error when an interactive refresh fails', async () => { + const result = await refreshPipPackages(environment, log); + + assert.strictEqual(result, undefined); + assert.ok(showErrorMessageWithLogsStub.calledOnce); + }); + + test('does not show an error when a headless refresh fails', async () => { + const result = await refreshPipPackages(environment, log, { showErrors: false }); + + assert.strictEqual(result, undefined); + assert.ok(showErrorMessageWithLogsStub.notCalled); + }); +}); diff --git a/src/test/managers/builtin/pipVersions.unit.test.ts b/src/test/managers/builtin/pipVersions.unit.test.ts index 5c06c394b..b2bd15f6b 100644 --- a/src/test/managers/builtin/pipVersions.unit.test.ts +++ b/src/test/managers/builtin/pipVersions.unit.test.ts @@ -1,13 +1,16 @@ -import assert from 'assert'; import { explain } from '@renovatebot/pep440'; -import { parsePipIndexVersionsJson } from '../../../managers/builtin/pipPackageManager'; +import assert from 'assert'; +import { parsePipIndexVersionsJson, parsePipIndexVersionsText } from '../../../managers/builtin/pipPackageManager'; suite('Pip Version Parsing', () => { suite('parsePipIndexVersionsJson', () => { test('parses valid JSON with versions array', () => { const output = JSON.stringify({ name: 'requests', versions: ['2.31.0', '2.30.0', '2.29.0'] }); const versions = parsePipIndexVersionsJson(output); - assert.deepStrictEqual(versions, ['2.31.0', '2.30.0', '2.29.0'].map((v) => explain(v))); + assert.deepStrictEqual( + versions, + ['2.31.0', '2.30.0', '2.29.0'].map((v) => explain(v)), + ); }); test('parses output with a single version', () => { @@ -33,5 +36,29 @@ suite('Pip Version Parsing', () => { assert.strictEqual(versions, undefined); }); }); -}); + suite('parsePipIndexVersionsText', () => { + test('parses and sorts the available versions line', () => { + const output = [ + 'requests (2.32.5)', + 'Available versions: 2.31.0, 2.32.5, 2.30.0', + ' INSTALLED: 2.31.0', + ' LATEST: 2.32.5', + ].join('\n'); + const versions = parsePipIndexVersionsText(output); + assert.deepStrictEqual( + versions, + ['2.32.5', '2.31.0', '2.30.0'].map((version) => explain(version)), + ); + }); + + test('returns undefined when the available versions line is missing', () => { + assert.strictEqual(parsePipIndexVersionsText('ERROR: No matching distribution found'), undefined); + }); + + test('ignores invalid versions', () => { + const versions = parsePipIndexVersionsText('Available versions: invalid, 1.2.3'); + assert.deepStrictEqual(versions, [explain('1.2.3')]); + }); + }); +}); diff --git a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts index 7e1e202be..12bf1c7b3 100644 --- a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts +++ b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts @@ -47,12 +47,11 @@ function createManager( const baseManager = { getEnvironments: sinon.stub().resolves(baseEnvironments), } as any as EnvironmentManager; - const manager = new VenvManager( - {} as NativePythonFinder, - api, - baseManager, - { info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any, - ); + const manager = new VenvManager({} as NativePythonFinder, api, baseManager, { + info: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + } as any); (manager as any)._initialized = { completed: true, promise: Promise.resolve() }; (manager as any).collection = []; return manager; @@ -221,6 +220,17 @@ suite('VenvManager.remove - orchestration', () => { assert.strictEqual(events[0][0].environment, env); }); + test('forwards headless removal options to the removal helper', async () => { + const manager = createManager(); + const env = environment(); + removeVenvStub.resolves(true); + + await manager.remove(env, { runHeadless: true }); + + assert.strictEqual(removeVenvStub.firstCall.args[0], env); + assert.deepStrictEqual(removeVenvStub.firstCall.args[2], { runHeadless: true }); + }); + test('does not mutate state when the removal helper returns false', async () => { const manager = createManager(); const env = environment(); diff --git a/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts b/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts index 068eb5dca..b1fae91bf 100644 --- a/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts +++ b/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts @@ -1,6 +1,13 @@ import * as assert from 'assert'; +import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; +import * as sinon from 'sinon'; +import * as windowApis from '../../../common/window.apis'; +import * as uvEnvironments from '../../../managers/builtin/uvEnvironments'; +import { removeVenv } from '../../../managers/builtin/venvUtils'; +import { createMockLogOutputChannel } from '../../mocks/helper'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; suite('venvUtils Path Validation', () => { suite('isDriveRoot behavior', () => { @@ -146,4 +153,28 @@ suite('venvUtils removeVenv validation integration', () => { 'Should check for pyvenv.cfg in the environment root', ); }); + + test('headless removal skips confirmation and removes the environment', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'remove-venv-')); + const envPath = path.join(tempRoot, '.venv'); + await fs.outputFile(path.join(envPath, 'pyvenv.cfg'), 'home = base'); + const showWarningMessageStub = sinon.stub(windowApis, 'showWarningMessage'); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + sinon.stub(uvEnvironments, 'removeUvEnvironment').resolves(); + + try { + const removed = await removeVenv( + createMockPythonEnvironment({ name: '.venv', envPath }), + createMockLogOutputChannel(), + { runHeadless: true }, + ); + + assert.strictEqual(removed, true); + assert.strictEqual(showWarningMessageStub.callCount, 0); + assert.strictEqual(await fs.pathExists(envPath), false); + } finally { + sinon.restore(); + await fs.remove(tempRoot); + } + }); }); diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts index 1f65b3c75..8f6f77402 100644 --- a/src/test/managers/common/packageChanges.unit.test.ts +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -127,6 +127,35 @@ suite('packageChanges', () => { assert.strictEqual(changes[0].kind, PackageChangeKind.add); }); + test('uses an operation-specific package fetcher when provided', async () => { + const fetched = [{ name: 'requests', version: '2.31.0' } as Package]; + const fetchPackages = sinon.stub().resolves(fetched); + const onChanges = sinon.stub(); + + const result = await updatePackagesAndNotify( + packageManager, + environment, + undefined, + onChanges, + fetchPackages, + ); + + assert.deepStrictEqual(result, fetched); + assert.ok(fetchPackages.calledOnce); + assert.ok(getPackagesStub.notCalled); + }); + + test('preserves undefined and does not report removals when fetching fails', async () => { + const before = [{ name: 'requests', version: '2.31.0' } as Package]; + getPackagesStub.resolves(undefined); + const onChanges = sinon.stub(); + + const result = await updatePackagesAndNotify(packageManager, environment, before, onChanges); + + assert.strictEqual(result, undefined); + assert.ok(onChanges.notCalled); + }); + test('does not fire callback when nothing changed', async () => { const pkgs = [{ name: 'requests', version: '2.31.0' } as Package]; getPackagesStub.resolves(pkgs); diff --git a/src/test/managers/conda/condaPackageManager.unit.test.ts b/src/test/managers/conda/condaPackageManager.unit.test.ts new file mode 100644 index 000000000..ea6614daf --- /dev/null +++ b/src/test/managers/conda/condaPackageManager.unit.test.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import * as errorUtils from '../../../common/errors/utils'; +import * as windowApis from '../../../common/window.apis'; +import { CondaPackageManager } from '../../../managers/conda/condaPackageManager'; +import * as condaUtils from '../../../managers/conda/condaUtils'; + +suite('CondaPackageManager', () => { + teardown(() => { + sinon.restore(); + }); + + test('headless package failures reject without showing error UI', async () => { + const environment = { + envId: { id: 'test-environment', managerId: 'test-manager' }, + environmentPath: Uri.file('/path/to/environment'), + } as PythonEnvironment; + const logError = sinon.stub(); + const log = { + error: logError, + } as unknown as LogOutputChannel; + const manager = new CondaPackageManager({} as PythonEnvironmentApi, log); + const operationError = new Error('conda install failed'); + sinon.stub(condaUtils, 'managePackages').rejects(operationError); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + + await assert.rejects( + manager.manage(environment, { install: ['requests'], runHeadless: true }), + (error: unknown) => error === operationError, + ); + + assert.ok(logError.calledOnce); + assert.ok(showErrorMessageWithLogs.notCalled); + }); +}); diff --git a/src/test/smoke/registration.smoke.test.ts b/src/test/smoke/registration.smoke.test.ts index bd8d469e0..56d0b2944 100644 --- a/src/test/smoke/registration.smoke.test.ts +++ b/src/test/smoke/registration.smoke.test.ts @@ -113,6 +113,38 @@ suite('Smoke: Registration Checks', function () { ); }); + test('Internal inline clear command is not publicly contributed', function () { + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + const contributedCommands = (extension.packageJSON?.contributes?.commands ?? []) as Array<{ command: string }>; + const commandPaletteEntries = (extension.packageJSON?.contributes?.menus?.commandPalette ?? []) as Array<{ + command: string; + }>; + + assert.ok( + !contributedCommands.some((entry) => entry.command === 'python-envs.clearScriptEnvCache'), + 'python-envs.clearScriptEnvCache should not be publicly contributed before rollout', + ); + assert.ok( + !commandPaletteEntries.some((entry) => entry.command === 'python-envs.clearScriptEnvCache'), + 'python-envs.clearScriptEnvCache should not appear in contributed menus before rollout', + ); + }); + + test('Internal inline clear command is not registered while the feature flag is off', async function () { + const allCommands = await vscode.commands.getCommands(true); + + assert.ok( + !allCommands.includes('python-envs.clearScriptEnvCache'), + 'python-envs.clearScriptEnvCache should not be registered by default', + ); + await assert.rejects( + () => Promise.resolve(vscode.commands.executeCommand('python-envs.clearScriptEnvCache')), + /not found/i, + ); + }); + // ========================================================================= // API METHODS - All API methods must exist and be functions // =========================================================================