diff --git a/crates/uv-resolver/src/lock/requirements_txt.rs b/crates/uv-resolver/src/lock/export/mod.rs similarity index 74% rename from crates/uv-resolver/src/lock/requirements_txt.rs rename to crates/uv-resolver/src/lock/export/mod.rs index 2ec3c894b9039..f3b92d2dd9ddd 100644 --- a/crates/uv-resolver/src/lock/requirements_txt.rs +++ b/crates/uv-resolver/src/lock/export/mod.rs @@ -1,52 +1,50 @@ -use std::borrow::Cow; use std::collections::hash_map::Entry; use std::collections::VecDeque; -use std::fmt::Formatter; -use std::path::{Component, Path, PathBuf}; use either::Either; -use owo_colors::OwoColorize; use petgraph::graph::NodeIndex; use petgraph::prelude::EdgeRef; use petgraph::visit::IntoNodeReferences; use petgraph::{Direction, Graph}; use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; -use url::Url; - -use uv_configuration::{ - DependencyGroupsWithDefaults, EditableMode, ExtrasSpecification, InstallOptions, -}; -use uv_distribution_filename::{DistExtension, SourceDistExtension}; -use uv_fs::Simplified; -use uv_git_types::GitReference; + +use uv_configuration::{DependencyGroupsWithDefaults, ExtrasSpecification, InstallOptions}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep508::MarkerTree; -use uv_pypi_types::{ConflictItem, ParsedArchiveUrl, ParsedGitUrl}; +use uv_pypi_types::ConflictItem; use crate::graph_ops::{marker_reachability, Reachable}; -use crate::lock::{Package, PackageId, Source}; +pub use crate::lock::export::requirements_txt::RequirementsTxtExport; use crate::universal_marker::resolve_conflicts; -use crate::{Installable, LockError}; - -/// An export of a [`Lock`] that renders in `requirements.txt` format. -#[derive(Debug)] -pub struct RequirementsTxtExport<'lock> { - nodes: Vec>, - hashes: bool, - editable: EditableMode, +use crate::{Installable, Package}; + +mod requirements_txt; + +/// A flat requirement, with its associated marker. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExportableRequirement<'lock> { + /// The [`Package`] associated with the requirement. + package: &'lock Package, + /// The marker that must be satisfied to install the package. + marker: MarkerTree, + /// The list of packages that depend on this package. + dependents: Vec<&'lock Package>, } -impl<'lock> RequirementsTxtExport<'lock> { - pub fn from_lock( +/// A set of flattened, exportable requirements, generated from a lockfile. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExportableRequirements<'lock>(Vec>); + +impl<'lock> ExportableRequirements<'lock> { + /// Generate the set of exportable [`ExportableRequirement`] entries from the given lockfile. + fn from_lock( target: &impl Installable<'lock>, prune: &[PackageName], extras: &ExtrasSpecification, dev: &DependencyGroupsWithDefaults, annotate: bool, - editable: EditableMode, - hashes: bool, install_options: &'lock InstallOptions, - ) -> Result { + ) -> Self { let size_guess = target.lock().packages.len(); let mut graph = Graph::, Edge<'lock>>::with_capacity(size_guess, size_guess); let mut inverse = FxHashMap::with_capacity_and_hasher(size_guess, FxBuildHasher); @@ -292,7 +290,7 @@ impl<'lock> RequirementsTxtExport<'lock> { }; // Collect all packages. - let mut nodes = graph + let nodes = graph .node_references() .filter_map(|(index, node)| match node { Node::Root => None, @@ -305,7 +303,7 @@ impl<'lock> RequirementsTxtExport<'lock> { target.lock().members(), ) }) - .map(|(index, package)| Requirement { + .map(|(index, package)| ExportableRequirement { package, marker: reachability.remove(&index).unwrap_or_default(), dependents: if annotate { @@ -327,16 +325,47 @@ impl<'lock> RequirementsTxtExport<'lock> { .filter(|requirement| !requirement.marker.is_false()) .collect::>(); - // Sort the nodes, such that unnamed URLs (editables) appear at the top. - nodes.sort_unstable_by(|a, b| { - RequirementComparator::from(a.package).cmp(&RequirementComparator::from(b.package)) - }); + Self(nodes) + } +} - Ok(Self { - nodes, - hashes, - editable, - }) +/// A node in the graph. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Node<'lock> { + Root, + Package(&'lock Package), +} + +/// An edge in the resolution graph, along with the marker that must be satisfied to traverse it. +#[derive(Debug, Clone)] +enum Edge<'lock> { + Prod(MarkerTree), + Optional(&'lock ExtraName, MarkerTree), + Dev(&'lock GroupName, MarkerTree), +} + +impl Edge<'_> { + /// Return the [`MarkerTree`] for this edge. + fn marker(&self) -> &MarkerTree { + match self { + Self::Prod(marker) => marker, + Self::Optional(_, marker) => marker, + Self::Dev(_, marker) => marker, + } + } +} + +impl Reachable for Edge<'_> { + fn true_marker() -> MarkerTree { + MarkerTree::TRUE + } + + fn false_marker() -> MarkerTree { + MarkerTree::FALSE + } + + fn marker(&self) -> MarkerTree { + *self.marker() } } @@ -502,197 +531,3 @@ fn conflict_marker_reachability<'lock>( reachability } - -impl std::fmt::Display for RequirementsTxtExport<'_> { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - // Write out each package. - for Requirement { - package, - marker, - dependents, - } in &self.nodes - { - match &package.id.source { - Source::Registry(_) => { - let version = package - .id - .version - .as_ref() - .expect("registry package without version"); - write!(f, "{}=={}", package.id.name, version)?; - } - Source::Git(url, git) => { - // Remove the fragment and query from the URL; they're already present in the - // `GitSource`. - let mut url = url.to_url().map_err(|_| std::fmt::Error)?; - url.set_fragment(None); - url.set_query(None); - - // Reconstruct the `GitUrl` from the `GitSource`. - let git_url = uv_git_types::GitUrl::from_commit( - url, - GitReference::from(git.kind.clone()), - git.precise, - ) - .expect("Internal Git URLs must have supported schemes"); - - // Reconstruct the PEP 508-compatible URL from the `GitSource`. - let url = Url::from(ParsedGitUrl { - url: git_url.clone(), - subdirectory: git.subdirectory.clone(), - }); - - write!(f, "{} @ {}", package.id.name, url)?; - } - Source::Direct(url, direct) => { - let url = Url::from(ParsedArchiveUrl { - url: url.to_url().map_err(|_| std::fmt::Error)?, - subdirectory: direct.subdirectory.clone(), - ext: DistExtension::Source(SourceDistExtension::TarGz), - }); - write!(f, "{} @ {}", package.id.name, url)?; - } - Source::Path(path) | Source::Directory(path) => { - if path.is_absolute() { - write!( - f, - "{}", - Url::from_file_path(path).map_err(|()| std::fmt::Error)? - )?; - } else { - write!(f, "{}", anchor(path).portable_display())?; - } - } - Source::Editable(path) => match self.editable { - EditableMode::Editable => { - write!(f, "-e {}", anchor(path).portable_display())?; - } - EditableMode::NonEditable => { - if path.is_absolute() { - write!( - f, - "{}", - Url::from_file_path(path).map_err(|()| std::fmt::Error)? - )?; - } else { - write!(f, "{}", anchor(path).portable_display())?; - } - } - }, - Source::Virtual(_) => { - continue; - } - } - - if let Some(contents) = marker.contents() { - write!(f, " ; {contents}")?; - } - - if self.hashes { - let mut hashes = package.hashes(); - hashes.sort_unstable(); - if !hashes.is_empty() { - for hash in hashes.iter() { - writeln!(f, " \\")?; - write!(f, " --hash=")?; - write!(f, "{hash}")?; - } - } - } - - writeln!(f)?; - - // Add "via ..." comments for all dependents. - match dependents.as_slice() { - [] => {} - [dependent] => { - writeln!(f, "{}", format!(" # via {}", dependent.id.name).green())?; - } - _ => { - writeln!(f, "{}", " # via".green())?; - for &dependent in dependents { - writeln!(f, "{}", format!(" # {}", dependent.id.name).green())?; - } - } - } - } - - Ok(()) - } -} - -/// A node in the graph. -#[derive(Debug, Clone, PartialEq, Eq)] -enum Node<'lock> { - Root, - Package(&'lock Package), -} - -/// An edge in the resolution graph, along with the marker that must be satisfied to traverse it. -#[derive(Debug, Clone)] -enum Edge<'lock> { - Prod(MarkerTree), - Optional(&'lock ExtraName, MarkerTree), - Dev(&'lock GroupName, MarkerTree), -} - -impl Edge<'_> { - /// Return the [`MarkerTree`] for this edge. - fn marker(&self) -> &MarkerTree { - match self { - Self::Prod(marker) => marker, - Self::Optional(_, marker) => marker, - Self::Dev(_, marker) => marker, - } - } -} - -impl Reachable for Edge<'_> { - fn true_marker() -> MarkerTree { - MarkerTree::TRUE - } - - fn false_marker() -> MarkerTree { - MarkerTree::FALSE - } - - fn marker(&self) -> MarkerTree { - *self.marker() - } -} - -/// A flat requirement, with its associated marker. -#[derive(Debug, Clone, PartialEq, Eq)] -struct Requirement<'lock> { - package: &'lock Package, - marker: MarkerTree, - dependents: Vec<&'lock Package>, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -enum RequirementComparator<'lock> { - Editable(&'lock Path), - Path(&'lock Path), - Package(&'lock PackageId), -} - -impl<'lock> From<&'lock Package> for RequirementComparator<'lock> { - fn from(value: &'lock Package) -> Self { - match &value.id.source { - Source::Path(path) | Source::Directory(path) => Self::Path(path), - Source::Editable(path) => Self::Editable(path), - _ => Self::Package(&value.id), - } - } -} - -/// Modify a relative [`Path`] to anchor it at the current working directory. -/// -/// For example, given `foo/bar`, returns `./foo/bar`. -fn anchor(path: &Path) -> Cow<'_, Path> { - match path.components().next() { - None => Cow::Owned(PathBuf::from(".")), - Some(Component::CurDir | Component::ParentDir) => Cow::Borrowed(path), - _ => Cow::Owned(PathBuf::from("./").join(path)), - } -} diff --git a/crates/uv-resolver/src/lock/export/requirements_txt.rs b/crates/uv-resolver/src/lock/export/requirements_txt.rs new file mode 100644 index 0000000000000..e38aa30309c2d --- /dev/null +++ b/crates/uv-resolver/src/lock/export/requirements_txt.rs @@ -0,0 +1,207 @@ +use std::borrow::Cow; +use std::fmt::Formatter; +use std::path::{Component, Path, PathBuf}; + +use owo_colors::OwoColorize; +use url::Url; + +use uv_configuration::{ + DependencyGroupsWithDefaults, EditableMode, ExtrasSpecification, InstallOptions, +}; +use uv_distribution_filename::{DistExtension, SourceDistExtension}; +use uv_fs::Simplified; +use uv_git_types::GitReference; +use uv_normalize::PackageName; +use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl}; + +use crate::lock::export::{ExportableRequirement, ExportableRequirements}; +use crate::lock::{Package, PackageId, Source}; +use crate::{Installable, LockError}; + +/// An export of a [`Lock`] that renders in `requirements.txt` format. +#[derive(Debug)] +pub struct RequirementsTxtExport<'lock> { + nodes: Vec>, + hashes: bool, + editable: EditableMode, +} + +impl<'lock> RequirementsTxtExport<'lock> { + pub fn from_lock( + target: &impl Installable<'lock>, + prune: &[PackageName], + extras: &ExtrasSpecification, + dev: &DependencyGroupsWithDefaults, + annotate: bool, + editable: EditableMode, + hashes: bool, + install_options: &'lock InstallOptions, + ) -> Result { + // Extract the packages from the lock file. + let ExportableRequirements(mut nodes) = ExportableRequirements::from_lock( + target, + prune, + extras, + dev, + annotate, + install_options, + ); + + // Sort the nodes, such that unnamed URLs (editables) appear at the top. + nodes.sort_unstable_by(|a, b| { + RequirementComparator::from(a.package).cmp(&RequirementComparator::from(b.package)) + }); + + Ok(Self { + nodes, + hashes, + editable, + }) + } +} + +impl std::fmt::Display for RequirementsTxtExport<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + // Write out each package. + for ExportableRequirement { + package, + marker, + dependents, + } in &self.nodes + { + match &package.id.source { + Source::Registry(_) => { + let version = package + .id + .version + .as_ref() + .expect("registry package without version"); + write!(f, "{}=={}", package.id.name, version)?; + } + Source::Git(url, git) => { + // Remove the fragment and query from the URL; they're already present in the + // `GitSource`. + let mut url = url.to_url().map_err(|_| std::fmt::Error)?; + url.set_fragment(None); + url.set_query(None); + + // Reconstruct the `GitUrl` from the `GitSource`. + let git_url = uv_git_types::GitUrl::from_commit( + url, + GitReference::from(git.kind.clone()), + git.precise, + ) + .expect("Internal Git URLs must have supported schemes"); + + // Reconstruct the PEP 508-compatible URL from the `GitSource`. + let url = Url::from(ParsedGitUrl { + url: git_url.clone(), + subdirectory: git.subdirectory.clone(), + }); + + write!(f, "{} @ {}", package.id.name, url)?; + } + Source::Direct(url, direct) => { + let url = Url::from(ParsedArchiveUrl { + url: url.to_url().map_err(|_| std::fmt::Error)?, + subdirectory: direct.subdirectory.clone(), + ext: DistExtension::Source(SourceDistExtension::TarGz), + }); + write!(f, "{} @ {}", package.id.name, url)?; + } + Source::Path(path) | Source::Directory(path) => { + if path.is_absolute() { + write!( + f, + "{}", + Url::from_file_path(path).map_err(|()| std::fmt::Error)? + )?; + } else { + write!(f, "{}", anchor(path).portable_display())?; + } + } + Source::Editable(path) => match self.editable { + EditableMode::Editable => { + write!(f, "-e {}", anchor(path).portable_display())?; + } + EditableMode::NonEditable => { + if path.is_absolute() { + write!( + f, + "{}", + Url::from_file_path(path).map_err(|()| std::fmt::Error)? + )?; + } else { + write!(f, "{}", anchor(path).portable_display())?; + } + } + }, + Source::Virtual(_) => { + continue; + } + } + + if let Some(contents) = marker.contents() { + write!(f, " ; {contents}")?; + } + + if self.hashes { + let mut hashes = package.hashes(); + hashes.sort_unstable(); + if !hashes.is_empty() { + for hash in hashes.iter() { + writeln!(f, " \\")?; + write!(f, " --hash=")?; + write!(f, "{hash}")?; + } + } + } + + writeln!(f)?; + + // Add "via ..." comments for all dependents. + match dependents.as_slice() { + [] => {} + [dependent] => { + writeln!(f, "{}", format!(" # via {}", dependent.id.name).green())?; + } + _ => { + writeln!(f, "{}", " # via".green())?; + for &dependent in dependents { + writeln!(f, "{}", format!(" # {}", dependent.id.name).green())?; + } + } + } + } + + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum RequirementComparator<'lock> { + Editable(&'lock Path), + Path(&'lock Path), + Package(&'lock PackageId), +} + +impl<'lock> From<&'lock Package> for RequirementComparator<'lock> { + fn from(value: &'lock Package) -> Self { + match &value.id.source { + Source::Path(path) | Source::Directory(path) => Self::Path(path), + Source::Editable(path) => Self::Editable(path), + _ => Self::Package(&value.id), + } + } +} + +/// Modify a relative [`Path`] to anchor it at the current working directory. +/// +/// For example, given `foo/bar`, returns `./foo/bar`. +fn anchor(path: &Path) -> Cow<'_, Path> { + match path.components().next() { + None => Cow::Owned(PathBuf::from(".")), + Some(Component::CurDir | Component::ParentDir) => Cow::Borrowed(path), + _ => Cow::Owned(PathBuf::from("./").join(path)), + } +} diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 13605fba306fc..e10a8dc5a4f0a 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -48,9 +48,9 @@ use uv_types::{BuildContext, HashStrategy}; use uv_workspace::WorkspaceMember; use crate::fork_strategy::ForkStrategy; +pub use crate::lock::export::RequirementsTxtExport; pub use crate::lock::installable::Installable; pub use crate::lock::map::PackageMap; -pub use crate::lock::requirements_txt::RequirementsTxtExport; pub use crate::lock::tree::TreeDisplay; use crate::requires_python::SimplifiedMarkerTree; use crate::resolution::{AnnotatedDist, ResolutionGraphNode}; @@ -60,9 +60,9 @@ use crate::{ ResolverOutput, }; +mod export; mod installable; mod map; -mod requirements_txt; mod tree; /// The current version of the lockfile format.