From 89ffbb3167e75a065ec873f0249d5ed71450b730 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 21 May 2026 16:44:02 -0700 Subject: [PATCH 01/13] Rename execPipList --- src/managers/builtin/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index 0e6f7c62..5d7141a7 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -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, From 24ed99df258782bcbd897f3db0ccc7fe46519b71 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 21 May 2026 17:09:02 -0700 Subject: [PATCH 02/13] Implement direct package discovery in all managers --- api/src/main.ts | 7 +++++++ src/api.ts | 12 +++++++++++ src/features/views/treeViewItems.ts | 2 +- src/managers/builtin/pipListUtils.ts | 13 ++++++++++++ src/managers/builtin/pipPackageManager.ts | 7 ++++++- src/managers/builtin/utils.ts | 22 ++++++++++++++++++++- src/managers/common/packageChanges.ts | 5 +++++ src/managers/poetry/poetryPackageManager.ts | 15 ++++++++++++++ 8 files changed, 80 insertions(+), 3 deletions(-) diff --git a/api/src/main.ts b/api/src/main.ts index aa381414..fabd42c5 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -687,6 +687,13 @@ export interface PackageManager { */ onDidChangePackages?: Event; + /** + * Fetches the names of direct (non-transitive) packages for the specified Python environment. + * @param environment - The Python environment for which to fetch direct package names. + * @returns A promise that resolves to an array of package name strings, or undefined if not supported. + */ + fetchDirectPackageNames?(environment: PythonEnvironment): Promise | undefined>; + /** * Clears the package manager's cache. * @returns A promise that resolves when the cache is cleared. diff --git a/src/api.ts b/src/api.ts index b3ab24cb..c42da384 100644 --- a/src/api.ts +++ b/src/api.ts @@ -572,6 +572,11 @@ export interface PackageInfo { * The URIs associated with the package. */ readonly uris?: readonly Uri[]; + + /** + * Whether the package is a transitive dependency. + */ + isTransitive?: boolean; } /** @@ -681,6 +686,13 @@ export interface PackageManager { */ onDidChangePackages?: Event; + /** + * Fetches the names of direct (non-transitive) packages for the specified Python environment. + * @param environment - The Python environment for which to fetch direct package names. + * @returns A promise that resolves to an array of package name strings, or undefined if not supported. + */ + fetchDirectPackageNames?(environment: PythonEnvironment): Promise | undefined>; + /** * Clears the package manager's cache. * @returns A promise that resolves when the cache is cleared. diff --git a/src/features/views/treeViewItems.ts b/src/features/views/treeViewItems.ts index f79cb948..9a7689e1 100644 --- a/src/features/views/treeViewItems.ts +++ b/src/features/views/treeViewItems.ts @@ -210,7 +210,7 @@ export class PackageTreeItem implements EnvTreeItem { public readonly manager: InternalPackageManager, ) { const item = new TreeItem(pkg.displayName); - item.iconPath = pkg.iconPath; + item.iconPath = pkg.isTransitive ? new ThemeIcon('list-tree') : new ThemeIcon('package'); item.contextValue = 'python-package'; item.description = pkg.description ?? pkg.version; item.tooltip = pkg.tooltip; diff --git a/src/managers/builtin/pipListUtils.ts b/src/managers/builtin/pipListUtils.ts index e0ca55ca..c6a87308 100644 --- a/src/managers/builtin/pipListUtils.ts +++ b/src/managers/builtin/pipListUtils.ts @@ -4,6 +4,19 @@ export interface PipPackage { displayName: string; description: string; } +export function isValidVersion(version: string): boolean { + return /^([1-9][0-9]*!)?(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))*((a|b|rc)(0|[1-9][0-9]*))?(\.post(0|[1-9][0-9]*))?(\.dev(0|[1-9][0-9]*))?$/.test( + version, + ); +} + +export function parseUvTree(data: string): string[] { + return data + .split('\n') + .map((line) => line.trim()) + .map((line) => line.split(/\s+/, 1)[0]) + .filter((name) => !!name); +} export function parsePipListJson(data: string): PipPackage[] { try { diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 1a517adc..f89c5345 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -21,7 +21,7 @@ import { } from '../../api'; import { updatePackagesAndNotify } from '../common/packageChanges'; import { getWorkspacePackagesToInstall } from './pipUtils'; -import { managePackages, refreshPipPackages } from './utils'; +import { managePackages, refreshPipDirectPackageNames, refreshPipPackages } from './utils'; import { VenvManager } from './venvManager'; export class PipPackageManager implements PackageManager, Disposable { @@ -129,4 +129,9 @@ export class PipPackageManager implements PackageManager, Disposable { this._onDidChangePackages.dispose(); this.packages.clear(); } + + async fetchDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { + const data = await refreshPipDirectPackageNames(environment, this.log); + return data ? new Set(data) : undefined; + } } diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index 5d7141a7..53c6794c 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -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'; @@ -243,6 +243,26 @@ export async function refreshPipPackages( } } +export async function refreshPipDirectPackageNames( + environment: PythonEnvironment, + log?: LogOutputChannel, +): Promise { + 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 = parsePipList(data); + return packages.map((pkg) => pkg.name); +} + export async function managePackages( environment: PythonEnvironment, options: PackageManagementOptions, diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index c2afa122..bc13d1ee 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -48,6 +48,11 @@ export async function updatePackagesAndNotify( onChanges: PackageChangesCallback, ): Promise { const after = (await packageManager.getPackages(environment, { skipCache: true })) ?? []; + const afterDirectDependenciesNames = + (await packageManager.fetchDirectPackageNames?.(environment)) ?? new Set(); + for (const pkg of after) { + pkg.isTransitive = !afterDirectDependenciesNames.has(pkg.name); + } const changes = getPackageChanges(before ?? [], after); if (changes.length > 0) { onChanges(changes); diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 0498e225..ed18b948 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -263,6 +263,21 @@ export class PoetryPackageManager implements PackageManager, Disposable { // Convert to Package objects using the API return poetryPackages.map((pkg) => this.api.createPackageItem(pkg, environment, this)); } + + async fetchDirectPackageNames(_environment: PythonEnvironment): Promise | undefined> { + try { + const topLevelResult = await runPoetry(['show', '--no-ansi', '--tree'], undefined, this.log); + const names = topLevelResult + .split('\n') + .map((line) => line.trim()) + .map((line) => line.match(/^(\S+)/)?.[1] ?? '') // Extract package name from lines like "├── package (version)" + .filter((name) => !!name); // Filter out empty names + return new Set(names); + } catch (err) { + this.log.error(`Error fetching direct package names with Poetry: ${err}`); + return undefined; + } + } } export async function runPoetry( From b61b80644ebaba19841a33837059f19ec0b6ef78 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 28 May 2026 14:15:44 -0700 Subject: [PATCH 03/13] Rename method and refresh --- src/api.ts | 2 +- src/features/views/envManagersView.ts | 1 + src/features/views/projectView.ts | 1 + src/managers/builtin/pipPackageManager.ts | 2 +- src/managers/builtin/utils.ts | 2 +- src/managers/common/packageChanges.ts | 12 +++++++++--- src/managers/poetry/poetryPackageManager.ts | 2 +- 7 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/api.ts b/src/api.ts index c42da384..04dcc982 100644 --- a/src/api.ts +++ b/src/api.ts @@ -691,7 +691,7 @@ export interface PackageManager { * @param environment - The Python environment for which to fetch direct package names. * @returns A promise that resolves to an array of package name strings, or undefined if not supported. */ - fetchDirectPackageNames?(environment: PythonEnvironment): Promise | undefined>; + getDirectPackageNames?(environment: PythonEnvironment): Promise | undefined>; /** * Clears the package manager's cache. diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index 131484e6..decfa9c3 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -252,6 +252,7 @@ export class EnvManagerView implements TreeDataProvider, Disposable const views: EnvTreeItem[] = []; if (pkgManager) { + await pkgManager.refresh(environment); const packages = await pkgManager.getPackages(environment); if (packages && packages.length > 0) { views.push(...packages.map((p) => new PackageTreeItem(p, parent, pkgManager))); diff --git a/src/features/views/projectView.ts b/src/features/views/projectView.ts index ba689ad9..57db0ab0 100644 --- a/src/features/views/projectView.ts +++ b/src/features/views/projectView.ts @@ -244,6 +244,7 @@ export class ProjectView implements TreeDataProvider { return [new ProjectEnvironmentInfo(environmentItem, ProjectViews.noPackageManager)]; } + await pkgManager.refresh(environment); let packages = await pkgManager.getPackages(environment); if (!packages) { return [new ProjectEnvironmentInfo(environmentItem, ProjectViews.noPackages)]; diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index f89c5345..8e39e8d7 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -130,7 +130,7 @@ export class PipPackageManager implements PackageManager, Disposable { this.packages.clear(); } - async fetchDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { + async getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { const data = await refreshPipDirectPackageNames(environment, this.log); return data ? new Set(data) : undefined; } diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index 53c6794c..fef51954 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -259,7 +259,7 @@ export async function refreshPipDirectPackageNames( return parseUvTree(treeOutput); } const data = await execPipList(environment, log, ['--not-required']); - const packages = parsePipList(data); + const packages = parsePipListJson(data); return packages.map((pkg) => pkg.name); } diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index bc13d1ee..8595633e 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -48,11 +48,17 @@ export async function updatePackagesAndNotify( onChanges: PackageChangesCallback, ): Promise { const after = (await packageManager.getPackages(environment, { skipCache: true })) ?? []; + + // Handle transitive dependencies const afterDirectDependenciesNames = - (await packageManager.fetchDirectPackageNames?.(environment)) ?? new Set(); - for (const pkg of after) { - pkg.isTransitive = !afterDirectDependenciesNames.has(pkg.name); + (await packageManager.getDirectPackageNames?.(environment)) ?? new Set(); + if (afterDirectDependenciesNames.size > 0) { + for (const pkg of after) { + pkg.isTransitive = !afterDirectDependenciesNames.has(pkg.name); + } } + + // Fire change event const changes = getPackageChanges(before ?? [], after); if (changes.length > 0) { onChanges(changes); diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index ed18b948..d3db2d36 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -264,7 +264,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { return poetryPackages.map((pkg) => this.api.createPackageItem(pkg, environment, this)); } - async fetchDirectPackageNames(_environment: PythonEnvironment): Promise | undefined> { + async getDirectPackageNames(_environment: PythonEnvironment): Promise | undefined> { try { const topLevelResult = await runPoetry(['show', '--no-ansi', '--tree'], undefined, this.log); const names = topLevelResult From 6afd2b1706d46a8aca3a0a65ae250f1a284dd026 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 28 May 2026 14:34:58 -0700 Subject: [PATCH 04/13] Sort packages by depth --- src/features/views/envManagersView.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index decfa9c3..78148937 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -255,7 +255,11 @@ export class EnvManagerView implements TreeDataProvider, Disposable await pkgManager.refresh(environment); const packages = await pkgManager.getPackages(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)), + ); } else { views.push(new EnvInfoTreeItem(parent, ProjectViews.noPackages)); } From d9f989d7d0592ef8914e19f8741189bbb654fd97 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 28 May 2026 16:20:34 -0700 Subject: [PATCH 05/13] Update package tree item view --- src/features/envCommands.ts | 8 ++++++++ src/features/views/treeViewItems.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index a10f8885..df183fe4 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -305,6 +305,14 @@ export async function removeEnvironmentCommand(context: unknown, managers: Envir export async function handlePackageUninstall(context: unknown, em: EnvironmentManagers) { if (context instanceof PackageTreeItem || context instanceof ProjectPackage) { + // Ask for user confirmation if the package is transitive + if (context.pkg.isTransitive) { + const message = `The package "${context.pkg.name}" is a transitive dependency. Uninstalling it may break other packages that depend on it. Are you sure you want to uninstall it?`; + const confirm = await showInformationMessage(message, { modal: true }, 'Uninstall', 'Cancel'); + if (confirm !== 'Uninstall') { + return; + } + } const moduleName = context.pkg.name; const environment = context instanceof ProjectPackage ? context.parent.environment : context.parent.environment; const packageManager = em.getPackageManager(environment); diff --git a/src/features/views/treeViewItems.ts b/src/features/views/treeViewItems.ts index 9a7689e1..b86e2b19 100644 --- a/src/features/views/treeViewItems.ts +++ b/src/features/views/treeViewItems.ts @@ -212,7 +212,7 @@ export class PackageTreeItem implements EnvTreeItem { const item = new TreeItem(pkg.displayName); item.iconPath = pkg.isTransitive ? new ThemeIcon('list-tree') : new ThemeIcon('package'); item.contextValue = 'python-package'; - item.description = pkg.description ?? pkg.version; + item.description = (pkg.isTransitive ? '(dependency) ' : '') + (pkg.description ?? pkg.version); item.tooltip = pkg.tooltip; this.treeItem = item; } From 3f579f5e9ddc1b966ff770d8256b438125ecad25 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 10 Jun 2026 14:58:13 -0700 Subject: [PATCH 06/13] Disable commands on transitive packages --- package-lock.json | 20 ++++++++++++++++++-- src/features/views/treeViewItems.ts | 4 ++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed794920..f5f09184 100644 --- a/package-lock.json +++ b/package-lock.json @@ -812,6 +812,7 @@ "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -1520,6 +1521,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1794,6 +1796,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2532,6 +2535,7 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4875,6 +4879,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5548,6 +5553,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5674,6 +5680,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -5722,6 +5729,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -6532,6 +6540,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.16.0.tgz", "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, + "peer": true, "requires": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -7039,7 +7048,8 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true + "dev": true, + "peer": true }, "acorn-import-phases": { "version": "1.0.4", @@ -7226,6 +7236,7 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, + "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7736,6 +7747,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, + "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9417,6 +9429,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9873,7 +9886,8 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true + "dev": true, + "peer": true }, "uc.micro": { "version": "1.0.6", @@ -9959,6 +9973,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, + "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -9992,6 +10007,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/src/features/views/treeViewItems.ts b/src/features/views/treeViewItems.ts index b86e2b19..ecf1aa3f 100644 --- a/src/features/views/treeViewItems.ts +++ b/src/features/views/treeViewItems.ts @@ -211,7 +211,7 @@ export class PackageTreeItem implements EnvTreeItem { ) { const item = new TreeItem(pkg.displayName); item.iconPath = pkg.isTransitive ? new ThemeIcon('list-tree') : new ThemeIcon('package'); - item.contextValue = 'python-package'; + item.contextValue = pkg.isTransitive ? 'python-package-transitive' : 'python-package'; item.description = (pkg.isTransitive ? '(dependency) ' : '') + (pkg.description ?? pkg.version); item.tooltip = pkg.tooltip; this.treeItem = item; @@ -431,7 +431,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; From a47a33d5943158e06603ca48720b025be91731e6 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 11 Jun 2026 15:47:41 -0700 Subject: [PATCH 07/13] Remove unused isValidVersion function Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/managers/builtin/pipListUtils.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/managers/builtin/pipListUtils.ts b/src/managers/builtin/pipListUtils.ts index c6a87308..d168bb8a 100644 --- a/src/managers/builtin/pipListUtils.ts +++ b/src/managers/builtin/pipListUtils.ts @@ -4,12 +4,6 @@ export interface PipPackage { displayName: string; description: string; } -export function isValidVersion(version: string): boolean { - return /^([1-9][0-9]*!)?(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))*((a|b|rc)(0|[1-9][0-9]*))?(\.post(0|[1-9][0-9]*))?(\.dev(0|[1-9][0-9]*))?$/.test( - version, - ); -} - export function parseUvTree(data: string): string[] { return data .split('\n') From ab8468bf51bdea881bec3bde3ba79f0a36cbd564 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 11 Jun 2026 15:55:21 -0700 Subject: [PATCH 08/13] Restore package-lock.json --- package-lock.json | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index f5f09184..ed794920 100644 --- a/package-lock.json +++ b/package-lock.json @@ -812,7 +812,6 @@ "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -1521,7 +1520,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1796,7 +1794,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2535,7 +2532,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4879,7 +4875,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5553,7 +5548,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5680,7 +5674,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -5729,7 +5722,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -6540,7 +6532,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.16.0.tgz", "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, - "peer": true, "requires": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -7048,8 +7039,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "peer": true + "dev": true }, "acorn-import-phases": { "version": "1.0.4", @@ -7236,7 +7226,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, - "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7747,7 +7736,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, - "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9429,7 +9417,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9886,8 +9873,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, - "peer": true + "dev": true }, "uc.micro": { "version": "1.0.6", @@ -9973,7 +9959,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, - "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -10007,7 +9992,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", From efcbdb75dc21e3bd736e4076f0c2566a2e35b5e9 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 11 Jun 2026 15:58:32 -0700 Subject: [PATCH 09/13] Rename --- src/features/views/treeViewItems.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/views/treeViewItems.ts b/src/features/views/treeViewItems.ts index ecf1aa3f..c0c42dd5 100644 --- a/src/features/views/treeViewItems.ts +++ b/src/features/views/treeViewItems.ts @@ -212,7 +212,7 @@ export class PackageTreeItem implements EnvTreeItem { const item = new TreeItem(pkg.displayName); item.iconPath = pkg.isTransitive ? new ThemeIcon('list-tree') : new ThemeIcon('package'); item.contextValue = pkg.isTransitive ? 'python-package-transitive' : 'python-package'; - item.description = (pkg.isTransitive ? '(dependency) ' : '') + (pkg.description ?? pkg.version); + item.description = (pkg.isTransitive ? '(transitive) ' : '') + (pkg.description ?? pkg.version); item.tooltip = pkg.tooltip; this.treeItem = item; } From 6392a2ecb59af448f02a602fba8f7b12c223602f Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 11 Jun 2026 16:07:17 -0700 Subject: [PATCH 10/13] Add tests --- .../common/packageChanges.unit.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts index 81e8235f..7d3d5acd 100644 --- a/src/test/managers/common/packageChanges.unit.test.ts +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -169,5 +169,101 @@ suite('packageChanges', () => { assert.ok(changes.some((c: { kind: PackageChangeKind }) => c.kind === PackageChangeKind.add)); assert.ok(changes.some((c: { kind: PackageChangeKind }) => c.kind === PackageChangeKind.remove)); }); + + test('marks transitive packages when getDirectPackageNames is provided', async () => { + const after = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'urllib3', version: '2.0.0' } as Package, + { name: 'charset-normalizer', version: '3.0.0' } as Package, + ]; + getPackagesStub.resolves(after); + const getDirectPackageNamesStub = sinon.stub().resolves(new Set(['requests'])); + (packageManager as unknown as Record).getDirectPackageNames = getDirectPackageNamesStub; + const onChanges = sinon.stub(); + + await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + + assert.strictEqual(after[0].isTransitive, false, 'requests should be direct'); + assert.strictEqual(after[1].isTransitive, true, 'urllib3 should be transitive'); + assert.strictEqual(after[2].isTransitive, true, 'charset-normalizer should be transitive'); + }); + + test('does not mark packages transitive when getDirectPackageNames is not implemented', async () => { + const after = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'urllib3', version: '2.0.0' } as Package, + ]; + getPackagesStub.resolves(after); + const onChanges = sinon.stub(); + + await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + + assert.strictEqual(after[0].isTransitive, undefined, 'should not be set'); + assert.strictEqual(after[1].isTransitive, undefined, 'should not be set'); + }); + + test('does not mark packages transitive when getDirectPackageNames returns undefined', async () => { + const after = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'urllib3', version: '2.0.0' } as Package, + ]; + getPackagesStub.resolves(after); + const getDirectPackageNamesStub = sinon.stub().resolves(undefined); + (packageManager as unknown as Record).getDirectPackageNames = getDirectPackageNamesStub; + const onChanges = sinon.stub(); + + await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + + assert.strictEqual(after[0].isTransitive, undefined, 'should not be set'); + assert.strictEqual(after[1].isTransitive, undefined, 'should not be set'); + }); + + test('does not mark packages transitive when getDirectPackageNames returns empty set', async () => { + const after = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'urllib3', version: '2.0.0' } as Package, + ]; + getPackagesStub.resolves(after); + const getDirectPackageNamesStub = sinon.stub().resolves(new Set()); + (packageManager as unknown as Record).getDirectPackageNames = getDirectPackageNamesStub; + const onChanges = sinon.stub(); + + await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + + assert.strictEqual(after[0].isTransitive, undefined, 'should not be set'); + assert.strictEqual(after[1].isTransitive, undefined, 'should not be set'); + }); + + test('all packages marked direct when all are in direct set', async () => { + const after = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'flask', version: '3.0.0' } as Package, + ]; + getPackagesStub.resolves(after); + const getDirectPackageNamesStub = sinon.stub().resolves(new Set(['requests', 'flask'])); + (packageManager as unknown as Record).getDirectPackageNames = getDirectPackageNamesStub; + const onChanges = sinon.stub(); + + await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + + assert.strictEqual(after[0].isTransitive, false, 'requests should be direct'); + assert.strictEqual(after[1].isTransitive, false, 'flask should be direct'); + }); + + test('all packages marked transitive when none are in direct set', async () => { + const after = [ + { name: 'urllib3', version: '2.0.0' } as Package, + { name: 'charset-normalizer', version: '3.0.0' } as Package, + ]; + getPackagesStub.resolves(after); + const getDirectPackageNamesStub = sinon.stub().resolves(new Set(['requests'])); + (packageManager as unknown as Record).getDirectPackageNames = getDirectPackageNamesStub; + const onChanges = sinon.stub(); + + await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + + assert.strictEqual(after[0].isTransitive, true, 'urllib3 should be transitive'); + assert.strictEqual(after[1].isTransitive, true, 'charset-normalizer should be transitive'); + }); }); }); From 8283e818c1cb49ebe7b9813b9908be2976662124 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Fri, 12 Jun 2026 11:51:12 -0700 Subject: [PATCH 11/13] Address PR review comments for transitive packages - Log parse failures in parsePipListJson() instead of silently returning [] - Make isTransitive readonly on PackageInfo and PythonPackageImpl - Rename fetchDirectPackageNames to getDirectPackageNames in public API - Fix JSDoc to say Set instead of array - Add isTransitive to public API PackageInfo - Localize transitive uninstall confirmation and (transitive) prefix - Respect pkg.iconPath, only fallback to ThemeIcon - Wrap getDirectPackageNames in try/catch for error isolation - Use poetry show --top-level instead of --tree; fix glyph regex - Only refresh packages when cache is empty, not on every expansion - Add unit tests for parsePipListJson, parseUvTree, and error handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/src/main.ts | 9 ++- src/api.ts | 4 +- src/features/envCommands.ts | 13 +++- src/features/views/envManagersView.ts | 7 +- src/features/views/projectView.ts | 5 +- src/features/views/treeViewItems.ts | 7 +- src/internal.api.ts | 3 + src/managers/builtin/pipListUtils.ts | 8 +- src/managers/builtin/utils.ts | 2 +- src/managers/common/packageChanges.ts | 14 ++-- src/managers/poetry/poetryPackageManager.ts | 6 +- .../builtin/pipListUtils.unit.test.ts | 73 ++++++++++++++++++- .../common/packageChanges.unit.test.ts | 17 +++++ 13 files changed, 140 insertions(+), 28 deletions(-) diff --git a/api/src/main.ts b/api/src/main.ts index fabd42c5..e7ac1ec0 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -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; } /** @@ -690,9 +695,9 @@ export interface PackageManager { /** * Fetches the names of direct (non-transitive) packages for the specified Python environment. * @param environment - The Python environment for which to fetch direct package names. - * @returns A promise that resolves to an array of package name strings, or undefined if not supported. + * @returns A promise that resolves to a set of package name strings, or undefined if not supported. */ - fetchDirectPackageNames?(environment: PythonEnvironment): Promise | undefined>; + getDirectPackageNames?(environment: PythonEnvironment): Promise | undefined>; /** * Clears the package manager's cache. diff --git a/src/api.ts b/src/api.ts index 04dcc982..d57d915b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -576,7 +576,7 @@ export interface PackageInfo { /** * Whether the package is a transitive dependency. */ - isTransitive?: boolean; + readonly isTransitive?: boolean; } /** @@ -689,7 +689,7 @@ export interface PackageManager { /** * Fetches the names of direct (non-transitive) packages for the specified Python environment. * @param environment - The Python environment for which to fetch direct package names. - * @returns A promise that resolves to an array of package name strings, or undefined if not supported. + * @returns A promise that resolves to a set of package name strings, or undefined if not supported. */ getDirectPackageNames?(environment: PythonEnvironment): Promise | undefined>; diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index df183fe4..d16f3e6f 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -307,9 +307,16 @@ export async function handlePackageUninstall(context: unknown, em: EnvironmentMa if (context instanceof PackageTreeItem || context instanceof ProjectPackage) { // Ask for user confirmation if the package is transitive if (context.pkg.isTransitive) { - const message = `The package "${context.pkg.name}" is a transitive dependency. Uninstalling it may break other packages that depend on it. Are you sure you want to uninstall it?`; - const confirm = await showInformationMessage(message, { modal: true }, 'Uninstall', 'Cancel'); - if (confirm !== 'Uninstall') { + const confirm = await showInformationMessage( + l10n.t( + 'The package "{0}" is a transitive dependency. Uninstalling it may break other packages that depend on it. Are you sure you want to uninstall it?', + context.pkg.name, + ), + { modal: true }, + l10n.t('Uninstall'), + l10n.t('Cancel'), + ); + if (confirm !== l10n.t('Uninstall')) { return; } } diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index 78148937..de5ec305 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -252,8 +252,11 @@ export class EnvManagerView implements TreeDataProvider, Disposable const views: EnvTreeItem[] = []; if (pkgManager) { - await pkgManager.refresh(environment); - const packages = await pkgManager.getPackages(environment); + let packages = await pkgManager.getPackages(environment); + if (!packages || packages.length === 0) { + await pkgManager.refresh(environment); + packages = await pkgManager.getPackages(environment); + } if (packages && packages.length > 0) { views.push( ...packages diff --git a/src/features/views/projectView.ts b/src/features/views/projectView.ts index 57db0ab0..38ccc469 100644 --- a/src/features/views/projectView.ts +++ b/src/features/views/projectView.ts @@ -244,8 +244,11 @@ export class ProjectView implements TreeDataProvider { return [new ProjectEnvironmentInfo(environmentItem, ProjectViews.noPackageManager)]; } - await pkgManager.refresh(environment); let packages = await pkgManager.getPackages(environment); + if (!packages || packages.length === 0) { + await pkgManager.refresh(environment); + packages = await pkgManager.getPackages(environment); + } if (!packages) { return [new ProjectEnvironmentInfo(environmentItem, ProjectViews.noPackages)]; } diff --git a/src/features/views/treeViewItems.ts b/src/features/views/treeViewItems.ts index c0c42dd5..577ddf8c 100644 --- a/src/features/views/treeViewItems.ts +++ b/src/features/views/treeViewItems.ts @@ -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'; @@ -210,9 +210,10 @@ export class PackageTreeItem implements EnvTreeItem { public readonly manager: InternalPackageManager, ) { const item = new TreeItem(pkg.displayName); - item.iconPath = pkg.isTransitive ? new ThemeIcon('list-tree') : new ThemeIcon('package'); + 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 ? '(transitive) ' : '') + (pkg.description ?? pkg.version); + item.description = (pkg.isTransitive ? l10n.t('(transitive) ') : '') + (pkg.description ?? pkg.version); item.tooltip = pkg.tooltip; this.treeItem = item; } diff --git a/src/internal.api.ts b/src/internal.api.ts index 4c80b527..8d2f547a 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -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, @@ -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; } } diff --git a/src/managers/builtin/pipListUtils.ts b/src/managers/builtin/pipListUtils.ts index d168bb8a..80519d89 100644 --- a/src/managers/builtin/pipListUtils.ts +++ b/src/managers/builtin/pipListUtils.ts @@ -1,3 +1,5 @@ +import { LogOutputChannel } from 'vscode'; + export interface PipPackage { name: string; version: string; @@ -12,7 +14,7 @@ export function parseUvTree(data: string): string[] { .filter((name) => !!name); } -export function parsePipListJson(data: string): PipPackage[] { +export function parsePipListJson(data: string, log?: LogOutputChannel): PipPackage[] { try { const json = JSON.parse(data); if (Array.isArray(json)) { @@ -25,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 []; } diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index fef51954..a57e7a3b 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -235,7 +235,7 @@ 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); diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index 8595633e..cbcfba22 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -49,12 +49,16 @@ export async function updatePackagesAndNotify( ): Promise { const after = (await packageManager.getPackages(environment, { skipCache: true })) ?? []; - // Handle transitive dependencies - const afterDirectDependenciesNames = - (await packageManager.getDirectPackageNames?.(environment)) ?? new Set(); - if (afterDirectDependenciesNames.size > 0) { + // Handle transitive dependencies (best-effort, don't break package refresh on failure) + let afterDirectDependenciesNames: Set | undefined; + try { + afterDirectDependenciesNames = await packageManager.getDirectPackageNames?.(environment); + } catch { + // If direct package detection fails, leave isTransitive undefined rather than breaking refresh + } + if (afterDirectDependenciesNames && afterDirectDependenciesNames.size > 0) { for (const pkg of after) { - pkg.isTransitive = !afterDirectDependenciesNames.has(pkg.name); + (pkg as { isTransitive?: boolean }).isTransitive = !afterDirectDependenciesNames.has(pkg.name); } } diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index d3db2d36..b4d646f4 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -266,12 +266,12 @@ export class PoetryPackageManager implements PackageManager, Disposable { async getDirectPackageNames(_environment: PythonEnvironment): Promise | undefined> { try { - const topLevelResult = await runPoetry(['show', '--no-ansi', '--tree'], undefined, this.log); + const topLevelResult = await runPoetry(['show', '--no-ansi', '--top-level'], undefined, this.log); const names = topLevelResult .split('\n') .map((line) => line.trim()) - .map((line) => line.match(/^(\S+)/)?.[1] ?? '') // Extract package name from lines like "├── package (version)" - .filter((name) => !!name); // Filter out empty names + .map((line) => line.match(/^([a-zA-Z0-9_-]+)/)?.[1] ?? '') + .filter((name) => !!name); return new Set(names); } catch (err) { this.log.error(`Error fetching direct package names with Poetry: ${err}`); diff --git a/src/test/managers/builtin/pipListUtils.unit.test.ts b/src/test/managers/builtin/pipListUtils.unit.test.ts index 6ba342de..0bc978e9 100644 --- a/src/test/managers/builtin/pipListUtils.unit.test.ts +++ b/src/test/managers/builtin/pipListUtils.unit.test.ts @@ -1,12 +1,28 @@ import assert from 'assert'; import * as fs from 'fs-extra'; import * as path from 'path'; -import { parsePipListJson } from '../../../managers/builtin/pipListUtils'; +import * as sinon from 'sinon'; +import { LogOutputChannel } from 'vscode'; +import { parsePipListJson, parseUvTree } from '../../../managers/builtin/pipListUtils'; import { EXTENSION_TEST_ROOT } from '../../constants'; const TEST_DATA_ROOT = path.join(EXTENSION_TEST_ROOT, 'managers', 'builtin'); suite('Pip List JSON Parser tests', () => { + let log: LogOutputChannel; + + setup(() => { + log = { + error: sinon.stub(), + warn: sinon.stub(), + info: sinon.stub(), + } as unknown as LogOutputChannel; + }); + + teardown(() => { + sinon.restore(); + }); + const testNames = ['piplist1', 'piplist2', 'piplist3']; testNames.forEach((testName) => { @@ -16,7 +32,7 @@ suite('Pip List JSON Parser tests', () => { ); const pipListOutput = JSON.stringify(expected.packages); - const actualPackages = parsePipListJson(pipListOutput); + const actualPackages = parsePipListJson(pipListOutput, log); assert.equal(actualPackages.length, expected.packages.length, 'Unexpected number of packages'); actualPackages.forEach((actualPackage) => { @@ -36,12 +52,23 @@ suite('Pip List JSON Parser tests', () => { }); test('Returns an empty array for invalid JSON input', () => { - assert.deepStrictEqual(parsePipListJson('not json'), []); + assert.deepStrictEqual(parsePipListJson('not json', log), []); + }); + + test('Logs error when JSON parsing fails', () => { + parsePipListJson('not valid json', log); + assert.ok((log.error as sinon.SinonStub).calledOnce, 'Expected error to be logged'); + }); + + test('Returns empty array without logging when no log is provided', () => { + const result = parsePipListJson('not valid json'); + assert.deepStrictEqual(result, []); }); test('Skips items without a name or version', () => { const actualPackages = parsePipListJson( JSON.stringify([{ name: 'pip', version: '24.0' }, { name: 'setuptools' }, { version: '1.0.0' }]), + log, ); assert.deepStrictEqual(actualPackages, [ @@ -53,4 +80,44 @@ suite('Pip List JSON Parser tests', () => { }, ]); }); + + test('Returns empty array for non-array JSON', () => { + const result = parsePipListJson('{"name": "pip"}', log); + assert.deepStrictEqual(result, []); + }); + + test('Returns empty array for empty array JSON', () => { + const result = parsePipListJson('[]', log); + assert.deepStrictEqual(result, []); + }); +}); + +suite('parseUvTree tests', () => { + test('Parses uv pip tree output with depth 0', () => { + const input = 'requests v2.31.0\nflask v3.0.0\n'; + const result = parseUvTree(input); + assert.deepStrictEqual(result, ['requests', 'flask']); + }); + + test('Handles empty output', () => { + assert.deepStrictEqual(parseUvTree(''), []); + }); + + test('Filters blank lines', () => { + const input = 'requests v2.31.0\n\n\nflask v3.0.0\n'; + const result = parseUvTree(input); + assert.deepStrictEqual(result, ['requests', 'flask']); + }); + + test('Handles single package', () => { + const input = 'pip v24.0\n'; + const result = parseUvTree(input); + assert.deepStrictEqual(result, ['pip']); + }); + + test('Trims leading whitespace from indented lines', () => { + const input = ' requests v2.31.0\n flask v3.0.0\n'; + const result = parseUvTree(input); + assert.deepStrictEqual(result, ['requests', 'flask']); + }); }); diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts index 7d3d5acd..ca1b064b 100644 --- a/src/test/managers/common/packageChanges.unit.test.ts +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -265,5 +265,22 @@ suite('packageChanges', () => { assert.strictEqual(after[0].isTransitive, true, 'urllib3 should be transitive'); assert.strictEqual(after[1].isTransitive, true, 'charset-normalizer should be transitive'); }); + + test('leaves isTransitive undefined when getDirectPackageNames throws', async () => { + const after = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'urllib3', version: '2.0.0' } as Package, + ]; + getPackagesStub.resolves(after); + const getDirectPackageNamesStub = sinon.stub().rejects(new Error('command failed')); + (packageManager as unknown as Record).getDirectPackageNames = getDirectPackageNamesStub; + const onChanges = sinon.stub(); + + await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + + assert.strictEqual(after[0].isTransitive, undefined, 'should not be set on error'); + assert.strictEqual(after[1].isTransitive, undefined, 'should not be set on error'); + assert.ok(onChanges.calledOnce, 'should still fire change event'); + }); }); }); From 2ddfad49ffabb638db6b3136616534d502843585 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Fri, 12 Jun 2026 11:59:52 -0700 Subject: [PATCH 12/13] Update src/features/envCommands.ts --- src/features/envCommands.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index d16f3e6f..6c0a53d7 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -307,18 +307,7 @@ export async function handlePackageUninstall(context: unknown, em: EnvironmentMa if (context instanceof PackageTreeItem || context instanceof ProjectPackage) { // Ask for user confirmation if the package is transitive if (context.pkg.isTransitive) { - const confirm = await showInformationMessage( - l10n.t( - 'The package "{0}" is a transitive dependency. Uninstalling it may break other packages that depend on it. Are you sure you want to uninstall it?', - context.pkg.name, - ), - { modal: true }, - l10n.t('Uninstall'), - l10n.t('Cancel'), - ); - if (confirm !== l10n.t('Uninstall')) { - return; - } + return; } const moduleName = context.pkg.name; const environment = context instanceof ProjectPackage ? context.parent.environment : context.parent.environment; From 46325476275ab17e70d99998a13f4e1f793e444a Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Fri, 12 Jun 2026 16:14:31 -0700 Subject: [PATCH 13/13] Block uninstall of transitive packages instead of confirming Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/features/envCommands.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 6c0a53d7..407e09ec 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -305,7 +305,6 @@ export async function removeEnvironmentCommand(context: unknown, managers: Envir export async function handlePackageUninstall(context: unknown, em: EnvironmentManagers) { if (context instanceof PackageTreeItem || context instanceof ProjectPackage) { - // Ask for user confirmation if the package is transitive if (context.pkg.isTransitive) { return; }