diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 3c416997d4a1..2519ec317edd 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -826,6 +826,13 @@ pub struct PipSyncArgs { #[arg(long, conflicts_with = "no_build")] pub only_binary: Option>, + /// Allow sync of empty requirements, which will clear the environment of all packages. + #[arg(long, overrides_with("no_allow_empty_requirements"))] + pub allow_empty_requirements: bool, + + #[arg(long, overrides_with("allow_empty_requirements"))] + pub no_allow_empty_requirements: bool, + /// The minimum Python version that should be supported by the requirements (e.g., /// `3.7` or `3.7.9`). /// diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 8ca0014f7748..21f0a1f8fbf3 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -165,6 +165,7 @@ pub struct PipOptions { pub extra: Option>, pub all_extras: Option, pub no_deps: Option, + pub allow_empty_requirements: Option, pub resolution: Option, pub prerelease: Option, pub output_file: Option, diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index 774a1d574bbf..db07d57c01f5 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -47,6 +47,7 @@ pub(crate) async fn pip_sync( index_strategy: IndexStrategy, keyring_provider: KeyringProviderType, setup_py: SetupPyStrategy, + allow_empty_requirements: bool, connectivity: Connectivity, config_settings: &ConfigSettings, no_build_isolation: bool, @@ -104,10 +105,12 @@ pub(crate) async fn pip_sync( .await?; // Validate that the requirements are non-empty. - let num_requirements = requirements.len() + source_trees.len(); - if num_requirements == 0 { - writeln!(printer.stderr(), "No requirements found")?; - return Ok(ExitStatus::Success); + if !allow_empty_requirements { + let num_requirements = requirements.len() + source_trees.len(); + if num_requirements == 0 { + writeln!(printer.stderr(), "No requirements found (hint: use `--allow-empty-requirements` to clear the environment)")?; + return Ok(ExitStatus::Success); + } } // Detect the current Python interpreter. diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 6eace57eb37d..d1df2f23479a 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -337,6 +337,7 @@ async fn run() -> Result { args.settings.index_strategy, args.settings.keyring_provider, args.settings.setup_py, + args.settings.allow_empty_requirements, globals.connectivity, &args.settings.config_setting, args.settings.no_build_isolation, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 83daa302c866..8f7ec32fa556 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -759,6 +759,8 @@ impl PipSyncSettings { no_break_system_packages, target, prefix, + allow_empty_requirements, + no_allow_empty_requirements, legacy_setup_py, no_legacy_setup_py, no_build_isolation, @@ -791,15 +793,19 @@ impl PipSyncSettings { exclude_newer, target, prefix, + require_hashes: flag(require_hashes, no_require_hashes), no_build: flag(no_build, build), no_binary, only_binary, - no_build_isolation: flag(no_build_isolation, build_isolation), - strict: flag(strict, no_strict), + allow_empty_requirements: flag( + allow_empty_requirements, + no_allow_empty_requirements, + ), legacy_setup_py: flag(legacy_setup_py, no_legacy_setup_py), + no_build_isolation: flag(no_build_isolation, build_isolation), python_version, python_platform, - require_hashes: flag(require_hashes, no_require_hashes), + strict: flag(strict, no_strict), concurrent_builds: env(env::CONCURRENT_BUILDS), concurrent_downloads: env(env::CONCURRENT_DOWNLOADS), concurrent_installs: env(env::CONCURRENT_INSTALLS), @@ -1629,6 +1635,7 @@ pub(crate) struct PipSettings { pub(crate) keyring_provider: KeyringProviderType, pub(crate) no_build_isolation: bool, pub(crate) build_options: BuildOptions, + pub(crate) allow_empty_requirements: bool, pub(crate) strict: bool, pub(crate) dependency_mode: DependencyMode, pub(crate) resolution: ResolutionMode, @@ -1688,6 +1695,7 @@ impl PipSettings { extra, all_extras, no_deps, + allow_empty_requirements, resolution, prerelease, output_file, @@ -1814,6 +1822,10 @@ impl PipSettings { .generate_hashes .combine(generate_hashes) .unwrap_or_default(), + allow_empty_requirements: args + .allow_empty_requirements + .combine(allow_empty_requirements) + .unwrap_or_default(), setup_py: if args .legacy_setup_py .combine(legacy_setup_py) diff --git a/crates/uv/tests/pip_sync.rs b/crates/uv/tests/pip_sync.rs index eea15e09b723..6ba4c1c85db2 100644 --- a/crates/uv/tests/pip_sync.rs +++ b/crates/uv/tests/pip_sync.rs @@ -250,6 +250,68 @@ fn noop() -> Result<()> { Ok(()) } +/// Attempt to sync an empty set of requirements. +#[test] +fn pip_sync_empty() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.touch()?; + + uv_snapshot!(context.pip_sync() + .arg("requirements.txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Requirements file requirements.txt does not contain any dependencies + No requirements found (hint: use `--allow-empty-requirements` to clear the environment) + "### + ); + + uv_snapshot!(context.pip_sync() + .arg("requirements.txt") + .arg("--allow-empty-requirements"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Requirements file requirements.txt does not contain any dependencies + Resolved 0 packages in [TIME] + Audited 0 packages in [TIME] + "### + ); + + // Install a package. + requirements_txt.write_str("iniconfig==2.0.0")?; + context + .pip_sync() + .arg("requirements.txt") + .assert() + .success(); + + // Now, syncing should remove the package. + requirements_txt.write_str("")?; + uv_snapshot!(context.pip_sync() + .arg("requirements.txt") + .arg("--allow-empty-requirements"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Requirements file requirements.txt does not contain any dependencies + Resolved 0 packages in [TIME] + Uninstalled 1 package in [TIME] + - iniconfig==2.0.0 + "### + ); + + Ok(()) +} + /// Install a package into a virtual environment, then install the same package into a different /// virtual environment. #[test] diff --git a/crates/uv/tests/show_settings.rs b/crates/uv/tests/show_settings.rs index 74cb5eb13f72..b133320b272c 100644 --- a/crates/uv/tests/show_settings.rs +++ b/crates/uv/tests/show_settings.rs @@ -124,6 +124,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: LowestDirect, @@ -255,6 +256,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: Highest, @@ -387,6 +389,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: Highest, @@ -551,6 +554,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: LowestDirect, @@ -661,6 +665,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: Highest, @@ -803,6 +808,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: LowestDirect, @@ -982,6 +988,7 @@ fn resolve_index_url() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: Highest, @@ -1160,6 +1167,7 @@ fn resolve_index_url() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: Highest, @@ -1311,6 +1319,7 @@ fn resolve_find_links() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: Highest, @@ -1443,6 +1452,7 @@ fn resolve_top_level() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: LowestDirect, @@ -1613,6 +1623,7 @@ fn resolve_top_level() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: Highest, @@ -1766,6 +1777,7 @@ fn resolve_top_level() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: LowestDirect, @@ -1898,6 +1910,7 @@ fn resolve_user_configuration() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: LowestDirect, @@ -2013,6 +2026,7 @@ fn resolve_user_configuration() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: LowestDirect, @@ -2128,6 +2142,7 @@ fn resolve_user_configuration() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: Highest, @@ -2245,6 +2260,7 @@ fn resolve_user_configuration() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: LowestDirect, @@ -2387,6 +2403,7 @@ fn resolve_poetry_toml() -> anyhow::Result<()> { no_binary: None, no_build: None, }, + allow_empty_requirements: false, strict: false, dependency_mode: Transitive, resolution: LowestDirect, diff --git a/uv.schema.json b/uv.schema.json index ad48f2f0ad4e..2ffd3d961571 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -449,6 +449,12 @@ "null" ] }, + "allow-empty-requirements": { + "type": [ + "boolean", + "null" + ] + }, "annotation-style": { "anyOf": [ {