Add uv export --format requirements.txt (#6778)

## Summary

The interface here is intentionally a bit more limited than `uv pip
compile`, because we don't want `requirements.txt` to be a system of
record -- it's just an export format. So, we don't write annotation
comments (i.e., which dependency is requested from which), we don't
allow writing extras, etc. It's just a flat list of requirements, with
their markers and hashes.

Closes #6007.

Closes #6668.

Closes #6670.
This commit is contained in:
Charlie Marsh 2024-08-29 13:46:42 -04:00 committed by GitHub
parent 670e9603ee
commit cbfc928a9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1610 additions and 105 deletions

View file

@ -0,0 +1,81 @@
use pep508_rs::MarkerTree;
use petgraph::algo::greedy_feedback_arc_set;
use petgraph::visit::{EdgeRef, Topo};
use petgraph::{Directed, Direction, Graph};
/// A trait for a graph node that can be annotated with a [`MarkerTree`].
pub(crate) trait Markers {
fn set_markers(&mut self, markers: MarkerTree);
}
/// Propagate the [`MarkerTree`] qualifiers across the graph.
///
/// The graph is directed, so if any edge contains a marker, we need to propagate it to all
/// downstream nodes.
pub(crate) fn propagate_markers<T: Markers>(
mut graph: Graph<T, MarkerTree, Directed>,
) -> Graph<T, MarkerTree, Directed> {
// Remove any cycles. By absorption, it should be fine to ignore cycles.
//
// Imagine a graph: `A -> B -> C -> A`. Assume that `A` has weight `1`, `B` has weight `2`,
// and `C` has weight `3`. The weights are the marker trees.
//
// When propagating, we'd return to `A` when we hit the cycle, to create `1 or (1 and 2 and 3)`,
// which resolves to `1`.
//
// TODO(charlie): The above reasoning could be incorrect. Consider using a graph algorithm that
// can handle weight propagation with cycles.
let edges = {
let mut fas = greedy_feedback_arc_set(&graph)
.map(|edge| edge.id())
.collect::<Vec<_>>();
fas.sort_unstable();
let mut edges = Vec::with_capacity(fas.len());
for edge_id in fas.into_iter().rev() {
edges.push(graph.edge_endpoints(edge_id).unwrap());
graph.remove_edge(edge_id);
}
edges
};
let mut topo = Topo::new(&graph);
while let Some(index) = topo.next(&graph) {
let marker_tree = {
// Fold over the edges to combine the marker trees. If any edge is `None`, then
// the combined marker tree is `None`.
let mut edges = graph.edges_directed(index, Direction::Incoming);
edges
.next()
.and_then(|edge| graph.edge_weight(edge.id()).cloned())
.and_then(|initial| {
edges.try_fold(initial, |mut acc, edge| {
acc.or(graph.edge_weight(edge.id())?.clone());
Some(acc)
})
})
.unwrap_or_default()
};
// Propagate the marker tree to all downstream nodes.
let mut walker = graph
.neighbors_directed(index, Direction::Outgoing)
.detach();
while let Some((outgoing, _)) = walker.next(&graph) {
if let Some(weight) = graph.edge_weight_mut(outgoing) {
weight.and(marker_tree.clone());
}
}
let node = &mut graph[index];
node.set_markers(marker_tree);
}
// Re-add the removed edges. We no longer care about the edge _weights_, but we do want the
// edges to be present, to power the `# via` annotations.
for (source, target) in edges {
graph.add_edge(source, target, MarkerTree::TRUE);
}
graph
}

View file

@ -3,7 +3,9 @@ pub use error::{NoSolutionError, NoSolutionHeader, ResolveError};
pub use exclude_newer::ExcludeNewer;
pub use exclusions::Exclusions;
pub use flat_index::FlatIndex;
pub use lock::{Lock, LockError, ResolverManifest, SatisfiesResult, TreeDisplay};
pub use lock::{
Lock, LockError, RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay,
};
pub use manifest::Manifest;
pub use options::{Options, OptionsBuilder};
pub use preferences::{Preference, PreferenceError, Preferences};
@ -31,6 +33,7 @@ mod exclude_newer;
mod exclusions;
mod flat_index;
mod fork_urls;
mod graph_ops;
mod lock;
mod manifest;
mod marker;

View file

@ -37,10 +37,12 @@ use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_types::BuildContext;
use uv_workspace::{VirtualProject, Workspace};
pub use crate::lock::requirements_txt::RequirementsTxtExport;
pub use crate::lock::tree::TreeDisplay;
use crate::resolution::{AnnotatedDist, ResolutionGraphNode};
use crate::{ExcludeNewer, PrereleaseMode, RequiresPython, ResolutionGraph, ResolutionMode};
mod requirements_txt;
mod tree;
/// The current version of the lockfile format.

View file

@ -0,0 +1,257 @@
use std::collections::hash_map::Entry;
use std::collections::VecDeque;
use std::fmt::Formatter;
use std::path::{Path, PathBuf};
use either::Either;
use petgraph::{Directed, Graph};
use rustc_hash::{FxHashMap, FxHashSet};
use url::Url;
use distribution_filename::{DistExtension, SourceDistExtension};
use pep508_rs::MarkerTree;
use pypi_types::{ParsedArchiveUrl, ParsedGitUrl};
use uv_configuration::ExtrasSpecification;
use uv_fs::Simplified;
use uv_git::GitReference;
use uv_normalize::{ExtraName, GroupName, PackageName};
use crate::graph_ops::{propagate_markers, Markers};
use crate::lock::{Package, PackageId, Source};
use crate::{Lock, LockError};
type LockGraph<'lock> = Graph<Node<'lock>, Edge, Directed>;
/// An export of a [`Lock`] that renders in `requirements.txt` format.
#[derive(Debug)]
pub struct RequirementsTxtExport<'lock>(LockGraph<'lock>);
impl<'lock> RequirementsTxtExport<'lock> {
pub fn from_lock(
lock: &'lock Lock,
root_name: &PackageName,
extras: &ExtrasSpecification,
dev: &[GroupName],
) -> Result<Self, LockError> {
let size_guess = lock.packages.len();
let mut petgraph = LockGraph::with_capacity(size_guess, size_guess);
let mut queue: VecDeque<(&Package, Option<&ExtraName>)> = VecDeque::new();
let mut inverse = FxHashMap::default();
// Add the workspace package to the queue.
let root = lock
.find_by_name(root_name)
.expect("found too many packages matching root")
.expect("could not find root");
// Add the base package.
queue.push_back((root, None));
// Add any extras.
match extras {
ExtrasSpecification::None => {}
ExtrasSpecification::All => {
for extra in root.optional_dependencies.keys() {
queue.push_back((root, Some(extra)));
}
}
ExtrasSpecification::Some(extras) => {
for extra in extras {
queue.push_back((root, Some(extra)));
}
}
}
// Add the root package to the graph.
inverse.insert(&root.id, petgraph.add_node(Node::from_package(root)));
// Create all the relevant nodes.
let mut seen = FxHashSet::default();
while let Some((package, extra)) = queue.pop_front() {
let index = inverse[&package.id];
let deps = if let Some(extra) = extra {
Either::Left(
package
.optional_dependencies
.get(extra)
.into_iter()
.flatten(),
)
} else {
Either::Right(package.dependencies.iter().chain(
dev.iter().flat_map(|group| {
package.dev_dependencies.get(group).into_iter().flatten()
}),
))
};
for dep in deps {
let dep_dist = lock.find_by_id(&dep.package_id);
// Add the dependency to the graph.
if let Entry::Vacant(entry) = inverse.entry(&dep.package_id) {
entry.insert(petgraph.add_node(Node::from_package(dep_dist)));
}
// Add the edge.
let dep_index = inverse[&dep.package_id];
petgraph.add_edge(index, dep_index, dep.marker.clone());
// Push its dependencies on the queue.
if seen.insert((&dep.package_id, None)) {
queue.push_back((dep_dist, None));
}
for extra in &dep.extra {
if seen.insert((&dep.package_id, Some(extra))) {
queue.push_back((dep_dist, Some(extra)));
}
}
}
}
let graph = propagate_markers(petgraph);
Ok(Self(graph))
}
}
impl std::fmt::Display for RequirementsTxtExport<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
// Collect all packages.
let mut nodes = self
.0
.raw_nodes()
.iter()
.map(|node| &node.weight)
.collect::<Vec<_>>();
// Sort the nodes, such that unnamed URLs (editables) appear at the top.
nodes.sort_unstable_by(|a, b| {
NodeComparator::from(a.package).cmp(&NodeComparator::from(b.package))
});
// Write out each node.
for node in nodes {
let Node { package, markers } = node;
match &package.id.source {
Source::Registry(_) => {
write!(f, "{}=={}", package.id.name, package.id.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();
url.set_fragment(None);
url.set_query(None);
// Reconstruct the `GitUrl` from the `GitSource`.
let git_url = uv_git::GitUrl::from_commit(
url,
GitReference::from(git.kind.clone()),
git.precise,
);
// Reconstruct the PEP 508-compatible URL from the `GitSource`.
let url = Url::from(ParsedGitUrl {
url: git_url.clone(),
subdirectory: git.subdirectory.as_ref().map(PathBuf::from),
});
write!(f, "{} @ {}", package.id.name, url)?;
}
Source::Direct(url, direct) => {
let subdirectory = direct.subdirectory.as_ref().map(PathBuf::from);
let url = Url::from(ParsedArchiveUrl {
url: url.to_url(),
subdirectory: subdirectory.clone(),
ext: DistExtension::Source(SourceDistExtension::TarGz),
});
write!(f, "{} @ {}", package.id.name, url)?;
}
Source::Path(path) | Source::Directory(path) => {
if path.as_os_str().is_empty() {
write!(f, ".")?;
} else if path.is_absolute() {
write!(f, "{}", Url::from_file_path(path).unwrap())?;
} else {
write!(f, "{}", path.portable_display())?;
}
}
Source::Editable(path) => {
if path.as_os_str().is_empty() {
write!(f, "-e .")?;
} else {
write!(f, "-e {}", path.portable_display())?;
}
}
Source::Virtual(_) => {
continue;
}
}
if let Some(contents) = markers.contents() {
write!(f, " ; {contents}")?;
}
let hashes = package.hashes();
if !hashes.is_empty() {
for hash in &hashes {
writeln!(f, " \\")?;
write!(f, " --hash=")?;
write!(f, "{hash}")?;
}
}
writeln!(f)?;
}
Ok(())
}
}
/// The nodes of the [`LockGraph`].
#[derive(Debug)]
struct Node<'lock> {
package: &'lock Package,
markers: MarkerTree,
}
impl<'lock> Node<'lock> {
/// Construct a [`Node`] from a [`Package`].
fn from_package(package: &'lock Package) -> Self {
Self {
package,
markers: MarkerTree::default(),
}
}
}
impl Markers for Node<'_> {
fn set_markers(&mut self, markers: MarkerTree) {
self.markers = markers;
}
}
/// The edges of the [`LockGraph`].
type Edge = MarkerTree;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum NodeComparator<'lock> {
Editable(&'lock Path),
Path(&'lock Path),
Package(&'lock PackageId),
}
impl<'lock> From<&'lock Package> for NodeComparator<'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),
}
}
}

View file

@ -1,8 +1,7 @@
use std::collections::BTreeSet;
use owo_colors::OwoColorize;
use petgraph::algo::greedy_feedback_arc_set;
use petgraph::visit::{EdgeRef, Topo};
use petgraph::visit::EdgeRef;
use petgraph::Direction;
use rustc_hash::{FxBuildHasher, FxHashMap};
@ -10,6 +9,7 @@ use distribution_types::{DistributionMetadata, Name, SourceAnnotation, SourceAnn
use pep508_rs::MarkerTree;
use uv_normalize::PackageName;
use crate::graph_ops::{propagate_markers, Markers};
use crate::resolution::{RequirementsTxtDist, ResolutionGraphNode};
use crate::{ResolutionGraph, ResolverMarkers};
@ -49,6 +49,14 @@ enum DisplayResolutionGraphNode {
Dist(RequirementsTxtDist),
}
impl Markers for DisplayResolutionGraphNode {
fn set_markers(&mut self, markers: MarkerTree) {
if let DisplayResolutionGraphNode::Dist(node) = self {
node.markers = markers;
}
}
}
impl<'a> From<&'a ResolutionGraph> for DisplayResolutionGraph<'a> {
fn from(resolution: &'a ResolutionGraph) -> Self {
Self::new(
@ -348,77 +356,6 @@ fn to_requirements_txt_graph(graph: &ResolutionPetGraph) -> IntermediatePetGraph
next
}
/// Propagate the [`MarkerTree`] qualifiers across the graph.
///
/// The graph is directed, so if any edge contains a marker, we need to propagate it to all
/// downstream nodes.
fn propagate_markers(mut graph: IntermediatePetGraph) -> IntermediatePetGraph {
// Remove any cycles. By absorption, it should be fine to ignore cycles.
//
// Imagine a graph: `A -> B -> C -> A`. Assume that `A` has weight `1`, `B` has weight `2`,
// and `C` has weight `3`. The weights are the marker trees.
//
// When propagating, we'd return to `A` when we hit the cycle, to create `1 or (1 and 2 and 3)`,
// which resolves to `1`.
//
// TODO(charlie): The above reasoning could be incorrect. Consider using a graph algorithm that
// can handle weight propagation with cycles.
let edges = {
let mut fas = greedy_feedback_arc_set(&graph)
.map(|edge| edge.id())
.collect::<Vec<_>>();
fas.sort_unstable();
let mut edges = Vec::with_capacity(fas.len());
for edge_id in fas.into_iter().rev() {
edges.push(graph.edge_endpoints(edge_id).unwrap());
graph.remove_edge(edge_id);
}
edges
};
let mut topo = Topo::new(&graph);
while let Some(index) = topo.next(&graph) {
let marker_tree: Option<MarkerTree> = {
// Fold over the edges to combine the marker trees. If any edge is `None`, then
// the combined marker tree is `None`.
let mut edges = graph.edges_directed(index, Direction::Incoming);
edges
.next()
.and_then(|edge| graph.edge_weight(edge.id()).cloned())
.and_then(|initial| {
edges.try_fold(initial, |mut acc, edge| {
acc.or(graph.edge_weight(edge.id())?.clone());
Some(acc)
})
})
};
// Propagate the marker tree to all downstream nodes.
if let Some(marker_tree) = marker_tree.as_ref() {
let mut walker = graph
.neighbors_directed(index, Direction::Outgoing)
.detach();
while let Some((outgoing, _)) = walker.next(&graph) {
if let Some(weight) = graph.edge_weight_mut(outgoing) {
weight.and(marker_tree.clone());
}
}
}
if let DisplayResolutionGraphNode::Dist(node) = &mut graph[index] {
node.markers = marker_tree;
};
}
// Re-add the removed edges. We no longer care about the edge _weights_, but we do want the
// edges to be present, to power the `# via` annotations.
for (source, target) in edges {
graph.add_edge(source, target, MarkerTree::TRUE);
}
graph
}
/// Reduce the graph, such that all nodes for a single package are combined, regardless of
/// the extras.
///

View file

@ -33,7 +33,7 @@ pub(crate) type MarkersForDistribution = FxHashMap<(Version, Option<VerbatimUrl>
/// A complete resolution graph in which every node represents a pinned package and every edge
/// represents a dependency between two pinned packages.
#[derive(Debug)]
#[derive(Debug, Default)]
pub struct ResolutionGraph {
/// The underlying graph.
pub(crate) petgraph: Graph<ResolutionGraphNode, MarkerTree, Directed>,

View file

@ -19,7 +19,7 @@ pub(crate) struct RequirementsTxtDist {
pub(crate) version: Version,
pub(crate) extras: Vec<ExtraName>,
pub(crate) hashes: Vec<HashDigest>,
pub(crate) markers: Option<MarkerTree>,
pub(crate) markers: MarkerTree,
}
impl RequirementsTxtDist {
@ -89,11 +89,8 @@ impl RequirementsTxtDist {
}
};
if let Some(given) = given {
return if let Some(markers) = self
.markers
.as_ref()
.filter(|_| include_markers)
.and_then(MarkerTree::contents)
return if let Some(markers) =
self.markers.contents().filter(|_| include_markers)
{
Cow::Owned(format!("{given} ; {markers}"))
} else {
@ -104,12 +101,7 @@ impl RequirementsTxtDist {
}
if self.extras.is_empty() || !include_extras {
if let Some(markers) = self
.markers
.as_ref()
.filter(|_| include_markers)
.and_then(MarkerTree::contents)
{
if let Some(markers) = self.markers.contents().filter(|_| include_markers) {
Cow::Owned(format!("{} ; {}", self.dist.verbatim(), markers))
} else {
self.dist.verbatim()
@ -118,12 +110,7 @@ impl RequirementsTxtDist {
let mut extras = self.extras.clone();
extras.sort_unstable();
extras.dedup();
if let Some(markers) = self
.markers
.as_ref()
.filter(|_| include_markers)
.and_then(MarkerTree::contents)
{
if let Some(markers) = self.markers.contents().filter(|_| include_markers) {
Cow::Owned(format!(
"{}[{}]{} ; {}",
self.name(),
@ -176,7 +163,7 @@ impl From<&AnnotatedDist> for RequirementsTxtDist {
vec![]
},
hashes: annotated.hashes.clone(),
markers: None,
markers: MarkerTree::default(),
}
}
}