Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/instructions/testing-workflow.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ This guide covers the full testing lifecycle:
## Learnings

- Pip commands that return JSON must pass `--disable-pip-version-check`; the process helper combines stderr with stdout, so update notices can otherwise make valid JSON unparseable (1).
- When a view subscribes to a newly added provider event, TypeMoq-based view tests must return a real `EventEmitter.event`; an unstubbed event yields an undefined disposable and fails during teardown (1).

### When to Use This Guide

Expand Down
12 changes: 12 additions & 0 deletions api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ 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.5.0]

### Added

- Added the optional `PackageManager.createForProject` factory for package managers whose operations depend on the calling Python project. Explicit project contexts are used directly; environment-only operations use a scoped manager only when exactly one tracked project matches.
- Added optional `PackageManager.dispose` support for releasing resources owned by project-scoped package managers.
- Added `PackageManagerRequiresProjectError` and `isPackageManagerRequiresProjectError` for environment-only package mutations and refreshes that cannot identify a unique project.

### Changed

- Environment-only package operations no longer fall back to an unbound project-aware package manager. Package reads return `undefined`; mutations and refreshes reject with `PackageManagerRequiresProjectError`.

## [1.4.0]

### Changed
Expand Down
4 changes: 2 additions & 2 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@vscode/python-environments",
"description": "An API facade for the Python Environments extension in VS Code",
"version": "1.4.0",
"version": "1.5.0",
"author": {
"name": "Microsoft Corporation"
},
Expand Down
85 changes: 80 additions & 5 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -793,8 +793,8 @@ getPackages(

**Returns** `Promise<Package[] | undefined>`. `undefined` means the manager
could not produce a list - for example no package manager is associated with
the environment - which is different from an empty array meaning "nothing
installed".
the environment or a project-aware manager cannot identify one unique project -
which is different from an empty array meaning "nothing installed".

```typescript
const packages = await api.getPackages(env);
Expand All @@ -818,7 +818,9 @@ refreshPackages(environment: PythonEnvironment): Promise<void>;
| `environment` | [`PythonEnvironment`](#pythonenvironment) | Yes | The environment whose package list should be refreshed. |

**Returns** `Promise<void>`. Changes surface through
[`onDidChangePackages`](#ondidchangepackages).
[`onDidChangePackages`](#ondidchangepackages). Rejects with
`PackageManagerRequiresProjectError` when a project-aware manager cannot identify
one unique project for the environment.

```typescript
// Packages were installed outside the extension - re-read the list.
Expand All @@ -842,8 +844,9 @@ managePackages(
| `environment` | [`PythonEnvironment`](#pythonenvironment) | Yes | The environment to modify. |
| `options` | [`PackageManagementOptions`](#packagemanagementoptions) | Yes | Must specify `install`, `uninstall`, or both. Also carries `upgrade`, `showSkipOption`, and `runHeadless`. |

**Returns** `Promise<void>`, resolving when the operation finishes. Rejects if
the underlying tool fails.
**Returns** `Promise<void>`, resolving when the operation finishes. Rejects with
`PackageManagerRequiresProjectError` when a project-aware manager cannot identify
one unique project for the environment, or if the underlying tool fails.

```typescript
await api.managePackages(env, {
Expand Down Expand Up @@ -973,6 +976,30 @@ context.subscriptions.push(

### Package errors

#### `PackageManagerRequiresProjectError`

Thrown by environment-only package mutations and refreshes when the selected
package manager is project-aware but the environment does not identify exactly
one tracked project. Its `code` is the stable
`'PackageManagerRequiresProject'` discriminator.

Use `isPackageManagerRequiresProjectError(error)` instead of `instanceof` when
the error may cross extension bundle boundaries:

```typescript
import { isPackageManagerRequiresProjectError } from '@vscode/python-environments';

try {
await api.refreshPackages(env);
} catch (error) {
if (isPackageManagerRequiresProjectError(error)) {
// Ask the user to open or select the intended Python project.
} else {
throw error;
}
}
```

#### `PackageVersionLookupNotSupportedError`

Thrown when a package manager cannot list available versions at all. It
Expand Down Expand Up @@ -1613,13 +1640,61 @@ Reports and changes the packages of an environment.
| `refresh(environment)` | `(environment: PythonEnvironment) => Promise<void>` | Yes | Re-reads the installed package list. |
| `getPackages(environment, options?)` | `(environment: PythonEnvironment, options?: GetPackagesOptions) => Promise<Package[] \| undefined>` | 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. |
| `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<Set<string> \| 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<void>` | No | Drops cached package data. |
| `getVersion(environment)` | `(environment: PythonEnvironment) => Promise<Pep440Version \| undefined>` | No | Version of the underlying tool, such as pip, uv, or conda. |
| `getPackageAvailableVersions(environment, packageName)` | `(environment: PythonEnvironment, packageName: string) => Promise<Pep440Version[] \| undefined>` | No | Available versions, newest first. Throw `PackageVersionLookupNotSupportedError` when unsupported and let operational failures propagate. Resolving to `undefined` is treated as unsupported. |
| `formatInstallSpec(packageName, version)` | `(packageName: string, version: string) => string` | No | Formats a pinned specifier for this tool, for example `requests==2.31.0` for pip or `requests=2.31.0` for conda. Callers default to `name==version` when absent. |
| `onDidChangePackages` | `Event<DidChangePackagesEventArgs>` | No | Fire when packages change. |

##### Project-scoped package managers

Implement `createForProject` when package operations depend on project files or
the process working directory, as they do for tools such as Poetry. Callers that
already have a `PythonProject` use that project directly. For environment-only
operations, the extension selects a project-scoped manager only when exactly one
tracked project uses the environment; it does not choose arbitrarily when no
project or multiple projects match.

Environment-only paths never use a project-aware provider as an unbound
fallback. When no unique project can be inferred, package reads return
`undefined`, package views suppress those operations, and package mutations or
refreshes reject with `PackageManagerRequiresProjectError`. Keep
project-specific caches and mutable state on the manager returned by
`createForProject`, and implement `dispose` when that manager owns resources.

When a scoped manager fires `onDidChangePackages`, the event's `manager` must be
the exact scoped instance returned by `createForProject`. This requirement also
applies when root and scoped managers share an event emitter.

```typescript
class ProjectPackageManager implements PackageManager {
readonly name = 'project-pm';

constructor(private readonly project?: PythonProject) {}

createForProject(project: PythonProject): PackageManager {
return new ProjectPackageManager(project);
}

dispose(): void {
// Release project-specific watchers or processes.
}

async manage(
environment: PythonEnvironment,
options: PackageManagementOptions,
): Promise<void> {
if (!this.project) {
throw new Error('Package management requires a Python project.');
}
await runPackageCommand(options, { cwd: this.project.uri.fsPath });
}
}
```

```typescript
class MyPackageManager implements PackageManager {
readonly name = 'my-pm';
Expand Down
20 changes: 14 additions & 6 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import {
NativePythonFinder,
} from './managers/common/nativePythonFinder';
import { registerPackageWatchers } from './managers/common/packageWatcher';
import { PackageManagerRequiresProjectError } from './managers/common/errors';
import { IDisposable } from './managers/common/types';
import { registerCondaFeatures } from './managers/conda/main';
import { registerPipenvFeatures } from './managers/pipenv/main';
Expand Down Expand Up @@ -297,7 +298,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
commands.registerCommand(
'python-envs.test.getDirectPackageNames',
async (environment: PythonEnvironment) => {
const manager = envManagers.getPackageManager(environment);
const { manager } = envManagers.resolvePackageManagerForEnvironment(environment);
const names = await manager?.getDirectPackageNames?.(environment);
return names ? Array.from(names) : undefined;
},
Expand Down Expand Up @@ -358,25 +359,32 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
try {
resolved = await getPackageCommandOptions(options, envManagers, projectManager);
} catch (err) {
if (!(err instanceof InlineScriptPackagesNotManagedError)) {
if (
!(err instanceof InlineScriptPackagesNotManagedError) &&
!(err instanceof PackageManagerRequiresProjectError)
) {
// Preserve the existing contract: other resolution failures still surface.
throw err;
}
traceError('Rejected a package command for an inline-script environment:', err);
traceError('Rejected a package command for the selected environment:', err);
await window.showErrorMessage(err.message);
return;
}
try {
resolved.packageManager.manage(resolved.environment, { install: [] });
await resolved.packageManager.manage(resolved.environment, { install: [] });
} catch (err) {
if (err instanceof PackageManagerRequiresProjectError) {
await window.showErrorMessage(err.message);
return;
}
traceError('Error when running command python-envs.packages', err);
}
}),
commands.registerCommand('python-envs.uninstallPackage', async (context: unknown) => {
await handlePackageUninstall(context, envManagers);
await handlePackageUninstall(context);
}),
commands.registerCommand('python-envs.managePackageVersion', async (context: unknown) => {
await managePackageVersion(context, envManagers);
await managePackageVersion(context);
}),
commands.registerCommand('python-envs.set', async (item) => {
await setEnvironmentCommand(item, envManagers, projectManager);
Expand Down
39 changes: 20 additions & 19 deletions src/extensionApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ 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 { PackageManagerRequiresProjectError } from './managers/common/errors';
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';
Expand Down Expand Up @@ -86,6 +87,7 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
this._onDidChangePythonProjects,
this._onDidChangePackages,
this._onDidChangeEnvironmentVariables,
this.envManagers.onDidChangePackageProviderPackages((e) => this._onDidChangePackages.fire(e)),
this.envManagers.onDidChangeActiveEnvironment((e) => {
this._onDidChangeEnvironment.fire(e);
const location = e.uri?.fsPath ?? 'global';
Expand Down Expand Up @@ -295,37 +297,36 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
}

registerPackageManager(manager: PackageManager, options?: { extensionId?: string }): Disposable {
const disposables: Disposable[] = [];
disposables.push(this.envManagers.registerPackageManager(manager, options));
if (manager.onDidChangePackages) {
disposables.push(manager.onDidChangePackages((e) => this._onDidChangePackages.fire(e)));
}
return new Disposable(() => disposables.forEach((d) => d.dispose()));
return this.envManagers.registerPackageManager(manager, options);
}
async managePackages(context: PythonEnvironment, options: PackageManagementOptions): Promise<void> {
await waitForEnvManagerId([context.envId.managerId]);
const manager = this.envManagers.getPackageManager(context);
if (!manager) {
return Promise.reject(new Error('No package manager found'));
}
const manager = this.requirePackageManagerForEnvironment(context);
return manager.manage(context, options);
}
async refreshPackages(context: PythonEnvironment): Promise<void> {
await waitForEnvManagerId([context.envId.managerId]);
const manager = this.envManagers.getPackageManager(context);
if (!manager) {
return Promise.reject(new Error('No package manager found'));
}
const manager = this.requirePackageManagerForEnvironment(context);
return manager.refresh(context);
}
async getPackages(context: PythonEnvironment, options?: GetPackagesOptions): Promise<Package[] | undefined> {
await waitForEnvManagerId([context.envId.managerId]);
const manager = this.envManagers.getPackageManager(context);
if (!manager) {
return Promise.resolve(undefined);
const { manager } = this.envManagers.resolvePackageManagerForEnvironment(context);
return manager?.getPackages(context, options);
}

private requirePackageManagerForEnvironment(context: PythonEnvironment): InternalPackageManager {
const resolution = this.envManagers.resolvePackageManagerForEnvironment(context);
switch (resolution.kind) {
case 'resolved':
return resolution.manager;
case 'projectRequired':
throw new PackageManagerRequiresProjectError();
case 'notFound':
throw new Error('No package manager found');
}
return manager.getPackages(context, options);
}

getPackageAvailableVersions(
context: PythonEnvironment,
packageName: string,
Expand Down
Loading
Loading