mirror of
https://github.com/astral-sh/uv
synced 2026-06-21 13:47:25 +00:00
Add locked subgraph materialization primitives
This commit is contained in:
@@ -54,6 +54,122 @@ pub trait Installable<'lock> {
|
||||
groups: &DependencyGroupsWithDefaults,
|
||||
build_options: &BuildOptions,
|
||||
install_options: &InstallOptions,
|
||||
) -> Result<Resolution, LockError> {
|
||||
let roots = self
|
||||
.roots()
|
||||
.map(|root_name| {
|
||||
self.lock()
|
||||
.find_by_name(root_name)
|
||||
.map_err(|_| LockErrorKind::MultipleRootPackages {
|
||||
name: root_name.clone(),
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
LockError::from(LockErrorKind::MissingRootPackage {
|
||||
name: root_name.clone(),
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, LockError>>()?;
|
||||
|
||||
InstallableExt::to_resolution_from_packages(
|
||||
self,
|
||||
&roots,
|
||||
true,
|
||||
marker_env,
|
||||
tags,
|
||||
extras,
|
||||
groups,
|
||||
build_options,
|
||||
install_options,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create an installable [`Node`] from a [`Package`].
|
||||
fn installable_node(
|
||||
&self,
|
||||
package: &Package,
|
||||
tags: &Tags,
|
||||
marker_env: &ResolverMarkerEnvironment,
|
||||
build_options: &BuildOptions,
|
||||
) -> Result<Node, LockError> {
|
||||
let tag_policy = TagPolicy::Required(tags);
|
||||
let HashedDist { dist, hashes } =
|
||||
package.to_dist(self.install_path(), tag_policy, build_options, marker_env)?;
|
||||
let version = package.version().cloned();
|
||||
let dist = ResolvedDist::Installable {
|
||||
dist: Arc::new(dist),
|
||||
version,
|
||||
};
|
||||
Ok(Node::Dist {
|
||||
dist,
|
||||
hashes,
|
||||
install: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a non-installable [`Node`] from a [`Package`].
|
||||
fn non_installable_node(
|
||||
&self,
|
||||
package: &Package,
|
||||
tags: &Tags,
|
||||
marker_env: &ResolverMarkerEnvironment,
|
||||
) -> Result<Node, LockError> {
|
||||
let HashedDist { dist, .. } = package.to_dist(
|
||||
self.install_path(),
|
||||
TagPolicy::Preferred(tags),
|
||||
&BuildOptions::default(),
|
||||
marker_env,
|
||||
)?;
|
||||
let version = package.version().cloned();
|
||||
let dist = ResolvedDist::Installable {
|
||||
dist: Arc::new(dist),
|
||||
version,
|
||||
};
|
||||
let hashes = package.hashes();
|
||||
Ok(Node::Dist {
|
||||
dist,
|
||||
hashes,
|
||||
install: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a lockfile entry to a graph [`Node`].
|
||||
fn package_to_node(
|
||||
&self,
|
||||
package: &Package,
|
||||
tags: &Tags,
|
||||
build_options: &BuildOptions,
|
||||
install_options: &InstallOptions,
|
||||
marker_env: &ResolverMarkerEnvironment,
|
||||
) -> Result<Node, LockError> {
|
||||
if install_options.include_package(
|
||||
package.as_install_target(),
|
||||
self.project_name(),
|
||||
self.lock().members(),
|
||||
) {
|
||||
self.installable_node(package, tags, marker_env, build_options)
|
||||
} else {
|
||||
self.non_installable_node(package, tags, marker_env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal lock-to-resolution implementation shared by [`Installable`] and [`Lock`].
|
||||
trait InstallableExt<'lock>: Installable<'lock> {
|
||||
/// Convert concrete locked packages to a [`Resolution`].
|
||||
///
|
||||
/// `include_manifest` controls whether requirements attached directly to the lock target are
|
||||
/// included in addition to `roots`.
|
||||
fn to_resolution_from_packages(
|
||||
&self,
|
||||
roots: &[&Package],
|
||||
include_manifest: bool,
|
||||
marker_env: &ResolverMarkerEnvironment,
|
||||
tags: &Tags,
|
||||
extras: &ExtrasSpecificationWithDefaults,
|
||||
groups: &DependencyGroupsWithDefaults,
|
||||
build_options: &BuildOptions,
|
||||
install_options: &InstallOptions,
|
||||
) -> Result<Resolution, LockError> {
|
||||
let size_guess = self.lock().packages.len();
|
||||
let mut petgraph = Graph::with_capacity(size_guess, size_guess);
|
||||
@@ -75,17 +191,7 @@ pub trait Installable<'lock> {
|
||||
// computed below. We somehow need to add the dependency groups _after_ we've computed all
|
||||
// enabled extras, but the groups themselves could depend on the set of enabled extras.
|
||||
if !self.lock().conflicts().is_empty() {
|
||||
for root_name in self.roots() {
|
||||
let dist = self
|
||||
.lock()
|
||||
.find_by_name(root_name)
|
||||
.map_err(|_| LockErrorKind::MultipleRootPackages {
|
||||
name: root_name.clone(),
|
||||
})?
|
||||
.ok_or_else(|| LockErrorKind::MissingRootPackage {
|
||||
name: root_name.clone(),
|
||||
})?;
|
||||
|
||||
for dist in roots.iter().copied() {
|
||||
// Track the activated extras.
|
||||
if groups.prod() {
|
||||
activated_projects.push(&dist.id.name);
|
||||
@@ -106,18 +212,8 @@ pub trait Installable<'lock> {
|
||||
}
|
||||
|
||||
// Initialize the workspace roots.
|
||||
let mut roots = vec![];
|
||||
for root_name in self.roots() {
|
||||
let dist = self
|
||||
.lock()
|
||||
.find_by_name(root_name)
|
||||
.map_err(|_| LockErrorKind::MultipleRootPackages {
|
||||
name: root_name.clone(),
|
||||
})?
|
||||
.ok_or_else(|| LockErrorKind::MissingRootPackage {
|
||||
name: root_name.clone(),
|
||||
})?;
|
||||
|
||||
let mut initialized_roots = vec![];
|
||||
for dist in roots.iter().copied() {
|
||||
// Add the workspace package to the graph.
|
||||
let index = petgraph.add_node(if groups.prod() {
|
||||
self.package_to_node(dist, tags, build_options, install_options, marker_env)?
|
||||
@@ -130,11 +226,11 @@ pub trait Installable<'lock> {
|
||||
petgraph.add_edge(root, index, Edge::Prod);
|
||||
|
||||
// Push the package onto the queue.
|
||||
roots.push((dist, index));
|
||||
initialized_roots.push((dist, index));
|
||||
}
|
||||
|
||||
// Add the workspace dependencies to the queue.
|
||||
for (dist, index) in roots {
|
||||
for (dist, index) in initialized_roots {
|
||||
if groups.prod() {
|
||||
// Push its dependencies onto the queue.
|
||||
queue.push_back((dist, None));
|
||||
@@ -227,7 +323,12 @@ pub trait Installable<'lock> {
|
||||
|
||||
// Add any requirements that are exclusive to the workspace root (e.g., dependencies in
|
||||
// PEP 723 scripts).
|
||||
for dependency in self.lock().requirements() {
|
||||
for dependency in self
|
||||
.lock()
|
||||
.requirements()
|
||||
.iter()
|
||||
.filter(|_| include_manifest)
|
||||
{
|
||||
if !dependency.marker.evaluate(marker_env, &[]) {
|
||||
continue;
|
||||
}
|
||||
@@ -271,6 +372,7 @@ pub trait Installable<'lock> {
|
||||
.lock()
|
||||
.dependency_groups()
|
||||
.iter()
|
||||
.filter(|_| include_manifest)
|
||||
.filter_map(|(group, deps)| {
|
||||
if groups.contains(group) {
|
||||
Some(deps.iter().map(move |dep| (group, dep)))
|
||||
@@ -530,73 +632,468 @@ pub trait Installable<'lock> {
|
||||
|
||||
Ok(Resolution::new(petgraph))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an installable [`Node`] from a [`Package`].
|
||||
fn installable_node(
|
||||
&self,
|
||||
package: &Package,
|
||||
tags: &Tags,
|
||||
marker_env: &ResolverMarkerEnvironment,
|
||||
build_options: &BuildOptions,
|
||||
) -> Result<Node, LockError> {
|
||||
let tag_policy = TagPolicy::Required(tags);
|
||||
let HashedDist { dist, hashes } =
|
||||
package.to_dist(self.install_path(), tag_policy, build_options, marker_env)?;
|
||||
let version = package.version().cloned();
|
||||
let dist = ResolvedDist::Installable {
|
||||
dist: Arc::new(dist),
|
||||
version,
|
||||
};
|
||||
Ok(Node::Dist {
|
||||
dist,
|
||||
hashes,
|
||||
install: true,
|
||||
})
|
||||
impl<'lock, T> InstallableExt<'lock> for T where T: Installable<'lock> + ?Sized {}
|
||||
|
||||
/// An [`Installable`] adapter for materializing concrete packages directly from a [`Lock`].
|
||||
struct LockedPackages<'lock> {
|
||||
lock: &'lock Lock,
|
||||
install_path: &'lock Path,
|
||||
project_name: Option<&'lock PackageName>,
|
||||
}
|
||||
|
||||
impl<'lock> Installable<'lock> for LockedPackages<'lock> {
|
||||
fn install_path(&self) -> &'lock Path {
|
||||
self.install_path
|
||||
}
|
||||
|
||||
/// Create a non-installable [`Node`] from a [`Package`].
|
||||
fn non_installable_node(
|
||||
&self,
|
||||
package: &Package,
|
||||
tags: &Tags,
|
||||
marker_env: &ResolverMarkerEnvironment,
|
||||
) -> Result<Node, LockError> {
|
||||
let HashedDist { dist, .. } = package.to_dist(
|
||||
self.install_path(),
|
||||
TagPolicy::Preferred(tags),
|
||||
&BuildOptions::default(),
|
||||
marker_env,
|
||||
)?;
|
||||
let version = package.version().cloned();
|
||||
let dist = ResolvedDist::Installable {
|
||||
dist: Arc::new(dist),
|
||||
version,
|
||||
};
|
||||
let hashes = package.hashes();
|
||||
Ok(Node::Dist {
|
||||
dist,
|
||||
hashes,
|
||||
install: false,
|
||||
})
|
||||
fn lock(&self) -> &'lock Lock {
|
||||
self.lock
|
||||
}
|
||||
|
||||
/// Convert a lockfile entry to a graph [`Node`].
|
||||
fn package_to_node(
|
||||
&self,
|
||||
package: &Package,
|
||||
tags: &Tags,
|
||||
build_options: &BuildOptions,
|
||||
install_options: &InstallOptions,
|
||||
marker_env: &ResolverMarkerEnvironment,
|
||||
) -> Result<Node, LockError> {
|
||||
if install_options.include_package(
|
||||
package.as_install_target(),
|
||||
self.project_name(),
|
||||
self.lock().members(),
|
||||
) {
|
||||
self.installable_node(package, tags, marker_env, build_options)
|
||||
} else {
|
||||
self.non_installable_node(package, tags, marker_env)
|
||||
}
|
||||
fn roots(&self) -> impl Iterator<Item = &PackageName> {
|
||||
std::iter::empty()
|
||||
}
|
||||
|
||||
fn project_name(&self) -> Option<&PackageName> {
|
||||
self.project_name
|
||||
}
|
||||
}
|
||||
|
||||
impl Lock {
|
||||
/// Materialize the exact dependency subgraph reachable from concrete locked `roots`.
|
||||
///
|
||||
/// Each root must be a [`Package`] from this lock. Unlike [`Installable::to_resolution`], this
|
||||
/// method does not include requirements or dependency groups attached directly to the lock
|
||||
/// manifest. Extras and dependency groups on the concrete roots are still included according
|
||||
/// to `extras` and `groups`. `project_name` identifies the project for project-specific
|
||||
/// [`InstallOptions`] filters, if applicable. Callers are responsible for selecting roots that
|
||||
/// apply to `marker_env`.
|
||||
pub fn to_resolution<'lock>(
|
||||
&'lock self,
|
||||
install_path: &'lock Path,
|
||||
roots: impl IntoIterator<Item = &'lock Package>,
|
||||
project_name: Option<&'lock PackageName>,
|
||||
marker_env: &ResolverMarkerEnvironment,
|
||||
tags: &Tags,
|
||||
extras: &ExtrasSpecificationWithDefaults,
|
||||
groups: &DependencyGroupsWithDefaults,
|
||||
build_options: &BuildOptions,
|
||||
install_options: &InstallOptions,
|
||||
) -> Result<Resolution, LockError> {
|
||||
let mut seen = FxHashSet::default();
|
||||
let mut concrete_roots = Vec::new();
|
||||
for root in roots {
|
||||
let Some(index) = self.by_id.get(&root.id) else {
|
||||
return Err(LockErrorKind::RootPackageMissingFromLock {
|
||||
id: root.id.clone(),
|
||||
}
|
||||
.into());
|
||||
};
|
||||
if seen.insert(&root.id) {
|
||||
let Some(root) = self.packages.get(*index) else {
|
||||
return Err(LockErrorKind::RootPackageMissingFromLock {
|
||||
id: root.id.clone(),
|
||||
}
|
||||
.into());
|
||||
};
|
||||
concrete_roots.push(root);
|
||||
}
|
||||
}
|
||||
|
||||
LockedPackages {
|
||||
lock: self,
|
||||
install_path,
|
||||
project_name,
|
||||
}
|
||||
.to_resolution_from_packages(
|
||||
&concrete_roots,
|
||||
false,
|
||||
marker_env,
|
||||
tags,
|
||||
extras,
|
||||
groups,
|
||||
build_options,
|
||||
install_options,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::Cell;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use petgraph::visit::EdgeRef;
|
||||
use uv_configuration::{DependencyGroups, ExtrasSpecification};
|
||||
use uv_distribution_types::Name;
|
||||
use uv_normalize::{DefaultExtras, DefaultGroups};
|
||||
use uv_pep508::{MarkerEnvironment, MarkerEnvironmentBuilder};
|
||||
use uv_platform_tags::{Arch, Os, Platform, TagsOptions};
|
||||
|
||||
use super::*;
|
||||
|
||||
static TAGS: LazyLock<Tags> = LazyLock::new(|| {
|
||||
Tags::from_env(
|
||||
&Platform::new(
|
||||
Os::Macos {
|
||||
major: 14,
|
||||
minor: 0,
|
||||
},
|
||||
Arch::Aarch64,
|
||||
),
|
||||
(3, 11),
|
||||
"cpython",
|
||||
(3, 11),
|
||||
TagsOptions::default(),
|
||||
)
|
||||
.expect("valid tags")
|
||||
});
|
||||
|
||||
static DARWIN_MARKERS: LazyLock<ResolverMarkerEnvironment> =
|
||||
LazyLock::new(|| ResolverMarkerEnvironment::from(marker_environment("darwin", "Darwin")));
|
||||
|
||||
static LINUX_MARKERS: LazyLock<ResolverMarkerEnvironment> =
|
||||
LazyLock::new(|| ResolverMarkerEnvironment::from(marker_environment("linux", "Linux")));
|
||||
|
||||
fn marker_environment(
|
||||
sys_platform: &'static str,
|
||||
platform_system: &'static str,
|
||||
) -> MarkerEnvironment {
|
||||
MarkerEnvironment::try_from(MarkerEnvironmentBuilder {
|
||||
implementation_name: "cpython",
|
||||
implementation_version: "3.11.5",
|
||||
os_name: "posix",
|
||||
platform_machine: "arm64",
|
||||
platform_python_implementation: "CPython",
|
||||
platform_release: "23.0.0",
|
||||
platform_system,
|
||||
platform_version: "test",
|
||||
python_full_version: "3.11.5",
|
||||
python_version: "3.11",
|
||||
sys_platform,
|
||||
})
|
||||
.expect("valid marker environment")
|
||||
}
|
||||
|
||||
fn lock() -> Lock {
|
||||
toml::from_str(
|
||||
r#"
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
resolution-markers = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform != 'darwin'",
|
||||
]
|
||||
|
||||
[manifest]
|
||||
requirements = [{ name = "unrelated" }]
|
||||
|
||||
[[package]]
|
||||
name = "dev-dependency"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://example.com/simple" }
|
||||
sdist = { url = "https://example.com/dev_dependency-1.0.0.tar.gz", hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
|
||||
|
||||
[[package]]
|
||||
name = "forked"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://example.com/simple" }
|
||||
resolution-markers = ["sys_platform == 'darwin'"]
|
||||
sdist = { url = "https://example.com/forked-1.0.0.tar.gz", hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222" }
|
||||
|
||||
[[package]]
|
||||
name = "forked"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://example.com/simple" }
|
||||
resolution-markers = ["sys_platform != 'darwin'"]
|
||||
sdist = { url = "https://example.com/forked-2.0.0.tar.gz", hash = "sha256:3333333333333333333333333333333333333333333333333333333333333333" }
|
||||
|
||||
[[package]]
|
||||
name = "optional-dependency"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://example.com/simple" }
|
||||
sdist = { url = "https://example.com/optional_dependency-1.0.0.tar.gz", hash = "sha256:4444444444444444444444444444444444444444444444444444444444444444" }
|
||||
|
||||
[[package]]
|
||||
name = "root-a"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://example.com/simple" }
|
||||
dependencies = [
|
||||
{ name = "forked", version = "1.0.0", source = { registry = "https://example.com/simple" }, marker = "sys_platform == 'darwin'" },
|
||||
{ name = "forked", version = "2.0.0", source = { registry = "https://example.com/simple" }, marker = "sys_platform != 'darwin'" },
|
||||
{ name = "shared" },
|
||||
]
|
||||
sdist = { url = "https://example.com/root_a-1.0.0.tar.gz", hash = "sha256:5555555555555555555555555555555555555555555555555555555555555555" }
|
||||
|
||||
[package.optional-dependencies]
|
||||
feature = [{ name = "optional-dependency" }]
|
||||
|
||||
[package.dependency-groups]
|
||||
dev = [{ name = "dev-dependency" }]
|
||||
|
||||
[package.metadata]
|
||||
provides-extras = ["feature"]
|
||||
|
||||
[[package]]
|
||||
name = "root-b"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://example.com/simple" }
|
||||
dependencies = [{ name = "shared" }]
|
||||
sdist = { url = "https://example.com/root_b-1.0.0.tar.gz", hash = "sha256:6666666666666666666666666666666666666666666666666666666666666666" }
|
||||
|
||||
[[package]]
|
||||
name = "shared"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://example.com/simple" }
|
||||
sdist = { url = "https://example.com/shared-1.0.0.tar.gz", hash = "sha256:7777777777777777777777777777777777777777777777777777777777777777" }
|
||||
|
||||
[[package]]
|
||||
name = "unrelated"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://example.com/simple" }
|
||||
sdist = { url = "https://example.com/unrelated-1.0.0.tar.gz", hash = "sha256:8888888888888888888888888888888888888888888888888888888888888888" }
|
||||
"#,
|
||||
)
|
||||
.expect("valid lock")
|
||||
}
|
||||
|
||||
fn package<'lock>(lock: &'lock Lock, name: &str, version: &str) -> &'lock Package {
|
||||
lock.packages()
|
||||
.iter()
|
||||
.find(|package| {
|
||||
package.name().as_ref() == name
|
||||
&& package
|
||||
.version()
|
||||
.is_some_and(|package_version| package_version.to_string() == version)
|
||||
})
|
||||
.expect("locked package")
|
||||
}
|
||||
|
||||
fn materialize(
|
||||
lock: &Lock,
|
||||
roots: &[&Package],
|
||||
marker_env: &ResolverMarkerEnvironment,
|
||||
) -> Resolution {
|
||||
let extras = ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default());
|
||||
let groups = DependencyGroups::from_all_groups().with_defaults(DefaultGroups::default());
|
||||
lock.to_resolution(
|
||||
Path::new("."),
|
||||
roots.iter().copied(),
|
||||
None,
|
||||
marker_env,
|
||||
&TAGS,
|
||||
&extras,
|
||||
&groups,
|
||||
&BuildOptions::default(),
|
||||
&InstallOptions::default(),
|
||||
)
|
||||
.expect("valid resolution")
|
||||
}
|
||||
|
||||
struct OverridingInstallable<'lock> {
|
||||
lock: &'lock Lock,
|
||||
root_name: &'lock PackageName,
|
||||
package_to_node_calls: Cell<usize>,
|
||||
}
|
||||
|
||||
impl<'lock> Installable<'lock> for OverridingInstallable<'lock> {
|
||||
fn install_path(&self) -> &'lock Path {
|
||||
Path::new(".")
|
||||
}
|
||||
|
||||
fn lock(&self) -> &'lock Lock {
|
||||
self.lock
|
||||
}
|
||||
|
||||
fn roots(&self) -> impl Iterator<Item = &PackageName> {
|
||||
std::iter::once(self.root_name)
|
||||
}
|
||||
|
||||
fn project_name(&self) -> Option<&PackageName> {
|
||||
None
|
||||
}
|
||||
|
||||
fn package_to_node(
|
||||
&self,
|
||||
_package: &Package,
|
||||
_tags: &Tags,
|
||||
_build_options: &BuildOptions,
|
||||
_install_options: &InstallOptions,
|
||||
_marker_env: &ResolverMarkerEnvironment,
|
||||
) -> Result<Node, LockError> {
|
||||
self.package_to_node_calls
|
||||
.set(self.package_to_node_calls.get() + 1);
|
||||
Ok(Node::Root)
|
||||
}
|
||||
}
|
||||
|
||||
fn graph_snapshot(resolution: &Resolution) -> (Vec<String>, Vec<String>) {
|
||||
let graph = resolution.graph();
|
||||
let labels = graph
|
||||
.node_weights()
|
||||
.map(|node| match node {
|
||||
Node::Root => "root".to_string(),
|
||||
Node::Dist {
|
||||
dist,
|
||||
hashes,
|
||||
install,
|
||||
} => format!(
|
||||
"{}=={} (install: {install}, hashes: {})",
|
||||
dist.name(),
|
||||
dist.version()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| "<dynamic>".to_string()),
|
||||
hashes.iter().map(ToString::to_string).join(", ")
|
||||
),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut nodes = labels.clone();
|
||||
nodes.sort_unstable();
|
||||
let mut edges = graph
|
||||
.edge_references()
|
||||
.map(|edge| {
|
||||
format!(
|
||||
"{} --{:?}--> {}",
|
||||
labels[edge.source().index()],
|
||||
edge.weight(),
|
||||
labels[edge.target().index()]
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
edges.sort_unstable();
|
||||
(nodes, edges)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materializes_multiple_concrete_roots_with_shared_dependencies() {
|
||||
let lock = lock();
|
||||
let resolution = materialize(
|
||||
&lock,
|
||||
&[
|
||||
package(&lock, "root-a", "1.0.0"),
|
||||
package(&lock, "root-b", "1.0.0"),
|
||||
],
|
||||
&DARWIN_MARKERS,
|
||||
);
|
||||
|
||||
insta::with_settings!({
|
||||
filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
|
||||
}, {
|
||||
insta::assert_debug_snapshot!(graph_snapshot(&resolution), @r#"
|
||||
(
|
||||
[
|
||||
"dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"forked==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-b==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"shared==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
],
|
||||
[
|
||||
"root --Prod--> root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root --Prod--> root-b==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Dev(GroupName(\"dev\"))--> dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"feature\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> forked==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-b==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
],
|
||||
)
|
||||
"#);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materializes_the_selected_universal_lock_fork() {
|
||||
let lock = lock();
|
||||
let root = package(&lock, "root-a", "1.0.0");
|
||||
let darwin = materialize(&lock, &[root], &DARWIN_MARKERS);
|
||||
let linux = materialize(&lock, &[root], &LINUX_MARKERS);
|
||||
let concrete_fork =
|
||||
materialize(&lock, &[package(&lock, "forked", "1.0.0")], &DARWIN_MARKERS);
|
||||
|
||||
insta::with_settings!({
|
||||
filters => [(r"sha256:[0-9a-f]{64}", "sha256:[HASH]")],
|
||||
}, {
|
||||
insta::assert_debug_snapshot!(graph_snapshot(&darwin), @r#"
|
||||
(
|
||||
[
|
||||
"dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"forked==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"shared==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
],
|
||||
[
|
||||
"root --Prod--> root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Dev(GroupName(\"dev\"))--> dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"feature\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> forked==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
],
|
||||
)
|
||||
"#);
|
||||
insta::assert_debug_snapshot!(graph_snapshot(&linux), @r#"
|
||||
(
|
||||
[
|
||||
"dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"forked==2.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"shared==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
],
|
||||
[
|
||||
"root --Prod--> root-a==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Dev(GroupName(\"dev\"))--> dev-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Optional(ExtraName(\"feature\"))--> optional-dependency==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> forked==2.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root-a==1.0.0 (install: true, hashes: sha256:[HASH]) --Prod--> shared==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
],
|
||||
)
|
||||
"#);
|
||||
insta::assert_debug_snapshot!(graph_snapshot(&concrete_fork), @r#"
|
||||
(
|
||||
[
|
||||
"forked==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
"root",
|
||||
],
|
||||
[
|
||||
"root --Prod--> forked==1.0.0 (install: true, hashes: sha256:[HASH])",
|
||||
],
|
||||
)
|
||||
"#);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installable_to_resolution_preserves_node_overrides() {
|
||||
let mut lock = lock();
|
||||
lock.manifest.requirements.clear();
|
||||
let target = OverridingInstallable {
|
||||
root_name: package(&lock, "root-a", "1.0.0").name(),
|
||||
lock: &lock,
|
||||
package_to_node_calls: Cell::new(0),
|
||||
};
|
||||
let extras = ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default());
|
||||
let groups = DependencyGroups::from_all_groups().with_defaults(DefaultGroups::default());
|
||||
|
||||
target
|
||||
.to_resolution(
|
||||
&DARWIN_MARKERS,
|
||||
&TAGS,
|
||||
&extras,
|
||||
&groups,
|
||||
&BuildOptions::default(),
|
||||
&InstallOptions::default(),
|
||||
)
|
||||
.expect("valid resolution");
|
||||
|
||||
assert!(target.package_to_node_calls.get() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6611,6 +6611,12 @@ enum LockErrorKind {
|
||||
/// The ID of the package.
|
||||
name: PackageName,
|
||||
},
|
||||
/// An error that occurs when a concrete root package does not belong to the lock.
|
||||
#[error("Could not find root package `{id}` in lock", id = id.cyan())]
|
||||
RootPackageMissingFromLock {
|
||||
/// The ID of the package.
|
||||
id: PackageId,
|
||||
},
|
||||
/// An error that occurs when resolving metadata for a package.
|
||||
#[error("Failed to generate package metadata for `{id}`", id = id.cyan())]
|
||||
Resolution {
|
||||
|
||||
@@ -157,6 +157,47 @@ impl CachedEnvironment {
|
||||
.await?,
|
||||
);
|
||||
|
||||
Self::from_resolution(
|
||||
&resolution,
|
||||
build_constraints,
|
||||
&interpreter,
|
||||
settings,
|
||||
client_builder,
|
||||
state,
|
||||
install,
|
||||
installer_metadata,
|
||||
concurrency,
|
||||
cache,
|
||||
printer,
|
||||
preview,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get or create a [`CachedEnvironment`] from an existing [`Resolution`].
|
||||
///
|
||||
/// Prefer [`Self::from_spec`] when starting from unresolved requirements; it selects the base
|
||||
/// interpreter and resolves the requirements for that interpreter before delegating here.
|
||||
///
|
||||
/// This method is intended for callers that already have a concrete [`Resolution`], and
|
||||
/// performs environment reuse or creation and installation without invoking the resolver.
|
||||
/// `interpreter` must be the base interpreter for which `resolution` was produced. In
|
||||
/// particular, callers materializing a universal lock must derive its markers and tags from
|
||||
/// the same interpreter.
|
||||
pub(crate) async fn from_resolution(
|
||||
resolution: &Resolution,
|
||||
build_constraints: Constraints,
|
||||
interpreter: &Interpreter,
|
||||
settings: &ResolverInstallerSettings,
|
||||
client_builder: &BaseClientBuilder<'_>,
|
||||
state: &PlatformState,
|
||||
install: Box<dyn InstallLogger>,
|
||||
installer_metadata: bool,
|
||||
concurrency: &Concurrency,
|
||||
cache: &Cache,
|
||||
printer: Printer,
|
||||
preview: Preview,
|
||||
) -> Result<Self, ProjectError> {
|
||||
// Hash the resolution by hashing the generated lockfile.
|
||||
let resolution_hash = {
|
||||
let mut distributions = resolution
|
||||
@@ -216,7 +257,7 @@ impl CachedEnvironment {
|
||||
let temp_dir = cache.venv_dir()?;
|
||||
let venv = uv_virtualenv::create_venv(
|
||||
temp_dir.path(),
|
||||
interpreter,
|
||||
interpreter.clone(),
|
||||
uv_virtualenv::Prompt::None,
|
||||
false,
|
||||
uv_virtualenv::OnExisting::Remove(uv_virtualenv::RemovalReason::TemporaryEnvironment),
|
||||
@@ -227,7 +268,7 @@ impl CachedEnvironment {
|
||||
|
||||
sync_environment(
|
||||
venv,
|
||||
&resolution,
|
||||
resolution,
|
||||
Modifications::Exact,
|
||||
build_constraints,
|
||||
settings.into(),
|
||||
|
||||
@@ -6,11 +6,17 @@ use std::str::FromStr;
|
||||
use itertools::Either;
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
use uv_configuration::{Constraints, DependencyGroupsWithDefaults, ExtrasSpecification};
|
||||
use uv_distribution_types::Index;
|
||||
use uv_configuration::{
|
||||
BuildOptions, Constraints, DependencyGroupsWithDefaults, ExtrasSpecification,
|
||||
ExtrasSpecificationWithDefaults, InstallOptions,
|
||||
};
|
||||
use uv_distribution_types::{Index, Resolution};
|
||||
use uv_normalize::{ExtraName, PackageName};
|
||||
use uv_pypi_types::{DependencyGroupSpecifier, LenientRequirement, VerbatimParsedUrl};
|
||||
use uv_resolver::{Installable, Lock, Package};
|
||||
use uv_platform_tags::Tags;
|
||||
use uv_pypi_types::{
|
||||
DependencyGroupSpecifier, LenientRequirement, ResolverMarkerEnvironment, VerbatimParsedUrl,
|
||||
};
|
||||
use uv_resolver::{Installable, Lock, LockError, Package};
|
||||
use uv_scripts::Pep723Script;
|
||||
use uv_workspace::Workspace;
|
||||
use uv_workspace::pyproject::{Source, Sources, ToolUvSources};
|
||||
@@ -113,6 +119,56 @@ impl<'lock> Installable<'lock> for InstallTarget<'lock> {
|
||||
}
|
||||
|
||||
impl<'lock> InstallTarget<'lock> {
|
||||
/// Convert the target's locked packages to a [`Resolution`].
|
||||
pub(crate) fn to_resolution(
|
||||
self,
|
||||
marker_env: &ResolverMarkerEnvironment,
|
||||
tags: &Tags,
|
||||
extras: &ExtrasSpecificationWithDefaults,
|
||||
groups: &DependencyGroupsWithDefaults,
|
||||
build_options: &BuildOptions,
|
||||
install_options: &InstallOptions,
|
||||
) -> Result<Resolution, LockError> {
|
||||
// Project and workspace targets have package-backed roots, so materialize them through the
|
||||
// concrete-root API. Non-project workspaces can also have package roots, but their root
|
||||
// dependencies live on the lock manifest, so they need the generic path. Scripts and
|
||||
// invalid or ambiguous roots also use that path.
|
||||
let use_concrete_roots = match self {
|
||||
Self::Project { workspace, .. }
|
||||
| Self::Projects { workspace, .. }
|
||||
| Self::Workspace { workspace, .. } => !workspace.is_non_project(),
|
||||
Self::NonProjectWorkspace { .. } | Self::Script { .. } => false,
|
||||
};
|
||||
if use_concrete_roots
|
||||
&& let Some(roots) = self
|
||||
.roots()
|
||||
.map(|root_name| self.lock().find_by_name(root_name).ok().flatten())
|
||||
.collect::<Option<Vec<_>>>()
|
||||
{
|
||||
return self.lock().to_resolution(
|
||||
self.install_path(),
|
||||
roots,
|
||||
self.project_name(),
|
||||
marker_env,
|
||||
tags,
|
||||
extras,
|
||||
groups,
|
||||
build_options,
|
||||
install_options,
|
||||
);
|
||||
}
|
||||
|
||||
Installable::to_resolution(
|
||||
&self,
|
||||
marker_env,
|
||||
tags,
|
||||
extras,
|
||||
groups,
|
||||
build_options,
|
||||
install_options,
|
||||
)
|
||||
}
|
||||
|
||||
/// Return an iterator over the [`Index`] definitions in the target.
|
||||
pub(crate) fn indexes(self) -> impl Iterator<Item = &'lock Index> {
|
||||
match self {
|
||||
|
||||
@@ -1353,6 +1353,17 @@ fn sync_non_project_dev_dependencies() -> Result<()> {
|
||||
+ urllib3==2.2.1
|
||||
");
|
||||
|
||||
// Selecting a member still includes the non-project root's default dependency group.
|
||||
uv_snapshot!(context.filters(), context.sync().arg("--package").arg("child"), @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Resolved 11 packages in [TIME]
|
||||
Checked 10 packages in [TIME]
|
||||
");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1724,6 +1724,42 @@ fn workspace_metadata_group_only() -> Result<()> {
|
||||
"#
|
||||
);
|
||||
|
||||
// With `--sync`, modules provided by the non-project root's dependency group should be
|
||||
// attributed to their locked package.
|
||||
let assert = context
|
||||
.workspace_metadata()
|
||||
.arg("--sync")
|
||||
.current_dir(&workspace)
|
||||
.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::assert_snapshot!(module_owners, @r#"
|
||||
{
|
||||
"iniconfig": [
|
||||
{
|
||||
"package_id": "iniconfig==2.0.0@registry+https://pypi.org/simple"
|
||||
}
|
||||
],
|
||||
"iniconfig._parse": [
|
||||
{
|
||||
"package_id": "iniconfig==2.0.0@registry+https://pypi.org/simple"
|
||||
}
|
||||
],
|
||||
"iniconfig._version": [
|
||||
{
|
||||
"package_id": "iniconfig==2.0.0@registry+https://pypi.org/simple"
|
||||
}
|
||||
],
|
||||
"iniconfig.exceptions": [
|
||||
{
|
||||
"package_id": "iniconfig==2.0.0@registry+https://pypi.org/simple"
|
||||
}
|
||||
]
|
||||
}
|
||||
"#);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user