From 0bc3cc6cfdeb8cf1077977bd68e1f09fd05bcd8e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 29 Jul 2026 14:35:48 -0400 Subject: [PATCH 01/11] Honor direct sources shared across workspace dependencies --- crates/uv-resolver/src/lock/mod.rs | 135 ++++++++++++++++++++++++----- crates/uv/tests/lock/lock.rs | 66 ++++++++++++++ 2 files changed, 180 insertions(+), 21 deletions(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 7b761442545..148a6ef9dd6 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -52,12 +52,13 @@ use uv_platform_tags::{ use uv_preview::PreviewFeature; use uv_pypi_types::{ ConflictItem, ConflictKindRef, Conflicts, HashAlgorithm, HashDigest, HashDigests, Hashes, - ParsedArchiveUrl, ParsedGitDirectoryUrl, ParsedGitPathUrl, PyProjectToml, + ParsedArchiveUrl, ParsedGitDirectoryUrl, ParsedGitPathUrl, PyProjectToml, VerbatimParsedUrl, }; use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError}; use uv_small_str::SmallString; use uv_types::{BuildContext, HashStrategy}; use uv_warnings::warn_user_once; +use uv_workspace::pyproject::{Source as WorkspaceSource, Sources as WorkspaceSources}; use uv_workspace::{Editability, WorkspaceMember}; use crate::fork_strategy::ForkStrategy; @@ -649,7 +650,7 @@ struct ExpectedPackageDependencies<'lock> { declarations: BTreeSet, provides_extra: &'lock [ExtraName], dependency_groups: BTreeMap>, - constraints: &'lock Constraints, + source_requirements: &'lock Constraints, activated_extras: BTreeSet, /// The environment under which this package can be selected. package_marker: UniversalMarker, @@ -664,7 +665,7 @@ impl<'lock> ExpectedPackageDependencies<'lock> { declarations: &BTreeSet, provides_extra: &'lock [ExtraName], dependency_groups: &BTreeMap>, - constraints: &'lock Constraints, + source_requirements: &'lock Constraints, overrides: &Overrides, excludes: &Excludes, package_requires_python: Option<&VersionSpecifiers>, @@ -724,7 +725,7 @@ impl<'lock> ExpectedPackageDependencies<'lock> { declarations, provides_extra, dependency_groups, - constraints, + source_requirements, activated_extras, package_marker, lock_marker, @@ -754,21 +755,21 @@ impl<'lock> ExpectedPackageDependencies<'lock> { .source .satisfies_requirement_source(&requirement.source, self.workspace_root)?; - // A constraint can select a direct source for an otherwise unqualified registry - // requirement. Sources apply globally, even across disjoint marker environments, - // but the locked source must still match that constraint exactly. + // A constraint or another first-party requirement can select a direct source for an + // otherwise unqualified registry requirement. Source selections apply globally, even + // across disjoint marker environments, but the locked source must match exactly. if !source_matches && matches!( requirement.source, RequirementSource::Registry { index: None, .. } ) - && let Some(constraints) = self.constraints.get(&requirement.name) + && let Some(source_requirements) = self.source_requirements.get(&requirement.name) { - for constraint in constraints { + for source_requirement in source_requirements { if package .id .source - .satisfies_requirement_source(&constraint.source, self.workspace_root)? + .satisfies_requirement_source(&source_requirement.source, self.workspace_root)? { source_matches = true; break; @@ -2061,7 +2062,7 @@ impl Lock { requires_dist: Box<[Requirement]>, provides_extra: &[ExtraName], dependency_groups: BTreeMap>, - constraints: &Constraints, + source_requirements: &Constraints, overrides: &Overrides, excludes: &Excludes, package_requires_python: Option<&VersionSpecifiers>, @@ -2178,7 +2179,7 @@ impl Lock { declarations, provides_extra, &expected_groups, - constraints, + source_requirements, overrides, excludes, package_requires_python, @@ -2454,11 +2455,6 @@ impl Lock { } } - let dependency_constraints = if allow_missing_package_metadata { - Constraints::from_requirements(normalized_constraints.into_iter()) - } else { - Constraints::default() - }; let dependency_overrides = if allow_missing_package_metadata { Overrides::from_entries(normalized_overrides.into_iter().collect()) .map_err(LockErrorKind::InvalidScopedOverride)? @@ -2470,6 +2466,103 @@ impl Lock { } else { Excludes::default() }; + let dependency_sources = if allow_missing_package_metadata { + let mut source_requirements = normalized_constraints; + for member in packages.values() { + let has_direct_requirements = member + .project() + .dependencies + .iter() + .flatten() + .any(|requirement| requirement.contains('@')); + let has_direct_sources = member + .pyproject_toml() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.sources.as_ref()) + .is_some_and(|sources| { + sources + .inner() + .values() + .flat_map(WorkspaceSources::iter) + .any(|source| { + matches!( + source, + WorkspaceSource::Git { .. } + | WorkspaceSource::Url { .. } + | WorkspaceSource::Path { .. } + ) + }) + }); + if !has_direct_requirements && !has_direct_sources { + continue; + } + + let Some(package) = self.find_by_name(&member.project().name).ok().flatten() else { + continue; + }; + let direct_requirements = if has_direct_sources { + let path = member.root().join("pyproject.toml"); + let pyproject_toml = + PyProjectToml::from_toml(&member.pyproject_toml().raw, path.user_display()) + .map_err(|err| LockErrorKind::InvalidPyprojectToml { + path: path.clone(), + err, + })?; + let Some(metadata) = database + .requires_dist(member.root(), &pyproject_toml) + .await + .map_err(|err| LockErrorKind::Resolution { + id: package.id.clone(), + err, + })? + else { + continue; + }; + metadata.requires_dist.into_vec() + } else { + member + .project() + .dependencies + .iter() + .flatten() + .filter(|requirement| requirement.contains('@')) + .filter_map(|requirement| { + uv_pep508::Requirement::::from_str(requirement).ok() + }) + .map(Requirement::from) + .collect() + }; + let package_context = package + .id + .version + .as_ref() + .map(|version| (&package.id.name, version)); + + for requirement in dependency_overrides + .apply_for_package(package_context, &direct_requirements) + .filter(|requirement| { + !dependency_excludes + .contains_for_package(package_context, &requirement.name) + }) + { + if matches!(requirement.source, RequirementSource::Registry { .. }) { + continue; + } + + source_requirements.insert(normalize_requirement( + requirement.into_owned(), + root, + &self.requires_python, + )?); + } + } + + Constraints::from_requirements(source_requirements.into_iter()) + } else { + Constraints::default() + }; // Validate that the lockfile was generated with the same build constraints. { @@ -2752,7 +2845,7 @@ impl Lock { metadata.requires_dist, &metadata.provides_extra, metadata.dependency_groups, - &dependency_constraints, + &dependency_sources, &dependency_overrides, &dependency_excludes, requires_python.as_ref(), @@ -2851,7 +2944,7 @@ impl Lock { metadata.requires_dist, &metadata.provides_extra, metadata.dependency_groups, - &dependency_constraints, + &dependency_sources, &dependency_overrides, &dependency_excludes, metadata.requires_python.as_ref(), @@ -2907,7 +3000,7 @@ impl Lock { metadata.requires_dist, &metadata.provides_extra, metadata.dependency_groups, - &dependency_constraints, + &dependency_sources, &dependency_overrides, &dependency_excludes, requires_python.as_ref(), @@ -3005,7 +3098,7 @@ impl Lock { metadata.requires_dist, &metadata.provides_extra, metadata.dependency_groups, - &dependency_constraints, + &dependency_sources, &dependency_overrides, &dependency_excludes, metadata.requires_python.as_ref(), diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index 3380d495483..1e1eb30d8cf 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -18816,6 +18816,72 @@ fn lock_metadata_free_disjoint_marker_direct_url_constraint() -> Result<()> { Ok(()) } +/// First-party direct sources can satisfy unqualified dependencies throughout the workspace. +#[cfg(feature = "test-universal")] +#[test] +fn lock_metadata_free_shared_direct_sources() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("extras/lock-without-metadata.toml"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["httpx[http2]", "six", "member"] + + [tool.uv.workspace] + members = ["member"] + + [tool.uv.sources] + member = { workspace = true } + "#})?; + context + .temp_dir + .child("member/pyproject.toml") + .write_str(&formatdoc! {r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["httpx @ {httpx_url}", "six"] + + [tool.uv.sources] + six = {{ url = "{six_url}" }} + "#, + httpx_url = server.file_url("httpx-1.0.0-py3-none-any.whl"), + six_url = server.file_url("six-1.0.0-py3-none-any.whl"), + })?; + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--locked") + .arg("--offline") + .arg("--no-cache") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + Ok(()) +} + /// Requested target extras must cover the same marker environments as their declarations. #[cfg(feature = "test-universal")] #[test] From 6e37a094a45f51c1e81b8153eb0c54f34895a872 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 29 Jul 2026 14:54:04 -0400 Subject: [PATCH 02/11] Cover direct sources from extras, groups, and local dependencies --- crates/uv-resolver/src/lock/mod.rs | 163 ++++++++++++++++++++++++----- crates/uv/tests/lock/lock.rs | 127 ++++++++++++++++++++++ 2 files changed, 263 insertions(+), 27 deletions(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 148a6ef9dd6..e8417d26cca 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -44,15 +44,17 @@ use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::{ - MarkerEnvironment, MarkerTree, Scheme, VerbatimUrl, VerbatimUrlError, split_scheme, + MarkerEnvironment, MarkerTree, Scheme, VerbatimUrl, VerbatimUrlError, VersionOrUrl, + split_scheme, }; use uv_platform_tags::{ AbiTag, IncompatibleTag, LanguageTag, PlatformTag, TagCompatibility, TagPriority, Tags, }; use uv_preview::PreviewFeature; use uv_pypi_types::{ - ConflictItem, ConflictKindRef, Conflicts, HashAlgorithm, HashDigest, HashDigests, Hashes, - ParsedArchiveUrl, ParsedGitDirectoryUrl, ParsedGitPathUrl, PyProjectToml, VerbatimParsedUrl, + ConflictItem, ConflictKindRef, Conflicts, DependencyGroupSpecifier, HashAlgorithm, HashDigest, + HashDigests, Hashes, ParsedArchiveUrl, ParsedGitDirectoryUrl, ParsedGitPathUrl, PyProjectToml, + VerbatimParsedUrl, }; use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError}; use uv_small_str::SmallString; @@ -2468,13 +2470,73 @@ impl Lock { }; let dependency_sources = if allow_missing_package_metadata { let mut source_requirements = normalized_constraints; + let mut add_source_requirements = + |package: &Package, requirements: Vec| -> Result<(), LockError> { + let package_context = package + .id + .version + .as_ref() + .map(|version| (&package.id.name, version)); + + for requirement in dependency_overrides + .apply_for_package(package_context, &requirements) + .filter(|requirement| { + !dependency_excludes + .contains_for_package(package_context, &requirement.name) + }) + { + if matches!(requirement.source, RequirementSource::Registry { .. }) { + continue; + } + + source_requirements.insert(normalize_requirement( + requirement.into_owned(), + root, + &self.requires_python, + )?); + } + + Ok(()) + }; + for member in packages.values() { - let has_direct_requirements = member + let has_direct_production_requirements = member .project() .dependencies .iter() .flatten() .any(|requirement| requirement.contains('@')); + let has_direct_optional_requirements = member + .project() + .optional_dependencies + .iter() + .flat_map(|extras| extras.values()) + .flatten() + .any(|requirement| requirement.contains('@')); + let has_direct_group_requirements = member + .pyproject_toml() + .dependency_groups + .iter() + .flatten() + .flat_map(|(_, requirements)| requirements) + .any(|requirement| { + matches!( + requirement, + DependencyGroupSpecifier::Requirement(requirement) + if requirement.contains('@') + ) + }); + let has_direct_dev_requirements = member + .pyproject_toml() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.dev_dependencies.as_ref()) + .is_some_and(|requirements| { + requirements.iter().any(|requirement| { + matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) + }) + }); let has_direct_sources = member .pyproject_toml() .tool @@ -2495,14 +2557,23 @@ impl Lock { ) }) }); - if !has_direct_requirements && !has_direct_sources { + if !has_direct_production_requirements + && !has_direct_optional_requirements + && !has_direct_group_requirements + && !has_direct_dev_requirements + && !has_direct_sources + { continue; } let Some(package) = self.find_by_name(&member.project().name).ok().flatten() else { continue; }; - let direct_requirements = if has_direct_sources { + let direct_requirements = if has_direct_sources + || has_direct_optional_requirements + || has_direct_group_requirements + || has_direct_dev_requirements + { let path = member.root().join("pyproject.toml"); let pyproject_toml = PyProjectToml::from_toml(&member.pyproject_toml().raw, path.user_display()) @@ -2520,7 +2591,17 @@ impl Lock { else { continue; }; - metadata.requires_dist.into_vec() + metadata + .requires_dist + .into_vec() + .into_iter() + .chain( + metadata + .dependency_groups + .into_values() + .flat_map(<[Requirement]>::into_vec), + ) + .collect() } else { member .project() @@ -2534,29 +2615,57 @@ impl Lock { .map(Requirement::from) .collect() }; - let package_context = package - .id - .version - .as_ref() - .map(|version| (&package.id.name, version)); + add_source_requirements(package, direct_requirements)?; + } - for requirement in dependency_overrides - .apply_for_package(package_context, &direct_requirements) - .filter(|requirement| { - !dependency_excludes - .contains_for_package(package_context, &requirement.name) - }) - { - if matches!(requirement.source, RequirementSource::Registry { .. }) { - continue; - } + for package in &self.packages { + if packages.contains_key(&package.id.name) { + continue; + } - source_requirements.insert(normalize_requirement( - requirement.into_owned(), - root, - &self.requires_python, - )?); + let Some(source_tree) = package.id.source.as_source_tree() else { + continue; + }; + let package_root = root.join(source_tree); + let path = package_root.join("pyproject.toml"); + let contents = match fs_err::tokio::read_to_string(&path).await { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => { + return Err(LockErrorKind::UnreadablePyprojectToml { path, err }.into()); + } + }; + if !contents.contains('@') && !contents.contains("sources") { + continue; } + + let pyproject_toml = PyProjectToml::from_toml(&contents, path.user_display()) + .map_err(|err| LockErrorKind::InvalidPyprojectToml { + path: path.clone(), + err, + })?; + let Some(metadata) = database + .requires_dist(&package_root, &pyproject_toml) + .await + .map_err(|err| LockErrorKind::Resolution { + id: package.id.clone(), + err, + })? + else { + continue; + }; + let direct_requirements = metadata + .requires_dist + .into_vec() + .into_iter() + .chain( + metadata + .dependency_groups + .into_values() + .flat_map(<[Requirement]>::into_vec), + ) + .collect(); + add_source_requirements(package, direct_requirements)?; } Constraints::from_requirements(source_requirements.into_iter()) diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index 1e1eb30d8cf..e256c5a391d 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -18882,6 +18882,133 @@ fn lock_metadata_free_shared_direct_sources() -> Result<()> { Ok(()) } +/// Extras and dependency groups can select direct sources for otherwise unqualified dependencies. +#[cfg(feature = "test-universal")] +#[test] +fn lock_metadata_free_shared_optional_and_group_direct_sources() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("extras/lock-without-metadata.toml"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["httpx", "six", "member[feature]"] + + [tool.uv.workspace] + members = ["member"] + + [tool.uv.sources] + member = { workspace = true } + "#})?; + context + .temp_dir + .child("member/pyproject.toml") + .write_str(&formatdoc! {r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.12" + + [project.optional-dependencies] + feature = ["httpx @ {httpx_url}"] + + [dependency-groups] + dev = ["six @ {six_url}"] + "#, + httpx_url = server.file_url("httpx-1.0.0-py3-none-any.whl"), + six_url = server.file_url("six-1.0.0-py3-none-any.whl"), + })?; + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--locked") + .arg("--offline") + .arg("--no-cache") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + +/// Direct sources selected by a non-workspace local dependency are shared across the resolution. +#[cfg(feature = "test-universal")] +#[test] +fn lock_metadata_free_shared_transitive_direct_source() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("extras/lock-without-metadata.toml"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["httpx[http2]", "local"] + + [tool.uv.sources] + local = { path = "local" } + "#})?; + context + .temp_dir + .child("local/pyproject.toml") + .write_str(&formatdoc! {r#" + [project] + name = "local" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["httpx @ {httpx_url}"] + "#, + httpx_url = server.file_url("httpx-1.0.0-py3-none-any.whl"), + })?; + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--locked") + .arg("--offline") + .arg("--no-cache") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + /// Requested target extras must cover the same marker environments as their declarations. #[cfg(feature = "test-universal")] #[test] From 056a36107de9b5f65e12312c9452391d545a0e69 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 29 Jul 2026 15:04:25 -0400 Subject: [PATCH 03/11] Honor direct sources declared by scripts and root groups --- crates/uv-resolver/src/lock/mod.rs | 22 ++++++ crates/uv/tests/lock/lock.rs | 121 +++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index e8417d26cca..8f5f2ceec1d 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -2470,6 +2470,28 @@ impl Lock { }; let dependency_sources = if allow_missing_package_metadata { let mut source_requirements = normalized_constraints; + for requirement in dependency_overrides + .apply_for_package( + None, + requirements + .iter() + .chain(dependency_groups.values().flatten()), + ) + .filter(|requirement| { + !dependency_excludes.contains_for_package(None, &requirement.name) + }) + { + if matches!(requirement.source, RequirementSource::Registry { .. }) { + continue; + } + + source_requirements.insert(normalize_requirement( + requirement.into_owned(), + root, + &self.requires_python, + )?); + } + let mut add_source_requirements = |package: &Package, requirements: Vec| -> Result<(), LockError> { let package_context = package diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index e256c5a391d..7859c35a662 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -19009,6 +19009,127 @@ fn lock_metadata_free_shared_transitive_direct_source() -> Result<()> { Ok(()) } +/// Projectless workspace root groups can select direct sources for workspace-member requirements. +#[cfg(feature = "test-universal")] +#[test] +fn lock_metadata_free_shared_root_group_direct_source() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("extras/lock-without-metadata.toml"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(&formatdoc! {r#" + [tool.uv.workspace] + members = ["member"] + + [dependency-groups] + dev = ["httpx @ {httpx_url}"] + "#, + httpx_url = server.file_url("httpx-1.0.0-py3-none-any.whl"), + })?; + context + .temp_dir + .child("member/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["httpx[http2]"] + "#})?; + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--locked") + .arg("--offline") + .arg("--no-cache") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + +/// PEP 723 script requirements can select direct sources for local-package requirements. +#[cfg(feature = "test-universal")] +#[test] +fn lock_metadata_free_shared_script_direct_source() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("extras/lock-without-metadata.toml"); + + context + .temp_dir + .child("script.py") + .write_str(&formatdoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = [ + # "httpx @ {httpx_url}", + # "local", + # ] + # + # [tool.uv.sources] + # local = {{ path = "local" }} + # /// + "#, + httpx_url = server.file_url("httpx-1.0.0-py3-none-any.whl"), + })?; + context + .temp_dir + .child("local/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "local" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["httpx[http2]"] + "#})?; + + uv_snapshot!(context.filters(), context.lock() + .arg("--script") + .arg("script.py") + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock() + .arg("--script") + .arg("script.py") + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--locked") + .arg("--offline") + .arg("--no-cache") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + /// Requested target extras must cover the same marker environments as their declarations. #[cfg(feature = "test-universal")] #[test] From 8dd249c6fbda2cb98adae334bcb57b4a0e164e57 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 29 Jul 2026 15:14:59 -0400 Subject: [PATCH 04/11] Honor direct sources from dynamic and static metadata --- crates/uv-resolver/src/lock/mod.rs | 87 +++++++++++++++++ crates/uv/tests/lock/lock.rs | 149 +++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 8f5f2ceec1d..382cbf00c00 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -2521,6 +2521,93 @@ impl Lock { Ok(()) }; + for package in &self.packages { + if let Some(metadata) = + dependency_metadata.get(&package.id.name, package.id.version.as_ref()) + { + add_source_requirements( + package, + Box::into_iter(metadata.requires_dist) + .map(Requirement::from) + .collect(), + )?; + continue; + } + + if !package + .all_dependencies() + .any(|dependency| !matches!(dependency.package_id.source, Source::Registry(..))) + { + continue; + } + + let Some(source_tree) = package.id.source.as_source_tree() else { + continue; + }; + let package_root = root.join(source_tree); + let path = package_root.join("pyproject.toml"); + let contents = match fs_err::tokio::read_to_string(&path).await { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => { + return Err(LockErrorKind::UnreadablePyprojectToml { path, err }.into()); + } + }; + if !contents.contains("dynamic") { + continue; + } + + let pyproject_toml = PyProjectToml::from_toml(&contents, path.user_display()) + .map_err(|err| LockErrorKind::InvalidPyprojectToml { + path: path.clone(), + err, + })?; + let has_dynamic_requirements = pyproject_toml + .project + .as_ref() + .and_then(|project| project.dynamic.as_ref()) + .is_some_and(|fields| { + fields.iter().any(|field| { + matches!(field.as_str(), "dependencies" | "optional-dependencies") + }) + }); + if !has_dynamic_requirements { + continue; + } + + let HashedDist { dist, .. } = + package.to_dist(root, TagPolicy::Preferred(tags), build_options, markers)?; + let id = dist.distribution_id(); + let metadata = if let Some(archive) = index + .distributions() + .get(&id) + .as_deref() + .and_then(|response| { + if let MetadataResponse::Found(archive, ..) = response { + Some(archive) + } else { + None + } + }) { + archive.metadata.clone() + } else { + let archive = database + .get_or_build_wheel_metadata(&dist, hasher.get(&dist)) + .await + .map_err(|err| LockErrorKind::Resolution { + id: package.id.clone(), + err, + })?; + let metadata = archive.metadata.clone(); + index + .distributions() + .done(id, Arc::new(MetadataResponse::Found(archive))); + metadata + }; + + add_source_requirements(package, Box::into_iter(metadata.requires_dist).collect())?; + } + for member in packages.values() { let has_direct_production_requirements = member .project() diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index 7859c35a662..ae870b20583 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -19009,6 +19009,155 @@ fn lock_metadata_free_shared_transitive_direct_source() -> Result<()> { Ok(()) } +/// Direct sources emitted by a dynamic workspace member apply to otherwise unqualified dependencies. +#[cfg(feature = "test-universal")] +#[test] +fn lock_metadata_free_shared_dynamic_direct_source() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("extras/lock-without-metadata.toml"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["httpx[http2]", "member"] + + [tool.uv.workspace] + members = ["member"] + + [tool.uv.sources] + member = { workspace = true } + "#})?; + context + .temp_dir + .child("member/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.12" + dynamic = ["dependencies"] + + [build-system] + requires = [] + backend-path = ["."] + build-backend = "backend" + "#})?; + context + .temp_dir + .child("member/backend.py") + .write_str(&formatdoc! {r#" + import pathlib + + def prepare_metadata_for_build_editable(metadata_directory, config_settings=None): + dist_info = pathlib.Path(metadata_directory, "member-0.1.0.dist-info") + dist_info.mkdir() + dist_info.joinpath("METADATA").write_text( + "Metadata-Version: 2.1\n" + "Name: member\n" + "Version: 0.1.0\n" + "Requires-Python: >=3.12\n" + "Requires-Dist: httpx @ {httpx_url}\n" + ) + return dist_info.name + "#, + httpx_url = server.file_url("httpx-1.0.0-py3-none-any.whl"), + })?; + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--locked") + .arg("--offline") + .arg("--no-cache") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + +/// Configured static dependency metadata can select a source for another first-party dependency. +#[cfg(feature = "test-universal")] +#[test] +fn lock_metadata_free_shared_static_metadata_direct_source() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("extras/lock-without-metadata.toml"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(&formatdoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["httpx[http2]", "local"] + + [tool.uv.sources] + local = {{ path = "local" }} + + [[tool.uv.dependency-metadata]] + name = "local" + version = "0.1.0" + requires-dist = ["httpx @ {httpx_url}"] + "#, + httpx_url = server.file_url("httpx-1.0.0-py3-none-any.whl"), + })?; + context + .temp_dir + .child("local/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "local" + version = "0.1.0" + requires-python = ">=3.12" + dynamic = ["dependencies"] + "#})?; + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--locked") + .arg("--offline") + .arg("--no-cache") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + /// Projectless workspace root groups can select direct sources for workspace-member requirements. #[cfg(feature = "test-universal")] #[test] From b066d6ab1b8c12ca8c1f352e1ed5db1f55b87287 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 29 Jul 2026 15:34:20 -0400 Subject: [PATCH 05/11] Honor direct sources across disjoint markers --- crates/uv/tests/lock/lock.rs | 73 ++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index ae870b20583..519073312bd 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -18882,6 +18882,79 @@ fn lock_metadata_free_shared_direct_sources() -> Result<()> { Ok(()) } +/// First-party direct sources apply globally, even across disjoint platform markers. +#[cfg(feature = "test-universal")] +#[test] +fn lock_metadata_free_shared_disjoint_marker_direct_sources() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("extras/lock-without-metadata.toml"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "httpx[http2] ; sys_platform == 'win32'", + "six ; sys_platform == 'win32'", + "member", + ] + + [tool.uv.workspace] + members = ["member"] + + [tool.uv.sources] + member = { workspace = true } + "#})?; + context + .temp_dir + .child("member/pyproject.toml") + .write_str(&formatdoc! {r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "httpx @ {httpx_url} ; sys_platform == 'darwin'", + "six ; sys_platform == 'darwin'", + ] + + [tool.uv.sources] + six = {{ url = "{six_url}" }} + "#, + httpx_url = server.file_url("httpx-1.0.0-py3-none-any.whl"), + six_url = server.file_url("six-1.0.0-py3-none-any.whl"), + })?; + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--locked") + .arg("--offline") + .arg("--no-cache") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + Ok(()) +} + /// Extras and dependency groups can select direct sources for otherwise unqualified dependencies. #[cfg(feature = "test-universal")] #[test] From f510ccd619241fbd0e0c61d933ff9623a00a8fbd Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 29 Jul 2026 15:42:27 -0400 Subject: [PATCH 06/11] Honor direct sources from dynamic dependency groups --- .../src/distribution_database.rs | 17 +++- crates/uv-resolver/src/lock/mod.rs | 19 +++- crates/uv/tests/lock/lock.rs | 91 +++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/crates/uv-distribution/src/distribution_database.rs b/crates/uv-distribution/src/distribution_database.rs index 937061acb4d..5724cb56b99 100644 --- a/crates/uv-distribution/src/distribution_database.rs +++ b/crates/uv-distribution/src/distribution_database.rs @@ -37,7 +37,7 @@ use crate::error::PythonVersion; use crate::hash::http_hash_algorithms; use crate::metadata::{ArchiveMetadata, Metadata}; use crate::source::SourceDistributionBuilder; -use crate::{Error, LocalWheel, Reporter, RequiresDist}; +use crate::{Error, LocalWheel, Reporter, RequiresDist, SourcedDependencyGroups}; /// A cached high-level interface to convert distributions (a requirement resolved to a location) /// to a wheel or wheel metadata. @@ -663,6 +663,21 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { .await } + /// Return the lowered dependency groups from a source tree's `pyproject.toml`. + pub async fn dependency_groups(&self, path: &Path) -> Result { + SourcedDependencyGroups::from_virtual_project( + &path.join("pyproject.toml"), + None, + self.build_context.locations(), + self.build_context.sources().clone(), + self.build_context.cache(), + self.build_context.workspace_cache(), + self.client.unmanaged.credentials_cache(), + ) + .await + .map_err(Error::from) + } + /// Stream a wheel from a URL, unzipping it into the cache as it's downloaded. async fn stream_wheel( &self, diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 382cbf00c00..bc93a66c702 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -2605,7 +2605,24 @@ impl Lock { metadata }; - add_source_requirements(package, Box::into_iter(metadata.requires_dist).collect())?; + let mut direct_requirements = metadata.requires_dist.into_vec(); + if contents.contains("dependency-groups") || contents.contains("dev-dependencies") { + let dependency_groups = database + .dependency_groups(&package_root) + .await + .map_err(|err| LockErrorKind::Resolution { + id: package.id.clone(), + err, + })?; + direct_requirements.extend( + dependency_groups + .dependency_groups + .into_values() + .flat_map(<[Requirement]>::into_vec), + ); + } + + add_source_requirements(package, direct_requirements)?; } for member in packages.values() { diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index 519073312bd..d13b5387502 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -19167,6 +19167,97 @@ fn lock_metadata_free_shared_dynamic_direct_source() -> Result<()> { Ok(()) } +/// Dependency groups on dynamic workspace members can select shared direct sources. +#[cfg(feature = "test-universal")] +#[test] +fn lock_metadata_free_shared_dynamic_group_direct_sources() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("extras/lock-without-metadata.toml"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["httpx", "six", "member"] + + [tool.uv.workspace] + members = ["member"] + + [tool.uv.sources] + member = { workspace = true } + "#})?; + context + .temp_dir + .child("member/pyproject.toml") + .write_str(&formatdoc! {r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.12" + dynamic = ["dependencies"] + + [dependency-groups] + dev = ["httpx @ {httpx_url}", "six"] + + [tool.uv.sources] + six = {{ url = "{six_url}" }} + + [build-system] + requires = [] + backend-path = ["."] + build-backend = "backend" + "#, + httpx_url = server.file_url("httpx-1.0.0-py3-none-any.whl"), + six_url = server.file_url("six-1.0.0-py3-none-any.whl"), + })?; + context + .temp_dir + .child("member/backend.py") + .write_str(indoc! {r#" + import pathlib + + def prepare_metadata_for_build_editable(metadata_directory, config_settings=None): + dist_info = pathlib.Path(metadata_directory, "member-0.1.0.dist-info") + dist_info.mkdir() + dist_info.joinpath("METADATA").write_text( + "Metadata-Version: 2.1\n" + "Name: member\n" + "Version: 0.1.0\n" + "Requires-Python: >=3.12\n" + ) + return dist_info.name + "#})?; + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--locked") + .arg("--offline") + .arg("--no-cache") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + /// Configured static dependency metadata can select a source for another first-party dependency. #[cfg(feature = "test-universal")] #[test] From dd77b82c4d7bb2795e7234a61cb54b736efc132a Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 29 Jul 2026 16:34:18 -0400 Subject: [PATCH 07/11] Honor direct sources from backend-only source trees --- crates/uv-resolver/src/lock/mod.rs | 17 +++---- crates/uv/tests/lock/lock.rs | 76 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index bc93a66c702..b438341e57b 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -2553,22 +2553,17 @@ impl Lock { return Err(LockErrorKind::UnreadablePyprojectToml { path, err }.into()); } }; - if !contents.contains("dynamic") { - continue; - } - let pyproject_toml = PyProjectToml::from_toml(&contents, path.user_display()) .map_err(|err| LockErrorKind::InvalidPyprojectToml { path: path.clone(), err, })?; - let has_dynamic_requirements = pyproject_toml - .project - .as_ref() - .and_then(|project| project.dynamic.as_ref()) - .is_some_and(|fields| { - fields.iter().any(|field| { - matches!(field.as_str(), "dependencies" | "optional-dependencies") + let has_dynamic_requirements = + pyproject_toml.project.as_ref().is_none_or(|project| { + project.dynamic.as_ref().is_some_and(|fields| { + fields.iter().any(|field| { + matches!(field.as_str(), "dependencies" | "optional-dependencies") + }) }) }); if !has_dynamic_requirements { diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index d13b5387502..9361b915297 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -19167,6 +19167,82 @@ fn lock_metadata_free_shared_dynamic_direct_source() -> Result<()> { Ok(()) } +/// Backend-only source trees can select direct sources without declaring PEP 621 dynamic fields. +#[cfg(feature = "test-universal")] +#[test] +fn lock_metadata_free_shared_backend_direct_source() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let server = PackseServer::new("extras/lock-without-metadata.toml"); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["httpx[http2]", "provider"] + + [tool.uv.sources] + provider = { path = "provider" } + "#})?; + context + .temp_dir + .child("provider/pyproject.toml") + .write_str(indoc! {r#" + [build-system] + requires = [] + backend-path = ["."] + build-backend = "backend" + "#})?; + context + .temp_dir + .child("provider/backend.py") + .write_str(&formatdoc! {r#" + import pathlib + + def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None): + dist_info = pathlib.Path(metadata_directory, "provider-1.0.0.dist-info") + dist_info.mkdir() + dist_info.joinpath("METADATA").write_text( + "Metadata-Version: 2.1\n" + "Name: provider\n" + "Version: 1.0.0\n" + "Requires-Python: >=3.12\n" + "Requires-Dist: httpx @ {httpx_url}\n" + ) + return dist_info.name + "#, + httpx_url = server.file_url("httpx-1.0.0-py3-none-any.whl"), + })?; + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--locked") + .arg("--offline") + .arg("--no-cache") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + /// Dependency groups on dynamic workspace members can select shared direct sources. #[cfg(feature = "test-universal")] #[test] From 565981b0a9b29513ccbc02054bec88bb2439105e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 29 Jul 2026 17:00:19 -0400 Subject: [PATCH 08/11] Honor direct sources from legacy source trees --- crates/uv-resolver/src/lock/mod.rs | 41 ++++++++++------- crates/uv/tests/lock/lock.rs | 72 ++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 17 deletions(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index b438341e57b..65abbd8dd25 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -2547,27 +2547,32 @@ impl Lock { let package_root = root.join(source_tree); let path = package_root.join("pyproject.toml"); let contents = match fs_err::tokio::read_to_string(&path).await { - Ok(contents) => contents, - Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Ok(contents) => Some(contents), + Err(err) if err.kind() == io::ErrorKind::NotFound => None, Err(err) => { return Err(LockErrorKind::UnreadablePyprojectToml { path, err }.into()); } }; - let pyproject_toml = PyProjectToml::from_toml(&contents, path.user_display()) - .map_err(|err| LockErrorKind::InvalidPyprojectToml { - path: path.clone(), - err, - })?; - let has_dynamic_requirements = - pyproject_toml.project.as_ref().is_none_or(|project| { - project.dynamic.as_ref().is_some_and(|fields| { - fields.iter().any(|field| { - matches!(field.as_str(), "dependencies" | "optional-dependencies") + if let Some(contents) = contents.as_ref() { + let pyproject_toml = PyProjectToml::from_toml(contents, path.user_display()) + .map_err(|err| LockErrorKind::InvalidPyprojectToml { + path: path.clone(), + err, + })?; + let has_dynamic_requirements = + pyproject_toml.project.as_ref().is_none_or(|project| { + project.dynamic.as_ref().is_some_and(|fields| { + fields.iter().any(|field| { + matches!( + field.as_str(), + "dependencies" | "optional-dependencies" + ) + }) }) - }) - }); - if !has_dynamic_requirements { - continue; + }); + if !has_dynamic_requirements { + continue; + } } let HashedDist { dist, .. } = @@ -2601,7 +2606,9 @@ impl Lock { }; let mut direct_requirements = metadata.requires_dist.into_vec(); - if contents.contains("dependency-groups") || contents.contains("dev-dependencies") { + if contents.as_ref().is_some_and(|contents| { + contents.contains("dependency-groups") || contents.contains("dev-dependencies") + }) { let dependency_groups = database .dependency_groups(&package_root) .await diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index 9361b915297..084b1aa0424 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -19243,6 +19243,78 @@ fn lock_metadata_free_shared_backend_direct_source() -> Result<()> { Ok(()) } +/// Legacy `setup.py` source trees can select direct sources without a `pyproject.toml`. +#[cfg(feature = "test-universal")] +#[test] +fn lock_metadata_free_shared_legacy_direct_source() -> Result<()> { + let context = uv_test::test_context!("3.10"); + let server = PackseServer::empty(); + + context + .python_command() + .arg("-m") + .arg("ensurepip") + .assert() + .success(); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.10" + dependencies = ["setuptools", "provider"] + + [tool.uv] + no-build-isolation-package = ["provider"] + + [tool.uv.sources] + provider = { path = "provider" } + "#})?; + context + .temp_dir + .child("provider/setup.py") + .write_str(&formatdoc! {r#" + from setuptools import setup + + setup( + name="provider", + version="1.0.0", + python_requires=">=3.10", + install_requires=["setuptools @ {setuptools_url}"], + ) + "#, + setuptools_url = server.file_url("setuptools-69.0.2-py3-none-any.whl"), + })?; + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock() + .arg("--preview-features") + .arg("lock-without-metadata") + .arg("--locked") + .arg("--offline") + .arg("--no-cache") + .arg("--index-url") + .arg(server.index_url()), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + /// Dependency groups on dynamic workspace members can select shared direct sources. #[cfg(feature = "test-universal")] #[test] From f27a35a52ae6e81e926a4e9e480411a1eaffe598 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 29 Jul 2026 17:47:25 -0400 Subject: [PATCH 09/11] Reuse source-tree metadata when reconstructing direct sources --- crates/uv-resolver/src/lock/mod.rs | 39 ++++++------------------------ 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 65abbd8dd25..f1da41ad80c 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -2544,36 +2544,13 @@ impl Lock { let Some(source_tree) = package.id.source.as_source_tree() else { continue; }; - let package_root = root.join(source_tree); - let path = package_root.join("pyproject.toml"); - let contents = match fs_err::tokio::read_to_string(&path).await { - Ok(contents) => Some(contents), - Err(err) if err.kind() == io::ErrorKind::NotFound => None, - Err(err) => { - return Err(LockErrorKind::UnreadablePyprojectToml { path, err }.into()); - } - }; - if let Some(contents) = contents.as_ref() { - let pyproject_toml = PyProjectToml::from_toml(contents, path.user_display()) - .map_err(|err| LockErrorKind::InvalidPyprojectToml { - path: path.clone(), - err, - })?; - let has_dynamic_requirements = - pyproject_toml.project.as_ref().is_none_or(|project| { - project.dynamic.as_ref().is_some_and(|fields| { - fields.iter().any(|field| { - matches!( - field.as_str(), - "dependencies" | "optional-dependencies" - ) - }) - }) - }); - if !has_dynamic_requirements { - continue; - } + if Self::source_tree_requires_dist(source_tree, root, package, database) + .await? + .is_some() + { + continue; } + let package_root = root.join(source_tree); let HashedDist { dist, .. } = package.to_dist(root, TagPolicy::Preferred(tags), build_options, markers)?; @@ -2606,9 +2583,7 @@ impl Lock { }; let mut direct_requirements = metadata.requires_dist.into_vec(); - if contents.as_ref().is_some_and(|contents| { - contents.contains("dependency-groups") || contents.contains("dev-dependencies") - }) { + if !package.resolved_dependency_groups().is_empty() { let dependency_groups = database .dependency_groups(&package_root) .await From 00bf6c986a23d1b8e2c74521fdf8a2b0a61b9e78 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 29 Jul 2026 18:06:01 -0400 Subject: [PATCH 10/11] Consolidate lock dependency metadata extraction --- .../src/distribution_database.rs | 17 +- crates/uv-resolver/src/lock/mod.rs | 374 ++++-------------- 2 files changed, 84 insertions(+), 307 deletions(-) diff --git a/crates/uv-distribution/src/distribution_database.rs b/crates/uv-distribution/src/distribution_database.rs index 5724cb56b99..937061acb4d 100644 --- a/crates/uv-distribution/src/distribution_database.rs +++ b/crates/uv-distribution/src/distribution_database.rs @@ -37,7 +37,7 @@ use crate::error::PythonVersion; use crate::hash::http_hash_algorithms; use crate::metadata::{ArchiveMetadata, Metadata}; use crate::source::SourceDistributionBuilder; -use crate::{Error, LocalWheel, Reporter, RequiresDist, SourcedDependencyGroups}; +use crate::{Error, LocalWheel, Reporter, RequiresDist}; /// A cached high-level interface to convert distributions (a requirement resolved to a location) /// to a wheel or wheel metadata. @@ -663,21 +663,6 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { .await } - /// Return the lowered dependency groups from a source tree's `pyproject.toml`. - pub async fn dependency_groups(&self, path: &Path) -> Result { - SourcedDependencyGroups::from_virtual_project( - &path.join("pyproject.toml"), - None, - self.build_context.locations(), - self.build_context.sources().clone(), - self.build_context.cache(), - self.build_context.workspace_cache(), - self.client.unmanaged.credentials_cache(), - ) - .await - .map_err(Error::from) - } - /// Stream a wheel from a URL, unzipping it into the cache as it's downloaded. async fn stream_wheel( &self, diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index f1da41ad80c..a569b7f6fea 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -24,7 +24,9 @@ use uv_configuration::{ ExtrasSpecificationWithDefaults, InstallTarget, Override, Overrides, PackageOverride, ScopedOverrideSourceError, }; -use uv_distribution::{DistributionDatabase, FlatRequiresDist, RequiresDist}; +use uv_distribution::{ + DistributionDatabase, FlatRequiresDist, Metadata as DistributionMetadata, RequiresDist, +}; use uv_distribution_filename::{ BuildTag, DistExtension, ExtensionError, SourceDistExtension, WheelFilename, }; @@ -44,23 +46,20 @@ use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::{ - MarkerEnvironment, MarkerTree, Scheme, VerbatimUrl, VerbatimUrlError, VersionOrUrl, - split_scheme, + MarkerEnvironment, MarkerTree, Scheme, VerbatimUrl, VerbatimUrlError, split_scheme, }; use uv_platform_tags::{ AbiTag, IncompatibleTag, LanguageTag, PlatformTag, TagCompatibility, TagPriority, Tags, }; use uv_preview::PreviewFeature; use uv_pypi_types::{ - ConflictItem, ConflictKindRef, Conflicts, DependencyGroupSpecifier, HashAlgorithm, HashDigest, - HashDigests, Hashes, ParsedArchiveUrl, ParsedGitDirectoryUrl, ParsedGitPathUrl, PyProjectToml, - VerbatimParsedUrl, + ConflictItem, ConflictKindRef, Conflicts, HashAlgorithm, HashDigest, HashDigests, Hashes, + ParsedArchiveUrl, ParsedGitDirectoryUrl, ParsedGitPathUrl, PyProjectToml, }; use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError}; use uv_small_str::SmallString; use uv_types::{BuildContext, HashStrategy}; use uv_warnings::warn_user_once; -use uv_workspace::pyproject::{Source as WorkspaceSource, Sources as WorkspaceSources}; use uv_workspace::{Editability, WorkspaceMember}; use crate::fork_strategy::ForkStrategy; @@ -2544,157 +2543,10 @@ impl Lock { let Some(source_tree) = package.id.source.as_source_tree() else { continue; }; - if Self::source_tree_requires_dist(source_tree, root, package, database) - .await? - .is_some() + if let Some(SourceTreeRequiresDist { metadata, .. }) = + Self::source_tree_requires_dist(source_tree, root, package, database).await? { - continue; - } - let package_root = root.join(source_tree); - - let HashedDist { dist, .. } = - package.to_dist(root, TagPolicy::Preferred(tags), build_options, markers)?; - let id = dist.distribution_id(); - let metadata = if let Some(archive) = index - .distributions() - .get(&id) - .as_deref() - .and_then(|response| { - if let MetadataResponse::Found(archive, ..) = response { - Some(archive) - } else { - None - } - }) { - archive.metadata.clone() - } else { - let archive = database - .get_or_build_wheel_metadata(&dist, hasher.get(&dist)) - .await - .map_err(|err| LockErrorKind::Resolution { - id: package.id.clone(), - err, - })?; - let metadata = archive.metadata.clone(); - index - .distributions() - .done(id, Arc::new(MetadataResponse::Found(archive))); - metadata - }; - - let mut direct_requirements = metadata.requires_dist.into_vec(); - if !package.resolved_dependency_groups().is_empty() { - let dependency_groups = database - .dependency_groups(&package_root) - .await - .map_err(|err| LockErrorKind::Resolution { - id: package.id.clone(), - err, - })?; - direct_requirements.extend( - dependency_groups - .dependency_groups - .into_values() - .flat_map(<[Requirement]>::into_vec), - ); - } - - add_source_requirements(package, direct_requirements)?; - } - - for member in packages.values() { - let has_direct_production_requirements = member - .project() - .dependencies - .iter() - .flatten() - .any(|requirement| requirement.contains('@')); - let has_direct_optional_requirements = member - .project() - .optional_dependencies - .iter() - .flat_map(|extras| extras.values()) - .flatten() - .any(|requirement| requirement.contains('@')); - let has_direct_group_requirements = member - .pyproject_toml() - .dependency_groups - .iter() - .flatten() - .flat_map(|(_, requirements)| requirements) - .any(|requirement| { - matches!( - requirement, - DependencyGroupSpecifier::Requirement(requirement) - if requirement.contains('@') - ) - }); - let has_direct_dev_requirements = member - .pyproject_toml() - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.dev_dependencies.as_ref()) - .is_some_and(|requirements| { - requirements.iter().any(|requirement| { - matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) - }) - }); - let has_direct_sources = member - .pyproject_toml() - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.sources.as_ref()) - .is_some_and(|sources| { - sources - .inner() - .values() - .flat_map(WorkspaceSources::iter) - .any(|source| { - matches!( - source, - WorkspaceSource::Git { .. } - | WorkspaceSource::Url { .. } - | WorkspaceSource::Path { .. } - ) - }) - }); - if !has_direct_production_requirements - && !has_direct_optional_requirements - && !has_direct_group_requirements - && !has_direct_dev_requirements - && !has_direct_sources - { - continue; - } - - let Some(package) = self.find_by_name(&member.project().name).ok().flatten() else { - continue; - }; - let direct_requirements = if has_direct_sources - || has_direct_optional_requirements - || has_direct_group_requirements - || has_direct_dev_requirements - { - let path = member.root().join("pyproject.toml"); - let pyproject_toml = - PyProjectToml::from_toml(&member.pyproject_toml().raw, path.user_display()) - .map_err(|err| LockErrorKind::InvalidPyprojectToml { - path: path.clone(), - err, - })?; - let Some(metadata) = database - .requires_dist(member.root(), &pyproject_toml) - .await - .map_err(|err| LockErrorKind::Resolution { - id: package.id.clone(), - err, - })? - else { - continue; - }; - metadata + let direct_requirements = metadata .requires_dist .into_vec() .into_iter() @@ -2704,59 +2556,22 @@ impl Lock { .into_values() .flat_map(<[Requirement]>::into_vec), ) - .collect() - } else { - member - .project() - .dependencies - .iter() - .flatten() - .filter(|requirement| requirement.contains('@')) - .filter_map(|requirement| { - uv_pep508::Requirement::::from_str(requirement).ok() - }) - .map(Requirement::from) - .collect() - }; - add_source_requirements(package, direct_requirements)?; - } - - for package in &self.packages { - if packages.contains_key(&package.id.name) { + .collect(); + add_source_requirements(package, direct_requirements)?; continue; } - let Some(source_tree) = package.id.source.as_source_tree() else { - continue; - }; - let package_root = root.join(source_tree); - let path = package_root.join("pyproject.toml"); - let contents = match fs_err::tokio::read_to_string(&path).await { - Ok(contents) => contents, - Err(err) if err.kind() == io::ErrorKind::NotFound => continue, - Err(err) => { - return Err(LockErrorKind::UnreadablePyprojectToml { path, err }.into()); - } - }; - if !contents.contains('@') && !contents.contains("sources") { - continue; - } - - let pyproject_toml = PyProjectToml::from_toml(&contents, path.user_display()) - .map_err(|err| LockErrorKind::InvalidPyprojectToml { - path: path.clone(), - err, - })?; - let Some(metadata) = database - .requires_dist(&package_root, &pyproject_toml) - .await - .map_err(|err| LockErrorKind::Resolution { - id: package.id.clone(), - err, - })? - else { - continue; - }; + let metadata = Self::package_metadata( + package, + root, + tags, + markers, + build_options, + hasher, + index, + database, + ) + .await?; let direct_requirements = metadata .requires_dist .into_vec() @@ -3081,50 +2896,17 @@ impl Lock { if !statically_satisfied { // For a non-dynamic package without usable static metadata, fetch the metadata // from the distribution database. - let HashedDist { dist, .. } = package.to_dist( + let metadata = Self::package_metadata( + package, root, - TagPolicy::Preferred(tags), - build_options, + tags, markers, - )?; - - let metadata = { - let id = dist.distribution_id(); - if let Some(archive) = - index - .distributions() - .get(&id) - .as_deref() - .and_then(|response| { - if let MetadataResponse::Found(archive, ..) = response { - Some(archive) - } else { - None - } - }) - { - // If the metadata is already in the index, return it. - archive.metadata.clone() - } else { - // Run the PEP 517 build process to extract metadata from the source distribution. - let archive = database - .get_or_build_wheel_metadata(&dist, hasher.get(&dist)) - .await - .map_err(|err| LockErrorKind::Resolution { - id: package.id.clone(), - err, - })?; - - let metadata = archive.metadata.clone(); - - // Insert the metadata into the index. - index - .distributions() - .done(id, Arc::new(MetadataResponse::Found(archive))); - - metadata - } - }; + build_options, + hasher, + index, + database, + ) + .await?; // If this is a local package, validate that it hasn't become dynamic (in which // case, we'd expect the version to be omitted). @@ -3245,50 +3027,17 @@ impl Lock { // exactly. For example, `hatchling` will flatten any recursive (or self-referential) // extras, while `setuptools` will not. if !satisfied { - let HashedDist { dist, .. } = package.to_dist( + let metadata = Self::package_metadata( + package, root, - TagPolicy::Preferred(tags), - build_options, + tags, markers, - )?; - - let metadata = { - let id = dist.distribution_id(); - if let Some(archive) = - index - .distributions() - .get(&id) - .as_deref() - .and_then(|response| { - if let MetadataResponse::Found(archive, ..) = response { - Some(archive) - } else { - None - } - }) - { - // If the metadata is already in the index, return it. - archive.metadata.clone() - } else { - // Run the PEP 517 build process to extract metadata from the source distribution. - let archive = database - .get_or_build_wheel_metadata(&dist, hasher.get(&dist)) - .await - .map_err(|err| LockErrorKind::Resolution { - id: package.id.clone(), - err, - })?; - - let metadata = archive.metadata.clone(); - - // Insert the metadata into the index. - index - .distributions() - .done(id, Arc::new(MetadataResponse::Found(archive))); - - metadata - } - }; + build_options, + hasher, + index, + database, + ) + .await?; // Validate that the package is still dynamic. if !metadata.dynamic { @@ -3353,6 +3102,49 @@ impl Lock { Ok(SatisfiesResult::Satisfied) } + /// Read the current metadata for a locked package, reusing the resolver's in-memory cache. + async fn package_metadata( + package: &Package, + root: &Path, + tags: &Tags, + markers: &MarkerEnvironment, + build_options: &BuildOptions, + hasher: &HashStrategy, + index: &InMemoryIndex, + database: &DistributionDatabase<'_, Context>, + ) -> Result { + let HashedDist { dist, .. } = + package.to_dist(root, TagPolicy::Preferred(tags), build_options, markers)?; + let id = dist.distribution_id(); + if let Some(archive) = index + .distributions() + .get(&id) + .as_deref() + .and_then(|response| { + if let MetadataResponse::Found(archive, ..) = response { + Some(archive) + } else { + None + } + }) + { + return Ok(archive.metadata.clone()); + } + + let archive = database + .get_or_build_wheel_metadata(&dist, hasher.get(&dist)) + .await + .map_err(|err| LockErrorKind::Resolution { + id: package.id.clone(), + err, + })?; + let metadata = archive.metadata.clone(); + index + .distributions() + .done(id, Arc::new(MetadataResponse::Found(archive))); + Ok(metadata) + } + async fn source_tree_requires_dist( source_tree: &Path, root: &Path, From 17b4cba7275572eec6b927cf29e9d11dc8d0030b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 30 Jul 2026 10:52:18 -0400 Subject: [PATCH 11/11] Install wheel for legacy direct-source fixture --- crates/uv/tests/lock/lock.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index 084b1aa0424..a67ccce6286 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -19256,6 +19256,11 @@ fn lock_metadata_free_shared_legacy_direct_source() -> Result<()> { .arg("ensurepip") .assert() .success(); + context + .pip_install() + .arg(server.file_url("wheel-0.42.0-py3-none-any.whl")) + .assert() + .success(); context .temp_dir