Skip to content

Commit 549fb12

Browse files
authored
Support recursive requirements and constraints inclusion (#15657)
uv currently panics with a stack overflow when requirements or constraints are recursively included. Instead, we ignore files we have already seen. The one complexity here is that we have to track whether we're in a requirements inclusion or in a constraints inclusion, to allow including a file separately for requirements and for constraints, and to handle `-r` inside or `-c` (which we treat as constraints too). Fixes #15650
1 parent b68161f commit 549fb12

File tree

4 files changed

+203
-19
lines changed

4 files changed

+203
-19
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/uv-requirements-txt/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ fs-err = { workspace = true }
3030
memchr = { workspace = true }
3131
reqwest = { workspace = true, optional = true }
3232
reqwest-middleware = { workspace = true, optional = true }
33+
rustc-hash = { workspace = true }
3334
thiserror = { workspace = true }
3435
tracing = { workspace = true }
3536
unscanny = { workspace = true }

crates/uv-requirements-txt/src/lib.rs

Lines changed: 191 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ use std::io;
4040
use std::path::{Path, PathBuf};
4141
use std::str::FromStr;
4242

43+
use rustc_hash::FxHashSet;
4344
use tracing::instrument;
4445
use unscanny::{Pattern, Scanner};
4546
use url::Url;
@@ -169,6 +170,24 @@ impl RequirementsTxt {
169170
requirements_txt: impl AsRef<Path>,
170171
working_dir: impl AsRef<Path>,
171172
client_builder: &BaseClientBuilder<'_>,
173+
) -> Result<Self, RequirementsTxtFileError> {
174+
let mut visited = VisitedFiles::Requirements {
175+
requirements: &mut FxHashSet::default(),
176+
constraints: &mut FxHashSet::default(),
177+
};
178+
Self::parse_impl(requirements_txt, working_dir, client_builder, &mut visited).await
179+
}
180+
181+
/// See module level documentation
182+
#[instrument(
183+
skip_all,
184+
fields(requirements_txt = requirements_txt.as_ref().as_os_str().to_str())
185+
)]
186+
async fn parse_impl(
187+
requirements_txt: impl AsRef<Path>,
188+
working_dir: impl AsRef<Path>,
189+
client_builder: &BaseClientBuilder<'_>,
190+
visited: &mut VisitedFiles<'_>,
172191
) -> Result<Self, RequirementsTxtFileError> {
173192
let requirements_txt = requirements_txt.as_ref();
174193
let working_dir = working_dir.as_ref();
@@ -220,6 +239,7 @@ impl RequirementsTxt {
220239
requirements_dir,
221240
client_builder,
222241
requirements_txt,
242+
visited,
223243
)
224244
.await
225245
.map_err(|err| RequirementsTxtFileError {
@@ -236,12 +256,13 @@ impl RequirementsTxt {
236256
/// the current working directory. However, relative paths to sub-files (e.g., `-r ../requirements.txt`)
237257
/// are resolved against the directory of the containing `requirements.txt` file, to match
238258
/// `pip`'s behavior.
239-
pub async fn parse_inner(
259+
async fn parse_inner(
240260
content: &str,
241261
working_dir: &Path,
242262
requirements_dir: &Path,
243263
client_builder: &BaseClientBuilder<'_>,
244264
requirements_txt: &Path,
265+
visited: &mut VisitedFiles<'_>,
245266
) -> Result<Self, RequirementsTxtParserError> {
246267
let mut s = Scanner::new(content);
247268

@@ -276,14 +297,33 @@ impl RequirementsTxt {
276297
} else {
277298
requirements_dir.join(filename.as_ref())
278299
};
279-
let sub_requirements =
280-
Box::pin(Self::parse(&sub_file, working_dir, client_builder))
281-
.await
282-
.map_err(|err| RequirementsTxtParserError::Subfile {
283-
source: Box::new(err),
284-
start,
285-
end,
286-
})?;
300+
match visited {
301+
VisitedFiles::Requirements { requirements, .. } => {
302+
if !requirements.insert(sub_file.clone()) {
303+
continue;
304+
}
305+
}
306+
// Treat any nested requirements or constraints as constraints. This differs
307+
// from `pip`, which seems to treat `-r` requirements in constraints files as
308+
// _requirements_, but we don't want to support that.
309+
VisitedFiles::Constraints { constraints } => {
310+
if !constraints.insert(sub_file.clone()) {
311+
continue;
312+
}
313+
}
314+
}
315+
let sub_requirements = Box::pin(Self::parse_impl(
316+
&sub_file,
317+
working_dir,
318+
client_builder,
319+
visited,
320+
))
321+
.await
322+
.map_err(|err| RequirementsTxtParserError::Subfile {
323+
source: Box::new(err),
324+
start,
325+
end,
326+
})?;
287327

288328
// Disallow conflicting `--index-url` in nested `requirements` files.
289329
if sub_requirements.index_url.is_some()
@@ -331,14 +371,35 @@ impl RequirementsTxt {
331371
} else {
332372
requirements_dir.join(filename.as_ref())
333373
};
334-
let sub_constraints =
335-
Box::pin(Self::parse(&sub_file, working_dir, client_builder))
336-
.await
337-
.map_err(|err| RequirementsTxtParserError::Subfile {
338-
source: Box::new(err),
339-
start,
340-
end,
341-
})?;
374+
375+
// Switch to constraints mode, if we aren't in it already.
376+
let mut visited = match visited {
377+
VisitedFiles::Requirements { constraints, .. } => {
378+
if !constraints.insert(sub_file.clone()) {
379+
continue;
380+
}
381+
VisitedFiles::Constraints { constraints }
382+
}
383+
VisitedFiles::Constraints { constraints } => {
384+
if !constraints.insert(sub_file.clone()) {
385+
continue;
386+
}
387+
VisitedFiles::Constraints { constraints }
388+
}
389+
};
390+
391+
let sub_constraints = Box::pin(Self::parse_impl(
392+
&sub_file,
393+
working_dir,
394+
client_builder,
395+
&mut visited,
396+
))
397+
.await
398+
.map_err(|err| RequirementsTxtParserError::Subfile {
399+
source: Box::new(err),
400+
start,
401+
end,
402+
})?;
342403

343404
// Treat any nested requirements or constraints as constraints. This differs
344405
// from `pip`, which seems to treat `-r` requirements in constraints files as
@@ -1312,6 +1373,23 @@ impl RequirementsTxtParserError {
13121373
}
13131374
}
13141375

1376+
/// Avoid infinite recursion through recursive inclusions, while also being mindful of nested
1377+
/// requirements and constraint inclusions.
1378+
#[derive(Debug)]
1379+
enum VisitedFiles<'a> {
1380+
/// The requirements are included as regular requirements, and can recursively include both
1381+
/// requirements and constraints.
1382+
Requirements {
1383+
requirements: &'a mut FxHashSet<PathBuf>,
1384+
constraints: &'a mut FxHashSet<PathBuf>,
1385+
},
1386+
/// The requirements are included as constraints, all recursive inclusions are considered
1387+
/// constraints.
1388+
Constraints {
1389+
constraints: &'a mut FxHashSet<PathBuf>,
1390+
},
1391+
}
1392+
13151393
/// Calculates the column and line offset of a given cursor based on the
13161394
/// number of Unicode codepoints.
13171395
fn calculate_row_column(content: &str, position: usize) -> (usize, usize) {
@@ -1354,12 +1432,14 @@ fn calculate_row_column(content: &str, position: usize) -> (usize, usize) {
13541432

13551433
#[cfg(test)]
13561434
mod test {
1435+
use std::collections::BTreeSet;
13571436
use std::path::{Path, PathBuf};
13581437

13591438
use anyhow::Result;
13601439
use assert_fs::prelude::*;
13611440
use fs_err as fs;
13621441
use indoc::indoc;
1442+
use insta::assert_debug_snapshot;
13631443
use itertools::Itertools;
13641444
use tempfile::tempdir;
13651445
use test_case::test_case;
@@ -2792,4 +2872,98 @@ mod test {
27922872
// Assert line and columns are expected
27932873
assert_eq!(line_column, expected, "Issues with input: {input}");
27942874
}
2875+
2876+
/// Test different kinds of recursive inclusions with requirements and constraints
2877+
#[tokio::test]
2878+
async fn recursive_circular_inclusion() -> Result<()> {
2879+
let temp_dir = assert_fs::TempDir::new()?;
2880+
let both = temp_dir.child("both.txt");
2881+
both.write_str(indoc! {"
2882+
pkg-both
2883+
"})?;
2884+
let both = temp_dir.child("both-recursive.txt");
2885+
both.write_str(indoc! {"
2886+
pkg-both-recursive
2887+
-r both-recursive.txt
2888+
-c both-recursive.txt
2889+
"})?;
2890+
let requirements_only = temp_dir.child("requirements-only.txt");
2891+
requirements_only.write_str(indoc! {"
2892+
pkg-requirements-only
2893+
-r requirements-only.txt
2894+
"})?;
2895+
let requirements_only = temp_dir.child("requirements-only-recursive.txt");
2896+
requirements_only.write_str(indoc! {"
2897+
pkg-requirements-only-recursive
2898+
-r requirements-only-recursive.txt
2899+
"})?;
2900+
let constraints_only = temp_dir.child("requirements-in-constraints.txt");
2901+
constraints_only.write_str(indoc! {"
2902+
pkg-requirements-in-constraints
2903+
# Some nested recursion for good measure
2904+
-c constraints-only.txt
2905+
"})?;
2906+
let constraints_only = temp_dir.child("constraints-only.txt");
2907+
constraints_only.write_str(indoc! {"
2908+
pkg-constraints-only
2909+
-c constraints-only.txt
2910+
# Using `-r` inside `-c`
2911+
-r requirements-in-constraints.txt
2912+
"})?;
2913+
let constraints_only = temp_dir.child("constraints-only-recursive.txt");
2914+
constraints_only.write_str(indoc! {"
2915+
pkg-constraints-only-recursive
2916+
-r constraints-only-recursive.txt
2917+
"})?;
2918+
2919+
let requirements = temp_dir.child("requirements.txt");
2920+
requirements.write_str(indoc! {"
2921+
# Even if a package was already included as a constraint, it is also included as
2922+
# requirement
2923+
-c both.txt
2924+
-r both.txt
2925+
-c both-recursive.txt
2926+
-r both-recursive.txt
2927+
2928+
-r requirements-only.txt
2929+
-r requirements-only-recursive.txt
2930+
-c constraints-only.txt
2931+
-c constraints-only-recursive.txt
2932+
"})?;
2933+
2934+
let parsed = RequirementsTxt::parse(
2935+
&requirements,
2936+
temp_dir.path(),
2937+
&BaseClientBuilder::default(),
2938+
)
2939+
.await?;
2940+
2941+
let requirements: BTreeSet<String> = parsed
2942+
.requirements
2943+
.iter()
2944+
.map(|entry| entry.requirement.to_string())
2945+
.collect();
2946+
let constraints: BTreeSet<String> =
2947+
parsed.constraints.iter().map(ToString::to_string).collect();
2948+
2949+
assert_debug_snapshot!(requirements, @r#"
2950+
{
2951+
"pkg-both",
2952+
"pkg-both-recursive",
2953+
"pkg-requirements-only",
2954+
"pkg-requirements-only-recursive",
2955+
}
2956+
"#);
2957+
assert_debug_snapshot!(constraints, @r#"
2958+
{
2959+
"pkg-both",
2960+
"pkg-both-recursive",
2961+
"pkg-constraints-only",
2962+
"pkg-constraints-only-recursive",
2963+
"pkg-requirements-in-constraints",
2964+
}
2965+
"#);
2966+
2967+
Ok(())
2968+
}
27952969
}

crates/uv-requirements-txt/src/requirement.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::fmt::Display;
12
use std::path::Path;
23

34
use uv_normalize::PackageName;
@@ -124,9 +125,7 @@ impl RequirementsTxtRequirement {
124125
}
125126
}
126127
}
127-
}
128128

129-
impl RequirementsTxtRequirement {
130129
/// Parse a requirement as seen in a `requirements.txt` file.
131130
pub fn parse(
132131
input: &str,
@@ -162,3 +161,12 @@ impl RequirementsTxtRequirement {
162161
.map_err(Box::new)
163162
}
164163
}
164+
165+
impl Display for RequirementsTxtRequirement {
166+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167+
match self {
168+
Self::Named(requirement) => Display::fmt(&requirement, f),
169+
Self::Unnamed(requirement) => Display::fmt(&requirement, f),
170+
}
171+
}
172+
}

0 commit comments

Comments
 (0)