diff --git a/extensions/positron-python/package.json b/extensions/positron-python/package.json index c3ea3c036163..0ba6ff4b1f76 100644 --- a/extensions/positron-python/package.json +++ b/extensions/positron-python/package.json @@ -552,6 +552,11 @@ "category": "Python", "command": "python.interpreters.debugInfo", "title": "%python.command.python.interpreterDebugInfo.title%" + }, + { + "category": "Python", + "command": "python.getEnvironmentHealth", + "title": "%python.command.python.getEnvironmentHealth.title%" } ], "configuration": { diff --git a/extensions/positron-python/package.nls.json b/extensions/positron-python/package.nls.json index 73fe916febbe..18386da2b7f3 100644 --- a/extensions/positron-python/package.nls.json +++ b/extensions/positron-python/package.nls.json @@ -47,6 +47,7 @@ "python.command.python.refreshTensorBoard.title": "Refresh TensorBoard", "python.command.python.testing.copyTestId.title": "Copy Test Id", "python.command.python.interpreterDebugInfo.title": "Print interpreter debug information to Output", + "python.command.python.getEnvironmentHealth.title": "Print environment health report to Output", "python.createEnvironment.contentButton.description": "Show or hide Create Environment button in the editor for `requirements.txt` or other dependency files.", "python.createEnvironment.trigger.description": "Detect if environment creation is required for the current project", "python.menu.createNewFile.title": "Python File", diff --git a/extensions/positron-python/src/client/common/constants.ts b/extensions/positron-python/src/client/common/constants.ts index 9d2edf77f86d..f11fe52230bb 100644 --- a/extensions/positron-python/src/client/common/constants.ts +++ b/extensions/positron-python/src/client/common/constants.ts @@ -87,6 +87,7 @@ export namespace Commands { export const Create_Pyproject_Toml = 'python.createPyprojectToml'; export const InstallPackages = 'python.installPackages'; export const InstallPythonViaUv = 'python.installPythonViaUv'; + export const Get_Environment_Health = 'python.getEnvironmentHealth'; // --- End Positron --- export const InstallJupyter = 'python.installJupyter'; export const InstallPython = 'python.installPython'; diff --git a/extensions/positron-python/src/client/interpreter/configuration/environmentTypeComparer.ts b/extensions/positron-python/src/client/interpreter/configuration/environmentTypeComparer.ts index 6bd235bf8dcf..7238eee81ccc 100644 --- a/extensions/positron-python/src/client/interpreter/configuration/environmentTypeComparer.ts +++ b/extensions/positron-python/src/client/interpreter/configuration/environmentTypeComparer.ts @@ -240,7 +240,10 @@ function getArchitectureSortName(arch?: Architecture) { } } -function isBaseCondaEnvironment(environment: PythonEnvironment): boolean { +// --- Start Positron --- +// export this +export function isBaseCondaEnvironment(environment: PythonEnvironment): boolean { + // --- End Positron --- return ( environment.envType === EnvironmentType.Conda && (environment.envName === 'base' || environment.envName === 'miniconda') diff --git a/extensions/positron-python/src/client/positron/createEnvApi.ts b/extensions/positron-python/src/client/positron/createEnvApi.ts index 74a2cd21bd1b..3c77f54fc72b 100644 --- a/extensions/positron-python/src/client/positron/createEnvApi.ts +++ b/extensions/positron-python/src/client/positron/createEnvApi.ts @@ -1,10 +1,11 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved. + * Copyright (C) 2024-2026 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ // eslint-disable-next-line import/no-unresolved import * as positron from 'positron'; +import { Uri, WorkspaceFolder } from 'vscode'; import { CreateEnvironmentOptionsInternal } from '../pythonEnvironments/creation/types'; import { CreateEnvironmentOptions, @@ -16,7 +17,7 @@ import { IPythonRuntimeManager } from './manager'; import { getExtension } from '../common/vscodeApis/extensionsApi'; import { PythonExtension } from '../api/types'; import { PVSC_EXTENSION_ID } from '../common/constants'; -import { getConfiguration } from '../common/vscodeApis/workspaceApis'; +import { getConfiguration, getWorkspaceFolder } from '../common/vscodeApis/workspaceApis'; import { CONDA_PROVIDER_ID } from '../pythonEnvironments/creation/provider/condaCreationProvider'; import { VenvCreationProviderId } from '../pythonEnvironments/creation/provider/venvCreationProvider'; import { UV_PROVIDER_ID } from '../pythonEnvironments/creation/provider/uvCreationProvider'; @@ -36,6 +37,33 @@ interface FlowEnvironmentProviders { */ type CreateEnvironmentAndRegisterResult = CreateEnvironmentResult & { metadata?: positron.LanguageRuntimeMetadata }; +/** + * JSON-safe options for the `python.createEnvironmentAndRegister` command. Mirrors + * `CreateEnvironmentOptions & CreateEnvironmentOptionsInternal`, but `workspaceFolder` is a URI + * string instead of a `WorkspaceFolder` object, so callers can pass this across a command + * boundary (e.g. embedded in `fix.args`) without needing to serialize a `WorkspaceFolder`. + */ +export interface CreateEnvironmentAndRegisterOptions + extends Omit { + workspaceFolder?: string; +} + +/** + * Rehydrates a workspace folder URI string into a `WorkspaceFolder`. + * @param workspaceFolderUri The URI string of the workspace folder, or undefined. + * @returns The corresponding `WorkspaceFolder`, or undefined if no URI was given. + */ +function rehydrateWorkspaceFolder(workspaceFolderUri: string | undefined): WorkspaceFolder | undefined { + if (!workspaceFolderUri) { + return undefined; + } + const folder = getWorkspaceFolder(Uri.parse(workspaceFolderUri)); + if (!folder) { + throw new Error(`Workspace folder not found for URI: ${workspaceFolderUri}`); + } + return folder; +} + /** * Get the list of providers that can be used in the Positron New Folder Flow * @param providers The available environment creation providers @@ -61,7 +89,7 @@ export async function getCreateEnvironmentProviders( export async function createEnvironmentAndRegister( providers: readonly CreateEnvironmentProvider[], pythonRuntimeManager: IPythonRuntimeManager, - options: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal, + options: CreateEnvironmentAndRegisterOptions, ): Promise { if (!options.providerId || (!options.interpreterPath && !options.condaPythonVersion && !options.uvPythonVersion)) { return { @@ -70,7 +98,11 @@ export async function createEnvironmentAndRegister( ), }; } - const result = await handleCreateEnvironmentCommand(providers, options); + const resolvedOptions: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal = { + ...options, + workspaceFolder: rehydrateWorkspaceFolder(options.workspaceFolder), + }; + const result = await handleCreateEnvironmentCommand(providers, resolvedOptions); if (result?.path) { const metadata = await pythonRuntimeManager.registerLanguageRuntimeFromPath(result.path); return { ...result, metadata }; diff --git a/extensions/positron-python/src/client/positron/environmentHealth.ts b/extensions/positron-python/src/client/positron/environmentHealth.ts new file mode 100644 index 000000000000..ee63135fd21f --- /dev/null +++ b/extensions/positron-python/src/client/positron/environmentHealth.ts @@ -0,0 +1,638 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as os from 'os'; +import * as vscode from 'vscode'; +import * as fs from '../common/platform/fs-paths'; +import { + NativePythonFinder, + getNativePythonFinder, +} from '../pythonEnvironments/base/locators/common/nativePythonFinder'; +import { + isVersionSupported, + isBaseCondaEnvironment, + isProblematicCondaEnvironment, + comparePythonVersionDescending, +} from '../interpreter/configuration/environmentTypeComparer'; +import { EnvironmentType, PythonEnvironment, virtualEnvTypes } from '../pythonEnvironments/info'; +import { VenvCreationProviderId } from '../pythonEnvironments/creation/provider/venvCreationProvider'; +import { UV_PROVIDER_ID } from '../pythonEnvironments/creation/provider/uvCreationProvider'; +import { IServiceContainer } from '../ioc/types'; +import { IInterpreterService } from '../interpreter/contracts'; +import { IInterpreterComparer } from '../interpreter/configuration/types'; +import { IInstaller, Product, ProductInstallStatus } from '../common/types'; +import { IPythonExecutionFactory } from '../common/process/types'; +import { IPYKERNEL_VERSION, MINIMUM_PYTHON_VERSION, MAXIMUM_PYTHON_VERSION_EXCLUSIVE } from '../common/constants'; +import { Architecture } from '../common/utils/platform'; +import { getConfiguration } from '../common/vscodeApis/workspaceApis'; +import { traceInfo } from '../logging'; +import { getIpykernelBundle } from './ipykernel'; +import { isUvInstalled } from '../pythonEnvironments/common/environmentManagers/uv'; + +// Human-readable inclusive supported range (e.g. "3.9-3.14") for user-facing messages, derived from +// the version bounds so it stays in sync when they change. The exclusive maximum is set at a minor +// boundary (e.g. 3.15.0), so the last supported minor is one below it. +const SUPPORTED_PYTHON_VERSION_RANGE = `${MINIMUM_PYTHON_VERSION.major}.${MINIMUM_PYTHON_VERSION.minor}-${ + MAXIMUM_PYTHON_VERSION_EXCLUSIVE.major +}.${MAXIMUM_PYTHON_VERSION_EXCLUSIVE.minor - 1}`; + +export type HealthItemStatus = 'pass' | 'warn' | 'fail' | 'skipped'; + +export interface HealthItemFix { + /** Extension OR core command id. */ + commandId: string; + /** Fully computed at check time; plain JSON only (no vscode types). */ + args?: unknown[]; + /** Localized button label. */ + label: string; +} + +export interface HealthItem { + /** Stable machine id, e.g. 'environmentReady'. */ + id: string; + status: HealthItemStatus; + /** Localized one-liner. */ + summary: string; + /** Localized, personalized (actual paths/versions). */ + detail?: string; + fix?: HealthItemFix; + /** Reserved for future docs deep links. */ + learnMoreUrl?: string; +} + +export interface EnvironmentHealthResult { + /** True when no item has status 'fail' (warn and skipped do not affect it). */ + ok: boolean; + /** In dependency order. */ + items: HealthItem[]; + /** Interpreter evaluated by items 3-4. */ + interpreterPath?: string; +} + +export function probeDiscovery(finder: Pick): HealthItem { + const summary = vscode.l10n.t('Positron can discover Python environments'); + if (finder.lastDiscoveryError) { + return { + id: 'discovery', + status: 'fail', + summary, + detail: vscode.l10n.t('Python environment discovery could not start: {0}', finder.lastDiscoveryError), + fix: { + commandId: 'positron.startupDiagnostics.show', + label: vscode.l10n.t('Show Runtime Startup Diagnostics'), + }, + }; + } + return { id: 'discovery', status: 'pass', summary }; +} + +export const DISCOVERY_WAIT_MS = 10_000; + +/** Resolves `true` if `refreshPromise` settles (resolve OR reject) within `waitMs`, else `false`. */ +async function discoveryFinishedWithin(refreshPromise: Promise, waitMs: number): Promise { + let finished = false; + const settled = refreshPromise.then( + () => { + finished = true; + }, + () => { + finished = true; + }, + ); + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, waitMs); + }); + try { + await Promise.race([settled, timeout]); + } finally { + clearTimeout(timer); + } + return finished; +} + +export async function probePythonInstalled(deps: { + getInterpreters: () => PythonEnvironment[]; + refreshPromise: Promise | undefined; + lastDiscoveryError: () => string | undefined; + allowUvPythonInstall: boolean; + waitMs: number; +}): Promise { + const summary = vscode.l10n.t('A supported Python is installed'); + const hasSupported = () => deps.getInterpreters().some((i) => isVersionSupported(i.version)); + + if (hasSupported()) { + return { id: 'pythonInstalled', status: 'pass', summary }; + } + + // No supported interpreter is known yet. If a discovery pass is in flight, join it + // (bounded); never trigger a new pass. `refreshPromise === undefined` post-activation + // means discovery already finished. + let finished = deps.refreshPromise === undefined; + if (deps.refreshPromise) { + finished = await discoveryFinishedWithin(deps.refreshPromise, deps.waitMs); + if (hasSupported()) { + return { id: 'pythonInstalled', status: 'pass', summary }; + } + } + + if (!finished) { + return { + id: 'pythonInstalled', + status: 'fail', + summary, + detail: vscode.l10n.t( + 'Python discovery had not finished. Re-run the check; it will likely resolve once discovery completes.', + ), + }; + } + + // The discovery probe (item 1) samples lastDiscoveryError once, before this bounded wait. + // A locator failure that only surfaces during the wait (e.g. the initial refresh rejecting) + // is set on the finder afterwards, so re-check it here: no supported Python plus a discovery + // error means the locator is broken, not that Python is missing. Point at diagnostics rather + // than offer an install-Python fix that would not resolve a broken locator. + const discoveryError = deps.lastDiscoveryError(); + if (discoveryError) { + return { + id: 'pythonInstalled', + status: 'fail', + summary, + detail: vscode.l10n.t( + 'A supported Python could not be confirmed because environment discovery failed: {0}', + discoveryError, + ), + fix: { + commandId: 'positron.startupDiagnostics.show', + label: vscode.l10n.t('Show Runtime Startup Diagnostics'), + }, + }; + } + + if (!deps.allowUvPythonInstall) { + return { + id: 'pythonInstalled', + status: 'fail', + summary, + detail: vscode.l10n.t( + 'No supported Python ({0}) was found, and automatic installation is disabled by the python.allowUvPythonInstall setting. Ask your administrator to provision a supported Python.', + SUPPORTED_PYTHON_VERSION_RANGE, + ), + }; + } + + return { + id: 'pythonInstalled', + status: 'fail', + summary, + detail: vscode.l10n.t('No supported Python ({0}) was found on this machine.', SUPPORTED_PYTHON_VERSION_RANGE), + fix: { + commandId: 'python.installPythonViaUv', + label: vscode.l10n.t('Install Python'), + }, + }; +} + +export async function resolveWouldBeUsedInterpreter(deps: { + workspaceUri: vscode.Uri | undefined; + getActiveInterpreter: (resource?: vscode.Uri) => Promise; + getInterpreters: () => PythonEnvironment[]; + getRecommended: ( + interpreters: PythonEnvironment[], + resource: vscode.Uri | undefined, + ) => PythonEnvironment | undefined; +}): Promise { + const active = await deps.getActiveInterpreter(deps.workspaceUri); + if (active) { + return active; + } + return deps.getRecommended(deps.getInterpreters(), deps.workspaceUri); +} + +/** + * A "dedicated" environment is a workspace-local or named virtual environment. A global/system + * install, an externally-managed interpreter (PEP 668), or conda `base` is NOT dedicated. + */ +export function isDedicatedEnvironment(env: PythonEnvironment, externallyManaged = false): boolean { + if (externallyManaged) { + return false; + } + if (env.envType === EnvironmentType.Global || env.envType === EnvironmentType.System) { + return false; + } + if (isBaseCondaEnvironment(env)) { + return false; + } + return virtualEnvTypes.includes(env.envType); +} + +// Env types safe to seed a new venv from: standalone global/system Pythons. Excludes virtual +// envs, Unknown (unclassified - could be an undetected venv), Module (Linux environment-module +// Pythons that must launch with the module loaded, not from the raw executable), MicrosoftStore +// (venv-creation quirks), and ActiveState (managed runtime). New env types default to excluded. +const supportedBaseTypes = [ + EnvironmentType.Global, + EnvironmentType.System, + EnvironmentType.Pyenv, + EnvironmentType.Custom, +]; + +export function bestSupportedGlobalPython(interpreters: PythonEnvironment[]): PythonEnvironment | undefined { + const globals = interpreters.filter((i) => isVersionSupported(i.version) && supportedBaseTypes.includes(i.envType)); + // Full-version comparator: orders by major/minor/patch and sorts unknown-version + // interpreters last, so an unparsed-version global is only ever chosen as a last resort. + globals.sort((a, b) => comparePythonVersionDescending(a.version, b.version)); + return globals[0]; +} + +export function buildCreateEnvFix(deps: { + workspaceUri: vscode.Uri; + uvInstalled: boolean; + allowUvPythonInstall: boolean; + baseInterpreterPath: string | undefined; +}): HealthItemFix | undefined { + // The uv auto-version path lets uv select and download a uv-managed Python as part of + // environment creation. When python.allowUvPythonInstall is off, skip it (mirroring the + // install-Python fixes elsewhere in this file) and seed a venv from an existing supported + // base interpreter instead, or omit the fix when there is none. + if (deps.uvInstalled && deps.allowUvPythonInstall) { + return { + commandId: 'python.createEnvironmentAndRegister', + args: [ + { providerId: UV_PROVIDER_ID, workspaceFolder: deps.workspaceUri.toString(), uvPythonVersion: 'auto' }, + ], + label: vscode.l10n.t('Create Python Environment'), + }; + } + if (!deps.baseInterpreterPath) { + // No usable uv path and no supported base interpreter to seed a venv. The command would + // reject with "Missing required options", so offer no create-env fix and let the caller + // fall back. + return undefined; + } + return { + commandId: 'python.createEnvironmentAndRegister', + args: [ + { + providerId: VenvCreationProviderId, + workspaceFolder: deps.workspaceUri.toString(), + interpreterPath: deps.baseInterpreterPath, + }, + ], + label: vscode.l10n.t('Create Python Environment'), + }; +} + +export function buildNewFolderFix(): HealthItemFix { + return { + commandId: 'positron.workbench.action.newFolderFromTemplate', + label: vscode.l10n.t('New Folder from Template'), + }; +} + +export function probeDedicatedEnvironment(deps: { + workspaceOpen: boolean; + interpreterDedicated: boolean; + anyDedicatedDiscovered: boolean; + createEnvFix: HealthItemFix; + newFolderFix: HealthItemFix; +}): HealthItem { + const id = 'dedicatedEnvironment'; + const summary = vscode.l10n.t('A dedicated Python environment is available'); + + if (deps.workspaceOpen) { + if (deps.interpreterDedicated) { + return { id, status: 'pass', summary }; + } + return { + id, + status: 'fail', + summary, + detail: vscode.l10n.t( + 'This workspace would use a global, system, or base environment. Create a dedicated environment to isolate its packages.', + ), + fix: deps.createEnvFix, + }; + } + + if (deps.anyDedicatedDiscovered) { + return { + id, + status: 'warn', + summary, + detail: vscode.l10n.t( + 'A dedicated environment exists but no folder is open. Open a folder (or create one) to use it; full green means an open folder using a dedicated environment.', + ), + fix: deps.newFolderFix, + }; + } + + return { + id, + status: 'fail', + summary, + detail: vscode.l10n.t( + 'No dedicated Python environment was found and no folder is open. Create a folder with a dedicated environment to get started.', + ), + fix: deps.newFolderFix, + }; +} + +export function probeEnvironmentReady(deps: { + resolvesAndRuns: boolean; + versionSupported: boolean; + kernelReady: boolean; + isRosetta: boolean; + recreateFix: HealthItemFix; + installIpykernelFix: HealthItemFix; + // Omitted when python.allowUvPythonInstall is off; the Rosetta warn then has no fix button. + installNativePythonFix?: HealthItemFix; +}): HealthItem { + const id = 'environmentReady'; + const summary = vscode.l10n.t('The environment is ready to use with Positron'); + + if (!deps.resolvesAndRuns) { + return { + id, + status: 'fail', + summary, + detail: vscode.l10n.t( + "This environment's interpreter could not be run (it may be stale or missing). Recreating it will not carry over installed packages.", + ), + fix: deps.recreateFix, + }; + } + if (!deps.versionSupported) { + return { + id, + status: 'fail', + summary, + detail: vscode.l10n.t( + "This environment's Python version is not supported (needs {0}).", + SUPPORTED_PYTHON_VERSION_RANGE, + ), + fix: deps.recreateFix, + }; + } + if (!deps.kernelReady) { + return { + id, + status: 'fail', + summary, + detail: vscode.l10n.t('The Jupyter kernel (ipykernel) is not usable for this environment.'), + fix: deps.installIpykernelFix, + }; + } + if (deps.isRosetta) { + return { + id, + status: 'warn', + summary, + detail: vscode.l10n.t( + 'This x64 Python runs under Rosetta on Apple silicon. Install a native (arm64) Python and recreate the environment on it for best performance.', + ), + fix: deps.installNativePythonFix, + }; + } + return { id, status: 'pass', summary }; +} + +interface ItemProducers { + discovery: () => HealthItem; + pythonInstalled: () => Promise; + dedicated: () => Promise; + ready: () => Promise; +} + +function skipped(id: string): HealthItem { + return { id, status: 'skipped', summary: id }; +} + +async function runItem(id: string, produce: () => HealthItem | Promise): Promise { + try { + return await produce(); + } catch (ex) { + return { + id, + status: 'fail', + summary: id, + detail: vscode.l10n.t('Health check failed: {0}', ex instanceof Error ? ex.message : String(ex)), + }; + } +} + +export async function assembleItems(producers: ItemProducers): Promise { + const items: HealthItem[] = []; + const discovery = await runItem('discovery', producers.discovery); + items.push(discovery); + if (discovery.status === 'fail') { + items.push(skipped('pythonInstalled'), skipped('dedicatedEnvironment'), skipped('environmentReady')); + return finalize(items); + } + + const pythonInstalled = await runItem('pythonInstalled', producers.pythonInstalled); + items.push(pythonInstalled); + if (pythonInstalled.status === 'fail') { + items.push(skipped('dedicatedEnvironment'), skipped('environmentReady')); + return finalize(items); + } + + items.push(await runItem('dedicatedEnvironment', producers.dedicated)); + items.push(await runItem('environmentReady', producers.ready)); + return finalize(items); +} + +// The orchestrator sets `interpreterPath` on the returned result once its memoized +// interpreter snapshot has resolved (see getEnvironmentHealth); assembleItems stays +// pure over the item producers. +function finalize(items: HealthItem[]): EnvironmentHealthResult { + return { ok: !items.some((i) => i.status === 'fail'), items }; +} + +export async function getEnvironmentHealth( + serviceContainer: IServiceContainer, + args?: { workspaceFolder?: string }, +): Promise { + const interpreterService = serviceContainer.get(IInterpreterService); + const comparer = serviceContainer.get(IInterpreterComparer); + const workspaceUri = resolveWorkspaceUri(args?.workspaceFolder); + const uvInstalled = await isUvInstalled(); + const allowUvPythonInstall = getConfiguration('python').get('allowUvPythonInstall') ?? true; + + // Resolve the interpreter snapshot lazily and memoize it. Item 2 (pythonInstalled) + // waits out any in-flight discovery, so items 3-4 must read the interpreter list + // AFTER that wait. Resolving it eagerly here would hand them a pre-wait (possibly + // empty) snapshot, so item 2 could pass (it found Python during its wait) while + // item 4 fails with "No interpreter to evaluate". The producers run in order, so + // the first of items 3-4 populates the snapshot post-wait and the rest -- plus the + // reported interpreterPath -- reuse it. + let snapshot: { interp: PythonEnvironment | undefined; interpreters: PythonEnvironment[] } | undefined; + const resolveSnapshot = async () => { + if (!snapshot) { + const interpreters = interpreterService.getInterpreters(workspaceUri); + const interp = await resolveWouldBeUsedInterpreter({ + workspaceUri, + getActiveInterpreter: (r) => interpreterService.getActiveInterpreter(r), + getInterpreters: () => interpreters, + getRecommended: (list, resource) => comparer.getRecommended(list, resource), + }); + snapshot = { interp, interpreters }; + } + return snapshot; + }; + + const result = await assembleItems({ + discovery: () => probeDiscovery(getNativePythonFinder()), + pythonInstalled: () => + probePythonInstalled({ + getInterpreters: () => interpreterService.getInterpreters(workspaceUri), + refreshPromise: interpreterService.getRefreshPromise(), + lastDiscoveryError: () => getNativePythonFinder().lastDiscoveryError, + allowUvPythonInstall, + waitMs: DISCOVERY_WAIT_MS, + }), + dedicated: async () => + evaluateDedicated({ workspaceUri, uvInstalled, allowUvPythonInstall, ...(await resolveSnapshot()) }), + ready: async () => + evaluateReady(serviceContainer, { + workspaceUri, + uvInstalled, + allowUvPythonInstall, + ...(await resolveSnapshot()), + }), + }); + // Read the memoized snapshot directly rather than calling resolveSnapshot() again. + // When the skip cascade fired (items 3-4 skipped) the snapshot was never resolved, so + // it stays undefined and interpreterPath is omitted. Re-invoking resolveSnapshot() here + // would run getActiveInterpreter outside runItem, where a rejection escapes and breaks + // the "command never rejects" contract. + result.interpreterPath = snapshot?.interp?.path; + + traceEnvironmentHealth(result); + return result; +} + +function resolveWorkspaceUri(workspaceFolder: string | undefined): vscode.Uri | undefined { + if (workspaceFolder) { + return vscode.Uri.parse(workspaceFolder); + } + return vscode.workspace.workspaceFolders?.[0]?.uri; +} + +async function evaluateDedicated(ctx: { + workspaceUri: vscode.Uri | undefined; + interp: PythonEnvironment | undefined; + interpreters: PythonEnvironment[]; + uvInstalled: boolean; + allowUvPythonInstall: boolean; +}): Promise { + const createEnvFix = + (ctx.workspaceUri && + buildCreateEnvFix({ + workspaceUri: ctx.workspaceUri, + uvInstalled: ctx.uvInstalled, + allowUvPythonInstall: ctx.allowUvPythonInstall, + baseInterpreterPath: bestSupportedGlobalPython(ctx.interpreters)?.path, + })) || + buildNewFolderFix(); + return probeDedicatedEnvironment({ + workspaceOpen: ctx.workspaceUri !== undefined, + interpreterDedicated: ctx.interp !== undefined && isDedicatedEnvironment(ctx.interp), + anyDedicatedDiscovered: ctx.interpreters.some((i) => isDedicatedEnvironment(i)), + createEnvFix, + newFolderFix: buildNewFolderFix(), + }); +} + +async function evaluateReady( + serviceContainer: IServiceContainer, + ctx: { + workspaceUri: vscode.Uri | undefined; + interp: PythonEnvironment | undefined; + interpreters: PythonEnvironment[]; + uvInstalled: boolean; + allowUvPythonInstall: boolean; + }, +): Promise { + if (!ctx.interp) { + throw new Error('No interpreter to evaluate'); + } + const interp = ctx.interp; + const factory = serviceContainer.get(IPythonExecutionFactory); + const installer = serviceContainer.get(IInstaller); + + const resolvesAndRuns = await interpreterResolvesAndRuns(factory, interp); + const versionSupported = isVersionSupported(interp.version); + + // getIpykernelBundle spawns the interpreter (always, on arm64) and can throw for a + // stale/missing interpreter. probeEnvironmentReady short-circuits on !resolvesAndRuns + // or !versionSupported before it ever reads kernelReady/isRosetta, so only compute the + // bundle-derived signals once those gates pass. Computing the bundle eagerly would let + // its throw escape evaluateReady and turn the intended ordered fail (with a recreate + // fix) into a generic caught item failure with no actionable fix. + let kernelReady = true; + let isRosetta = false; + if (resolvesAndRuns && versionSupported) { + const bundle = await getIpykernelBundle(interp, serviceContainer, ctx.workspaceUri); + kernelReady = + bundle.disabledReason === undefined || + (await installer.isProductVersionCompatible(Product.ipykernel, IPYKERNEL_VERSION, interp)) === + ProductInstallStatus.Installed; + isRosetta = os.arch() === 'arm64' && bundle.architecture === Architecture.x64; + } + + const recreateFix = + (ctx.workspaceUri && + buildCreateEnvFix({ + workspaceUri: ctx.workspaceUri, + uvInstalled: ctx.uvInstalled, + allowUvPythonInstall: ctx.allowUvPythonInstall, + baseInterpreterPath: bestSupportedGlobalPython(ctx.interpreters)?.path, + })) || + buildNewFolderFix(); + + return probeEnvironmentReady({ + resolvesAndRuns, + versionSupported, + kernelReady, + isRosetta, + recreateFix, + installIpykernelFix: { + commandId: 'python.installIpykernel', + args: [interp.path], + label: vscode.l10n.t('Install ipykernel'), + }, + // python.installPythonViaUv does not itself honor python.allowUvPythonInstall (only the + // runtime-picker path does), so omit this fix when the setting is off - otherwise the + // Rosetta warn's button would install Python via uv against the configured policy. + installNativePythonFix: ctx.allowUvPythonInstall + ? { commandId: 'python.installPythonViaUv', label: vscode.l10n.t('Install Native Python') } + : undefined, + }); +} + +async function interpreterResolvesAndRuns( + factory: IPythonExecutionFactory, + interp: PythonEnvironment, +): Promise { + if (isProblematicCondaEnvironment(interp)) { + return false; + } + if (!(await fs.pathExists(interp.path))) { + return false; + } + try { + const execService = await factory.create({ pythonPath: interp.path }); + return (await execService.getInterpreterInformation()) !== undefined; + } catch { + return false; + } +} + +function traceEnvironmentHealth(result: EnvironmentHealthResult): void { + traceInfo('===================== [START] PYTHON ENVIRONMENT HEALTH ====================='); + traceInfo(JSON.stringify(result, null, 2)); + traceInfo('====================== [END] PYTHON ENVIRONMENT HEALTH ======================'); +} diff --git a/extensions/positron-python/src/client/positron/extension.ts b/extensions/positron-python/src/client/positron/extension.ts index 4857c7a70042..66e0d610a2e1 100644 --- a/extensions/positron-python/src/client/positron/extension.ts +++ b/extensions/positron-python/src/client/positron/extension.ts @@ -1,5 +1,5 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved. + * Copyright (C) 2023-2026 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ @@ -10,6 +10,7 @@ import { IServiceContainer } from '../ioc/types'; import { traceError, traceInfo } from '../logging'; import { MINIMUM_PYTHON_VERSION, Commands } from '../common/constants'; import { getIpykernelBundle } from './ipykernel'; +import { getEnvironmentHealth } from './environmentHealth'; import { InstallOptions } from '../common/installer/types'; import { activateAppDetection as activateWebAppDetection } from './webAppContexts'; import { activateWebAppCommands } from './webAppCommands'; @@ -85,6 +86,26 @@ export async function activatePositron(serviceContainer: IServiceContainer): Pro printInterpreterDebugInfo(interpreters); }), ); + // Register a command that returns a machine-readable Python environment health report and + // logs it (as JSON) to the Python output channel. A frontend for surfacing this will come + // later; for now the command doubles as a developer probe run from the Command Palette. + disposables.push( + vscode.commands.registerCommand( + Commands.Get_Environment_Health, + async (args?: { workspaceFolder?: string }) => { + const result = await getEnvironmentHealth(serviceContainer, args); + // Reveal the Python output channel so a manual palette run shows the report that + // getEnvironmentHealth just logged there. Ignore failures: python.viewOutput is only + // registered in trusted, non-virtual workspaces, and programmatic callers rely on the + // return value regardless. + await Promise.resolve(vscode.commands.executeCommand(Commands.ViewOutput)).then( + () => undefined, + () => undefined, + ); + return result; + }, + ), + ); // Activate detection for web applications activateWebAppDetection(disposables); diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts index 60bf356ee950..5d2c4edde50d 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts @@ -110,6 +110,17 @@ export interface NativePythonFinder extends Disposable { * Used only for telemetry. */ getCondaInfo(): Promise; + // --- Start Positron --- + /** + * The last fatal discovery error (PET failed to spawn/respond), or `undefined` if + * discovery is operational. Set when the finder process errors, the RPC connection + * errors, or a refresh request rejects, and cleared when a refresh completes + * successfully so a recovered PET stops reporting a stale failure; these are + * otherwise only logged. Read by the environment health check (item 1) to + * distinguish a broken locator from an empty one. + */ + readonly lastDiscoveryError: string | undefined; + // --- End Positron --- } interface NativeLog { @@ -187,6 +198,14 @@ class NativePythonFinderImpl extends DisposableBase implements NativePythonFinde private readonly suppressErrorNotification: IPersistentStorage; + // --- Start Positron --- + private _lastDiscoveryError: string | undefined; + + public get lastDiscoveryError(): string | undefined { + return this._lastDiscoveryError; + } + // --- End Positron --- + constructor(private readonly cacheDirectory?: Uri, private readonly context?: IExtensionContext) { super(); this.suppressErrorNotification = this.context @@ -261,6 +280,9 @@ class NativePythonFinderImpl extends DisposableBase implements NativePythonFinde proc.on('error', (error) => { this.outputChannel.error(`Python Locator process error: ${error.message}`); this.outputChannel.error(`Error details: ${JSON.stringify(error)}`); + // --- Start Positron --- + this._lastDiscoveryError = error.message; + // --- End Positron --- this.handleSpawnError(error.message); }); @@ -277,6 +299,9 @@ class NativePythonFinderImpl extends DisposableBase implements NativePythonFinde if (signal) { this.outputChannel.error(`Exit signal: ${signal}`); } + // --- Start Positron --- + this._lastDiscoveryError = errorMessage; + // --- End Positron --- this.handleSpawnError(errorMessage); } }); @@ -309,6 +334,9 @@ class NativePythonFinderImpl extends DisposableBase implements NativePythonFinde connection.onError((ex) => { disposeStreams.dispose(); this.outputChannel.error('Connection Error:', ex); + // --- Start Positron --- + this._lastDiscoveryError = `Connection error: ${String(ex)}`; + // --- End Positron --- }), connection.onNotification('log', (data: NativeLog) => { switch (data.level) { @@ -423,8 +451,16 @@ class NativePythonFinderImpl extends DisposableBase implements NativePythonFinde .then(({ duration }) => { this.outputChannel.info(`Refresh completed in ${duration}ms`); this.initialRefreshMetrics.timeToRefresh = stopWatch.elapsedTime; + // --- Start Positron --- + this._lastDiscoveryError = undefined; + // --- End Positron --- }) - .catch((ex) => this.outputChannel.error('Refresh error', ex)), + .catch((ex) => { + this.outputChannel.error('Refresh error', ex); + // --- Start Positron --- + this._lastDiscoveryError = `Refresh error: ${ex instanceof Error ? ex.message : String(ex)}`; + // --- End Positron --- + }), ), ); @@ -621,6 +657,9 @@ export function getNativePythonFinder(context?: IExtensionContext): NativePython traceError('Python discovery not supported in untrusted workspace'); return {} as unknown as NativeCondaInfo; }, + // --- Start Positron --- + lastDiscoveryError: undefined, + // --- End Positron --- dispose() { // do nothing }, diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvApi.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvApi.ts index 18b4db97c950..144bdce647ca 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvApi.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvApi.ts @@ -44,6 +44,7 @@ import { } from '../common/environmentManagers/uvPythonInstaller'; import { createEnvironmentAndRegister, + CreateEnvironmentAndRegisterOptions, getCreateEnvironmentProviders, isEnvProviderEnabled, isGlobalPython, @@ -206,13 +207,10 @@ export async function registerCreateEnvironmentFeatures( const providers = _createEnvironmentProviders.getAll(); return getCreateEnvironmentProviders(providers); }), - registerCommand( - Commands.Create_Environment_And_Register, - (options: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal) => { - const providers = _createEnvironmentProviders.getAll(); - return createEnvironmentAndRegister(providers, pythonRuntimeManager, options); - }, - ), + registerCommand(Commands.Create_Environment_And_Register, (options: CreateEnvironmentAndRegisterOptions) => { + const providers = _createEnvironmentProviders.getAll(); + return createEnvironmentAndRegister(providers, pythonRuntimeManager, options); + }), registerCommand(Commands.Is_Conda_Installed, async (): Promise => { const conda = await Conda.getConda(); return conda !== undefined; diff --git a/extensions/positron-python/src/test/positron/createEnvApi.unit.test.ts b/extensions/positron-python/src/test/positron/createEnvApi.unit.test.ts index d467e5768a16..214d5f92ce38 100644 --- a/extensions/positron-python/src/test/positron/createEnvApi.unit.test.ts +++ b/extensions/positron-python/src/test/positron/createEnvApi.unit.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved. + * Copyright (C) 2023-2026 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ @@ -18,14 +18,10 @@ import * as workspaceApis from '../../client/common/vscodeApis/workspaceApis'; import * as createEnvironmentApis from '../../client/pythonEnvironments/creation/createEnvironment'; import { IDisposableRegistry, IPathUtils } from '../../client/common/types'; import { registerCreateEnvironmentFeatures } from '../../client/pythonEnvironments/creation/createEnvApi'; -import { - CreateEnvironmentOptions, - CreateEnvironmentProvider, -} from '../../client/pythonEnvironments/creation/proposed.createEnvApis'; -import { CreateEnvironmentOptionsInternal } from '../../client/pythonEnvironments/creation/types'; +import { CreateEnvironmentProvider } from '../../client/pythonEnvironments/creation/proposed.createEnvApis'; import { IPythonRuntimeManager } from '../../client/positron/manager'; import { IInterpreterQuickPick, IPythonPathUpdaterServiceManager } from '../../client/interpreter/configuration/types'; -import { createEnvironmentAndRegister } from '../../client/positron/createEnvApi'; +import { createEnvironmentAndRegister, CreateEnvironmentAndRegisterOptions } from '../../client/positron/createEnvApi'; import { createTypeMoq } from '../mocks/helper'; chaiUse(chaiAsPromised.default); @@ -34,6 +30,7 @@ suite('Positron Create Environment APIs', () => { let registerCommandStub: sinon.SinonStub; let handleCreateEnvironmentCommandStub: sinon.SinonStub; let getConfigurationStub: sinon.SinonStub; + let getWorkspaceFolderStub: sinon.SinonStub; const disposables: IDisposableRegistry = []; const mockProvider = createTypeMoq(); @@ -51,12 +48,13 @@ suite('Positron Create Environment APIs', () => { name: 'workspace1', index: 0, }; + const workspace1UriString = workspace1.uri.toString(); - // Environment options - const envOptions: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal = { + // Environment options (workspaceFolder is now a URI string) + const envOptions: CreateEnvironmentAndRegisterOptions = { providerId: 'envProvider-id', interpreterPath: '/path/to/venv/python', - workspaceFolder: workspace1, + workspaceFolder: workspace1UriString, }; const envOptionsWithInfo = { withInterpreterPath: { ...envOptions }, @@ -91,6 +89,11 @@ suite('Positron Create Environment APIs', () => { return undefined; }); + getWorkspaceFolderStub = sinon.stub(workspaceApis, 'getWorkspaceFolder'); + getWorkspaceFolderStub.callsFake((uri: Uri) => + uri.toString() === workspace1UriString ? workspace1 : undefined, + ); + registerCommandStub.callsFake((_command: string, _callback: (...args: any[]) => any) => ({ dispose: () => { // Do nothing @@ -149,4 +152,46 @@ suite('Positron Create Environment APIs', () => { pythonRuntimeManager.verifyAll(); }); }); + + test('Rehydrates workspaceFolder URI string to a WorkspaceFolder before dispatching', async () => { + const resultPath = '/path/to/created/env'; + pythonRuntimeManager + .setup((p) => p.registerLanguageRuntimeFromPath(resultPath)) + .returns(() => Promise.resolve(createTypeMoq().object)); + handleCreateEnvironmentCommandStub.returns(Promise.resolve({ path: resultPath })); + + await createEnvironmentAndRegister(mockProviders, pythonRuntimeManager.object, { ...envOptions }); + + const dispatched = handleCreateEnvironmentCommandStub.firstCall.args[1]; + assert.strictEqual(dispatched.workspaceFolder, workspace1); + }); + + test('Leaves workspaceFolder undefined when not provided', async () => { + const resultPath = '/path/to/created/env'; + pythonRuntimeManager + .setup((p) => p.registerLanguageRuntimeFromPath(resultPath)) + .returns(() => Promise.resolve(createTypeMoq().object)); + handleCreateEnvironmentCommandStub.returns(Promise.resolve({ path: resultPath })); + + await createEnvironmentAndRegister(mockProviders, pythonRuntimeManager.object, { + ...envOptions, + workspaceFolder: undefined, + }); + + const dispatched = handleCreateEnvironmentCommandStub.firstCall.args[1]; + assert.isUndefined(dispatched.workspaceFolder); + }); + + test('Throws when workspaceFolder URI does not resolve to a known workspace folder', async () => { + const unknownUri = Uri.file('/no/such/workspace').toString(); + + await assert.isRejected( + createEnvironmentAndRegister(mockProviders, pythonRuntimeManager.object, { + ...envOptions, + workspaceFolder: unknownUri, + }), + /Workspace folder not found/, + ); + assert.isTrue(handleCreateEnvironmentCommandStub.notCalled); + }); }); diff --git a/extensions/positron-python/src/test/positron/environmentHealth.unit.test.ts b/extensions/positron-python/src/test/positron/environmentHealth.unit.test.ts new file mode 100644 index 000000000000..46f7a96804bc --- /dev/null +++ b/extensions/positron-python/src/test/positron/environmentHealth.unit.test.ts @@ -0,0 +1,434 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as sinon from 'sinon'; +import { assert } from 'chai'; +import { probeDiscovery, probePythonInstalled, DISCOVERY_WAIT_MS } from '../../client/positron/environmentHealth'; +import { PythonEnvironment } from '../../client/pythonEnvironments/info'; + +suite('Python Environment Health - discovery (item 1)', () => { + teardown(() => sinon.restore()); + + test('passes when discovery is operational', () => { + const item = probeDiscovery({ lastDiscoveryError: undefined }); + assert.strictEqual(item.id, 'discovery'); + assert.strictEqual(item.status, 'pass'); + assert.isUndefined(item.fix); + }); + + test('fails with a diagnostics link-out on fatal discovery error', () => { + const item = probeDiscovery({ lastDiscoveryError: 'spawn ENOENT' }); + assert.strictEqual(item.status, 'fail'); + assert.strictEqual(item.fix?.commandId, 'positron.startupDiagnostics.show'); + assert.isUndefined(item.fix?.args); + assert.include(item.detail ?? '', 'spawn ENOENT'); + }); +}); + +function env(version: { major: number; minor: number; patch: number } | undefined): PythonEnvironment { + return { path: '/py', version: version && { ...version, raw: '', build: [], prerelease: [] } } as PythonEnvironment; +} +const supported = env({ major: 3, minor: 12, patch: 0 }); +const unsupported = env({ major: 3, minor: 7, patch: 0 }); + +suite('Python Environment Health - pythonInstalled (item 2)', () => { + test('passes immediately when a supported interpreter is already known', async () => { + const item = await probePythonInstalled({ + getInterpreters: () => [unsupported, supported], + refreshPromise: undefined, + lastDiscoveryError: () => undefined, + allowUvPythonInstall: true, + waitMs: 10, + }); + assert.strictEqual(item.status, 'pass'); + }); + + test('waits for in-flight discovery, then fails cleanly with an install fix', async () => { + let list: PythonEnvironment[] = []; + const refreshPromise = new Promise((r) => setTimeout(r, 1)); + const item = await probePythonInstalled({ + getInterpreters: () => list, + refreshPromise, + lastDiscoveryError: () => undefined, + allowUvPythonInstall: true, + waitMs: 50, + }); + assert.strictEqual(item.status, 'fail'); + assert.strictEqual(item.fix?.commandId, 'python.installPythonViaUv'); + }); + + test('reports a broken locator, not a missing Python, when discovery errors during the wait', async () => { + // The discovery probe (item 1) already passed because the error had not surfaced yet. + // The refresh rejects during the bounded wait; the finder records the error afterwards. + let discoveryError: string | undefined; + const refreshPromise = new Promise((r) => + setTimeout(() => { + discoveryError = 'Refresh error: spawn ENOENT'; + r(); + }, 1), + ); + const item = await probePythonInstalled({ + getInterpreters: () => [], + refreshPromise, + lastDiscoveryError: () => discoveryError, + allowUvPythonInstall: true, + waitMs: 50, + }); + assert.strictEqual(item.status, 'fail'); + assert.strictEqual(item.fix?.commandId, 'positron.startupDiagnostics.show'); + assert.include(item.detail ?? '', 'spawn ENOENT'); + }); + + test('times out with no fix when discovery does not finish in time', async () => { + const neverResolves = new Promise(() => undefined); + const item = await probePythonInstalled({ + getInterpreters: () => [], + refreshPromise: neverResolves, + lastDiscoveryError: () => undefined, + allowUvPythonInstall: true, + waitMs: 10, + }); + assert.strictEqual(item.status, 'fail'); + assert.isUndefined(item.fix); + }); + + test('omits the fix when python.allowUvPythonInstall is false', async () => { + const item = await probePythonInstalled({ + getInterpreters: () => [], + refreshPromise: undefined, + lastDiscoveryError: () => undefined, + allowUvPythonInstall: false, + waitMs: 10, + }); + assert.strictEqual(item.status, 'fail'); + assert.isUndefined(item.fix); + assert.isDefined(item.detail); + }); + + test('exposes a ~10s default wait constant', () => { + assert.strictEqual(DISCOVERY_WAIT_MS, 10_000); + }); +}); + +import { + isDedicatedEnvironment, + bestSupportedGlobalPython, + resolveWouldBeUsedInterpreter, + buildCreateEnvFix, +} from '../../client/positron/environmentHealth'; +import { EnvironmentType } from '../../client/pythonEnvironments/info'; +import { Uri } from 'vscode'; + +function envOf(overrides: Partial): PythonEnvironment { + return { path: '/py', envType: EnvironmentType.Venv, ...overrides } as PythonEnvironment; +} + +suite('Python Environment Health - shared helpers', () => { + test('classifies dedicated vs non-dedicated environments', () => { + assert.isTrue(isDedicatedEnvironment(envOf({ envType: EnvironmentType.Venv }))); + assert.isTrue(isDedicatedEnvironment(envOf({ envType: EnvironmentType.Conda, envName: 'myenv' }))); + assert.isFalse(isDedicatedEnvironment(envOf({ envType: EnvironmentType.Conda, envName: 'base' }))); + assert.isFalse(isDedicatedEnvironment(envOf({ envType: EnvironmentType.Global }))); + assert.isFalse(isDedicatedEnvironment(envOf({ envType: EnvironmentType.System }))); + assert.isFalse(isDedicatedEnvironment(envOf({ envType: EnvironmentType.Venv }), true)); + }); + + test('picks the highest-version safe base Python and ignores unsafe types', () => { + const v = (minor: number) => ({ major: 3, minor, patch: 0, raw: `3.${minor}.0`, build: [], prerelease: [] }); + const older = envOf({ path: '/g1', envType: EnvironmentType.Global, version: v(10) }); + const newer = envOf({ path: '/g2', envType: EnvironmentType.System, version: v(12) }); + const custom = envOf({ path: '/c', envType: EnvironmentType.Custom, version: v(11) }); + const venv = envOf({ path: '/v', envType: EnvironmentType.Venv, version: v(13) }); + assert.strictEqual(bestSupportedGlobalPython([older, newer, custom, venv])?.path, '/g2'); + + // Custom is a safe base: when it is the only candidate it is picked. + assert.strictEqual(bestSupportedGlobalPython([custom, venv])?.path, '/c'); + + // Same minor, different patch: the full-version comparator picks the higher patch + // regardless of input order (the old minor-only sort left this order-dependent). + const p = (minor: number, patch: number) => ({ + major: 3, + minor, + patch, + raw: `3.${minor}.${patch}`, + build: [], + prerelease: [], + }); + const lowPatch = envOf({ path: '/lo', envType: EnvironmentType.Global, version: p(12, 1) }); + const highPatch = envOf({ path: '/hi', envType: EnvironmentType.Global, version: p(12, 9) }); + assert.strictEqual(bestSupportedGlobalPython([lowPatch, highPatch])?.path, '/hi'); + + // Unknown and Module are non-virtual but unsafe bases, so they are never picked. + const unknown = envOf({ path: '/u', envType: EnvironmentType.Unknown, version: v(12) }); + const module = envOf({ path: '/m', envType: EnvironmentType.Module, version: v(12) }); + assert.isUndefined(bestSupportedGlobalPython([unknown, module])); + }); + + test('resolves the active interpreter, falling back to the recommendation', async () => { + const rec = envOf({ path: '/rec' }); + const active = await resolveWouldBeUsedInterpreter({ + workspaceUri: undefined, + getActiveInterpreter: async () => envOf({ path: '/active' }), + getInterpreters: () => [rec], + getRecommended: () => rec, + }); + assert.strictEqual(active?.path, '/active'); + + const fallback = await resolveWouldBeUsedInterpreter({ + workspaceUri: undefined, + getActiveInterpreter: async () => undefined, + getInterpreters: () => [rec], + getRecommended: () => rec, + }); + assert.strictEqual(fallback?.path, '/rec'); + }); + + test('builds a plain-JSON create-env fix branching on uv vs venv', () => { + const ws = Uri.file('/work'); + // uv does not need a base interpreter to seed the environment. + const uvFix = buildCreateEnvFix({ + workspaceUri: ws, + uvInstalled: true, + allowUvPythonInstall: true, + baseInterpreterPath: undefined, + }); + assert.strictEqual(uvFix?.commandId, 'python.createEnvironmentAndRegister'); + assert.deepStrictEqual(uvFix?.args, [ + { providerId: 'ms-python.python:uv', workspaceFolder: ws.toString(), uvPythonVersion: 'auto' }, + ]); + + const venvFix = buildCreateEnvFix({ + workspaceUri: ws, + uvInstalled: false, + allowUvPythonInstall: true, + baseInterpreterPath: '/g/py', + }); + assert.deepStrictEqual(venvFix?.args, [ + { providerId: 'ms-python.python:venv', workspaceFolder: ws.toString(), interpreterPath: '/g/py' }, + ]); + + // No uv and no supported base interpreter: no runnable create-env fix (caller falls back). + const noFix = buildCreateEnvFix({ + workspaceUri: ws, + uvInstalled: false, + allowUvPythonInstall: true, + baseInterpreterPath: undefined, + }); + assert.strictEqual(noFix, undefined); + }); + + test('skips the uv auto-version path when python.allowUvPythonInstall is off', () => { + const ws = Uri.file('/work'); + // uv is installed, but installs are disallowed: seed a venv from the base interpreter + // rather than let the uv auto path download a uv-managed Python. + const seededFix = buildCreateEnvFix({ + workspaceUri: ws, + uvInstalled: true, + allowUvPythonInstall: false, + baseInterpreterPath: '/g/py', + }); + assert.deepStrictEqual(seededFix?.args, [ + { providerId: 'ms-python.python:venv', workspaceFolder: ws.toString(), interpreterPath: '/g/py' }, + ]); + + // uv installed but disallowed and no base interpreter to seed: no runnable fix. + const noFix = buildCreateEnvFix({ + workspaceUri: ws, + uvInstalled: true, + allowUvPythonInstall: false, + baseInterpreterPath: undefined, + }); + assert.strictEqual(noFix, undefined); + }); +}); + +import { probeDedicatedEnvironment } from '../../client/positron/environmentHealth'; + +suite('Python Environment Health - dedicatedEnvironment (item 3)', () => { + const createEnvFix = { commandId: 'python.createEnvironmentAndRegister', label: 'c', args: [{}] }; + const newFolderFix = { commandId: 'positron.workbench.action.newFolderFromTemplate', label: 'n' }; + + test('workspace open + dedicated interpreter => pass', () => { + const item = probeDedicatedEnvironment({ + workspaceOpen: true, + interpreterDedicated: true, + anyDedicatedDiscovered: true, + createEnvFix, + newFolderFix, + }); + assert.strictEqual(item.status, 'pass'); + assert.isUndefined(item.fix); + }); + + test('workspace open + non-dedicated interpreter => fail + create-env fix', () => { + const item = probeDedicatedEnvironment({ + workspaceOpen: true, + interpreterDedicated: false, + anyDedicatedDiscovered: true, + createEnvFix, + newFolderFix, + }); + assert.strictEqual(item.status, 'fail'); + assert.strictEqual(item.fix, createEnvFix); + }); + + test('no workspace + a dedicated env exists => warn + New Folder fix', () => { + const item = probeDedicatedEnvironment({ + workspaceOpen: false, + interpreterDedicated: false, + anyDedicatedDiscovered: true, + createEnvFix, + newFolderFix, + }); + assert.strictEqual(item.status, 'warn'); + assert.strictEqual(item.fix, newFolderFix); + }); + + test('no workspace + no dedicated env => fail + New Folder fix', () => { + const item = probeDedicatedEnvironment({ + workspaceOpen: false, + interpreterDedicated: false, + anyDedicatedDiscovered: false, + createEnvFix, + newFolderFix, + }); + assert.strictEqual(item.status, 'fail'); + assert.strictEqual(item.fix, newFolderFix); + }); +}); + +import { probeEnvironmentReady } from '../../client/positron/environmentHealth'; + +suite('Python Environment Health - environmentReady (item 4)', () => { + const recreateFix = { commandId: 'python.createEnvironmentAndRegister', label: 'r', args: [{}] }; + const installIpykernelFix = { commandId: 'python.installIpykernel', label: 'k', args: ['/py'] }; + const installNativePythonFix = { commandId: 'python.installPythonViaUv', label: 'n' }; + const green = { + resolvesAndRuns: true, + versionSupported: true, + kernelReady: true, + isRosetta: false, + recreateFix, + installIpykernelFix, + installNativePythonFix, + }; + + test('broken env short-circuits before version/kernel/arch', () => { + const item = probeEnvironmentReady({ ...green, resolvesAndRuns: false }); + assert.strictEqual(item.status, 'fail'); + assert.strictEqual(item.fix, recreateFix); + assert.include(item.detail ?? '', 'packages'); + }); + + test('unsupported version fails with recreate fix', () => { + const item = probeEnvironmentReady({ ...green, versionSupported: false }); + assert.strictEqual(item.status, 'fail'); + assert.strictEqual(item.fix, recreateFix); + }); + + test('kernel not ready fails with install-ipykernel fix', () => { + const item = probeEnvironmentReady({ ...green, kernelReady: false }); + assert.strictEqual(item.status, 'fail'); + assert.strictEqual(item.fix, installIpykernelFix); + }); + + test('Rosetta warns only when earlier probes pass', () => { + const item = probeEnvironmentReady({ ...green, isRosetta: true }); + assert.strictEqual(item.status, 'warn'); + assert.strictEqual(item.fix, installNativePythonFix); + }); + + test('Rosetta warns without a fix when native-Python install is disabled', () => { + const item = probeEnvironmentReady({ ...green, isRosetta: true, installNativePythonFix: undefined }); + assert.strictEqual(item.status, 'warn'); + assert.isUndefined(item.fix); + }); + + test('all green => pass', () => { + assert.strictEqual(probeEnvironmentReady(green).status, 'pass'); + }); +}); + +import { assembleItems, HealthItem } from '../../client/positron/environmentHealth'; + +suite('Python Environment Health - orchestration', () => { + const pass = (id: string): HealthItem => ({ id, status: 'pass', summary: id }); + const fail = (id: string): HealthItem => ({ id, status: 'fail', summary: id }); + + test('fatal discovery skips items 2-4', async () => { + const result = await assembleItems({ + discovery: () => fail('discovery'), + pythonInstalled: async () => pass('pythonInstalled'), + dedicated: async () => pass('dedicatedEnvironment'), + ready: async () => pass('environmentReady'), + }); + assert.deepStrictEqual( + result.items.map((i) => [i.id, i.status]), + [ + ['discovery', 'fail'], + ['pythonInstalled', 'skipped'], + ['dedicatedEnvironment', 'skipped'], + ['environmentReady', 'skipped'], + ], + ); + assert.isFalse(result.ok); + }); + + test('failed pythonInstalled skips items 3-4', async () => { + const result = await assembleItems({ + discovery: () => pass('discovery'), + pythonInstalled: async () => fail('pythonInstalled'), + dedicated: async () => pass('dedicatedEnvironment'), + ready: async () => pass('environmentReady'), + }); + assert.deepStrictEqual( + result.items.map((i) => [i.id, i.status]), + [ + ['discovery', 'pass'], + ['pythonInstalled', 'fail'], + ['dedicatedEnvironment', 'skipped'], + ['environmentReady', 'skipped'], + ], + ); + }); + + test('item 4 runs even when item 3 warns; warn does not affect ok', async () => { + const warn = (id: string): HealthItem => ({ id, status: 'warn', summary: id }); + const result = await assembleItems({ + discovery: () => pass('discovery'), + pythonInstalled: async () => pass('pythonInstalled'), + dedicated: async () => warn('dedicatedEnvironment'), + ready: async () => pass('environmentReady'), + }); + assert.strictEqual(result.items[3].status, 'pass'); + assert.isTrue(result.ok); + }); + + test('a probe that throws becomes a fail, not a rejection', async () => { + const result = await assembleItems({ + discovery: () => pass('discovery'), + pythonInstalled: async () => { + throw new Error('boom'); + }, + dedicated: async () => pass('dedicatedEnvironment'), + ready: async () => pass('environmentReady'), + }); + assert.strictEqual(result.items[1].status, 'fail'); + assert.include(result.items[1].detail ?? '', 'boom'); + }); +}); + +suite('Python Environment Health - contract shape', () => { + test('create-env fix args are plain JSON values', () => { + const fix = buildCreateEnvFix({ + workspaceUri: Uri.file('/w'), + uvInstalled: false, + allowUvPythonInstall: true, + baseInterpreterPath: '/g/py', + }); + // JSON round-trip must be lossless (no Uri/WorkspaceFolder/etc.) + assert.deepStrictEqual(JSON.parse(JSON.stringify(fix?.args)), fix?.args); + }); +}); diff --git a/extensions/positron-python/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts index 9fe481c4da3f..c0489cbf9470 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts @@ -29,6 +29,10 @@ import { OSType, getOSType } from '../../../../common'; import * as nativeFinder from '../../../../../client/pythonEnvironments/base/locators/common/nativePythonFinder'; class MockNativePythonFinder implements nativeFinder.NativePythonFinder { + // --- Start Positron --- + readonly lastDiscoveryError: string | undefined; + // --- End Positron --- + find(_searchPath: string): Promise { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/services/positronNewFolder/common/positronNewFolderService.ts b/src/vs/workbench/services/positronNewFolder/common/positronNewFolderService.ts index 1d3c2abb911e..1fb654ddc795 100644 --- a/src/vs/workbench/services/positronNewFolder/common/positronNewFolderService.ts +++ b/src/vs/workbench/services/positronNewFolder/common/positronNewFolderService.ts @@ -494,7 +494,7 @@ export class PositronNewFolderService extends Disposable implements IPositronNew await this._commandService.executeCommand( createEnvCommand, { - workspaceFolder, + workspaceFolder: workspaceFolder.uri.toString(), providerId: provider, interpreterPath, condaPythonVersion, @@ -526,7 +526,7 @@ export class PositronNewFolderService extends Disposable implements IPositronNew createEnvCommand + ' with arguments: ' + JSON.stringify({ - workspaceFolder, + workspaceFolder: workspaceFolder.uri.toString(), providerId: provider, interpreterPath, }) +