Skip to content

Commit ee1499b

Browse files
edvilmeCopilot
andauthored
Split UV/Pip PackageManagers (#1941)
This pull request introduces a new architecture for Python package management in the extension, adding support for `uv` as a first-class package manager alongside `pip`. It refactors the internal routing so that package operations can be dynamically directed to either `pip` or `uv` based on user settings and environment context, and ensures that each manager only executes its own commands. The documentation is updated to describe the new behavior and configuration options. **New package manager architecture and routing:** * Introduces a new `BuiltInPackageManager` class that routes package operations between `pip` and `uv` managers according to user configuration and environment, using the existing automatic selection rules. This enables seamless switching and explicit selection of `uv` for package operations. * Refactors the registration of package managers so that both `pip` and `uv` managers are registered, and the new router is used as the default package manager. **Refactoring and code simplification:** * Refactors `PipPackageManager` to only manage packages using `pip`, removing all logic and command creation related to `uv`. The manager now delegates ownership and events to the router when wrapped. [[1]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291L39-L42) [[2]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291L51-R54) [[3]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291R93-R109) [[4]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291L144-R150) [[5]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291L160-R178) [[6]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291L187-R187) [[7]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291L206-R201) [[8]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291R243) [[9]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291L260-R256) [[10]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291R266) [[11]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291L284-R294) **Documentation updates:** * Updates the `README.md` to document `uv` as a supported package manager, describe how to select it, and clarify the routing and fallback behavior for package operations. **Internal consistency and event handling:** * Ensures that package change events and package item ownership are correctly attributed to the router when `PipPackageManager` is wrapped, improving downstream event handling and UI consistency. [[1]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291R93-R109) [[2]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291L206-R201) [[3]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291L260-R256) **Code documentation and clarity:** * Adds and improves docstrings and comments throughout the new and updated classes for better maintainability and clarity. [[1]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291R123-R129) [[2]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291R243) [[3]](diffhunk://#diff-bd7813d2ba80cb1d4b12c11a3b1e5832dbe0b65a1a68963ae95ca78e0a325291R266) These changes lay the groundwork for robust, user-configurable Python package management in the extension, with clear separation between `pip` and `uv` and improved maintainability. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 897bc3b commit ee1499b

16 files changed

Lines changed: 1415 additions & 177 deletions

‎.github/workflows/pr-check.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,9 @@ jobs:
339339
activate-environment: ''
340340
auto-activate: false
341341

342+
- name: Install uv
343+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
344+
342345
- name: Run Integration Tests (Linux)
343346
if: runner.os == 'Linux'
344347
uses: ./.github/actions/run-linux-tests

‎.github/workflows/push-check.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,9 @@ jobs:
340340
activate-environment: ''
341341
auto-activate: false
342342

343+
- name: Install uv
344+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
345+
343346
- name: Run Integration Tests (Linux)
344347
if: runner.os == 'Linux'
345348
uses: ./.github/actions/run-linux-tests

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ out
22
dist
33
node_modules
44
.vscode-test/
5+
src/test/integration/test-workspace/**/pyenvs-*/
56
*.vsix
67
*.tgz
78
*.tsbuildinfo

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,21 @@ The extension uses `pip` as the default package manager, but you can use the pac
7474
| Id | Name | Description |
7575
| ---------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7676
| ms-python.python:pip | `pip` | Pip acts as the default package manager and it's typically built-in to Python. |
77+
| ms-python.python:uv | `uv` | Uses [uv](https://docs.astral.sh/uv/) to manage packages in the selected environment, without requiring pip in that environment. |
7778
| ms-python.python:conda | `conda` | The [conda](https://conda.org) package manager, as provided by conda distributions like [Anaconda Distribution](https://docs.anaconda.com/anaconda/) or [conda-forge](https://conda-forge.org/download/). |
7879

80+
Set `"python-envs.defaultPackageManager": "ms-python.python:uv"` to select uv for package operations.
81+
Environment-only API calls still use the environment manager's preferred package manager
82+
(for example, pip for venv); project-scoped UI operations use the selected package manager.
83+
Tool operations use the selected project manager only when its ID matches the environment manager's
84+
preferred package manager ID; otherwise, they use the environment's preferred manager.
85+
Automatic routing preserves the existing uv selection rules: when the configured or preferred
86+
manager is pip, `python-envs.alwaysUseUv` and whether the environment was created by uv determine
87+
whether the router selects pip or uv. Each manager executes only its own commands. Selecting the uv
88+
manager explicitly always uses uv, independently of that setting. The uv package manager resolves
89+
uv from PATH, a current-session installation, or the owning trusted workspace's `.pyprojectx/main/uv`
90+
(`uv.exe` on Windows). If uv is unavailable, it reports an error rather than falling back to pip.
91+
7992
#### Default Package Manager by Environment Manager
8093

8194
| Environment Manager | Default Package Manager |

‎src/extension.ts‎

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
Uri,
1010
window,
1111
} from 'vscode';
12-
import { PythonEnvironment, PythonEnvironmentApi, PythonProjectCreator } from './api';
12+
import { PackageManagementOptions, PythonEnvironment, PythonEnvironmentApi, PythonProjectCreator } from './api';
1313
import { ENVS_EXTENSION_ID } from './common/constants';
1414
import { ensureCorrectVersion } from './common/extVersion';
1515
import { InlineScriptPackagesNotManagedError } from './common/inlineScript/errors';
@@ -259,29 +259,55 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
259259
);
260260
}),
261261
...(process.env.VSC_PYTHON_INTEGRATION_TEST === '1'
262-
? [
263-
commands.registerCommand('python-envs.test.getPackageManagerIds', () =>
264-
envManagers.packageManagers.map((manager) => manager.id),
265-
),
266-
commands.registerCommand(
267-
'python-envs.test.resolveEnvironmentWithManager',
268-
async (managerId: string, environmentUri: Uri) => {
269-
const manager = envManagers.getEnvironmentManager(managerId);
270-
if (!manager) {
271-
throw new Error(`Environment manager not found: ${managerId}`);
272-
}
273-
return manager.resolve(environmentUri);
274-
},
275-
),
276-
commands.registerCommand(
277-
'python-envs.test.getDirectPackageNames',
278-
async (environment: PythonEnvironment) => {
279-
const manager = envManagers.getPackageManager(environment);
280-
const names = await manager?.getDirectPackageNames?.(environment);
281-
return names ? Array.from(names) : undefined;
282-
},
283-
),
284-
]
262+
? (() => {
263+
const getProjectPackageManager = (scope: Uri) => {
264+
const manager = envManagers.getPackageManager(scope);
265+
if (!manager) {
266+
throw new Error(`Package manager not found for project: ${scope.fsPath}`);
267+
}
268+
return manager;
269+
};
270+
return [
271+
commands.registerCommand('python-envs.test.getPackageManagerIds', () =>
272+
envManagers.packageManagers.map((manager) => manager.id),
273+
),
274+
commands.registerCommand(
275+
'python-envs.test.resolveEnvironmentWithManager',
276+
async (managerId: string, environmentUri: Uri) => {
277+
const manager = envManagers.getEnvironmentManager(managerId);
278+
if (!manager) {
279+
throw new Error(`Environment manager not found: ${managerId}`);
280+
}
281+
return manager.resolve(environmentUri);
282+
},
283+
),
284+
commands.registerCommand(
285+
'python-envs.test.getPackagesForProject',
286+
(scope: Uri, environment: PythonEnvironment) =>
287+
getProjectPackageManager(scope).getPackages(environment, { skipCache: true }),
288+
),
289+
commands.registerCommand(
290+
'python-envs.test.managePackagesForProject',
291+
(scope: Uri, environment: PythonEnvironment, options: PackageManagementOptions) =>
292+
getProjectPackageManager(scope).manage(environment, options),
293+
),
294+
commands.registerCommand(
295+
'python-envs.test.getPackageAvailableVersionsForProject',
296+
(scope: Uri, environment: PythonEnvironment, packageName: string) =>
297+
getProjectPackageManager(scope).getPackageAvailableVersions(environment, packageName, {
298+
errorMode: 'throw',
299+
}),
300+
),
301+
commands.registerCommand(
302+
'python-envs.test.getDirectPackageNames',
303+
async (environment: PythonEnvironment, scope?: Uri) => {
304+
const manager = envManagers.getPackageManager(scope ?? environment);
305+
const names = await manager?.getDirectPackageNames?.(environment);
306+
return names ? Array.from(names) : undefined;
307+
},
308+
),
309+
];
310+
})()
285311
: []),
286312
commands.registerCommand('python-envs.searchSettings', async () => {
287313
await openSearchSettings();
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import type { Pep440Version } from '@renovatebot/pep440';
2+
import { CancellationToken, Disposable, Uri } from 'vscode';
3+
import {
4+
Capabilities,
5+
CapabilityCheck,
6+
GetPackagesOptions,
7+
Package,
8+
PackageCapability,
9+
PackageManagementOptions,
10+
PackageManager,
11+
PackageManagerCapability,
12+
PythonEnvironment,
13+
} from '../../api';
14+
import { PackageToolSupport, pythonToolSupport, throwIfCancelled } from '../../internal/pythonToolSupport';
15+
import { shouldUseUv } from './helpers';
16+
import { PipPackageManager } from './pipPackageManager';
17+
import { UvPackageManager } from './uvPackageManager';
18+
19+
/** Routes the existing pip entry point and coordinates invalidation between independent backend caches. */
20+
export class BuiltInPackageManager implements PackageManager, Disposable {
21+
private readonly cacheSubscriptions: Disposable[];
22+
23+
constructor(
24+
private readonly pip: PipPackageManager,
25+
private readonly uv: UvPackageManager,
26+
) {
27+
this.pip.setPackageManagerOwner(this);
28+
this.cacheSubscriptions = [
29+
this.pip.onDidMutatePackages(({ environment, packages }) => this.uv.invalidateCache(environment, packages)),
30+
this.uv.onDidMutatePackages(({ environment, packages }) => this.pip.invalidateCache(environment, packages)),
31+
];
32+
}
33+
34+
readonly name = 'pip';
35+
readonly displayName = 'Pip';
36+
37+
get description() {
38+
return this.pip.description;
39+
}
40+
get tooltip() {
41+
return this.pip.tooltip;
42+
}
43+
get iconPath() {
44+
return this.pip.iconPath;
45+
}
46+
get onDidChangePackages() {
47+
return this.pip.onDidChangePackages;
48+
}
49+
50+
readonly capabilities: Capabilities<PackageManagerCapability> = {
51+
[PackageCapability.List]: this.routeCapability(PackageCapability.List),
52+
[PackageCapability.Install]: this.routeCapability(PackageCapability.Install),
53+
[PackageCapability.Uninstall]: this.routeCapability(PackageCapability.Uninstall),
54+
[PackageCapability.Upgrade]: this.routeCapability(PackageCapability.Upgrade),
55+
[PackageCapability.FetchAvailableVersions]: this.routeCapability(PackageCapability.FetchAvailableVersions),
56+
[PackageCapability.ListDirect]: this.routeCapability(PackageCapability.ListDirect),
57+
};
58+
59+
readonly [pythonToolSupport]: PackageToolSupport = {
60+
manage: (environment, options, token, scope) =>
61+
this.manage(environment, { ...options, runHeadless: true }, token, scope),
62+
getPackages: async (environment, token, scope) =>
63+
(await this.getManager(environment, token))[pythonToolSupport].getPackages(environment, token, scope),
64+
};
65+
66+
private async getManager(
67+
environment: PythonEnvironment,
68+
token?: CancellationToken,
69+
): Promise<PipPackageManager | UvPackageManager> {
70+
throwIfCancelled(token);
71+
if (!environment.execInfo?.run?.executable) {
72+
return this.pip;
73+
}
74+
const envPath = environment.environmentPath.fsPath;
75+
return await shouldUseUv(this.pip.log, envPath, envPath, token) ? this.uv : this.pip;
76+
}
77+
78+
private routeCapability(capability: PackageManagerCapability): CapabilityCheck {
79+
return async (context) => {
80+
const manager = context.environment ? await this.getManager(context.environment) : this.pip;
81+
return manager.capabilities[capability](context);
82+
};
83+
}
84+
85+
/** Routes package mutations using the existing automatic uv rules. */
86+
async manage(
87+
environment: PythonEnvironment,
88+
options: PackageManagementOptions,
89+
toolToken?: CancellationToken,
90+
scope?: Uri,
91+
): Promise<void> {
92+
throwIfCancelled(toolToken);
93+
if (!options.install?.length && !options.uninstall?.length && options.runHeadless) {
94+
return;
95+
}
96+
const support = await this.pip.capabilities[PackageCapability.Install]({ environment });
97+
const manager = support.supported ? await this.getManager(environment, toolToken) : this.pip;
98+
return manager.manage(environment, options, toolToken, scope);
99+
}
100+
101+
/** Refreshes packages through the selected backend. */
102+
async refresh(environment: PythonEnvironment): Promise<void> {
103+
return (await this.getManager(environment)).refresh(environment);
104+
}
105+
106+
/** Lists packages through the selected backend, preserving its cache behavior. */
107+
async getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise<Package[] | undefined> {
108+
return (await this.getManager(environment)).getPackages(environment, options);
109+
}
110+
111+
/** Returns the version of the selected backend. */
112+
async getVersion(environment: PythonEnvironment): Promise<Pep440Version | undefined> {
113+
return (await this.getManager(environment)).getVersion(environment);
114+
}
115+
116+
/** Looks up compatible package versions through the selected backend. */
117+
async getPackageAvailableVersions(environment: PythonEnvironment, packageName: string): Promise<Pep440Version[]> {
118+
return (await this.getManager(environment)).getPackageAvailableVersions(environment, packageName);
119+
}
120+
121+
/** Lists dependency-root package names through the selected backend. */
122+
async getDirectPackageNames(environment: PythonEnvironment): Promise<Set<string> | undefined> {
123+
return (await this.getManager(environment)).getDirectPackageNames(environment);
124+
}
125+
126+
/** Releases the pip fallback owned by this registered entry point. */
127+
dispose(): void {
128+
this.cacheSubscriptions.forEach((subscription) => subscription.dispose());
129+
this.pip.dispose();
130+
}
131+
}

‎src/managers/builtin/main.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { createSimpleDebounce } from '../../common/utils/debounce';
44
import { createFileSystemWatcher, onDidDeleteFiles } from '../../common/workspace.apis';
55
import { getPythonApi } from '../../extensionApi';
66
import { NativePythonFinder } from '../common/nativePythonFinder';
7+
import { BuiltInPackageManager } from './builtInPackageManager';
78
import { PipPackageManager } from './pipPackageManager';
89
import { SysPythonManager } from './sysPythonManager';
10+
import { UvPackageManager } from './uvPackageManager';
911
import { VenvManager } from './venvManager';
1012

1113
export async function registerSystemPythonFeatures(
@@ -16,10 +18,13 @@ export async function registerSystemPythonFeatures(
1618
): Promise<void> {
1719
const api: PythonEnvironmentApi = await getPythonApi();
1820
const venvManager = new VenvManager(nativeFinder, api, envManager, log);
19-
const pkgManager = new PipPackageManager(api, log, venvManager);
21+
const uvPackageManager = new UvPackageManager(api, log, venvManager);
22+
const pipPackageManager = new PipPackageManager(api, log, venvManager);
23+
const pkgManager = new BuiltInPackageManager(pipPackageManager, uvPackageManager);
2024

2125
disposables.push(
2226
api.registerPackageManager(pkgManager),
27+
api.registerPackageManager(uvPackageManager),
2328
api.registerEnvironmentManager(envManager),
2429
api.registerEnvironmentManager(venvManager),
2530
);

0 commit comments

Comments
 (0)