From 4cfcab9292ce9856363c3de7478bd2bad3278e89 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Wed, 22 Apr 2026 17:06:29 -0400 Subject: [PATCH 01/21] Add module owners to workspace metadata --- crates/uv-cli/src/lib.rs | 7 + .../uv-resolver/src/lock/export/metadata.rs | 16 ++ crates/uv/src/commands/project/mod.rs | 4 +- crates/uv/src/commands/project/sync.rs | 2 +- crates/uv/src/commands/workspace/metadata.rs | 117 +++++--- crates/uv/src/commands/workspace/mod.rs | 1 + .../src/commands/workspace/module_owners.rs | 236 ++++++++++++++++ crates/uv/src/lib.rs | 1 + crates/uv/src/settings.rs | 3 + crates/uv/tests/it/workspace_metadata.rs | 253 +++++++++++++++++- 10 files changed, 596 insertions(+), 44 deletions(-) create mode 100644 crates/uv/src/commands/workspace/module_owners.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 5fd42f03bd3..cbc4ccc7f28 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -8041,6 +8041,13 @@ pub struct MetadataArgs { #[command(flatten)] pub refresh: RefreshArgs, + /// Include module ownership metadata in the output. + /// + /// This adds a mapping from importable module names to the package names that provide + /// them. To do this, the venv will be synced in "inexact" mode. + #[arg(long)] + pub module_owners: bool, + /// The Python interpreter to use during resolution. /// /// A Python interpreter is required for building source distributions to determine package diff --git a/crates/uv-resolver/src/lock/export/metadata.rs b/crates/uv-resolver/src/lock/export/metadata.rs index f618d9a6bc7..e91e8500cfc 100644 --- a/crates/uv-resolver/src/lock/export/metadata.rs +++ b/crates/uv-resolver/src/lock/export/metadata.rs @@ -1,6 +1,9 @@ use std::collections::BTreeMap; use std::fmt::Display; +/// The name of an importable Python module. +type ModuleName = String; + use uv_distribution_filename::WheelFilename; use uv_distribution_types::{RequiresPython, UrlString}; use uv_fs::PortablePathBuf; @@ -66,6 +69,9 @@ pub struct Metadata { requires_python: RequiresPython, /// Info about conflicting packages conflicts: MetadataConflicts, + /// A mapping from importable module names to the distributions that provide them + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + module_owners: BTreeMap>, /// An index of which nodes are workspace members /// /// These entries are often what you should use as the entry-points into the `resolve` graph. @@ -818,6 +824,7 @@ impl Metadata { version: SchemaVersion::Preview, }, conflicts, + module_owners: BTreeMap::new(), workspace_root, requires_python: lock.requires_python.clone(), members, @@ -825,6 +832,15 @@ impl Metadata { }) } + #[must_use] + pub fn with_module_owners( + mut self, + module_owners: BTreeMap>, + ) -> Self { + self.module_owners = module_owners; + self + } + pub fn to_json(&self) -> Result { Ok(serde_json::to_string_pretty(self)?) } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index e1869388ebb..50cd23cf051 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -70,7 +70,7 @@ pub(crate) mod environment; pub(crate) mod export; pub(crate) mod format; pub(crate) mod init; -mod install_target; +pub(crate) mod install_target; pub(crate) mod lock; pub(crate) mod lock_target; pub(crate) mod remove; @@ -1402,7 +1402,7 @@ impl ScriptPython { /// The Python environment for a project. #[derive(Debug)] -enum ProjectEnvironment { +pub(crate) enum ProjectEnvironment { /// An existing [`PythonEnvironment`] was discovered, which satisfies the project's requirements. Existing(PythonEnvironment), /// An existing [`PythonEnvironment`] was discovered, but did not satisfy the project's diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index dce812ae017..412fd2651ff 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -633,7 +633,7 @@ impl Deref for SyncEnvironment { } /// Sync a lockfile with an environment. -pub(super) async fn do_sync( +pub(crate) async fn do_sync( target: InstallTarget<'_>, venv: &PythonEnvironment, extras: &ExtrasSpecificationWithDefaults, diff --git a/crates/uv/src/commands/workspace/metadata.rs b/crates/uv/src/commands/workspace/metadata.rs index a212c70facd..03ad87073ff 100644 --- a/crates/uv/src/commands/workspace/metadata.rs +++ b/crates/uv/src/commands/workspace/metadata.rs @@ -1,7 +1,7 @@ use std::fmt::Write; use std::path::Path; -use anyhow::Result; +use anyhow::{Context, Result}; use owo_colors::OwoColorize; use uv_cache::{Cache, Refresh}; @@ -9,19 +9,23 @@ use uv_client::BaseClientBuilder; use uv_configuration::{Concurrency, DependencyGroupsWithDefaults, DryRun}; use uv_preview::{Preview, PreviewFeature}; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; -use uv_resolver::{Lock, Metadata}; +use uv_resolver::Metadata; use uv_settings::PythonInstallMirrors; use uv_warnings::warn_user; -use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceCache}; +use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache}; use crate::commands::pip::loggers::DefaultResolveLogger; use crate::commands::project::lock::{LockMode, LockOperation}; use crate::commands::project::lock_target::LockTarget; -use crate::commands::project::{ProjectError, ProjectInterpreter, UniversalState, WorkspacePython}; +use crate::commands::project::{ + ProjectEnvironment, ProjectError, ProjectInterpreter, UniversalState, WorkspacePython, +}; use crate::commands::{ExitStatus, diagnostics}; use crate::printer::Printer; use crate::settings::{FrozenSource, LockCheck, ResolverSettings}; +use super::module_owners::collect_module_owners; + /// Display metadata about the workspace. pub(crate) async fn metadata( project_dir: &Path, @@ -29,6 +33,7 @@ pub(crate) async fn metadata( frozen: Option, dry_run: DryRun, refresh: Refresh, + module_owners: bool, python: Option, install_mirrors: PythonInstallMirrors, settings: ResolverSettings, @@ -54,38 +59,37 @@ pub(crate) async fn metadata( .await?; let target = LockTarget::Workspace(virtual_project.workspace()); + // Don't enable any groups' requires-python for interpreter discovery. + let groups = DependencyGroupsWithDefaults::none(); + let workspace_python = WorkspacePython::from_request( + python.as_deref().map(PythonRequest::parse), + Some(virtual_project.workspace()), + &groups, + project_dir, + no_config, + ) + .await?; + let interpreter = ProjectInterpreter::discover( + virtual_project.workspace(), + &groups, + workspace_python, + &client_builder, + python_preference, + python_downloads, + &install_mirrors, + false, + Some(false), + cache, + printer, + preview, + ) + .await? + .into_interpreter(); + // Determine the lock mode. - let interpreter; let mode = if let Some(frozen_source) = frozen { LockMode::Frozen(frozen_source.into()) } else { - // Don't enable any groups' requires-python for interpreter discovery - let groups = DependencyGroupsWithDefaults::none(); - let workspace_python = WorkspacePython::from_request( - python.as_deref().map(PythonRequest::parse), - Some(virtual_project.workspace()), - &groups, - project_dir, - no_config, - ) - .await?; - interpreter = ProjectInterpreter::discover( - virtual_project.workspace(), - &groups, - workspace_python, - &client_builder, - python_preference, - python_downloads, - &install_mirrors, - false, - Some(false), - cache, - printer, - preview, - ) - .await? - .into_interpreter(); - if let LockCheck::Enabled(lock_check) = lock_check { LockMode::Locked(&interpreter, lock_check) } else if dry_run.enabled() { @@ -117,7 +121,46 @@ pub(crate) async fn metadata( ) .await { - Ok(lock) => print_lock_as_metadata(virtual_project.workspace(), &lock.into_lock(), printer), + Ok(lock) => { + let lock = lock.into_lock(); + let mut export = Metadata::from_lock(virtual_project.workspace(), &lock)?; + if module_owners { + let environment = ProjectEnvironment::get_or_init( + virtual_project.workspace(), + &groups, + python.as_deref().map(PythonRequest::parse), + &install_mirrors, + &client_builder, + python_preference, + python_downloads, + false, + no_config, + Some(false), + cache, + DryRun::Disabled, + printer, + preview, + ) + .await?; + let module_owners = collect_module_owners( + virtual_project.workspace(), + &lock, + &environment, + &settings, + &client_builder, + &state, + &concurrency, + cache, + workspace_cache, + preview, + ) + .await + .context("Failed to collect module owners")?; + export = export.with_module_owners(module_owners); + } + + print_metadata(&export, printer) + } Err(err @ ProjectError::LockMismatch(..)) => { writeln!(printer.stderr(), "{}", err.to_string().bold())?; Ok(ExitStatus::Failure) @@ -131,13 +174,7 @@ pub(crate) async fn metadata( } } -fn print_lock_as_metadata( - workspace: &Workspace, - lock: &Lock, - printer: Printer, -) -> Result { - let export = Metadata::from_lock(workspace, lock)?; - +fn print_metadata(export: &Metadata, printer: Printer) -> Result { writeln!(printer.stdout(), "{}", export.to_json()?)?; Ok(ExitStatus::Success) diff --git a/crates/uv/src/commands/workspace/mod.rs b/crates/uv/src/commands/workspace/mod.rs index 6ee08d3e2b0..e4f1ca0d776 100644 --- a/crates/uv/src/commands/workspace/mod.rs +++ b/crates/uv/src/commands/workspace/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod dir; pub(crate) mod list; pub(crate) mod metadata; +mod module_owners; diff --git a/crates/uv/src/commands/workspace/module_owners.rs b/crates/uv/src/commands/workspace/module_owners.rs new file mode 100644 index 00000000000..b34247db16a --- /dev/null +++ b/crates/uv/src/commands/workspace/module_owners.rs @@ -0,0 +1,236 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use anyhow::Result; +use uv_cache::Cache; +use uv_client::BaseClientBuilder; +use uv_configuration::{ + Concurrency, DependencyGroups, DryRun, ExtrasSpecification, InstallOptions, Reinstall, +}; +use uv_distribution_types::Name; +use uv_install_wheel::read_record; +use uv_installer::SitePackages; +use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; +use uv_preview::Preview; +use uv_python::PythonEnvironment; +use uv_resolver::{Installable, Lock}; +use uv_workspace::{Workspace, WorkspaceCache}; + +use crate::commands::pip::loggers::DefaultInstallLogger; +use crate::commands::pip::operations::Modifications; +use crate::commands::pip::{resolution_markers, resolution_tags}; +use crate::commands::project::UniversalState; +use crate::commands::project::install_target::InstallTarget; +use crate::commands::project::sync::do_sync; +use crate::printer::Printer; +use crate::settings::{InstallerSettingsRef, ResolverSettings}; + +pub(crate) async fn collect_module_owners( + workspace: &Workspace, + lock: &Lock, + venv: &PythonEnvironment, + settings: &ResolverSettings, + client_builder: &BaseClientBuilder<'_>, + state: &UniversalState, + concurrency: &Concurrency, + cache: &Cache, + workspace_cache: &WorkspaceCache, + preview: Preview, +) -> Result>> { + let target = InstallTarget::Workspace { workspace, lock }; + let marker_env = resolution_markers(None, None, venv.interpreter()); + let tags = resolution_tags(None, None, venv.interpreter())?; + let extras = ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default()); + let groups = DependencyGroups::from_args( + false, + false, + false, + Vec::new(), + Vec::new(), + false, + Vec::new(), + true, + ) + .with_defaults(DefaultGroups::default()); + + let resolution = target.to_resolution( + &marker_env, + &tags, + &extras, + &groups, + &settings.build_options, + &InstallOptions::default(), + )?; + if resolution.is_empty() { + return Ok(BTreeMap::new()); + } + + let package_names = resolution + .distributions() + .map(|dist| dist.name().clone()) + .collect::>(); + + let reinstall = Reinstall::None; + let installer_settings = InstallerSettingsRef { + index_locations: &settings.index_locations, + index_strategy: settings.index_strategy, + keyring_provider: settings.keyring_provider, + dependency_metadata: &settings.dependency_metadata, + config_setting: &settings.config_setting, + config_settings_package: &settings.config_settings_package, + build_isolation: &settings.build_isolation, + extra_build_dependencies: &settings.extra_build_dependencies, + extra_build_variables: &settings.extra_build_variables, + exclude_newer: &settings.exclude_newer, + link_mode: settings.link_mode, + compile_bytecode: false, + reinstall: &reinstall, + build_options: &settings.build_options, + sources: settings.sources.clone(), + }; + + do_sync( + target, + venv, + &extras, + &groups, + None, + InstallOptions::default(), + Modifications::Sufficient, + None, + installer_settings, + client_builder, + &state.fork(), + Box::new(DefaultInstallLogger), + false, + concurrency, + cache, + workspace_cache, + DryRun::Disabled, + Printer::Silent, + preview, + ) + .await?; + + let mut owners = BTreeMap::>::new(); + for dist in SitePackages::from_environment(venv)? + .iter() + .filter(|dist| package_names.contains(dist.name())) + { + for module in inspect_installed_modules(dist.install_path())? { + owners + .entry(module) + .or_default() + .insert(dist.name().clone()); + } + } + + Ok(owners + .into_iter() + .map(|(module, owners)| (module, owners.into_iter().collect())) + .collect()) +} + +fn inspect_installed_modules(dist_info: &Path) -> Result> { + if !has_extension(dist_info, "dist-info") { + return Ok(BTreeSet::new()); + } + + let mut modules = BTreeSet::new(); + + let top_level = dist_info.join("top_level.txt"); + if let Ok(contents) = fs_err::read_to_string(top_level) { + for line in contents.lines() { + add_module_name(line.trim(), &mut modules); + } + } + + let record_path = dist_info.join("RECORD"); + let record = read_record(fs_err::File::open(&record_path)?)?; + for entry in record { + add_record_module(&entry.path, &mut modules); + } + + Ok(modules) +} + +fn add_record_module(path: &str, modules: &mut BTreeSet) { + let components = path + .split('/') + .filter(|component| !component.is_empty()) + .collect::>(); + let Some((file_name, parents)) = components.split_last() else { + return; + }; + + if components + .iter() + .any(|component| has_extension(component, "dist-info")) + { + return; + } + if components + .first() + .is_some_and(|component| has_extension(component, "data")) + { + return; + } + + let mut module_components = parents.to_vec(); + if *file_name == "__init__.py" { + // The parent path is the package. + } else if let Some(stem) = file_name.strip_suffix(".py") { + module_components.push(stem); + } else if let Some(stem) = extension_module_stem(file_name) { + if stem != "__init__" { + module_components.push(stem); + } + } else { + return; + } + + add_module_components(&module_components, modules); +} + +fn extension_module_stem(file_name: &str) -> Option<&str> { + let stem = file_name + .strip_suffix(".so") + .or_else(|| file_name.strip_suffix(".pyd"))?; + stem.split('.').next().filter(|stem| !stem.is_empty()) +} + +fn has_extension(path: impl AsRef, extension: &str) -> bool { + path.as_ref() + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(extension)) +} + +fn add_module_name(module: &str, modules: &mut BTreeSet) { + if module.is_empty() { + return; + } + let components = module.split('.').collect::>(); + add_module_components(&components, modules); +} + +fn add_module_components(components: &[&str], modules: &mut BTreeSet) { + if components.is_empty() || !components.iter().all(|component| is_identifier(component)) { + return; + } + + for index in 1..=components.len() { + modules.insert(components[..index].join(".")); + } +} + +fn is_identifier(component: &str) -> bool { + let mut chars = component.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first == '_' || first.is_ascii_alphabetic()) { + return false; + } + chars.all(|char| char == '_' || char.is_ascii_alphanumeric()) +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 8eb95960a22..8d8af4f88ce 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1954,6 +1954,7 @@ async fn run(cli: Cli) -> Result { args.frozen, args.dry_run, args.refresh, + args.module_owners, args.python, args.install_mirrors, args.settings, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index e00ca9b8077..9094dc7d49f 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1923,6 +1923,7 @@ pub(crate) struct MetadataSettings { pub(crate) lock_check: LockCheck, pub(crate) frozen: Option, pub(crate) dry_run: DryRun, + pub(crate) module_owners: bool, pub(crate) python: Option, pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) refresh: Refresh, @@ -1943,6 +1944,7 @@ impl MetadataSettings { resolver, build, refresh, + module_owners, python, } = *args; @@ -1962,6 +1964,7 @@ impl MetadataSettings { lock_check: resolve_lock_check(locked), frozen: resolve_frozen(frozen), dry_run: DryRun::from_args(dry_run), + module_owners, python: python.and_then(Maybe::into_option), refresh: Refresh::from(refresh), settings: ResolverSettings::combine( diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index f70af696ad3..715fff58104 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -1,9 +1,55 @@ +use std::io::Write; +use std::path::Path; + use anyhow::Result; use assert_cmd::assert::OutputAssertExt; -use assert_fs::fixture::PathChild; +use assert_fs::fixture::{FileWriteStr, PathChild}; +use fs_err::File; +use url::Url; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; use uv_test::{copy_dir_ignore, uv_snapshot}; +fn write_wheel( + path: &Path, + name: &str, + dist_info_prefix: &str, + files: &[(&str, &str)], +) -> Result<()> { + let mut writer = ZipWriter::new(File::create(path)?); + let options = SimpleFileOptions::default(); + let mut record = Vec::new(); + + for (file_path, contents) in files { + writer.start_file(file_path, options)?; + writer.write_all(contents.as_bytes())?; + record.push(format!("{file_path},,")); + } + + let metadata_path = format!("{dist_info_prefix}.dist-info/METADATA"); + writer.start_file(&metadata_path, options)?; + writer + .write_all(format!("Metadata-Version: 2.1\nName: {name}\nVersion: 0.1.0\n").as_bytes())?; + record.push(format!("{metadata_path},,")); + + let wheel_path = format!("{dist_info_prefix}.dist-info/WHEEL"); + writer.start_file(&wheel_path, options)?; + writer.write_all( + b"Wheel-Version: 1.0\nGenerator: uv-test\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + )?; + record.push(format!("{wheel_path},,")); + + let record_path = format!("{dist_info_prefix}.dist-info/RECORD"); + record.push(format!("{record_path},,")); + writer.start_file(&record_path, options)?; + writer.write_all(record.join("\n").as_bytes())?; + writer.write_all(b"\n")?; + + writer.finish()?; + Ok(()) +} + /// Test basic metadata output for a simple workspace with one member. #[test] fn workspace_metadata_simple() { @@ -55,6 +101,211 @@ fn workspace_metadata_simple() { ); } +#[test] +fn workspace_metadata_module_owners_from_locked_wheels() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let gpu_a = context.temp_dir.child("gpu_a-0.1.0-py3-none-any.whl"); + write_wheel(gpu_a.path(), "gpu-a", "gpu_a-0.1.0", &[("gpu/a.py", "")])?; + + let gpu_b = context.temp_dir.child("gpu_b-0.1.0-py3-none-any.whl"); + write_wheel(gpu_b.path(), "gpu-b", "gpu_b-0.1.0", &[("gpu/b.py", "")])?; + + let typing_extensions = context + .temp_dir + .child("typing_extensions-0.1.0-py3-none-any.whl"); + write_wheel( + typing_extensions.path(), + "typing-extensions", + "typing_extensions-0.1.0", + &[("typing_extensions.py", "")], + )?; + + let gpu_a_url = Url::from_file_path(gpu_a.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + let gpu_b_url = Url::from_file_path(gpu_b.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + let typing_extensions_url = Url::from_file_path(typing_extensions.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + + context + .temp_dir + .child("pyproject.toml") + .write_str(&format!( + r#"[project] +name = "module-owner-root" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "gpu-a @ {gpu_a_url}", + "gpu-b @ {gpu_b_url}", + "typing-extensions @ {typing_extensions_url}", +] +"# + ))?; + + let mut filters = context.filters(); + filters.push((r#""sha256": "[0-9a-f]{64}""#, r#""sha256": "[SHA256]""#)); + + uv_snapshot!(filters, context.workspace_metadata().arg("--module-owners"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/", + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, + "module_owners": { + "gpu": [ + "gpu-a", + "gpu-b" + ], + "gpu.a": [ + "gpu-a" + ], + "gpu.b": [ + "gpu-b" + ], + "typing_extensions": [ + "typing-extensions" + ] + }, + "members": [ + { + "name": "module-owner-root", + "path": "[TEMP_DIR]/", + "id": "module-owner-root==0.1.0@virtual+[TEMP_DIR]/" + } + ], + "resolution": { + "gpu-a==0.1.0@path+[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl": { + "name": "gpu-a", + "version": "0.1.0", + "source": { + "path": "[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl" + }, + "kind": "package", + "dependencies": [], + "wheels": [ + { + "hashes": { + "sha256": "[SHA256]" + }, + "filename": "gpu_a-0.1.0-py3-none-any.whl" + } + ] + }, + "gpu-b==0.1.0@path+[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl": { + "name": "gpu-b", + "version": "0.1.0", + "source": { + "path": "[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl" + }, + "kind": "package", + "dependencies": [], + "wheels": [ + { + "hashes": { + "sha256": "[SHA256]" + }, + "filename": "gpu_b-0.1.0-py3-none-any.whl" + } + ] + }, + "module-owner-root==0.1.0@virtual+[TEMP_DIR]/": { + "name": "module-owner-root", + "version": "0.1.0", + "source": { + "virtual": "[TEMP_DIR]/" + }, + "kind": "package", + "dependencies": [ + { + "id": "gpu-a==0.1.0@path+[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl" + }, + { + "id": "gpu-b==0.1.0@path+[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl" + }, + { + "id": "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" + } + ] + }, + "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl": { + "name": "typing-extensions", + "version": "0.1.0", + "source": { + "path": "[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" + }, + "kind": "package", + "dependencies": [], + "wheels": [ + { + "hashes": { + "sha256": "[SHA256]" + }, + "filename": "typing_extensions-0.1.0-py3-none-any.whl" + } + ] + } + } + } + + ----- stderr ----- + warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Resolved 4 packages in [TIME] + "#); + + Ok(()) +} + +#[test] +fn workspace_metadata_module_owners_failure_is_error() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let gpu_a = context.temp_dir.child("gpu_a-0.1.0-py3-none-any.whl"); + write_wheel(gpu_a.path(), "gpu-a", "gpu_a-0.1.0", &[("gpu/a.py", "")])?; + + let gpu_a_url = Url::from_file_path(gpu_a.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + + context + .temp_dir + .child("pyproject.toml") + .write_str(&format!( + r#"[project] +name = "module-owner-root" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "gpu-a @ {gpu_a_url}", +] +"# + ))?; + + context.lock().assert().success(); + fs_err::remove_file(gpu_a.path())?; + + uv_snapshot!(context.filters(), context.workspace_metadata().arg("--frozen").arg("--module-owners"), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + error: Failed to collect module owners + Caused by: Failed to determine installation plan + Caused by: Distribution not found at: file://[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl + "#); + + Ok(()) +} + /// Test metadata for a root workspace (workspace with a root package). #[test] #[cfg(feature = "test-pypi")] From bb38e44e4602bb5256cf595b732de54c48fb0622 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Tue, 28 Apr 2026 15:32:27 +0100 Subject: [PATCH 02/21] Address review feedback - Model module names using a new uv_pypi_types::ModuleName type (reusing Identifier validation logic). - Move installed module inspection out of the uv command layer and onto InstalledDist::read_modules in uv-distribution-types. - Inspect installed distributions from RECORD only, reusing uv_install_wheel::read_record instead of parsing top_level.txt. - Normalize RECORD paths and ignore non-module paths such as dist-info metadata and .data entries. - Detect modules from packages, .py files, .pyc bytecode, and extension modules. --- crates/uv-cli/src/lib.rs | 2 +- .../src/installed_modules.rs | 186 ++++++++++++++++++ crates/uv-distribution-types/src/lib.rs | 1 + crates/uv-pypi-types/src/lib.rs | 2 + crates/uv-pypi-types/src/module_name.rs | 141 +++++++++++++ .../uv-resolver/src/lock/export/metadata.rs | 7 +- .../src/commands/workspace/module_owners.rs | 113 +---------- crates/uv/tests/it/workspace_metadata.rs | 15 +- 8 files changed, 351 insertions(+), 116 deletions(-) create mode 100644 crates/uv-distribution-types/src/installed_modules.rs create mode 100644 crates/uv-pypi-types/src/module_name.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index cbc4ccc7f28..ef6e0609f07 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -8044,7 +8044,7 @@ pub struct MetadataArgs { /// Include module ownership metadata in the output. /// /// This adds a mapping from importable module names to the package names that provide - /// them. To do this, the venv will be synced in "inexact" mode. + /// them. To do this, the venv will be synced in inexact mode. #[arg(long)] pub module_owners: bool, diff --git a/crates/uv-distribution-types/src/installed_modules.rs b/crates/uv-distribution-types/src/installed_modules.rs new file mode 100644 index 00000000000..404174f1717 --- /dev/null +++ b/crates/uv-distribution-types/src/installed_modules.rs @@ -0,0 +1,186 @@ +use std::collections::BTreeSet; +use std::path::{Component, Path}; + +use fs_err::File; +use uv_fs::normalize_path; +use uv_install_wheel::read_record; +use uv_pypi_types::ModuleName; + +use crate::installed::{InstalledDist, InstalledDistError}; + +impl InstalledDist { + /// Read the modules provided by this installed distribution. + pub fn read_modules(&self) -> Result, InstalledDistError> { + read_modules(self.install_path()) + } +} + +fn read_modules(dist_info: &Path) -> Result, InstalledDistError> { + if !has_extension(dist_info, "dist-info") { + return Ok(BTreeSet::new()); + } + + let record_path = dist_info.join("RECORD"); + let record = read_record(File::open(&record_path)?)?; + + let mut modules = BTreeSet::new(); + for entry in record { + add_record_module(&entry.path, &mut modules); + } + + Ok(modules) +} + +fn add_record_module(path: &str, modules: &mut BTreeSet) { + let Some(components) = record_path_components(path) else { + return; + }; + let Some((file_name, parents)) = components.split_last() else { + return; + }; + + if components + .iter() + .any(|component| has_extension(component, "dist-info")) + { + return; + } + if components + .first() + .is_some_and(|component| has_extension(component, "data")) + { + return; + } + + let mut module_components = parents.iter().map(String::as_str).collect::>(); + if file_name == "__init__.py" { + // The parent path is the package. + } else if let Some(stem) = file_name.strip_suffix(".py") { + module_components.push(stem); + } else if let Some((stem, bytecode_parents)) = bytecode_module_stem(file_name, parents) { + module_components = bytecode_parents.iter().map(String::as_str).collect(); + if stem != "__init__" { + module_components.push(stem); + } + } else if let Some(stem) = extension_module_stem(file_name) { + if stem != "__init__" { + module_components.push(stem); + } + } else { + return; + } + + add_module_components(&module_components, modules); +} + +fn record_path_components(path: &str) -> Option> { + let normalized = normalize_path(Path::new(path)); + let path = normalized.as_ref(); + + if path.is_absolute() { + return None; + } + + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::Normal(component) => { + components.push(component.to_str()?.to_string()); + } + Component::CurDir => {} + Component::ParentDir | Component::Prefix(_) | Component::RootDir => return None, + } + } + + Some(components) +} + +fn bytecode_module_stem<'a>( + file_name: &'a str, + parents: &'a [String], +) -> Option<(&'a str, &'a [String])> { + let stem = file_name.strip_suffix(".pyc")?; + if parents.last().is_some_and(|parent| parent == "__pycache__") { + Some(( + stem.split('.').next().filter(|stem| !stem.is_empty())?, + &parents[..parents.len() - 1], + )) + } else { + Some((stem, parents)) + } +} + +fn extension_module_stem(file_name: &str) -> Option<&str> { + let stem = file_name + .strip_suffix(".so") + .or_else(|| file_name.strip_suffix(".pyd"))?; + // Extension modules include ABI and platform tags after the importable module name, e.g. + // `foo.cpython-312-darwin.so`. The first dot separates the module name from those tags. + stem.split('.').next().filter(|stem| !stem.is_empty()) +} + +fn has_extension(path: impl AsRef, extension: &str) -> bool { + path.as_ref() + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(extension)) +} + +fn add_module_components(components: &[&str], modules: &mut BTreeSet) { + if ModuleName::from_components(components.iter().copied()).is_err() { + return; + } + + for index in 1..=components.len() { + if let Ok(module) = ModuleName::from_components(components[..index].iter().copied()) { + modules.insert(module); + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use uv_pypi_types::ModuleName; + + use super::add_record_module; + + fn module_names(modules: BTreeSet) -> String { + modules + .into_iter() + .map(|module| module.to_string()) + .collect::>() + .join("\n") + } + + #[test] + fn record_module_normalizes_record_paths() { + let mut modules = BTreeSet::new(); + add_record_module("./package/../café.py", &mut modules); + + assert_eq!(module_names(modules), "café"); + } + + #[test] + fn record_module_from_bytecode() { + let mut modules = BTreeSet::new(); + add_record_module( + "package/__pycache__/module.cpython-312.opt-1.pyc", + &mut modules, + ); + add_record_module("package/__pycache__/__init__.cpython-312.pyc", &mut modules); + add_record_module("legacy.pyc", &mut modules); + + assert_eq!(module_names(modules), "legacy\npackage\npackage.module"); + } + + #[test] + fn record_module_from_extension_module() { + let mut modules = BTreeSet::new(); + add_record_module("package/extension.cpython-312-darwin.so", &mut modules); + add_record_module("package/__init__.cpython-312-darwin.so", &mut modules); + + assert_eq!(module_names(modules), "package\npackage.extension"); + } +} diff --git a/crates/uv-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs index d37d0be5472..89270c4ef53 100644 --- a/crates/uv-distribution-types/src/lib.rs +++ b/crates/uv-distribution-types/src/lib.rs @@ -106,6 +106,7 @@ mod index; mod index_name; mod index_url; mod installed; +mod installed_modules; mod known_platform; mod origin; mod pip_index; diff --git a/crates/uv-pypi-types/src/lib.rs b/crates/uv-pypi-types/src/lib.rs index 58f77175332..8bac08563b9 100644 --- a/crates/uv-pypi-types/src/lib.rs +++ b/crates/uv-pypi-types/src/lib.rs @@ -6,6 +6,7 @@ pub use identifier::*; pub use lenient_requirement::*; pub use marker_environment::*; pub use metadata::*; +pub use module_name::*; pub use parsed_url::*; pub use project_status::*; pub use scheme::*; @@ -20,6 +21,7 @@ mod identifier; mod lenient_requirement; mod marker_environment; mod metadata; +mod module_name; mod parsed_url; mod project_status; mod scheme; diff --git a/crates/uv-pypi-types/src/module_name.rs b/crates/uv-pypi-types/src/module_name.rs new file mode 100644 index 00000000000..8ebc5ed392e --- /dev/null +++ b/crates/uv-pypi-types/src/module_name.rs @@ -0,0 +1,141 @@ +use std::borrow::Cow; +use std::fmt::Display; +use std::str::FromStr; + +use serde::{Serialize, Serializer}; +use thiserror::Error; + +use crate::{Identifier, IdentifierParseError}; + +/// The name of an importable Python module. +/// +/// This is a dotted sequence of Python identifiers, like `foo` or `foo.bar`. +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct ModuleName(Box); + +#[derive(Debug, Clone, Error)] +pub enum ModuleNameParseError { + #[error("A module name must not be empty")] + Empty, + #[error("Invalid module name component `{component}` in `{module}`")] + InvalidComponent { + component: Box, + module: Box, + #[source] + err: IdentifierParseError, + }, +} + +impl ModuleName { + pub fn new(module: impl Into>) -> Result { + let module = module.into(); + if module.is_empty() { + return Err(ModuleNameParseError::Empty); + } + + for component in module.split('.') { + Identifier::new(component.to_string()).map_err(|err| { + ModuleNameParseError::InvalidComponent { + component: component.to_string().into_boxed_str(), + module: module.clone(), + err, + } + })?; + } + + Ok(Self(module)) + } + + pub fn from_components<'a>( + components: impl IntoIterator, + ) -> Result { + let components = components.into_iter().collect::>(); + if components.is_empty() { + return Err(ModuleNameParseError::Empty); + } + + Self::new(components.join(".")) + } +} + +impl FromStr for ModuleName { + type Err = ModuleNameParseError; + + fn from_str(module: &str) -> Result { + Self::new(module.to_string()) + } +} + +impl Display for ModuleName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for ModuleName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl<'de> serde::de::Deserialize<'de> for ModuleName { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + let s = >::deserialize(deserializer)?; + Self::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl Serialize for ModuleName { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + Serialize::serialize(&self.0, serializer) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for ModuleName { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("ModuleName") + } + + fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "pattern": r"^[_\p{Alphabetic}][_0-9\p{Alphabetic}]*(\.[_\p{Alphabetic}][_0-9\p{Alphabetic}]*)*$", + "description": "A dotted Python module name" + }) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use insta::assert_snapshot; + + use super::ModuleName; + + #[test] + fn valid() { + for module_name in ["abc", "abc.def", "_abc", "férrîs", "package.안녕하세요"] { + assert!(ModuleName::from_str(module_name).is_ok(), "{module_name}"); + } + } + + #[test] + fn invalid() { + assert_snapshot!( + ModuleName::from_str("foo-bar").unwrap_err(), + @"Invalid module name component `foo-bar` in `foo-bar`" + ); + assert_snapshot!( + ModuleName::from_str("foo.").unwrap_err(), + @"Invalid module name component `` in `foo.`" + ); + } +} diff --git a/crates/uv-resolver/src/lock/export/metadata.rs b/crates/uv-resolver/src/lock/export/metadata.rs index e91e8500cfc..3458e04c082 100644 --- a/crates/uv-resolver/src/lock/export/metadata.rs +++ b/crates/uv-resolver/src/lock/export/metadata.rs @@ -1,15 +1,12 @@ use std::collections::BTreeMap; use std::fmt::Display; -/// The name of an importable Python module. -type ModuleName = String; - use uv_distribution_filename::WheelFilename; use uv_distribution_types::{RequiresPython, UrlString}; use uv_fs::PortablePathBuf; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::Version; -use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts}; +use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts, ModuleName}; use uv_workspace::Workspace; use crate::Lock; @@ -69,7 +66,7 @@ pub struct Metadata { requires_python: RequiresPython, /// Info about conflicting packages conflicts: MetadataConflicts, - /// A mapping from importable module names to the distributions that provide them + /// A mapping from importable module names to the package names that provide them #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] module_owners: BTreeMap>, /// An index of which nodes are workspace members diff --git a/crates/uv/src/commands/workspace/module_owners.rs b/crates/uv/src/commands/workspace/module_owners.rs index b34247db16a..65aa3082fcc 100644 --- a/crates/uv/src/commands/workspace/module_owners.rs +++ b/crates/uv/src/commands/workspace/module_owners.rs @@ -1,5 +1,4 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; use anyhow::Result; use uv_cache::Cache; @@ -8,10 +7,10 @@ use uv_configuration::{ Concurrency, DependencyGroups, DryRun, ExtrasSpecification, InstallOptions, Reinstall, }; use uv_distribution_types::Name; -use uv_install_wheel::read_record; use uv_installer::SitePackages; use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_preview::Preview; +use uv_pypi_types::ModuleName; use uv_python::PythonEnvironment; use uv_resolver::{Installable, Lock}; use uv_workspace::{Workspace, WorkspaceCache}; @@ -36,7 +35,7 @@ pub(crate) async fn collect_module_owners( cache: &Cache, workspace_cache: &WorkspaceCache, preview: Preview, -) -> Result>> { +) -> Result>> { let target = InstallTarget::Workspace { workspace, lock }; let marker_env = resolution_markers(None, None, venv.interpreter()); let tags = resolution_tags(None, None, venv.interpreter())?; @@ -112,12 +111,12 @@ pub(crate) async fn collect_module_owners( ) .await?; - let mut owners = BTreeMap::>::new(); + let mut owners = BTreeMap::>::new(); for dist in SitePackages::from_environment(venv)? .iter() .filter(|dist| package_names.contains(dist.name())) { - for module in inspect_installed_modules(dist.install_path())? { + for module in dist.read_modules()? { owners .entry(module) .or_default() @@ -130,107 +129,3 @@ pub(crate) async fn collect_module_owners( .map(|(module, owners)| (module, owners.into_iter().collect())) .collect()) } - -fn inspect_installed_modules(dist_info: &Path) -> Result> { - if !has_extension(dist_info, "dist-info") { - return Ok(BTreeSet::new()); - } - - let mut modules = BTreeSet::new(); - - let top_level = dist_info.join("top_level.txt"); - if let Ok(contents) = fs_err::read_to_string(top_level) { - for line in contents.lines() { - add_module_name(line.trim(), &mut modules); - } - } - - let record_path = dist_info.join("RECORD"); - let record = read_record(fs_err::File::open(&record_path)?)?; - for entry in record { - add_record_module(&entry.path, &mut modules); - } - - Ok(modules) -} - -fn add_record_module(path: &str, modules: &mut BTreeSet) { - let components = path - .split('/') - .filter(|component| !component.is_empty()) - .collect::>(); - let Some((file_name, parents)) = components.split_last() else { - return; - }; - - if components - .iter() - .any(|component| has_extension(component, "dist-info")) - { - return; - } - if components - .first() - .is_some_and(|component| has_extension(component, "data")) - { - return; - } - - let mut module_components = parents.to_vec(); - if *file_name == "__init__.py" { - // The parent path is the package. - } else if let Some(stem) = file_name.strip_suffix(".py") { - module_components.push(stem); - } else if let Some(stem) = extension_module_stem(file_name) { - if stem != "__init__" { - module_components.push(stem); - } - } else { - return; - } - - add_module_components(&module_components, modules); -} - -fn extension_module_stem(file_name: &str) -> Option<&str> { - let stem = file_name - .strip_suffix(".so") - .or_else(|| file_name.strip_suffix(".pyd"))?; - stem.split('.').next().filter(|stem| !stem.is_empty()) -} - -fn has_extension(path: impl AsRef, extension: &str) -> bool { - path.as_ref() - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(extension)) -} - -fn add_module_name(module: &str, modules: &mut BTreeSet) { - if module.is_empty() { - return; - } - let components = module.split('.').collect::>(); - add_module_components(&components, modules); -} - -fn add_module_components(components: &[&str], modules: &mut BTreeSet) { - if components.is_empty() || !components.iter().all(|component| is_identifier(component)) { - return; - } - - for index in 1..=components.len() { - modules.insert(components[..index].join(".")); - } -} - -fn is_identifier(component: &str) -> bool { - let mut chars = component.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !(first == '_' || first.is_ascii_alphabetic()) { - return false; - } - chars.all(|char| char == '_' || char.is_ascii_alphanumeric()) -} diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index 715fff58104..9506dfef87b 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -118,7 +118,11 @@ fn workspace_metadata_module_owners_from_locked_wheels() -> Result<()> { typing_extensions.path(), "typing-extensions", "typing_extensions-0.1.0", - &[("typing_extensions.py", "")], + &[ + ("typing_extensions.py", ""), + ("café.py", ""), + ("bytecode/__pycache__/compiled.cpython-312.pyc", ""), + ], )?; let gpu_a_url = Url::from_file_path(gpu_a.path()) @@ -161,6 +165,15 @@ dependencies = [ "sets": [] }, "module_owners": { + "bytecode": [ + "typing-extensions" + ], + "bytecode.compiled": [ + "typing-extensions" + ], + "café": [ + "typing-extensions" + ], "gpu": [ "gpu-a", "gpu-b" From 32b5897238932ee5374d9a4b2139f610055f060d Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Thu, 30 Apr 2026 10:31:04 +0100 Subject: [PATCH 03/21] Rename workspace metadata sync flag --- crates/uv-cli/src/lib.rs | 4 ++-- crates/uv/src/commands/workspace/metadata.rs | 4 ++-- crates/uv/src/lib.rs | 2 +- crates/uv/src/settings.rs | 6 +++--- crates/uv/tests/it/workspace_metadata.rs | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index ef6e0609f07..be509370655 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -8041,12 +8041,12 @@ pub struct MetadataArgs { #[command(flatten)] pub refresh: RefreshArgs, - /// Include module ownership metadata in the output. + /// Sync the environment to include module ownership metadata in the output. /// /// This adds a mapping from importable module names to the package names that provide /// them. To do this, the venv will be synced in inexact mode. #[arg(long)] - pub module_owners: bool, + pub sync: bool, /// The Python interpreter to use during resolution. /// diff --git a/crates/uv/src/commands/workspace/metadata.rs b/crates/uv/src/commands/workspace/metadata.rs index 03ad87073ff..0a1b54641f8 100644 --- a/crates/uv/src/commands/workspace/metadata.rs +++ b/crates/uv/src/commands/workspace/metadata.rs @@ -33,7 +33,7 @@ pub(crate) async fn metadata( frozen: Option, dry_run: DryRun, refresh: Refresh, - module_owners: bool, + sync: bool, python: Option, install_mirrors: PythonInstallMirrors, settings: ResolverSettings, @@ -124,7 +124,7 @@ pub(crate) async fn metadata( Ok(lock) => { let lock = lock.into_lock(); let mut export = Metadata::from_lock(virtual_project.workspace(), &lock)?; - if module_owners { + if sync { let environment = ProjectEnvironment::get_or_init( virtual_project.workspace(), &groups, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 8d8af4f88ce..526b2c99b53 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1954,7 +1954,7 @@ async fn run(cli: Cli) -> Result { args.frozen, args.dry_run, args.refresh, - args.module_owners, + args.sync, args.python, args.install_mirrors, args.settings, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 9094dc7d49f..6596e5bc598 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1923,7 +1923,7 @@ pub(crate) struct MetadataSettings { pub(crate) lock_check: LockCheck, pub(crate) frozen: Option, pub(crate) dry_run: DryRun, - pub(crate) module_owners: bool, + pub(crate) sync: bool, pub(crate) python: Option, pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) refresh: Refresh, @@ -1944,7 +1944,7 @@ impl MetadataSettings { resolver, build, refresh, - module_owners, + sync, python, } = *args; @@ -1964,7 +1964,7 @@ impl MetadataSettings { lock_check: resolve_lock_check(locked), frozen: resolve_frozen(frozen), dry_run: DryRun::from_args(dry_run), - module_owners, + sync, python: python.and_then(Maybe::into_option), refresh: Refresh::from(refresh), settings: ResolverSettings::combine( diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index 9506dfef87b..fd0b8996a0e 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -151,7 +151,7 @@ dependencies = [ let mut filters = context.filters(); filters.push((r#""sha256": "[0-9a-f]{64}""#, r#""sha256": "[SHA256]""#)); - uv_snapshot!(filters, context.workspace_metadata().arg("--module-owners"), @r#" + uv_snapshot!(filters, context.workspace_metadata().arg("--sync"), @r#" success: true exit_code: 0 ----- stdout ----- @@ -304,7 +304,7 @@ dependencies = [ context.lock().assert().success(); fs_err::remove_file(gpu_a.path())?; - uv_snapshot!(context.filters(), context.workspace_metadata().arg("--frozen").arg("--module-owners"), @r#" + uv_snapshot!(context.filters(), context.workspace_metadata().arg("--frozen").arg("--sync"), @r#" success: false exit_code: 2 ----- stdout ----- From 7ff7a3846e12ad1314246593d63ae05b80103143 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Thu, 30 Apr 2026 11:04:01 +0100 Subject: [PATCH 04/21] Make workspace metadata sync conflict with dry-run --- crates/uv-cli/src/lib.rs | 7 ++++++- crates/uv/tests/it/workspace_metadata.rs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index be509370655..ab8ed992c5d 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -8029,7 +8029,12 @@ pub struct MetadataArgs { /// /// In dry-run mode, uv will resolve the project's dependencies and report on the resulting /// changes, but will not write the lockfile to disk. - #[arg(long, conflicts_with = "frozen", conflicts_with = "locked")] + #[arg( + long, + conflicts_with = "frozen", + conflicts_with = "locked", + conflicts_with = "sync" + )] pub dry_run: bool, #[command(flatten)] diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index fd0b8996a0e..635e5c0d6a2 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -101,6 +101,24 @@ fn workspace_metadata_simple() { ); } +#[test] +fn workspace_metadata_dry_run_sync_conflict() { + let context = uv_test::test_context!("3.12"); + + uv_snapshot!(context.filters(), context.workspace_metadata().arg("--dry-run").arg("--sync"), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: the argument '--dry-run' cannot be used with '--sync' + + Usage: uv workspace metadata --cache-dir [CACHE_DIR] --dry-run --exclude-newer + + For more information, try '--help'. + "#); +} + #[test] fn workspace_metadata_module_owners_from_locked_wheels() -> Result<()> { let context = uv_test::test_context!("3.12"); From a7071066034d929afdad7ec02b92f648aa4bcd84 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Thu, 30 Apr 2026 10:51:14 +0100 Subject: [PATCH 05/21] Use package IDs for module owners --- crates/uv-cli/src/lib.rs | 4 +- .../uv-resolver/src/lock/export/metadata.rs | 45 ++++++++--- .../src/commands/workspace/module_owners.rs | 27 ++++--- crates/uv/tests/it/workspace_metadata.rs | 81 +++++++++++++++++-- 4 files changed, 126 insertions(+), 31 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index ab8ed992c5d..b1e02c5d40a 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -8048,8 +8048,8 @@ pub struct MetadataArgs { /// Sync the environment to include module ownership metadata in the output. /// - /// This adds a mapping from importable module names to the package names that provide - /// them. To do this, the venv will be synced in inexact mode. + /// This adds a mapping from importable module names to the IDs of the package nodes + /// that provide them. To do this, the venv will be synced in inexact mode. #[arg(long)] pub sync: bool, diff --git a/crates/uv-resolver/src/lock/export/metadata.rs b/crates/uv-resolver/src/lock/export/metadata.rs index 3458e04c082..e8b9a8d495e 100644 --- a/crates/uv-resolver/src/lock/export/metadata.rs +++ b/crates/uv-resolver/src/lock/export/metadata.rs @@ -2,23 +2,25 @@ use std::collections::BTreeMap; use std::fmt::Display; use uv_distribution_filename::WheelFilename; -use uv_distribution_types::{RequiresPython, UrlString}; +use uv_distribution_types::{Name, RequiresPython, ResolvedDist, UrlString}; use uv_fs::PortablePathBuf; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::Version; use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts, ModuleName}; use uv_workspace::Workspace; -use crate::Lock; use crate::lock::{ Dependency, DirectSource, PackageId, RegistrySource, Source, SourceDist, SourceDistMetadata, Wheel, WheelWireSource, ZstdWheel, }; +use crate::{Lock, LockError}; #[derive(Debug, thiserror::Error)] enum MetadataErrorKind { #[error(transparent)] Serialize(#[from] serde_json::error::Error), + #[error(transparent)] + Lock(#[from] LockError), } #[derive(Debug)] @@ -66,9 +68,9 @@ pub struct Metadata { requires_python: RequiresPython, /// Info about conflicting packages conflicts: MetadataConflicts, - /// A mapping from importable module names to the package names that provide them + /// A mapping from importable module names to the IDs of the package nodes that provide them #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] - module_owners: BTreeMap>, + module_owners: BTreeMap>, /// An index of which nodes are workspace members /// /// These entries are often what you should use as the entry-points into the `resolve` graph. @@ -829,12 +831,37 @@ impl Metadata { }) } + pub fn package_node_id( + workspace: &Workspace, + dist: &ResolvedDist, + ) -> Result { + let workspace_root = PortablePathBuf::from(workspace.install_path().as_path()); + Self::package_node_id_with_root(&workspace_root, dist) + } + + fn package_node_id_with_root( + workspace_root: &PortablePathBuf, + dist: &ResolvedDist, + ) -> Result { + let source = Source::from_resolved_dist(dist, workspace_root.as_ref())?; + Ok(MetadataNodeId { + name: dist.name().clone(), + version: dist.version().cloned(), + source: MetadataSource::from_source(workspace_root, source), + kind: MetadataNodeKind::Package, + } + .to_flat()) + } + #[must_use] - pub fn with_module_owners( - mut self, - module_owners: BTreeMap>, - ) -> Self { - self.module_owners = module_owners; + pub fn with_module_owners(mut self, module_owners: BTreeMap>) -> Self { + self.module_owners = module_owners + .into_iter() + .filter_map(|(module, mut owners)| { + owners.retain(|owner| self.resolution.contains_key(owner)); + (!owners.is_empty()).then_some((module, owners)) + }) + .collect(); self } diff --git a/crates/uv/src/commands/workspace/module_owners.rs b/crates/uv/src/commands/workspace/module_owners.rs index 65aa3082fcc..e194ebb2a04 100644 --- a/crates/uv/src/commands/workspace/module_owners.rs +++ b/crates/uv/src/commands/workspace/module_owners.rs @@ -12,7 +12,7 @@ use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_preview::Preview; use uv_pypi_types::ModuleName; use uv_python::PythonEnvironment; -use uv_resolver::{Installable, Lock}; +use uv_resolver::{Installable, Lock, Metadata}; use uv_workspace::{Workspace, WorkspaceCache}; use crate::commands::pip::loggers::DefaultInstallLogger; @@ -35,7 +35,7 @@ pub(crate) async fn collect_module_owners( cache: &Cache, workspace_cache: &WorkspaceCache, preview: Preview, -) -> Result>> { +) -> Result>> { let target = InstallTarget::Workspace { workspace, lock }; let marker_env = resolution_markers(None, None, venv.interpreter()); let tags = resolution_tags(None, None, venv.interpreter())?; @@ -64,10 +64,13 @@ pub(crate) async fn collect_module_owners( return Ok(BTreeMap::new()); } - let package_names = resolution - .distributions() - .map(|dist| dist.name().clone()) - .collect::>(); + let mut package_ids = BTreeMap::>::new(); + for dist in resolution.distributions() { + package_ids + .entry(dist.name().clone()) + .or_default() + .insert(Metadata::package_node_id(workspace, dist)?); + } let reinstall = Reinstall::None; let installer_settings = InstallerSettingsRef { @@ -111,16 +114,16 @@ pub(crate) async fn collect_module_owners( ) .await?; - let mut owners = BTreeMap::>::new(); - for dist in SitePackages::from_environment(venv)? - .iter() - .filter(|dist| package_names.contains(dist.name())) - { + let mut owners = BTreeMap::>::new(); + for dist in SitePackages::from_environment(venv)?.iter() { + let Some(package_ids) = package_ids.get(dist.name()) else { + continue; + }; for module in dist.read_modules()? { owners .entry(module) .or_default() - .insert(dist.name().clone()); + .extend(package_ids.iter().cloned()); } } diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index 635e5c0d6a2..6cc638c6c23 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -184,26 +184,26 @@ dependencies = [ }, "module_owners": { "bytecode": [ - "typing-extensions" + "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" ], "bytecode.compiled": [ - "typing-extensions" + "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" ], "café": [ - "typing-extensions" + "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" ], "gpu": [ - "gpu-a", - "gpu-b" + "gpu-a==0.1.0@path+[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl", + "gpu-b==0.1.0@path+[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl" ], "gpu.a": [ - "gpu-a" + "gpu-a==0.1.0@path+[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl" ], "gpu.b": [ - "gpu-b" + "gpu-b==0.1.0@path+[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl" ], "typing_extensions": [ - "typing-extensions" + "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" ] }, "members": [ @@ -295,6 +295,71 @@ dependencies = [ Ok(()) } +#[test] +fn workspace_metadata_module_owners_use_installed_package_id() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let py311_dir = context.temp_dir.child("py311"); + fs_err::create_dir_all(py311_dir.path())?; + let module_owner_311 = py311_dir.child("module_owner-0.1.0-py3-none-any.whl"); + write_wheel( + module_owner_311.path(), + "module-owner", + "module_owner-0.1.0", + &[("shared.py", "")], + )?; + + let py312_dir = context.temp_dir.child("py312"); + fs_err::create_dir_all(py312_dir.path())?; + let module_owner_312 = py312_dir.child("module_owner-0.1.0-py3-none-any.whl"); + write_wheel( + module_owner_312.path(), + "module-owner", + "module_owner-0.1.0", + &[("shared.py", "")], + )?; + + let module_owner_311_url = Url::from_file_path(module_owner_311.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + let module_owner_312_url = Url::from_file_path(module_owner_312.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + + context + .temp_dir + .child("pyproject.toml") + .write_str(&format!( + r#"[project] +name = "module-owner-root" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "module-owner @ {module_owner_311_url} ; python_version < '3.12'", + "module-owner @ {module_owner_312_url} ; python_version >= '3.12'", +] +"# + ))?; + + let assert = context + .workspace_metadata() + .arg("--sync") + .assert() + .success(); + let metadata: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout)?; + let module_owners = serde_json::to_string_pretty(&metadata["module_owners"])?; + + insta::with_settings!({ filters => context.filters() }, { + insta::assert_snapshot!(module_owners, @r#" + { + "shared": [ + "module-owner==0.1.0@path+[TEMP_DIR]/py312/module_owner-0.1.0-py3-none-any.whl" + ] + } + "#); + }); + + Ok(()) +} + #[test] fn workspace_metadata_module_owners_failure_is_error() -> Result<()> { let context = uv_test::test_context!("3.12"); From 125ed1b7207fc6f953c01e39d0be87aca534b38d Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Thu, 30 Apr 2026 11:07:50 +0100 Subject: [PATCH 06/21] Avoid Python discovery for frozen workspace metadata --- crates/uv/src/commands/workspace/metadata.rs | 50 ++++++++++---------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/crates/uv/src/commands/workspace/metadata.rs b/crates/uv/src/commands/workspace/metadata.rs index 0a1b54641f8..c7e5bfbbc9b 100644 --- a/crates/uv/src/commands/workspace/metadata.rs +++ b/crates/uv/src/commands/workspace/metadata.rs @@ -61,35 +61,37 @@ pub(crate) async fn metadata( // Don't enable any groups' requires-python for interpreter discovery. let groups = DependencyGroupsWithDefaults::none(); - let workspace_python = WorkspacePython::from_request( - python.as_deref().map(PythonRequest::parse), - Some(virtual_project.workspace()), - &groups, - project_dir, - no_config, - ) - .await?; - let interpreter = ProjectInterpreter::discover( - virtual_project.workspace(), - &groups, - workspace_python, - &client_builder, - python_preference, - python_downloads, - &install_mirrors, - false, - Some(false), - cache, - printer, - preview, - ) - .await? - .into_interpreter(); // Determine the lock mode. + let interpreter; let mode = if let Some(frozen_source) = frozen { LockMode::Frozen(frozen_source.into()) } else { + let workspace_python = WorkspacePython::from_request( + python.as_deref().map(PythonRequest::parse), + Some(virtual_project.workspace()), + &groups, + project_dir, + no_config, + ) + .await?; + interpreter = ProjectInterpreter::discover( + virtual_project.workspace(), + &groups, + workspace_python, + &client_builder, + python_preference, + python_downloads, + &install_mirrors, + false, + Some(false), + cache, + printer, + preview, + ) + .await? + .into_interpreter(); + if let LockCheck::Enabled(lock_check) = lock_check { LockMode::Locked(&interpreter, lock_check) } else if dry_run.enabled() { From cf8cd8074fa1945920c15a4ca63a4936a5225e4b Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Thu, 30 Apr 2026 11:48:16 +0100 Subject: [PATCH 07/21] Simplify workspace metadata module owners --- .../uv-configuration/src/dependency_groups.rs | 8 +++ .../src/installed_modules.rs | 10 ++-- crates/uv-pypi-types/src/module_name.rs | 49 +++++++++++++++---- .../uv-resolver/src/lock/export/metadata.rs | 10 +--- .../src/commands/workspace/module_owners.rs | 31 ++++-------- 5 files changed, 62 insertions(+), 46 deletions(-) diff --git a/crates/uv-configuration/src/dependency_groups.rs b/crates/uv-configuration/src/dependency_groups.rs index 62f51dd7ff9..4196c3a9d48 100644 --- a/crates/uv-configuration/src/dependency_groups.rs +++ b/crates/uv-configuration/src/dependency_groups.rs @@ -140,6 +140,14 @@ impl DependencyGroups { }) } + /// Helper to make a spec from just --all-groups. + pub fn from_all_groups() -> Self { + Self::from_history(DependencyGroupsHistory { + all_groups: true, + ..Default::default() + }) + } + /// Apply defaults to a base [`DependencyGroups`]. /// /// This is appropriate in projects, where the `dev` group is synced by default. diff --git a/crates/uv-distribution-types/src/installed_modules.rs b/crates/uv-distribution-types/src/installed_modules.rs index 404174f1717..be3c3e585a1 100644 --- a/crates/uv-distribution-types/src/installed_modules.rs +++ b/crates/uv-distribution-types/src/installed_modules.rs @@ -127,15 +127,11 @@ fn has_extension(path: impl AsRef, extension: &str) -> bool { } fn add_module_components(components: &[&str], modules: &mut BTreeSet) { - if ModuleName::from_components(components.iter().copied()).is_err() { + let Ok(module) = ModuleName::from_components(components.iter().copied()) else { return; - } + }; - for index in 1..=components.len() { - if let Ok(module) = ModuleName::from_components(components[..index].iter().copied()) { - modules.insert(module); - } - } + modules.extend(module.prefixes()); } #[cfg(test)] diff --git a/crates/uv-pypi-types/src/module_name.rs b/crates/uv-pypi-types/src/module_name.rs index 8ebc5ed392e..b1fdadd2e37 100644 --- a/crates/uv-pypi-types/src/module_name.rs +++ b/crates/uv-pypi-types/src/module_name.rs @@ -34,13 +34,7 @@ impl ModuleName { } for component in module.split('.') { - Identifier::new(component.to_string()).map_err(|err| { - ModuleNameParseError::InvalidComponent { - component: component.to_string().into_boxed_str(), - module: module.clone(), - err, - } - })?; + Self::validate_component(&module, component)?; } Ok(Self(module)) @@ -54,7 +48,33 @@ impl ModuleName { return Err(ModuleNameParseError::Empty); } - Self::new(components.join(".")) + let module = components.join(".").into_boxed_str(); + for component in components { + Self::validate_component(&module, component)?; + } + + Ok(Self(module)) + } + + /// Iterate over this module and its parent modules. + /// + /// For example, `foo.bar.baz` yields `foo`, `foo.bar`, and `foo.bar.baz`. + pub fn prefixes(&self) -> impl Iterator + '_ { + self.0 + .match_indices('.') + .map(|(index, _)| Self(Box::from(&self.0[..index]))) + .chain(std::iter::once(self.clone())) + } + + fn validate_component(module: &str, component: &str) -> Result<(), ModuleNameParseError> { + Identifier::new(component.to_string()).map_err(|err| { + ModuleNameParseError::InvalidComponent { + component: component.to_string().into_boxed_str(), + module: module.into(), + err, + } + })?; + Ok(()) } } @@ -62,7 +82,7 @@ impl FromStr for ModuleName { type Err = ModuleNameParseError; fn from_str(module: &str) -> Result { - Self::new(module.to_string()) + Self::new(module) } } @@ -138,4 +158,15 @@ mod tests { @"Invalid module name component `` in `foo.`" ); } + + #[test] + fn prefixes() { + let prefixes = ModuleName::from_str("foo.bar.baz") + .expect("valid module name") + .prefixes() + .map(|module| module.to_string()) + .collect::>(); + + assert_eq!(prefixes, ["foo", "foo.bar", "foo.bar.baz"]); + } } diff --git a/crates/uv-resolver/src/lock/export/metadata.rs b/crates/uv-resolver/src/lock/export/metadata.rs index e8b9a8d495e..c6691dcd14f 100644 --- a/crates/uv-resolver/src/lock/export/metadata.rs +++ b/crates/uv-resolver/src/lock/export/metadata.rs @@ -832,17 +832,9 @@ impl Metadata { } pub fn package_node_id( - workspace: &Workspace, - dist: &ResolvedDist, - ) -> Result { - let workspace_root = PortablePathBuf::from(workspace.install_path().as_path()); - Self::package_node_id_with_root(&workspace_root, dist) - } - - fn package_node_id_with_root( workspace_root: &PortablePathBuf, dist: &ResolvedDist, - ) -> Result { + ) -> Result { let source = Source::from_resolved_dist(dist, workspace_root.as_ref())?; Ok(MetadataNodeId { name: dist.name().clone(), diff --git a/crates/uv/src/commands/workspace/module_owners.rs b/crates/uv/src/commands/workspace/module_owners.rs index e194ebb2a04..3b292b0f75a 100644 --- a/crates/uv/src/commands/workspace/module_owners.rs +++ b/crates/uv/src/commands/workspace/module_owners.rs @@ -7,6 +7,7 @@ use uv_configuration::{ Concurrency, DependencyGroups, DryRun, ExtrasSpecification, InstallOptions, Reinstall, }; use uv_distribution_types::Name; +use uv_fs::PortablePathBuf; use uv_installer::SitePackages; use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_preview::Preview; @@ -40,17 +41,7 @@ pub(crate) async fn collect_module_owners( let marker_env = resolution_markers(None, None, venv.interpreter()); let tags = resolution_tags(None, None, venv.interpreter())?; let extras = ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default()); - let groups = DependencyGroups::from_args( - false, - false, - false, - Vec::new(), - Vec::new(), - false, - Vec::new(), - true, - ) - .with_defaults(DefaultGroups::default()); + let groups = DependencyGroups::from_all_groups().with_defaults(DefaultGroups::default()); let resolution = target.to_resolution( &marker_env, @@ -64,12 +55,13 @@ pub(crate) async fn collect_module_owners( return Ok(BTreeMap::new()); } - let mut package_ids = BTreeMap::>::new(); + let workspace_root = PortablePathBuf::from(workspace.install_path().as_path()); + let mut package_ids = BTreeMap::::new(); for dist in resolution.distributions() { - package_ids - .entry(dist.name().clone()) - .or_default() - .insert(Metadata::package_node_id(workspace, dist)?); + package_ids.insert( + dist.name().clone(), + Metadata::package_node_id(&workspace_root, dist)?, + ); } let reinstall = Reinstall::None; @@ -116,14 +108,11 @@ pub(crate) async fn collect_module_owners( let mut owners = BTreeMap::>::new(); for dist in SitePackages::from_environment(venv)?.iter() { - let Some(package_ids) = package_ids.get(dist.name()) else { + let Some(package_id) = package_ids.get(dist.name()) else { continue; }; for module in dist.read_modules()? { - owners - .entry(module) - .or_default() - .extend(package_ids.iter().cloned()); + owners.entry(module).or_default().insert(package_id.clone()); } } From b092dc2dfc437e3a14d411d16680bfaf9a25fda1 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Thu, 30 Apr 2026 13:47:25 +0100 Subject: [PATCH 08/21] Avoid stale owners for virtual metadata packages --- .../src/commands/workspace/module_owners.rs | 16 ++++++- crates/uv/tests/it/workspace_metadata.rs | 48 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/uv/src/commands/workspace/module_owners.rs b/crates/uv/src/commands/workspace/module_owners.rs index 3b292b0f75a..7e4d5ce00e5 100644 --- a/crates/uv/src/commands/workspace/module_owners.rs +++ b/crates/uv/src/commands/workspace/module_owners.rs @@ -6,7 +6,7 @@ use uv_client::BaseClientBuilder; use uv_configuration::{ Concurrency, DependencyGroups, DryRun, ExtrasSpecification, InstallOptions, Reinstall, }; -use uv_distribution_types::Name; +use uv_distribution_types::{Dist, Name, ResolvedDist}; use uv_fs::PortablePathBuf; use uv_installer::SitePackages; use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; @@ -57,7 +57,7 @@ pub(crate) async fn collect_module_owners( let workspace_root = PortablePathBuf::from(workspace.install_path().as_path()); let mut package_ids = BTreeMap::::new(); - for dist in resolution.distributions() { + for dist in resolution.distributions().filter(|dist| !is_virtual(dist)) { package_ids.insert( dist.name().clone(), Metadata::package_node_id(&workspace_root, dist)?, @@ -111,6 +111,8 @@ pub(crate) async fn collect_module_owners( let Some(package_id) = package_ids.get(dist.name()) else { continue; }; + // TODO: Editable installs often only record a `.pth` file; we'll + // need to handle them specially. for module in dist.read_modules()? { owners.entry(module).or_default().insert(package_id.clone()); } @@ -121,3 +123,13 @@ pub(crate) async fn collect_module_owners( .map(|(module, owners)| (module, owners.into_iter().collect())) .collect()) } + +fn is_virtual(dist: &ResolvedDist) -> bool { + let ResolvedDist::Installable { dist, .. } = dist else { + return false; + }; + let Dist::Source(source) = dist.as_ref() else { + return false; + }; + source.is_virtual() +} diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index 6cc638c6c23..474ba43d900 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -360,6 +360,54 @@ dependencies = [ Ok(()) } +#[test] +fn workspace_metadata_module_owners_ignore_stale_virtual_package() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let stale_owner = context + .temp_dir + .child("module_owner_root-0.1.0-py3-none-any.whl"); + write_wheel( + stale_owner.path(), + "module-owner-root", + "module_owner_root-0.1.0", + &[("stale.py", "")], + )?; + + context.temp_dir.child("pyproject.toml").write_str( + r#"[project] +name = "module-owner-root" +version = "0.1.0" +requires-python = ">=3.12" + +[tool.uv] +package = false +"#, + )?; + + context + .pip_install() + .arg(stale_owner.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 module_owners = if let Some(module_owners) = metadata.get("module_owners") { + serde_json::to_string_pretty(module_owners)? + } else { + "".to_string() + }; + + insta::assert_snapshot!(module_owners, @""); + + Ok(()) +} + #[test] fn workspace_metadata_module_owners_failure_is_error() -> Result<()> { let context = uv_test::test_context!("3.12"); From 927c4d17b21d1a47b7b33a417e6a11ac5f244e80 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Sun, 3 May 2026 13:51:20 +0100 Subject: [PATCH 09/21] Harden bytecode and extension stemming --- .../src/installed_modules.rs | 95 ++++++++++++++++--- crates/uv/tests/it/workspace_metadata.rs | 6 -- 2 files changed, 82 insertions(+), 19 deletions(-) diff --git a/crates/uv-distribution-types/src/installed_modules.rs b/crates/uv-distribution-types/src/installed_modules.rs index be3c3e585a1..657c2b97f61 100644 --- a/crates/uv-distribution-types/src/installed_modules.rs +++ b/crates/uv-distribution-types/src/installed_modules.rs @@ -101,22 +101,65 @@ fn bytecode_module_stem<'a>( ) -> Option<(&'a str, &'a [String])> { let stem = file_name.strip_suffix(".pyc")?; if parents.last().is_some_and(|parent| parent == "__pycache__") { - Some(( - stem.split('.').next().filter(|stem| !stem.is_empty())?, - &parents[..parents.len() - 1], - )) - } else { - Some((stem, parents)) + // A `.pyc` file in `__pycache__` does not make the module importable + // without the corresponding source file. Sourceless imports use the + // legacy `module.pyc` location instead. + return None; } + + Some((stem, parents)) } fn extension_module_stem(file_name: &str) -> Option<&str> { let stem = file_name .strip_suffix(".so") .or_else(|| file_name.strip_suffix(".pyd"))?; - // Extension modules include ABI and platform tags after the importable module name, e.g. - // `foo.cpython-312-darwin.so`. The first dot separates the module name from those tags. - stem.split('.').next().filter(|stem| !stem.is_empty()) + if stem.is_empty() { + return None; + } + + if let Some(module) = stem.strip_suffix(".abi3") { + return non_empty(module); + } + + let Some((module, tag)) = stem.rsplit_once('.') else { + return Some(stem); + }; + if is_extension_module_tag(tag) { + non_empty(module) + } else { + None + } +} + +fn is_extension_module_tag(tag: &str) -> bool { + // Hardcoded forms from common `importlib.machinery.EXTENSION_SUFFIXES` values. + // These resemble wheel ABI tags, but they are import suffixes instead. For + // example, Windows debug builds use `_d.cp314t-win_amd64.pyd`, with the + // debug marker attached to the module stem rather than encoded in the tag as + // `cp314td-win_amd64`. + if tag.starts_with("cpython-") || tag.starts_with("pypy") || tag.starts_with("graalpy") { + return true; + } + + let Some(rest) = tag.strip_prefix("cp") else { + return false; + }; + let digit_count = rest + .chars() + .take_while(|char| char.is_ascii_digit()) + .count(); + if digit_count == 0 { + return false; + } + + let rest = &rest[digit_count..]; + let rest = rest.strip_prefix('t').unwrap_or(rest); + rest.is_empty() || rest.starts_with('-') || rest.starts_with('_') +} + +fn non_empty(value: &str) -> Option<&str> { + (!value.is_empty()).then_some(value) } fn has_extension(path: impl AsRef, extension: &str) -> bool { @@ -159,24 +202,50 @@ mod tests { } #[test] - fn record_module_from_bytecode() { + fn record_module_from_legacy_bytecode() { + let mut modules = BTreeSet::new(); + add_record_module("package/module.pyc", &mut modules); + add_record_module("legacy.pyc", &mut modules); + + assert_eq!(module_names(modules), "legacy\npackage\npackage.module"); + } + + #[test] + fn record_module_ignores_pycache_bytecode() { let mut modules = BTreeSet::new(); add_record_module( "package/__pycache__/module.cpython-312.opt-1.pyc", &mut modules, ); add_record_module("package/__pycache__/__init__.cpython-312.pyc", &mut modules); - add_record_module("legacy.pyc", &mut modules); - assert_eq!(module_names(modules), "legacy\npackage\npackage.module"); + assert_eq!(module_names(modules), ""); } #[test] fn record_module_from_extension_module() { let mut modules = BTreeSet::new(); add_record_module("package/extension.cpython-312-darwin.so", &mut modules); + add_record_module( + "package/free_threaded.cpython-314td-darwin.so", + &mut modules, + ); + add_record_module("package/limited.abi3.so", &mut modules); + add_record_module("package/windows.cp312-win_amd64.pyd", &mut modules); add_record_module("package/__init__.cpython-312-darwin.so", &mut modules); + add_record_module("plain.so", &mut modules); + + assert_eq!( + module_names(modules), + "package\npackage.extension\npackage.free_threaded\npackage.limited\npackage.windows\nplain" + ); + } + + #[test] + fn record_module_ignores_unknown_extension_tags() { + let mut modules = BTreeSet::new(); + add_record_module("package/extension.not-an-extension-tag.so", &mut modules); - assert_eq!(module_names(modules), "package\npackage.extension"); + assert_eq!(module_names(modules), ""); } } diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index 474ba43d900..4cf71faa9a4 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -183,12 +183,6 @@ dependencies = [ "sets": [] }, "module_owners": { - "bytecode": [ - "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" - ], - "bytecode.compiled": [ - "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" - ], "café": [ "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" ], From 4a01221b5ff938116485297efa886183735bba99 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Thu, 7 May 2026 08:56:23 -0700 Subject: [PATCH 10/21] clippy --- crates/uv-distribution-types/src/installed_modules.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/uv-distribution-types/src/installed_modules.rs b/crates/uv-distribution-types/src/installed_modules.rs index 657c2b97f61..653a6258785 100644 --- a/crates/uv-distribution-types/src/installed_modules.rs +++ b/crates/uv-distribution-types/src/installed_modules.rs @@ -145,10 +145,7 @@ fn is_extension_module_tag(tag: &str) -> bool { let Some(rest) = tag.strip_prefix("cp") else { return false; }; - let digit_count = rest - .chars() - .take_while(|char| char.is_ascii_digit()) - .count(); + let digit_count = rest.chars().take_while(char::is_ascii_digit).count(); if digit_count == 0 { return false; } From 4b57499869e38af3a4ada9f745ffdd77e957ea7b Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Tue, 26 May 2026 17:21:27 +0100 Subject: [PATCH 11/21] Use async_zip for workspace metadata wheel fixtures --- crates/uv/tests/it/workspace_metadata.rs | 37 ++++++++++++------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index 4cf71faa9a4..b4c10b94384 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -1,13 +1,12 @@ -use std::io::Write; use std::path::Path; use anyhow::Result; use assert_cmd::assert::OutputAssertExt; use assert_fs::fixture::{FileWriteStr, PathChild}; -use fs_err::File; +use async_zip::base::write::ZipFileWriter; +use async_zip::{Compression, ZipEntryBuilder}; +use futures::executor::block_on; use url::Url; -use zip::ZipWriter; -use zip::write::SimpleFileOptions; use uv_test::{copy_dir_ignore, uv_snapshot}; @@ -17,36 +16,38 @@ fn write_wheel( dist_info_prefix: &str, files: &[(&str, &str)], ) -> Result<()> { - let mut writer = ZipWriter::new(File::create(path)?); - let options = SimpleFileOptions::default(); + let mut writer = ZipFileWriter::new(Vec::new()); let mut record = Vec::new(); for (file_path, contents) in files { - writer.start_file(file_path, options)?; - writer.write_all(contents.as_bytes())?; + let entry = ZipEntryBuilder::new((*file_path).into(), Compression::Stored); + block_on(writer.write_entry_whole(entry, contents.as_bytes()))?; record.push(format!("{file_path},,")); } let metadata_path = format!("{dist_info_prefix}.dist-info/METADATA"); - writer.start_file(&metadata_path, options)?; - writer - .write_all(format!("Metadata-Version: 2.1\nName: {name}\nVersion: 0.1.0\n").as_bytes())?; + let entry = ZipEntryBuilder::new(metadata_path.clone().into(), Compression::Stored); + block_on(writer.write_entry_whole( + entry, + format!("Metadata-Version: 2.1\nName: {name}\nVersion: 0.1.0\n").as_bytes(), + ))?; record.push(format!("{metadata_path},,")); let wheel_path = format!("{dist_info_prefix}.dist-info/WHEEL"); - writer.start_file(&wheel_path, options)?; - writer.write_all( + let entry = ZipEntryBuilder::new(wheel_path.clone().into(), Compression::Stored); + block_on(writer.write_entry_whole( + entry, b"Wheel-Version: 1.0\nGenerator: uv-test\nRoot-Is-Purelib: true\nTag: py3-none-any\n", - )?; + ))?; record.push(format!("{wheel_path},,")); let record_path = format!("{dist_info_prefix}.dist-info/RECORD"); record.push(format!("{record_path},,")); - writer.start_file(&record_path, options)?; - writer.write_all(record.join("\n").as_bytes())?; - writer.write_all(b"\n")?; + let entry = ZipEntryBuilder::new(record_path.into(), Compression::Stored); + let record = format!("{}\n", record.join("\n")); + block_on(writer.write_entry_whole(entry, record.as_bytes()))?; - writer.finish()?; + fs_err::write(path, block_on(writer.close())?)?; Ok(()) } From 0c24e82c30d3d7106b3152594d1725bcd6fff5a5 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Tue, 26 May 2026 17:21:42 +0100 Subject: [PATCH 12/21] Pass malware settings to workspace metadata sync --- crates/uv/src/commands/workspace/metadata.rs | 4 +++- crates/uv/src/commands/workspace/module_owners.rs | 3 +++ crates/uv/src/lib.rs | 1 + crates/uv/src/settings.rs | 4 ++++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/uv/src/commands/workspace/metadata.rs b/crates/uv/src/commands/workspace/metadata.rs index c7e5bfbbc9b..c839ad0b594 100644 --- a/crates/uv/src/commands/workspace/metadata.rs +++ b/crates/uv/src/commands/workspace/metadata.rs @@ -10,7 +10,7 @@ use uv_configuration::{Concurrency, DependencyGroupsWithDefaults, DryRun}; use uv_preview::{Preview, PreviewFeature}; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_resolver::Metadata; -use uv_settings::PythonInstallMirrors; +use uv_settings::{MalwareCheckSettings, PythonInstallMirrors}; use uv_warnings::warn_user; use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache}; @@ -36,6 +36,7 @@ pub(crate) async fn metadata( sync: bool, python: Option, install_mirrors: PythonInstallMirrors, + malware_settings: MalwareCheckSettings, settings: ResolverSettings, client_builder: BaseClientBuilder<'_>, python_preference: PythonPreference, @@ -155,6 +156,7 @@ pub(crate) async fn metadata( cache, workspace_cache, preview, + &malware_settings, ) .await .context("Failed to collect module owners")?; diff --git a/crates/uv/src/commands/workspace/module_owners.rs b/crates/uv/src/commands/workspace/module_owners.rs index 7e4d5ce00e5..07f0da1474e 100644 --- a/crates/uv/src/commands/workspace/module_owners.rs +++ b/crates/uv/src/commands/workspace/module_owners.rs @@ -14,6 +14,7 @@ use uv_preview::Preview; use uv_pypi_types::ModuleName; use uv_python::PythonEnvironment; use uv_resolver::{Installable, Lock, Metadata}; +use uv_settings::MalwareCheckSettings; use uv_workspace::{Workspace, WorkspaceCache}; use crate::commands::pip::loggers::DefaultInstallLogger; @@ -36,6 +37,7 @@ pub(crate) async fn collect_module_owners( cache: &Cache, workspace_cache: &WorkspaceCache, preview: Preview, + malware_settings: &MalwareCheckSettings, ) -> Result>> { let target = InstallTarget::Workspace { workspace, lock }; let marker_env = resolution_markers(None, None, venv.interpreter()); @@ -103,6 +105,7 @@ pub(crate) async fn collect_module_owners( DryRun::Disabled, Printer::Silent, preview, + malware_settings, ) .await?; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 526b2c99b53..65917b0ba0f 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1957,6 +1957,7 @@ async fn run(cli: Cli) -> Result { args.sync, args.python, args.install_mirrors, + args.malware_settings, args.settings, client_builder.subcommand(vec!["workspace".to_owned(), "metadata".to_owned()]), globals.python_preference, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 6596e5bc598..09ea285c7bf 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1928,6 +1928,7 @@ pub(crate) struct MetadataSettings { pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) refresh: Refresh, pub(crate) settings: ResolverSettings, + pub(crate) malware_settings: MalwareCheckSettings, } impl MetadataSettings { @@ -1960,6 +1961,8 @@ impl MetadataSettings { // Check for conflicts between locked and frozen. check_conflicts(locked, frozen); + let malware_settings = MalwareCheckSettings::from(&environment); + Self { lock_check: resolve_lock_check(locked), frozen: resolve_frozen(frozen), @@ -1975,6 +1978,7 @@ impl MetadataSettings { install_mirrors: environment .install_mirrors .combine(filesystem_install_mirrors), + malware_settings, } } } From 5ab3a1907a0fba9934aa7ebe33db2e8e22320148 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Wed, 27 May 2026 15:27:00 +0100 Subject: [PATCH 13/21] Inline InstalledDist module reading --- .../src/installed_modules.rs | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/crates/uv-distribution-types/src/installed_modules.rs b/crates/uv-distribution-types/src/installed_modules.rs index 653a6258785..f9ad824c860 100644 --- a/crates/uv-distribution-types/src/installed_modules.rs +++ b/crates/uv-distribution-types/src/installed_modules.rs @@ -11,24 +11,21 @@ use crate::installed::{InstalledDist, InstalledDistError}; impl InstalledDist { /// Read the modules provided by this installed distribution. pub fn read_modules(&self) -> Result, InstalledDistError> { - read_modules(self.install_path()) - } -} + let dist_info = self.install_path(); + if !has_extension(dist_info, "dist-info") { + return Ok(BTreeSet::new()); + } -fn read_modules(dist_info: &Path) -> Result, InstalledDistError> { - if !has_extension(dist_info, "dist-info") { - return Ok(BTreeSet::new()); - } + let record_path = dist_info.join("RECORD"); + let record = read_record(File::open(&record_path)?)?; - let record_path = dist_info.join("RECORD"); - let record = read_record(File::open(&record_path)?)?; + let mut modules = BTreeSet::new(); + for entry in record { + add_record_module(&entry.path, &mut modules); + } - let mut modules = BTreeSet::new(); - for entry in record { - add_record_module(&entry.path, &mut modules); + Ok(modules) } - - Ok(modules) } fn add_record_module(path: &str, modules: &mut BTreeSet) { From 7bc993134ebec7713fb297e3cd86a1179b2dce1b Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Wed, 27 May 2026 15:30:32 +0100 Subject: [PATCH 14/21] Remove workspace metadata dry-run conflict test --- crates/uv/tests/it/workspace_metadata.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index b4c10b94384..64eb867127f 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -102,24 +102,6 @@ fn workspace_metadata_simple() { ); } -#[test] -fn workspace_metadata_dry_run_sync_conflict() { - let context = uv_test::test_context!("3.12"); - - uv_snapshot!(context.filters(), context.workspace_metadata().arg("--dry-run").arg("--sync"), @r#" - success: false - exit_code: 2 - ----- stdout ----- - - ----- stderr ----- - error: the argument '--dry-run' cannot be used with '--sync' - - Usage: uv workspace metadata --cache-dir [CACHE_DIR] --dry-run --exclude-newer - - For more information, try '--help'. - "#); -} - #[test] fn workspace_metadata_module_owners_from_locked_wheels() -> Result<()> { let context = uv_test::test_context!("3.12"); From 3b5912c275dd78c812ef351df85872ce3cd9fd11 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Wed, 27 May 2026 15:44:24 +0100 Subject: [PATCH 15/21] Document module ownership parsing --- .../src/installed_modules.rs | 33 +++++++++++++++---- .../src/commands/workspace/module_owners.rs | 5 +++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/uv-distribution-types/src/installed_modules.rs b/crates/uv-distribution-types/src/installed_modules.rs index f9ad824c860..08b8d5481b2 100644 --- a/crates/uv-distribution-types/src/installed_modules.rs +++ b/crates/uv-distribution-types/src/installed_modules.rs @@ -1,3 +1,12 @@ +//! Discovers importable modules provided by an installed wheel. +//! +//! Installed wheels record installed paths in `-.dist-info/RECORD`. Python source +//! files, legacy sourceless bytecode, and recognized native extension modules located under the +//! import root contribute a [`ModuleName`] and its parent package prefixes. +//! +//! This is intentionally file-based: it does not infer modules exposed through `.pth` files, +//! legacy namespace declarations in `__init__.py`, or `.pyi`-only stub distributions. + use std::collections::BTreeSet; use std::path::{Component, Path}; @@ -36,12 +45,15 @@ fn add_record_module(path: &str, modules: &mut BTreeSet) { return; }; + // Metadata and other entries under `.dist-info` directories are not modules. if components .iter() .any(|component| has_extension(component, "dist-info")) { return; } + // Files in a `.data` directory that were not relocated into the import root are not modules. + // Relocated files are recorded at their installed paths instead. if components .first() .is_some_and(|component| has_extension(component, "data")) @@ -54,8 +66,7 @@ fn add_record_module(path: &str, modules: &mut BTreeSet) { // The parent path is the package. } else if let Some(stem) = file_name.strip_suffix(".py") { module_components.push(stem); - } else if let Some((stem, bytecode_parents)) = bytecode_module_stem(file_name, parents) { - module_components = bytecode_parents.iter().map(String::as_str).collect(); + } else if let Some(stem) = bytecode_module_stem(file_name, parents) { if stem != "__init__" { module_components.push(stem); } @@ -92,10 +103,12 @@ fn record_path_components(path: &str) -> Option> { Some(components) } -fn bytecode_module_stem<'a>( - file_name: &'a str, - parents: &'a [String], -) -> Option<(&'a str, &'a [String])> { +/// Return the module stem for importable sourceless bytecode in a `RECORD` path. +/// +/// CPython can import `package/module.pyc` directly when only bytecode is installed. In +/// contrast, `package/__pycache__/module.cpython-312.pyc` is not an import source without +/// `package/module.py`. +fn bytecode_module_stem<'a>(file_name: &'a str, parents: &[String]) -> Option<&'a str> { let stem = file_name.strip_suffix(".pyc")?; if parents.last().is_some_and(|parent| parent == "__pycache__") { // A `.pyc` file in `__pycache__` does not make the module importable @@ -104,9 +117,15 @@ fn bytecode_module_stem<'a>( return None; } - Some((stem, parents)) + Some(stem) } +/// Return the module stem for a supported native extension module filename. +/// +/// Extension modules can be named `module.so` or `module.pyd`, or include an interpreter-specific +/// suffix such as `module.cpython-312-darwin.so` or `module.cp312-win_amd64.pyd`. Dotted names +/// with unrecognized suffixes are rejected instead of treating arbitrary shared libraries as +/// Python modules. fn extension_module_stem(file_name: &str) -> Option<&str> { let stem = file_name .strip_suffix(".so") diff --git a/crates/uv/src/commands/workspace/module_owners.rs b/crates/uv/src/commands/workspace/module_owners.rs index 07f0da1474e..ed36c35bdc2 100644 --- a/crates/uv/src/commands/workspace/module_owners.rs +++ b/crates/uv/src/commands/workspace/module_owners.rs @@ -26,6 +26,11 @@ use crate::commands::project::sync::do_sync; use crate::printer::Printer; use crate::settings::{InstallerSettingsRef, ResolverSettings}; +/// Sync all locked extras and groups as needed, then map importable modules to package IDs. +/// +/// 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. pub(crate) async fn collect_module_owners( workspace: &Workspace, lock: &Lock, From a54aceff8ddb4a011a6c8acef9b0ffd5c26f3c86 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Wed, 27 May 2026 15:58:26 +0100 Subject: [PATCH 16/21] Serialize module owners as package references --- crates/uv-cli/src/lib.rs | 2 +- .../uv-resolver/src/lock/export/metadata.rs | 19 ++++++++++--- crates/uv/tests/it/workspace_metadata.rs | 28 ++++++++++++++----- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index b1e02c5d40a..5df014304ab 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -8048,7 +8048,7 @@ pub struct MetadataArgs { /// Sync the environment to include module ownership metadata in the output. /// - /// This adds a mapping from importable module names to the IDs of the package nodes + /// 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. #[arg(long)] pub sync: bool, diff --git a/crates/uv-resolver/src/lock/export/metadata.rs b/crates/uv-resolver/src/lock/export/metadata.rs index c6691dcd14f..eaa69f6658b 100644 --- a/crates/uv-resolver/src/lock/export/metadata.rs +++ b/crates/uv-resolver/src/lock/export/metadata.rs @@ -68,9 +68,9 @@ pub struct Metadata { requires_python: RequiresPython, /// Info about conflicting packages conflicts: MetadataConflicts, - /// A mapping from importable module names to the IDs of the package nodes that provide them + /// A mapping from importable module names to the package nodes that provide them #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] - module_owners: BTreeMap>, + module_owners: BTreeMap>, /// An index of which nodes are workspace members /// /// These entries are often what you should use as the entry-points into the `resolve` graph. @@ -108,6 +108,13 @@ struct MetadataWorkspaceMember { id: MetadataNodeIdFlat, } +/// An installed distribution that provides an importable module. +#[derive(Debug, serde::Serialize)] +struct MetadataModuleOwner { + /// Key for the package node in the `resolution` graph. + package_id: MetadataNodeIdFlat, +} + /// A node in the dependency graph /// /// There are 4 kinds of nodes: @@ -849,8 +856,12 @@ impl Metadata { pub fn with_module_owners(mut self, module_owners: BTreeMap>) -> Self { self.module_owners = module_owners .into_iter() - .filter_map(|(module, mut owners)| { - owners.retain(|owner| self.resolution.contains_key(owner)); + .filter_map(|(module, owners)| { + let owners = owners + .into_iter() + .filter(|package_id| self.resolution.contains_key(package_id)) + .map(|package_id| MetadataModuleOwner { package_id }) + .collect::>(); (!owners.is_empty()).then_some((module, owners)) }) .collect(); diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index 64eb867127f..a4010644574 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -167,20 +167,32 @@ dependencies = [ }, "module_owners": { "café": [ - "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" + { + "package_id": "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" + } ], "gpu": [ - "gpu-a==0.1.0@path+[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl", - "gpu-b==0.1.0@path+[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl" + { + "package_id": "gpu-a==0.1.0@path+[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl" + }, + { + "package_id": "gpu-b==0.1.0@path+[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl" + } ], "gpu.a": [ - "gpu-a==0.1.0@path+[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl" + { + "package_id": "gpu-a==0.1.0@path+[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl" + } ], "gpu.b": [ - "gpu-b==0.1.0@path+[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl" + { + "package_id": "gpu-b==0.1.0@path+[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl" + } ], "typing_extensions": [ - "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" + { + "package_id": "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" + } ] }, "members": [ @@ -328,7 +340,9 @@ dependencies = [ insta::assert_snapshot!(module_owners, @r#" { "shared": [ - "module-owner==0.1.0@path+[TEMP_DIR]/py312/module_owner-0.1.0-py3-none-any.whl" + { + "package_id": "module-owner==0.1.0@path+[TEMP_DIR]/py312/module_owner-0.1.0-py3-none-any.whl" + } ] } "#); From c971ee3204ed3cbe23ed4e086173ad4560e1cc86 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Wed, 27 May 2026 18:15:39 +0100 Subject: [PATCH 17/21] Store installed module paths as Box --- .../src/installed_modules.rs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/uv-distribution-types/src/installed_modules.rs b/crates/uv-distribution-types/src/installed_modules.rs index 08b8d5481b2..bc347c82659 100644 --- a/crates/uv-distribution-types/src/installed_modules.rs +++ b/crates/uv-distribution-types/src/installed_modules.rs @@ -44,11 +44,12 @@ fn add_record_module(path: &str, modules: &mut BTreeSet) { let Some((file_name, parents)) = components.split_last() else { return; }; + let file_name = file_name.as_ref(); // Metadata and other entries under `.dist-info` directories are not modules. if components .iter() - .any(|component| has_extension(component, "dist-info")) + .any(|component| has_extension(component.as_ref(), "dist-info")) { return; } @@ -56,12 +57,15 @@ fn add_record_module(path: &str, modules: &mut BTreeSet) { // Relocated files are recorded at their installed paths instead. if components .first() - .is_some_and(|component| has_extension(component, "data")) + .is_some_and(|component| has_extension(component.as_ref(), "data")) { return; } - let mut module_components = parents.iter().map(String::as_str).collect::>(); + let mut module_components = parents + .iter() + .map(std::convert::AsRef::as_ref) + .collect::>(); if file_name == "__init__.py" { // The parent path is the package. } else if let Some(stem) = file_name.strip_suffix(".py") { @@ -81,7 +85,7 @@ fn add_record_module(path: &str, modules: &mut BTreeSet) { add_module_components(&module_components, modules); } -fn record_path_components(path: &str) -> Option> { +fn record_path_components(path: &str) -> Option>> { let normalized = normalize_path(Path::new(path)); let path = normalized.as_ref(); @@ -93,7 +97,7 @@ fn record_path_components(path: &str) -> Option> { for component in path.components() { match component { Component::Normal(component) => { - components.push(component.to_str()?.to_string()); + components.push(Box::from(component.to_str()?)); } Component::CurDir => {} Component::ParentDir | Component::Prefix(_) | Component::RootDir => return None, @@ -108,9 +112,12 @@ fn record_path_components(path: &str) -> Option> { /// CPython can import `package/module.pyc` directly when only bytecode is installed. In /// contrast, `package/__pycache__/module.cpython-312.pyc` is not an import source without /// `package/module.py`. -fn bytecode_module_stem<'a>(file_name: &'a str, parents: &[String]) -> Option<&'a str> { +fn bytecode_module_stem<'a>(file_name: &'a str, parents: &[Box]) -> Option<&'a str> { let stem = file_name.strip_suffix(".pyc")?; - if parents.last().is_some_and(|parent| parent == "__pycache__") { + if parents + .last() + .is_some_and(|parent| parent.as_ref() == "__pycache__") + { // A `.pyc` file in `__pycache__` does not make the module importable // without the corresponding source file. Sourceless imports use the // legacy `module.pyc` location instead. From a37c804a49c6de9189b8c5c4773a462ff8826da6 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Wed, 27 May 2026 18:26:04 +0100 Subject: [PATCH 18/21] Use exact matching for wheel layout extensions --- crates/uv-distribution-types/src/installed_modules.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/uv-distribution-types/src/installed_modules.rs b/crates/uv-distribution-types/src/installed_modules.rs index bc347c82659..4f7f6ac3cd3 100644 --- a/crates/uv-distribution-types/src/installed_modules.rs +++ b/crates/uv-distribution-types/src/installed_modules.rs @@ -185,8 +185,7 @@ fn non_empty(value: &str) -> Option<&str> { fn has_extension(path: impl AsRef, extension: &str) -> bool { path.as_ref() .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(extension)) + .is_some_and(|candidate| candidate == extension) } fn add_module_components(components: &[&str], modules: &mut BTreeSet) { From 65699f8333a979d1445fd3ede29ad1a5f0f70bcf Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Wed, 27 May 2026 18:32:07 +0100 Subject: [PATCH 19/21] Clarify runtime module ownership excludes pyi files --- crates/uv-distribution-types/src/installed_modules.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/uv-distribution-types/src/installed_modules.rs b/crates/uv-distribution-types/src/installed_modules.rs index 4f7f6ac3cd3..200ffedf8e9 100644 --- a/crates/uv-distribution-types/src/installed_modules.rs +++ b/crates/uv-distribution-types/src/installed_modules.rs @@ -66,6 +66,8 @@ fn add_record_module(path: &str, modules: &mut BTreeSet) { .iter() .map(std::convert::AsRef::as_ref) .collect::>(); + // We intentionally skip `.pyi` files here because we're looking for runtime module ownership. + // Type stubs will require separate ownership modeling. if file_name == "__init__.py" { // The parent path is the package. } else if let Some(stem) = file_name.strip_suffix(".py") { From 92afccd9d4eb9dada5924dcbc3eb15716911287c Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Wed, 27 May 2026 18:40:37 +0100 Subject: [PATCH 20/21] Document why non-module RECORD paths are ignored --- crates/uv-distribution-types/src/installed_modules.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/uv-distribution-types/src/installed_modules.rs b/crates/uv-distribution-types/src/installed_modules.rs index 200ffedf8e9..fd82cda993e 100644 --- a/crates/uv-distribution-types/src/installed_modules.rs +++ b/crates/uv-distribution-types/src/installed_modules.rs @@ -91,6 +91,8 @@ fn record_path_components(path: &str) -> Option>> { let normalized = normalize_path(Path::new(path)); let path = normalized.as_ref(); + // `RECORD` can include absolute paths and relative paths that leave the directory containing + // `.dist-info`, for example installed scripts. Those entries cannot describe modules here. if path.is_absolute() { return None; } From a261aea03e5df53cf9f38289af8d719f56a72815 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Thu, 28 May 2026 11:18:24 +0100 Subject: [PATCH 21/21] Use interpreter extension suffixes for module discovery --- .../src/installed_modules.rs | 142 +++++++++--------- .../uv-python/python/get_interpreter_info.py | 7 +- crates/uv-python/src/interpreter.rs | 9 ++ crates/uv-python/src/lib.rs | 2 + .../src/commands/workspace/module_owners.rs | 2 +- crates/uv/tests/it/workspace_metadata.rs | 1 + 6 files changed, 89 insertions(+), 74 deletions(-) diff --git a/crates/uv-distribution-types/src/installed_modules.rs b/crates/uv-distribution-types/src/installed_modules.rs index fd82cda993e..d53dd5cd449 100644 --- a/crates/uv-distribution-types/src/installed_modules.rs +++ b/crates/uv-distribution-types/src/installed_modules.rs @@ -19,7 +19,10 @@ use crate::installed::{InstalledDist, InstalledDistError}; impl InstalledDist { /// Read the modules provided by this installed distribution. - pub fn read_modules(&self) -> Result, InstalledDistError> { + pub fn read_modules( + &self, + extension_suffixes: &[Box], + ) -> Result, InstalledDistError> { let dist_info = self.install_path(); if !has_extension(dist_info, "dist-info") { return Ok(BTreeSet::new()); @@ -30,14 +33,18 @@ impl InstalledDist { let mut modules = BTreeSet::new(); for entry in record { - add_record_module(&entry.path, &mut modules); + add_record_module(&entry.path, extension_suffixes, &mut modules); } Ok(modules) } } -fn add_record_module(path: &str, modules: &mut BTreeSet) { +fn add_record_module( + path: &str, + extension_suffixes: &[Box], + modules: &mut BTreeSet, +) { let Some(components) = record_path_components(path) else { return; }; @@ -76,7 +83,15 @@ fn add_record_module(path: &str, modules: &mut BTreeSet) { if stem != "__init__" { module_components.push(stem); } - } else if let Some(stem) = extension_module_stem(file_name) { + } else if let Some(stem) = { + // Python reports the recognized suffixes in import lookup order through + // `importlib.machinery.EXTENSION_SUFFIXES`; preserve that order so a generic suffix such + // as `.so` does not consume a more-specific suffix such as `.abi3.so`. + extension_suffixes.iter().find_map(|suffix| { + let stem = file_name.strip_suffix(suffix.as_ref())?; + (!stem.is_empty()).then_some(stem) + }) + } { if stem != "__init__" { module_components.push(stem); } @@ -131,61 +146,6 @@ fn bytecode_module_stem<'a>(file_name: &'a str, parents: &[Box]) -> Option< Some(stem) } -/// Return the module stem for a supported native extension module filename. -/// -/// Extension modules can be named `module.so` or `module.pyd`, or include an interpreter-specific -/// suffix such as `module.cpython-312-darwin.so` or `module.cp312-win_amd64.pyd`. Dotted names -/// with unrecognized suffixes are rejected instead of treating arbitrary shared libraries as -/// Python modules. -fn extension_module_stem(file_name: &str) -> Option<&str> { - let stem = file_name - .strip_suffix(".so") - .or_else(|| file_name.strip_suffix(".pyd"))?; - if stem.is_empty() { - return None; - } - - if let Some(module) = stem.strip_suffix(".abi3") { - return non_empty(module); - } - - let Some((module, tag)) = stem.rsplit_once('.') else { - return Some(stem); - }; - if is_extension_module_tag(tag) { - non_empty(module) - } else { - None - } -} - -fn is_extension_module_tag(tag: &str) -> bool { - // Hardcoded forms from common `importlib.machinery.EXTENSION_SUFFIXES` values. - // These resemble wheel ABI tags, but they are import suffixes instead. For - // example, Windows debug builds use `_d.cp314t-win_amd64.pyd`, with the - // debug marker attached to the module stem rather than encoded in the tag as - // `cp314td-win_amd64`. - if tag.starts_with("cpython-") || tag.starts_with("pypy") || tag.starts_with("graalpy") { - return true; - } - - let Some(rest) = tag.strip_prefix("cp") else { - return false; - }; - let digit_count = rest.chars().take_while(char::is_ascii_digit).count(); - if digit_count == 0 { - return false; - } - - let rest = &rest[digit_count..]; - let rest = rest.strip_prefix('t').unwrap_or(rest); - rest.is_empty() || rest.starts_with('-') || rest.starts_with('_') -} - -fn non_empty(value: &str) -> Option<&str> { - (!value.is_empty()).then_some(value) -} - fn has_extension(path: impl AsRef, extension: &str) -> bool { path.as_ref() .extension() @@ -208,6 +168,19 @@ mod tests { use super::add_record_module; + fn extension_suffixes() -> Vec> { + [ + ".cpython-312-darwin.so", + ".cpython-314td-darwin.so", + ".abi3.so", + ".cp312-win_amd64.pyd", + ".so", + ] + .into_iter() + .map(Box::from) + .collect() + } + fn module_names(modules: BTreeSet) -> String { modules .into_iter() @@ -219,7 +192,7 @@ mod tests { #[test] fn record_module_normalizes_record_paths() { let mut modules = BTreeSet::new(); - add_record_module("./package/../café.py", &mut modules); + add_record_module("./package/../café.py", &[], &mut modules); assert_eq!(module_names(modules), "café"); } @@ -227,8 +200,8 @@ mod tests { #[test] fn record_module_from_legacy_bytecode() { let mut modules = BTreeSet::new(); - add_record_module("package/module.pyc", &mut modules); - add_record_module("legacy.pyc", &mut modules); + add_record_module("package/module.pyc", &[], &mut modules); + add_record_module("legacy.pyc", &[], &mut modules); assert_eq!(module_names(modules), "legacy\npackage\npackage.module"); } @@ -238,25 +211,44 @@ mod tests { let mut modules = BTreeSet::new(); add_record_module( "package/__pycache__/module.cpython-312.opt-1.pyc", + &[], + &mut modules, + ); + add_record_module( + "package/__pycache__/__init__.cpython-312.pyc", + &[], &mut modules, ); - add_record_module("package/__pycache__/__init__.cpython-312.pyc", &mut modules); assert_eq!(module_names(modules), ""); } #[test] fn record_module_from_extension_module() { + let extension_suffixes = extension_suffixes(); let mut modules = BTreeSet::new(); - add_record_module("package/extension.cpython-312-darwin.so", &mut modules); + add_record_module( + "package/extension.cpython-312-darwin.so", + &extension_suffixes, + &mut modules, + ); add_record_module( "package/free_threaded.cpython-314td-darwin.so", + &extension_suffixes, + &mut modules, + ); + add_record_module("package/limited.abi3.so", &extension_suffixes, &mut modules); + add_record_module( + "package/windows.cp312-win_amd64.pyd", + &extension_suffixes, &mut modules, ); - add_record_module("package/limited.abi3.so", &mut modules); - add_record_module("package/windows.cp312-win_amd64.pyd", &mut modules); - add_record_module("package/__init__.cpython-312-darwin.so", &mut modules); - add_record_module("plain.so", &mut modules); + add_record_module( + "package/__init__.cpython-312-darwin.so", + &extension_suffixes, + &mut modules, + ); + add_record_module("plain.so", &extension_suffixes, &mut modules); assert_eq!( module_names(modules), @@ -265,9 +257,19 @@ mod tests { } #[test] - fn record_module_ignores_unknown_extension_tags() { + fn record_module_ignores_unrecognized_extension_suffixes() { + let extension_suffixes = extension_suffixes(); let mut modules = BTreeSet::new(); - add_record_module("package/extension.not-an-extension-tag.so", &mut modules); + add_record_module( + "package/extension.not-an-extension-tag.so", + &extension_suffixes, + &mut modules, + ); + add_record_module( + "package/bogus.pypynonsense.so", + &extension_suffixes, + &mut modules, + ); assert_eq!(module_names(modules), ""); } diff --git a/crates/uv-python/python/get_interpreter_info.py b/crates/uv-python/python/get_interpreter_info.py index 30735ae3e5d..fdd9f8d9ec3 100644 --- a/crates/uv-python/python/get_interpreter_info.py +++ b/crates/uv-python/python/get_interpreter_info.py @@ -4,13 +4,13 @@ The script will exit with status 0 on known error that are turned into rust errors. """ -import site -import sys - +import importlib.machinery import json import os import platform +import site import struct +import sys import sysconfig @@ -682,6 +682,7 @@ def main() -> None: "sys_path": sys.path[1:], "site_packages": site.getsitepackages(), "stdlib": sysconfig.get_path("stdlib"), + "extension_suffixes": importlib.machinery.EXTENSION_SUFFIXES, # Prior to the introduction of `sysconfig` patching, python-build-standalone installations would always use # "/install" as the prefix. With `sysconfig` patching, we rewrite the prefix to match the actual installation # location. So in newer versions, we also write a dedicated flag to indicate standalone builds. diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 41e3392ad33..6f13c1e81a2 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -54,6 +54,7 @@ pub struct Interpreter { sys_executable: PathBuf, site_packages: Vec, stdlib: PathBuf, + extension_suffixes: Vec>, standalone: bool, tags: OnceLock, target: Option, @@ -90,6 +91,7 @@ impl Interpreter { sys_executable: info.sys_executable, site_packages: info.site_packages, stdlib: info.stdlib, + extension_suffixes: info.extension_suffixes, standalone: info.standalone, tags: OnceLock::new(), target: None, @@ -458,6 +460,11 @@ impl Interpreter { &self.sys_executable } + /// Return the recognized native extension module suffixes for this Python interpreter. + pub fn extension_suffixes(&self) -> &[Box] { + &self.extension_suffixes + } + /// Return the "real" queried executable path for this Python interpreter. pub fn real_executable(&self) -> &Path { &self.real_executable @@ -954,6 +961,7 @@ struct InterpreterInfo { sys_path: Vec, site_packages: Vec, stdlib: PathBuf, + extension_suffixes: Vec>, standalone: bool, pointer_size: PointerSize, gil_disabled: bool, @@ -1384,6 +1392,7 @@ mod tests { "/home/ferris/.pyenv/versions/3.12.0/lib/python3.12/site-packages" ], "stdlib": "/home/ferris/.pyenv/versions/3.12.0/lib/python3.12", + "extension_suffixes": [".cpython-312-x86_64-linux-gnu.so", ".abi3.so", ".so"], "scheme": { "data": "/home/ferris/.pyenv/versions/3.12.0", "include": "/home/ferris/.pyenv/versions/3.12.0/include", diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index e97673e435d..811e98d0e1e 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -360,6 +360,7 @@ mod tests { "/home/ferris/.pyenv/versions/{FULL_VERSION}/lib/python{VERSION}/site-packages" ], "stdlib": "/home/ferris/.pyenv/versions/{FULL_VERSION}/lib/python{VERSION}", + "extension_suffixes": [".cpython-{VERSION}-x86_64-linux-gnu.so", ".abi3.so", ".so"], "scheme": { "data": "/home/ferris/.pyenv/versions/{FULL_VERSION}", "include": "/home/ferris/.pyenv/versions/{FULL_VERSION}/include", @@ -455,6 +456,7 @@ mod tests { "/lib/python{VERSION}/site-packages" ], "stdlib": "//lib/python{VERSION}", + "extension_suffixes": [".cpython-{VERSION}-wasm32-emscripten.so", ".so"], "scheme": { "platlib": "//lib/python{VERSION}/site-packages", "purelib": "//lib/python{VERSION}/site-packages", diff --git a/crates/uv/src/commands/workspace/module_owners.rs b/crates/uv/src/commands/workspace/module_owners.rs index ed36c35bdc2..473afbef0a9 100644 --- a/crates/uv/src/commands/workspace/module_owners.rs +++ b/crates/uv/src/commands/workspace/module_owners.rs @@ -121,7 +121,7 @@ pub(crate) async fn collect_module_owners( }; // TODO: Editable installs often only record a `.pth` file; we'll // need to handle them specially. - for module in dist.read_modules()? { + for module in dist.read_modules(venv.interpreter().extension_suffixes())? { owners.entry(module).or_default().insert(package_id.clone()); } } diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index a4010644574..a1c970b104f 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -122,6 +122,7 @@ fn workspace_metadata_module_owners_from_locked_wheels() -> Result<()> { &[ ("typing_extensions.py", ""), ("café.py", ""), + ("bogus.pypynonsense.so", ""), ("bytecode/__pycache__/compiled.cpython-312.pyc", ""), ], )?;