Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 8 additions & 3 deletions src/managers/builtin/commands/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@ export async function createPipOrUvCommandWithKind<P, U>(
PipCommand: CommandConstructor<P>,
UvCommand: CommandConstructor<U>,
): Promise<PipOrUvCommand<P, U>> {
return (await shouldUseUv(options.log, environmentPath))
? { kind: 'uv', command: new UvCommand(options) }
: { kind: 'pip', command: new PipCommand(options) };
if (await shouldUseUv(options.log, environmentPath)) {
// uv accepts an environment directory as its `--python` target. A symlinked
// environment executable (for example Pipenv) can resolve to the externally
// managed base interpreter, so passing the environment directory preserves

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

📍 src/managers/builtin/commands/factory.ts:17
pythonExecutable now represents either an executable or, for UV, an environment directory. This is safe for current commands but weakens the constructor contract; track a follow-up to introduce an accurately named UV target before future commands assume this value is executable.

[verified]

// the environment boundary. Pip commands keep using the interpreter itself.
return { kind: 'uv', command: new UvCommand({ ...options, pythonExecutable: environmentPath }) };
}
return { kind: 'pip', command: new PipCommand(options) };
}

export async function createPipOrUvCommand<T, P extends T, U extends T>(
Expand Down
91 changes: 87 additions & 4 deletions src/test/managers/builtin/commands.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ import {
PipAvailableVersionsTextCommand,
UvAvailableVersionsCommand,
} from '../../../managers/builtin/commands/availableVersions';
import { createPipOrUvCommand } from '../../../managers/builtin/commands/factory';
import { PipInstallCommand, UvInstallCommand } from '../../../managers/builtin/commands/install';
import { PipListCommand, UvListCommand } from '../../../managers/builtin/commands/list';
import { PipListDirectNamesCommand, UvListDirectNamesCommand } from '../../../managers/builtin/commands/listDirectNames';
import {
PipListDirectNamesCommand,
UvListDirectNamesCommand,
} from '../../../managers/builtin/commands/listDirectNames';
import { PipUninstallCommand, UvUninstallCommand } from '../../../managers/builtin/commands/uninstall';
import { PipVersionCommand, UvVersionCommand } from '../../../managers/builtin/commands/version';
import * as helpers from '../../../managers/builtin/helpers';
Expand All @@ -28,6 +32,7 @@ suite('Pip and UV command parsing', () => {
let mockLog: LogOutputChannel;
let runPythonStub: sinon.SinonStub;
let runUvStub: sinon.SinonStub;
let shouldUseUvStub: sinon.SinonStub;

setup(() => {
log = createMockLogOutputChannel();
Expand All @@ -37,6 +42,7 @@ suite('Pip and UV command parsing', () => {
} as unknown as ReturnType<typeof workspaceApis.getConfiguration>);
runPythonStub = sinon.stub(helpers, 'runPython').resolves('');
runUvStub = sinon.stub(helpers, 'runUV').resolves('');
shouldUseUvStub = sinon.stub(helpers, 'shouldUseUv').resolves(false);
});

teardown(() => {
Expand Down Expand Up @@ -259,7 +265,10 @@ suite('Pip and UV command parsing', () => {
'--python-version',
'3.13.1',
]);
assert.deepStrictEqual(result.map((version) => version.public), ['1.0.0']);
assert.deepStrictEqual(
result.map((version) => version.public),
['1.0.0'],
);
});

test('PipAvailableVersionsTextCommand parses text output for Pip 21.2 through 25.0', async () => {
Expand All @@ -282,7 +291,10 @@ suite('Pip and UV command parsing', () => {
'--python-version',
'3.13.1',
]);
assert.deepStrictEqual(result.map((version) => version.public), ['1.0.0']);
assert.deepStrictEqual(
result.map((version) => version.public),
['1.0.0'],
);
});

test('UvAvailableVersionsCommand rejects output surrounding JSON', async () => {
Expand Down Expand Up @@ -319,7 +331,10 @@ suite('Pip and UV command parsing', () => {
const result = await command.execute();

assert.deepStrictEqual(runUvStub.firstCall.args[0], ['pip', 'list', '--format=json', '--python', 'python']);
assert.deepStrictEqual(result.map((pkg) => pkg.name), ['package']);
assert.deepStrictEqual(
result.map((pkg) => pkg.name),
['package'],
);
});

test('direct-package commands normalize names and ignore UV dependencies', async () => {
Expand Down Expand Up @@ -349,4 +364,72 @@ suite('Pip and UV command parsing', () => {
assert.strictEqual(pipVersion?.public, '24.0');
assert.strictEqual(uvVersion?.public, '0.4.20');
});

test('UV package commands target the environment directory rather than the resolved interpreter', async () => {
const baseInterpreter = path.join(path.sep, 'uv', 'python', 'cpython-3.13', 'bin', 'python');
const environmentPath = path.join(path.sep, 'virtualenvs', 'pipenv-project');
shouldUseUvStub.resolves(true);
const options = { pythonExecutable: baseInterpreter, log: mockLog };

const install: PipInstallCommand | UvInstallCommand = await createPipOrUvCommand(
options,
environmentPath,
PipInstallCommand,
UvInstallCommand,
);
await install.execute({ packages: [{ packageName: 'requests' }] });

const uninstall: PipUninstallCommand | UvUninstallCommand = await createPipOrUvCommand(
options,
environmentPath,
PipUninstallCommand,
UvUninstallCommand,
);
await uninstall.execute({ packages: [{ packageName: 'requests' }] });

const list: PipListCommand | UvListCommand = await createPipOrUvCommand(
options,
environmentPath,
PipListCommand,
UvListCommand,
);
runUvStub.resolves('[]');
await list.execute();

const directNames: PipListDirectNamesCommand | UvListDirectNamesCommand = await createPipOrUvCommand(
options,
environmentPath,
PipListDirectNamesCommand,
UvListDirectNamesCommand,
);
runUvStub.resolves('');
await directNames.execute();

assert.strictEqual(runPythonStub.callCount, 0);
assert.strictEqual(runUvStub.callCount, 4);
for (const call of runUvStub.getCalls()) {
const args = call.args[0] as string[];
const pythonIndex = args.indexOf('--python');
assert.notStrictEqual(pythonIndex, -1);
assert.strictEqual(args[pythonIndex + 1], environmentPath);
assert.ok(!args.includes(baseInterpreter));
}
});

test('Pip package commands continue using the environment interpreter', async () => {
const pythonExecutable = path.join(path.sep, 'virtualenvs', 'project', 'bin', 'python');
const environmentPath = path.join(path.sep, 'virtualenvs', 'project');
shouldUseUvStub.resolves(false);

const install: PipInstallCommand | UvInstallCommand = await createPipOrUvCommand(
{ pythonExecutable, log: mockLog },
environmentPath,
PipInstallCommand,
UvInstallCommand,
);
await install.execute({ packages: [{ packageName: 'requests' }] });

assert.strictEqual(runPythonStub.firstCall.args[0], pythonExecutable);
assert.ok(runUvStub.notCalled);
});
});
Loading