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                        git.lfs,
95                    )
96                    .expect("Internal Git URLs must have supported schemes");
97
98                    // Reconstruct the PEP 508-compatible URL from the `GitSource`.
99                    let url = DisplaySafeUrl::from(ParsedGitUrl {
100                        url: git_url.clone(),
101                        subdirectory: git.subdirectory.clone(),
102                    });
103
104                    write!(f, "{} @ {}", package.id.name, url)?;
105                }
106                Source::Direct(url, direct) => {
107                    let url = DisplaySafeUrl::from(ParsedArchiveUrl {
108                        url: url.to_url().map_err(|_| std::fmt::Error)?,
109                        subdirectory: direct.subdirectory.clone(),
110                        ext: DistExtension::Source(SourceDistExtension::TarGz),
111                    });
112                    write!(
113                        f,
114                        "{} @ {}",
115                        package.id.name,
116                        // TODO(zanieb): We should probably omit passwords here by default, but we
117                        // should change it in a breaking release and allow opt-in to include them.
118                        url.displayable_with_credentials()
119                    )?;
120                }
121                Source::Path(path) | Source::Directory(path) => {
122                    if path.is_absolute() {
123                        write!(
124                            f,
125                            "{}",
126                            Url::from_file_path(path).map_err(|()| std::fmt::Error)?
127                        )?;
128                    } else {
129                        write!(f, "{}", anchor(path).portable_display())?;
130                    }
131                }
132                Source::Editable(path) => match self.editable {
133                    None | Some(EditableMode::Editable) => {
134                        write!(f, "-e {}", anchor(path).portable_display())?;
135                    }
136                    Some(EditableMode::NonEditable) => {
137                        if path.is_absolute() {
138                            write!(
139                                f,
140                                "{}",
141                                Url::from_file_path(path).map_err(|()| std::fmt::Error)?
142                            )?;
143                        } else {
144                            write!(f, "{}", anchor(path).portable_display())?;
145                        }
146                    }
147                },
148                Source::Virtual(_) => {
149                    continue;
150                }
151            }
152
153            if let Some(contents) = marker.contents() {
154                write!(f, " ; {contents}")?;
155            }
156
157            if self.hashes {
158                let mut hashes = package.hashes();
159                hashes.sort_unstable();
160                if !hashes.is_empty() {
161                    for hash in hashes.iter() {
162                        writeln!(f, " \\")?;
163                        write!(f, "    --hash=")?;
164                        write!(f, "{hash}")?;
165                    }
166                }
167            }
168
169            writeln!(f)?;
170
171            // Add "via ..." comments for all dependents.
172            match dependents.as_slice() {
173                [] => {}
174                [dependent] => {
175                    writeln!(f, "{}", format!("    # via {}", dependent.id.name).green())?;
176                }
177                _ => {
178                    writeln!(f, "{}", "    # via".green())?;
179                    for &dependent in dependents {
180                        writeln!(f, "{}", format!("    #   {}", dependent.id.name).green())?;
181                    }
182                }
183            }
184        }
185
186        Ok(())
187    }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
191enum RequirementComparator<'lock> {
192    Editable(&'lock Path),
193    Path(&'lock Path),
194    Package(&'lock PackageId),
195}
196
197impl<'lock> From<&'lock Package> for RequirementComparator<'lock> {
198    fn from(value: &'lock Package) -> Self {
199        match &value.id.source {
200            Source::Path(path) | Source::Directory(path) => Self::Path(path),
201            Source::Editable(path) => Self::Editable(path),
202            _ => Self::Package(&value.id),
203        }
204    }
205}
206
207/// Modify a relative [`Path`] to anchor it at the current working directory.
208///
209/// For example, given `foo/bar`, returns `./foo/bar`.
210fn anchor(path: &Path) -> Cow<'_, Path> {
211    match path.components().next() {
212        None => Cow::Owned(PathBuf::from(".")),
213        Some(Component::CurDir | Component::ParentDir) => Cow::Borrowed(path),
214        _ => Cow::Owned(PathBuf::from("./").join(path)),
215    }
216}