Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
27 changes: 23 additions & 4 deletions api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,11 @@ export interface PackageInfo {
* The URIs associated with the package.
*/
readonly uris?: readonly Uri[];

/**
* Whether the package is a transitive dependency.
*/
readonly isTransitive?: boolean;
}

/**
Expand Down Expand Up @@ -670,9 +675,9 @@ export interface PackageManager {
/**
* Refreshes the package list for the specified Python environment.
* @param environment - The Python environment for which to refresh the package list.
* @returns A promise that resolves when the refresh is complete.
* @returns A promise that resolves with the refreshed list of packages, or undefined.
*/
refresh(environment: PythonEnvironment): Promise<void>;
refresh(environment: PythonEnvironment): Promise<Package[] | undefined>;

/**
* Retrieves the list of packages for the specified Python environment.
Expand All @@ -687,6 +692,20 @@ export interface PackageManager {
*/
onDidChangePackages?: Event<DidChangePackagesEventArgs>;

/**
* Fetches the names of direct (non-transitive) packages for the specified Python environment.
*
* **Caveat:** Most package managers cannot track user install intent. For pip, this uses
* `pip list --not-required` which returns packages with no installed dependents (leaf packages),
* not necessarily packages the user explicitly installed. For example, if a user runs
* `pip install flask werkzeug`, werkzeug will still be reported as transitive because flask
* depends on it. This is a best-effort approximation.
*
* @param environment - The Python environment for which to fetch direct package names.
* @returns A promise that resolves to a set of package name strings, or undefined if not supported.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot generated: Two questions on this signature:

  1. Set<string> is unusual on our public API surface; readonly string[] would be more consistent with the rest of PackageManager and easier for consumers to serialize/transport.
  2. The three observable states — method not implemented, method returns undefined, method returns an empty Set — all need documented semantics. The current consumer treats empty-set as "no info" (via a size > 0 guard), which silently drops legitimately-empty results. Likewise, consumers of PackageInfo.isTransitive can't distinguish undefined from false; please document that undefined means "unknown".

*/
getDirectPackageNames?(environment: PythonEnvironment): Promise<Set<string> | undefined>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it implemented for conda? Seems not covered

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conda doesn't have a direct mechanism for getting direct packages, only conda env export --from-history which isn't strictly the same as direct or transitive packages, and can lead to more confusion


/**
* Clears the package manager's cache.
* @returns A promise that resolves when the cache is cleared.
Expand Down Expand Up @@ -1029,9 +1048,9 @@ export interface PythonPackageGetterApi {
* Refresh the list of packages in a Python Environment.
*
* @param environment The Python Environment for which the list of packages is to be refreshed.
* @returns A promise that resolves when the list of packages has been refreshed.
* @returns A promise that resolves with the refreshed list of packages, or undefined.
*/
refreshPackages(environment: PythonEnvironment): Promise<void>;
refreshPackages(environment: PythonEnvironment): Promise<Package[] | undefined>;

/**
* Get the list of packages in a Python Environment.
Expand Down
27 changes: 23 additions & 4 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,11 @@ export interface PackageInfo {
* The URIs associated with the package.
*/
readonly uris?: readonly Uri[];

/**
* Whether the package is a transitive dependency.
*/
readonly isTransitive?: boolean;
}

/**
Expand Down Expand Up @@ -664,9 +669,9 @@ export interface PackageManager {
/**
* Refreshes the package list for the specified Python environment.
* @param environment - The Python environment for which to refresh the package list.
* @returns A promise that resolves when the refresh is complete.
* @returns A promise that resolves with the refreshed list of packages, or undefined.
*/
refresh(environment: PythonEnvironment): Promise<void>;
refresh(environment: PythonEnvironment): Promise<Package[] | undefined>;

/**
* Retrieves the list of packages for the specified Python environment.
Expand All @@ -681,6 +686,20 @@ export interface PackageManager {
*/
onDidChangePackages?: Event<DidChangePackagesEventArgs>;

/**
* Fetches the names of direct (non-transitive) packages for the specified Python environment.
*
* **Caveat:** Most package managers cannot track user install intent. For pip, this uses
* `pip list --not-required` which returns packages with no installed dependents (leaf packages),
* not necessarily packages the user explicitly installed. For example, if a user runs
* `pip install flask werkzeug`, werkzeug will still be reported as transitive because flask
* depends on it. This is a best-effort approximation.
*
* @param environment - The Python environment for which to fetch direct package names.
* @returns A promise that resolves to a set of package name strings, or undefined if not supported.
*/
getDirectPackageNames?(environment: PythonEnvironment): Promise<Set<string> | undefined>;

Comment thread
edvilme marked this conversation as resolved.
/**
* Clears the package manager's cache.
* @returns A promise that resolves when the cache is cleared.
Expand Down Expand Up @@ -1023,9 +1042,9 @@ export interface PythonPackageGetterApi {
* Refresh the list of packages in a Python Environment.
*
* @param environment The Python Environment for which the list of packages is to be refreshed.
* @returns A promise that resolves when the list of packages has been refreshed.
* @returns A promise that resolves with the refreshed list of packages, or undefined.
*/
refreshPackages(environment: PythonEnvironment): Promise<void>;
refreshPackages(environment: PythonEnvironment): Promise<Package[] | undefined>;

/**
* Get the list of packages in a Python Environment.
Expand Down
14 changes: 14 additions & 0 deletions src/features/envCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,20 @@ export async function removeEnvironmentCommand(context: unknown, managers: Envir

export async function handlePackageUninstall(context: unknown, em: EnvironmentManagers) {
if (context instanceof PackageTreeItem || context instanceof ProjectPackage) {
if (context.pkg.isTransitive) {
Comment thread
edvilme marked this conversation as resolved.
const confirm = await showInformationMessage(
l10n.t(
'The package "{0}" is a transitive dependency. Uninstalling it may break other packages that depend on it.',
context.pkg.name,
),
{ modal: true },
l10n.t('Uninstall Anyway'),
l10n.t('Cancel'),
);
if (confirm !== l10n.t('Uninstall Anyway')) {
return;
}
}
Comment thread
edvilme marked this conversation as resolved.
const moduleName = context.pkg.name;
const environment = context instanceof ProjectPackage ? context.parent.environment : context.parent.environment;
const packageManager = em.getPackageManager(environment);
Expand Down
2 changes: 1 addition & 1 deletion src/features/pythonApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
}
return manager.manage(context, options);
}
async refreshPackages(context: PythonEnvironment): Promise<void> {
async refreshPackages(context: PythonEnvironment): Promise<Package[] | undefined> {
await waitForEnvManagerId([context.envId.managerId]);
const manager = this.envManagers.getPackageManager(context);
if (!manager) {
Expand Down
8 changes: 6 additions & 2 deletions src/features/views/envManagersView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,13 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
const views: EnvTreeItem[] = [];

if (pkgManager) {
const packages = await pkgManager.getPackages(environment);
let packages = await pkgManager.refresh(environment);
if (packages && packages.length > 0) {
views.push(...packages.map((p) => new PackageTreeItem(p, parent, pkgManager)));
views.push(
...packages
.sort((a, b) => (a.isTransitive === b.isTransitive ? 0 : a.isTransitive ? 1 : -1))
.map((p) => new PackageTreeItem(p, parent, pkgManager)),
);
Comment thread
edvilme marked this conversation as resolved.
} else {
views.push(new EnvInfoTreeItem(parent, ProjectViews.noPackages));
}
Expand Down
2 changes: 1 addition & 1 deletion src/features/views/projectView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export class ProjectView implements TreeDataProvider<ProjectTreeItem> {
return [new ProjectEnvironmentInfo(environmentItem, ProjectViews.noPackageManager)];
}

let packages = await pkgManager.getPackages(environment);
let packages = await pkgManager.refresh(environment);
if (!packages) {
return [new ProjectEnvironmentInfo(environmentItem, ProjectViews.noPackages)];
}
Expand Down
15 changes: 9 additions & 6 deletions src/features/views/treeViewItems.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Command, MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode';
import { Command, MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState, l10n } from 'vscode';
import { EnvironmentGroupInfo, IconPath, Package, PythonEnvironment, PythonProject } from '../../api';
import { EnvViewStrings, UvInstallStrings, VenvManagerStrings } from '../../common/localize';
import { InternalEnvironmentManager, InternalPackageManager } from '../../internal.api';
Expand Down Expand Up @@ -210,10 +210,13 @@ export class PackageTreeItem implements EnvTreeItem {
public readonly manager: InternalPackageManager,
) {
const item = new TreeItem(pkg.displayName);
item.iconPath = pkg.iconPath;
item.contextValue = 'python-package';
item.description = pkg.description ?? pkg.version;
item.tooltip = pkg.tooltip;
const defaultIcon = pkg.isTransitive ? new ThemeIcon('list-tree') : new ThemeIcon('package');
item.iconPath = pkg.iconPath ?? defaultIcon;
item.contextValue = pkg.isTransitive ? 'python-package-transitive' : 'python-package';
item.description = (pkg.isTransitive ? l10n.t('(transitive) ') : '') + (pkg.description ?? pkg.version);
item.tooltip = pkg.isTransitive
? l10n.t('This package is a dependency of another installed package. It may also have been explicitly installed.')
: pkg.tooltip;
this.treeItem = item;
}
}
Expand Down Expand Up @@ -431,7 +434,7 @@ export class ProjectPackage implements ProjectTreeItem {
this.id = ProjectPackage.getId(parent, pkg);
const item = new TreeItem(this.pkg.displayName, TreeItemCollapsibleState.None);
item.iconPath = this.pkg.iconPath;
item.contextValue = 'python-package';
item.contextValue = this.pkg.isTransitive ? 'python-package-transitive' : 'python-package';
item.description = this.pkg.description ?? this.pkg.version;
item.tooltip = this.pkg.tooltip;
this.treeItem = item;
Expand Down
5 changes: 4 additions & 1 deletion src/internal.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ export class InternalPackageManager implements PackageManager {
}
}

refresh(environment: PythonEnvironment): Promise<void> {
refresh(environment: PythonEnvironment): Promise<Package[] | undefined> {
return this.manager.refresh(environment);
}

Expand Down Expand Up @@ -446,6 +446,8 @@ export class PythonPackageImpl implements Package {
public readonly iconPath?: IconPath;
public readonly uris?: readonly Uri[];

public readonly isTransitive?: boolean;

constructor(
public readonly pkgId: PackageId,
info: PackageInfo,
Expand All @@ -457,6 +459,7 @@ export class PythonPackageImpl implements Package {
this.tooltip = info.tooltip;
this.iconPath = info.iconPath;
this.uris = info.uris;
this.isTransitive = info.isTransitive;
}
}

Expand Down
15 changes: 12 additions & 3 deletions src/managers/builtin/pipListUtils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { LogOutputChannel } from 'vscode';

export interface PipPackage {
name: string;
version: string;
displayName: string;
description: string;
}
export function parseUvTree(data: string): string[] {
return data
.split('\n')
.map((line) => line.trim())
.map((line) => line.split(/\s+/, 1)[0])
.filter((name) => !!name);
}
Comment thread
edvilme marked this conversation as resolved.

export function parsePipListJson(data: string): PipPackage[] {
export function parsePipListJson(data: string, log?: LogOutputChannel): PipPackage[] {
try {
const json = JSON.parse(data);
if (Array.isArray(json)) {
Expand All @@ -18,8 +27,8 @@ export function parsePipListJson(data: string): PipPackage[] {
description: version,
}));
}
} catch (_) {
// If JSON parsing fails, return an empty array. The caller can decide how to handle this case.
} catch (ex) {
log?.error('Failed to parse pip list JSON output', ex);
}
return [];
}
28 changes: 22 additions & 6 deletions src/managers/builtin/pipPackageManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from '../../api';
import { updatePackagesAndNotify } from '../common/packageChanges';
import { getWorkspacePackagesToInstall } from './pipUtils';
import { managePackages, refreshPipPackages } from './utils';
import { managePackages, normalizePackageName, refreshPipDirectPackageNames, refreshPipPackages } from './utils';
import { VenvManager } from './venvManager';

export class PipPackageManager implements PackageManager, Disposable {
Expand Down Expand Up @@ -101,16 +101,21 @@ export class PipPackageManager implements PackageManager, Disposable {
);
}

async refresh(environment: PythonEnvironment): Promise<void> {
await window.withProgress(
async refresh(environment: PythonEnvironment): Promise<Package[] | undefined> {
return window.withProgress(
{
location: ProgressLocation.Window,
title: 'Refreshing packages',
},
async () => {
await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id), (changes) => {
this._onDidChangePackages.fire({ environment, manager: this, changes });
});
return updatePackagesAndNotify(
this,
environment,
this.packages.get(environment.envId.id),
(changes) => {
this._onDidChangePackages.fire({ environment, manager: this, changes });
},
);
},
);
}
Expand All @@ -129,4 +134,15 @@ export class PipPackageManager implements PackageManager, Disposable {
this._onDidChangePackages.dispose();
this.packages.clear();
}

/**
* Returns direct (non-transitive) package names using `pip list --not-required` or `uv pip tree --depth=0`.
*
* Note: These commands return packages with no installed dependents (leaf packages), not packages
* the user explicitly installed. pip/uv do not track install intent.
*/
async getDirectPackageNames(environment: PythonEnvironment): Promise<Set<string> | undefined> {
const data = await refreshPipDirectPackageNames(environment, this.log);
return data ? new Set(data.map(normalizePackageName)) : undefined;
}
}
38 changes: 35 additions & 3 deletions src/managers/builtin/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
} from '../common/nativePythonFinder';
import { shortenVersionString, sortEnvironments } from '../common/utils';
import { runPython, runUV, shouldUseUv } from './helpers';
import { parsePipListJson, PipPackage } from './pipListUtils';
import { parsePipListJson, parseUvTree, PipPackage } from './pipListUtils';

const PIXI_EXTENSION_ID = 'renan-r-santos.pixi-code';
const PIXI_RECOMMEND_DONT_ASK_KEY = 'pixi-extension-recommend-dont-ask';
Expand Down Expand Up @@ -200,7 +200,7 @@ async function execPipList(environment: PythonEnvironment, log?: LogOutputChanne
try {
return await runPython(
environment.execInfo.run.executable,
['-m', 'pip', 'list', '--format=json'],
['-m', 'pip', 'list', '--format=json', ...(args ?? [])],
undefined,
log,
undefined,
Expand Down Expand Up @@ -235,14 +235,42 @@ export async function refreshPipPackages(
data = await execPipList(environment, log);
}

return parsePipListJson(data);
return parsePipListJson(data, log);
} catch (e) {
log?.error('Error refreshing packages', e);
showErrorMessageWithLogs(SysManagerStrings.packageRefreshError, log);
return undefined;
}
}

/**
* Returns names of packages with no installed dependents (leaf packages).
*
* Uses `pip list --not-required` (pip) or `uv pip tree --depth=0` (uv). These report
* packages that nothing else depends on, which is a proxy for "directly installed" but
* not equivalent — e.g., `pip install flask werkzeug` will report werkzeug as having
* dependents (flask) even though the user installed it explicitly.
*/
export async function refreshPipDirectPackageNames(
environment: PythonEnvironment,
log?: LogOutputChannel,
): Promise<string[] | undefined> {
const useUv = await shouldUseUv(log, environment.environmentPath.fsPath);
if (useUv) {
const treeOutput = await runUV(
['pip', 'tree', '--python', environment.execInfo.run.executable, '--depth=0'],
undefined,
log,
undefined,
PIP_LIST_TIMEOUT_MS,
);
return parseUvTree(treeOutput);
}
const data = await execPipList(environment, log, ['--not-required']);
const packages = parsePipListJson(data);
Comment thread
edvilme marked this conversation as resolved.
return packages.map((pkg) => pkg.name);
}

export async function managePackages(
environment: PythonEnvironment,
options: PackageManagementOptions,
Expand Down Expand Up @@ -360,3 +388,7 @@ export async function resolveSystemPythonEnvironmentPath(
}
return undefined;
}

export function normalizePackageName(name: string): string {
return name.replace(/[-_.]+/g, '-').toLowerCase();
}
Loading
Loading