@@ -40,6 +40,7 @@ use std::io;
4040use std:: path:: { Path , PathBuf } ;
4141use std:: str:: FromStr ;
4242
43+ use rustc_hash:: FxHashSet ;
4344use tracing:: instrument;
4445use unscanny:: { Pattern , Scanner } ;
4546use 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.
13171395fn 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) ]
13561434mod 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}
0 commit comments