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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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; } From 21be3e22b17f91bf4e87604a81aa833a63f3bedd Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 15 Jun 2026 12:40:04 -0700 Subject: [PATCH 14/20] Refactor --- api/src/main.ts | 8 ++++---- src/api.ts | 8 ++++---- src/features/pythonApi.ts | 2 +- src/features/views/envManagersView.ts | 6 +----- src/features/views/projectView.ts | 6 +----- src/internal.api.ts | 2 +- src/managers/builtin/pipPackageManager.ts | 15 ++++++++++----- src/managers/common/packageChanges.ts | 17 ++++++++--------- src/managers/conda/condaPackageManager.ts | 15 ++++++++++----- src/managers/poetry/poetryPackageManager.ts | 7 ++++--- 10 files changed, 44 insertions(+), 42 deletions(-) diff --git a/api/src/main.ts b/api/src/main.ts index e7ac1ec0..8d8eb0c8 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -675,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; + refresh(environment: PythonEnvironment): Promise; /** * Retrieves the list of packages for the specified Python environment. @@ -1041,9 +1041,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; + refreshPackages(environment: PythonEnvironment): Promise; /** * Get the list of packages in a Python Environment. diff --git a/src/api.ts b/src/api.ts index d57d915b..d13ecc2c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -669,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; + refresh(environment: PythonEnvironment): Promise; /** * Retrieves the list of packages for the specified Python environment. @@ -1035,9 +1035,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; + refreshPackages(environment: PythonEnvironment): Promise; /** * Get the list of packages in a Python Environment. diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index b20de34f..1cd8217a 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -250,7 +250,7 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi { } return manager.manage(context, options); } - async refreshPackages(context: PythonEnvironment): Promise { + async refreshPackages(context: PythonEnvironment): Promise { await waitForEnvManagerId([context.envId.managerId]); const manager = this.envManagers.getPackageManager(context); if (!manager) { diff --git a/src/features/views/envManagersView.ts b/src/features/views/envManagersView.ts index de5ec305..044815aa 100644 --- a/src/features/views/envManagersView.ts +++ b/src/features/views/envManagersView.ts @@ -252,11 +252,7 @@ export class EnvManagerView implements TreeDataProvider, Disposable const views: EnvTreeItem[] = []; if (pkgManager) { - let packages = await pkgManager.getPackages(environment); - if (!packages || packages.length === 0) { - await pkgManager.refresh(environment); - packages = await pkgManager.getPackages(environment); - } + let packages = await pkgManager.refresh(environment); if (packages && packages.length > 0) { views.push( ...packages diff --git a/src/features/views/projectView.ts b/src/features/views/projectView.ts index 38ccc469..e6fd7f3b 100644 --- a/src/features/views/projectView.ts +++ b/src/features/views/projectView.ts @@ -244,11 +244,7 @@ export class ProjectView implements TreeDataProvider { return [new ProjectEnvironmentInfo(environmentItem, ProjectViews.noPackageManager)]; } - let packages = await pkgManager.getPackages(environment); - if (!packages || packages.length === 0) { - await pkgManager.refresh(environment); - packages = await pkgManager.getPackages(environment); - } + let packages = await pkgManager.refresh(environment); if (!packages) { return [new ProjectEnvironmentInfo(environmentItem, ProjectViews.noPackages)]; } diff --git a/src/internal.api.ts b/src/internal.api.ts index 8d2f547a..801d4f91 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -364,7 +364,7 @@ export class InternalPackageManager implements PackageManager { } } - refresh(environment: PythonEnvironment): Promise { + refresh(environment: PythonEnvironment): Promise { return this.manager.refresh(environment); } diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 8e39e8d7..d51192bb 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -101,16 +101,21 @@ export class PipPackageManager implements PackageManager, Disposable { ); } - async refresh(environment: PythonEnvironment): Promise { - await window.withProgress( + async refresh(environment: PythonEnvironment): Promise { + 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 }); + }, + ); }, ); } diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index cbcfba22..0a535da7 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -46,16 +46,13 @@ export async function updatePackagesAndNotify( environment: PythonEnvironment, before: Package[] | undefined, onChanges: PackageChangesCallback, -): Promise { - const after = (await packageManager.getPackages(environment, { skipCache: true })) ?? []; +): Promise { + const [after, afterDirectDependenciesNames] = await Promise.all([ + packageManager.getPackages(environment, { skipCache: true }).then((pkgs) => pkgs ?? []), + // Handle transitive dependencies (best-effort, don't break package refresh on failure) + packageManager.getDirectPackageNames?.(environment).catch(() => undefined), + ]); - // 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 as { isTransitive?: boolean }).isTransitive = !afterDirectDependenciesNames.has(pkg.name); @@ -67,4 +64,6 @@ export async function updatePackagesAndNotify( if (changes.length > 0) { onChanges(changes); } + + return after; } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index 6a492ffa..27c26a4f 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -95,16 +95,21 @@ export class CondaPackageManager implements PackageManager, Disposable { ); } - async refresh(environment: PythonEnvironment): Promise { - await withProgress( + async refresh(environment: PythonEnvironment): Promise { + return withProgress( { location: ProgressLocation.Window, title: CondaStrings.condaRefreshingPackages, }, 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 }); + }, + ); }, ); } diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index b4d646f4..9172a2ce 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -108,15 +108,15 @@ export class PoetryPackageManager implements PackageManager, Disposable { ); } - async refresh(environment: PythonEnvironment): Promise { - await withProgress( + async refresh(environment: PythonEnvironment): Promise { + return withProgress( { location: ProgressLocation.Window, title: 'Refreshing Poetry packages', }, async () => { try { - await updatePackagesAndNotify( + return await updatePackagesAndNotify( this, environment, this.packages.get(environment.envId.id), @@ -133,6 +133,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { this.log.show(); } }); + return undefined; } }, ); From ea8bbce3f78466538cca89ee3f6a44840c1d4217 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 15 Jun 2026 12:45:26 -0700 Subject: [PATCH 15/20] Add normalizePackageName --- src/managers/builtin/pipPackageManager.ts | 4 +- src/managers/builtin/utils.ts | 4 ++ src/managers/common/packageChanges.ts | 13 ++++-- src/managers/poetry/poetryPackageManager.ts | 4 +- .../builtin/normalizePackageName.unit.test.ts | 46 +++++++++++++++++++ 5 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 src/test/managers/builtin/normalizePackageName.unit.test.ts diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index d51192bb..4fd0a367 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, refreshPipDirectPackageNames, refreshPipPackages } from './utils'; +import { managePackages, normalizePackageName, refreshPipDirectPackageNames, refreshPipPackages } from './utils'; import { VenvManager } from './venvManager'; export class PipPackageManager implements PackageManager, Disposable { @@ -137,6 +137,6 @@ export class PipPackageManager implements PackageManager, Disposable { async getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { const data = await refreshPipDirectPackageNames(environment, this.log); - return data ? new Set(data) : undefined; + return data ? new Set(data.map(normalizePackageName)) : undefined; } } diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index a57e7a3b..a57583fb 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -380,3 +380,7 @@ export async function resolveSystemPythonEnvironmentPath( } return undefined; } + +export function normalizePackageName(name: string): string { + return name.replace(/[-_.]+/g, '-').toLowerCase(); +} diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index 0a535da7..b97b0941 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { Package, PackageChangeKind, PackageManager, PythonEnvironment } from '../../api'; +import { normalizePackageName } from '../builtin/utils'; /** * Callback invoked with the computed changes when at least one change is detected. @@ -15,17 +16,17 @@ export type PackageChangesCallback = (changes: { kind: PackageChangeKind; pkg: P * @returns An array of changes indicating which packages were added or removed. */ export function getPackageChanges(before: Package[], after: Package[]): { kind: PackageChangeKind; pkg: Package }[] { - const beforeSet = new Set(before.map(({ name, version }) => `${name}==${version}`)); - const afterSet = new Set(after.map(({ name, version }) => `${name}==${version}`)); + const beforeSet = new Set(before.map(({ name, version }) => `${normalizePackageName(name)}==${version}`)); + const afterSet = new Set(after.map(({ name, version }) => `${normalizePackageName(name)}==${version}`)); const changes: { kind: PackageChangeKind; pkg: Package }[] = []; for (const pkg of after) { - if (!beforeSet.has(`${pkg.name}==${pkg.version}`)) { + if (!beforeSet.has(`${normalizePackageName(pkg.name)}==${pkg.version}`)) { changes.push({ kind: PackageChangeKind.add, pkg }); } } for (const pkg of before) { - if (!afterSet.has(`${pkg.name}==${pkg.version}`)) { + if (!afterSet.has(`${normalizePackageName(pkg.name)}==${pkg.version}`)) { changes.push({ kind: PackageChangeKind.remove, pkg }); } } @@ -55,7 +56,9 @@ export async function updatePackagesAndNotify( if (afterDirectDependenciesNames && afterDirectDependenciesNames.size > 0) { for (const pkg of after) { - (pkg as { isTransitive?: boolean }).isTransitive = !afterDirectDependenciesNames.has(pkg.name); + (pkg as { isTransitive?: boolean }).isTransitive = !afterDirectDependenciesNames.has( + normalizePackageName(pkg.name), + ); } } diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 9172a2ce..e2533b14 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -24,6 +24,7 @@ import { } from '../../api'; import { spawnProcess } from '../../common/childProcess.apis'; import { showErrorMessage, showInputBox, withProgress } from '../../common/window.apis'; +import { normalizePackageName } from '../builtin/utils'; import { updatePackagesAndNotify } from '../common/packageChanges'; import { PoetryManager } from './poetryManager'; import { getPoetry } from './poetryUtils'; @@ -272,7 +273,8 @@ export class PoetryPackageManager implements PackageManager, Disposable { .split('\n') .map((line) => line.trim()) .map((line) => line.match(/^([a-zA-Z0-9_-]+)/)?.[1] ?? '') - .filter((name) => !!name); + .filter((name) => !!name) + .map(normalizePackageName); return new Set(names); } catch (err) { this.log.error(`Error fetching direct package names with Poetry: ${err}`); diff --git a/src/test/managers/builtin/normalizePackageName.unit.test.ts b/src/test/managers/builtin/normalizePackageName.unit.test.ts new file mode 100644 index 00000000..cd59e39b --- /dev/null +++ b/src/test/managers/builtin/normalizePackageName.unit.test.ts @@ -0,0 +1,46 @@ +import assert from 'assert'; +import { normalizePackageName } from '../../../managers/builtin/utils'; + +suite('normalizePackageName', () => { + test('should lowercase names', () => { + assert.strictEqual(normalizePackageName('Requests'), 'requests'); + assert.strictEqual(normalizePackageName('NUMPY'), 'numpy'); + }); + + test('should replace underscores with hyphens', () => { + assert.strictEqual(normalizePackageName('my_package'), 'my-package'); + }); + + test('should replace dots with hyphens', () => { + assert.strictEqual(normalizePackageName('zope.interface'), 'zope-interface'); + }); + + test('should collapse consecutive separators into a single hyphen', () => { + assert.strictEqual(normalizePackageName('my__package'), 'my-package'); + assert.strictEqual(normalizePackageName('my_-package'), 'my-package'); + assert.strictEqual(normalizePackageName('my_.package'), 'my-package'); + }); + + test('should handle mixed separators and casing', () => { + assert.strictEqual(normalizePackageName('My_Package.Name'), 'my-package-name'); + assert.strictEqual(normalizePackageName('Foo-Bar_Baz'), 'foo-bar-baz'); + }); + + test('should return already-normalized names unchanged', () => { + assert.strictEqual(normalizePackageName('requests'), 'requests'); + assert.strictEqual(normalizePackageName('my-package'), 'my-package'); + }); + + test('should handle single-word names', () => { + assert.strictEqual(normalizePackageName('pip'), 'pip'); + }); + + test('should produce equal results for equivalent package names', () => { + const variants = ['My_Package', 'my-package', 'my.package', 'My.Package', 'MY_PACKAGE', 'my_package']; + const normalized = variants.map(normalizePackageName); + assert.ok( + normalized.every((n) => n === normalized[0]), + `All variants should normalize to the same value, got: ${JSON.stringify(normalized)}`, + ); + }); +}); From e3a41993ff3b5ca8da67717b9a15fb2113e66fbd Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 15 Jun 2026 12:54:54 -0700 Subject: [PATCH 16/20] Restore confirmation dialog for transitive package uninstall Show a modal warning with 'Uninstall Anyway' / 'Cancel' instead of silently blocking uninstall of transitive packages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/features/envCommands.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 407e09ec..d6a7f816 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -306,7 +306,18 @@ 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) { - return; + 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; + } } const moduleName = context.pkg.name; const environment = context instanceof ProjectPackage ? context.parent.environment : context.parent.environment; From 6f35266414370096a55d12c77aaded66ed6d3966 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 15 Jun 2026 12:58:33 -0700 Subject: [PATCH 17/20] Document caveats for getDirectPackageNames (best-effort, not install intent) pip list --not-required and uv pip tree --depth=0 return leaf packages (no dependents), not user-installed packages. Document this limitation on the PackageManager interface, pip implementation, and utility function. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/src/main.ts | 7 +++++++ src/api.ts | 7 +++++++ src/managers/builtin/pipPackageManager.ts | 6 ++++++ src/managers/builtin/utils.ts | 8 ++++++++ 4 files changed, 28 insertions(+) diff --git a/api/src/main.ts b/api/src/main.ts index 8d8eb0c8..d66f6bdc 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -694,6 +694,13 @@ export interface PackageManager { /** * 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. */ diff --git a/src/api.ts b/src/api.ts index d13ecc2c..6e07a505 100644 --- a/src/api.ts +++ b/src/api.ts @@ -688,6 +688,13 @@ export interface PackageManager { /** * 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. */ diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 4fd0a367..2a85f5ac 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -135,6 +135,12 @@ export class PipPackageManager implements PackageManager, Disposable { 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 | undefined> { const data = await refreshPipDirectPackageNames(environment, this.log); return data ? new Set(data.map(normalizePackageName)) : undefined; diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index a57583fb..dc44fe75 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -243,6 +243,14 @@ export async function refreshPipPackages( } } +/** + * 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, From b6cec0d9a6fdc2a882172572ef58471151191461 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 15 Jun 2026 13:08:20 -0700 Subject: [PATCH 18/20] Add tooltip for transitive packages explaining the caveat Transitive packages now show a tooltip: 'This package is a dependency of another installed package. It may also have been explicitly installed.' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/features/views/treeViewItems.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/views/treeViewItems.ts b/src/features/views/treeViewItems.ts index 577ddf8c..0293afb7 100644 --- a/src/features/views/treeViewItems.ts +++ b/src/features/views/treeViewItems.ts @@ -214,7 +214,9 @@ export class PackageTreeItem implements EnvTreeItem { 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.tooltip; + 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; } } From 6c9a7e26a3edcb47b75f674a0068fd2ea7f0b588 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 15 Jun 2026 14:10:40 -0700 Subject: [PATCH 19/20] Fix poetry regex to include dots in package names Include . in the extraction regex so dotted package names like zope.interface and ruamel.yaml are not truncated. Normalization via normalizePackageName (PEP 503) is already applied for comparison. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/managers/poetry/poetryPackageManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index e2533b14..4e72cd23 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -272,7 +272,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { const names = topLevelResult .split('\n') .map((line) => line.trim()) - .map((line) => line.match(/^([a-zA-Z0-9_-]+)/)?.[1] ?? '') + .map((line) => line.match(/^([a-zA-Z0-9._-]+)/)?.[1] ?? '') .filter((name) => !!name) .map(normalizePackageName); return new Set(names); From 1b4483e1bb976d40fb3626126d50fba9002ac845 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 15 Jun 2026 21:17:33 -0700 Subject: [PATCH 20/20] Use spread instead of cast to set isTransitive on packages Replace the readonly-bypassing cast with object spread to create new enriched package objects. Original cached objects are not mutated, making the readonly contract genuinely respected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/managers/common/packageChanges.ts | 18 +++--- .../common/packageChanges.unit.test.ts | 55 +++++++++++-------- 2 files changed, 41 insertions(+), 32 deletions(-) diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index b97b0941..3e16ae36 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -54,19 +54,19 @@ export async function updatePackagesAndNotify( packageManager.getDirectPackageNames?.(environment).catch(() => undefined), ]); - if (afterDirectDependenciesNames && afterDirectDependenciesNames.size > 0) { - for (const pkg of after) { - (pkg as { isTransitive?: boolean }).isTransitive = !afterDirectDependenciesNames.has( - normalizePackageName(pkg.name), - ); - } - } + // Enrich packages with transitive dependency info (best-effort, creates new objects to respect readonly) + const enriched = afterDirectDependenciesNames && afterDirectDependenciesNames.size > 0 + ? after.map((pkg) => ({ + ...pkg, + isTransitive: !afterDirectDependenciesNames.has(normalizePackageName(pkg.name)), + })) + : after; // Fire change event - const changes = getPackageChanges(before ?? [], after); + const changes = getPackageChanges(before ?? [], enriched); if (changes.length > 0) { onChanges(changes); } - return after; + return enriched; } diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts index ca1b064b..1f65b3c7 100644 --- a/src/test/managers/common/packageChanges.unit.test.ts +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -181,11 +181,14 @@ suite('packageChanges', () => { (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'); + const result = await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + + assert.ok(result); + assert.strictEqual(result![0].isTransitive, false, 'requests should be direct'); + assert.strictEqual(result![1].isTransitive, true, 'urllib3 should be transitive'); + assert.strictEqual(result![2].isTransitive, true, 'charset-normalizer should be transitive'); + // Original objects should not be mutated + assert.strictEqual(after[0].isTransitive, undefined, 'original should not be mutated'); }); test('does not mark packages transitive when getDirectPackageNames is not implemented', async () => { @@ -196,10 +199,11 @@ suite('packageChanges', () => { getPackagesStub.resolves(after); const onChanges = sinon.stub(); - await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + const result = 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'); + assert.ok(result); + assert.strictEqual(result![0].isTransitive, undefined, 'should not be set'); + assert.strictEqual(result![1].isTransitive, undefined, 'should not be set'); }); test('does not mark packages transitive when getDirectPackageNames returns undefined', async () => { @@ -212,10 +216,11 @@ suite('packageChanges', () => { (packageManager as unknown as Record).getDirectPackageNames = getDirectPackageNamesStub; const onChanges = sinon.stub(); - await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + const result = 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'); + assert.ok(result); + assert.strictEqual(result![0].isTransitive, undefined, 'should not be set'); + assert.strictEqual(result![1].isTransitive, undefined, 'should not be set'); }); test('does not mark packages transitive when getDirectPackageNames returns empty set', async () => { @@ -228,10 +233,11 @@ suite('packageChanges', () => { (packageManager as unknown as Record).getDirectPackageNames = getDirectPackageNamesStub; const onChanges = sinon.stub(); - await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + const result = 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'); + assert.ok(result); + assert.strictEqual(result![0].isTransitive, undefined, 'should not be set'); + assert.strictEqual(result![1].isTransitive, undefined, 'should not be set'); }); test('all packages marked direct when all are in direct set', async () => { @@ -244,10 +250,11 @@ suite('packageChanges', () => { (packageManager as unknown as Record).getDirectPackageNames = getDirectPackageNamesStub; const onChanges = sinon.stub(); - await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + const result = 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'); + assert.ok(result); + assert.strictEqual(result![0].isTransitive, false, 'requests should be direct'); + assert.strictEqual(result![1].isTransitive, false, 'flask should be direct'); }); test('all packages marked transitive when none are in direct set', async () => { @@ -260,10 +267,11 @@ suite('packageChanges', () => { (packageManager as unknown as Record).getDirectPackageNames = getDirectPackageNamesStub; const onChanges = sinon.stub(); - await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + const result = 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'); + assert.ok(result); + assert.strictEqual(result![0].isTransitive, true, 'urllib3 should be transitive'); + assert.strictEqual(result![1].isTransitive, true, 'charset-normalizer should be transitive'); }); test('leaves isTransitive undefined when getDirectPackageNames throws', async () => { @@ -276,10 +284,11 @@ suite('packageChanges', () => { (packageManager as unknown as Record).getDirectPackageNames = getDirectPackageNamesStub; const onChanges = sinon.stub(); - await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + const result = 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(result); + assert.strictEqual(result![0].isTransitive, undefined, 'should not be set on error'); + assert.strictEqual(result![1].isTransitive, undefined, 'should not be set on error'); assert.ok(onChanges.calledOnce, 'should still fire change event'); }); });