diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 80965752b..4f6bd465d 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1637,6 +1637,18 @@ pub struct PipInstallArgs { #[arg(long)] pub python_platform: Option, + /// Do not remove extraneous packages present in the environment. + #[arg(long, overrides_with("exact"), alias = "no-exact", hide = true)] + pub inexact: bool, + + /// Perform an exact sync, removing extraneous packages. + /// + /// By default, installing will make the minimum necessary changes to satisfy the requirements. + /// When enabled, uv will update the environment to exactly match the requirements, removing + /// packages that are not included in the requirements. + #[arg(long, overrides_with("inexact"))] + pub exact: bool, + /// Validate the Python environment after completing the installation, to detect and with /// missing dependencies or other issues. #[arg(long, overrides_with("no_strict"))] diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index bd31e1b73..f444de692 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -67,6 +67,7 @@ pub(crate) async fn pip_install( no_build_isolation: bool, no_build_isolation_package: Vec, build_options: BuildOptions, + modifications: Modifications, python_version: Option, python_platform: Option, strict: bool, @@ -408,7 +409,7 @@ pub(crate) async fn pip_install( operations::install( &resolution, site_packages, - Modifications::Sufficient, + modifications, &reinstall, &build_options, link_mode, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 010538a0c..533b0f3c4 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -494,6 +494,7 @@ async fn run(cli: Cli) -> Result { args.settings.no_build_isolation, args.settings.no_build_isolation_package, args.settings.build_options, + args.modifications, args.settings.python_version, args.settings.python_platform, args.settings.strict, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index b9048eeef..22c35107c 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1307,6 +1307,7 @@ pub(crate) struct PipInstallSettings { pub(crate) dry_run: bool, pub(crate) constraints_from_workspace: Vec, pub(crate) overrides_from_workspace: Vec, + pub(crate) modifications: Modifications, pub(crate) refresh: Refresh, pub(crate) settings: PipSettings, } @@ -1320,16 +1321,16 @@ impl PipInstallSettings { editable, constraint, r#override, + build_constraint, extra, all_extras, no_all_extras, - build_constraint, + installer, refresh, no_deps, deps, require_hashes, no_require_hashes, - installer, verify_hashes, no_verify_hashes, python, @@ -1345,6 +1346,8 @@ impl PipInstallSettings { only_binary, python_version, python_platform, + inexact, + exact, strict, no_strict, dry_run, @@ -1398,6 +1401,11 @@ impl PipInstallSettings { dry_run, constraints_from_workspace, overrides_from_workspace, + modifications: if flag(exact, inexact).unwrap_or(false) { + Modifications::Exact + } else { + Modifications::Sufficient + }, refresh: Refresh::from(refresh), settings: PipSettings::combine( PipOptions { diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index 962a531fc..be6ee3a49 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -748,6 +748,64 @@ fn reinstall_incomplete() -> Result<()> { Ok(()) } +#[test] +fn exact_install_removes_extraneous_packages() -> Result<()> { + let context = TestContext::new("3.12").with_filtered_counts(); + + // Install flask + uv_snapshot!(context.filters(), context.pip_install() + .arg("flask"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + blinker==1.7.0 + + click==8.1.7 + + flask==3.0.2 + + itsdangerous==2.1.2 + + jinja2==3.1.3 + + markupsafe==2.1.5 + + werkzeug==3.0.1 + "### + ); + + // Install anyio with exact flag removes flask and flask dependencies. + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("anyio==3.7.0")?; + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--exact") + .arg("-r") + .arg("requirements.txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Uninstalled [N] packages in [TIME] + Installed [N] packages in [TIME] + + anyio==3.7.0 + - blinker==1.7.0 + - click==8.1.7 + - flask==3.0.2 + + idna==3.6 + - itsdangerous==2.1.2 + - jinja2==3.1.3 + - markupsafe==2.1.5 + + sniffio==1.3.1 + - werkzeug==3.0.1 + "### + ); + + Ok(()) +} + /// Like `pip`, we (unfortunately) allow incompatible environments. #[test] fn allow_incompatibilities() -> Result<()> { diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f4ce014ec..c0aefd0da 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5444,6 +5444,10 @@ uv pip install [OPTIONS] |--editable
--editable, -e editable

Install the editable package based on the provided local file path

+
--exact

Perform an exact sync, removing extraneous packages.

+ +

By default, installing will make the minimum necessary changes to satisfy the requirements. When enabled, uv will update the environment to exactly match the requirements, removing packages that are not included in the requirements.

+
--exclude-newer exclude-newer

Limit candidate packages to those that were uploaded prior to the given date.

Accepts both RFC 3339 timestamps (e.g., 2006-12-02T02:07:43Z) and local dates in the same format (e.g., 2006-12-02) in your system’s configured time zone.