1use std::borrow::Cow;
38use std::fmt::{Display, Formatter};
39use std::io;
40use std::path::{Path, PathBuf};
41use std::str::FromStr;
42
43use rustc_hash::{FxHashMap, FxHashSet};
44use tracing::instrument;
45use unscanny::{Pattern, Scanner};
46use url::Url;
47
48#[cfg(feature = "http")]
49use uv_client::{BaseClient, ClientBuildError};
50use uv_client::{BaseClientBuilder, Connectivity};
51use uv_configuration::{NoBinary, NoBuild, PackageNameSpecifier};
52use uv_distribution_types::{
53 Requirement, UnresolvedRequirement, UnresolvedRequirementSpecification,
54};
55use uv_fs::{Simplified, normalize_path};
56use uv_pep508::{Pep508Error, RequirementOrigin, VerbatimUrl, expand_env_vars};
57use uv_pypi_types::VerbatimParsedUrl;
58#[cfg(feature = "http")]
59use uv_redacted::DisplaySafeUrl;
60use uv_redacted::DisplaySafeUrlError;
61
62use crate::requirement::EditableError;
63pub use crate::requirement::RequirementsTxtRequirement;
64use crate::shquote::unquote;
65
66mod requirement;
67mod shquote;
68
69pub type SourceCache = FxHashMap<PathBuf, String>;
71
72enum RequirementsTxtStatement {
74 Requirements {
76 filename: String,
77 start: usize,
78 end: usize,
79 },
80 Constraint {
82 filename: String,
83 start: usize,
84 end: usize,
85 },
86 RequirementEntry(RequirementEntry),
88 EditableRequirementEntry(RequirementEntry),
90 IndexUrl(VerbatimUrl),
92 ExtraIndexUrl(VerbatimUrl),
94 FindLinks(VerbatimUrl),
96 NoIndex,
98 NoBinary(NoBinary),
100 OnlyBinary(NoBuild),
102 UnsupportedOption(UnsupportedOption),
104}
105
106#[derive(Debug, Clone, Eq, PartialEq, Hash)]
109pub struct RequirementEntry {
110 pub requirement: RequirementsTxtRequirement,
112 pub hashes: Vec<String>,
114}
115
116impl From<RequirementEntry> for UnresolvedRequirementSpecification {
120 fn from(value: RequirementEntry) -> Self {
121 Self {
122 requirement: match value.requirement {
123 RequirementsTxtRequirement::Named(named) => {
124 UnresolvedRequirement::Named(Requirement::from(named))
125 }
126 RequirementsTxtRequirement::Unnamed(unnamed) => {
127 UnresolvedRequirement::Unnamed(unnamed)
128 }
129 },
130 hashes: value.hashes,
131 }
132 }
133}
134
135impl From<RequirementsTxtRequirement> for UnresolvedRequirementSpecification {
136 fn from(value: RequirementsTxtRequirement) -> Self {
137 Self::from(RequirementEntry {
138 requirement: value,
139 hashes: vec![],
140 })
141 }
142}
143
144#[derive(Debug, Default, Clone, PartialEq, Eq)]
146pub struct RequirementsTxt {
147 pub requirements: Vec<RequirementEntry>,
149 pub constraints: Vec<uv_pep508::Requirement<VerbatimParsedUrl>>,
151 pub editables: Vec<RequirementEntry>,
153 pub index_url: Option<VerbatimUrl>,
155 pub extra_index_urls: Vec<VerbatimUrl>,
157 pub find_links: Vec<VerbatimUrl>,
159 pub no_index: bool,
161 pub no_binary: NoBinary,
163 pub only_binary: NoBuild,
165}
166
167impl RequirementsTxt {
168 #[instrument(
170 skip_all,
171 fields(requirements_txt = requirements_txt.as_ref().as_os_str().to_str())
172 )]
173 pub async fn parse(
174 requirements_txt: impl AsRef<Path>,
175 working_dir: impl AsRef<Path>,
176 ) -> Result<Self, RequirementsTxtFileError> {
177 Self::parse_with_cache(
178 requirements_txt,
179 working_dir,
180 &BaseClientBuilder::default().connectivity(Connectivity::Offline),
181 &mut SourceCache::default(),
182 )
183 .await
184 }
185
186 #[instrument(
188 skip_all,
189 fields(requirements_txt = requirements_txt.as_ref().as_os_str().to_str())
190 )]
191 pub async fn parse_with_cache(
192 requirements_txt: impl AsRef<Path>,
193 working_dir: impl AsRef<Path>,
194 client_builder: &BaseClientBuilder<'_>,
195 cache: &mut SourceCache,
196 ) -> Result<Self, RequirementsTxtFileError> {
197 let mut visited = VisitedFiles::Requirements {
198 requirements: &mut FxHashSet::default(),
199 constraints: &mut FxHashSet::default(),
200 };
201 Self::parse_impl(
202 requirements_txt,
203 working_dir,
204 client_builder,
205 &mut visited,
206 cache,
207 )
208 .await
209 }
210
211 pub async fn parse_str(
214 content: &str,
215 requirements_txt: impl AsRef<Path>,
216 working_dir: impl AsRef<Path>,
217 client_builder: &BaseClientBuilder<'_>,
218 source_contents: &mut SourceCache,
219 ) -> Result<Self, RequirementsTxtFileError> {
220 let requirements_txt = requirements_txt.as_ref();
221 let working_dir = working_dir.as_ref();
222 let requirements_dir = requirements_txt.parent().unwrap_or(working_dir);
223
224 let mut visited = VisitedFiles::Requirements {
225 requirements: &mut FxHashSet::default(),
226 constraints: &mut FxHashSet::default(),
227 };
228
229 Self::parse_inner(
230 content,
231 working_dir,
232 requirements_dir,
233 client_builder,
234 requirements_txt,
235 &mut visited,
236 source_contents,
237 )
238 .await
239 .map_err(|err| RequirementsTxtFileError {
240 file: requirements_txt.to_path_buf(),
241 error: err,
242 })
243 }
244
245 #[instrument(
247 skip_all,
248 fields(requirements_txt = requirements_txt.as_ref().as_os_str().to_str())
249 )]
250 async fn parse_impl(
251 requirements_txt: impl AsRef<Path>,
252 working_dir: impl AsRef<Path>,
253 client_builder: &BaseClientBuilder<'_>,
254 visited: &mut VisitedFiles<'_>,
255 cache: &mut SourceCache,
256 ) -> Result<Self, RequirementsTxtFileError> {
257 let requirements_txt = requirements_txt.as_ref();
258 let working_dir = working_dir.as_ref();
259
260 let content = if let Some(content) = cache.get(requirements_txt) {
261 content.clone()
263 } else if requirements_txt.starts_with("http://") | requirements_txt.starts_with("https://")
264 {
265 #[cfg(not(feature = "http"))]
266 {
267 return Err(RequirementsTxtFileError {
268 file: requirements_txt.to_path_buf(),
269 error: RequirementsTxtParserError::Io(io::Error::new(
270 io::ErrorKind::InvalidInput,
271 "Remote file not supported without `http` feature",
272 )),
273 });
274 }
275
276 #[cfg(feature = "http")]
277 {
278 let url = requirements_txt.display().to_string();
279 let url = DisplaySafeUrl::parse(&url).map_err(|err| RequirementsTxtFileError {
280 file: requirements_txt.to_path_buf(),
281 error: RequirementsTxtParserError::InvalidUrl(
282 requirements_txt.display().to_string(),
283 err,
284 ),
285 })?;
286
287 if client_builder.is_offline() {
289 return Err(RequirementsTxtFileError {
290 file: requirements_txt.to_path_buf(),
291 error: RequirementsTxtParserError::Io(io::Error::new(
292 io::ErrorKind::InvalidInput,
293 format!(
294 "Network connectivity is disabled, but a remote requirements file was requested: {url}"
295 ),
296 )),
297 });
298 }
299 let client = client_builder
300 .build()
301 .map_err(|err| RequirementsTxtFileError {
302 file: requirements_txt.to_path_buf(),
303 error: RequirementsTxtParserError::ClientBuild(url.clone(), Box::new(err)),
304 })?;
305 let content = read_url_to_string(&requirements_txt, client)
306 .await
307 .map_err(|err| RequirementsTxtFileError {
308 file: requirements_txt.to_path_buf(),
309 error: err,
310 })?;
311 cache.insert(requirements_txt.to_path_buf(), content.clone());
312 content
313 }
314 } else {
315 let content = uv_fs::read_to_string_transcode(&requirements_txt)
317 .await
318 .map_err(|err| RequirementsTxtFileError {
319 file: requirements_txt.to_path_buf(),
320 error: RequirementsTxtParserError::Io(err),
321 })?;
322 cache.insert(requirements_txt.to_path_buf(), content.clone());
323 content
324 };
325
326 let requirements_dir = requirements_txt.parent().unwrap_or(working_dir);
327 let data = Self::parse_inner(
328 &content,
329 working_dir,
330 requirements_dir,
331 client_builder,
332 requirements_txt,
333 visited,
334 cache,
335 )
336 .await
337 .map_err(|err| RequirementsTxtFileError {
338 file: requirements_txt.to_path_buf(),
339 error: err,
340 })?;
341
342 Ok(data)
343 }
344
345 async fn parse_inner(
352 content: &str,
353 working_dir: &Path,
354 requirements_dir: &Path,
355 client_builder: &BaseClientBuilder<'_>,
356 requirements_txt: &Path,
357 visited: &mut VisitedFiles<'_>,
358 cache: &mut SourceCache,
359 ) -> Result<Self, RequirementsTxtParserError> {
360 let mut s = Scanner::new(content);
361
362 let mut data = Self::default();
363 while let Some(statement) = parse_entry(&mut s, content, working_dir, requirements_txt)? {
364 match statement {
365 RequirementsTxtStatement::Requirements {
366 filename,
367 start,
368 end,
369 } => {
370 let filename = expand_env_vars(&filename);
371 let sub_file =
372 if filename.starts_with("http://") || filename.starts_with("https://") {
373 PathBuf::from(filename.as_ref())
374 } else if filename.starts_with("file://") {
375 requirements_txt.join(
376 Url::parse(filename.as_ref())
377 .map_err(|err| RequirementsTxtParserError::Url {
378 source: DisplaySafeUrlError::Url(err).into(),
379 url: filename.to_string(),
380 start,
381 end,
382 })?
383 .to_file_path()
384 .map_err(|()| RequirementsTxtParserError::FileUrl {
385 url: filename.to_string(),
386 start,
387 end,
388 })?,
389 )
390 } else {
391 requirements_dir.join(filename.as_ref())
392 };
393 match visited {
394 VisitedFiles::Requirements { requirements, .. } => {
395 if !requirements.insert(visited_file(&sub_file)) {
396 continue;
397 }
398 }
399 VisitedFiles::Constraints { constraints } => {
403 if !constraints.insert(visited_file(&sub_file)) {
404 continue;
405 }
406 }
407 }
408 let sub_requirements = Box::pin(Self::parse_impl(
409 &sub_file,
410 working_dir,
411 client_builder,
412 visited,
413 cache,
414 ))
415 .await
416 .map_err(|err| RequirementsTxtParserError::Subfile {
417 source: Box::new(err),
418 start,
419 end,
420 })?;
421
422 if sub_requirements.index_url.is_some()
424 && data.index_url.is_some()
425 && sub_requirements.index_url != data.index_url
426 {
427 let (line, column) = calculate_row_column(content, s.cursor());
428 return Err(RequirementsTxtParserError::Parser {
429 message:
430 "Nested `requirements` file contains conflicting `--index-url`"
431 .to_string(),
432 line,
433 column,
434 });
435 }
436
437 data.update_from(sub_requirements);
439 }
440 RequirementsTxtStatement::Constraint {
441 filename,
442 start,
443 end,
444 } => {
445 let filename = expand_env_vars(&filename);
446 let sub_file =
447 if filename.starts_with("http://") || filename.starts_with("https://") {
448 PathBuf::from(filename.as_ref())
449 } else if filename.starts_with("file://") {
450 requirements_txt.join(
451 Url::parse(filename.as_ref())
452 .map_err(|err| RequirementsTxtParserError::Url {
453 source: DisplaySafeUrlError::Url(err).into(),
454 url: filename.to_string(),
455 start,
456 end,
457 })?
458 .to_file_path()
459 .map_err(|()| RequirementsTxtParserError::FileUrl {
460 url: filename.to_string(),
461 start,
462 end,
463 })?,
464 )
465 } else {
466 requirements_dir.join(filename.as_ref())
467 };
468
469 let mut visited = match visited {
471 VisitedFiles::Requirements { constraints, .. } => {
472 if !constraints.insert(visited_file(&sub_file)) {
473 continue;
474 }
475 VisitedFiles::Constraints { constraints }
476 }
477 VisitedFiles::Constraints { constraints } => {
478 if !constraints.insert(visited_file(&sub_file)) {
479 continue;
480 }
481 VisitedFiles::Constraints { constraints }
482 }
483 };
484
485 let sub_constraints = Box::pin(Self::parse_impl(
486 &sub_file,
487 working_dir,
488 client_builder,
489 &mut visited,
490 cache,
491 ))
492 .await
493 .map_err(|err| RequirementsTxtParserError::Subfile {
494 source: Box::new(err),
495 start,
496 end,
497 })?;
498
499 for entry in sub_constraints.requirements {
503 match entry.requirement {
504 RequirementsTxtRequirement::Named(requirement) => {
505 data.constraints.push(requirement);
506 }
507 RequirementsTxtRequirement::Unnamed(_) => {
508 return Err(RequirementsTxtParserError::UnnamedConstraint {
509 start,
510 end,
511 });
512 }
513 }
514 }
515 for constraint in sub_constraints.constraints {
516 data.constraints.push(constraint);
517 }
518 }
519 RequirementsTxtStatement::RequirementEntry(requirement_entry) => {
520 data.requirements.push(requirement_entry);
521 }
522 RequirementsTxtStatement::EditableRequirementEntry(editable) => {
523 data.editables.push(editable);
524 }
525 RequirementsTxtStatement::IndexUrl(url) => {
526 if data.index_url.is_some() {
527 let (line, column) = calculate_row_column(content, s.cursor());
528 return Err(RequirementsTxtParserError::Parser {
529 message: "Multiple `--index-url` values provided".to_string(),
530 line,
531 column,
532 });
533 }
534 data.index_url = Some(url);
535 }
536 RequirementsTxtStatement::ExtraIndexUrl(url) => {
537 data.extra_index_urls.push(url);
538 }
539 RequirementsTxtStatement::FindLinks(url) => {
540 data.find_links.push(url);
541 }
542 RequirementsTxtStatement::NoIndex => {
543 data.no_index = true;
544 }
545 RequirementsTxtStatement::NoBinary(no_binary) => {
546 data.no_binary.extend(no_binary);
547 }
548 RequirementsTxtStatement::OnlyBinary(only_binary) => {
549 data.only_binary.extend(only_binary);
550 }
551 RequirementsTxtStatement::UnsupportedOption(flag) => {
552 if requirements_txt == Path::new("-") {
553 if flag.cli() {
554 uv_warnings::warn_user!(
555 "Ignoring unsupported option from stdin: `{flag}` (hint: pass `{flag}` on the command line instead)",
556 flag = flag.green()
557 );
558 } else {
559 uv_warnings::warn_user!(
560 "Ignoring unsupported option from stdin: `{flag}`",
561 flag = flag.green()
562 );
563 }
564 } else {
565 if flag.cli() {
566 uv_warnings::warn_user!(
567 "Ignoring unsupported option in `{path}`: `{flag}` (hint: pass `{flag}` on the command line instead)",
568 path = requirements_txt.user_display().cyan(),
569 flag = flag.green()
570 );
571 } else {
572 uv_warnings::warn_user!(
573 "Ignoring unsupported option in `{path}`: `{flag}`",
574 path = requirements_txt.user_display().cyan(),
575 flag = flag.green()
576 );
577 }
578 }
579 }
580 }
581 }
582 Ok(data)
583 }
584
585 fn update_from(&mut self, other: Self) {
587 let Self {
588 requirements,
589 constraints,
590 editables,
591 index_url,
592 extra_index_urls,
593 find_links,
594 no_index,
595 no_binary,
596 only_binary,
597 } = other;
598 self.requirements.extend(requirements);
599 self.constraints.extend(constraints);
600 self.editables.extend(editables);
601 if self.index_url.is_none() {
602 self.index_url = index_url;
603 }
604 self.extra_index_urls.extend(extra_index_urls);
605 self.find_links.extend(find_links);
606 self.no_index = self.no_index || no_index;
607 self.no_binary.extend(no_binary);
608 self.only_binary.extend(only_binary);
609 }
610}
611
612#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616enum UnsupportedOption {
617 PreferBinary,
618 RequireHashes,
619 Pre,
620 TrustedHost,
621 UseFeature,
622}
623
624impl UnsupportedOption {
625 fn name(self) -> &'static str {
627 match self {
628 Self::PreferBinary => "--prefer-binary",
629 Self::RequireHashes => "--require-hashes",
630 Self::Pre => "--pre",
631 Self::TrustedHost => "--trusted-host",
632 Self::UseFeature => "--use-feature",
633 }
634 }
635
636 fn cli(self) -> bool {
638 match self {
639 Self::PreferBinary => false,
640 Self::RequireHashes => true,
641 Self::Pre => true,
642 Self::TrustedHost => true,
643 Self::UseFeature => false,
644 }
645 }
646
647 fn iter() -> impl Iterator<Item = Self> {
649 [
650 Self::PreferBinary,
651 Self::RequireHashes,
652 Self::Pre,
653 Self::TrustedHost,
654 Self::UseFeature,
655 ]
656 .iter()
657 .copied()
658 }
659}
660
661impl Display for UnsupportedOption {
662 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
663 write!(f, "{}", self.name())
664 }
665}
666
667const fn is_terminal(c: char) -> bool {
669 matches!(c, '\n' | '\r' | '#')
670}
671
672fn parse_entry(
677 s: &mut Scanner,
678 content: &str,
679 working_dir: &Path,
680 requirements_txt: &Path,
681) -> Result<Option<RequirementsTxtStatement>, RequirementsTxtParserError> {
682 eat_wrappable_whitespace(s);
684 while s.at(['\n', '\r', '#']) {
685 eat_trailing_line(content, s)?;
687 eat_wrappable_whitespace(s);
688 }
689
690 let start = s.cursor();
691 Ok(Some(if s.eat_if("-r") || s.eat_if("--requirement") {
692 let filename = parse_value("--requirement", content, s, |c: char| !is_terminal(c))?;
693 let filename = unquote(filename)
694 .ok()
695 .flatten()
696 .unwrap_or_else(|| filename.to_string());
697 let end = s.cursor();
698 RequirementsTxtStatement::Requirements {
699 filename,
700 start,
701 end,
702 }
703 } else if s.eat_if("-c") || s.eat_if("--constraint") {
704 let filename = parse_value("--constraint", content, s, |c: char| !is_terminal(c))?;
705 let filename = unquote(filename)
706 .ok()
707 .flatten()
708 .unwrap_or_else(|| filename.to_string());
709 let end = s.cursor();
710 RequirementsTxtStatement::Constraint {
711 filename,
712 start,
713 end,
714 }
715 } else if s.eat_if("-e") || s.eat_if("--editable") {
716 if s.eat_if('=') {
717 } else if s.eat_if(char::is_whitespace) {
719 s.eat_whitespace();
721 } else {
722 let (line, column) = calculate_row_column(content, s.cursor());
723 return Err(RequirementsTxtParserError::Parser {
724 message: format!("Expected '=' or whitespace, found {:?}", s.peek()),
725 line,
726 column,
727 });
728 }
729
730 let source = if requirements_txt == Path::new("-") {
731 None
732 } else {
733 Some(requirements_txt)
734 };
735
736 let (requirement, hashes) =
737 parse_requirement_and_hashes(s, content, source, working_dir, true)?;
738 let requirement =
739 requirement
740 .into_editable()
741 .map_err(|err| RequirementsTxtParserError::NonEditable {
742 source: err,
743 start,
744 end: s.cursor(),
745 })?;
746 RequirementsTxtStatement::EditableRequirementEntry(RequirementEntry {
747 requirement,
748 hashes,
749 })
750 } else if s.eat_if("-i") || s.eat_if("--index-url") {
751 let given = parse_value("--index-url", content, s, |c: char| !is_terminal(c))?;
752 let given = unquote(given)
753 .ok()
754 .flatten()
755 .map(Cow::Owned)
756 .unwrap_or(Cow::Borrowed(given));
757 let expanded = expand_env_vars(given.as_ref());
758 let url = if let Some(path) = std::path::absolute(expanded.as_ref())
759 .ok()
760 .filter(|path| path.exists())
761 {
762 VerbatimUrl::from_absolute_path(path).map_err(|err| {
763 RequirementsTxtParserError::VerbatimUrl {
764 source: err,
765 url: given.to_string(),
766 start,
767 end: s.cursor(),
768 }
769 })?
770 } else {
771 VerbatimUrl::parse_url(expanded.as_ref()).map_err(|err| {
772 RequirementsTxtParserError::Url {
773 source: err,
774 url: given.to_string(),
775 start,
776 end: s.cursor(),
777 }
778 })?
779 };
780 RequirementsTxtStatement::IndexUrl(url.with_given(given))
781 } else if s.eat_if("--extra-index-url") {
782 let given = parse_value("--extra-index-url", content, s, |c: char| !is_terminal(c))?;
783 let given = unquote(given)
784 .ok()
785 .flatten()
786 .map(Cow::Owned)
787 .unwrap_or(Cow::Borrowed(given));
788 let expanded = expand_env_vars(given.as_ref());
789 let url = if let Some(path) = std::path::absolute(expanded.as_ref())
790 .ok()
791 .filter(|path| path.exists())
792 {
793 VerbatimUrl::from_absolute_path(path).map_err(|err| {
794 RequirementsTxtParserError::VerbatimUrl {
795 source: err,
796 url: given.to_string(),
797 start,
798 end: s.cursor(),
799 }
800 })?
801 } else {
802 VerbatimUrl::parse_url(expanded.as_ref()).map_err(|err| {
803 RequirementsTxtParserError::Url {
804 source: err,
805 url: given.to_string(),
806 start,
807 end: s.cursor(),
808 }
809 })?
810 };
811 RequirementsTxtStatement::ExtraIndexUrl(url.with_given(given))
812 } else if s.eat_if("--no-index") {
813 RequirementsTxtStatement::NoIndex
814 } else if s.eat_if("--find-links") || s.eat_if("-f") {
815 let given = parse_value("--find-links", content, s, |c: char| !is_terminal(c))?;
816 let given = unquote(given)
817 .ok()
818 .flatten()
819 .map(Cow::Owned)
820 .unwrap_or(Cow::Borrowed(given));
821 let expanded = expand_env_vars(given.as_ref());
822 let url = if let Some(path) = std::path::absolute(expanded.as_ref())
823 .ok()
824 .filter(|path| path.exists())
825 {
826 VerbatimUrl::from_absolute_path(path).map_err(|err| {
827 RequirementsTxtParserError::VerbatimUrl {
828 source: err,
829 url: given.to_string(),
830 start,
831 end: s.cursor(),
832 }
833 })?
834 } else {
835 VerbatimUrl::parse_url(expanded.as_ref()).map_err(|err| {
836 RequirementsTxtParserError::Url {
837 source: err,
838 url: given.to_string(),
839 start,
840 end: s.cursor(),
841 }
842 })?
843 };
844 RequirementsTxtStatement::FindLinks(url.with_given(given))
845 } else if s.eat_if("--no-binary") {
846 let given = parse_value("--no-binary", content, s, |c: char| !is_terminal(c))?;
847 let given = unquote(given)
848 .ok()
849 .flatten()
850 .map(Cow::Owned)
851 .unwrap_or(Cow::Borrowed(given));
852 let specifier = PackageNameSpecifier::from_str(given.as_ref()).map_err(|err| {
853 RequirementsTxtParserError::NoBinary {
854 source: err,
855 specifier: given.to_string(),
856 start,
857 end: s.cursor(),
858 }
859 })?;
860 RequirementsTxtStatement::NoBinary(NoBinary::from_pip_arg(specifier))
861 } else if s.eat_if("--only-binary") {
862 let given = parse_value("--only-binary", content, s, |c: char| !is_terminal(c))?;
863 let given = unquote(given)
864 .ok()
865 .flatten()
866 .map(Cow::Owned)
867 .unwrap_or(Cow::Borrowed(given));
868 let specifier = PackageNameSpecifier::from_str(given.as_ref()).map_err(|err| {
869 RequirementsTxtParserError::NoBinary {
870 source: err,
871 specifier: given.to_string(),
872 start,
873 end: s.cursor(),
874 }
875 })?;
876 RequirementsTxtStatement::OnlyBinary(NoBuild::from_pip_arg(specifier))
877 } else if s.at(char::is_ascii_alphanumeric) || s.at(|char| matches!(char, '.' | '/' | '$')) {
878 let source = if requirements_txt == Path::new("-") {
879 None
880 } else {
881 Some(requirements_txt)
882 };
883
884 let (requirement, hashes) =
885 parse_requirement_and_hashes(s, content, source, working_dir, false)?;
886 RequirementsTxtStatement::RequirementEntry(RequirementEntry {
887 requirement,
888 hashes,
889 })
890 } else if let Some(char) = s.peek() {
891 if let Some(option) = UnsupportedOption::iter().find(|option| s.eat_if(option.name())) {
893 s.eat_while(|c: char| !is_terminal(c));
894 RequirementsTxtStatement::UnsupportedOption(option)
895 } else {
896 let (line, column) = calculate_row_column(content, s.cursor());
897 return Err(RequirementsTxtParserError::Parser {
898 message: format!(
899 "Unexpected '{char}', expected '-c', '-e', '-r' or the start of a requirement"
900 ),
901 line,
902 column,
903 });
904 }
905 } else {
906 return Ok(None);
908 }))
909}
910
911fn eat_wrappable_whitespace<'a>(s: &mut Scanner<'a>) -> &'a str {
913 let start = s.cursor();
914 s.eat_while([' ', '\t']);
915 while s.eat_if("\\\n") || s.eat_if("\\\r\n") || s.eat_if("\\\r") {
918 s.eat_while([' ', '\t']);
919 }
920 s.from(start)
921}
922
923fn eat_trailing_line(content: &str, s: &mut Scanner) -> Result<(), RequirementsTxtParserError> {
925 s.eat_while([' ', '\t']);
926 match s.eat() {
927 None | Some('\n') => {} Some('\r') => {
929 s.eat_if('\n'); }
931 Some('#') => {
932 s.eat_until(['\r', '\n']);
933 if s.at('\r') {
934 s.eat_if('\n'); }
936 }
937 Some(other) => {
938 let (line, column) = calculate_row_column(content, s.cursor());
939 return Err(RequirementsTxtParserError::Parser {
940 message: format!("Expected comment or end-of-line, found `{other}`"),
941 line,
942 column,
943 });
944 }
945 }
946 Ok(())
947}
948
949fn parse_requirement_and_hashes(
951 s: &mut Scanner,
952 content: &str,
953 source: Option<&Path>,
954 working_dir: &Path,
955 editable: bool,
956) -> Result<(RequirementsTxtRequirement, Vec<String>), RequirementsTxtParserError> {
957 let start = s.cursor();
959 let (end, has_hashes) = loop {
961 let end = s.cursor();
962
963 if s.eat_if('\n') {
965 break (end, false);
966 }
967 if s.eat_if('\r') {
968 s.eat_if('\n'); break (end, false);
970 }
971 if !eat_wrappable_whitespace(s).is_empty() {
973 if s.after().starts_with("--") {
974 break (end, true);
975 } else if s.eat_if('#') {
976 s.eat_until(['\r', '\n']);
977 if s.at('\r') {
978 s.eat_if('\n'); }
980 break (end, false);
981 }
982 continue;
983 }
984 if s.eat().is_none() {
986 break (end, false);
987 }
988 };
989
990 let requirement = &content[start..end];
991
992 #[expect(clippy::case_sensitive_file_extension_comparisons)]
998 if requirement.ends_with(".txt") || requirement.ends_with(".in") {
999 let path = Path::new(requirement);
1000 let path = if path.is_absolute() {
1001 Cow::Borrowed(path)
1002 } else {
1003 Cow::Owned(working_dir.join(path))
1004 };
1005 if path.is_file() {
1006 return Err(RequirementsTxtParserError::MissingRequirementPrefix(
1007 requirement.to_string(),
1008 ));
1009 }
1010 }
1011
1012 let requirement = RequirementsTxtRequirement::parse(requirement, working_dir, editable)
1013 .map(|requirement| {
1014 if let Some(source) = source {
1015 requirement.with_origin(RequirementOrigin::File(source.to_path_buf()))
1016 } else {
1017 requirement
1018 }
1019 })
1020 .map_err(|err| RequirementsTxtParserError::Pep508 {
1021 source: err,
1022 start,
1023 end,
1024 })?;
1025
1026 let hashes = if has_hashes {
1027 parse_hashes(content, s)?
1028 } else {
1029 Vec::new()
1030 };
1031 Ok((requirement, hashes))
1032}
1033
1034fn parse_hashes(content: &str, s: &mut Scanner) -> Result<Vec<String>, RequirementsTxtParserError> {
1036 let mut hashes = Vec::new();
1037 if !s.eat_if("--hash") {
1038 let (line, column) = calculate_row_column(content, s.cursor());
1039 return Err(RequirementsTxtParserError::Parser {
1040 message: format!(
1041 "Expected `--hash`, found `{:?}`",
1042 s.eat_while(|c: char| !c.is_whitespace())
1043 ),
1044 line,
1045 column,
1046 });
1047 }
1048 let hash = parse_value("--hash", content, s, |c: char| !c.is_whitespace())?;
1049 hashes.push(hash.to_string());
1050 loop {
1051 eat_wrappable_whitespace(s);
1052 if !s.eat_if("--hash") {
1053 break;
1054 }
1055 let hash = parse_value("--hash", content, s, |c: char| !c.is_whitespace())?;
1056 hashes.push(hash.to_string());
1057 }
1058 Ok(hashes)
1059}
1060
1061fn parse_value<'a, T>(
1063 option: &str,
1064 content: &str,
1065 s: &mut Scanner<'a>,
1066 while_pattern: impl Pattern<T>,
1067) -> Result<&'a str, RequirementsTxtParserError> {
1068 let value = if s.eat_if('=') {
1069 s.eat_while(while_pattern).trim_end()
1071 } else if s.eat_if(char::is_whitespace) {
1072 s.eat_whitespace();
1074 s.eat_while(while_pattern).trim_end()
1075 } else {
1076 let (line, column) = calculate_row_column(content, s.cursor());
1077 return Err(RequirementsTxtParserError::Parser {
1078 message: format!("Expected '=' or whitespace, found {:?}", s.peek()),
1079 line,
1080 column,
1081 });
1082 };
1083
1084 if value.is_empty() {
1085 let (line, column) = calculate_row_column(content, s.cursor());
1086 return Err(RequirementsTxtParserError::Parser {
1087 message: format!("`{option}` must be followed by an argument"),
1088 line,
1089 column,
1090 });
1091 }
1092
1093 Ok(value)
1094}
1095
1096#[cfg(feature = "http")]
1098async fn read_url_to_string(
1099 path: impl AsRef<Path>,
1100 client: BaseClient,
1101) -> Result<String, RequirementsTxtParserError> {
1102 let path_utf8 =
1104 path.as_ref()
1105 .to_str()
1106 .ok_or_else(|| RequirementsTxtParserError::NonUnicodeUrl {
1107 url: path.as_ref().to_owned(),
1108 })?;
1109
1110 let url = DisplaySafeUrl::from_str(path_utf8)
1111 .map_err(|err| RequirementsTxtParserError::InvalidUrl(path_utf8.to_string(), err))?;
1112 let response = client
1113 .for_host(&url)
1114 .get(Url::from(url.clone()))
1115 .send()
1116 .await
1117 .map_err(|err| RequirementsTxtParserError::from_reqwest_middleware(url.clone(), err))?;
1118 let text = response
1119 .error_for_status()
1120 .map_err(|err| RequirementsTxtParserError::from_reqwest(url.clone(), err))?
1121 .text()
1122 .await
1123 .map_err(|err| RequirementsTxtParserError::from_reqwest(url.clone(), err))?;
1124 Ok(text)
1125}
1126
1127#[derive(Debug)]
1129pub struct RequirementsTxtFileError {
1130 file: PathBuf,
1131 error: RequirementsTxtParserError,
1132}
1133
1134#[derive(Debug)]
1136pub enum RequirementsTxtParserError {
1137 Io(io::Error),
1138 Url {
1139 source: uv_pep508::VerbatimUrlError,
1140 url: String,
1141 start: usize,
1142 end: usize,
1143 },
1144 FileUrl {
1145 url: String,
1146 start: usize,
1147 end: usize,
1148 },
1149 VerbatimUrl {
1150 source: uv_pep508::VerbatimUrlError,
1151 url: String,
1152 start: usize,
1153 end: usize,
1154 },
1155 UrlConversion(String),
1156 UnsupportedUrl(String),
1157 MissingRequirementPrefix(String),
1158 NonEditable {
1159 source: EditableError,
1160 start: usize,
1161 end: usize,
1162 },
1163 NoBinary {
1164 source: uv_normalize::InvalidNameError,
1165 specifier: String,
1166 start: usize,
1167 end: usize,
1168 },
1169 OnlyBinary {
1170 source: uv_normalize::InvalidNameError,
1171 specifier: String,
1172 start: usize,
1173 end: usize,
1174 },
1175 UnnamedConstraint {
1176 start: usize,
1177 end: usize,
1178 },
1179 Parser {
1180 message: String,
1181 line: usize,
1182 column: usize,
1183 },
1184 UnsupportedRequirement {
1185 source: Box<Pep508Error<VerbatimParsedUrl>>,
1186 start: usize,
1187 end: usize,
1188 },
1189 Pep508 {
1190 source: Box<Pep508Error<VerbatimParsedUrl>>,
1191 start: usize,
1192 end: usize,
1193 },
1194 ParsedUrl {
1195 source: Box<Pep508Error<VerbatimParsedUrl>>,
1196 start: usize,
1197 end: usize,
1198 },
1199 Subfile {
1200 source: Box<RequirementsTxtFileError>,
1201 start: usize,
1202 end: usize,
1203 },
1204 NonUnicodeUrl {
1205 url: PathBuf,
1206 },
1207 #[cfg(feature = "http")]
1208 Reqwest(DisplaySafeUrl, reqwest_middleware::Error),
1209 #[cfg(feature = "http")]
1210 ClientBuild(DisplaySafeUrl, Box<ClientBuildError>),
1211 #[cfg(feature = "http")]
1212 InvalidUrl(String, DisplaySafeUrlError),
1213}
1214
1215impl Display for RequirementsTxtParserError {
1216 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1217 match self {
1218 Self::Io(err) => err.fmt(f),
1219 Self::Url { url, start, .. } => {
1220 write!(f, "Invalid URL at position {start}: `{url}`")
1221 }
1222 Self::FileUrl { url, start, .. } => {
1223 write!(f, "Invalid file URL at position {start}: `{url}`")
1224 }
1225 Self::VerbatimUrl { url, start, .. } => {
1226 write!(f, "Invalid URL at position {start}: `{url}`")
1227 }
1228 Self::UrlConversion(given) => {
1229 write!(f, "Unable to convert URL to path: {given}")
1230 }
1231 Self::UnsupportedUrl(url) => {
1232 write!(f, "Unsupported URL (expected a `file://` scheme): `{url}`")
1233 }
1234 Self::NonEditable { .. } => {
1235 write!(f, "Unsupported editable requirement")
1236 }
1237 Self::MissingRequirementPrefix(given) => {
1238 write!(
1239 f,
1240 "Requirement `{given}` looks like a requirements file but was passed as a package name. Did you mean `-r {given}`?"
1241 )
1242 }
1243 Self::NoBinary { specifier, .. } => {
1244 write!(f, "Invalid specifier for `--no-binary`: {specifier}")
1245 }
1246 Self::OnlyBinary { specifier, .. } => {
1247 write!(f, "Invalid specifier for `--only-binary`: {specifier}")
1248 }
1249 Self::UnnamedConstraint { .. } => {
1250 write!(f, "Unnamed requirements are not allowed as constraints")
1251 }
1252 Self::Parser {
1253 message,
1254 line,
1255 column,
1256 } => {
1257 write!(f, "{message} at {line}:{column}")
1258 }
1259 Self::UnsupportedRequirement { start, end, .. } => {
1260 write!(f, "Unsupported requirement in position {start} to {end}")
1261 }
1262 Self::Pep508 { start, .. } => {
1263 write!(f, "Couldn't parse requirement at position {start}")
1264 }
1265 Self::ParsedUrl { start, .. } => {
1266 write!(f, "Couldn't URL at position {start}")
1267 }
1268 Self::Subfile { start, .. } => {
1269 write!(f, "Error parsing included file at position {start}")
1270 }
1271 Self::NonUnicodeUrl { url } => {
1272 write!(
1273 f,
1274 "Remote requirements URL contains non-unicode characters: {}",
1275 url.display(),
1276 )
1277 }
1278 #[cfg(feature = "http")]
1279 Self::Reqwest(url, _err) => {
1280 write!(f, "Error while accessing remote requirements file: `{url}`")
1281 }
1282 #[cfg(feature = "http")]
1283 Self::ClientBuild(url, _err) => {
1284 write!(f, "Error while accessing remote requirements file: `{url}`")
1285 }
1286 #[cfg(feature = "http")]
1287 Self::InvalidUrl(url, err) => {
1288 match err {
1289 DisplaySafeUrlError::Url(err) => write!(f, "Not a valid URL, {err}: `{url}`"),
1290 DisplaySafeUrlError::AmbiguousAuthority(_) => {
1291 write!(f, "Invalid URL: {err}")
1295 }
1296 }
1297 }
1298 }
1299 }
1300}
1301
1302impl std::error::Error for RequirementsTxtParserError {
1303 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1304 match self {
1305 Self::Io(err) => err.source(),
1306 Self::Url { source, .. } => Some(source),
1307 Self::FileUrl { .. } => None,
1308 Self::VerbatimUrl { source, .. } => Some(source),
1309 Self::UrlConversion(_) => None,
1310 Self::UnsupportedUrl(_) => None,
1311 Self::NonEditable { source, .. } => Some(source),
1312 Self::MissingRequirementPrefix(_) => None,
1313 Self::NoBinary { source, .. } => Some(source),
1314 Self::OnlyBinary { source, .. } => Some(source),
1315 Self::UnnamedConstraint { .. } => None,
1316 Self::UnsupportedRequirement { source, .. } => Some(source),
1317 Self::Pep508 { source, .. } => Some(source),
1318 Self::ParsedUrl { source, .. } => Some(source),
1319 Self::Subfile { source, .. } => Some(source.as_ref()),
1320 Self::Parser { .. } => None,
1321 Self::NonUnicodeUrl { .. } => None,
1322 #[cfg(feature = "http")]
1323 Self::Reqwest(_, err) => err.source(),
1324 #[cfg(feature = "http")]
1325 Self::ClientBuild(_, err) => Some(err.as_ref()),
1326 #[cfg(feature = "http")]
1327 Self::InvalidUrl(_, err) => err.source(),
1328 }
1329 }
1330}
1331
1332impl Display for RequirementsTxtFileError {
1333 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1334 match &self.error {
1335 RequirementsTxtParserError::Io(err) => err.fmt(f),
1336 RequirementsTxtParserError::Url { url, start, .. } => {
1337 write!(
1338 f,
1339 "Invalid URL in `{}` at position {start}: `{url}`",
1340 self.file.user_display(),
1341 )
1342 }
1343 RequirementsTxtParserError::FileUrl { url, start, .. } => {
1344 write!(
1345 f,
1346 "Invalid file URL in `{}` at position {start}: `{url}`",
1347 self.file.user_display(),
1348 )
1349 }
1350 RequirementsTxtParserError::VerbatimUrl { url, start, .. } => {
1351 write!(
1352 f,
1353 "Invalid URL in `{}` at position {start}: `{url}`",
1354 self.file.user_display(),
1355 )
1356 }
1357 RequirementsTxtParserError::UrlConversion(given) => {
1358 write!(
1359 f,
1360 "Unable to convert URL to path `{}`: {given}",
1361 self.file.user_display()
1362 )
1363 }
1364 RequirementsTxtParserError::UnsupportedUrl(url) => {
1365 write!(
1366 f,
1367 "Unsupported URL (expected a `file://` scheme) in `{}`: `{url}`",
1368 self.file.user_display(),
1369 )
1370 }
1371 RequirementsTxtParserError::NonEditable { .. } => {
1372 write!(
1373 f,
1374 "Unsupported editable requirement in `{}`",
1375 self.file.user_display(),
1376 )
1377 }
1378 RequirementsTxtParserError::MissingRequirementPrefix(given) => {
1379 write!(
1380 f,
1381 "Requirement `{given}` in `{}` looks like a requirements file but was passed as a package name. Did you mean `-r {given}`?",
1382 self.file.user_display(),
1383 )
1384 }
1385 RequirementsTxtParserError::NoBinary { specifier, .. } => {
1386 write!(
1387 f,
1388 "Invalid specifier for `--no-binary` in `{}`: {specifier}",
1389 self.file.user_display(),
1390 )
1391 }
1392 RequirementsTxtParserError::OnlyBinary { specifier, .. } => {
1393 write!(
1394 f,
1395 "Invalid specifier for `--only-binary` in `{}`: {specifier}",
1396 self.file.user_display(),
1397 )
1398 }
1399 RequirementsTxtParserError::UnnamedConstraint { .. } => {
1400 write!(
1401 f,
1402 "Unnamed requirements are not allowed as constraints in `{}`",
1403 self.file.user_display(),
1404 )
1405 }
1406 RequirementsTxtParserError::Parser {
1407 message,
1408 line,
1409 column,
1410 } => {
1411 write!(
1412 f,
1413 "{message} at {}:{line}:{column}",
1414 self.file.user_display(),
1415 )
1416 }
1417 RequirementsTxtParserError::UnsupportedRequirement { start, .. } => {
1418 write!(
1419 f,
1420 "Unsupported requirement in {} at position {start}",
1421 self.file.user_display(),
1422 )
1423 }
1424 RequirementsTxtParserError::Pep508 { start, .. } => {
1425 write!(
1426 f,
1427 "Couldn't parse requirement in `{}` at position {start}",
1428 self.file.user_display(),
1429 )
1430 }
1431 RequirementsTxtParserError::ParsedUrl { start, .. } => {
1432 write!(
1433 f,
1434 "Couldn't parse URL in `{}` at position {start}",
1435 self.file.user_display(),
1436 )
1437 }
1438 RequirementsTxtParserError::Subfile { start, .. } => {
1439 write!(
1440 f,
1441 "Error parsing included file in `{}` at position {start}",
1442 self.file.user_display(),
1443 )
1444 }
1445 RequirementsTxtParserError::NonUnicodeUrl { url } => {
1446 write!(
1447 f,
1448 "Remote requirements URL contains non-unicode characters: {}",
1449 url.display(),
1450 )
1451 }
1452 #[cfg(feature = "http")]
1453 RequirementsTxtParserError::Reqwest(url, _err) => {
1454 write!(f, "Error while accessing remote requirements file: `{url}`")
1455 }
1456 #[cfg(feature = "http")]
1457 RequirementsTxtParserError::ClientBuild(url, _err) => {
1458 write!(f, "Error while accessing remote requirements file: `{url}`")
1459 }
1460 #[cfg(feature = "http")]
1461 RequirementsTxtParserError::InvalidUrl(url, err) => match err {
1462 DisplaySafeUrlError::Url(err) => write!(f, "Not a valid URL, {err}: `{url}`"),
1463 DisplaySafeUrlError::AmbiguousAuthority(_) => {
1464 write!(f, "Invalid URL: {err}")
1468 }
1469 },
1470 }
1471 }
1472}
1473
1474impl std::error::Error for RequirementsTxtFileError {
1475 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1476 self.error.source()
1477 }
1478}
1479
1480impl From<io::Error> for RequirementsTxtParserError {
1481 fn from(err: io::Error) -> Self {
1482 Self::Io(err)
1483 }
1484}
1485
1486#[cfg(feature = "http")]
1487impl RequirementsTxtParserError {
1488 fn from_reqwest(url: DisplaySafeUrl, err: reqwest::Error) -> Self {
1489 Self::Reqwest(url, reqwest_middleware::Error::Reqwest(err))
1490 }
1491
1492 fn from_reqwest_middleware(url: DisplaySafeUrl, err: reqwest_middleware::Error) -> Self {
1493 Self::Reqwest(url, err)
1494 }
1495}
1496
1497#[derive(Debug)]
1500enum VisitedFiles<'a> {
1501 Requirements {
1504 requirements: &'a mut FxHashSet<PathBuf>,
1505 constraints: &'a mut FxHashSet<PathBuf>,
1506 },
1507 Constraints {
1510 constraints: &'a mut FxHashSet<PathBuf>,
1511 },
1512}
1513
1514fn visited_file(path: &Path) -> PathBuf {
1516 if path.starts_with("http://") || path.starts_with("https://") {
1517 path.to_path_buf()
1518 } else {
1519 normalize_path(path).into_owned()
1520 }
1521}
1522
1523fn calculate_row_column(content: &str, position: usize) -> (usize, usize) {
1526 let mut line = 1;
1527 let mut column = 1;
1528
1529 let mut chars = content.char_indices().peekable();
1530 while let Some((index, char)) = chars.next() {
1531 if index >= position {
1532 break;
1533 }
1534 match char {
1535 '\r' => {
1536 if chars
1538 .peek()
1539 .is_some_and(|&(_, next_char)| next_char == '\n')
1540 {
1541 chars.next();
1542 }
1543
1544 line += 1;
1546 column = 1;
1547 }
1548 '\n' => {
1549 line += 1;
1551 column = 1;
1552 }
1553 _ => column += 1,
1557 }
1558 }
1559
1560 (line, column)
1561}
1562
1563#[cfg(test)]
1564mod test {
1565 use std::collections::BTreeSet;
1566 use std::path::{Path, PathBuf};
1567
1568 use anyhow::Result;
1569 use assert_fs::prelude::*;
1570 use fs_err as fs;
1571 use indoc::indoc;
1572 use insta::assert_debug_snapshot;
1573 use itertools::Itertools;
1574 use tempfile::tempdir;
1575 use test_case::test_case;
1576 use unscanny::Scanner;
1577
1578 use uv_fs::Simplified;
1579
1580 use crate::{RequirementsTxt, calculate_row_column};
1581
1582 fn workspace_test_data_dir() -> PathBuf {
1583 Path::new("./test-data").simple_canonicalize().unwrap()
1584 }
1585
1586 fn path_filter(path: &Path) -> String {
1592 regex::escape(&path.simplified_display().to_string()).replace(r"\\", r"(\\\\|/)")
1593 }
1594
1595 fn path_filters(filter: &str) -> Vec<(&str, &str)> {
1597 vec![(filter, "<REQUIREMENTS_DIR>"), (r"\\\\", "/")]
1598 }
1599
1600 #[test_case(Path::new("basic.txt"))]
1601 #[test_case(Path::new("constraints-a.txt"))]
1602 #[test_case(Path::new("constraints-b.txt"))]
1603 #[test_case(Path::new("empty.txt"))]
1604 #[test_case(Path::new("for-poetry.txt"))]
1605 #[test_case(Path::new("include-a.txt"))]
1606 #[test_case(Path::new("include-b.txt"))]
1607 #[test_case(Path::new("poetry-with-hashes.txt"))]
1608 #[test_case(Path::new("small.txt"))]
1609 #[test_case(Path::new("whitespace.txt"))]
1610 #[tokio::test]
1611 async fn parse(path: &Path) {
1612 let working_dir = workspace_test_data_dir().join("requirements-txt");
1613 let requirements_txt = working_dir.join(path);
1614
1615 let actual = RequirementsTxt::parse(requirements_txt.clone(), &working_dir)
1616 .await
1617 .unwrap();
1618
1619 let snapshot = format!("parse-{}", path.to_string_lossy());
1620
1621 insta::with_settings!({
1622 filters => path_filters(&path_filter(&working_dir)),
1623 }, {
1624 insta::assert_debug_snapshot!(snapshot, actual);
1625 });
1626 }
1627
1628 #[test_case(Path::new("basic.txt"))]
1629 #[test_case(Path::new("constraints-a.txt"))]
1630 #[test_case(Path::new("constraints-b.txt"))]
1631 #[test_case(Path::new("empty.txt"))]
1632 #[test_case(Path::new("for-poetry.txt"))]
1633 #[test_case(Path::new("include-a.txt"))]
1634 #[test_case(Path::new("include-b.txt"))]
1635 #[test_case(Path::new("poetry-with-hashes.txt"))]
1636 #[test_case(Path::new("small.txt"))]
1637 #[test_case(Path::new("whitespace.txt"))]
1638 #[tokio::test]
1639 async fn line_endings(path: &Path) {
1640 let working_dir = workspace_test_data_dir().join("requirements-txt");
1641 let requirements_txt = working_dir.join(path);
1642
1643 let temp_dir = tempdir().unwrap();
1645 for entry in fs::read_dir(&working_dir).unwrap() {
1646 let entry = entry.unwrap();
1647 let path = entry.path();
1648 let dest = temp_dir.path().join(path.file_name().unwrap());
1649 fs::copy(&path, &dest).unwrap();
1650 }
1651
1652 let contents = fs::read_to_string(requirements_txt).unwrap();
1655 let contents = if contents.contains("\r\n") {
1656 contents.replace("\r\n", "\n")
1657 } else {
1658 contents.replace('\n', "\r\n")
1659 };
1660 let requirements_txt = temp_dir.path().join(path);
1661 fs::write(&requirements_txt, contents).unwrap();
1662
1663 let actual = RequirementsTxt::parse(&requirements_txt, &working_dir)
1664 .await
1665 .unwrap();
1666
1667 let snapshot = format!("line-endings-{}", path.to_string_lossy());
1668
1669 insta::with_settings!({
1670 filters => path_filters(&path_filter(temp_dir.path())),
1671 }, {
1672 insta::assert_debug_snapshot!(snapshot, actual);
1673 });
1674 }
1675
1676 #[cfg(unix)]
1677 #[test_case(Path::new("bare-url.txt"))]
1678 #[test_case(Path::new("editable.txt"))]
1679 #[tokio::test]
1680 async fn parse_unix(path: &Path) {
1681 let working_dir = workspace_test_data_dir().join("requirements-txt");
1682 let requirements_txt = working_dir.join(path);
1683
1684 let actual = RequirementsTxt::parse(requirements_txt, &working_dir)
1685 .await
1686 .unwrap();
1687
1688 let snapshot = format!("parse-unix-{}", path.to_string_lossy());
1689
1690 insta::with_settings!({
1691 filters => path_filters(&path_filter(&working_dir)),
1692 }, {
1693 insta::assert_debug_snapshot!(snapshot, actual);
1694 });
1695 }
1696
1697 #[cfg(unix)]
1698 #[test_case(Path::new("semicolon.txt"))]
1699 #[test_case(Path::new("hash.txt"))]
1700 #[tokio::test]
1701 async fn parse_err(path: &Path) {
1702 let working_dir = workspace_test_data_dir().join("requirements-txt");
1703 let requirements_txt = working_dir.join(path);
1704
1705 let actual = RequirementsTxt::parse(requirements_txt, &working_dir)
1706 .await
1707 .unwrap_err();
1708
1709 let snapshot = format!("parse-unix-{}", path.to_string_lossy());
1710
1711 insta::with_settings!({
1712 filters => path_filters(&path_filter(&working_dir)),
1713 }, {
1714 insta::assert_debug_snapshot!(snapshot, actual);
1715 });
1716 }
1717
1718 #[cfg(windows)]
1719 #[test_case(Path::new("bare-url.txt"))]
1720 #[test_case(Path::new("editable.txt"))]
1721 #[tokio::test]
1722 async fn parse_windows(path: &Path) {
1723 let working_dir = workspace_test_data_dir().join("requirements-txt");
1724 let requirements_txt = working_dir.join(path);
1725
1726 let actual = RequirementsTxt::parse(requirements_txt, &working_dir)
1727 .await
1728 .unwrap();
1729
1730 let snapshot = format!("parse-windows-{}", path.to_string_lossy());
1731
1732 insta::with_settings!({
1733 filters => path_filters(&path_filter(&working_dir)),
1734 }, {
1735 insta::assert_debug_snapshot!(snapshot, actual);
1736 });
1737 }
1738
1739 #[tokio::test]
1740 async fn invalid_include_missing_file() -> Result<()> {
1741 let temp_dir = assert_fs::TempDir::new()?;
1742 let missing_txt = temp_dir.child("missing.txt");
1743 let requirements_txt = temp_dir.child("requirements.txt");
1744 requirements_txt.write_str(indoc! {"
1745 -r missing.txt
1746 "})?;
1747
1748 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1749 .await
1750 .unwrap_err();
1751 let errors = anyhow::Error::new(error)
1752 .chain()
1753 .take(2)
1755 .join("\n");
1756
1757 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1758 let missing_txt = regex::escape(&missing_txt.path().user_display().to_string());
1759 let filters = vec![
1760 (requirement_txt.as_str(), "<REQUIREMENTS_TXT>"),
1761 (missing_txt.as_str(), "<MISSING_TXT>"),
1762 (
1765 r": .* \(os error 2\)",
1766 ": The system cannot find the path specified. (os error 2)",
1767 ),
1768 ];
1769 insta::with_settings!({
1770 filters => filters,
1771 }, {
1772 insta::assert_snapshot!(errors, @"
1773 Error parsing included file in `<REQUIREMENTS_TXT>` at position 0
1774 failed to read from file `<MISSING_TXT>`: The system cannot find the path specified. (os error 2)
1775 ");
1776 });
1777
1778 Ok(())
1779 }
1780
1781 #[tokio::test]
1782 async fn invalid_requirement_version() -> Result<()> {
1783 let temp_dir = assert_fs::TempDir::new()?;
1784 let requirements_txt = temp_dir.child("requirements.txt");
1785 requirements_txt.write_str(indoc! {"
1786 numpy[ö]==1.29
1787 "})?;
1788
1789 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1790 .await
1791 .unwrap_err();
1792 let errors = anyhow::Error::new(error).chain().join("\n");
1793
1794 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1795 let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1796 insta::with_settings!({
1797 filters => filters
1798 }, {
1799 insta::assert_snapshot!(errors, @"
1800 Couldn't parse requirement in `<REQUIREMENTS_TXT>` at position 0
1801 Expected an alphanumeric character starting the extra name, found `ö`
1802 numpy[ö]==1.29
1803 ^
1804 ");
1805 });
1806
1807 Ok(())
1808 }
1809
1810 #[tokio::test]
1811 async fn invalid_requirement_url() -> Result<()> {
1812 let temp_dir = assert_fs::TempDir::new()?;
1813 let requirements_txt = temp_dir.child("requirements.txt");
1814 requirements_txt.write_str(indoc! {"
1815 numpy @ https:///
1816 "})?;
1817
1818 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1819 .await
1820 .unwrap_err();
1821 let errors = anyhow::Error::new(error).chain().join("\n");
1822
1823 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1824 let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1825 insta::with_settings!({
1826 filters => filters
1827 }, {
1828 insta::assert_snapshot!(errors, @"
1829 Couldn't parse requirement in `<REQUIREMENTS_TXT>` at position 0
1830 empty host
1831 numpy @ https:///
1832 ^^^^^^^^^
1833 ");
1834 });
1835
1836 Ok(())
1837 }
1838
1839 #[tokio::test]
1840 async fn unsupported_editable() -> Result<()> {
1841 let temp_dir = assert_fs::TempDir::new()?;
1842 let requirements_txt = temp_dir.child("requirements.txt");
1843 requirements_txt.write_str(indoc! {"
1844 -e https://localhost:8080/
1845 "})?;
1846
1847 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1848 .await
1849 .unwrap_err();
1850 let errors = anyhow::Error::new(error).chain().join("\n");
1851
1852 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1853 let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1854 insta::with_settings!({
1855 filters => filters
1856 }, {
1857 insta::assert_snapshot!(errors, @"
1858 Couldn't parse requirement in `<REQUIREMENTS_TXT>` at position 3
1859 Expected direct URL (`https://localhost:8080/`) to end in a supported file extension: `.whl`, `.tar.gz`, `.zip`, `.tar.bz2`, `.tar.lz`, `.tar.lzma`, `.tar.xz`, `.tar.zst`, `.tar`, `.tbz`, `.tgz`, `.tlz`, or `.txz`
1860 https://localhost:8080/
1861 ^^^^^^^^^^^^^^^^^^^^^^^
1862 ");
1863 });
1864
1865 Ok(())
1866 }
1867
1868 #[tokio::test]
1869 async fn unsupported_editable_extension() -> Result<()> {
1870 let temp_dir = assert_fs::TempDir::new()?;
1871 let requirements_txt = temp_dir.child("requirements.txt");
1872 requirements_txt.write_str(indoc! {"
1873 -e https://files.pythonhosted.org/packages/f7/69/96766da2cdb5605e6a31ef2734aff0be17901cefb385b885c2ab88896d76/ruff-0.5.6.tar.gz
1874 "})?;
1875
1876 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1877 .await
1878 .unwrap_err();
1879 let errors = anyhow::Error::new(error).chain().join("\n");
1880
1881 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1882 let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1883 insta::with_settings!({
1884 filters => filters
1885 }, {
1886 insta::assert_snapshot!(errors, @"
1887 Unsupported editable requirement in `<REQUIREMENTS_TXT>`
1888 Editable must refer to a local directory, not an HTTPS URL: `https://files.pythonhosted.org/packages/f7/69/96766da2cdb5605e6a31ef2734aff0be17901cefb385b885c2ab88896d76/ruff-0.5.6.tar.gz`
1889 ");
1890 });
1891
1892 Ok(())
1893 }
1894
1895 #[tokio::test]
1896 async fn invalid_editable_extra() -> Result<()> {
1897 let temp_dir = assert_fs::TempDir::new()?;
1898 let requirements_txt = temp_dir.child("requirements.txt");
1899 requirements_txt.write_str(indoc! {"
1900 -e black[,abcdef]
1901 "})?;
1902
1903 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1904 .await
1905 .unwrap_err();
1906 let errors = anyhow::Error::new(error).chain().join("\n");
1907
1908 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1909 let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1910 insta::with_settings!({
1911 filters => filters
1912 }, {
1913 insta::assert_snapshot!(errors, @"
1914 Couldn't parse requirement in `<REQUIREMENTS_TXT>` at position 3
1915 Expected either alphanumerical character (starting the extra name) or `]` (ending the extras section), found `,`
1916 black[,abcdef]
1917 ^
1918 ");
1919 });
1920
1921 Ok(())
1922 }
1923
1924 #[tokio::test]
1925 async fn relative_index_url() -> Result<()> {
1926 let temp_dir = assert_fs::TempDir::new()?;
1927 let requirements_txt = temp_dir.child("requirements.txt");
1928 requirements_txt.write_str(indoc! {"
1929 --index-url 123
1930 "})?;
1931
1932 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1933 .await
1934 .unwrap_err();
1935 let errors = anyhow::Error::new(error).chain().join("\n");
1936
1937 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1938 let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1939 insta::with_settings!({
1940 filters => filters
1941 }, {
1942 insta::assert_snapshot!(errors, @"
1943 Invalid URL in `<REQUIREMENTS_TXT>` at position 0: `123`
1944 relative URL without a base
1945 ");
1946 });
1947
1948 Ok(())
1949 }
1950
1951 #[tokio::test]
1952 async fn invalid_index_url() -> Result<()> {
1953 let temp_dir = assert_fs::TempDir::new()?;
1954 let requirements_txt = temp_dir.child("requirements.txt");
1955 requirements_txt.write_str(indoc! {"
1956 --index-url https:////
1957 "})?;
1958
1959 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1960 .await
1961 .unwrap_err();
1962 let errors = anyhow::Error::new(error).chain().join("\n");
1963
1964 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1965 let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1966 insta::with_settings!({
1967 filters => filters
1968 }, {
1969 insta::assert_snapshot!(errors, @"
1970 Invalid URL in `<REQUIREMENTS_TXT>` at position 0: `https:////`
1971 empty host
1972 ");
1973 });
1974
1975 Ok(())
1976 }
1977
1978 #[tokio::test]
1979 async fn missing_value() -> Result<()> {
1980 let temp_dir = assert_fs::TempDir::new()?;
1981 let requirements_txt = temp_dir.child("requirements.txt");
1982 requirements_txt.write_str(indoc! {"
1983 flask
1984 --no-binary
1985 "})?;
1986
1987 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
1988 .await
1989 .unwrap_err();
1990 let errors = anyhow::Error::new(error).chain().join("\n");
1991
1992 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
1993 let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
1994 insta::with_settings!({
1995 filters => filters
1996 }, {
1997 insta::assert_snapshot!(errors, @"`--no-binary` must be followed by an argument at <REQUIREMENTS_TXT>:3:1");
1998 });
1999
2000 Ok(())
2001 }
2002
2003 #[tokio::test]
2004 async fn missing_r() -> Result<()> {
2005 let temp_dir = assert_fs::TempDir::new()?;
2006
2007 let file_txt = temp_dir.child("file.txt");
2008 file_txt.touch()?;
2009
2010 let requirements_txt = temp_dir.child("requirements.txt");
2011 requirements_txt.write_str(indoc! {"
2012 flask
2013 file.txt
2014 "})?;
2015
2016 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2017 .await
2018 .unwrap_err();
2019 let errors = anyhow::Error::new(error).chain().join("\n");
2020
2021 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
2022 let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
2023 insta::with_settings!({
2024 filters => filters
2025 }, {
2026 insta::assert_snapshot!(errors, @"Requirement `file.txt` in `<REQUIREMENTS_TXT>` looks like a requirements file but was passed as a package name. Did you mean `-r file.txt`?");
2027 });
2028
2029 Ok(())
2030 }
2031
2032 #[tokio::test]
2033 async fn relative_requirement() -> Result<()> {
2034 let temp_dir = assert_fs::TempDir::new()?;
2035
2036 let sub_dir = temp_dir.child("subdir");
2038
2039 let sibling_txt = sub_dir.child("sibling.txt");
2040 sibling_txt.write_str(indoc! {"
2041 flask
2042 "})?;
2043
2044 let child_txt = sub_dir.child("child.txt");
2045 child_txt.write_str(indoc! {"
2046 -r sibling.txt
2047 "})?;
2048
2049 let parent_txt = temp_dir.child("parent.txt");
2051 parent_txt.write_str(indoc! {"
2052 -r subdir/child.txt
2053 "})?;
2054
2055 let requirements = RequirementsTxt::parse(parent_txt.path(), temp_dir.path())
2056 .await
2057 .unwrap();
2058
2059 insta::with_settings!({
2060 filters => path_filters(&path_filter(temp_dir.path())),
2061 }, {
2062 insta::assert_debug_snapshot!(requirements, @r#"
2063 RequirementsTxt {
2064 requirements: [
2065 RequirementEntry {
2066 requirement: Named(
2067 Requirement {
2068 name: PackageName(
2069 "flask",
2070 ),
2071 extras: [],
2072 version_or_url: None,
2073 marker: true,
2074 origin: Some(
2075 File(
2076 "<REQUIREMENTS_DIR>/subdir/sibling.txt",
2077 ),
2078 ),
2079 },
2080 ),
2081 hashes: [],
2082 },
2083 ],
2084 constraints: [],
2085 editables: [],
2086 index_url: None,
2087 extra_index_urls: [],
2088 find_links: [],
2089 no_index: false,
2090 no_binary: None,
2091 only_binary: None,
2092 }
2093 "#);
2094 });
2095
2096 Ok(())
2097 }
2098
2099 #[tokio::test]
2100 async fn nested_no_binary() -> Result<()> {
2101 let temp_dir = assert_fs::TempDir::new()?;
2102
2103 let requirements_txt = temp_dir.child("requirements.txt");
2104 requirements_txt.write_str(indoc! {"
2105 flask
2106 --no-binary :none:
2107 -r child.txt
2108 "})?;
2109
2110 let child = temp_dir.child("child.txt");
2111 child.write_str(indoc! {"
2112 --no-binary flask
2113 "})?;
2114
2115 let requirements = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2116 .await
2117 .unwrap();
2118
2119 insta::with_settings!({
2120 filters => path_filters(&path_filter(temp_dir.path())),
2121 }, {
2122 insta::assert_debug_snapshot!(requirements, @r#"
2123 RequirementsTxt {
2124 requirements: [
2125 RequirementEntry {
2126 requirement: Named(
2127 Requirement {
2128 name: PackageName(
2129 "flask",
2130 ),
2131 extras: [],
2132 version_or_url: None,
2133 marker: true,
2134 origin: Some(
2135 File(
2136 "<REQUIREMENTS_DIR>/requirements.txt",
2137 ),
2138 ),
2139 },
2140 ),
2141 hashes: [],
2142 },
2143 ],
2144 constraints: [],
2145 editables: [],
2146 index_url: None,
2147 extra_index_urls: [],
2148 find_links: [],
2149 no_index: false,
2150 no_binary: Packages(
2151 [
2152 PackageName(
2153 "flask",
2154 ),
2155 ],
2156 ),
2157 only_binary: None,
2158 }
2159 "#);
2160 });
2161
2162 Ok(())
2163 }
2164
2165 #[tokio::test]
2166 #[cfg(not(windows))]
2167 async fn nested_editable() -> Result<()> {
2168 let temp_dir = assert_fs::TempDir::new()?;
2169
2170 let requirements_txt = temp_dir.child("requirements.txt");
2171 requirements_txt.write_str(indoc! {"
2172 -r child.txt
2173 "})?;
2174
2175 let child = temp_dir.child("child.txt");
2176 child.write_str(indoc! {"
2177 -r grandchild.txt
2178 "})?;
2179
2180 let grandchild = temp_dir.child("grandchild.txt");
2181 grandchild.write_str(indoc! {"
2182 -e /foo/bar
2183 --no-index
2184 "})?;
2185
2186 let requirements = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2187 .await
2188 .unwrap();
2189
2190 insta::with_settings!({
2191 filters => path_filters(&path_filter(temp_dir.path())),
2192 }, {
2193 insta::assert_debug_snapshot!(requirements, @r#"
2194 RequirementsTxt {
2195 requirements: [],
2196 constraints: [],
2197 editables: [
2198 RequirementEntry {
2199 requirement: Unnamed(
2200 UnnamedRequirement {
2201 url: VerbatimParsedUrl {
2202 parsed_url: Directory(
2203 ParsedDirectoryUrl {
2204 url: DisplaySafeUrl {
2205 scheme: "file",
2206 cannot_be_a_base: false,
2207 username: "",
2208 password: None,
2209 host: None,
2210 port: None,
2211 path: "/foo/bar",
2212 query: None,
2213 fragment: None,
2214 },
2215 install_path: "/foo/bar",
2216 editable: Some(
2217 true,
2218 ),
2219 virtual: None,
2220 },
2221 ),
2222 verbatim: VerbatimUrl {
2223 url: DisplaySafeUrl {
2224 scheme: "file",
2225 cannot_be_a_base: false,
2226 username: "",
2227 password: None,
2228 host: None,
2229 port: None,
2230 path: "/foo/bar",
2231 query: None,
2232 fragment: None,
2233 },
2234 given: Some(
2235 "/foo/bar",
2236 ),
2237 expanded: false,
2238 },
2239 },
2240 extras: [],
2241 marker: true,
2242 origin: Some(
2243 File(
2244 "<REQUIREMENTS_DIR>/grandchild.txt",
2245 ),
2246 ),
2247 },
2248 ),
2249 hashes: [],
2250 },
2251 ],
2252 index_url: None,
2253 extra_index_urls: [],
2254 find_links: [],
2255 no_index: true,
2256 no_binary: None,
2257 only_binary: None,
2258 }
2259 "#);
2260 });
2261
2262 Ok(())
2263 }
2264
2265 #[tokio::test]
2266 async fn nested_conflicting_index_url() -> Result<()> {
2267 let temp_dir = assert_fs::TempDir::new()?;
2268
2269 let requirements_txt = temp_dir.child("requirements.txt");
2270 requirements_txt.write_str(indoc! {"
2271 --index-url https://test.pypi.org/simple
2272 -r child.txt
2273 "})?;
2274
2275 let child = temp_dir.child("child.txt");
2276 child.write_str(indoc! {"
2277 -r grandchild.txt
2278 "})?;
2279
2280 let grandchild = temp_dir.child("grandchild.txt");
2281 grandchild.write_str(indoc! {"
2282 --index-url https://fake.pypi.org/simple
2283 "})?;
2284
2285 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2286 .await
2287 .unwrap_err();
2288 let errors = anyhow::Error::new(error).chain().join("\n");
2289
2290 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
2291 let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
2292 insta::with_settings!({
2293 filters => filters
2294 }, {
2295 insta::assert_snapshot!(errors, @"Nested `requirements` file contains conflicting `--index-url` at <REQUIREMENTS_TXT>:2:13");
2296 });
2297
2298 Ok(())
2299 }
2300
2301 #[tokio::test]
2302 async fn comments() -> Result<()> {
2303 let temp_dir = assert_fs::TempDir::new()?;
2304
2305 let requirements_txt = temp_dir.child("requirements.txt");
2306 requirements_txt.write_str(indoc! {r"
2307 -r ./sibling.txt # comment
2308 --index-url https://test.pypi.org/simple/ # comment
2309 --no-binary :all: # comment
2310
2311 flask==3.0.0 \
2312 --hash=sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef \
2313 # comment
2314
2315 requests==2.26.0 \
2316 --hash=sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321 # comment
2317
2318 black==21.12b0 # comment
2319
2320 mypy==0.910 \
2321 # comment
2322 "})?;
2323
2324 let sibling_txt = temp_dir.child("sibling.txt");
2325 sibling_txt.write_str(indoc! {"
2326 httpx # comment
2327 "})?;
2328
2329 let requirements = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2330 .await
2331 .unwrap();
2332
2333 insta::with_settings!({
2334 filters => path_filters(&path_filter(temp_dir.path())),
2335 }, {
2336 insta::assert_debug_snapshot!(requirements, @r#"
2337 RequirementsTxt {
2338 requirements: [
2339 RequirementEntry {
2340 requirement: Named(
2341 Requirement {
2342 name: PackageName(
2343 "httpx",
2344 ),
2345 extras: [],
2346 version_or_url: None,
2347 marker: true,
2348 origin: Some(
2349 File(
2350 "<REQUIREMENTS_DIR>/./sibling.txt",
2351 ),
2352 ),
2353 },
2354 ),
2355 hashes: [],
2356 },
2357 RequirementEntry {
2358 requirement: Named(
2359 Requirement {
2360 name: PackageName(
2361 "flask",
2362 ),
2363 extras: [],
2364 version_or_url: Some(
2365 VersionSpecifier(
2366 VersionSpecifiers(
2367 [
2368 VersionSpecifier {
2369 operator: Equal,
2370 version: "3.0.0",
2371 },
2372 ],
2373 ),
2374 ),
2375 ),
2376 marker: true,
2377 origin: Some(
2378 File(
2379 "<REQUIREMENTS_DIR>/requirements.txt",
2380 ),
2381 ),
2382 },
2383 ),
2384 hashes: [
2385 "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
2386 ],
2387 },
2388 RequirementEntry {
2389 requirement: Named(
2390 Requirement {
2391 name: PackageName(
2392 "requests",
2393 ),
2394 extras: [],
2395 version_or_url: Some(
2396 VersionSpecifier(
2397 VersionSpecifiers(
2398 [
2399 VersionSpecifier {
2400 operator: Equal,
2401 version: "2.26.0",
2402 },
2403 ],
2404 ),
2405 ),
2406 ),
2407 marker: true,
2408 origin: Some(
2409 File(
2410 "<REQUIREMENTS_DIR>/requirements.txt",
2411 ),
2412 ),
2413 },
2414 ),
2415 hashes: [
2416 "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
2417 ],
2418 },
2419 RequirementEntry {
2420 requirement: Named(
2421 Requirement {
2422 name: PackageName(
2423 "black",
2424 ),
2425 extras: [],
2426 version_or_url: Some(
2427 VersionSpecifier(
2428 VersionSpecifiers(
2429 [
2430 VersionSpecifier {
2431 operator: Equal,
2432 version: "21.12b0",
2433 },
2434 ],
2435 ),
2436 ),
2437 ),
2438 marker: true,
2439 origin: Some(
2440 File(
2441 "<REQUIREMENTS_DIR>/requirements.txt",
2442 ),
2443 ),
2444 },
2445 ),
2446 hashes: [],
2447 },
2448 RequirementEntry {
2449 requirement: Named(
2450 Requirement {
2451 name: PackageName(
2452 "mypy",
2453 ),
2454 extras: [],
2455 version_or_url: Some(
2456 VersionSpecifier(
2457 VersionSpecifiers(
2458 [
2459 VersionSpecifier {
2460 operator: Equal,
2461 version: "0.910",
2462 },
2463 ],
2464 ),
2465 ),
2466 ),
2467 marker: true,
2468 origin: Some(
2469 File(
2470 "<REQUIREMENTS_DIR>/requirements.txt",
2471 ),
2472 ),
2473 },
2474 ),
2475 hashes: [],
2476 },
2477 ],
2478 constraints: [],
2479 editables: [],
2480 index_url: Some(
2481 VerbatimUrl {
2482 url: DisplaySafeUrl {
2483 scheme: "https",
2484 cannot_be_a_base: false,
2485 username: "",
2486 password: None,
2487 host: Some(
2488 Domain(
2489 "test.pypi.org",
2490 ),
2491 ),
2492 port: None,
2493 path: "/simple/",
2494 query: None,
2495 fragment: None,
2496 },
2497 given: Some(
2498 "https://test.pypi.org/simple/",
2499 ),
2500 expanded: false,
2501 },
2502 ),
2503 extra_index_urls: [],
2504 find_links: [],
2505 no_index: false,
2506 no_binary: All,
2507 only_binary: None,
2508 }
2509 "#);
2510 });
2511
2512 Ok(())
2513 }
2514
2515 #[tokio::test]
2516 #[cfg(not(windows))]
2517 async fn archive_requirement() -> Result<()> {
2518 let temp_dir = assert_fs::TempDir::new()?;
2519
2520 let requirements_txt = temp_dir.child("requirements.txt");
2521 requirements_txt.write_str(indoc! {r"
2522 # Archive name that's also a valid Python package name.
2523 importlib_metadata-8.3.0-py3-none-any.whl
2524
2525 # Archive name that's also a valid Python package name, with markers.
2526 importlib_metadata-8.2.0-py3-none-any.whl ; sys_platform == 'win32'
2527
2528 # Archive name that's also a valid Python package name, with extras.
2529 importlib_metadata-8.2.0-py3-none-any.whl[extra]
2530
2531 # Archive name that's not a valid Python package name.
2532 importlib_metadata-8.2.0+local-py3-none-any.whl
2533
2534 # Archive name that's not a valid Python package name, with markers.
2535 importlib_metadata-8.2.0+local-py3-none-any.whl ; sys_platform == 'win32'
2536
2537 # Archive name that's not a valid Python package name, with extras.
2538 importlib_metadata-8.2.0+local-py3-none-any.whl[extra]
2539 "})?;
2540
2541 let requirements = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2542 .await
2543 .unwrap();
2544
2545 insta::with_settings!({
2546 filters => path_filters(&path_filter(temp_dir.path())),
2547 }, {
2548 insta::assert_debug_snapshot!(requirements, @r#"
2549 RequirementsTxt {
2550 requirements: [
2551 RequirementEntry {
2552 requirement: Unnamed(
2553 UnnamedRequirement {
2554 url: VerbatimParsedUrl {
2555 parsed_url: Path(
2556 ParsedPathUrl {
2557 url: DisplaySafeUrl {
2558 scheme: "file",
2559 cannot_be_a_base: false,
2560 username: "",
2561 password: None,
2562 host: None,
2563 port: None,
2564 path: "<REQUIREMENTS_DIR>/importlib_metadata-8.3.0-py3-none-any.whl",
2565 query: None,
2566 fragment: None,
2567 },
2568 install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.3.0-py3-none-any.whl",
2569 ext: Wheel,
2570 },
2571 ),
2572 verbatim: VerbatimUrl {
2573 url: DisplaySafeUrl {
2574 scheme: "file",
2575 cannot_be_a_base: false,
2576 username: "",
2577 password: None,
2578 host: None,
2579 port: None,
2580 path: "<REQUIREMENTS_DIR>/importlib_metadata-8.3.0-py3-none-any.whl",
2581 query: None,
2582 fragment: None,
2583 },
2584 given: Some(
2585 "importlib_metadata-8.3.0-py3-none-any.whl",
2586 ),
2587 expanded: false,
2588 },
2589 },
2590 extras: [],
2591 marker: true,
2592 origin: Some(
2593 File(
2594 "<REQUIREMENTS_DIR>/requirements.txt",
2595 ),
2596 ),
2597 },
2598 ),
2599 hashes: [],
2600 },
2601 RequirementEntry {
2602 requirement: Unnamed(
2603 UnnamedRequirement {
2604 url: VerbatimParsedUrl {
2605 parsed_url: Path(
2606 ParsedPathUrl {
2607 url: DisplaySafeUrl {
2608 scheme: "file",
2609 cannot_be_a_base: false,
2610 username: "",
2611 password: None,
2612 host: None,
2613 port: None,
2614 path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
2615 query: None,
2616 fragment: None,
2617 },
2618 install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
2619 ext: Wheel,
2620 },
2621 ),
2622 verbatim: VerbatimUrl {
2623 url: DisplaySafeUrl {
2624 scheme: "file",
2625 cannot_be_a_base: false,
2626 username: "",
2627 password: None,
2628 host: None,
2629 port: None,
2630 path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
2631 query: None,
2632 fragment: None,
2633 },
2634 given: Some(
2635 "importlib_metadata-8.2.0-py3-none-any.whl",
2636 ),
2637 expanded: false,
2638 },
2639 },
2640 extras: [],
2641 marker: sys_platform == 'win32',
2642 origin: Some(
2643 File(
2644 "<REQUIREMENTS_DIR>/requirements.txt",
2645 ),
2646 ),
2647 },
2648 ),
2649 hashes: [],
2650 },
2651 RequirementEntry {
2652 requirement: Unnamed(
2653 UnnamedRequirement {
2654 url: VerbatimParsedUrl {
2655 parsed_url: Path(
2656 ParsedPathUrl {
2657 url: DisplaySafeUrl {
2658 scheme: "file",
2659 cannot_be_a_base: false,
2660 username: "",
2661 password: None,
2662 host: None,
2663 port: None,
2664 path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
2665 query: None,
2666 fragment: None,
2667 },
2668 install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
2669 ext: Wheel,
2670 },
2671 ),
2672 verbatim: VerbatimUrl {
2673 url: DisplaySafeUrl {
2674 scheme: "file",
2675 cannot_be_a_base: false,
2676 username: "",
2677 password: None,
2678 host: None,
2679 port: None,
2680 path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
2681 query: None,
2682 fragment: None,
2683 },
2684 given: Some(
2685 "importlib_metadata-8.2.0-py3-none-any.whl",
2686 ),
2687 expanded: false,
2688 },
2689 },
2690 extras: [
2691 ExtraName(
2692 "extra",
2693 ),
2694 ],
2695 marker: true,
2696 origin: Some(
2697 File(
2698 "<REQUIREMENTS_DIR>/requirements.txt",
2699 ),
2700 ),
2701 },
2702 ),
2703 hashes: [],
2704 },
2705 RequirementEntry {
2706 requirement: Unnamed(
2707 UnnamedRequirement {
2708 url: VerbatimParsedUrl {
2709 parsed_url: Path(
2710 ParsedPathUrl {
2711 url: DisplaySafeUrl {
2712 scheme: "file",
2713 cannot_be_a_base: false,
2714 username: "",
2715 password: None,
2716 host: None,
2717 port: None,
2718 path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2719 query: None,
2720 fragment: None,
2721 },
2722 install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2723 ext: Wheel,
2724 },
2725 ),
2726 verbatim: VerbatimUrl {
2727 url: DisplaySafeUrl {
2728 scheme: "file",
2729 cannot_be_a_base: false,
2730 username: "",
2731 password: None,
2732 host: None,
2733 port: None,
2734 path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2735 query: None,
2736 fragment: None,
2737 },
2738 given: Some(
2739 "importlib_metadata-8.2.0+local-py3-none-any.whl",
2740 ),
2741 expanded: false,
2742 },
2743 },
2744 extras: [],
2745 marker: true,
2746 origin: Some(
2747 File(
2748 "<REQUIREMENTS_DIR>/requirements.txt",
2749 ),
2750 ),
2751 },
2752 ),
2753 hashes: [],
2754 },
2755 RequirementEntry {
2756 requirement: Unnamed(
2757 UnnamedRequirement {
2758 url: VerbatimParsedUrl {
2759 parsed_url: Path(
2760 ParsedPathUrl {
2761 url: DisplaySafeUrl {
2762 scheme: "file",
2763 cannot_be_a_base: false,
2764 username: "",
2765 password: None,
2766 host: None,
2767 port: None,
2768 path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2769 query: None,
2770 fragment: None,
2771 },
2772 install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2773 ext: Wheel,
2774 },
2775 ),
2776 verbatim: VerbatimUrl {
2777 url: DisplaySafeUrl {
2778 scheme: "file",
2779 cannot_be_a_base: false,
2780 username: "",
2781 password: None,
2782 host: None,
2783 port: None,
2784 path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2785 query: None,
2786 fragment: None,
2787 },
2788 given: Some(
2789 "importlib_metadata-8.2.0+local-py3-none-any.whl",
2790 ),
2791 expanded: false,
2792 },
2793 },
2794 extras: [],
2795 marker: sys_platform == 'win32',
2796 origin: Some(
2797 File(
2798 "<REQUIREMENTS_DIR>/requirements.txt",
2799 ),
2800 ),
2801 },
2802 ),
2803 hashes: [],
2804 },
2805 RequirementEntry {
2806 requirement: Unnamed(
2807 UnnamedRequirement {
2808 url: VerbatimParsedUrl {
2809 parsed_url: Path(
2810 ParsedPathUrl {
2811 url: DisplaySafeUrl {
2812 scheme: "file",
2813 cannot_be_a_base: false,
2814 username: "",
2815 password: None,
2816 host: None,
2817 port: None,
2818 path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2819 query: None,
2820 fragment: None,
2821 },
2822 install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2823 ext: Wheel,
2824 },
2825 ),
2826 verbatim: VerbatimUrl {
2827 url: DisplaySafeUrl {
2828 scheme: "file",
2829 cannot_be_a_base: false,
2830 username: "",
2831 password: None,
2832 host: None,
2833 port: None,
2834 path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
2835 query: None,
2836 fragment: None,
2837 },
2838 given: Some(
2839 "importlib_metadata-8.2.0+local-py3-none-any.whl",
2840 ),
2841 expanded: false,
2842 },
2843 },
2844 extras: [
2845 ExtraName(
2846 "extra",
2847 ),
2848 ],
2849 marker: true,
2850 origin: Some(
2851 File(
2852 "<REQUIREMENTS_DIR>/requirements.txt",
2853 ),
2854 ),
2855 },
2856 ),
2857 hashes: [],
2858 },
2859 ],
2860 constraints: [],
2861 editables: [],
2862 index_url: None,
2863 extra_index_urls: [],
2864 find_links: [],
2865 no_index: false,
2866 no_binary: None,
2867 only_binary: None,
2868 }
2869 "#);
2870 });
2871
2872 Ok(())
2873 }
2874
2875 #[tokio::test]
2876 async fn parser_error_line_and_column() -> Result<()> {
2877 let temp_dir = assert_fs::TempDir::new()?;
2878 let requirements_txt = temp_dir.child("requirements.txt");
2879 requirements_txt.write_str(indoc! {"
2880 numpy>=1,<2
2881 --broken
2882 tqdm
2883 "})?;
2884
2885 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2886 .await
2887 .unwrap_err();
2888 let errors = anyhow::Error::new(error).chain().join("\n");
2889
2890 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
2891 let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
2892 insta::with_settings!({
2893 filters => filters
2894 }, {
2895 insta::assert_snapshot!(errors, @"Unexpected '-', expected '-c', '-e', '-r' or the start of a requirement at <REQUIREMENTS_TXT>:2:3");
2896 });
2897
2898 Ok(())
2899 }
2900
2901 #[tokio::test]
2902 async fn malformed_hash_option() -> Result<()> {
2903 let temp_dir = assert_fs::TempDir::new()?;
2904 let requirements_txt = temp_dir.child("requirements.txt");
2905 requirements_txt.write_str("flask==3.0.0 --hash--hash=sha256:deadbeef")?;
2906
2907 let error = RequirementsTxt::parse(requirements_txt.path(), temp_dir.path())
2908 .await
2909 .unwrap_err();
2910 let errors = anyhow::Error::new(error).chain().join("\n");
2911
2912 let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
2913 let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
2914 insta::with_settings!({
2915 filters => filters
2916 }, {
2917 insta::assert_snapshot!(errors, @"Expected '=' or whitespace, found Some('-') at <REQUIREMENTS_TXT>:1:20");
2918 });
2919
2920 Ok(())
2921 }
2922
2923 #[test_case("numpy>=1,<2\n @-broken\ntqdm", "2:4"; "ASCII Character with LF")]
2924 #[test_case("numpy>=1,<2\r\n #-broken\ntqdm", "2:4"; "ASCII Character with CRLF")]
2925 #[test_case("numpy>=1,<2\n \n-broken\ntqdm", "3:1"; "ASCII Character LF then LF")]
2926 #[test_case("numpy>=1,<2\n \r-broken\ntqdm", "3:1"; "ASCII Character LF then CR but no LF")]
2927 #[test_case("numpy>=1,<2\n \r\n-broken\ntqdm", "3:1"; "ASCII Character LF then CRLF")]
2928 #[test_case("numpy>=1,<2\n 🚀-broken\ntqdm", "2:4"; "Emoji (Wide) Character")]
2929 #[test_case("numpy>=1,<2\n ä¸-broken\ntqdm", "2:4"; "Fullwidth character")]
2930 #[test_case("numpy>=1,<2\n e\u{0301}-broken\ntqdm", "2:5"; "Two codepoints")]
2931 #[test_case("numpy>=1,<2\n a\u{0300}\u{0316}-broken\ntqdm", "2:6"; "Three codepoints")]
2932 fn test_calculate_line_column_pair(input: &str, expected: &str) {
2933 let mut s = Scanner::new(input);
2934 s.eat_until('-');
2936
2937 let (line, column) = calculate_row_column(input, s.cursor());
2939 let line_column = format!("{line}:{column}");
2940
2941 assert_eq!(line_column, expected, "Issues with input: {input}");
2943 }
2944
2945 #[tokio::test]
2947 async fn recursive_circular_inclusion() -> Result<()> {
2948 let temp_dir = assert_fs::TempDir::new()?;
2949 let both = temp_dir.child("both.txt");
2950 both.write_str(indoc! {"
2951 pkg-both
2952 "})?;
2953 let both = temp_dir.child("both-recursive.txt");
2954 both.write_str(indoc! {"
2955 pkg-both-recursive
2956 -r both-recursive.txt
2957 -c both-recursive.txt
2958 "})?;
2959 let requirements_only = temp_dir.child("requirements-only.txt");
2960 requirements_only.write_str(indoc! {"
2961 pkg-requirements-only
2962 -r requirements-only.txt
2963 "})?;
2964 let requirements_only = temp_dir.child("requirements-only-recursive.txt");
2965 requirements_only.write_str(indoc! {"
2966 pkg-requirements-only-recursive
2967 -r requirements-only-recursive.txt
2968 "})?;
2969 let constraints_only = temp_dir.child("requirements-in-constraints.txt");
2970 constraints_only.write_str(indoc! {"
2971 pkg-requirements-in-constraints
2972 # Some nested recursion for good measure
2973 -c constraints-only.txt
2974 "})?;
2975 let constraints_only = temp_dir.child("constraints-only.txt");
2976 constraints_only.write_str(indoc! {"
2977 pkg-constraints-only
2978 -c constraints-only.txt
2979 # Using `-r` inside `-c`
2980 -r requirements-in-constraints.txt
2981 "})?;
2982 let constraints_only = temp_dir.child("constraints-only-recursive.txt");
2983 constraints_only.write_str(indoc! {"
2984 pkg-constraints-only-recursive
2985 -r constraints-only-recursive.txt
2986 "})?;
2987
2988 let requirements = temp_dir.child("requirements.txt");
2989 requirements.write_str(indoc! {"
2990 # Even if a package was already included as a constraint, it is also included as
2991 # requirement
2992 -c both.txt
2993 -r both.txt
2994 -c both-recursive.txt
2995 -r both-recursive.txt
2996
2997 -r requirements-only.txt
2998 -r requirements-only-recursive.txt
2999 -c constraints-only.txt
3000 -c constraints-only-recursive.txt
3001 "})?;
3002
3003 let parsed = RequirementsTxt::parse(&requirements, temp_dir.path()).await?;
3004
3005 let requirements: BTreeSet<String> = parsed
3006 .requirements
3007 .iter()
3008 .map(|entry| entry.requirement.to_string())
3009 .collect();
3010 let constraints: BTreeSet<String> =
3011 parsed.constraints.iter().map(ToString::to_string).collect();
3012
3013 assert_debug_snapshot!(requirements, @r#"
3014 {
3015 "pkg-both",
3016 "pkg-both-recursive",
3017 "pkg-requirements-only",
3018 "pkg-requirements-only-recursive",
3019 }
3020 "#);
3021 assert_debug_snapshot!(constraints, @r#"
3022 {
3023 "pkg-both",
3024 "pkg-both-recursive",
3025 "pkg-constraints-only",
3026 "pkg-constraints-only-recursive",
3027 "pkg-requirements-in-constraints",
3028 }
3029 "#);
3030
3031 Ok(())
3032 }
3033}