Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cfe1e49
Add package manager command smoke tests
edvilme Jul 28, 2026
b18db3d
Adopt package manager command classes
edvilme Jul 28, 2026
a135bc8
Parse pep440 versions
edvilme Jul 28, 2026
e292a5d
Address #1686 review comments
edvilme Jul 31, 2026
d82423c
Add parametrized package manager roundtrip integration test
edvilme Jul 31, 2026
dfa4f9a
test: exercise getPackageAvailableVersions in roundtrip
edvilme Jul 31, 2026
fb1bd18
test: skip roundtrip for managers that cannot install the test package
edvilme Aug 2, 2026
01575b1
test: use flask (universal + transitive deps) for package-manager rou…
edvilme Aug 3, 2026
7e9c7a3
test: restrict package roundtrip to isolated environments
edvilme Aug 6, 2026
0f2ac6f
fix: preserve Poetry command working directory
edvilme Aug 6, 2026
e4c7f10
fix: reject pip management for Python 2
edvilme Aug 6, 2026
39f8559
test: restore package command parser coverage
edvilme Aug 6, 2026
29c2d41
fix: address remaining package manager review feedback
edvilme Aug 6, 2026
0a8c3ad
test: use disposable package manager environments
edvilme Aug 7, 2026
d59560d
test: remove package manager tests from refactor
edvilme Aug 12, 2026
9edd9ef
Merge branch 'main' into package-manager-command-adoption
edvilme Aug 17, 2026
29c5b74
Merge origin/main into package-manager-command-adoption
edvilme Aug 18, 2026
5053e1a
Merge branch 'main' into package-manager-command-adoption
edvilme Aug 18, 2026
4b4b292
Address package manager review feedback
edvilme Aug 18, 2026
d2c0993
Preserve Poetry package manager behavior
edvilme Aug 18, 2026
850bc29
Merge branch 'main' into package-manager-command-adoption
edvilme Aug 18, 2026
b2eff5a
Fix Conda package list error caching
edvilme Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 0 additions & 34 deletions src/managers/builtin/pipListUtils.ts

This file was deleted.

252 changes: 141 additions & 111 deletions src/managers/builtin/pipPackageManager.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { Pep440Version } from '@renovatebot/pep440';
import { compare, explain as parse, rcompare } from '@renovatebot/pep440';
import { compare, explain as parse } from '@renovatebot/pep440';
import {
CancellationError,
Disposable,
Event,
EventEmitter,
l10n,
LogOutputChannel,
MarkdownString,
ProgressLocation,
ThemeIcon,
window,
} from 'vscode';
import {
DidChangePackagesEventArgs,
Expand All @@ -21,10 +21,26 @@ import {
PythonEnvironment,
PythonEnvironmentApi,
} from '../../api';
import { showErrorMessage, withProgress } from '../../common/window.apis';
import { CommandConstructorOptions } from '../base/commands/index';
import { updatePackagesAndNotify } from '../common/packageChanges';
import { runPython, runUV, shouldUseUv } from './helpers';
import { createPipOrUvCommand } from './commands/factory';
import {
PipAvailableVersionsCommand,
PipInstallCommand,
PipListCommand,
PipListDirectNamesCommand,
PipUninstallCommand,
PipVersionCommand,
UvAvailableVersionsCommand,
UvInstallCommand,
UvListCommand,
UvListDirectNamesCommand,
UvUninstallCommand,
UvVersionCommand,
} from './commands/index';
import { getWorkspacePackagesToInstall } from './pipUtils';
import { managePackages, normalizePackageName, refreshPipDirectPackageNames, refreshPipPackages } from './utils';
import { parsePackageSpecs } from './utils';
import { VenvManager } from './venvManager';

export class PipPackageManager implements PackageManager, Disposable {
Expand Down Expand Up @@ -69,37 +85,71 @@ export class PipPackageManager implements PackageManager, Disposable {
}
}

const manageOptions = {
...options,
install: toInstall,
uninstall: toUninstall,
};
await window.withProgress(
if (environment.version.startsWith('2.')) {
throw new Error('Python 2.* is not supported (deprecated)');
}

await withProgress(
{
location: ProgressLocation.Notification,
title: 'Installing packages',
title: l10n.t('Managing packages'),
cancellable: true,
},
async (_progress, token) => {
try {
await managePackages(environment, manageOptions, this, token);
const pythonExecutable = environment.execInfo?.run?.executable;
if (!pythonExecutable) {
throw new Error('Unable to determine Python executable path');
}
const commandOptions: CommandConstructorOptions = {
pythonExecutable,
log: this.log,
};

if (toUninstall.length > 0) {
const command: PipUninstallCommand | UvUninstallCommand = await createPipOrUvCommand(
commandOptions,
environment.environmentPath.fsPath,
PipUninstallCommand,
UvUninstallCommand,
);
await command.execute({
packages: parsePackageSpecs(toUninstall),
cancellationToken: token,
});
}

if (toInstall.length > 0) {
const command: PipInstallCommand | UvInstallCommand = await createPipOrUvCommand(
commandOptions,
environment.environmentPath.fsPath,
PipInstallCommand,
UvInstallCommand,
);
await command.execute({
packages: parsePackageSpecs(toInstall),
upgrade: options.upgrade,
cancellationToken: token,
});
}

await updatePackagesAndNotify(
this,
environment,
this.packages.get(environment.envId.id),
(changes) => {
this._onDidChangePackages.fire({ environment, manager: this, changes });
},
() => this.fetchPackages(environment, !manageOptions.runHeadless),
() => this.fetchPackages(environment, !options.runHeadless),
);
} catch (e) {
if (e instanceof CancellationError) {
throw e;
}
this.log.error('Error managing packages', e);
if (!manageOptions.runHeadless) {
if (!options.runHeadless) {
setImmediate(async () => {
const result = await window.showErrorMessage('Error managing packages', 'View Output');
const result = await showErrorMessage('Error managing packages', 'View Output');
if (result === 'View Output') {
this.log.show();
}
Expand All @@ -112,7 +162,7 @@ export class PipPackageManager implements PackageManager, Disposable {
}

async refresh(environment: PythonEnvironment): Promise<void> {
await window.withProgress(
await withProgress(
{
location: ProgressLocation.Window,
title: 'Refreshing packages',
Expand Down Expand Up @@ -141,34 +191,48 @@ export class PipPackageManager implements PackageManager, Disposable {
}

private async fetchPackages(environment: PythonEnvironment, showErrors = true): Promise<Package[] | undefined> {
const data = await refreshPipPackages(environment, this.log, { showErrors });
if (data === undefined) {
try {
const pythonExecutable = environment.execInfo?.run?.executable;
if (!pythonExecutable) {
throw new Error('Unable to determine Python executable path');
}
const command: PipListCommand | UvListCommand = await createPipOrUvCommand(
{ pythonExecutable, log: this.log },
environment.environmentPath.fsPath,
PipListCommand,
UvListCommand,
);
const data = await command.execute();
const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this));
this.packages.set(environment.envId.id, packages);
return packages;
} catch (error) {
this.log.error('Error refreshing packages', error);
if (showErrors) {
setImmediate(async () => {
const result = await showErrorMessage('Error refreshing packages', 'View Output');
if (result === 'View Output') {
this.log.show();
}
});
}
return this.packages.get(environment.envId.id);
}

const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this));
this.packages.set(environment.envId.id, packages);
return packages;
}

async getVersion(environment: PythonEnvironment): Promise<Pep440Version | undefined> {
try {
const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath);
if (useUv) {
const result = await runUV(['--version'], undefined, this.log);
// "uv X.Y.Z"
const match = result.match(/^uv\s+(\d+\.\d+(?:\.\d+)*)/);
return match ? (parse(match[1]) ?? undefined) : undefined;
const pythonExecutable = environment.execInfo?.run?.executable;
if (!pythonExecutable) {
return undefined;
}
const result = await runPython(
environment.execInfo?.run?.executable ?? 'python',
['-m', 'pip', '--version'],
undefined,
this.log,
const versionCmd: PipVersionCommand | UvVersionCommand = await createPipOrUvCommand(
{ pythonExecutable, log: this.log },
environment.environmentPath.fsPath,
PipVersionCommand,
UvVersionCommand,
);
// "pip X.Y.Z from /path/to/pip (python X.Y)"
const match = result.match(/^pip\s+(\d+\.\d+(?:\.\d+)*)/);
return match ? (parse(match[1]) ?? undefined) : undefined;
return await versionCmd.execute();
} catch {
return undefined;
}
Expand All @@ -179,49 +243,42 @@ export class PipPackageManager implements PackageManager, Disposable {
packageName: string,
): Promise<Pep440Version[] | undefined> {
try {
const python = environment.execInfo?.run?.executable;
if (!python) {
const pythonExecutable = environment.execInfo?.run?.executable;
if (!pythonExecutable) {
return undefined;
}

const baseVersion = parse(environment.version)?.base_version;
// Normalize versions like '3.13.1.final.0' (Python's sys.version_info format) to '3.13.1'
// before parsing, since pep440 only accepts valid PEP 440 version strings.
const versionMatch = (environment.version ?? '').match(/^(\d+(?:\.\d+)*)/);
const normalizedVersion = versionMatch?.[1] ?? '';
const baseVersion = parse(normalizedVersion)?.base_version;
if (!baseVersion) {
return undefined;
}
// uv - Run pip via `uv tool run pip`
const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath);
if (useUv) {
const output = await runUV(
['tool', 'run', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion],
undefined,
this.log,
);
return parsePipIndexVersionsJson(output);
}

// pip >= 25.1 - use `pip index versions <package> --json` to get available versions in a machine readable format.
const pipVersion = await this.getVersion(environment);
if (pipVersion && compare(pipVersion.public, '25.1') >= 0) {
const output = await runPython(
python,
['-m', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion],
undefined,
this.log,
const availableVersionsCmd: PipAvailableVersionsCommand | UvAvailableVersionsCommand =
await createPipOrUvCommand(
{ pythonExecutable, log: this.log },
environment.environmentPath.fsPath,
PipAvailableVersionsCommand,
UvAvailableVersionsCommand,
);
return parsePipIndexVersionsJson(output);
}

if (pipVersion && compare(pipVersion.public, '21.2') >= 0) {
const output = await runPython(
python,
['-m', 'pip', 'index', 'versions', packageName, '--python-version', baseVersion],
undefined,
this.log,
);
return parsePipIndexVersionsText(output);
// For pip < 21.2.0, check version first
if (availableVersionsCmd instanceof PipAvailableVersionsCommand) {
const pipVersion = await this.getVersion(environment);
if (!pipVersion || compare(pipVersion.public, '21.2.0') < 0) {
// pip <= 20.3.4 - version picking is undefined; no reliable machine-readable API exists.
return undefined;
}
}

// pip < 21.2 - version picking is undefined; `pip index versions` is unavailable.
const versions = await availableVersionsCmd.execute({
packageName,
pythonVersion: baseVersion,
});
return versions.sort((a, b) => compare(b.public, a.public));
Comment thread
edvilme marked this conversation as resolved.
} catch {
return undefined;
}
Expand All @@ -233,52 +290,25 @@ export class PipPackageManager implements PackageManager, Disposable {
}

/**
* Returns direct (non-transitive) package names using `pip list --not-required` or `uv pip tree --depth=0`.
* Returns direct (non-transitive) package names.
*
* Note: These commands return packages with no installed dependents (leaf packages), not packages
* the user explicitly installed. pip/uv do not track install intent.
* pip uses `pip list --format=json --not-required`; uv uses `uv pip tree --depth=0`.
*
Comment thread
edvilme marked this conversation as resolved.
* Note: These return top-level packages (no installed dependents / roots of the
* dependency tree), not necessarily packages the user explicitly installed. pip/uv
* do not track install intent.
*/
async getDirectPackageNames(environment: PythonEnvironment): Promise<Set<string> | undefined> {
const data = await refreshPipDirectPackageNames(environment, this.log);
return data ? new Set(data.map(normalizePackageName)) : undefined;
}
}

/**
* Parses JSON output from `pip index versions <package> --json`.
* Expected format: { "name": "...", "versions": ["1.2.3", "1.2.2", ...] }
*/
export function parsePipIndexVersionsJson(output: string): Pep440Version[] | undefined {
// Only capture output between braces
const match = output.match(/{[\s\S]*}/);
if (!match) {
return undefined;
}
try {
const parsed = JSON.parse(match[0]);
if (parsed && Array.isArray(parsed.versions) && parsed.versions.length > 0) {
return (parsed.versions as string[])
.filter((v) => !!v.trim())
.map((v) => parse(v.trim()))
.filter((v): v is Pep440Version => v !== null)
.sort((a, b) => rcompare(a.public, b.public));
const pythonExecutable = environment.execInfo?.run?.executable;
if (!pythonExecutable) {
return undefined;
}
return undefined;
} catch {
return undefined;
}
}

/** Parses the legacy text output from `pip index versions <package>`. */
export function parsePipIndexVersionsText(output: string): Pep440Version[] | undefined {
const match = output.match(/^Available versions:\s*(.+)$/im);
if (!match) {
return undefined;
const listDirectNamesCmd: PipListDirectNamesCommand | UvListDirectNamesCommand = await createPipOrUvCommand(
{ pythonExecutable, log: this.log },
environment.environmentPath.fsPath,
PipListDirectNamesCommand,
UvListDirectNamesCommand,
);
return listDirectNamesCmd.execute();
}
const versions = match[1]
.split(',')
.map((version) => parse(version.trim()))
.filter((version): version is Pep440Version => version !== null)
.sort((a, b) => rcompare(a.public, b.public));
return versions.length > 0 ? versions : undefined;
}
Loading
Loading