Skip to main content

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