Skip to content

Commit 86c5747

Browse files
edvilmeCopilotCopilot
authored
API: Advertise EnvironmentManager and PackageManager capabilities (#1874)
Contributes to #1837. ## Summary Adds manager-agnostic feature detection for environment and package managers, with flat capability keys exposed through `EnvironmentCapability` and `PackageCapability`. - Managers may advertise contextual, read-only checks returning `Support`: supported, or unsupported with a reason. Support is advisory; unexpected probe failures reject rather than being treated as unsupported. - Capability maps are optional for legacy compatibility. Omitted maps use the new method-presence inference helpers and return `isLegacy: true`. Supplied maps must advertise every catalog key; missing keys return unsupported without legacy inference. - Adds public `getEnvironmentManagerCapability` and `getPackageManagerCapability` queries. Queries use currently registered providers without activation, registration waits, or prompts. Package queries honor project-bound routing; unregistered explicit projects return unavailable rather than using a containing project's provider. - Built-in managers advertise extension-supported behavior, including inline-script creation requirements, Poetry project binding, pip version-lookup support, and the Python 2 restriction on pip installation, uninstallation, and upgrades. - Adds capability-query telemetry with the routed manager, tool version, outcome, and legacy flag. Version collection runs asynchronously with a one-second timeout and cannot delay support results or probe errors. - Bumps `@vscode/python-environments` to **1.7.0** and updates public exports, package source copying, API-change checks, and documentation. ## Validation - Lint and TypeScript checks passed. - Extension and API package builds passed; modern and legacy API consumer type-checks passed. - **2,883 unit tests passed**, with 7 pending. - **2 capability integration tests passed**, including production nested-project routing coverage. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Copilot-Session: 408a66e2-1fd6-432d-9ce9-fa2cff9313ea
1 parent 9f1e63d commit 86c5747

35 files changed

Lines changed: 1947 additions & 103 deletions

‎.github/workflows/pr-file-check.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ jobs:
4949
src/api.ts
5050
src/types.ts
5151
src/publicErrors.ts
52+
src/capabilities.ts
53+
src/legacyCapabilities.ts
5254
file-pattern: 'api/package.json'
5355
skip-label: 'skip api version'
5456
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:
6062
src/api.ts
6163
src/types.ts
6264
src/publicErrors.ts
65+
src/capabilities.ts
66+
src/legacyCapabilities.ts
6367
file-pattern: 'api/CHANGELOG.md'
6468
skip-label: 'skip api changelog'
6569
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:
7175
src/api.ts
7276
src/types.ts
7377
src/publicErrors.ts
78+
src/capabilities.ts
79+
src/legacyCapabilities.ts
7480
file-pattern: 'docs/README.md'
7581
skip-label: 'skip api docs'
7682
failure-message: 'The public API (${prereq-pattern}) was changed without updating ${file-pattern} (the ${skip-label} label can be used to pass this check)'

‎api/.gitignore‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
# Copied from ../src/api.ts, ../src/types.ts, ../src/publicErrors.ts by the publish pipeline
2-
# (build/azure-pipeline.npm.yml). These are the published package sources; they are produced
3-
# at publish time and intentionally NOT committed. src/api.ts, src/types.ts, and
4-
# src/publicErrors.ts are the single sources of truth.
5-
src/main.ts
6-
src/types.ts
7-
src/publicErrors.ts
1+
# Generated package sources copied from ../src by scripts/copy-sources.cjs.
2+
# The original files under ../src are the single sources of truth.
3+
/src/

‎api/CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ All notable changes to the `@vscode/python-environments` API package are documen
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.7.0]
9+
10+
### Added
11+
12+
- 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.
13+
- Raw-provider capability resolvers and routed extension API queries that report support without invoking manager operations or prompting.
14+
- 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.
15+
- Exported `EnvironmentCapability` and `PackageCapability` constant objects for named access to capability keys, with derived string-literal unions.
16+
- `getLegacyPackageManagerCapability` and `getLegacyEnvironmentManagerCapability` helpers that infer support from method presence without invoking operations. Resolvers use them automatically when a provider omits its capability map.
17+
- Optional `Support.isLegacy` metadata, set to true by legacy inference helpers and included in public capability-query telemetry.
18+
19+
### Notes
20+
21+
- Capability prerequisite checks have no cycle protection; providers must keep delegation chains acyclic.
22+
823
## [1.6.0]
924

1025
### Added

‎api/README.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,30 @@ export async function activate() {
3535
}
3636
```
3737

38+
## Manager capabilities
39+
40+
Capabilities report general manager support without invoking an operation:
41+
42+
```typescript
43+
import { PackageCapability, PythonEnvironments } from '@vscode/python-environments';
44+
45+
const api = await PythonEnvironments.api();
46+
const environment = await api.getEnvironment(undefined);
47+
if (environment) {
48+
const support = await api.getPackageManagerCapability(environment, PackageCapability.ListDirect);
49+
if (support.supported) {
50+
// Best-effort direct/transitive package classification is available.
51+
}
52+
}
53+
```
54+
55+
Manager advertisements are optional for legacy compatibility. Missing maps use
56+
method-presence inference and return `isLegacy: true`; missing keys in a supplied
57+
map report unsupported. Support is advisory, not a guarantee that an operation succeeds.
58+
59+
See the [capability guide](https://github.com/microsoft/vscode-python-environments/blob/main/docs/README.md#manager-capabilities)
60+
for the full catalog and provider authoring.
61+
3862
## Full API reference
3963

4064
📘 **[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.
5175
- [Extensibility](https://github.com/microsoft/vscode-python-environments/blob/main/docs/README.md#extensibility) - register your own environment manager, package manager, or project creator
5276

5377
See [`CHANGELOG.md`](https://github.com/microsoft/vscode-python-environments/blob/main/api/CHANGELOG.md) for API changes between versions.
54-

‎api/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎api/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@vscode/python-environments",
33
"description": "An API facade for the Python Environments extension in VS Code",
4-
"version": "1.6.0",
4+
"version": "1.7.0",
55
"author": {
66
"name": "Microsoft Corporation"
77
},

‎api/scripts/copy-sources.cjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const sources = [
1717
{ from: path.join(repoRoot, 'src', 'api.ts'), to: path.join(srcDir, 'main.ts') },
1818
{ from: path.join(repoRoot, 'src', 'types.ts'), to: path.join(srcDir, 'types.ts') },
1919
{ from: path.join(repoRoot, 'src', 'publicErrors.ts'), to: path.join(srcDir, 'publicErrors.ts') },
20+
{ from: path.join(repoRoot, 'src', 'capabilities.ts'), to: path.join(srcDir, 'capabilities.ts') },
21+
{ from: path.join(repoRoot, 'src', 'legacyCapabilities.ts'), to: path.join(srcDir, 'legacyCapabilities.ts') },
2022
];
2123

2224
fs.mkdirSync(srcDir, { recursive: true });

‎api/scripts/test-package.cjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ try {
9494
"};",
9595
'exports.__runtimeApi = runtimeApi;',
9696
'exports.extensions = { getExtension: () => extension };',
97+
'exports.l10n = { t: (message) => message };',
9798
].join('\n'),
9899
);
99100
const installedPackageJson = JSON.parse(fs.readFileSync(path.join(installedPackageRoot, 'package.json'), 'utf8'));

‎api/test/consumer.ts‎

Lines changed: 125 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,148 @@
11
import type {
2+
Capabilities,
3+
CapabilityContext,
4+
EnvironmentManager,
5+
EnvironmentManagerCapability,
26
PackageManager,
7+
PackageManagerCapability,
38
Pep440Version,
49
PythonEnvironment,
510
PythonEnvironmentApi,
611
PythonPackageGetterApi,
12+
Support,
713
} from '@vscode/python-environments';
814
import {
15+
EnvironmentCapability,
16+
getLegacyEnvironmentManagerCapability,
17+
getLegacyPackageManagerCapability,
918
isPackageVersionLookupNotSupportedError,
19+
PackageCapability,
1020
PackageVersionLookupNotSupportedError,
1121
PythonEnvironments,
22+
resolveEnvironmentManagerCapability,
23+
resolvePackageManagerCapability,
24+
supportedCapability,
1225
} from '@vscode/python-environments';
1326

1427
type Equal<Left, Right> =
1528
(<Value>() => Value extends Left ? 1 : 2) extends <Value>() => Value extends Right ? 1 : 2 ? true : false;
1629

30+
// Compile-only fixture shared by modern and legacy consumers; not executed.
31+
declare const api: PythonEnvironmentApi;
32+
declare const environment: PythonEnvironment;
33+
PythonEnvironments.api() satisfies Promise<PythonEnvironmentApi>;
34+
35+
// Package version lookup contracts.
1736
type AvailableVersionsReturn = ReturnType<PythonPackageGetterApi['getPackageAvailableVersions']>;
1837
type RefreshReturn = ReturnType<PackageManager['refresh']>;
1938

20-
const availableVersionsReturnIsExact: Equal<AvailableVersionsReturn, Promise<Pep440Version[] | undefined>> = true;
21-
const refreshReturnIsExact: Equal<RefreshReturn, Promise<void>> = true;
39+
true satisfies Equal<AvailableVersionsReturn, Promise<Pep440Version[] | undefined>>;
40+
true satisfies Equal<RefreshReturn, Promise<void>>;
2241

23-
declare const api: PythonPackageGetterApi;
24-
declare const environment: PythonEnvironment;
25-
const legacyAvailableVersions: Promise<Pep440Version[] | undefined> = api.getPackageAvailableVersions(
26-
environment,
27-
'example',
28-
);
29-
const explicitLegacyAvailableVersions: Promise<Pep440Version[] | undefined> = api.getPackageAvailableVersions(
30-
environment,
31-
'example',
32-
{ errorMode: 'legacy' },
33-
);
34-
const throwingAvailableVersions: Promise<Pep440Version[]> = api.getPackageAvailableVersions(environment, 'example', {
35-
errorMode: 'throw',
36-
});
37-
const runtimeApi: Promise<PythonEnvironmentApi> = PythonEnvironments.api();
38-
39-
// The unsupported-capability error is part of the public contract: it is constructible, extends
40-
// Error, and exposes a stable string-literal `code` discriminator.
42+
api.getPackageAvailableVersions(environment, 'example') satisfies Promise<Pep440Version[] | undefined>;
43+
api.getPackageAvailableVersions(environment, 'example', {
44+
errorMode: 'legacy',
45+
}) satisfies Promise<Pep440Version[] | undefined>;
46+
api.getPackageAvailableVersions(environment, 'example', { errorMode: 'throw' }) satisfies Promise<Pep440Version[]>;
47+
48+
// Public error construction, discriminator, and narrowing.
4149
const lookupError = new PackageVersionLookupNotSupportedError('unsupported');
42-
const lookupErrorIsError: Error = lookupError;
43-
const lookupErrorCodeIsExact: Equal<typeof lookupError.code, 'PackageVersionLookupNotSupported'> = true;
50+
lookupError satisfies Error;
51+
true satisfies Equal<typeof lookupError.code, 'PackageVersionLookupNotSupported'>;
4452

45-
// The type guard narrows unknown values via the stable discriminator (bundle-boundary safe).
4653
declare const maybeError: unknown;
47-
const guardNarrows: boolean = isPackageVersionLookupNotSupportedError(maybeError)
48-
? maybeError.code === 'PackageVersionLookupNotSupported'
49-
: false;
50-
51-
void availableVersionsReturnIsExact;
52-
void refreshReturnIsExact;
53-
void legacyAvailableVersions;
54-
void explicitLegacyAvailableVersions;
55-
void throwingAvailableVersions;
56-
void runtimeApi;
57-
void lookupErrorIsError;
58-
void lookupErrorCodeIsExact;
59-
void guardNarrows;
54+
if (isPackageVersionLookupNotSupportedError(maybeError)) {
55+
maybeError.code satisfies 'PackageVersionLookupNotSupported';
56+
}
57+
58+
// Legacy providers may omit advertisements; advertised maps must include every key without defaults.
59+
true satisfies Equal<typeof PackageCapability.ListDirect, 'packages.list.direct'>;
60+
true satisfies Equal<typeof PackageCapability.FetchAvailableVersions, 'packages.fetchversions'>;
61+
const fullPackageCapabilities: Capabilities<PackageManagerCapability> = {
62+
[PackageCapability.List]: supportedCapability,
63+
[PackageCapability.Install]: supportedCapability,
64+
[PackageCapability.Uninstall]: supportedCapability,
65+
[PackageCapability.Upgrade]: supportedCapability,
66+
[PackageCapability.ListDirect]: supportedCapability,
67+
[PackageCapability.FetchAvailableVersions]: supportedCapability,
68+
};
69+
const fullEnvironmentCapabilities: Capabilities<EnvironmentManagerCapability> = {
70+
[EnvironmentCapability.List]: supportedCapability,
71+
[EnvironmentCapability.Resolve]: supportedCapability,
72+
[EnvironmentCapability.GetSelected]: supportedCapability,
73+
[EnvironmentCapability.SetSelected]: supportedCapability,
74+
[EnvironmentCapability.Create]: supportedCapability,
75+
[EnvironmentCapability.Remove]: supportedCapability,
76+
};
77+
78+
const legacyPackageManager: PackageManager = {
79+
name: 'legacy',
80+
manage: async () => {},
81+
refresh: async () => {},
82+
getPackages: async () => [],
83+
};
84+
const legacyEnvironmentManager: EnvironmentManager = {
85+
name: 'legacy',
86+
preferredPackageManagerId: 'example:legacy',
87+
refresh: async () => {},
88+
getEnvironments: async () => [],
89+
get: async () => undefined,
90+
set: async () => {},
91+
resolve: async () => undefined,
92+
};
93+
94+
getLegacyPackageManagerCapability(legacyPackageManager, PackageCapability.Install) satisfies Promise<Support>;
95+
getLegacyEnvironmentManagerCapability(legacyEnvironmentManager, EnvironmentCapability.Create) satisfies Promise<Support>;
96+
97+
// Capability authoring and raw-provider resolution.
98+
const advertised: Capabilities<PackageManagerCapability> = {
99+
...fullPackageCapabilities,
100+
[PackageCapability.Upgrade]: (context: CapabilityContext) =>
101+
resolvePackageManagerCapability(legacyPackageManager, PackageCapability.Install, context),
102+
[PackageCapability.ListDirect]: async (_context) => ({ supported: false, reason: 'Unavailable' }),
103+
};
104+
const managerWithCapabilities: PackageManager = { ...legacyPackageManager, capabilities: advertised };
105+
resolvePackageManagerCapability(managerWithCapabilities, PackageCapability.ListDirect, { environment }) satisfies Promise<Support>;
106+
resolveEnvironmentManagerCapability(legacyEnvironmentManager, EnvironmentCapability.Create, {
107+
scope: 'global',
108+
}) satisfies Promise<Support>;
109+
110+
// Public queries and support-result narrowing.
111+
api.getPackageManagerCapability(environment, PackageCapability.List) satisfies Promise<Support>;
112+
api.getEnvironmentManagerCapability('example:legacy', EnvironmentCapability.List, { scope: 'all' }) satisfies Promise<Support>;
113+
declare const support: Support;
114+
support.isLegacy satisfies boolean | undefined;
115+
if (!support.supported) {
116+
support.reason satisfies string;
117+
}
118+
119+
// Invalid capability keys, advertisements, and context must remain compile errors.
120+
// @ts-expect-error Legacy environment inference requires an environment capability.
121+
getLegacyEnvironmentManagerCapability(legacyEnvironmentManager, PackageCapability.List);
122+
// @ts-expect-error Legacy package inference requires a package capability.
123+
getLegacyPackageManagerCapability(legacyPackageManager, EnvironmentCapability.List);
124+
// @ts-expect-error Package keys do not belong to the environment capability domain.
125+
PackageCapability.List satisfies EnvironmentManagerCapability;
126+
// @ts-expect-error Environment keys do not belong to the package capability domain.
127+
EnvironmentCapability.List satisfies PackageManagerCapability;
128+
// @ts-expect-error Advertisements must be asynchronous functions, not static Support values.
129+
({ [PackageCapability.List]: { supported: true } } satisfies Capabilities<PackageManagerCapability>);
130+
// @ts-expect-error Advertisements cannot introduce arbitrary keys.
131+
({ 'packages.unknown': async () => ({ supported: true }) } satisfies Capabilities<PackageManagerCapability>);
132+
// @ts-expect-error Operation options are not capability query context.
133+
({ createOptions: { quickCreate: true } } satisfies CapabilityContext);
134+
declare const arbitraryKey: string;
135+
// @ts-expect-error A general string is not a declared environment capability.
136+
resolveEnvironmentManagerCapability(legacyEnvironmentManager, arbitraryKey);
137+
// @ts-expect-error A general string is not a declared package capability.
138+
resolvePackageManagerCapability(legacyPackageManager, arbitraryKey);
139+
// @ts-expect-error Public environment queries require a catalog key.
140+
api.getEnvironmentManagerCapability('example:legacy', arbitraryKey);
141+
// @ts-expect-error Public package queries require a catalog key.
142+
api.getPackageManagerCapability(environment, arbitraryKey);
143+
// @ts-expect-error Capability dictionaries have no arbitrary string index signature.
144+
fullPackageCapabilities[arbitraryKey];
145+
// @ts-expect-error Capability dictionaries have no arbitrary string index signature.
146+
fullEnvironmentCapabilities[arbitraryKey];
147+
// @ts-expect-error Capability dictionaries must advertise every catalog key; partial maps are rejected.
148+
({ [PackageCapability.List]: async () => ({ supported: true }) } satisfies Capabilities<PackageManagerCapability>);

‎build/azure-pipeline.npm.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ extends:
8686

8787
- script: npm run copy:sources
8888
workingDirectory: $(Build.SourcesDirectory)/api
89-
displayName: Copy src/api.ts, src/types.ts, src/publicErrors.ts to API package sources
89+
displayName: Copy API facade, types, errors, and capabilities to API package sources
9090

9191
- script: npm run compile
9292
workingDirectory: $(Build.SourcesDirectory)/api

0 commit comments

Comments
 (0)