Skip to content
Merged
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
11 changes: 11 additions & 0 deletions api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ 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.

### Changed

- Environment-only package operations no longer fall back to an unbound project-aware package manager. When no unique project can be inferred, package reads return `undefined` and mutations and refreshes reject with `No package manager found`.

## [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
62 changes: 58 additions & 4 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +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
the environment, or a project-aware manager cannot identify one unique project
for the environment - which is different from an empty array meaning "nothing
installed".

```typescript
Expand All @@ -818,7 +819,10 @@ 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
`No package manager found` when no package manager can be resolved for the
environment - including when a project-aware manager cannot identify one unique
project.

```typescript
// Packages were installed outside the extension - re-read the list.
Expand All @@ -842,8 +846,10 @@ 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
`No package manager found` when no package manager can be resolved for the
environment - including when a project-aware manager cannot identify one unique
project - or if the underlying tool fails.

```typescript
await api.managePackages(env, {
Expand Down Expand Up @@ -1613,13 +1619,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, `getPackageManager` returns no
manager, so package reads return `undefined`, package views suppress those
operations, and package mutations or refreshes reject with `No package manager
found`. 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
6 changes: 3 additions & 3 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,16 +367,16 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
return;
}
try {
resolved.packageManager.manage(resolved.environment, { install: [] });
await resolved.packageManager.manage(resolved.environment, { install: [] });
} catch (err) {
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
8 changes: 2 additions & 6 deletions src/extensionApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,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,12 +296,7 @@ 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]);
Expand Down
17 changes: 5 additions & 12 deletions src/features/envCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,7 @@ export async function refreshPackagesCommand(context: unknown, managers?: Enviro
}
} else if (context instanceof PythonEnvTreeItem) {
const view = context as PythonEnvTreeItem;
const envManager =
view.parent.kind === EnvTreeItemKind.environmentGroup ? view.parent.parent.manager : view.parent.manager;
const pkgManager = managers?.getPackageManager(envManager.preferredPackageManagerId);
const pkgManager = managers?.getPackageManager(view.environment);
if (pkgManager) {
await pkgManager.refresh(view.environment);
}
Expand Down Expand Up @@ -325,7 +323,7 @@ export async function removeEnvironmentCommand(context: unknown, managers: Envir
}
}

export async function handlePackageUninstall(context: unknown, em: EnvironmentManagers) {
export async function handlePackageUninstall(context: unknown) {
if (context instanceof PackageTreeItem || context instanceof ProjectPackage) {
if (context.pkg.isTransitive) {
const confirm = await showInformationMessage(
Expand All @@ -343,8 +341,7 @@ export async function handlePackageUninstall(context: unknown, em: EnvironmentMa
}
const moduleName = context.pkg.name;
const environment = context.parent.environment;
const packageManager = em.getPackageManager(environment);
await packageManager?.manage(environment, { uninstall: [moduleName], install: [] });
await context.manager.manage(environment, { uninstall: [moduleName], install: [] });
return;
}
traceError(`Invalid context for uninstall command: ${typeof context}`);
Expand All @@ -354,15 +351,11 @@ export async function handlePackageUninstall(context: unknown, em: EnvironmentMa
* Manages package versions by allowing the user to select from available versions or enter a specific version.
* If available versions can be fetched, a QuickPick is shown. Otherwise, an InputBox is used for free-text version entry.
*/
export async function managePackageVersion(context: unknown, em: EnvironmentManagers) {
export async function managePackageVersion(context: unknown) {
if (context instanceof PackageTreeItem || context instanceof ProjectPackage) {
const pkg = context.pkg;
const environment = context.parent.environment;
const packageManager = em.getPackageManager(environment);

if (!packageManager) {
return;
}
const packageManager = context.manager;

if (pkg.isTransitive) {
const confirm = await showInformationMessage(
Expand Down
Loading
Loading