diff --git a/.github/workflows/pr-file-check.yml b/.github/workflows/pr-file-check.yml index 158ca5f1e..42acf708e 100644 --- a/.github/workflows/pr-file-check.yml +++ b/.github/workflows/pr-file-check.yml @@ -49,6 +49,8 @@ jobs: src/api.ts src/types.ts src/publicErrors.ts + src/capabilities.ts + src/legacyCapabilities.ts file-pattern: 'api/package.json' skip-label: 'skip api version' failure-message: 'The public API (${prereq-pattern}) was changed without bumping the package version in ${file-pattern} (the ${skip-label} label can be used to pass this check)' @@ -60,6 +62,8 @@ jobs: src/api.ts src/types.ts src/publicErrors.ts + src/capabilities.ts + src/legacyCapabilities.ts file-pattern: 'api/CHANGELOG.md' skip-label: 'skip api changelog' failure-message: 'The public API (${prereq-pattern}) was changed without a changelog entry in ${file-pattern} (the ${skip-label} label can be used to pass this check)' @@ -71,6 +75,8 @@ jobs: src/api.ts src/types.ts src/publicErrors.ts + src/capabilities.ts + src/legacyCapabilities.ts file-pattern: 'docs/README.md' skip-label: 'skip api docs' failure-message: 'The public API (${prereq-pattern}) was changed without updating ${file-pattern} (the ${skip-label} label can be used to pass this check)' diff --git a/api/.gitignore b/api/.gitignore index 0653c4146..3f850edf6 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -1,7 +1,3 @@ -# Copied from ../src/api.ts, ../src/types.ts, ../src/publicErrors.ts by the publish pipeline -# (build/azure-pipeline.npm.yml). These are the published package sources; they are produced -# at publish time and intentionally NOT committed. src/api.ts, src/types.ts, and -# src/publicErrors.ts are the single sources of truth. -src/main.ts -src/types.ts -src/publicErrors.ts \ No newline at end of file +# Generated package sources copied from ../src by scripts/copy-sources.cjs. +# The original files under ../src are the single sources of truth. +/src/ \ No newline at end of file diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 00c157869..d89d31ea6 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -5,6 +5,21 @@ 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.7.0] + +### Added + +- Optional `capabilities` maps for environment and package managers, with typed keys and contextual checks. Omitted maps use legacy method inference; when present, the map must advertise every catalog key explicitly, and missing keys resolve unsupported. +- Raw-provider capability resolvers and routed extension API queries that report support without invoking manager operations or prompting. +- Capability catalogs for six environment and six package features, with flat operation names. Listing includes refresh, and package installation, uninstallation, and upgrades are advertised individually rather than through a management umbrella. +- Exported `EnvironmentCapability` and `PackageCapability` constant objects for named access to capability keys, with derived string-literal unions. +- `getLegacyPackageManagerCapability` and `getLegacyEnvironmentManagerCapability` helpers that infer support from method presence without invoking operations. Resolvers use them automatically when a provider omits its capability map. +- Optional `Support.isLegacy` metadata, set to true by legacy inference helpers and included in public capability-query telemetry. + +### Notes + +- Capability prerequisite checks have no cycle protection; providers must keep delegation chains acyclic. + ## [1.6.0] ### Added diff --git a/api/README.md b/api/README.md index c3262bbfa..7f9e99892 100644 --- a/api/README.md +++ b/api/README.md @@ -35,6 +35,30 @@ export async function activate() { } ``` +## Manager capabilities + +Capabilities report general manager support without invoking an operation: + +```typescript +import { PackageCapability, PythonEnvironments } from '@vscode/python-environments'; + +const api = await PythonEnvironments.api(); +const environment = await api.getEnvironment(undefined); +if (environment && typeof api.getPackageManagerCapability === 'function') { + const support = await api.getPackageManagerCapability(environment, PackageCapability.ListDirect); + if (support.supported) { + // Best-effort direct/transitive package classification is available. + } +} +``` + +Manager advertisements are optional for legacy compatibility. Missing maps use +method-presence inference and return `isLegacy: true`; missing keys in a supplied +map report unsupported. Support is advisory, not a guarantee that an operation succeeds. + +See the [capability guide](https://github.com/microsoft/vscode-python-environments/blob/main/docs/README.md#manager-capabilities) +for the full catalog and provider authoring. + ## Full API reference 📘 **[Python Environments API reference](https://github.com/microsoft/vscode-python-environments/blob/main/docs/README.md)** @@ -51,4 +75,3 @@ extensibility - with field tables, parameter tables, return types, and examples. - [Extensibility](https://github.com/microsoft/vscode-python-environments/blob/main/docs/README.md#extensibility) - register your own environment manager, package manager, or project creator See [`CHANGELOG.md`](https://github.com/microsoft/vscode-python-environments/blob/main/api/CHANGELOG.md) for API changes between versions. - diff --git a/api/package-lock.json b/api/package-lock.json index 3be873d42..85fa61167 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/python-environments", - "version": "1.6.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/python-environments", - "version": "1.6.0", + "version": "1.7.0", "license": "MIT", "dependencies": { "@renovatebot/pep440": "^3.1.0" diff --git a/api/package.json b/api/package.json index b2be12d99..821a5353f 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.6.0", + "version": "1.7.0", "author": { "name": "Microsoft Corporation" }, diff --git a/api/scripts/copy-sources.cjs b/api/scripts/copy-sources.cjs index 9e66b4c72..58747031e 100644 --- a/api/scripts/copy-sources.cjs +++ b/api/scripts/copy-sources.cjs @@ -17,6 +17,8 @@ const sources = [ { from: path.join(repoRoot, 'src', 'api.ts'), to: path.join(srcDir, 'main.ts') }, { from: path.join(repoRoot, 'src', 'types.ts'), to: path.join(srcDir, 'types.ts') }, { from: path.join(repoRoot, 'src', 'publicErrors.ts'), to: path.join(srcDir, 'publicErrors.ts') }, + { from: path.join(repoRoot, 'src', 'capabilities.ts'), to: path.join(srcDir, 'capabilities.ts') }, + { from: path.join(repoRoot, 'src', 'legacyCapabilities.ts'), to: path.join(srcDir, 'legacyCapabilities.ts') }, ]; fs.mkdirSync(srcDir, { recursive: true }); diff --git a/api/scripts/test-package.cjs b/api/scripts/test-package.cjs index 24f510963..0ee2eeb39 100644 --- a/api/scripts/test-package.cjs +++ b/api/scripts/test-package.cjs @@ -94,6 +94,7 @@ try { "};", 'exports.__runtimeApi = runtimeApi;', 'exports.extensions = { getExtension: () => extension };', + 'exports.l10n = { t: (message) => message };', ].join('\n'), ); const installedPackageJson = JSON.parse(fs.readFileSync(path.join(installedPackageRoot, 'package.json'), 'utf8')); diff --git a/api/test/consumer.ts b/api/test/consumer.ts index 5442ba36c..e7f2d9242 100644 --- a/api/test/consumer.ts +++ b/api/test/consumer.ts @@ -1,59 +1,146 @@ import type { + Capabilities, + CapabilityContext, + EnvironmentManager, + EnvironmentManagerCapability, PackageManager, + PackageManagerCapability, Pep440Version, PythonEnvironment, PythonEnvironmentApi, PythonPackageGetterApi, + Support, } from '@vscode/python-environments'; import { + EnvironmentCapability, + getLegacyEnvironmentManagerCapability, + getLegacyPackageManagerCapability, isPackageVersionLookupNotSupportedError, + PackageCapability, PackageVersionLookupNotSupportedError, PythonEnvironments, + resolveEnvironmentManagerCapability, + resolvePackageManagerCapability, + supportedCapability, } from '@vscode/python-environments'; type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; +// Compile-only fixture shared by modern and legacy consumers; not executed. +declare const api: PythonEnvironmentApi; +declare const environment: PythonEnvironment; +PythonEnvironments.api() satisfies Promise; + +// Package version lookup contracts. type AvailableVersionsReturn = ReturnType; type RefreshReturn = ReturnType; -const availableVersionsReturnIsExact: Equal> = true; -const refreshReturnIsExact: Equal> = true; +true satisfies Equal>; +true satisfies Equal>; -declare const api: PythonPackageGetterApi; -declare const environment: PythonEnvironment; -const legacyAvailableVersions: Promise = api.getPackageAvailableVersions( - environment, - 'example', -); -const explicitLegacyAvailableVersions: Promise = api.getPackageAvailableVersions( - environment, - 'example', - { errorMode: 'legacy' }, -); -const throwingAvailableVersions: Promise = api.getPackageAvailableVersions(environment, 'example', { - errorMode: 'throw', -}); -const runtimeApi: Promise = PythonEnvironments.api(); - -// The unsupported-capability error is part of the public contract: it is constructible, extends -// Error, and exposes a stable string-literal `code` discriminator. +api.getPackageAvailableVersions(environment, 'example') satisfies Promise; +api.getPackageAvailableVersions(environment, 'example', { + errorMode: 'legacy', +}) satisfies Promise; +api.getPackageAvailableVersions(environment, 'example', { errorMode: 'throw' }) satisfies Promise; + +// Public error construction, discriminator, and narrowing. const lookupError = new PackageVersionLookupNotSupportedError('unsupported'); -const lookupErrorIsError: Error = lookupError; -const lookupErrorCodeIsExact: Equal = true; +lookupError satisfies Error; +true satisfies Equal; -// The type guard narrows unknown values via the stable discriminator (bundle-boundary safe). declare const maybeError: unknown; -const guardNarrows: boolean = isPackageVersionLookupNotSupportedError(maybeError) - ? maybeError.code === 'PackageVersionLookupNotSupported' - : false; - -void availableVersionsReturnIsExact; -void refreshReturnIsExact; -void legacyAvailableVersions; -void explicitLegacyAvailableVersions; -void throwingAvailableVersions; -void runtimeApi; -void lookupErrorIsError; -void lookupErrorCodeIsExact; -void guardNarrows; +if (isPackageVersionLookupNotSupportedError(maybeError)) { + maybeError.code satisfies 'PackageVersionLookupNotSupported'; +} + +// Legacy providers may omit advertisements; advertised maps must include every key without defaults. +const fullPackageCapabilities: Capabilities = { + [PackageCapability.List]: supportedCapability, + [PackageCapability.Install]: supportedCapability, + [PackageCapability.Uninstall]: supportedCapability, + [PackageCapability.Upgrade]: supportedCapability, + [PackageCapability.ListDirect]: supportedCapability, + [PackageCapability.ListAvailableVersions]: supportedCapability, +}; +const fullEnvironmentCapabilities: Capabilities = { + [EnvironmentCapability.List]: supportedCapability, + [EnvironmentCapability.Resolve]: supportedCapability, + [EnvironmentCapability.GetSelected]: supportedCapability, + [EnvironmentCapability.SetSelected]: supportedCapability, + [EnvironmentCapability.Create]: supportedCapability, + [EnvironmentCapability.Remove]: supportedCapability, +}; + +const legacyPackageManager: PackageManager = { + name: 'legacy', + manage: async () => {}, + refresh: async () => {}, + getPackages: async () => [], +}; +const legacyEnvironmentManager: EnvironmentManager = { + name: 'legacy', + preferredPackageManagerId: 'example:legacy', + refresh: async () => {}, + getEnvironments: async () => [], + get: async () => undefined, + set: async () => {}, + resolve: async () => undefined, +}; + +getLegacyPackageManagerCapability(legacyPackageManager, PackageCapability.Install) satisfies Promise; +getLegacyEnvironmentManagerCapability(legacyEnvironmentManager, EnvironmentCapability.Create) satisfies Promise; + +// Capability authoring and raw-provider resolution. +const advertised: Capabilities = { + ...fullPackageCapabilities, + [PackageCapability.Upgrade]: (context: CapabilityContext) => + resolvePackageManagerCapability(legacyPackageManager, PackageCapability.Install, context), + [PackageCapability.ListDirect]: async (_context) => ({ supported: false, reason: 'Unavailable' }), +}; +const managerWithCapabilities: PackageManager = { ...legacyPackageManager, capabilities: advertised }; +resolvePackageManagerCapability(managerWithCapabilities, PackageCapability.ListDirect, { environment }) satisfies Promise; +resolveEnvironmentManagerCapability(legacyEnvironmentManager, EnvironmentCapability.Create, { + scope: 'global', +}) satisfies Promise; + +// Public queries and support-result narrowing. +api.getPackageManagerCapability(environment, PackageCapability.List) satisfies Promise; +api.getEnvironmentManagerCapability('example:legacy', EnvironmentCapability.List, { scope: 'all' }) satisfies Promise; +declare const support: Support; +support.isLegacy satisfies boolean | undefined; +if (!support.supported) { + support.reason satisfies string; +} + +// Invalid capability keys, advertisements, and context must remain compile errors. +// @ts-expect-error Legacy environment inference requires an environment capability. +getLegacyEnvironmentManagerCapability(legacyEnvironmentManager, PackageCapability.List); +// @ts-expect-error Legacy package inference requires a package capability. +getLegacyPackageManagerCapability(legacyPackageManager, EnvironmentCapability.List); +// @ts-expect-error Package keys do not belong to the environment capability domain. +PackageCapability.List satisfies EnvironmentManagerCapability; +// @ts-expect-error Environment keys do not belong to the package capability domain. +EnvironmentCapability.List satisfies PackageManagerCapability; +// @ts-expect-error Advertisements must be asynchronous functions, not static Support values. +({ [PackageCapability.List]: { supported: true } } satisfies Capabilities); +// @ts-expect-error Advertisements cannot introduce arbitrary keys. +({ 'packages.unknown': async () => ({ supported: true }) } satisfies Capabilities); +// @ts-expect-error Operation options are not capability query context. +({ createOptions: { quickCreate: true } } satisfies CapabilityContext); +declare const arbitraryKey: string; +// @ts-expect-error A general string is not a declared environment capability. +resolveEnvironmentManagerCapability(legacyEnvironmentManager, arbitraryKey); +// @ts-expect-error A general string is not a declared package capability. +resolvePackageManagerCapability(legacyPackageManager, arbitraryKey); +// @ts-expect-error Public environment queries require a catalog key. +api.getEnvironmentManagerCapability('example:legacy', arbitraryKey); +// @ts-expect-error Public package queries require a catalog key. +api.getPackageManagerCapability(environment, arbitraryKey); +// @ts-expect-error Capability dictionaries have no arbitrary string index signature. +fullPackageCapabilities[arbitraryKey]; +// @ts-expect-error Capability dictionaries have no arbitrary string index signature. +fullEnvironmentCapabilities[arbitraryKey]; +// @ts-expect-error Capability dictionaries must advertise every catalog key; partial maps are rejected. +({ [PackageCapability.List]: async () => ({ supported: true }) } satisfies Capabilities); diff --git a/build/azure-pipeline.npm.yml b/build/azure-pipeline.npm.yml index aa42c2a13..68779d6dc 100644 --- a/build/azure-pipeline.npm.yml +++ b/build/azure-pipeline.npm.yml @@ -86,7 +86,7 @@ extends: - script: npm run copy:sources workingDirectory: $(Build.SourcesDirectory)/api - displayName: Copy src/api.ts, src/types.ts, src/publicErrors.ts to API package sources + displayName: Copy API facade, types, errors, and capabilities to API package sources - script: npm run compile workingDirectory: $(Build.SourcesDirectory)/api diff --git a/docs/README.md b/docs/README.md index 7fd0ce7d6..aa68e4ab8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,8 @@ Use this manual to: The runtime facade is [`src/api.ts`](../src/api.ts). The authoritative public contracts are in [`src/types.ts`](../src/types.ts), with public errors and type guards in [`src/publicErrors.ts`](../src/publicErrors.ts). +Capability types, catalog key sets, and raw-provider resolvers are in +[`src/capabilities.ts`](../src/capabilities.ts). > [!IMPORTANT] > The API is flat. Call `api.getEnvironments()`, not @@ -46,6 +48,7 @@ guards in [`src/publicErrors.ts`](../src/publicErrors.ts). - [Extensibility data types](#extensibility-data-types) - [Extensibility methods](#extensibility-methods) - [API interface groups](#api-interface-groups) +- [Manager capabilities](#manager-capabilities) - [Compatibility guidance](#compatibility-guidance) - [Related documentation](#related-documentation) @@ -1552,6 +1555,7 @@ trigger, as the specification. | `name` | `string` | Yes | Manager name. Allowed characters: `a-z`, `A-Z`, `0-9`, `-`, `_`. | | `displayName` | `string` | No | Name shown in the UI. | | `preferredPackageManagerId` | `string` | Yes | Package manager to pair with, formatted `.:`, for example `ms-python.python:pip`. | +| `capabilities` | `Capabilities` | No | Optional advertisements for legacy compatibility; when supplied, includes every environment capability key. See [manager capabilities](#manager-capabilities). | | `description` | `string` | No | Secondary text shown in the UI. | | `tooltip` | `string \| MarkdownString` | No | Hover text for the manager. | | `iconPath` | [`IconPath`](#iconpath) | No | Icon shown for the manager. | @@ -1625,6 +1629,7 @@ Reports and changes the packages of an environment. | `getPackages(environment, options?)` | `(environment: PythonEnvironment, options?: GetPackagesOptions) => Promise` | Yes | Returns installed packages, or `undefined` if they cannot be retrieved. | | `getPackageWatchTargets(environment)` | `(environment: PythonEnvironment) => RelativePattern[]` | No | Extra filesystem patterns to watch for install and uninstall changes, appended to the default site-packages locations. Implement for manager-specific locations such as `conda-meta`. | | `createForProject(project)` | `(project: PythonProject) => PackageManager` | No | Creates a manager bound to a project for project-sensitive operations. | +| `capabilities` | `Capabilities` | No | Optional advertisements for legacy compatibility; when supplied, includes every package capability key, evaluated on the correct project-bound instance. See [manager capabilities](#manager-capabilities). | | `dispose()` | `() => void` | No | Releases resources owned by the manager. The extension disposes project-scoped managers when their project is removed or replaced, their provider is unregistered, or the extension shuts down. | | `getDirectPackageNames(environment)` | `(environment: PythonEnvironment) => Promise \| undefined>` | No | Best-effort set of non-transitive package names. Most tools cannot record user intent - pip uses `pip list --not-required`, which reports leaf packages rather than explicitly installed ones. | | `clearCache()` | `() => Promise` | No | Drops cached package data. | @@ -1823,13 +1828,13 @@ directly on the single flat API object. | Interface | Members grouped by the interface | Description | | --- | --- | --- | -| `PythonEnvironmentsApi` | Environment discovery and resolution | Lists and refreshes discovered environments, resolves environment URIs, and reports discovery changes. | +| `PythonEnvironmentsApi` | Environment discovery, resolution, and capabilities | Lists and refreshes discovered environments, resolves environment URIs, queries manager support, and reports discovery changes. | | `PythonProjectEnvironmentApi` | Selected environment get/set | Reads, updates, and observes the selected environment for URI or global scopes. | | `PythonEnvironmentManagementApi` | Environment creation/removal | Creates and removes environments through their associated environment managers. | | `PythonEnvironmentItemApi` | Environment item creation | Converts provider-supplied environment information into an identified `PythonEnvironment`. | | `PythonEnvironmentManagerRegistrationApi` | Environment manager registration | Registers an `EnvironmentManager` implementation with the extension. | | `PythonEnvironmentManagerApi` | Combined environment API | Combines environment registration, item creation, lifecycle, discovery, and selection interfaces. | -| `PythonPackageGetterApi` | Package retrieval and version lookup | Retrieves and refreshes packages, looks up available versions, and reports package changes. | +| `PythonPackageGetterApi` | Package retrieval, version lookup, and capabilities | Retrieves and refreshes packages, looks up available versions, queries routed manager support, and reports package changes. | | `PythonPackageManagementApi` | Package installation/removal | Installs, upgrades, or uninstalls packages in an environment. | | `PythonPackageItemApi` | Package item creation | Converts provider-supplied package information into an identified `Package`. | | `PythonPackageManagerRegistrationApi` | Package manager registration | Registers a `PackageManager` implementation with the extension. | @@ -1846,6 +1851,104 @@ directly on the single flat API object. | `PythonEnvironmentVariablesApi` | Environment variable lookup/events | Resolves effective environment variables and reports source changes. | | `PythonEnvironmentApi` | Complete flat public API | Combines all environment, package, project, execution, and environment-variable APIs exposed at runtime. | +## Manager capabilities + +Capabilities are advisory, read-only checks of manager support. They do not +invoke operations, validate requests, or guarantee success. + +| Environment capabilities | Package capabilities | +| --- | --- | +| `environments.list` | `packages.list` | +| `environments.resolve` | `packages.install` | +| `environments.getSelected` | `packages.uninstall` | +| `environments.setSelected` | `packages.upgrade` | +| `environments.create` | `packages.listDirect` | +| `environments.remove` | `packages.listAvailableVersions` | + +Listing includes refresh. Package installation, uninstallation, and upgrades +use the existing `manage()` method. `packages.listDirect` is best-effort +classification, not exact installation intent. Selection is scoped environment +selection, not shell activation. +Pip reports installation, uninstallation, and upgrades as unsupported for Python 2 environments. + +Use the exported `EnvironmentCapability` and `PackageCapability` constants or +their literal string values. + +### Querying support + +Environment queries target an explicit manager ID; package queries follow +environment/project routing. They use registered managers without activation, +waiting, or prompting. Unavailable managers return `{ supported: false, reason }`. +An explicit package-query project must be registered; an unregistered nested +project cannot use its containing project's provider. +File-project paths are compared using platform-aware normalization and routed through the registered URI. +Feature-detect query methods when supporting older extension runtimes: + +```typescript +import { PackageCapability } from '@vscode/python-environments'; + +if (typeof api.getPackageManagerCapability === 'function') { + const support = await api.getPackageManagerCapability(environment, PackageCapability.ListDirect); + if (!support.supported) { + // support.reason explains why this feature is unavailable. + } +} +``` + +### Advertising manager support + +Managers may omit `capabilities` for legacy compatibility. When supplied, the +map must advertise every capability key. An omitted map uses legacy method +inference; a missing key in a supplied map resolves `{ supported: false, +reason: 'Capability not implemented' }`. Checks receive `CapabilityContext` +and return `Promise`. + +Legacy inference uses `getLegacyPackageManagerCapability(manager, capability)` +or `getLegacyEnvironmentManagerCapability(manager, capability)`. They inspect method +presence without executing methods or reading advertisements. Listing requires both +listing and refresh methods; package install, uninstall, and upgrade require `manage`. +Other capabilities require their corresponding methods. This does not verify tool +versions or option behavior. Regular queries use this fallback only when the map is omitted. +Legacy helper results include `isLegacy: true`; other `Support` results may omit +the field, which means false. + +```typescript +import { + Capabilities, + PackageCapability, + PackageManagerCapability, + supportedCapability, + unsupportedCapability, +} from '@vscode/python-environments'; +import { l10n } from 'vscode'; + +// Members of a PackageManager implementation: +readonly capabilities: Capabilities = { + [PackageCapability.List]: supportedCapability, + [PackageCapability.ListDirect]: supportedCapability, + [PackageCapability.ListAvailableVersions]: unsupportedCapability, + [PackageCapability.Install]: supportedCapability, + [PackageCapability.Uninstall]: supportedCapability, + [PackageCapability.Upgrade]: async (_context) => ({ + supported: false, + reason: l10n.t('This manager does not support upgrading packages.'), + }), +}; +``` + +Checks must not prompt, mutate state, or invoke operations to test support. +Return `{ supported: false, reason }` for unsupported features; let unexpected +probe failures reject. + +Public routed queries emit `MANAGER.CAPABILITY_QUERY` with the manager ID, kind, +tool version, capability, outcome, and `isLegacy` (false when omitted from support). +Unavailable managers use ID `none`; +unknown or failed version lookups use `unknown`. Version collection runs in the +background with a one-second timeout and cannot delay query results or errors. +Context, paths, reasons, and error messages are not sent. Unresolved routing +emits an `unavailable` event. Ownership errors and routing exceptions thrown +before capture, as well as internal checks, do not emit this event. + ## Compatibility guidance - Use only exports from `@vscode/python-environments`; do not import diff --git a/src/api.ts b/src/api.ts index 05062c77c..2577b88ba 100644 --- a/src/api.ts +++ b/src/api.ts @@ -15,6 +15,8 @@ import type { PythonEnvironmentApi } from './types.js'; export * from './types.js'; export * from './publicErrors.js'; +export * from './capabilities.js'; +export * from './legacyCapabilities.js'; export const EXTENSION_ID = 'ms-python.vscode-python-envs'; diff --git a/src/capabilities.ts b/src/capabilities.ts new file mode 100644 index 000000000..7f4ab2778 --- /dev/null +++ b/src/capabilities.ts @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { l10n } from 'vscode'; +import { getLegacyEnvironmentManagerCapability, getLegacyPackageManagerCapability } from './legacyCapabilities.js'; +import type { + CreateEnvironmentScope, + EnvironmentManager, + GetEnvironmentsScope, + PackageManager, + PythonEnvironment, + PythonProject, +} from './types.js'; + +/** Capabilities that an environment manager can provide. */ +export const EnvironmentCapability = { + /** Discover the environments available within a scope. */ + List: 'environments.list', + /** Resolve a URI to its corresponding Python environment. */ + Resolve: 'environments.resolve', + /** Get the Python environment selected for a scope. */ + GetSelected: 'environments.getSelected', + /** Select or clear the Python environment for a scope. */ + SetSelected: 'environments.setSelected', + /** Create a new Python environment. */ + Create: 'environments.create', + /** Remove an existing Python environment. */ + Remove: 'environments.remove', +} as const; + +/** Capabilities that a package manager can provide. */ +export const PackageCapability = { + /** List the packages installed in an environment. */ + List: 'packages.list', + /** Install packages into an environment. */ + Install: 'packages.install', + /** Uninstall packages from an environment. */ + Uninstall: 'packages.uninstall', + /** Upgrade packages in an environment. */ + Upgrade: 'packages.upgrade', + /** List the packages that are direct dependencies of an environment. */ + ListDirect: 'packages.list.direct', + /** List the versions available for a package. */ + FetchAvailableVersions: 'packages.fetchversions', +} as const; + +/** Environment capability keys for manager advertisements. */ +export type EnvironmentManagerCapability = (typeof EnvironmentCapability)[keyof typeof EnvironmentCapability]; + +/** Package capability keys for manager advertisements. */ +export type PackageManagerCapability = (typeof PackageCapability)[keyof typeof PackageCapability]; + +type ManagerCapability = EnvironmentManagerCapability | PackageManagerCapability; +const environmentCapabilityKeys = Object.values(EnvironmentCapability); +const packageCapabilityKeys = Object.values(PackageCapability); + +/** + * Identifies catalog keys so telemetry does not record arbitrary runtime input. + * @param capability Runtime value supplied by a caller. + */ +export function isKnownManagerCapability(capability: unknown): capability is ManagerCapability { + return ( + environmentCapabilityKeys.some((key) => key === capability) || + packageCapabilityKeys.some((key) => key === capability) + ); +} + +/** General feature support, not a guarantee that a particular operation will succeed. */ +export type Support = ({ readonly supported: true } | { readonly supported: false; readonly reason: string }) & { + /** Whether support was inferred from legacy methods. Omitted means false. */ + readonly isLegacy?: boolean; +}; + +/** Context for a support query. Operation arguments and request validation are intentionally excluded. */ +export interface CapabilityContext { + readonly scope?: CreateEnvironmentScope | GetEnvironmentsScope; + readonly environment?: PythonEnvironment; + readonly project?: PythonProject; +} + +/** A read-only, noninteractive check. Unexpected probe failures should reject, not report unsupported. */ +export type CapabilityCheck = (context: CapabilityContext) => Promise; + +/** Every capability key must be advertised as a read-only, noninteractive check; there are no defaults. */ +export type Capabilities = Readonly>; + +/** A capability check that always reports support. Use for unconditionally supported operations. */ +export const supportedCapability: CapabilityCheck = async () => ({ supported: true }); + +/** A capability check that reports unsupported with the generic "not implemented" reason. */ +export const unsupportedCapability: CapabilityCheck = async () => ({ + supported: false, + reason: l10n.t('Capability not implemented'), +}); + +/** + * Resolves environment-manager support without invoking the operation or prompting. + * @param manager Manager whose advertised capabilities are queried. + * @param capability Environment capability to look up; missing keys in a supplied map resolve unsupported. + * @param context Context forwarded unchanged to the advertised check. + * @returns Advertised support, or legacy method inference when the map is omitted. Probe errors reject. + */ +export function resolveEnvironmentManagerCapability( + manager: EnvironmentManager, + capability: EnvironmentManagerCapability, + context: CapabilityContext = {}, +): Promise { + if (manager.capabilities === undefined) { + return getLegacyEnvironmentManagerCapability(manager, capability); + } + return resolveCapability(manager, capability, context); +} + +/** + * Resolves package-manager support without invoking the operation or prompting. + * @param manager Manager whose advertised capabilities are queried, including the project-bound instance when applicable. + * @param capability Package capability to look up; missing keys in a supplied map resolve unsupported. + * @param context Context forwarded unchanged to the advertised check. + * @returns Advertised support, or legacy method inference when the map is omitted. Probe errors reject. + */ +export function resolvePackageManagerCapability( + manager: PackageManager, + capability: PackageManagerCapability, + context: CapabilityContext = {}, +): Promise { + if (manager.capabilities === undefined) { + return getLegacyPackageManagerCapability(manager, capability); + } + return resolveCapability(manager, capability, context); +} + +async function resolveCapability( + manager: { readonly capabilities?: Capabilities }, + capability: C, + context: CapabilityContext, +): Promise { + const capabilities = manager.capabilities; + const check = + capabilities && Object.prototype.hasOwnProperty.call(capabilities, capability) + ? capabilities[capability] + : undefined; + return typeof check === 'function' ? check(context) : unsupportedCapability(context); +} diff --git a/src/common/telemetry/constants.ts b/src/common/telemetry/constants.ts index e0b758cb3..a983a1cf7 100644 --- a/src/common/telemetry/constants.ts +++ b/src/common/telemetry/constants.ts @@ -6,6 +6,7 @@ export enum EventNames { PACKAGE_MANAGER_REGISTERED = 'PACKAGE_MANAGER.REGISTERED', ENVIRONMENT_MANAGER_SELECTED = 'ENVIRONMENT_MANAGER.SELECTED', PACKAGE_MANAGER_SELECTED = 'PACKAGE_MANAGER.SELECTED', + MANAGER_CAPABILITY_QUERY = 'MANAGER.CAPABILITY_QUERY', VENV_USING_UV = 'VENV.USING_UV', VENV_CREATION = 'VENV.CREATION', @@ -328,6 +329,25 @@ export interface IEventNamePropertyMapping { managerId: string; }; + /* __GDPR__ + "manager.capability_query": { + "managerId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eduardovil" }, + "managerKind": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eduardovil" }, + "managerVersion": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eduardovil" }, + "capability": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eduardovil" }, + "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eduardovil" }, + "isLegacy": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eduardovil" } + } + */ + [EventNames.MANAGER_CAPABILITY_QUERY]: { + managerId: string; + managerKind: 'environment' | 'package'; + managerVersion: string; + capability: string; + outcome: 'supported' | 'unsupported' | 'unavailable' | 'error'; + isLegacy: boolean; + }; + /* __GDPR__ "environment_manager.selected": { "managerId" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" } diff --git a/src/extensionApi.ts b/src/extensionApi.ts index c916f2f4f..de407f52e 100644 --- a/src/extensionApi.ts +++ b/src/extensionApi.ts @@ -1,4 +1,10 @@ -import { Disposable, Event, EventEmitter, TaskExecution, Terminal, Uri } from 'vscode'; +import { Disposable, Event, EventEmitter, l10n, TaskExecution, Terminal, Uri } from 'vscode'; +import * as path from 'path'; +import type { CapabilityContext, EnvironmentManagerCapability, PackageManagerCapability, Support } from './capabilities'; +import { isKnownManagerCapability } from './capabilities'; +import { explain as parsePep440Version } from '@renovatebot/pep440'; +import { EventNames } from './common/telemetry/constants'; +import { sendTelemetryEvent } from './common/telemetry/sender'; import type { CreateEnvironmentOptions, CreateEnvironmentScope, @@ -40,12 +46,12 @@ import { traceError, traceInfo } from './common/logging'; import { pickEnvironmentManager } from './common/pickers/managers'; import { timeout } from './common/utils/asyncUtils'; import { createDeferred } from './common/utils/deferred'; -import { checkUri } from './common/utils/pathUtils'; +import { checkUri, normalizePath } from './common/utils/pathUtils'; import { handlePythonPath } from './common/utils/pythonPath'; import type { EnvironmentManagers } from './features/envManagers'; import type { ProjectCreators } from './features/creators/projectCreators'; import type { PythonProjectManager } from './features/projectManager'; -import type { InternalEnvironmentManager } from './managers/common/registeredManagers'; +import type { InternalEnvironmentManager, InternalPackageManager } from './managers/common/registeredManagers'; import { PythonEnvironmentImpl, PythonPackageImpl } from './managers/common/models'; import { waitForAllEnvManagers, waitForEnvManager, waitForEnvManagerId } from './features/common/managerReady'; import { EnvVarManager } from './features/execution/envVariableManager'; @@ -61,6 +67,22 @@ import type { PythonToolsApi } from './internal/pythonToolsApi'; // workspace/configuration handler) from hanging on the full environment enumeration at startup. const GET_ENVIRONMENT_TIMEOUT_MS = 1000; const GET_ENVIRONMENT_TIMED_OUT = Symbol('getEnvironmentTimedOut'); +const CAPABILITY_TELEMETRY_VERSION_TIMEOUT_MS = 1000; + +type ManagerCapabilityQuery = + | { + kind: 'environment'; + manager: InternalEnvironmentManager | undefined; + managerId: string; + capability: EnvironmentManagerCapability; + context?: CapabilityContext; + } + | { + kind: 'package'; + manager: InternalPackageManager | undefined; + capability: PackageManagerCapability; + context: CapabilityContext & { environment: PythonEnvironment }; + }; export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { readonly __pythonTools: PythonToolsApi; @@ -144,6 +166,131 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { return new Disposable(() => disposables.forEach((d) => d.dispose())); } + /** + * Queries one registered environment provider without activation, waiting, or prompting. + * @param managerId Exact registered environment manager ID. + * @param capability Feature to query. + * @param context Optional scope, environment, and project forwarded to the provider. + */ + async getEnvironmentManagerCapability( + managerId: string, + capability: EnvironmentManagerCapability, + context?: CapabilityContext, + ): Promise { + if (context?.environment && context.environment.envId.managerId !== managerId) { + throw new Error(l10n.t('The environment does not belong to environment manager {0}.', managerId)); + } + const manager = this.envManagers.getEnvironmentManager(managerId); + return this.captureCapabilityQuery({ + kind: 'environment', + manager, + managerId, + capability, + context, + }); + } + + /** + * Queries the registered package provider routed for an environment or explicit project without activation or waiting. + * @param environment Environment whose packages would be managed. + * @param capability Feature to query. + * @param project Optional project selecting its configured, project-bound provider. + */ + async getPackageManagerCapability( + environment: PythonEnvironment, + capability: PackageManagerCapability, + project?: PythonProject, + ): Promise { + const registeredProject = project && this.projectManager.getProjects().find( + ({ uri }) => uri.scheme === project.uri.scheme && ( + uri.scheme === 'file' + ? normalizePath(path.resolve(uri.fsPath)) === normalizePath(path.resolve(project.uri.fsPath)) + : uri.toString() === project.uri.toString() + ), + ); + let manager = !project || registeredProject + ? this.envManagers.getPackageManager(registeredProject?.uri ?? environment) + : undefined; + // A project-aware root is not a usable substitute for a missing scoped instance. + if (manager?.createForProject && !manager.project) { + manager = undefined; + } + return this.captureCapabilityQuery({ + kind: 'package', + manager, + capability, + context: { environment, project: registeredProject ?? project }, + }); + } + + private async captureCapabilityQuery(query: ManagerCapabilityQuery): Promise { + const { manager, capability } = query; + let outcome: 'supported' | 'unsupported' | 'unavailable' | 'error' = 'error'; + let isLegacy = false; + try { + if (!query.manager) { + outcome = 'unavailable'; + return { + supported: false, + reason: query.kind === 'environment' + ? l10n.t('Environment manager {0} is unavailable.', query.managerId) + : l10n.t('No package manager is available for the environment and project context.'), + }; + } + const support = query.kind === 'environment' + ? await query.manager.getCapability(query.capability, query.context) + : await query.manager.getCapability(query.capability, query.context); + outcome = support.supported ? 'supported' : 'unsupported'; + isLegacy = support.isLegacy === true; + return support; + } finally { + void this.getManagerVersionForTelemetryWithTimeout(query, CAPABILITY_TELEMETRY_VERSION_TIMEOUT_MS) + .then((managerVersion) => { + sendTelemetryEvent(EventNames.MANAGER_CAPABILITY_QUERY, undefined, { + managerId: manager?.id ?? 'none', + managerKind: query.kind, + managerVersion, + capability: isKnownManagerCapability(capability) ? capability : 'unknown', + outcome, + isLegacy, + }); + }) + .catch((error) => traceError('Failed to send manager capability query telemetry:', error)); + } + } + + private async getManagerVersionForTelemetryWithTimeout( + query: ManagerCapabilityQuery, + timeoutMs: number, + ): Promise { + if (!query.manager) { + return 'unknown'; + } + + let versionTimer: ReturnType | undefined; + try { + const version = await Promise.race([ + query.kind === 'package' + ? query.manager.getVersion(query.context.environment) + : query.manager.getVersion(), + new Promise((_, reject) => { + versionTimer = setTimeout( + () => reject(new Error('Manager version lookup timed out.')), + timeoutMs, + ); + }), + ]); + return version ? parsePep440Version(version.public)?.public ?? 'unknown' : 'unknown'; + } catch (error) { + traceError(`Failed to retrieve manager version for capability telemetry: ${query.manager.id}`, error); + return 'unknown'; + } finally { + if (versionTimer !== undefined) { + clearTimeout(versionTimer); + } + } + } + createPythonEnvironmentItem(info: PythonEnvironmentInfo, manager: EnvironmentManager): PythonEnvironment { const mgr = this.envManagers.managers.find((m) => m.equals(manager)); if (!mgr) { diff --git a/src/legacyCapabilities.ts b/src/legacyCapabilities.ts new file mode 100644 index 000000000..c1ccac531 --- /dev/null +++ b/src/legacyCapabilities.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import type { EnvironmentManagerCapability, PackageManagerCapability, Support } from './capabilities.js'; +import { + EnvironmentCapability, + PackageCapability, + supportedCapability, + unsupportedCapability, +} from './capabilities.js'; +import type { EnvironmentManager, PackageManager } from './types.js'; + +/** + * @deprecated Use the capabilities advertisement instead of relying on legacy method presence. + * Infers legacy package support from method presence without invoking operations or reading advertisements. + * @param packageManager Provider whose methods are inspected. + * @param capability Package capability to infer. + * @returns Structural support, not a guarantee of tool-version or option support. + */ +export async function getLegacyPackageManagerCapability( + packageManager: PackageManager, + capability: PackageManagerCapability, +): Promise { + switch (capability) { + case PackageCapability.List: + return getMethodSupport(packageManager.getPackages, packageManager.refresh); + case PackageCapability.Install: + case PackageCapability.Uninstall: + case PackageCapability.Upgrade: + return getMethodSupport(packageManager.manage); + case PackageCapability.ListDirect: + return getMethodSupport(packageManager.getDirectPackageNames); + case PackageCapability.FetchAvailableVersions: + return getMethodSupport(packageManager.getPackageAvailableVersions); + default: + return getMethodSupport(); + } +} + +/** + * @deprecated Use the capabilities advertisement instead of relying on legacy method presence. + * Infers legacy environment support from method presence without invoking operations or reading advertisements. + * @param environmentManager Provider whose methods are inspected. + * @param capability Environment capability to infer. + * @returns Structural support; the methods are not executed to verify their behavior. + */ +export async function getLegacyEnvironmentManagerCapability( + environmentManager: EnvironmentManager, + capability: EnvironmentManagerCapability, +): Promise { + switch (capability) { + case EnvironmentCapability.List: + return getMethodSupport(environmentManager.getEnvironments, environmentManager.refresh); + case EnvironmentCapability.Resolve: + return getMethodSupport(environmentManager.resolve); + case EnvironmentCapability.GetSelected: + return getMethodSupport(environmentManager.get); + case EnvironmentCapability.SetSelected: + return getMethodSupport(environmentManager.set); + case EnvironmentCapability.Create: + return getMethodSupport(environmentManager.create); + case EnvironmentCapability.Remove: + return getMethodSupport(environmentManager.remove); + default: + return getMethodSupport(); + } +} + +async function getMethodSupport(...methods: unknown[]): Promise { + const support = await (methods.length > 0 && methods.every((method) => typeof method === 'function') + ? supportedCapability({}) + : unsupportedCapability({})); + return { ...support, isLegacy: true }; +} diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 3067a06a6..ab168e752 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -34,6 +34,7 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../../api'; +import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../../capabilities'; import { CONDA_MANAGER_ID, INLINE_SCRIPT_MANAGER_ID, @@ -291,6 +292,30 @@ interface SavedMetadataSnapshot { /** Manages extension-owned PEP 723 script environments. */ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { + readonly capabilities: Capabilities = { + [EnvironmentCapability.List]: supportedCapability, + [EnvironmentCapability.Resolve]: async () => ({ + supported: false, + reason: l10n.t('Inline-script environments do not support resolving interpreter or environment URIs.'), + }), + [EnvironmentCapability.GetSelected]: supportedCapability, + [EnvironmentCapability.SetSelected]: supportedCapability, + [EnvironmentCapability.Remove]: supportedCapability, + [EnvironmentCapability.Create]: async ({ scope }) => { + const scriptUri = scope !== undefined && scope !== 'all' ? this.getScriptUri(scope) : undefined; + if (!scriptUri) { + return { + supported: false, + reason: l10n.t('Inline-script creation requires exactly one local file URI.'), + }; + } + // Read the bounded header afresh so saved metadata edits affect the next query. + return (await readInlineScriptMetadataFromFile(scriptUri)) + ? { supported: true } + : { supported: false, reason: l10n.t('The script must contain valid PEP 723 metadata.') }; + }, + }; + private readonly pendingSetups = new Map>(); private readonly pendingCreations = new Map(); private readonly directlyResolvedBaseInterpreters = new Map(); diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index f22ede104..a06124639 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -17,6 +17,7 @@ import { DidChangePackagesEventArgs, GetPackagesOptions, IconPath, + isPackageVersionLookupNotSupportedError, Package, PackageManagementOptions, PackageManager, @@ -24,6 +25,13 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; +import { + Capabilities, + CapabilityCheck, + PackageCapability, + PackageManagerCapability, + supportedCapability, +} from '../../capabilities'; import { showErrorMessageWithLogs } from '../../common/errors/utils'; import { PythonVersion } from '../../common/pythonVersion'; import { showErrorMessage, withProgress } from '../../common/window.apis'; @@ -51,6 +59,39 @@ import { getWorkspacePackagesToInstall } from './pipUtils'; import { VenvManager } from './venvManager'; export class PipPackageManager implements PackageManager, Disposable { + private readonly packageManagementCapability: CapabilityCheck = async ({ environment }) => { + if (PythonVersion.tryParse(environment?.version)?.major === 2) { + return { supported: false, reason: l10n.t('Python 2.* is not supported (deprecated)') }; + } + return { supported: true }; + }; + + readonly capabilities: Capabilities = { + [PackageCapability.List]: supportedCapability, + [PackageCapability.Install]: this.packageManagementCapability, + [PackageCapability.Uninstall]: this.packageManagementCapability, + [PackageCapability.Upgrade]: this.packageManagementCapability, + [PackageCapability.FetchAvailableVersions]: async ({ environment }) => { + if (!environment) { + return { + supported: false, + reason: l10n.t('An environment is required to check package version lookup support.'), + }; + } + try { + await this.getPackageAvailableVersionsCommand(environment); + } catch (error) { + if (isPackageVersionLookupNotSupportedError(error)) { + return { supported: false, reason: error.message }; + } + throw error; + } + return await supportedCapability({ environment }); + }, + // Dependency roots are best-effort classification, not exact user install intent. + [PackageCapability.ListDirect]: supportedCapability, + }; + private readonly _onDidChangePackages = new EventEmitter(); onDidChangePackages: Event = this._onDidChangePackages.event; @@ -109,8 +150,9 @@ export class PipPackageManager implements PackageManager, Disposable { } } - if (PythonVersion.tryParse(environment.version)?.major === 2) { - throw new Error('Python 2.* is not supported (deprecated)'); + const support = await this.packageManagementCapability({ environment }); + if (!support.supported) { + throw new Error(support.reason); } const execute = async (token?: CancellationToken): Promise => { @@ -317,7 +359,10 @@ export class PipPackageManager implements PackageManager, Disposable { } if (compare(version.public, '21.2.0') < 0) { throw new PackageVersionLookupNotSupportedError( - `Package version lookup requires pip 21.2 or newer; the environment has pip ${version.public}.`, + l10n.t( + 'Package version lookup requires pip 21.2 or newer; the environment has pip {0}.', + version.public, + ), ); } if (compare(version.public, '25.1') >= 0) { diff --git a/src/managers/builtin/sysPythonManager.ts b/src/managers/builtin/sysPythonManager.ts index 8d4349d3e..9484449d3 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { EventEmitter, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri } from 'vscode'; +import { EventEmitter, l10n, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri } from 'vscode'; import { CreateEnvironmentOptions, CreateEnvironmentScope, @@ -17,6 +17,7 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; +import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../capabilities'; import { SysManagerStrings } from '../../common/localize'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; @@ -36,6 +37,18 @@ import { getDefaultGlobalPython, refreshPythons, resolveSystemPythonEnvironmentP import { installPythonWithUv, promptInstallPythonViaUv, selectPythonVersionToInstall } from './uvPythonInstaller'; export class SysPythonManager implements EnvironmentManager { + readonly capabilities: Capabilities = { + [EnvironmentCapability.List]: supportedCapability, + [EnvironmentCapability.Resolve]: supportedCapability, + [EnvironmentCapability.GetSelected]: supportedCapability, + [EnvironmentCapability.SetSelected]: supportedCapability, + [EnvironmentCapability.Create]: supportedCapability, + [EnvironmentCapability.Remove]: async () => ({ + supported: false, + reason: l10n.t('This environment manager does not support removing environments.'), + }), + }; + private collection: PythonEnvironment[] = []; private readonly fsPathToEnv: Map = new Map(); private globalEnv: PythonEnvironment | undefined; diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 4ab121b05..8238509f5 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -29,6 +29,7 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; +import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../capabilities'; import { executeCommand } from '../../common/command.api'; import { PYTHON_EXTENSION_ID } from '../../common/constants'; import { VenvManagerStrings } from '../../common/localize'; @@ -69,6 +70,15 @@ import { } from './venvUtils'; export class VenvManager implements EnvironmentManager { + readonly capabilities: Capabilities = { + [EnvironmentCapability.List]: supportedCapability, + [EnvironmentCapability.Resolve]: supportedCapability, + [EnvironmentCapability.GetSelected]: supportedCapability, + [EnvironmentCapability.SetSelected]: supportedCapability, + [EnvironmentCapability.Create]: supportedCapability, + [EnvironmentCapability.Remove]: supportedCapability, + }; + private collection: PythonEnvironment[] = []; private environmentFolders: { collection: PythonEnvironment[]; length: number; folders: Set } | undefined; private readonly fsPathToEnv: Map = new Map(); diff --git a/src/managers/common/registeredManagers.ts b/src/managers/common/registeredManagers.ts index 37aa0c8d4..23d6470e9 100644 --- a/src/managers/common/registeredManagers.ts +++ b/src/managers/common/registeredManagers.ts @@ -4,6 +4,11 @@ import type { Pep440Version } from '@renovatebot/pep440'; import { CancellationError, Disposable, Event, LogOutputChannel, MarkdownString, RelativePattern } from 'vscode'; import { PackageVersionLookupNotSupportedError } from '../../publicErrors'; +import { + resolveEnvironmentManagerCapability, + resolvePackageManagerCapability, +} from '../../capabilities'; +import type { CapabilityContext, EnvironmentManagerCapability, PackageManagerCapability, Support } from '../../capabilities'; import { ISSUES_URL } from '../../common/constants'; import { CreateEnvironmentNotSupported, RemoveEnvironmentNotSupported } from '../../common/errors/NotSupportedError'; import { traceWarn } from '../../common/logging'; @@ -85,10 +90,19 @@ export class InternalEnvironmentManager implements EnvironmentManager { return this.manager.getVersion ? this.manager.getVersion() : Promise.resolve(undefined); } + public get capabilities(): EnvironmentManager['capabilities'] { + return this.manager.capabilities; + } + public get supportsCreate(): boolean { return this.manager.create !== undefined; } + /** Resolves raw-provider advertisements or legacy method support, independently of operational fallbacks. */ + public getCapability(capability: EnvironmentManagerCapability, context?: CapabilityContext): Promise { + return resolveEnvironmentManagerCapability(this.manager, capability, context); + } + create( scope: CreateEnvironmentScope, options: CreateEnvironmentOptions | undefined, @@ -231,6 +245,15 @@ export class InternalPackageManager implements PackageManager { private isDisposed = false; public readonly createForProject?: (project: PythonProject) => InternalPackageManager; + /** Resolves support on this live raw instance, retaining any project-bound state. */ + public getCapability(capability: PackageManagerCapability, context: CapabilityContext = {}): Promise { + this.throwIfDisposed(); + return resolvePackageManagerCapability(this.manager, capability, { + ...context, + project: this.project ?? context.project, + }); + } + public constructor( public readonly id: string, private readonly manager: PackageManager, @@ -289,6 +312,10 @@ export class InternalPackageManager implements PackageManager { return this.manager.log; } + public get capabilities(): PackageManager['capabilities'] { + return this.manager.capabilities; + } + async manage(environment: PythonEnvironment, options: PackageManagementOptions): Promise { this.throwIfDisposed(); const stopWatch = new StopWatch(); diff --git a/src/managers/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts index 6d64ebcb4..413034d9e 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -29,6 +29,7 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; +import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../capabilities'; import { CondaStrings } from '../../common/localize'; import { traceError, traceInfo } from '../../common/logging'; import { StopWatch } from '../../common/stopWatch'; @@ -71,6 +72,15 @@ import { } from './condaUtils'; export class CondaEnvManager implements EnvironmentManager, Disposable { + readonly capabilities: Capabilities = { + [EnvironmentCapability.List]: supportedCapability, + [EnvironmentCapability.Resolve]: supportedCapability, + [EnvironmentCapability.GetSelected]: supportedCapability, + [EnvironmentCapability.SetSelected]: supportedCapability, + [EnvironmentCapability.Create]: supportedCapability, + [EnvironmentCapability.Remove]: supportedCapability, + }; + private collection: PythonEnvironment[] = []; private fsPathToEnv: Map = new Map(); private globalEnv: PythonEnvironment | undefined; diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index eef0c5646..c4e52bfa6 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -7,6 +7,7 @@ import { Disposable, Event, EventEmitter, + l10n, LogOutputChannel, MarkdownString, ProgressLocation, @@ -22,10 +23,12 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; +import { Capabilities, PackageCapability, PackageManagerCapability, supportedCapability } from '../../capabilities'; import { showErrorMessageWithLogs } from '../../common/errors/utils'; import { CondaStrings } from '../../common/localize'; import { withProgress } from '../../common/window.apis'; +import { PackageToolSupport, pythonToolSupport, throwIfCancelled } from '../../internal/pythonToolSupport'; import { updatePackagesAndNotify } from '../common/packageChanges'; import { parsePackageSpecs } from '../common/packageUtils'; import { @@ -37,9 +40,20 @@ import { CondaVersionCommand, } from './commands/index'; import { getCommonCondaPackagesToInstall } from './condaUtils'; -import { PackageToolSupport, pythonToolSupport, throwIfCancelled } from '../../internal/pythonToolSupport'; export class CondaPackageManager implements PackageManager, Disposable { + readonly capabilities: Capabilities = { + [PackageCapability.List]: supportedCapability, + [PackageCapability.Install]: supportedCapability, + [PackageCapability.Uninstall]: supportedCapability, + [PackageCapability.Upgrade]: supportedCapability, + [PackageCapability.FetchAvailableVersions]: supportedCapability, + [PackageCapability.ListDirect]: async () => ({ + supported: false, + reason: l10n.t('Conda does not classify direct and transitive packages.'), + }), + }; + private readonly _onDidChangePackages = new EventEmitter(); onDidChangePackages: Event = this._onDidChangePackages.event; @@ -236,10 +250,7 @@ export class CondaPackageManager implements PackageManager, Disposable { } } - async getPackageAvailableVersions( - _environment: PythonEnvironment, - packageName: string, - ): Promise { + async getPackageAvailableVersions(_environment: PythonEnvironment, packageName: string): Promise { const availableVersionsCmd = new CondaAvailableVersionsCommand({ pythonExecutable: 'conda', log: this.log, diff --git a/src/managers/pipenv/pipenvManager.ts b/src/managers/pipenv/pipenvManager.ts index 250c4fffc..abbe6b240 100644 --- a/src/managers/pipenv/pipenvManager.ts +++ b/src/managers/pipenv/pipenvManager.ts @@ -1,5 +1,5 @@ import { explain as parsePep440Version } from '@renovatebot/pep440'; -import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri } from 'vscode'; +import { Disposable, EventEmitter, l10n, MarkdownString, ProgressLocation, Uri } from 'vscode'; import { DidChangeEnvironmentEventArgs, DidChangeEnvironmentsEventArgs, @@ -16,6 +16,7 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; +import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../capabilities'; import { PipenvStrings } from '../../common/localize'; import { traceError, traceInfo } from '../../common/logging'; import { StopWatch } from '../../common/stopWatch'; @@ -49,6 +50,21 @@ import { } from './pipenvUtils'; export class PipenvManager implements EnvironmentManager, Disposable { + readonly capabilities: Capabilities = { + [EnvironmentCapability.List]: supportedCapability, + [EnvironmentCapability.Resolve]: supportedCapability, + [EnvironmentCapability.GetSelected]: supportedCapability, + [EnvironmentCapability.SetSelected]: supportedCapability, + [EnvironmentCapability.Create]: async () => ({ + supported: false, + reason: l10n.t('{0} does not support creating environments.', 'Pipenv'), + }), + [EnvironmentCapability.Remove]: async () => ({ + supported: false, + reason: l10n.t('{0} does not support removing environments.', 'Pipenv'), + }), + }; + private collection: PythonEnvironment[] = []; private fsPathToEnv: Map = new Map(); private globalEnv: PythonEnvironment | undefined; diff --git a/src/managers/poetry/poetryManager.ts b/src/managers/poetry/poetryManager.ts index 00041932b..8de448b3e 100644 --- a/src/managers/poetry/poetryManager.ts +++ b/src/managers/poetry/poetryManager.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri } from 'vscode'; +import { Disposable, EventEmitter, l10n, MarkdownString, ProgressLocation, Uri } from 'vscode'; import { DidChangeEnvironmentEventArgs, DidChangeEnvironmentsEventArgs, @@ -16,6 +16,7 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; +import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../capabilities'; import { PoetryStrings } from '../../common/localize'; import { traceError, traceInfo } from '../../common/logging'; import { StopWatch } from '../../common/stopWatch'; @@ -49,6 +50,21 @@ import { import { PoetryVersionCommand } from './commands/version'; export class PoetryManager implements EnvironmentManager, Disposable { + readonly capabilities: Capabilities = { + [EnvironmentCapability.List]: supportedCapability, + [EnvironmentCapability.Resolve]: supportedCapability, + [EnvironmentCapability.GetSelected]: supportedCapability, + [EnvironmentCapability.SetSelected]: supportedCapability, + [EnvironmentCapability.Create]: async () => ({ + supported: false, + reason: l10n.t('{0} does not support creating environments.', 'Poetry'), + }), + [EnvironmentCapability.Remove]: async () => ({ + supported: false, + reason: l10n.t('{0} does not support removing environments.', 'Poetry'), + }), + }; + private collection: PythonEnvironment[] = []; private fsPathToEnv: Map = new Map(); private globalEnv: PythonEnvironment | undefined; diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index c79bf1ec4..7be4dad56 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -27,10 +27,17 @@ import { PythonEnvironmentApi, PythonProject, } from '../../api'; +import { Capabilities, PackageCapability, PackageManagerCapability, Support } from '../../capabilities'; +import { normalizePath } from '../../common/utils/pathUtils'; import { showErrorMessage, showInputBox, withProgress } from '../../common/window.apis'; import * as workspaceFs from '../../common/workspace.fs.apis'; +import { + PackageToolSupport, + PythonToolError, + pythonToolSupport, + throwIfCancelled, +} from '../../internal/pythonToolSupport'; import { PackageManagerRequiresProjectError } from '../common/errors'; -import { normalizePath } from '../../common/utils/pathUtils'; import { updatePackagesAndNotify } from '../common/packageChanges'; import { parsePackageSpecs } from '../common/packageUtils'; import { @@ -40,12 +47,33 @@ import { PoetryShowTopLevelCommand, PoetryVersionCommand, } from './commands/index'; +import { runPoetry } from './commands/runPoetry'; import { PoetryManager } from './poetryManager'; import { getPoetry } from './poetryUtils'; -import { runPoetry } from './commands/runPoetry'; -import { PackageToolSupport, PythonToolError, pythonToolSupport, throwIfCancelled } from '../../internal/pythonToolSupport'; export class PoetryPackageManager implements PackageManager, Disposable { + readonly capabilities: Capabilities = { + [PackageCapability.List]: async () => this.checkProjectSupport(), + [PackageCapability.ListDirect]: async () => this.checkProjectSupport(), + [PackageCapability.Install]: async () => this.checkProjectSupport(), + [PackageCapability.Uninstall]: async () => this.checkProjectSupport(), + [PackageCapability.FetchAvailableVersions]: async () => ({ + supported: false, + reason: l10n.t('Poetry package version lookup is not supported by this extension.'), + }), + [PackageCapability.Upgrade]: async () => ({ + supported: false, + reason: l10n.t('Poetry package management does not support the upgrade option.'), + }), + }; + + private checkProjectSupport(): Support { + if (!this.project) { + return { supported: false, reason: l10n.t('Poetry package management requires a project-bound manager.') }; + } + return { supported: true }; + } + private readonly packagesChangedEmitter = new EventEmitter(); readonly onDidChangePackages: Event = this.packagesChangedEmitter.event; @@ -76,12 +104,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { * @returns A Poetry package manager scoped to the project. */ createForProject(project: PythonProject): PoetryPackageManager { - return new PoetryPackageManager( - this.api, - this.log, - this.poetryManager, - project, - ); + return new PoetryPackageManager(this.api, this.log, this.poetryManager, project); } readonly [pythonToolSupport]: PackageToolSupport = { @@ -109,7 +132,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { throw new PythonToolError( 'ENVIRONMENT_MISMATCH', l10n.t( - 'Poetry for {0} targets {1}, not the selected environment {2}. Select this project\'s Poetry environment before querying or installing packages.', + "Poetry for {0} targets {1}, not the selected environment {2}. Select this project's Poetry environment before querying or installing packages.", scope.fsPath, target || l10n.t('no environment'), environment.sysPrefix, @@ -196,12 +219,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { const execute = async (token?: CancellationToken): Promise => { try { - await this.runPoetryManage( - { install: toInstall, uninstall: toUninstall }, - cwd, - token, - !!toolToken, - ); + await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, cwd, token, !!toolToken); await updatePackagesAndNotify( this, environment, @@ -304,10 +322,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { return await versionCmd.execute(); } - async getPackageAvailableVersions( - _environment: PythonEnvironment, - _packageName: string, - ): Promise { + async getPackageAvailableVersions(_environment: PythonEnvironment, _packageName: string): Promise { throw new PackageVersionLookupNotSupportedError( 'Poetry does not provide a package version lookup command supported by this extension.', ); diff --git a/src/managers/pyenv/pyenvManager.ts b/src/managers/pyenv/pyenvManager.ts index b1c2e914d..1042c9b70 100644 --- a/src/managers/pyenv/pyenvManager.ts +++ b/src/managers/pyenv/pyenvManager.ts @@ -1,6 +1,6 @@ import { explain as parsePep440Version } from '@renovatebot/pep440'; import * as path from 'path'; -import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri } from 'vscode'; +import { Disposable, EventEmitter, l10n, MarkdownString, ProgressLocation, Uri } from 'vscode'; import { DidChangeEnvironmentEventArgs, DidChangeEnvironmentsEventArgs, @@ -17,6 +17,7 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; +import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../capabilities'; import { PYENV_MANAGER_ID } from '../../common/constants'; import { PyenvStrings } from '../../common/localize'; import { traceError, traceInfo } from '../../common/logging'; @@ -47,6 +48,21 @@ import { } from './pyenvUtils'; export class PyEnvManager implements EnvironmentManager, Disposable { + readonly capabilities: Capabilities = { + [EnvironmentCapability.List]: supportedCapability, + [EnvironmentCapability.Resolve]: supportedCapability, + [EnvironmentCapability.GetSelected]: supportedCapability, + [EnvironmentCapability.SetSelected]: supportedCapability, + [EnvironmentCapability.Create]: async () => ({ + supported: false, + reason: l10n.t('{0} does not support creating environments.', 'Pyenv'), + }), + [EnvironmentCapability.Remove]: async () => ({ + supported: false, + reason: l10n.t('{0} does not support removing environments.', 'Pyenv'), + }), + }; + private collection: PythonEnvironment[] = []; private fsPathToEnv: Map = new Map(); private globalEnv: PythonEnvironment | undefined; diff --git a/src/test/capabilities.unit.test.ts b/src/test/capabilities.unit.test.ts new file mode 100644 index 000000000..0e0b5937c --- /dev/null +++ b/src/test/capabilities.unit.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import { l10n, Uri } from 'vscode'; +import { + Capabilities, + CapabilityContext, + EnvironmentManagerCapability, + PackageManagerCapability, + resolveEnvironmentManagerCapability as environmentCapability, + resolvePackageManagerCapability as packageCapability, + Support, + EnvironmentCapability, + PackageCapability, +} from '../capabilities'; +import { InternalPackageManager } from '../managers/common/registeredManagers'; +import type { EnvironmentManager, PackageManager } from '../types'; +import { allSupportedEnvironmentCapabilities, allSupportedPackageCapabilities } from './capabilityFixtures'; + +function unexpectedOperation(): never { + throw new Error('A capability query must not invoke a manager operation'); +} + +const supported: Support = { supported: true }; +const notImplemented: Support = { supported: false, reason: l10n.t('Capability not implemented') }; +const denied: Support = { supported: false, reason: 'Disabled by provider' }; + +function environmentManager(capabilities: Partial> = {}): EnvironmentManager { + return { + name: 'environment', + preferredPackageManagerId: 'test:packages', + refresh: unexpectedOperation, + getEnvironments: unexpectedOperation, + get: unexpectedOperation, + set: unexpectedOperation, + resolve: unexpectedOperation, + capabilities: { ...allSupportedEnvironmentCapabilities, ...capabilities }, + }; +} + +function packageManager(capabilities: Partial> = {}): PackageManager { + return { + name: 'packages', + manage: unexpectedOperation, + refresh: unexpectedOperation, + getPackages: unexpectedOperation, + capabilities: { ...allSupportedPackageCapabilities, ...capabilities }, + }; +} + +suite('Manager capabilities', () => { + suite('Optional advertisements with legacy fallback', () => { + test('advertised checks are honored in either direction', async () => { + const env = environmentManager({ [EnvironmentCapability.List]: async () => denied, [EnvironmentCapability.Create]: async () => supported }); + assert.strictEqual(await environmentCapability(env, EnvironmentCapability.List), denied); + assert.strictEqual(await environmentCapability(env, EnvironmentCapability.Create), supported); + + const pkg = packageManager({ [PackageCapability.Install]: async () => denied, [PackageCapability.ListDirect]: async () => supported }); + assert.strictEqual(await packageCapability(pkg, PackageCapability.Install), denied); + assert.strictEqual(await packageCapability(pkg, PackageCapability.ListDirect), supported); + }); + + test('only omitted maps use legacy inference; supplied maps and missing keys remain unsupported', async () => { + const env = environmentManager(); + const pkg = packageManager(); + for (const capabilities of [{}, undefined, null, false, 0, '']) { + Reflect.set(env, 'capabilities', capabilities); + Reflect.set(pkg, 'capabilities', capabilities); + const expected: Support = capabilities === undefined ? { supported: true, isLegacy: true } : notImplemented; + assert.deepStrictEqual(await environmentCapability(env, EnvironmentCapability.List), expected); + assert.deepStrictEqual(await packageCapability(pkg, PackageCapability.List), expected); + assert.deepStrictEqual( + await environmentCapability(env, EnvironmentCapability.Create), + capabilities === undefined ? { ...notImplemented, isLegacy: true } : notImplemented, + ); + } + }); + + test('unknown runtime keys cannot resolve inherited object properties', async () => { + for (const key of ['future.capability', 'toString', 'constructor', '__proto__']) { + assert.deepStrictEqual(await Reflect.apply(environmentCapability, undefined, [environmentManager(), key]), notImplemented); + assert.deepStrictEqual(await Reflect.apply(packageCapability, undefined, [packageManager(), key]), notImplemented); + } + }); + + test('a capability value that is not a function resolves unsupported, not a crash', async () => { + for (const value of [null, false, { supported: true }]) { + const env = environmentManager({ [EnvironmentCapability.List]: value as never }); + const pkg = packageManager({ [PackageCapability.List]: value as never }); + assert.deepStrictEqual(await environmentCapability(env, EnvironmentCapability.List), notImplemented); + assert.deepStrictEqual(await packageCapability(pkg, PackageCapability.List), notImplemented); + } + }); + + test('dynamic checks forward context without mutating or caching it', async () => { + const context: CapabilityContext = Object.freeze({ scope: 'global', project: { name: 'project', uri: Uri.file('.') } }); + let result: Support = denied; + const manager = packageManager({ + [PackageCapability.Install]: async (received) => { + assert.strictEqual(received.scope, context.scope); + assert.strictEqual(received.project, context.project); + return result; + }, + }); + assert.strictEqual(await packageCapability(manager, PackageCapability.Install, context), denied); + result = supported; + assert.strictEqual(await packageCapability(manager, PackageCapability.Install, context), supported); + }); + + test('sync and async checker failures propagate instead of resolving unsupported', async () => { + const failure = new Error('Probe failed'); + const env = environmentManager({ + [EnvironmentCapability.List]: () => { + throw failure; + }, + }); + const pkg = packageManager({ [PackageCapability.Install]: async () => Promise.reject(failure) }); + await assert.rejects(environmentCapability(env, EnvironmentCapability.List), (error) => error === failure); + await assert.rejects(packageCapability(pkg, PackageCapability.Install), (error) => error === failure); + }); + }); + + test('disposed package wrappers reject capability queries', () => { + const manager = new InternalPackageManager('test:packages', packageManager()); + manager.dispose(); + assert.throws(() => manager.getCapability(PackageCapability.List), /disposed/); + }); +}); diff --git a/src/test/capabilityFixtures.ts b/src/test/capabilityFixtures.ts new file mode 100644 index 000000000..e47013ed9 --- /dev/null +++ b/src/test/capabilityFixtures.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + Capabilities, + EnvironmentCapability, + EnvironmentManagerCapability, + PackageCapability, + PackageManagerCapability, + supportedCapability, +} from '../capabilities'; + +export const allSupportedEnvironmentCapabilities: Capabilities = { + [EnvironmentCapability.List]: supportedCapability, + [EnvironmentCapability.Resolve]: supportedCapability, + [EnvironmentCapability.GetSelected]: supportedCapability, + [EnvironmentCapability.SetSelected]: supportedCapability, + [EnvironmentCapability.Create]: supportedCapability, + [EnvironmentCapability.Remove]: supportedCapability, +}; + +export const allSupportedPackageCapabilities: Capabilities = { + [PackageCapability.List]: supportedCapability, + [PackageCapability.Install]: supportedCapability, + [PackageCapability.Uninstall]: supportedCapability, + [PackageCapability.Upgrade]: supportedCapability, + [PackageCapability.ListDirect]: supportedCapability, + [PackageCapability.FetchAvailableVersions]: supportedCapability, +}; diff --git a/src/test/extensionApi.capabilities.unit.test.ts b/src/test/extensionApi.capabilities.unit.test.ts new file mode 100644 index 000000000..d0d6ddb4b --- /dev/null +++ b/src/test/extensionApi.capabilities.unit.test.ts @@ -0,0 +1,359 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { explain as parsePep440Version } from '@renovatebot/pep440'; +import { Disposable, EventEmitter, l10n, Uri } from 'vscode'; +import type { + DidChangeEnvironmentVariablesEventArgs, + EnvironmentManager, + PackageManager, + Pep440Version, + PythonProject, +} from '../api'; +import { EnvironmentCapability, PackageCapability } from '../capabilities'; +import type { CapabilityContext, Support } from '../capabilities'; +import * as logging from '../common/logging'; +import { EventNames } from '../common/telemetry/constants'; +import * as telemetry from '../common/telemetry/sender'; +import { allSupportedEnvironmentCapabilities, allSupportedPackageCapabilities } from './capabilityFixtures'; +import * as extensionApis from '../common/extension.apis'; +import * as frameUtils from '../common/utils/frameUtils'; +import { createDeferred } from '../common/utils/deferred'; +import * as platformUtils from '../common/utils/platformUtils'; +import * as windowApis from '../common/window.apis'; +import { PythonEnvironmentApiImpl } from '../extensionApi'; +import { PythonEnvironmentManagers } from '../features/envManagers'; +import type { PythonProjectManager } from '../features/projectManager'; +import * as settings from '../features/settings/settingHelpers'; +import { createMockPythonEnvironment } from './mocks/pythonEnvironment'; + +suite('PythonEnvironmentApiImpl - capability queries', () => { + const extensionId = 'test.capabilities'; + const ownerId = `${extensionId}:owner`; + const packageId = `${extensionId}:packages`; + const project: PythonProject = { name: 'project', uri: Uri.joinPath(Uri.file(process.cwd()), 'project') }; + const environment = createMockPythonEnvironment({ envPath: process.cwd(), managerId: ownerId }); + let clock: sinon.SinonFakeTimers; + let api: PythonEnvironmentApiImpl; + let managers: PythonEnvironmentManagers; + let projectManager: PythonProjectManager; + let projects: PythonProject[]; + let disposables: Disposable[]; + let getExtension: sinon.SinonStub; + let sendTelemetry: sinon.SinonStub; + let environmentManagerVersion: sinon.SinonStub; + let packageManagerVersion: sinon.SinonStub; + let scopedPackageManagerVersion: sinon.SinonStub; + let configuredPackage: sinon.SinonStub; + let envProbe: sinon.SinonStub<[CapabilityContext], Promise>; + let pkgProbe: sinon.SinonStub<[CapabilityContext], Promise>; + let scopedProbe: sinon.SinonStub<[CapabilityContext], Promise>; + const scopedSupport: Support = { supported: false, reason: 'Scoped provider response' }; + + setup(() => { + clock = sinon.useFakeTimers(); + disposables = []; + projects = [project]; + getExtension = sinon.stub(extensionApis, 'getExtension').returns(undefined); + sendTelemetry = sinon.stub(telemetry, 'sendTelemetryEvent'); + environmentManagerVersion = sinon.stub().resolves(parsePep440Version('2.0.0')); + packageManagerVersion = sinon.stub().resolves(parsePep440Version('25.1')); + scopedPackageManagerVersion = sinon.stub().resolves(parsePep440Version('2.1.0')); + sinon.stub(frameUtils, 'getCallingExtension').returns(extensionId); + sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + sinon.stub(windowApis, 'showQuickPick').resolves(undefined); + sinon.stub(settings, 'getDefaultEnvManagerSetting').returns(ownerId); + configuredPackage = sinon.stub(settings, 'getDefaultPkgManagerSetting').returns(packageId); + const projectChanges = new EventEmitter(); + const variablesChanged = new EventEmitter(); + disposables.push(projectChanges, variablesChanged); + projectManager = { + getProjects: () => projects, + get: (uri: Uri) => projects.find((candidate) => candidate.uri.toString() === uri.toString()), + onDidChangeProjects: projectChanges.event, + } as unknown as PythonProjectManager; + managers = new PythonEnvironmentManagers(projectManager); + type ApiArgs = ConstructorParameters; + api = new PythonEnvironmentApiImpl( + managers, + projectManager, + {} as ApiArgs[2], + {} as ApiArgs[3], + { onDidChangeEnvironmentVariables: variablesChanged.event } as unknown as ApiArgs[4], + disposables, + ); + envProbe = sinon.stub<[CapabilityContext], Promise>().resolves({ supported: true }); + pkgProbe = sinon.stub<[CapabilityContext], Promise>().resolves({ supported: true }); + scopedProbe = sinon.stub<[CapabilityContext], Promise>().resolves(scopedSupport); + }); + + teardown(() => { + try { + managers.dispose(); + disposables.forEach((item) => item.dispose()); + assert.ok((windowApis.showErrorMessage as sinon.SinonStub).notCalled); + assert.ok((windowApis.showQuickPick as sinon.SinonStub).notCalled); + assert.ok(getExtension.notCalled); + } finally { + sinon.restore(); + } + }); + + function registerOwner(overrides: Partial = {}): Disposable { + const provider: EnvironmentManager = { + name: 'owner', + preferredPackageManagerId: packageId, + refresh: async () => {}, + getEnvironments: async () => [], + get: async () => undefined, + set: async () => {}, + resolve: async () => undefined, + getVersion: environmentManagerVersion, + capabilities: { ...allSupportedEnvironmentCapabilities, [EnvironmentCapability.List]: envProbe }, + ...overrides, + }; + return api.registerEnvironmentManager(provider, { extensionId }); + } + + function packageProvider(name = 'packages'): PackageManager { + return { + name, + manage: async () => {}, + refresh: async () => {}, + getPackages: async () => [], + getVersion: packageManagerVersion, + capabilities: { ...allSupportedPackageCapabilities, [PackageCapability.List]: pkgProbe }, + }; + } + + function registerPackages(scoped = false, name = 'packages'): sinon.SinonStub | undefined { + const provider = packageProvider(name); + const create = scoped ? sinon.stub().callsFake(() => ({ + ...packageProvider(name), + getVersion: scopedPackageManagerVersion, + capabilities: { ...allSupportedPackageCapabilities, [PackageCapability.List]: scopedProbe }, + })) : undefined; + api.registerPackageManager({ ...provider, createForProject: create }, { extensionId }); + return create; + } + + test('queries only the explicit registered environment manager', async () => { + registerOwner(); + const context = { environment, project, scope: project.uri }; + assert.deepStrictEqual(await api.getEnvironmentManagerCapability(ownerId, EnvironmentCapability.List, context), { + supported: true, + }); + await clock.tickAsync(0); + assert.ok(envProbe.calledOnceWithExactly(context)); + assert.ok(sendTelemetry.calledWithExactly(EventNames.MANAGER_CAPABILITY_QUERY, undefined, { + managerId: ownerId, managerKind: 'environment', managerVersion: '2.0.0', + capability: EnvironmentCapability.List, outcome: 'supported', + isLegacy: false, + })); + assert.ok(environmentManagerVersion.calledOnceWithExactly()); + assert.strictEqual(clock.countTimers(), 0); + }); + + test('registered legacy providers infer supported and unsupported capabilities without advertised checks', async () => { + registerOwner({ capabilities: undefined }); + api.registerPackageManager({ ...packageProvider(), capabilities: undefined }, { extensionId }); + const supported = { supported: true, isLegacy: true }; + const unsupported = { supported: false, reason: l10n.t('Capability not implemented'), isLegacy: true }; + + assert.deepStrictEqual(await api.getEnvironmentManagerCapability(ownerId, EnvironmentCapability.List), supported); + assert.deepStrictEqual(await api.getEnvironmentManagerCapability(ownerId, EnvironmentCapability.Create), unsupported); + assert.deepStrictEqual(await api.getPackageManagerCapability(environment, PackageCapability.Install), supported); + assert.deepStrictEqual(await api.getPackageManagerCapability(environment, PackageCapability.ListDirect), unsupported); + assert.ok(envProbe.notCalled && pkgProbe.notCalled); + await clock.tickAsync(0); + const events = sendTelemetry.withArgs(EventNames.MANAGER_CAPABILITY_QUERY); + assert.strictEqual(events.callCount, 4); + assert.ok(events.getCalls().every((call) => call.args[2].isLegacy === true)); + }); + + test('version lookup failures are logged without changing support results or probe errors', async () => { + registerOwner(); + const versionError = new Error('Version lookup failed'); + const probeError = new Error('Probe failed'); + const trace = sinon.stub(logging, 'traceError'); + environmentManagerVersion.rejects(versionError); + + assert.deepStrictEqual(await api.getEnvironmentManagerCapability(ownerId, EnvironmentCapability.List), { supported: true }); + envProbe.rejects(probeError); + await assert.rejects(api.getEnvironmentManagerCapability(ownerId, EnvironmentCapability.List), (error) => error === probeError); + await clock.tickAsync(0); + + const events = sendTelemetry.getCalls().filter((call) => call.args[0] === EventNames.MANAGER_CAPABILITY_QUERY); + assert.deepStrictEqual(events.map((call) => [call.args[2].managerVersion, call.args[2].outcome]), [ + ['unknown', 'supported'], ['unknown', 'error'], + ]); + assert.strictEqual(trace.callCount, 2); + assert.strictEqual(trace.firstCall.args[1], versionError); + }); + + test('stalled version lookups cannot delay results or errors and emit unknown after one second', async () => { + registerOwner(); + registerPackages(); + const version = createDeferred(); + environmentManagerVersion.returns(version.promise); + packageManagerVersion.returns(version.promise); + const trace = sinon.stub(logging, 'traceError'); + const events = sendTelemetry.withArgs(EventNames.MANAGER_CAPABILITY_QUERY); + + assert.deepStrictEqual(await api.getEnvironmentManagerCapability(ownerId, EnvironmentCapability.List), { supported: true }); + const probeError = new Error('Probe failed'); + envProbe.rejects(probeError); + await assert.rejects(api.getEnvironmentManagerCapability(ownerId, EnvironmentCapability.List), (error) => error === probeError); + assert.deepStrictEqual(await api.getPackageManagerCapability(environment, PackageCapability.List), { supported: true }); + assert.ok(events.notCalled); + + await clock.tickAsync(999); + assert.ok(events.notCalled); + await clock.tickAsync(1); + assert.deepStrictEqual(events.getCalls().map((call) => [ + call.args[2].managerKind, call.args[2].managerVersion, call.args[2].outcome, + ]), [ + ['environment', 'unknown', 'supported'], + ['environment', 'unknown', 'error'], + ['package', 'unknown', 'supported'], + ]); + assert.strictEqual(trace.callCount, 3); + assert.strictEqual(clock.countTimers(), 0); + + version.reject(new Error('Late version failure')); + await clock.tickAsync(0); + assert.strictEqual(events.callCount, 3); + assert.strictEqual(trace.callCount, 3); + }); + + test('rejects contradictory environment ownership before probing', async () => { + registerOwner(); + await assert.rejects(api.getEnvironmentManagerCapability('other:manager', EnvironmentCapability.List, { environment })); + assert.ok(envProbe.notCalled); + }); + + test('environment and package availability follows registration and disposal', async () => { + const support = await api.getEnvironmentManagerCapability(ownerId, EnvironmentCapability.List); + assert.ok(!support.supported && support.reason.includes(ownerId)); + assert.ok(sendTelemetry.calledWithExactly(EventNames.MANAGER_CAPABILITY_QUERY, undefined, { + managerId: 'none', managerKind: 'environment', managerVersion: 'unknown', + capability: EnvironmentCapability.List, outcome: 'unavailable', + isLegacy: false, + })); + assert.ok(environmentManagerVersion.notCalled); + assert.ok(envProbe.notCalled); + assert.strictEqual(clock.countTimers(), 0); + assert.strictEqual((await api.getPackageManagerCapability(environment, PackageCapability.List)).supported, false); + const registration = registerOwner(); + assert.strictEqual((await api.getPackageManagerCapability(environment, PackageCapability.List)).supported, false); + const packageRegistration = api.registerPackageManager(packageProvider(), { extensionId }); + assert.deepStrictEqual(await api.getEnvironmentManagerCapability(ownerId, EnvironmentCapability.List), { + supported: true, + }); + assert.strictEqual((await api.getPackageManagerCapability(environment, PackageCapability.List)).supported, true); + await clock.tickAsync(0); + envProbe.resetHistory(); + pkgProbe.resetHistory(); + packageRegistration.dispose(); + assert.strictEqual((await api.getPackageManagerCapability(environment, PackageCapability.List)).supported, false); + registration.dispose(); + assert.strictEqual((await api.getEnvironmentManagerCapability(ownerId, EnvironmentCapability.List)).supported, false); + assert.ok(envProbe.notCalled && pkgProbe.notCalled); + }); + + test('provider probe failures reach the public caller', async () => { + registerOwner(); + registerPackages(); + const failure = new Error('Provider probe failed'); + envProbe.rejects(failure); + pkgProbe.rejects(failure); + await assert.rejects(api.getEnvironmentManagerCapability(ownerId, EnvironmentCapability.List), (error) => error === failure); + await assert.rejects(api.getPackageManagerCapability(environment, PackageCapability.List), (error) => error === failure); + }); + + test('explicit project selects its configured scoped provider without a registered owner', async () => { + configuredPackage.returns(`${extensionId}:configured`); + const create = registerPackages(true, 'configured'); + assert.strictEqual(await api.getPackageManagerCapability(environment, PackageCapability.List, project), scopedSupport); + await clock.tickAsync(0); + assert.ok(create?.calledOnceWithExactly(project)); + assert.ok(scopedProbe.calledOnceWithExactly({ environment, project })); + assert.ok(pkgProbe.notCalled); + assert.ok(configuredPackage.calledWithExactly(projectManager, project.uri)); + assert.ok(sendTelemetry.calledWithExactly(EventNames.MANAGER_CAPABILITY_QUERY, undefined, { + managerId: `${extensionId}:configured`, managerKind: 'package', managerVersion: '2.1.0', + capability: PackageCapability.List, outcome: 'unsupported', + isLegacy: false, + })); + assert.ok(scopedPackageManagerVersion.calledOnceWithExactly(environment)); + assert.ok(packageManagerVersion.notCalled && environmentManagerVersion.notCalled); + }); + + test('file project matching respects platform casing and routes the registered URI', async () => { + const isWindows = sinon.stub(platformUtils, 'isWindows').returns(false); + const create = registerPackages(true); + const equivalentProject = { ...project, uri: project.uri.with({ path: project.uri.path.toUpperCase() }) }; + assert.strictEqual((await api.getPackageManagerCapability(environment, PackageCapability.List, equivalentProject)).supported, false); + isWindows.returns(true); + for (const uri of [ + project.uri.with({ scheme: 'vscode-remote', authority: 'ssh-remote+other-host' }), + project.uri.with({ authority: 'other-host' }), + ]) { + assert.strictEqual((await api.getPackageManagerCapability(environment, PackageCapability.List, { ...project, uri })).supported, false); + } + assert.ok(create?.notCalled); + + assert.strictEqual(await api.getPackageManagerCapability(environment, PackageCapability.List, equivalentProject), scopedSupport); + assert.ok(create?.calledOnceWithExactly(project)); + assert.ok(configuredPackage.calledWithExactly(projectManager, project.uri)); + assert.ok(scopedProbe.calledOnceWithExactly({ environment, project })); + await clock.tickAsync(0); + }); + + test('file project matching resolves equivalent paths before routing', async () => { + const create = registerPackages(true); + for (const suffix of ['/', '/./']) { + const equivalentProject = { ...project, uri: project.uri.with({ path: `${project.uri.path}${suffix}` }) }; + assert.strictEqual(await api.getPackageManagerCapability(environment, PackageCapability.List, equivalentProject), scopedSupport); + } + assert.ok(create?.calledOnceWithExactly(project)); + assert.ok(scopedProbe.calledTwice); + assert.ok(scopedProbe.alwaysCalledWithExactly({ environment, project })); + }); + + test('an untracked explicit project never probes a root or a containing project provider', async () => { + projects = []; + const create = registerPackages(true); + assert.strictEqual((await api.getPackageManagerCapability(environment, PackageCapability.List, project)).supported, false); + + projects = [project]; + const nestedProject: PythonProject = { name: 'nested', uri: Uri.joinPath(project.uri, 'nested') }; + sinon.stub(projectManager, 'get').returns(project); + assert.strictEqual(projectManager.get(nestedProject.uri), project); + assert.strictEqual((await api.getPackageManagerCapability(environment, PackageCapability.List, nestedProject)).supported, false); + assert.ok(create?.notCalled); + assert.ok(pkgProbe.notCalled); + assert.ok(scopedProbe.notCalled); + }); + + test('a missing configured provider cannot fall back to the preferred root', async () => { + registerOwner(); + const create = registerPackages(true); + configuredPackage.returns(`${extensionId}:missing`); + const support = await api.getPackageManagerCapability(environment, PackageCapability.List, project); + assert.ok(!support.supported && support.reason.length > 0); + assert.ok(create?.notCalled); + assert.ok(pkgProbe.notCalled); + }); + + test('project factory errors reject rather than falling back to the root', async () => { + const create = registerPackages(true); + const failure = new Error('project factory failed'); + create?.throws(failure); + await assert.rejects(api.getPackageManagerCapability(environment, PackageCapability.List, project), (e) => e === failure); + assert.ok(pkgProbe.notCalled); + assert.ok(scopedProbe.notCalled); + }); +}); diff --git a/src/test/integration/managerCapabilities.integration.test.ts b/src/test/integration/managerCapabilities.integration.test.ts new file mode 100644 index 000000000..527449b36 --- /dev/null +++ b/src/test/integration/managerCapabilities.integration.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import { ConfigurationTarget, Disposable, extensions, Uri, workspace } from 'vscode'; +import { + Capabilities, + EnvironmentManager, + EnvironmentManagerCapability, + PackageManager, + PackageManagerCapability, + PythonEnvironment, + PythonEnvironmentApi, + EnvironmentCapability, + PackageCapability, +} from '../../api'; +import { ENVS_EXTENSION_ID } from '../constants'; + +// Minimal advertisements used by these tests. Only the keys a test cares about need overriding; +// every other key resolves unsupported, matching the no-defaults contract providers must honor. +const noPackageCapabilities: Capabilities = {} as Capabilities; +const noEnvironmentCapabilities: Capabilities = + {} as Capabilities; + +suite('Manager capabilities integration', function () { + this.timeout(60_000); + let api: PythonEnvironmentApi; + let environment: PythonEnvironment; + let packages: PackageManager; + let environmentCapabilities: Capabilities; + const disposables: Disposable[] = []; + + suiteSetup(async () => { + const extension = extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, 'Extension not found'); + api = extension.isActive ? extension.exports : await extension.activate(); + assert.ok(api, 'API not available'); + }); + + setup(() => { + environmentCapabilities = noEnvironmentCapabilities; + packages = { + name: 'capability-test-packages', + capabilities: noPackageCapabilities, + manage: async () => assert.fail('Capability queries must not manage packages'), + refresh: async () => {}, + getPackages: async () => [], + }; + const manager: EnvironmentManager = { + name: 'capability-test-environment', + get capabilities() { + return environmentCapabilities; + }, + preferredPackageManagerId: `${ENVS_EXTENSION_ID}:${packages.name}`, + refresh: async () => {}, + getEnvironments: async () => [], + get: async () => undefined, + set: async () => {}, + resolve: async () => undefined, + }; + disposables.push( + api.registerPackageManager(packages, { extensionId: ENVS_EXTENSION_ID }), + api.registerEnvironmentManager(manager, { extensionId: ENVS_EXTENSION_ID }), + ); + const uri = Uri.file('.'); + environment = api.createPythonEnvironmentItem( + { + name: 'capability-test', + displayName: 'Capability test', + displayPath: uri.fsPath, + version: '3.12', + environmentPath: uri, + execInfo: { run: { executable: 'python' } }, + sysPrefix: uri.fsPath, + }, + manager, + ); + }); + + teardown(() => { + disposables.splice(0).reverse().forEach((disposable) => disposable.dispose()); + }); + + test('environment queries resolve unsupported when not advertised, and honor provider overrides', async () => { + assert.deepStrictEqual( + await api.getEnvironmentManagerCapability(environment.envId.managerId, EnvironmentCapability.List), + { supported: false, reason: 'Capability not implemented' }, + ); + const unsupported = { supported: false, reason: 'Disabled in this scope' } as const; + environmentCapabilities = { + ...noEnvironmentCapabilities, + [EnvironmentCapability.List]: async (context) => { + assert.strictEqual(context.scope, 'global'); + return unsupported; + }, + }; + assert.deepStrictEqual( + await api.getEnvironmentManagerCapability(environment.envId.managerId, EnvironmentCapability.List, { scope: 'global' }), + unsupported, + ); + }); + + test('package queries reach the configured project-bound provider, not its root', async () => { + const folder = workspace.workspaceFolders?.[0]; + assert.ok(folder, 'The integration workspace must be open'); + const project = api.getPythonProject(folder.uri); + assert.ok(project, 'The workspace project must be registered'); + const scopedSupport = { supported: false, reason: 'Project-bound provider response' } as const; + const provider: PackageManager = { + ...packages, + name: 'capability-test-scoped', + capabilities: { + ...noPackageCapabilities, + [PackageCapability.List]: async () => assert.fail('The unbound root must not be queried'), + }, + createForProject: (boundProject) => ({ + ...packages, + name: 'capability-test-scoped', + capabilities: { + ...noPackageCapabilities, + [PackageCapability.List]: async (context) => { + assert.strictEqual(boundProject.uri.toString(), project.uri.toString()); + assert.strictEqual(context.project, boundProject); + assert.strictEqual(context.environment, environment); + return scopedSupport; + }, + }, + }), + }; + disposables.push(api.registerPackageManager(provider, { extensionId: ENVS_EXTENSION_ID })); + const config = workspace.getConfiguration('python-envs', folder.uri); + const target = workspace.workspaceFolders?.length === 1 ? ConfigurationTarget.Workspace : ConfigurationTarget.WorkspaceFolder; + const inspected = config.inspect('pythonProjects'); + const previous = target === ConfigurationTarget.Workspace ? inspected?.workspaceValue : inspected?.workspaceFolderValue; + try { + await config.update('pythonProjects', [ + { path: '.', packageManager: `${ENVS_EXTENSION_ID}:${provider.name}` }, + ...config.get('pythonProjects', []), + ], target); + const nestedProject = { name: 'unregistered', uri: Uri.joinPath(project.uri, 'unregistered-capability-project') }; + assert.strictEqual(api.getPythonProject(nestedProject.uri)?.uri.toString(), project.uri.toString()); + assert.deepStrictEqual( + await api.getPackageManagerCapability(environment, PackageCapability.List, nestedProject), + { supported: false, reason: 'No package manager is available for the environment and project context.' }, + ); + assert.deepStrictEqual( + await api.getPackageManagerCapability(environment, PackageCapability.List, project), + scopedSupport, + ); + } finally { + await config.update('pythonProjects', previous, target); + } + }); + +}); diff --git a/src/test/legacyCapabilities.unit.test.ts b/src/test/legacyCapabilities.unit.test.ts new file mode 100644 index 000000000..68c8f118e --- /dev/null +++ b/src/test/legacyCapabilities.unit.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import { l10n } from 'vscode'; +import type { EnvironmentManagerCapability, PackageManagerCapability } from '../capabilities'; +import { EnvironmentCapability, PackageCapability } from '../capabilities'; +import { getLegacyEnvironmentManagerCapability, getLegacyPackageManagerCapability } from '../legacyCapabilities'; +import type { EnvironmentManager, PackageManager } from '../types'; + +function unexpectedOperation(): never { + throw new Error('Legacy capability inference must not invoke operations'); +} + +const legacySupported = { supported: true, isLegacy: true }; +const legacyUnsupported = { supported: false, reason: l10n.t('Capability not implemented'), isLegacy: true }; + +suite('Legacy manager capabilities', () => { + test('package capabilities require their callable methods without executing them', async () => { + const manager: PackageManager = { + name: 'legacy', + manage: unexpectedOperation, + refresh: unexpectedOperation, + getPackages: unexpectedOperation, + getDirectPackageNames: unexpectedOperation, + getPackageAvailableVersions: unexpectedOperation, + }; + const methods: Record = { + [PackageCapability.List]: ['getPackages', 'refresh'], + [PackageCapability.Install]: ['manage'], + [PackageCapability.Uninstall]: ['manage'], + [PackageCapability.Upgrade]: ['manage'], + [PackageCapability.ListDirect]: ['getDirectPackageNames'], + [PackageCapability.FetchAvailableVersions]: ['getPackageAvailableVersions'], + }; + + for (const capability of Object.values(PackageCapability)) { + assert.deepStrictEqual(await getLegacyPackageManagerCapability(manager, capability), legacySupported); + for (const method of methods[capability]) { + for (const value of [undefined, false]) { + Reflect.set(manager, method, value); + assert.deepStrictEqual( + await getLegacyPackageManagerCapability(manager, capability), + legacyUnsupported, + ); + } + Reflect.set(manager, method, unexpectedOperation); + } + } + assert.deepStrictEqual( + await Reflect.apply(getLegacyPackageManagerCapability, undefined, [manager, 'unknown']), + legacyUnsupported, + ); + }); + + test('environment capabilities require their callable methods without executing them', async () => { + const manager: EnvironmentManager = { + name: 'legacy', + preferredPackageManagerId: 'legacy:packages', + refresh: unexpectedOperation, + getEnvironments: unexpectedOperation, + resolve: unexpectedOperation, + get: unexpectedOperation, + set: unexpectedOperation, + create: unexpectedOperation, + remove: unexpectedOperation, + }; + const methods: Record = { + [EnvironmentCapability.List]: ['getEnvironments', 'refresh'], + [EnvironmentCapability.Resolve]: ['resolve'], + [EnvironmentCapability.GetSelected]: ['get'], + [EnvironmentCapability.SetSelected]: ['set'], + [EnvironmentCapability.Create]: ['create'], + [EnvironmentCapability.Remove]: ['remove'], + }; + + for (const capability of Object.values(EnvironmentCapability)) { + assert.deepStrictEqual(await getLegacyEnvironmentManagerCapability(manager, capability), legacySupported); + for (const method of methods[capability]) { + for (const value of [undefined, false]) { + Reflect.set(manager, method, value); + assert.deepStrictEqual( + await getLegacyEnvironmentManagerCapability(manager, capability), + legacyUnsupported, + ); + } + Reflect.set(manager, method, unexpectedOperation); + } + } + assert.deepStrictEqual( + await Reflect.apply(getLegacyEnvironmentManagerCapability, undefined, [manager, 'unknown']), + legacyUnsupported, + ); + }); +}); diff --git a/src/test/managers/builtin/pipPackageManager.unit.test.ts b/src/test/managers/builtin/pipPackageManager.unit.test.ts index c1765b020..549fd9a26 100644 --- a/src/test/managers/builtin/pipPackageManager.unit.test.ts +++ b/src/test/managers/builtin/pipPackageManager.unit.test.ts @@ -7,6 +7,7 @@ import * as sinon from 'sinon'; import { LogOutputChannel, Uri } from 'vscode'; import { Package, + PackageCapability, PythonEnvironment, PythonEnvironmentApi, isPackageVersionLookupNotSupportedError, @@ -97,6 +98,15 @@ suite('PipPackageManager', () => { manager.getPackageAvailableVersions(environment, 'requests'), isPackageVersionLookupNotSupportedError, ); + const support = await manager.capabilities[PackageCapability.FetchAvailableVersions]({ environment }); + assert.strictEqual(support.supported, false); + if (!support.supported) { + assert.strictEqual( + support.reason, + 'Package version lookup requires pip 21.2 or newer; the environment has pip 20.3.4.', + ); + } + assert.strictEqual((await manager.capabilities[PackageCapability.FetchAvailableVersions]({})).supported, false); }); test('propagates version lookup command failures for supported pip', async () => { @@ -104,7 +114,7 @@ suite('PipPackageManager', () => { const environment = createEnvironment(); const lookupError = new Error('pip index failed'); sinon.stub(helpers, 'shouldUseUv').resolves(false); - const runPython = sinon.stub(helpers, 'runPython'); + const runPython = sinon.stub(helpers, 'runPython').resolves('pip 25.1 from /path/to/pip (python 3.12)'); runPython.onFirstCall().resolves('pip 25.1 from /path/to/pip (python 3.12)'); runPython.onSecondCall().rejects(lookupError); @@ -112,6 +122,9 @@ suite('PipPackageManager', () => { manager.getPackageAvailableVersions(environment, 'requests'), (error: unknown) => error === lookupError, ); + assert.deepStrictEqual(await manager.capabilities[PackageCapability.FetchAvailableVersions]({ environment }), { + supported: true, + }); }); test('propagates pip version command failures during version lookup', async () => { @@ -125,6 +138,10 @@ suite('PipPackageManager', () => { manager.getPackageAvailableVersions(environment, 'requests'), (error: unknown) => error === versionError, ); + await assert.rejects( + manager.capabilities[PackageCapability.FetchAvailableVersions]({ environment }), + (error: unknown) => error === versionError, + ); }); test('rejects version lookup when pip version output cannot be parsed', async () => { @@ -197,9 +214,7 @@ suite('PipPackageManager', () => { const shouldUseUv = sinon.stub(helpers, 'shouldUseUv').resolves(true); const getUvExecutable = sinon.stub(helpers, 'getUvExecutable').resolves('workspace-uv'); const runPython = sinon.stub(helpers, 'runPython'); - const runUV = sinon - .stub(helpers, 'runUV') - .resolves(JSON.stringify({ versions: ['2.32.5', '2.31.0'] })); + const runUV = sinon.stub(helpers, 'runUV').resolves(JSON.stringify({ versions: ['2.32.5', '2.31.0'] })); const versions = await manager.getPackageAvailableVersions(environment, 'requests'); @@ -212,9 +227,7 @@ suite('PipPackageManager', () => { undefined, ), ); - assert.ok( - getUvExecutable.calledOnceWithExactly(manager.log, environment.environmentPath.fsPath, undefined), - ); + assert.ok(getUvExecutable.calledOnceWithExactly(manager.log, environment.environmentPath.fsPath, undefined)); assert.strictEqual(runUV.firstCall.args[5], 'workspace-uv'); assert.deepStrictEqual( versions.map((version) => version.public), @@ -222,23 +235,35 @@ suite('PipPackageManager', () => { ); }); - test('rejects package management for Python 2 environments', async () => { + test('advertises and enforces the Python 2 package management restriction', async () => { const shouldUseUvStub = sinon.stub(helpers, 'shouldUseUv'); const environment = createMockPythonEnvironment({ envPath: path.join(process.cwd(), 'python2'), version: '2.7.18', managerId: 'ms-python.python:venv', }); - const manager = new PipPackageManager( - {} as PythonEnvironmentApi, - {} as LogOutputChannel, - {} as VenvManager, - ); - - await assert.rejects( - manager.manage(environment, { install: ['flask'] }), - /Python 2\.\* is not supported \(deprecated\)/, - ); + const manager = new PipPackageManager({} as PythonEnvironmentApi, {} as LogOutputChannel, {} as VenvManager); + + for (const capability of [PackageCapability.Install, PackageCapability.Uninstall, PackageCapability.Upgrade]) { + assert.deepStrictEqual(await manager.capabilities[capability]({ environment }), { + supported: false, + reason: 'Python 2.* is not supported (deprecated)', + }); + assert.deepStrictEqual( + await manager.capabilities[capability]({ + environment: { ...environment, version: '3.12.0' }, + }), + { supported: true }, + ); + assert.deepStrictEqual(await manager.capabilities[capability]({}), { supported: true }); + } + for (const options of [ + { install: ['flask'] }, + { uninstall: ['flask'] }, + { install: ['flask'], upgrade: true }, + ]) { + await assert.rejects(manager.manage(environment, options), /Python 2\.\* is not supported \(deprecated\)/); + } assert.strictEqual(shouldUseUvStub.callCount, 0); }); diff --git a/src/test/managers/builtinCapabilities.unit.test.ts b/src/test/managers/builtinCapabilities.unit.test.ts new file mode 100644 index 000000000..86ef3ebc8 --- /dev/null +++ b/src/test/managers/builtinCapabilities.unit.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { Disposable, l10n, Memento, Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironmentApi, PythonProject } from '../../api'; +import { + CapabilityContext, + resolveEnvironmentManagerCapability as environmentCapability, + EnvironmentCapability, + resolvePackageManagerCapability as packageCapability, + PackageCapability, + Support, +} from '../../capabilities'; +import * as metadata from '../../common/inlineScript/metadata'; +import * as workspaceApis from '../../common/workspace.apis'; +import { InlineScriptEnvManager } from '../../managers/builtin/inlineScript/envManager'; +import { SysPythonManager } from '../../managers/builtin/sysPythonManager'; +import { NativePythonFinder } from '../../managers/common/nativePythonFinder'; +import { PoetryManager } from '../../managers/poetry/poetryManager'; +import { PoetryPackageManager } from '../../managers/poetry/poetryPackageManager'; +import { createMockLogOutputChannel } from '../mocks/helper'; +import { createMockPythonEnvironment } from '../mocks/pythonEnvironment'; + +suite('Built-in manager capabilities', () => { + const root = path.join(__dirname, 'capability-fixtures'); + const project: PythonProject = { name: 'project', uri: Uri.file(path.join(root, 'project')) }; + let api: PythonEnvironmentApi; + let log: ReturnType; + const disposables: Disposable[] = []; + + function assertUnsupported(result: Support): void { + assert.strictEqual(result.supported, false); + if (!result.supported) { + assert.ok(result.reason.length > 0); + } + } + + setup(() => { + api = { + getPythonProject: sinon.stub().throws(new Error('Unexpected project lookup')), + getEnvironments: sinon.stub().rejects(new Error('Unexpected environment discovery')), + } as unknown as PythonEnvironmentApi; + log = createMockLogOutputChannel(); + }); + + teardown(() => { + try { + disposables.splice(0).forEach((disposable) => disposable.dispose()); + } finally { + sinon.restore(); + } + }); + + test('system-owned local environments report manager-level removal support without classifying the installation', async () => { + const manager = new SysPythonManager({} as NativePythonFinder, api, log); + const environment = createMockPythonEnvironment({ + envPath: path.join(root, '.venv'), + managerId: 'test:system', + }); + assert.deepStrictEqual(await environmentCapability(manager, EnvironmentCapability.Remove, { environment }), { + supported: false, + reason: l10n.t('This environment manager does not support removing environments.'), + }); + }); + + suite('Inline-script creation', () => { + let manager: InlineScriptEnvManager; + let readMetadata: sinon.SinonStub; + let stateUpdate: sinon.SinonStub; + const script = Uri.file(path.join(root, 'script.py')); + + setup(() => { + sinon.stub(workspaceApis, 'onDidDeleteFiles').returns(new Disposable(() => {})); + sinon.stub(workspaceApis, 'onDidRenameFiles').returns(new Disposable(() => {})); + stateUpdate = sinon.stub().resolves(); + manager = new InlineScriptEnvManager( + {} as NativePythonFinder, + api, + {} as EnvironmentManager, + Uri.file(path.join(root, 'storage')), + log, + { get: sinon.stub().returns(undefined), update: stateUpdate, keys: () => [] } as Memento, + ); + disposables.push(manager); + readMetadata = sinon.stub(metadata, 'readInlineScriptMetadataFromFile').resolves({ + requiresPython: '>=3.11', + dependencies: ['requests'], + range: { start: 0, end: 40 }, + }); + }); + + teardown(() => { + assert.ok(stateUpdate.notCalled); + }); + + test('accepts a single local script with metadata without a quick-create UI hook', async () => { + const create = sinon.stub(manager, 'create').throws(new Error('Unexpected creation')); + assert.strictEqual((manager as EnvironmentManager).quickCreateConfig, undefined); + for (const scope of [script, [script]]) { + assert.deepStrictEqual(await environmentCapability(manager, EnvironmentCapability.Create, { scope }), { + supported: true, + }); + } + assert.ok(readMetadata.alwaysCalledWithExactly(script)); + assert.ok(create.notCalled); + }); + + test('rejects unsupported scopes before reading metadata', async () => { + const scopes: CapabilityContext['scope'][] = [ + undefined, + 'global', + 'all', + [], + [script, script], + Uri.parse('untitled:script.py'), + ]; + for (const scope of scopes) { + assertUnsupported(await environmentCapability(manager, EnvironmentCapability.Create, { scope })); + } + assert.ok(readMetadata.notCalled); + }); + + test('missing metadata disables creation', async () => { + readMetadata.resolves(undefined); + assertUnsupported(await environmentCapability(manager, EnvironmentCapability.Create, { scope: script })); + }); + + test('unexpected metadata probe failures reject', async () => { + const failure = new Error('Metadata probe failed'); + readMetadata.rejects(failure); + await assert.rejects( + environmentCapability(manager, EnvironmentCapability.Create, { scope: script }), + (error) => error === failure, + ); + }); + }); + + suite('Poetry project context', () => { + let manager: PoetryPackageManager; + const projectCapabilities = [ + PackageCapability.List, + PackageCapability.ListDirect, + PackageCapability.Install, + PackageCapability.Uninstall, + ] as const; + + setup(() => { + manager = new PoetryPackageManager(api, log, {} as PoetryManager); + disposables.push(manager); + }); + + test('a query context cannot substitute for a project-bound instance', async () => { + for (const key of projectCapabilities) { + assertUnsupported(await packageCapability(manager, key, { project })); + } + }); + + test('a project-bound instance supports its project-dependent capabilities', async () => { + const scoped = manager.createForProject(project); + disposables.push(scoped); + for (const key of projectCapabilities) { + assert.deepStrictEqual(await packageCapability(scoped, key), { supported: true }); + } + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index f3cfd72ae..7c8319641 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,13 @@ */ import type { Pep440Version } from '@renovatebot/pep440'; +import type { + Capabilities, + CapabilityContext, + EnvironmentManagerCapability, + PackageManagerCapability, + Support, +} from './capabilities.js'; import type { Disposable, Event, @@ -371,6 +378,13 @@ export interface RemoveEnvironmentOptions { * should focus on the documented contract rather than any specific caller. */ export interface EnvironmentManager { + /** + * Optional for legacy providers. When present, advertises every environment capability key. + * An omitted map uses legacy method inference. Missing keys in a supplied map resolve unsupported. + * Use instance-field arrow functions to retain manager state; checks must not prompt or mutate. + */ + readonly capabilities?: Capabilities; + /** * The name of the environment manager. Allowed characters (a-z, A-Z, 0-9, -, _). */ @@ -655,6 +669,13 @@ export interface DidChangePackagesEventArgs { * Interface representing a package manager. */ export interface PackageManager { + /** + * Optional for legacy providers. When present, advertises every package capability key. + * An omitted map uses legacy method inference. Missing keys in a supplied map resolve unsupported. + * Use instance-field arrow functions to retain manager state; checks must not prompt or mutate. + */ + readonly capabilities?: Capabilities; + /** * The name of the package manager. Allowed characters (a-z, A-Z, 0-9, -, _). */ @@ -1089,6 +1110,23 @@ export interface PythonEnvironmentManagementApi { } export interface PythonEnvironmentsApi { + /** + * Queries one explicitly identified manager without picking or aggregating managers. + * Only currently registered managers are queried; no activation or registration wait occurs. + * @param managerId Registered environment-manager ID. + * @param capability Feature to query; general support does not validate operation arguments. + * @param context Optional scope/environment/project. An environment must belong to managerId. + * @returns Support, or a reason when the manager is missing or the feature unsupported. + * Unavailable managers can be queried again after registration. + * Unexpected lookup/probe errors and contradictory manager arguments reject. + * Feature-detect this method when supporting older installed extension runtimes. + */ + getEnvironmentManagerCapability( + managerId: string, + capability: EnvironmentManagerCapability, + context?: CapabilityContext, + ): Promise; + /** * Initiates a refresh of Python environments within the specified scope. * @param scope - The scope within which to search for environments. @@ -1180,6 +1218,24 @@ export interface GetPackageAvailableVersionsOptions { } export interface PythonPackageGetterApi { + /** + * Queries the package manager selected for an environment or explicit project, without prompting. + * Only currently registered managers are queried; no activation or registration wait occurs. + * @param environment Environment used for the support check and default manager routing. + * @param capability Feature to query; support does not guarantee a particular request succeeds. + * @param project Explicit registered project for project-specific manager selection. + * Unregistered projects return unavailable instead of using a containing project's provider. + * If omitted, existing routing must identify a unique project when the selected manager requires one. + * @returns Support, or a reason when routing is unresolved/ambiguous or the feature unsupported. + * Unavailable managers can be queried again after registration. + * Unexpected lookup/probe errors reject. Feature-detect on older extension runtimes. + */ + getPackageManagerCapability( + environment: PythonEnvironment, + capability: PackageManagerCapability, + project?: PythonProject, + ): Promise; + /** * Refresh the list of packages in a Python Environment. *