Skip to content

Commit ac47640

Browse files
authored
Distinguish between direct and transitive packages (#1530)
This pull request attempts to identify transitive packages in the user environment, and show indicators in the UI.
1 parent 9c56441 commit ac47640

17 files changed

Lines changed: 443 additions & 49 deletions

api/src/main.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,11 @@ export interface PackageInfo {
578578
* The URIs associated with the package.
579579
*/
580580
readonly uris?: readonly Uri[];
581+
582+
/**
583+
* Whether the package is a transitive dependency.
584+
*/
585+
readonly isTransitive?: boolean;
581586
}
582587

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

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

695+
/**
696+
* Fetches the names of direct (non-transitive) packages for the specified Python environment.
697+
*
698+
* **Caveat:** Most package managers cannot track user install intent. For pip, this uses
699+
* `pip list --not-required` which returns packages with no installed dependents (leaf packages),
700+
* not necessarily packages the user explicitly installed. For example, if a user runs
701+
* `pip install flask werkzeug`, werkzeug will still be reported as transitive because flask
702+
* depends on it. This is a best-effort approximation.
703+
*
704+
* @param environment - The Python environment for which to fetch direct package names.
705+
* @returns A promise that resolves to a set of package name strings, or undefined if not supported.
706+
*/
707+
getDirectPackageNames?(environment: PythonEnvironment): Promise<Set<string> | undefined>;
708+
690709
/**
691710
* Clears the package manager's cache.
692711
* @returns A promise that resolves when the cache is cleared.
@@ -1029,9 +1048,9 @@ export interface PythonPackageGetterApi {
10291048
* Refresh the list of packages in a Python Environment.
10301049
*
10311050
* @param environment The Python Environment for which the list of packages is to be refreshed.
1032-
* @returns A promise that resolves when the list of packages has been refreshed.
1051+
* @returns A promise that resolves with the refreshed list of packages, or undefined.
10331052
*/
1034-
refreshPackages(environment: PythonEnvironment): Promise<void>;
1053+
refreshPackages(environment: PythonEnvironment): Promise<Package[] | undefined>;
10351054

10361055
/**
10371056
* Get the list of packages in a Python Environment.

src/api.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,11 @@ export interface PackageInfo {
572572
* The URIs associated with the package.
573573
*/
574574
readonly uris?: readonly Uri[];
575+
576+
/**
577+
* Whether the package is a transitive dependency.
578+
*/
579+
readonly isTransitive?: boolean;
575580
}
576581

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

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

689+
/**
690+
* Fetches the names of direct (non-transitive) packages for the specified Python environment.
691+
*
692+
* **Caveat:** Most package managers cannot track user install intent. For pip, this uses
693+
* `pip list --not-required` which returns packages with no installed dependents (leaf packages),
694+
* not necessarily packages the user explicitly installed. For example, if a user runs
695+
* `pip install flask werkzeug`, werkzeug will still be reported as transitive because flask
696+
* depends on it. This is a best-effort approximation.
697+
*
698+
* @param environment - The Python environment for which to fetch direct package names.
699+
* @returns A promise that resolves to a set of package name strings, or undefined if not supported.
700+
*/
701+
getDirectPackageNames?(environment: PythonEnvironment): Promise<Set<string> | undefined>;
702+
684703
/**
685704
* Clears the package manager's cache.
686705
* @returns A promise that resolves when the cache is cleared.
@@ -1023,9 +1042,9 @@ export interface PythonPackageGetterApi {
10231042
* Refresh the list of packages in a Python Environment.
10241043
*
10251044
* @param environment The Python Environment for which the list of packages is to be refreshed.
1026-
* @returns A promise that resolves when the list of packages has been refreshed.
1045+
* @returns A promise that resolves with the refreshed list of packages, or undefined.
10271046
*/
1028-
refreshPackages(environment: PythonEnvironment): Promise<void>;
1047+
refreshPackages(environment: PythonEnvironment): Promise<Package[] | undefined>;
10291048

10301049
/**
10311050
* Get the list of packages in a Python Environment.

src/features/envCommands.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,20 @@ export async function removeEnvironmentCommand(context: unknown, managers: Envir
305305

306306
export async function handlePackageUninstall(context: unknown, em: EnvironmentManagers) {
307307
if (context instanceof PackageTreeItem || context instanceof ProjectPackage) {
308+
if (context.pkg.isTransitive) {
309+
const confirm = await showInformationMessage(
310+
l10n.t(
311+
'The package "{0}" is a transitive dependency. Uninstalling it may break other packages that depend on it.',
312+
context.pkg.name,
313+
),
314+
{ modal: true },
315+
l10n.t('Uninstall Anyway'),
316+
l10n.t('Cancel'),
317+
);
318+
if (confirm !== l10n.t('Uninstall Anyway')) {
319+
return;
320+
}
321+
}
308322
const moduleName = context.pkg.name;
309323
const environment = context instanceof ProjectPackage ? context.parent.environment : context.parent.environment;
310324
const packageManager = em.getPackageManager(environment);

src/features/pythonApi.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
250250
}
251251
return manager.manage(context, options);
252252
}
253-
async refreshPackages(context: PythonEnvironment): Promise<void> {
253+
async refreshPackages(context: PythonEnvironment): Promise<Package[] | undefined> {
254254
await waitForEnvManagerId([context.envId.managerId]);
255255
const manager = this.envManagers.getPackageManager(context);
256256
if (!manager) {

src/features/views/envManagersView.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,9 +252,13 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
252252
const views: EnvTreeItem[] = [];
253253

254254
if (pkgManager) {
255-
const packages = await pkgManager.getPackages(environment);
255+
let packages = await pkgManager.refresh(environment);
256256
if (packages && packages.length > 0) {
257-
views.push(...packages.map((p) => new PackageTreeItem(p, parent, pkgManager)));
257+
views.push(
258+
...packages
259+
.sort((a, b) => (a.isTransitive === b.isTransitive ? 0 : a.isTransitive ? 1 : -1))
260+
.map((p) => new PackageTreeItem(p, parent, pkgManager)),
261+
);
258262
} else {
259263
views.push(new EnvInfoTreeItem(parent, ProjectViews.noPackages));
260264
}

src/features/views/projectView.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ export class ProjectView implements TreeDataProvider<ProjectTreeItem> {
244244
return [new ProjectEnvironmentInfo(environmentItem, ProjectViews.noPackageManager)];
245245
}
246246

247-
let packages = await pkgManager.getPackages(environment);
247+
let packages = await pkgManager.refresh(environment);
248248
if (!packages) {
249249
return [new ProjectEnvironmentInfo(environmentItem, ProjectViews.noPackages)];
250250
}

src/features/views/treeViewItems.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Command, MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode';
1+
import { Command, MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState, l10n } from 'vscode';
22
import { EnvironmentGroupInfo, IconPath, Package, PythonEnvironment, PythonProject } from '../../api';
33
import { EnvViewStrings, UvInstallStrings, VenvManagerStrings } from '../../common/localize';
44
import { InternalEnvironmentManager, InternalPackageManager } from '../../internal.api';
@@ -210,10 +210,13 @@ export class PackageTreeItem implements EnvTreeItem {
210210
public readonly manager: InternalPackageManager,
211211
) {
212212
const item = new TreeItem(pkg.displayName);
213-
item.iconPath = pkg.iconPath;
214-
item.contextValue = 'python-package';
215-
item.description = pkg.description ?? pkg.version;
216-
item.tooltip = pkg.tooltip;
213+
const defaultIcon = pkg.isTransitive ? new ThemeIcon('list-tree') : new ThemeIcon('package');
214+
item.iconPath = pkg.iconPath ?? defaultIcon;
215+
item.contextValue = pkg.isTransitive ? 'python-package-transitive' : 'python-package';
216+
item.description = (pkg.isTransitive ? l10n.t('(transitive) ') : '') + (pkg.description ?? pkg.version);
217+
item.tooltip = pkg.isTransitive
218+
? l10n.t('This package is a dependency of another installed package. It may also have been explicitly installed.')
219+
: pkg.tooltip;
217220
this.treeItem = item;
218221
}
219222
}
@@ -431,7 +434,7 @@ export class ProjectPackage implements ProjectTreeItem {
431434
this.id = ProjectPackage.getId(parent, pkg);
432435
const item = new TreeItem(this.pkg.displayName, TreeItemCollapsibleState.None);
433436
item.iconPath = this.pkg.iconPath;
434-
item.contextValue = 'python-package';
437+
item.contextValue = this.pkg.isTransitive ? 'python-package-transitive' : 'python-package';
435438
item.description = this.pkg.description ?? this.pkg.version;
436439
item.tooltip = this.pkg.tooltip;
437440
this.treeItem = item;

src/internal.api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ export class InternalPackageManager implements PackageManager {
364364
}
365365
}
366366

367-
refresh(environment: PythonEnvironment): Promise<void> {
367+
refresh(environment: PythonEnvironment): Promise<Package[] | undefined> {
368368
return this.manager.refresh(environment);
369369
}
370370

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

449+
public readonly isTransitive?: boolean;
450+
449451
constructor(
450452
public readonly pkgId: PackageId,
451453
info: PackageInfo,
@@ -457,6 +459,7 @@ export class PythonPackageImpl implements Package {
457459
this.tooltip = info.tooltip;
458460
this.iconPath = info.iconPath;
459461
this.uris = info.uris;
462+
this.isTransitive = info.isTransitive;
460463
}
461464
}
462465

src/managers/builtin/pipListUtils.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1+
import { LogOutputChannel } from 'vscode';
2+
13
export interface PipPackage {
24
name: string;
35
version: string;
46
displayName: string;
57
description: string;
68
}
9+
export function parseUvTree(data: string): string[] {
10+
return data
11+
.split('\n')
12+
.map((line) => line.trim())
13+
.map((line) => line.split(/\s+/, 1)[0])
14+
.filter((name) => !!name);
15+
}
716

8-
export function parsePipListJson(data: string): PipPackage[] {
17+
export function parsePipListJson(data: string, log?: LogOutputChannel): PipPackage[] {
918
try {
1019
const json = JSON.parse(data);
1120
if (Array.isArray(json)) {
@@ -18,8 +27,8 @@ export function parsePipListJson(data: string): PipPackage[] {
1827
description: version,
1928
}));
2029
}
21-
} catch (_) {
22-
// If JSON parsing fails, return an empty array. The caller can decide how to handle this case.
30+
} catch (ex) {
31+
log?.error('Failed to parse pip list JSON output', ex);
2332
}
2433
return [];
2534
}

src/managers/builtin/pipPackageManager.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from '../../api';
2222
import { updatePackagesAndNotify } from '../common/packageChanges';
2323
import { getWorkspacePackagesToInstall } from './pipUtils';
24-
import { managePackages, refreshPipPackages } from './utils';
24+
import { managePackages, normalizePackageName, refreshPipDirectPackageNames, refreshPipPackages } from './utils';
2525
import { VenvManager } from './venvManager';
2626

2727
export class PipPackageManager implements PackageManager, Disposable {
@@ -101,16 +101,21 @@ export class PipPackageManager implements PackageManager, Disposable {
101101
);
102102
}
103103

104-
async refresh(environment: PythonEnvironment): Promise<void> {
105-
await window.withProgress(
104+
async refresh(environment: PythonEnvironment): Promise<Package[] | undefined> {
105+
return window.withProgress(
106106
{
107107
location: ProgressLocation.Window,
108108
title: 'Refreshing packages',
109109
},
110110
async () => {
111-
await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id), (changes) => {
112-
this._onDidChangePackages.fire({ environment, manager: this, changes });
113-
});
111+
return updatePackagesAndNotify(
112+
this,
113+
environment,
114+
this.packages.get(environment.envId.id),
115+
(changes) => {
116+
this._onDidChangePackages.fire({ environment, manager: this, changes });
117+
},
118+
);
114119
},
115120
);
116121
}
@@ -129,4 +134,15 @@ export class PipPackageManager implements PackageManager, Disposable {
129134
this._onDidChangePackages.dispose();
130135
this.packages.clear();
131136
}
137+
138+
/**
139+
* Returns direct (non-transitive) package names using `pip list --not-required` or `uv pip tree --depth=0`.
140+
*
141+
* Note: These commands return packages with no installed dependents (leaf packages), not packages
142+
* the user explicitly installed. pip/uv do not track install intent.
143+
*/
144+
async getDirectPackageNames(environment: PythonEnvironment): Promise<Set<string> | undefined> {
145+
const data = await refreshPipDirectPackageNames(environment, this.log);
146+
return data ? new Set(data.map(normalizePackageName)) : undefined;
147+
}
132148
}

0 commit comments

Comments
 (0)