Skip to content

Commit e1a77dd

Browse files
committed
Adopt package manager command classes
1 parent 469835c commit e1a77dd

11 files changed

Lines changed: 342 additions & 763 deletions

File tree

package-lock.json

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/managers/builtin/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export async function runPython(
153153
proc.stderr?.on('data', (data) => {
154154
const s = data.toString('utf-8');
155155
builder += s;
156-
log?.append(`python: ${s}`);
156+
log?.append(s);
157157
});
158158
proc.on('close', () => {
159159
resolve(builder);

src/managers/builtin/pipListUtils.ts

Lines changed: 0 additions & 34 deletions
This file was deleted.
Lines changed: 141 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Pep440Version } from '@renovatebot/pep440';
2-
import { compare, explain as parse, rcompare } from '@renovatebot/pep440';
2+
import { compare, explain as parse } from '@renovatebot/pep440';
33
import {
44
CancellationError,
55
Disposable,
@@ -21,10 +21,26 @@ import {
2121
PythonEnvironment,
2222
PythonEnvironmentApi,
2323
} from '../../api';
24+
import { withProgress } from '../../common/window.apis';
25+
import { CommandConstructorOptions } from '../base/commands/index';
2426
import { updatePackagesAndNotify } from '../common/packageChanges';
25-
import { runPython, runUV, shouldUseUv } from './helpers';
27+
import { createPipOrUvCommand } from './commands/factory';
28+
import {
29+
PipAvailableVersionsCommand,
30+
PipInstallCommand,
31+
PipListCommand,
32+
PipListDirectNamesCommand,
33+
PipUninstallCommand,
34+
PipVersionCommand,
35+
UvAvailableVersionsCommand,
36+
UvInstallCommand,
37+
UvListCommand,
38+
UvListDirectNamesCommand,
39+
UvUninstallCommand,
40+
UvVersionCommand,
41+
} from './commands/index';
2642
import { getWorkspacePackagesToInstall } from './pipUtils';
27-
import { managePackages, normalizePackageName, refreshPipDirectPackageNames, refreshPipPackages } from './utils';
43+
import { parsePackageSpecs } from './utils';
2844
import { VenvManager } from './venvManager';
2945

3046
export class PipPackageManager implements PackageManager, Disposable {
@@ -65,43 +81,66 @@ export class PipPackageManager implements PackageManager, Disposable {
6581
}
6682
}
6783

68-
const manageOptions = {
69-
...options,
70-
install: toInstall,
71-
uninstall: toUninstall,
72-
};
73-
await window.withProgress(
74-
{
75-
location: ProgressLocation.Notification,
76-
title: 'Installing packages',
77-
cancellable: true,
78-
},
79-
async (_progress, token) => {
80-
try {
81-
await managePackages(environment, manageOptions, this, token);
82-
await updatePackagesAndNotify(
83-
this,
84-
environment,
85-
this.packages.get(environment.envId.id),
86-
(changes) => {
87-
this._onDidChangePackages.fire({ environment, manager: this, changes });
88-
},
89-
);
90-
} catch (e) {
91-
if (e instanceof CancellationError) {
92-
throw e;
93-
}
94-
this.log.error('Error managing packages', e);
95-
setImmediate(async () => {
96-
const result = await window.showErrorMessage('Error managing packages', 'View Output');
97-
if (result === 'View Output') {
98-
this.log.show();
99-
}
100-
});
101-
throw e;
84+
try {
85+
const pythonExecutable = environment.execInfo?.run?.executable;
86+
if (!pythonExecutable) {
87+
throw new Error('Unable to determine Python executable path');
88+
}
89+
90+
// Centralize command options for install/uninstall operations
91+
const manageCommandOptions: CommandConstructorOptions = {
92+
pythonExecutable,
93+
log: this.log,
94+
};
95+
96+
// Execute uninstall if needed
97+
if (toUninstall.length > 0) {
98+
const uninstallCmd: PipUninstallCommand | UvUninstallCommand = await createPipOrUvCommand(
99+
manageCommandOptions,
100+
environment.environmentPath.fsPath,
101+
PipUninstallCommand,
102+
UvUninstallCommand,
103+
);
104+
const packages = parsePackageSpecs(toUninstall);
105+
await withProgress(
106+
{ location: ProgressLocation.Notification, title: 'Installing packages', cancellable: true },
107+
(_progress, token) => uninstallCmd.execute({ packages, cancellationToken: token }),
108+
);
109+
}
110+
111+
// Execute install if needed
112+
if (toInstall.length > 0) {
113+
const installCmd: PipInstallCommand | UvInstallCommand = await createPipOrUvCommand(
114+
manageCommandOptions,
115+
environment.environmentPath.fsPath,
116+
PipInstallCommand,
117+
UvInstallCommand,
118+
);
119+
const packages = parsePackageSpecs(toInstall);
120+
await withProgress(
121+
{ location: ProgressLocation.Notification, title: 'Installing packages', cancellable: true },
122+
(_progress, token) =>
123+
installCmd.execute({ packages, upgrade: options.upgrade, cancellationToken: token }),
124+
);
125+
}
126+
127+
await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id), (changes) => {
128+
this._onDidChangePackages.fire({ environment, manager: this, changes });
129+
});
130+
} catch (e) {
131+
if (e instanceof CancellationError) {
132+
// Cancellation is a normal control-flow exit; do not surface an error.
133+
return;
134+
}
135+
this.log.error('Error managing packages', e);
136+
setImmediate(async () => {
137+
const result = await window.showErrorMessage('Error managing packages', 'View Output');
138+
if (result === 'View Output') {
139+
this.log.show();
102140
}
103-
},
104-
);
141+
});
142+
throw e;
143+
}
105144
}
106145

107146
async refresh(environment: PythonEnvironment): Promise<Package[] | undefined> {
@@ -125,7 +164,20 @@ export class PipPackageManager implements PackageManager, Disposable {
125164

126165
async getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise<Package[] | undefined> {
127166
if (options?.skipCache || !this.packages.has(environment.envId.id)) {
128-
const data = await refreshPipPackages(environment, this.log);
167+
const pythonExecutable = environment.execInfo?.run?.executable;
168+
if (!pythonExecutable) {
169+
return undefined;
170+
}
171+
const listCmd: PipListCommand | UvListCommand = await createPipOrUvCommand(
172+
{
173+
pythonExecutable,
174+
log: this.log,
175+
},
176+
environment.environmentPath.fsPath,
177+
PipListCommand,
178+
UvListCommand,
179+
);
180+
const data = await listCmd.execute();
129181
const packages = (data ?? []).map((pkg) => this.api.createPackageItem(pkg, environment, this));
130182
this.packages.set(environment.envId.id, packages);
131183
return packages;
@@ -135,22 +187,17 @@ export class PipPackageManager implements PackageManager, Disposable {
135187

136188
async getVersion(environment: PythonEnvironment): Promise<Pep440Version | undefined> {
137189
try {
138-
const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath);
139-
if (useUv) {
140-
const result = await runUV(['--version'], undefined, this.log);
141-
// "uv X.Y.Z"
142-
const match = result.match(/^uv\s+(\d+\.\d+(?:\.\d+)*)/);
143-
return match ? (parse(match[1]) ?? undefined) : undefined;
190+
const pythonExecutable = environment.execInfo?.run?.executable;
191+
if (!pythonExecutable) {
192+
return undefined;
144193
}
145-
const result = await runPython(
146-
environment.execInfo?.run?.executable ?? 'python',
147-
['-m', 'pip', '--version'],
148-
undefined,
149-
this.log,
194+
const versionCmd: PipVersionCommand | UvVersionCommand = await createPipOrUvCommand(
195+
{ pythonExecutable, log: this.log },
196+
environment.environmentPath.fsPath,
197+
PipVersionCommand,
198+
UvVersionCommand,
150199
);
151-
// "pip X.Y.Z from /path/to/pip (python X.Y)"
152-
const match = result.match(/^pip\s+(\d+\.\d+(?:\.\d+)*)/);
153-
return match ? (parse(match[1]) ?? undefined) : undefined;
200+
return await versionCmd.execute();
154201
} catch {
155202
return undefined;
156203
}
@@ -161,39 +208,45 @@ export class PipPackageManager implements PackageManager, Disposable {
161208
packageName: string,
162209
): Promise<Pep440Version[] | undefined> {
163210
try {
164-
const python = environment.execInfo?.run?.executable;
165-
if (!python) {
211+
const pythonExecutable = environment.execInfo?.run?.executable;
212+
if (!pythonExecutable) {
166213
return undefined;
167214
}
168215

169-
const baseVersion = parse(environment.version)?.base_version;
216+
// Normalize versions like '3.13.1.final.0' (Python's sys.version_info format) to '3.13.1'
217+
// before parsing, since pep440 only accepts valid PEP 440 version strings.
218+
const versionMatch = (environment.version ?? '').match(/^(\d+(?:\.\d+)*)/);
219+
const normalizedVersion = versionMatch?.[1] ?? '';
220+
const baseVersion = parse(normalizedVersion)?.base_version;
170221
if (!baseVersion) {
171222
return undefined;
172223
}
173-
// uv - Run pip via `uv tool run pip`
174-
const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath);
175-
if (useUv) {
176-
const output = await runUV(
177-
['tool', 'run', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion],
178-
undefined,
179-
this.log,
180-
);
181-
return parsePipIndexVersionsJson(output);
182-
}
183224

184-
// pip >= 21.2.0 - use `pip index versions <package> --json` to get available versions in a machine readable format.
185-
const pipVersion = await this.getVersion(environment);
186-
if (pipVersion && compare(pipVersion.public, '21.2.0') >= 0) {
187-
const output = await runPython(
188-
python,
189-
['-m', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion],
190-
undefined,
191-
this.log,
225+
const availableVersionsCmd: PipAvailableVersionsCommand | UvAvailableVersionsCommand =
226+
await createPipOrUvCommand(
227+
{ pythonExecutable, log: this.log },
228+
environment.environmentPath.fsPath,
229+
PipAvailableVersionsCommand,
230+
UvAvailableVersionsCommand,
192231
);
193-
return parsePipIndexVersionsJson(output);
232+
233+
// For pip < 21.2.0, check version first
234+
if (availableVersionsCmd instanceof PipAvailableVersionsCommand) {
235+
const pipVersion = await this.getVersion(environment);
236+
if (!pipVersion || compare(pipVersion.public, '21.2.0') < 0) {
237+
// pip <= 20.3.4 - version picking is undefined; no reliable machine-readable API exists.
238+
return undefined;
239+
}
194240
}
195241

196-
// pip <= 20.3.4 - version picking is undefined; no reliable machine-readable API exists.
242+
const versionStrings = await availableVersionsCmd.execute({
243+
packageName,
244+
pythonVersion: baseVersion,
245+
});
246+
return versionStrings
247+
.map((v) => parse(v))
248+
.filter((parsed): parsed is Pep440Version => parsed !== null)
249+
.sort((a, b) => compare(b.public, a.public));
197250
} catch {
198251
return undefined;
199252
}
@@ -205,38 +258,22 @@ export class PipPackageManager implements PackageManager, Disposable {
205258
}
206259

207260
/**
208-
* Returns direct (non-transitive) package names using `pip list --not-required` or `uv pip tree --depth=0`.
261+
* Returns direct (non-transitive) package names using `pip list --not-required` or `uv pip list --not-required`.
209262
*
210263
* Note: These commands return packages with no installed dependents (leaf packages), not packages
211264
* the user explicitly installed. pip/uv do not track install intent.
212265
*/
213266
async getDirectPackageNames(environment: PythonEnvironment): Promise<Set<string> | undefined> {
214-
const data = await refreshPipDirectPackageNames(environment, this.log);
215-
return data ? new Set(data.map(normalizePackageName)) : undefined;
216-
}
217-
}
218-
219-
/**
220-
* Parses JSON output from `pip index versions <package> --json`.
221-
* Expected format: { "name": "...", "versions": ["1.2.3", "1.2.2", ...] }
222-
*/
223-
export function parsePipIndexVersionsJson(output: string): Pep440Version[] | undefined {
224-
// Only capture output between braces
225-
const match = output.match(/{[\s\S]*}/);
226-
if (!match) {
227-
return undefined;
228-
}
229-
try {
230-
const parsed = JSON.parse(match[0]);
231-
if (parsed && Array.isArray(parsed.versions) && parsed.versions.length > 0) {
232-
return (parsed.versions as string[])
233-
.filter((v) => !!v.trim())
234-
.map((v) => parse(v.trim()))
235-
.filter((v): v is Pep440Version => v !== null)
236-
.sort((a, b) => rcompare(a.public, b.public));
267+
const pythonExecutable = environment.execInfo?.run?.executable;
268+
if (!pythonExecutable) {
269+
return undefined;
237270
}
238-
return undefined;
239-
} catch {
240-
return undefined;
271+
const listDirectNamesCmd: PipListDirectNamesCommand | UvListDirectNamesCommand = await createPipOrUvCommand(
272+
{ pythonExecutable, log: this.log },
273+
environment.environmentPath.fsPath,
274+
PipListDirectNamesCommand,
275+
UvListDirectNamesCommand,
276+
);
277+
return listDirectNamesCmd.execute();
241278
}
242279
}

0 commit comments

Comments
 (0)