diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 6ae2e6c387a..bc74ed8f8c6 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -7685,10 +7685,17 @@ pub struct MetadataArgs { /// Sync the environment to include module ownership metadata in the output. /// /// This adds a mapping from importable module names to references to the package nodes - /// that provide them. To do this, the venv will be synced in inexact mode. + /// that provide them. By default, the environment is synced in inexact mode. #[arg(long)] pub sync: bool, + /// Perform an exact sync, removing extraneous packages. + /// + /// By default, synchronization preserves packages that are not part of the selected + /// resolution. When enabled, uv removes those packages from the environment. + #[arg(long, requires = "sync")] + pub exact: bool, + /// Sync dependencies to the active virtual environment. /// /// Instead of creating or updating the virtual environment for the project or script, the diff --git a/crates/uv/src/commands/workspace/metadata.rs b/crates/uv/src/commands/workspace/metadata.rs index 2bfdfa47821..5d20a5b7ba2 100644 --- a/crates/uv/src/commands/workspace/metadata.rs +++ b/crates/uv/src/commands/workspace/metadata.rs @@ -14,6 +14,7 @@ use uv_warnings::warn_user; use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache}; use crate::commands::pip::loggers::DefaultResolveLogger; +use crate::commands::pip::operations::Modifications; use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::{LockMode, LockOperation}; use crate::commands::project::lock_target::LockTarget; @@ -34,7 +35,7 @@ pub(crate) async fn metadata( frozen: Option, dry_run: DryRun, refresh: Refresh, - sync: bool, + sync: Option, active: bool, python: Option, install_mirrors: PythonInstallMirrors, @@ -113,7 +114,7 @@ pub(crate) async fn metadata( python_preference, python_downloads, &install_mirrors, - if sync { + if sync.is_some() { ProjectEnvironmentPolicy::Compatible } else { ProjectEnvironmentPolicy::Optional @@ -173,7 +174,7 @@ pub(crate) async fn metadata( }, }; let mut export = metadata_for_target(install_target)?; - let environment = if sync { + let environment = if sync.is_some() { Some(match target { LockTarget::Workspace(workspace) => ProjectEnvironment::get_or_init( workspace, diff --git a/crates/uv/src/commands/workspace/module_owners.rs b/crates/uv/src/commands/workspace/module_owners.rs index d236a66adea..afd8ba4d1f3 100644 --- a/crates/uv/src/commands/workspace/module_owners.rs +++ b/crates/uv/src/commands/workspace/module_owners.rs @@ -29,9 +29,10 @@ use crate::settings::{InstallerSettingsRef, ResolverSettings}; /// Map importable modules to package IDs, optionally syncing all locked extras and groups first. /// -/// This uses a sufficient (inexact) sync so required distributions are available to inspect -/// without removing unrelated packages from an existing environment. Only distributions in the -/// selected resolution are assigned package IDs, so those unrelated packages are not reported. +/// By default, synchronization is sufficient (inexact), so required distributions are available +/// to inspect without removing unrelated packages from an existing environment. Exact +/// synchronization removes those unrelated packages instead. Only distributions in the selected +/// resolution are assigned package IDs. pub(crate) async fn collect_module_owners( target: InstallTarget<'_>, venv: &PythonEnvironment, @@ -43,14 +44,15 @@ pub(crate) async fn collect_module_owners( workspace_cache: &WorkspaceCache, preview: Preview, malware_settings: &MalwareCheckSettings, - sync: bool, + sync: Option, ) -> Result>> { let (extras, groups) = target_selection(target); - let Some(package_ids) = selected_package_ids(target, venv, &extras, &groups, settings)? else { + let package_ids = selected_package_ids(target, venv, &extras, &groups, settings)?; + if package_ids.is_none() && !matches!(sync, Some(Modifications::Exact)) { return Ok(BTreeMap::new()); - }; + } - if sync { + if let Some(modifications) = sync { let reinstall = Reinstall::None; let installer_settings = InstallerSettingsRef { index_locations: &settings.index_locations, @@ -77,7 +79,7 @@ pub(crate) async fn collect_module_owners( &groups, None, InstallOptions::default(), - Modifications::Sufficient, + modifications, None, installer_settings, client_builder, @@ -95,6 +97,10 @@ pub(crate) async fn collect_module_owners( .await?; } + let Some(package_ids) = package_ids else { + return Ok(BTreeMap::new()); + }; + find_module_owners_in_environment(venv, &package_ids) } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 4ed0fb0969f..c6902284e20 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -2145,7 +2145,7 @@ pub(crate) struct MetadataSettings { pub(crate) lock_check: LockCheck, pub(crate) frozen: Option, pub(crate) dry_run: DryRun, - pub(crate) sync: bool, + pub(crate) sync: Option, pub(crate) active: bool, pub(crate) python: Option, pub(crate) install_mirrors: PythonInstallMirrors, @@ -2170,6 +2170,7 @@ impl MetadataSettings { build, refresh, sync, + exact, active, python, } = *args; @@ -2193,7 +2194,11 @@ impl MetadataSettings { lock_check: resolve_lock_check(locked), frozen: resolve_frozen(frozen), dry_run: DryRun::from_args(dry_run), - sync, + sync: sync.then_some(if exact { + Modifications::Exact + } else { + Modifications::Sufficient + }), active, python: python.and_then(Maybe::into_option), refresh: Refresh::try_from(refresh)?, diff --git a/crates/uv/tests/workspace/workspace_metadata.rs b/crates/uv/tests/workspace/workspace_metadata.rs index 03ba2dbe40c..f3103ee66d4 100644 --- a/crates/uv/tests/workspace/workspace_metadata.rs +++ b/crates/uv/tests/workspace/workspace_metadata.rs @@ -6,6 +6,7 @@ use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; use async_zip::base::write::ZipFileWriter; use async_zip::{Compression, ZipEntryBuilder}; use futures::executor::block_on; +use indoc::{formatdoc, indoc}; use url::Url; use uv_static::EnvVars; @@ -459,6 +460,75 @@ fn workspace_metadata_script_includes_existing_environment() -> Result<()> { Ok(()) } +#[test] +fn workspace_metadata_script_exact_sync_removes_extraneous_packages() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + "# + })?; + + let extraneous = context + .temp_dir + .child("metadata_extra-0.1.0-py3-none-any.whl"); + write_wheel( + extraneous.path(), + "metadata-extra", + "metadata_extra-0.1.0", + &[("extra_module.py", "")], + )?; + + context + .pip_install() + .arg(extraneous.path()) + .assert() + .success(); + + context + .workspace_metadata() + .arg("--script") + .arg(script.path()) + .arg("--sync") + .arg("--active") + .env(EnvVars::VIRTUAL_ENV, context.venv.path()) + .assert() + .success(); + context.pip_show().arg("metadata-extra").assert().success(); + + let assert = context + .workspace_metadata() + .arg("--script") + .arg(script.path()) + .arg("--sync") + .arg("--exact") + .arg("--active") + .env(EnvVars::VIRTUAL_ENV, context.venv.path()) + .assert() + .success(); + let metadata: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout)?; + + insta::assert_json_snapshot!(serde_json::json!({ + "extraneous_installed": context + .pip_show() + .arg("metadata-extra") + .output()? + .status + .success(), + "module_owners": metadata.get("module_owners"), + }), @r#" + { + "extraneous_installed": false, + "module_owners": null + } + "#); + + Ok(()) +} + #[test] fn workspace_metadata_script_dependency_edges() -> Result<()> { let context = uv_test::test_context!("3.12"); @@ -755,6 +825,148 @@ fn workspace_metadata_sync_active_environment() -> Result<()> { Ok(()) } +#[test] +fn workspace_metadata_exact_requires_sync() { + let context = uv_test::test_context!("3.12"); + + uv_snapshot!(context.filters(), context.workspace_metadata().arg("--exact"), @r" + exit_code: 2 (failure) + ----- stderr ----- + error: the following required arguments were not provided: + --sync + + Usage: uv workspace metadata --sync --cache-dir [CACHE_DIR] --exact --exclude-newer + + For more information, try '--help'. + "); +} + +#[test] +fn workspace_metadata_exact_sync_removes_extraneous_packages() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let required = context + .temp_dir + .child("metadata_required-0.1.0-py3-none-any.whl"); + write_wheel( + required.path(), + "metadata-required", + "metadata_required-0.1.0", + &[("required_module.py", "")], + )?; + let required_url = Url::from_file_path(required.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + + let extraneous = context + .temp_dir + .child("metadata_extra-0.1.0-py3-none-any.whl"); + write_wheel( + extraneous.path(), + "metadata-extra", + "metadata_extra-0.1.0", + &[("extra_module.py", "")], + )?; + + context + .temp_dir + .child("pyproject.toml") + .write_str(&formatdoc! {r#" + [project] + name = "module-owner-root" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["metadata-required @ {required_url}"] + "# + })?; + + context + .pip_install() + .arg(extraneous.path()) + .assert() + .success(); + + let assert = context + .workspace_metadata() + .arg("--sync") + .assert() + .success(); + let metadata: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout)?; + let extraneous_installed = context + .pip_show() + .arg("metadata-extra") + .output()? + .status + .success(); + let required_installed = context + .pip_show() + .arg("metadata-required") + .output()? + .status + .success(); + + insta::with_settings!({ filters => context.filters() }, { + insta::assert_json_snapshot!(serde_json::json!({ + "extraneous_installed": extraneous_installed, + "module_owners": metadata["module_owners"], + "required_installed": required_installed, + }), @r#" + { + "extraneous_installed": true, + "module_owners": { + "required_module": [ + { + "package_id": "metadata-required==0.1.0@path+[TEMP_DIR]/metadata_required-0.1.0-py3-none-any.whl" + } + ] + }, + "required_installed": true + } + "#); + }); + + let assert = context + .workspace_metadata() + .arg("--sync") + .arg("--exact") + .assert() + .success(); + let metadata: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout)?; + let extraneous_installed = context + .pip_show() + .arg("metadata-extra") + .output()? + .status + .success(); + let required_installed = context + .pip_show() + .arg("metadata-required") + .output()? + .status + .success(); + + insta::with_settings!({ filters => context.filters() }, { + insta::assert_json_snapshot!(serde_json::json!({ + "extraneous_installed": extraneous_installed, + "module_owners": metadata["module_owners"], + "required_installed": required_installed, + }), @r#" + { + "extraneous_installed": false, + "module_owners": { + "required_module": [ + { + "package_id": "metadata-required==0.1.0@path+[TEMP_DIR]/metadata_required-0.1.0-py3-none-any.whl" + } + ] + }, + "required_installed": true + } + "#); + }); + + Ok(()) +} + #[test] fn workspace_metadata_includes_existing_environment() -> Result<()> { let context = uv_test::test_context!("3.12") diff --git a/docs/reference/internals/metadata.md b/docs/reference/internals/metadata.md index ae059862c37..5c4919261a7 100644 --- a/docs/reference/internals/metadata.md +++ b/docs/reference/internals/metadata.md @@ -5,6 +5,9 @@ JSON so other tools can use it. In particular, if you want access to the informa or script lockfile, you should prefer this command's output, as lockfiles are not a stable format we guarantee anything about. Pass `--script path/to/script.py` to request metadata for a script. +Pass `--sync` to install the selected packages before collecting module ownership information. +Synchronization preserves unrelated installed packages by default; add `--exact` to remove them. + The primary structure is the "resolution" field which contains the dependency graph with exact package versions that a `uv.lock` encodes.