uv_resolver/lock/export/
requirements_txt.rs

1use std::borrow::Cow;
2use std::fmt::Formatter;
3use std::path::{Component, Path, PathBuf};
4
5use owo_colors::OwoColorize;
6use url::Url;
7
8use uv_configuration::{
9    DependencyGroupsWithDefaults, EditableMode, ExtrasSpecificationWithDefaults, InstallOptions,
10};
11use uv_distribution_filename::{DistExtension, SourceDistExtension};
12use uv_fs::Simplified;
13use uv_git_types::GitReference;
14use uv_normalize::PackageName;
15use uv_pypi_types::{ParsedArchiveUrl, ParsedGitUrl};
16use uv_redacted::DisplaySafeUrl;
17
18use crate::lock::export::{ExportableRequirement, ExportableRequirements};
19use crate::lock::{Package, PackageId, Source};
20use crate::{Installable, LockError};
21
22/// An export of a [`Lock`] that renders in `requirements.txt` format.
23#[derive(Debug)]
24pub struct RequirementsTxtExport<'lock> {
25    nodes: Vec<ExportableRequirement<'lock>>,
26    hashes: bool,
27    editable: Option<EditableMode>,
28}
29
30impl<'lock> RequirementsTxtExport<'lock> {
31    pub fn from_lock(
32        target: &impl Installable<'lock>,
33        prune: &[PackageName],
34        extras: &ExtrasSpecificationWithDefaults,
35        dev: &DependencyGroupsWithDefaults,
36        annotate: bool,
37        editable: Option<EditableMode>,
38        hashes: bool,
39        install_options: &'lock InstallOptions,
40    ) -> Result<Self, LockError> {
41        // Extract the packages from the lock file.
42        let ExportableRequirements(mut nodes) = ExportableRequirements::from_lock(
43            target,
44            prune,
45            extras,
46            dev,
47            annotate,
48            install_options,
49        )?;
50
51        // Sort the nodes, such that unnamed URLs (editables) appear at the top.
52        nodes.sort_unstable_by(|a, b| {
53            RequirementComparator::from(a.package).cmp(&RequirementComparator::from(b.package))
54        });
55
56        Ok(Self {
57            nodes,
58            hashes,
59            editable,
60        })
61    }
62}
63
64impl std::fmt::Display for RequirementsTxtExport<'_> {
65    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
66        // Write out each package.
67        for ExportableRequirement {
68            package,
69            marker,
70            dependents,
71        } in &self.nodes
72        {
73            match &package.id.source {
74                Source::Registry(_) => {
75                    let version = package
76                        .id
77                        .version
78                        .as_ref()
79                        .expect("registry package without version");
80                    write!(f, "{}=={}", package.id.name, version)?;
81                }
82                Source::Git(url, git) => {
83                    // Remove the fragment and query from the URL; they're already present in the
84                    // `GitSource`.
85                    let mut url = url.to_url().map_err(|_| std::fmt::Error)?;
86                    url.set_fragment(None);
87                    url.set_query(None);
88
89                    // Reconstruct the `GitUrl` from the `GitSource`.
90                    let git_url = uv_git_types::GitUrl::from_commit(
91                        url,
92                        GitReference::from(git.kind.clone()),
93                        git.precise,
94                    )
95                    .expect("Internal Git URLs must have supported schemes");
96
97                    // Reconstruct the PEP 508-compatible URL from the `GitSource`.
98                    let url = DisplaySafeUrl::from(ParsedGitUrl {
99                        url: git_url.clone(),
100                        subdirectory: git.subdirectory.clone(),
101                    });
102
103                    write!(f, "{} @ {}", package.id.name, url)?;
104                }
105                Source::Direct(url, direct) => {
106                    let url = DisplaySafeUrl::from(ParsedArchiveUrl {
107                        url: url.to_url().map_err(|_| std::fmt::Error)?,
108                        subdirectory: direct.subdirectory.clone(),
109                        ext: DistExtension::Source(SourceDistExtension::TarGz),
110                    });
111                    write!(
112                        f,
113                        "{} @ {}",
114                        package.id.name,
115                        // TODO(zanieb): We should probably omit passwords here by default, but we
116                        // should change it in a breaking release and allow opt-in to include them.
117                        url.displayable_with_credentials()
118                    )?;
119                }
120                Source::Path(path) | Source::Directory(path) => {
121                    if path.is_absolute() {
122                        write!(
123                            f,
124                            "{}",
125                            Url::from_file_path(path).map_err(|()| std::fmt::Error)?
126                        )?;
127                    } else {
128                        write!(f, "{}", anchor(path).portable_display())?;
129                    }
130                }
131                Source::Editable(path) => match self.editable {
132                    None | Some(EditableMode::Editable) => {
133                        write!(f, "-e {}", anchor(path).portable_display())?;
134                    }
135                    Some(EditableMode::NonEditable) => {
136                        if path.is_absolute() {
137                            write!(
138                                f,
139                                "{}",
140                                Url::from_file_path(path).map_err(|()| std::fmt::Error)?
141                            )?;
142                        } else {
143                            write!(f, "{}", anchor(path).portable_display())?;
144                        }
145                    }
146                },
147                Source::Virtual(_) => {
148                    continue;
149                }
150            }
151
152            if let Some(contents) = marker.contents() {
153                write!(f, " ; {contents}")?;
154            }
155
156            if self.hashes {
157                let mut hashes = package.hashes();
158                hashes.sort_unstable();
159                if !hashes.is_empty() {
160                    for hash in hashes.iter() {
161                        writeln!(f, " \\")?;
162                        write!(f, "    --hash=")?;
163                        write!(f, "{hash}")?;
164                    }
165                }
166            }
167
168            writeln!(f)?;
169
170            // Add "via ..." comments for all dependents.
171            match dependents.as_slice() {
172                [] => {}
173                [dependent] => {
174                    writeln!(f, "{}", format!("    # via {}", dependent.id.name).green())?;
175                }
176                _ => {
177                    writeln!(f, "{}", "    # via".green())?;
178                    for &dependent in dependents {
179                        writeln!(f, "{}", format!("    #   {}", dependent.id.name).green())?;
180                    }
181                }
182            }
183        }
184
185        Ok(())
186    }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
190enum RequirementComparator<'lock> {
191    Editable(&'lock Path),
192    Path(&'lock Path),
193    Package(&'lock PackageId),
194}
195
196impl<'lock> From<&'lock Package> for RequirementComparator<'lock> {
197    fn from(value: &'lock Package) -> Self {
198        match &value.id.source {
199            Source::Path(path) | Source::Directory(path) => Self::Path(path),
200            Source::Editable(path) => Self::Editable(path),
201            _ => Self::Package(&value.id),
202        }
203    }
204}
205
206/// Modify a relative [`Path`] to anchor it at the current working directory.
207///
208/// For example, given `foo/bar`, returns `./foo/bar`.
209fn anchor(path: &Path) -> Cow<'_, Path> {
210    match path.components().next() {
211        None => Cow::Owned(PathBuf::from(".")),
212        Some(Component::CurDir | Component::ParentDir) => Cow::Borrowed(path),
213        _ => Cow::Owned(PathBuf::from("./").join(path)),
214    }
215}