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
9 changes: 8 additions & 1 deletion crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions crates/uv/src/commands/workspace/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,7 +35,7 @@ pub(crate) async fn metadata(
frozen: Option<FrozenSource>,
dry_run: DryRun,
refresh: Refresh,
sync: bool,
sync: Option<Modifications>,
active: bool,
python: Option<String>,
install_mirrors: PythonInstallMirrors,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 14 additions & 8 deletions crates/uv/src/commands/workspace/module_owners.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -43,14 +44,15 @@ pub(crate) async fn collect_module_owners(
workspace_cache: &WorkspaceCache,
preview: Preview,
malware_settings: &MalwareCheckSettings,
sync: bool,
sync: Option<Modifications>,
) -> Result<BTreeMap<ModuleName, Vec<String>>> {
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,
Expand All @@ -77,7 +79,7 @@ pub(crate) async fn collect_module_owners(
&groups,
None,
InstallOptions::default(),
Modifications::Sufficient,
modifications,
None,
installer_settings,
client_builder,
Expand All @@ -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)
}

Expand Down
9 changes: 7 additions & 2 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2145,7 +2145,7 @@ pub(crate) struct MetadataSettings {
pub(crate) lock_check: LockCheck,
pub(crate) frozen: Option<FrozenSource>,
pub(crate) dry_run: DryRun,
pub(crate) sync: bool,
pub(crate) sync: Option<Modifications>,
pub(crate) active: bool,
pub(crate) python: Option<String>,
pub(crate) install_mirrors: PythonInstallMirrors,
Expand All @@ -2170,6 +2170,7 @@ impl MetadataSettings {
build,
refresh,
sync,
exact,
active,
python,
} = *args;
Expand All @@ -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)?,
Expand Down
212 changes: 212 additions & 0 deletions crates/uv/tests/workspace/workspace_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 <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")
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/internals/metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down