From a30c07aaaf01615b12094e0126a1036fc478effe Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 25 Aug 2024 19:56:18 -0400 Subject: [PATCH] Add req export --- crates/uv-cli/src/lib.rs | 77 +++- crates/uv-configuration/src/export_format.rs | 11 + crates/uv-configuration/src/lib.rs | 2 + crates/uv-resolver/src/graph_ops.rs | 81 ++++ crates/uv-resolver/src/lib.rs | 5 +- crates/uv-resolver/src/lock/mod.rs | 2 + .../uv-resolver/src/lock/requirements_txt.rs | 244 ++++++++++++ crates/uv-resolver/src/resolution/display.rs | 83 +--- crates/uv-resolver/src/resolution/graph.rs | 2 +- .../src/resolution/requirements_txt.rs | 25 +- crates/uv/src/commands/mod.rs | 1 + crates/uv/src/commands/project/export.rs | 116 ++++++ crates/uv/src/commands/project/mod.rs | 1 + crates/uv/src/commands/project/sync.rs | 3 +- crates/uv/src/lib.rs | 25 ++ crates/uv/src/settings.rs | 45 ++- crates/uv/tests/common/mod.rs | 8 + crates/uv/tests/export.rs | 373 ++++++++++++++++++ 18 files changed, 1005 insertions(+), 99 deletions(-) create mode 100644 crates/uv-configuration/src/export_format.rs create mode 100644 crates/uv-resolver/src/graph_ops.rs create mode 100644 crates/uv-resolver/src/lock/requirements_txt.rs create mode 100644 crates/uv/src/commands/project/export.rs create mode 100644 crates/uv/tests/export.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 1cdefd25e1772..c121c7ab2a8c4 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -12,8 +12,8 @@ use pep508_rs::Requirement; use pypi_types::VerbatimParsedUrl; use uv_cache::CacheArgs; use uv_configuration::{ - ConfigSettingEntry, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple, - TrustedHost, + ConfigSettingEntry, ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier, + TargetTriple, TrustedHost, }; use uv_normalize::{ExtraName, PackageName}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; @@ -654,6 +654,23 @@ pub enum ProjectCommand { after_long_help = "" )] Lock(LockArgs), + /// Export the lockfile to an alternate format. + /// + /// At present, only `requirements.txt` is supported. + /// + /// The project is re-locked before exporting unless the `--locked` or `--frozen` flag is + /// provided. + /// + /// uv will search for a project in the current directory or any parent directory. If a project + /// cannot be found, uv will exit with an error. + /// + /// If operating in a workspace, the root will be exported by default; however, a specific + /// member can be selected using the `--package` option. + #[command( + after_help = "Use `uv help export` for more details.", + after_long_help = "" + )] + Export(ExportArgs), /// Display the project's dependency tree. Tree(TreeArgs), } @@ -2753,6 +2770,62 @@ pub struct TreeArgs { pub python: Option, } +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +pub struct ExportArgs { + /// Export the dependencies for a specific package in the workspace. + /// + /// If not in a workspace, or if the workspace member does not exist, uv + /// will exit with an error. + #[arg(long)] + pub package: Option, + + /// The format to which `uv.lock` should be exported. + /// + /// At present, only `requirements.txt` is supported. + #[arg(long)] + pub format: ExportFormat, + + /// Assert that the `uv.lock` will remain unchanged. + /// + /// Requires that the lockfile is up-to-date. If the lockfile is missing or + /// needs to be updated, uv will exit with an error. + #[arg(long, conflicts_with = "frozen")] + pub locked: bool, + + /// Assert that a `uv.lock` exists, without updating it. + #[arg(long, conflicts_with = "locked")] + pub frozen: bool, + + #[command(flatten)] + pub resolver: ResolverArgs, + + #[command(flatten)] + pub build: BuildArgs, + + #[command(flatten)] + pub refresh: RefreshArgs, + + /// The Python interpreter to use during resolution. + /// + /// A Python interpreter is required for building source distributions to + /// determine package metadata when there are not wheels. + /// + /// The interpreter is also used as the fallback value for the minimum + /// Python version if `requires-python` is not set. + /// + /// See `uv help python` for details on Python discovery and supported + /// request formats. + #[arg( + long, + short, + env = "UV_PYTHON", + verbatim_doc_comment, + help_heading = "Python options" + )] + pub python: Option, +} + #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct ToolNamespace { diff --git a/crates/uv-configuration/src/export_format.rs b/crates/uv-configuration/src/export_format.rs new file mode 100644 index 0000000000000..cec476188decb --- /dev/null +++ b/crates/uv-configuration/src/export_format.rs @@ -0,0 +1,11 @@ +/// The format to use when exporting a `uv.lock` file. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum ExportFormat { + /// Export as `requirements.txt`. + #[default] + #[serde(rename = "requirements.txt")] + RequirementsTxt, +} diff --git a/crates/uv-configuration/src/lib.rs b/crates/uv-configuration/src/lib.rs index bdfdf67f5cbbc..fad24007e19f4 100644 --- a/crates/uv-configuration/src/lib.rs +++ b/crates/uv-configuration/src/lib.rs @@ -3,6 +3,7 @@ pub use build_options::*; pub use concurrency::*; pub use config_settings::*; pub use constraints::*; +pub use export_format::*; pub use extras::*; pub use hash::*; pub use install_options::*; @@ -19,6 +20,7 @@ mod build_options; mod concurrency; mod config_settings; mod constraints; +mod export_format; mod extras; mod hash; mod install_options; diff --git a/crates/uv-resolver/src/graph_ops.rs b/crates/uv-resolver/src/graph_ops.rs new file mode 100644 index 0000000000000..4e08e097d84c0 --- /dev/null +++ b/crates/uv-resolver/src/graph_ops.rs @@ -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( + mut graph: Graph, +) -> Graph { + // 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::>(); + 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 +} diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index a21a3ebaa1ba7..b66e0dc4383ca 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -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; diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 1e8de4e656729..887e90772ef04 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -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. diff --git a/crates/uv-resolver/src/lock/requirements_txt.rs b/crates/uv-resolver/src/lock/requirements_txt.rs new file mode 100644 index 0000000000000..e35ef9087e9da --- /dev/null +++ b/crates/uv-resolver/src/lock/requirements_txt.rs @@ -0,0 +1,244 @@ +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_fs::Simplified; +use uv_git::GitReference; +use uv_normalize::{ExtraName, PackageName}; + +use crate::graph_ops::{propagate_markers, Markers}; +use crate::lock::{Package, PackageId, Source}; +use crate::{Lock, LockError}; + +type LockGraph = Graph; + +/// An export of a [`Lock`] that renders in `requirements.txt` format. +#[derive(Debug)] +pub struct RequirementsTxtExport(LockGraph); + +impl RequirementsTxtExport { + pub fn from_lock(lock: &Lock, package_root: &PackageName) -> Result { + 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(); + + // Traverse the graph, starting from root, and adding all nodes and edges. + let root = lock + .find_by_name(package_root) + .expect("found too many packages matching root") + .expect("could not find root"); + + // Add the base package to the graph. + inverse.insert( + &root.id, + petgraph.add_node(Node::from_package(root.clone())), + ); + + // Add the base package. + queue.push_back((root, None)); + + // Add the extras. + for extra in root.optional_dependencies.keys() { + queue.push_back((root, Some(extra))); + } + + // 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()) + }; + + 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.clone()))); + } + + // 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::>(); + + // 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) => { + if path.as_os_str().is_empty() { + write!(f, "{} @ .", package.id.name)?; + } else { + write!(f, "{} @ {}", package.id.name, path.portable_display())?; + } + } + Source::Directory(path) => { + if path.as_os_str().is_empty() { + write!(f, "{} @ .", package.id.name)?; + } else { + write!(f, "{} @ {}", package.id.name, 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 { + package: Package, + markers: MarkerTree, +} + +impl Node { + /// Construct a [`Node`] from a [`Package`]. + fn from_package(package: 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<'a> { + Path(&'a Path), + Package(&'a PackageId), +} + +impl<'a> From<&'a Package> for NodeComparator<'a> { + fn from(value: &'a Package) -> Self { + if let Source::Editable(path) = &value.id.source { + Self::Path(path) + } else { + Self::Package(&value.id) + } + } +} diff --git a/crates/uv-resolver/src/resolution/display.rs b/crates/uv-resolver/src/resolution/display.rs index 5cbbd9edae68d..624d76aeea58a 100644 --- a/crates/uv-resolver/src/resolution/display.rs +++ b/crates/uv-resolver/src/resolution/display.rs @@ -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::>(); - 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 = { - // 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. /// diff --git a/crates/uv-resolver/src/resolution/graph.rs b/crates/uv-resolver/src/resolution/graph.rs index a4d2859323bb9..0d9bc8908c1f7 100644 --- a/crates/uv-resolver/src/resolution/graph.rs +++ b/crates/uv-resolver/src/resolution/graph.rs @@ -33,7 +33,7 @@ pub(crate) type MarkersForDistribution = FxHashMap<(Version, Option /// 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, diff --git a/crates/uv-resolver/src/resolution/requirements_txt.rs b/crates/uv-resolver/src/resolution/requirements_txt.rs index b58dd594b9437..2fa758f034b08 100644 --- a/crates/uv-resolver/src/resolution/requirements_txt.rs +++ b/crates/uv-resolver/src/resolution/requirements_txt.rs @@ -19,7 +19,7 @@ pub(crate) struct RequirementsTxtDist { pub(crate) version: Version, pub(crate) extras: Vec, pub(crate) hashes: Vec, - pub(crate) markers: Option, + 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(), } } } diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 3da2330abb1c3..d013d48b5e65e 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -19,6 +19,7 @@ pub(crate) use pip::sync::pip_sync; pub(crate) use pip::tree::pip_tree; pub(crate) use pip::uninstall::pip_uninstall; pub(crate) use project::add::add; +pub(crate) use project::export::export; pub(crate) use project::init::{init, InitProjectKind}; pub(crate) use project::lock::lock; pub(crate) use project::remove::remove; diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs new file mode 100644 index 0000000000000..930126ac25692 --- /dev/null +++ b/crates/uv/src/commands/project/export.rs @@ -0,0 +1,116 @@ +use anyhow::{Context, Result}; +use owo_colors::OwoColorize; + +use uv_cache::Cache; +use uv_client::Connectivity; +use uv_configuration::{Concurrency, ExportFormat}; +use uv_fs::CWD; +use uv_normalize::PackageName; +use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; +use uv_resolver::RequirementsTxtExport; +use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace}; + +use crate::commands::pip::loggers::DefaultResolveLogger; +use crate::commands::project::lock::do_safe_lock; +use crate::commands::project::{FoundInterpreter, ProjectError}; +use crate::commands::{pip, ExitStatus}; +use crate::printer::Printer; +use crate::settings::ResolverSettings; + +/// Export the project's `uv.lock` in an alternate format. +#[allow(clippy::fn_params_excessive_bools)] +pub(crate) async fn export( + package: Option, + format: ExportFormat, + locked: bool, + frozen: bool, + python: Option, + settings: ResolverSettings, + python_preference: PythonPreference, + python_downloads: PythonDownloads, + connectivity: Connectivity, + concurrency: Concurrency, + native_tls: bool, + cache: &Cache, + printer: Printer, +) -> Result { + // Identify the project. + let project = if let Some(package) = package { + VirtualProject::Project( + Workspace::discover(&CWD, &DiscoveryOptions::default()) + .await? + .with_current_project(package.clone()) + .with_context(|| format!("Package `{package}` not found in workspace"))?, + ) + } else if frozen { + VirtualProject::discover( + &CWD, + &DiscoveryOptions { + members: MemberDiscovery::None, + ..DiscoveryOptions::default() + }, + ) + .await? + } else { + VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await? + }; + + let VirtualProject::Project(project) = project else { + return Err(anyhow::anyhow!("Legacy non-project roots are not supported in `uv export`; add a `[project]` table to your `pyproject.toml` to enable exports")); + }; + + // Find an interpreter for the project + let interpreter = FoundInterpreter::discover( + project.workspace(), + python.as_deref().map(PythonRequest::parse), + python_preference, + python_downloads, + connectivity, + native_tls, + cache, + printer, + ) + .await? + .into_interpreter(); + + // Lock the project. + let lock = match do_safe_lock( + locked, + frozen, + project.workspace(), + &interpreter, + settings.as_ref(), + Box::new(DefaultResolveLogger), + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await + { + Ok(result) => result.into_lock(), + Err(ProjectError::Operation(pip::operations::Error::Resolve( + uv_resolver::ResolveError::NoSolution(err), + ))) => { + let report = miette::Report::msg(format!("{err}")).context(err.header()); + anstream::eprint!("{report:?}"); + return Ok(ExitStatus::Failure); + } + Err(err) => return Err(err.into()), + }; + + // Generate the export. + match format { + ExportFormat::RequirementsTxt => { + let export = RequirementsTxtExport::from_lock(&lock, project.project_name())?; + anstream::println!( + "{}", + "# This file was autogenerated via `uv export`.".green() + ); + anstream::print!("{export}"); + } + } + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 60c8ea9ceb396..47953bc1cf02f 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -38,6 +38,7 @@ use crate::settings::{InstallerSettingsRef, ResolverInstallerSettings, ResolverS pub(crate) mod add; pub(crate) mod environment; +pub(crate) mod export; pub(crate) mod init; pub(crate) mod lock; pub(crate) mod remove; diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 0739fd7a65da2..31fdbe82b748c 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; -use distribution_types::{Dist, ResolvedDist, SourceDist}; use itertools::Itertools; + +use distribution_types::{Dist, ResolvedDist, SourceDist}; use pep508_rs::MarkerTree; use uv_auth::store_credentials_from_url; use uv_cache::Cache; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 9188e793eb0ec..dff7f93351686 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1262,6 +1262,31 @@ async fn run_project( ) .await } + ProjectCommand::Export(args) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::ExportSettings::resolve(args, filesystem); + show_settings!(args); + + // Initialize the cache. + let cache = cache.init()?; + + commands::export( + args.package, + args.format, + args.locked, + args.frozen, + args.python, + args.settings, + globals.python_preference, + globals.python_downloads, + globals.connectivity, + globals.concurrency, + globals.native_tls, + &cache, + printer, + ) + .await + } } } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 8e981ce5e87f8..b16248de396ce 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -11,7 +11,7 @@ use pypi_types::{Requirement, SupportedEnvironments}; use uv_cache::{CacheArgs, Refresh}; use uv_cli::{ options::{flag, resolver_installer_options, resolver_options}, - ToolUpgradeArgs, + ExportArgs, ToolUpgradeArgs, }; use uv_cli::{ AddArgs, ColorChoice, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe, @@ -22,7 +22,7 @@ use uv_cli::{ }; use uv_client::Connectivity; use uv_configuration::{ - BuildOptions, Concurrency, ConfigSettings, ExtrasSpecification, HashCheckingMode, + BuildOptions, Concurrency, ConfigSettings, ExportFormat, ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions, KeyringProviderType, NoBinary, NoBuild, PreviewMode, Reinstall, SourceStrategy, TargetTriple, TrustedHost, Upgrade, }; @@ -935,6 +935,47 @@ impl TreeSettings { } } } + +/// The resolved settings to use for an `export` invocation. +#[allow(clippy::struct_excessive_bools, dead_code)] +#[derive(Debug, Clone)] +pub(crate) struct ExportSettings { + pub(crate) package: Option, + pub(crate) format: ExportFormat, + pub(crate) locked: bool, + pub(crate) frozen: bool, + pub(crate) python: Option, + pub(crate) refresh: Refresh, + pub(crate) settings: ResolverSettings, +} + +impl ExportSettings { + /// Resolve the [`ExportSettings`] from the CLI and filesystem configuration. + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn resolve(args: ExportArgs, filesystem: Option) -> Self { + let ExportArgs { + package, + format, + locked, + frozen, + resolver, + build, + refresh, + python, + } = args; + + Self { + package, + format, + locked, + frozen, + python, + refresh: Refresh::from(refresh), + settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem), + } + } +} + /// The resolved settings to use for a `pip compile` invocation. #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index ec187573b3189..0a1ea9a4f2a4a 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -492,6 +492,14 @@ impl TestContext { command } + /// Create a `uv export` command with options shared across scenarios. + pub fn export(&self) -> Command { + let mut command = Command::new(get_bin()); + command.arg("export"); + self.add_shared_args(&mut command); + command + } + /// Create a `uv python find` command with options shared across scenarios. pub fn python_find(&self) -> Command { let mut command = Command::new(get_bin()); diff --git a/crates/uv/tests/export.rs b/crates/uv/tests/export.rs new file mode 100644 index 0000000000000..24e3b0849fff1 --- /dev/null +++ b/crates/uv/tests/export.rs @@ -0,0 +1,373 @@ +#![cfg(all(feature = "python", feature = "pypi"))] + +use std::io::BufReader; + +use anyhow::Result; +use assert_cmd::assert::OutputAssertExt; +use assert_fs::prelude::*; +use indoc::{formatdoc, indoc}; +use insta::assert_snapshot; +use url::Url; + +use common::{uv_snapshot, TestContext}; +use uv_fs::Simplified; + +use crate::common::{build_vendor_links_url, decode_token, packse_index_url}; + +mod common; + +#[test] +fn dependency() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio==3.7.0"] + + [build-system] + requires = ["setuptools>=42", "wheel"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("requirements-txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated via `uv export`. + -e . + anyio==3.7.0 \ + --hash=sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce \ + --hash=sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0 + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + sniffio==1.3.1 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 + + ----- stderr ----- + Resolved 4 packages in [TIME] + "###); + + Ok(()) +} + +#[test] +fn dependency_extra() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["flask[dotenv]"] + + [build-system] + requires = ["setuptools>=42", "wheel"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("requirements-txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated via `uv export`. + -e . + blinker==1.7.0 \ + --hash=sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182 \ + --hash=sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9 + click==8.1.7 \ + --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de \ + --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 + colorama==0.4.6 ; platform_system == 'Windows' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + flask==3.0.2 \ + --hash=sha256:822c03f4b799204250a7ee84b1eddc40665395333973dfb9deebfe425fefcb7d \ + --hash=sha256:3232e0e9c850d781933cf0207523d1ece087eb8d87b23777ae38456e2fbe7c6e + itsdangerous==2.1.2 \ + --hash=sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a \ + --hash=sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44 + jinja2==3.1.3 \ + --hash=sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90 \ + --hash=sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa + markupsafe==2.1.5 \ + --hash=sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b \ + --hash=sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1 \ + --hash=sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4 \ + --hash=sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee \ + --hash=sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5 \ + --hash=sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b \ + --hash=sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a \ + --hash=sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f \ + --hash=sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169 \ + --hash=sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad \ + --hash=sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb + python-dotenv==1.0.1 \ + --hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \ + --hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a + werkzeug==3.0.1 \ + --hash=sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc \ + --hash=sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10 + + ----- stderr ----- + Resolved 10 packages in [TIME] + "###); + + Ok(()) +} + +#[test] +fn project_extra() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + async = ["anyio==3.7.0"] + + [build-system] + requires = ["setuptools>=42", "wheel"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("requirements-txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated via `uv export`. + -e . + anyio==3.7.0 \ + --hash=sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce \ + --hash=sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0 + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + sniffio==1.3.1 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 + + ----- stderr ----- + Resolved 4 packages in [TIME] + "###); + + Ok(()) +} + +#[test] +fn dependency_marker() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio ; sys_platform == 'darwin'", "iniconfig"] + + [build-system] + requires = ["setuptools>=42", "wheel"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("requirements-txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated via `uv export`. + -e . + anyio==4.3.0 ; sys_platform == 'darwin' \ + --hash=sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6 \ + --hash=sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8 + idna==3.6 ; sys_platform == 'darwin' \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + iniconfig==2.0.0 \ + --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ + --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + sniffio==1.3.1 ; sys_platform == 'darwin' \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 + + ----- stderr ----- + Resolved 5 packages in [TIME] + "###); + + Ok(()) +} + +#[test] +fn dependency_multiple_markers() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "trio ; python_version > '3.11'", + "trio ; sys_platform == 'win32'", + ] + + [build-system] + requires = ["setuptools>=42", "wheel"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("requirements-txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated via `uv export`. + -e . + attrs==23.2.0 \ + --hash=sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30 \ + --hash=sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1 + cffi==1.16.0 ; implementation_name != 'pypy' and os_name == 'nt' \ + --hash=sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0 \ + --hash=sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956 \ + --hash=sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e \ + --hash=sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e \ + --hash=sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2 \ + --hash=sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357 \ + --hash=sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6 \ + --hash=sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969 \ + --hash=sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520 \ + --hash=sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b \ + --hash=sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235 + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + outcome==1.3.0.post0 \ + --hash=sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8 \ + --hash=sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b + pycparser==2.21 ; implementation_name != 'pypy' and os_name == 'nt' \ + --hash=sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206 \ + --hash=sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9 + sniffio==1.3.1 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 + sortedcontainers==2.4.0 \ + --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ + --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 + trio==0.25.0 \ + --hash=sha256:9b41f5993ad2c0e5f62d0acca320ec657fdb6b2a2c22b8c7aed6caf154475c4e \ + --hash=sha256:e6458efe29cc543e557a91e614e2b51710eba2961669329ce9c862d50c6e8e81 + + ----- stderr ----- + Resolved 9 packages in [TIME] + "###); + + Ok(()) +} + +#[test] +fn dependency_conflicting_markers() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "trio==0.25.0 ; sys_platform == 'darwin'", + "trio==0.10.0 ; sys_platform == 'win32'", + ] + + [build-system] + requires = ["setuptools>=42", "wheel"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("requirements-txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated via `uv export`. + -e . + async-generator==1.10 ; sys_platform == 'win32' \ + --hash=sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144 \ + --hash=sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b + attrs==23.2.0 ; sys_platform == 'darwin' or sys_platform == 'win32' \ + --hash=sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30 \ + --hash=sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1 + cffi==1.16.0 ; (implementation_name != 'pypy' and os_name == 'nt' and sys_platform == 'darwin') or (os_name == 'nt' and sys_platform == 'win32') \ + --hash=sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0 \ + --hash=sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956 \ + --hash=sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e \ + --hash=sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e \ + --hash=sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2 \ + --hash=sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357 \ + --hash=sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6 \ + --hash=sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969 \ + --hash=sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520 \ + --hash=sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b \ + --hash=sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235 + idna==3.6 ; sys_platform == 'darwin' or sys_platform == 'win32' \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + outcome==1.3.0.post0 ; sys_platform == 'darwin' or sys_platform == 'win32' \ + --hash=sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8 \ + --hash=sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b + pycparser==2.21 ; (implementation_name != 'pypy' and os_name == 'nt' and sys_platform == 'darwin') or (os_name == 'nt' and sys_platform == 'win32') \ + --hash=sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206 \ + --hash=sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9 + sniffio==1.3.1 ; sys_platform == 'darwin' or sys_platform == 'win32' \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 + sortedcontainers==2.4.0 ; sys_platform == 'darwin' or sys_platform == 'win32' \ + --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ + --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 + trio==0.10.0 ; sys_platform == 'win32' \ + --hash=sha256:d323cc15f6406d15954af91e5e34af2001cc24163fdde29e3f88a227a1b53ab0 + trio==0.25.0 ; sys_platform == 'darwin' \ + --hash=sha256:9b41f5993ad2c0e5f62d0acca320ec657fdb6b2a2c22b8c7aed6caf154475c4e \ + --hash=sha256:e6458efe29cc543e557a91e614e2b51710eba2961669329ce9c862d50c6e8e81 + + ----- stderr ----- + Resolved 11 packages in [TIME] + "###); + + Ok(()) +}