1use std::collections::{BTreeMap, VecDeque};
2use std::path::Path;
3use std::slice;
4
5use rustc_hash::FxHashSet;
6
7use uv_auth::CredentialsCache;
8use uv_configuration::NoSources;
9use uv_distribution_types::{IndexLocations, Requirement};
10use uv_normalize::{ExtraName, GroupName, PackageName};
11use uv_pep508::MarkerTree;
12use uv_workspace::dependency_groups::FlatDependencyGroups;
13use uv_workspace::pyproject::{Sources, ToolUvSources};
14use uv_workspace::{DiscoveryOptions, MemberDiscovery, ProjectWorkspace, WorkspaceCache};
15
16use crate::Metadata;
17use crate::metadata::{GitWorkspaceMember, LoweredRequirement, MetadataError};
18
19#[derive(Debug, Clone)]
20pub struct RequiresDist {
21 pub name: PackageName,
22 pub requires_dist: Box<[Requirement]>,
23 pub provides_extra: Box<[ExtraName]>,
24 pub dependency_groups: BTreeMap<GroupName, Box<[Requirement]>>,
25 pub dynamic: bool,
26}
27
28impl RequiresDist {
29 pub(crate) async fn from_project_maybe_workspace(
32 metadata: uv_pypi_types::RequiresDist,
33 install_path: &Path,
34 git_member: Option<&GitWorkspaceMember<'_>>,
35 locations: &IndexLocations,
36 sources: NoSources,
37 editable: bool,
38 cache: &WorkspaceCache,
39 credentials_cache: &CredentialsCache,
40 ) -> Result<Self, MetadataError> {
41 let discovery = DiscoveryOptions {
42 stop_discovery_at: git_member.map(|git_member| {
43 git_member
44 .fetch_root
45 .parent()
46 .expect("git checkout has a parent")
47 .to_path_buf()
48 }),
49 members: if sources.is_none() {
50 MemberDiscovery::default()
51 } else {
52 MemberDiscovery::None
53 },
54 ..DiscoveryOptions::default()
55 };
56 let Some(project_workspace) =
57 ProjectWorkspace::from_maybe_project_root(install_path, &discovery, cache).await?
58 else {
59 return Self::from_metadata23_with_source_context(metadata, git_member);
60 };
61
62 Self::from_project_workspace(
63 metadata,
64 &project_workspace,
65 git_member,
66 locations,
67 &sources,
68 editable,
69 credentials_cache,
70 )
71 }
72
73 fn from_metadata23_with_source_context(
74 metadata: uv_pypi_types::RequiresDist,
75 git_member: Option<&GitWorkspaceMember<'_>>,
76 ) -> Result<Self, MetadataError> {
77 let requires_dist = Box::into_iter(metadata.requires_dist)
78 .map(|requirement| {
79 let requirement_name = requirement.name.clone();
80 LoweredRequirement::preserve_git_source(requirement, git_member)
81 .map(LoweredRequirement::into_inner)
82 .map_err(|err| MetadataError::LoweringError(requirement_name, Box::new(err)))
83 })
84 .collect::<Result<Box<_>, _>>()?;
85
86 Ok(Self {
87 name: metadata.name,
88 requires_dist,
89 provides_extra: metadata.provides_extra,
90 dependency_groups: BTreeMap::default(),
91 dynamic: metadata.dynamic,
92 })
93 }
94
95 fn from_project_workspace(
96 metadata: uv_pypi_types::RequiresDist,
97 project_workspace: &ProjectWorkspace,
98 git_member: Option<&GitWorkspaceMember<'_>>,
99 locations: &IndexLocations,
100 no_sources: &NoSources,
101 editable: bool,
102 credentials_cache: &CredentialsCache,
103 ) -> Result<Self, MetadataError> {
104 let empty = vec![];
106 let project_indexes = project_workspace
107 .current_project()
108 .pyproject_toml()
109 .tool
110 .as_ref()
111 .and_then(|tool| tool.uv.as_ref())
112 .and_then(|uv| uv.index.as_deref())
113 .unwrap_or(&empty);
114
115 let empty = BTreeMap::default();
117 let project_sources = project_workspace
118 .current_project()
119 .pyproject_toml()
120 .tool
121 .as_ref()
122 .and_then(|tool| tool.uv.as_ref())
123 .and_then(|uv| uv.sources.as_ref())
124 .map(ToolUvSources::inner)
125 .unwrap_or(&empty);
126
127 let dependency_groups = FlatDependencyGroups::from_pyproject_toml(
128 project_workspace.current_project().root(),
129 project_workspace.current_project().pyproject_toml(),
130 )?;
131
132 Self::validate_sources(project_sources, &metadata, &dependency_groups)?;
135
136 let dependency_groups = dependency_groups
138 .into_iter()
139 .map(|(name, flat_group)| {
140 let requirements = flat_group
141 .requirements
142 .into_iter()
143 .flat_map(|requirement| {
144 if no_sources.for_package(&requirement.name) {
146 vec![Ok(Requirement::from(requirement))].into_iter()
147 } else {
148 let requirement_name = requirement.name.clone();
149 let group = name.clone();
150 let extra = None;
151
152 LoweredRequirement::from_requirement(
153 requirement,
154 Some(&metadata.name),
155 project_workspace.project_root(),
156 project_sources,
157 project_indexes,
158 extra,
159 Some(&group),
160 locations,
161 project_workspace.workspace(),
162 git_member,
163 editable,
164 credentials_cache,
165 )
166 .map(move |requirement| match requirement {
167 Ok(requirement) => Ok(requirement.into_inner()),
168 Err(err) => Err(MetadataError::GroupLoweringError(
169 group.clone(),
170 requirement_name.clone(),
171 Box::new(err),
172 )),
173 })
174 .collect::<Vec<_>>()
175 .into_iter()
176 }
177 })
178 .collect::<Result<Box<_>, _>>()?;
179 Ok::<(GroupName, Box<_>), MetadataError>((name, requirements))
180 })
181 .collect::<Result<BTreeMap<_, _>, _>>()?;
182
183 let requires_dist = Box::into_iter(metadata.requires_dist);
185 let requires_dist = requires_dist
186 .flat_map(|requirement| {
187 if no_sources.for_package(&requirement.name) {
189 vec![Ok(Requirement::from(requirement))].into_iter()
190 } else {
191 let requirement_name = requirement.name.clone();
192 let extra = requirement.marker.top_level_extra_name();
193 let group = None;
194
195 LoweredRequirement::from_requirement(
196 requirement,
197 Some(&metadata.name),
198 project_workspace.project_root(),
199 project_sources,
200 project_indexes,
201 extra.as_deref(),
202 group,
203 locations,
204 project_workspace.workspace(),
205 git_member,
206 editable,
207 credentials_cache,
208 )
209 .map(move |requirement| match requirement {
210 Ok(requirement) => Ok(requirement.into_inner()),
211 Err(err) => Err(MetadataError::LoweringError(
212 requirement_name.clone(),
213 Box::new(err),
214 )),
215 })
216 .collect::<Vec<_>>()
217 .into_iter()
218 }
219 })
220 .collect::<Result<Box<_>, _>>()?;
221
222 Ok(Self {
223 name: metadata.name,
224 requires_dist,
225 dependency_groups,
226 provides_extra: metadata.provides_extra,
227 dynamic: metadata.dynamic,
228 })
229 }
230
231 fn validate_sources(
236 sources: &BTreeMap<PackageName, Sources>,
237 metadata: &uv_pypi_types::RequiresDist,
238 dependency_groups: &FlatDependencyGroups,
239 ) -> Result<(), MetadataError> {
240 for (name, sources) in sources {
241 for source in sources.iter() {
242 if let Some(extra) = source.extra() {
243 if !metadata.provides_extra.contains(extra) {
245 return Err(MetadataError::MissingSourceExtra(
246 name.clone(),
247 extra.clone(),
248 ));
249 }
250
251 if !metadata.requires_dist.iter().any(|requirement| {
253 requirement.name == *name
254 && requirement.marker.top_level_extra_name().as_deref() == Some(extra)
255 }) {
256 return Err(MetadataError::IncompleteSourceExtra(
257 name.clone(),
258 extra.clone(),
259 ));
260 }
261 }
262
263 if let Some(group) = source.group() {
264 let Some(flat_group) = dependency_groups.get(group) else {
266 return Err(MetadataError::MissingSourceGroup(
267 name.clone(),
268 group.clone(),
269 ));
270 };
271
272 if !flat_group
274 .requirements
275 .iter()
276 .any(|requirement| requirement.name == *name)
277 {
278 return Err(MetadataError::IncompleteSourceGroup(
279 name.clone(),
280 group.clone(),
281 ));
282 }
283 }
284 }
285 }
286
287 Ok(())
288 }
289}
290
291impl From<Metadata> for RequiresDist {
292 fn from(metadata: Metadata) -> Self {
293 Self {
294 name: metadata.name,
295 requires_dist: metadata.requires_dist,
296 provides_extra: metadata.provides_extra,
297 dependency_groups: metadata.dependency_groups,
298 dynamic: metadata.dynamic,
299 }
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct FlatRequiresDist(Box<[Requirement]>);
351
352impl FlatRequiresDist {
353 pub fn from_requirements(requirements: Box<[Requirement]>, name: &PackageName) -> Self {
355 if requirements.iter().all(|req| req.name != *name) {
357 return Self(requirements);
358 }
359
360 let top_level_extras: Vec<_> = requirements
362 .iter()
363 .map(|req| req.marker.top_level_extra_name())
364 .collect();
365
366 let mut flattened = requirements.to_vec();
368 let mut seen = FxHashSet::<(ExtraName, MarkerTree)>::default();
369 let mut queue: VecDeque<_> = flattened
370 .iter()
371 .filter(|req| req.name == *name)
372 .flat_map(|req| req.extras.iter().cloned().map(|extra| (extra, req.marker)))
373 .collect();
374 while let Some((extra, marker)) = queue.pop_front() {
375 if !seen.insert((extra.clone(), marker)) {
376 continue;
377 }
378
379 for (requirement, top_level_extra) in requirements.iter().zip(top_level_extras.iter()) {
381 if top_level_extra.as_deref() != Some(&extra) {
382 continue;
383 }
384 let requirement = {
385 let mut marker = marker;
386 marker.and(requirement.marker);
387 Requirement {
388 name: requirement.name.clone(),
389 extras: requirement.extras.clone(),
390 groups: requirement.groups.clone(),
391 source: requirement.source.clone(),
392 origin: requirement.origin.clone(),
393 marker: marker.simplify_extras(slice::from_ref(&extra)),
394 }
395 };
396 if requirement.name == *name {
397 queue.extend(
399 requirement
400 .extras
401 .iter()
402 .cloned()
403 .map(|extra| (extra, requirement.marker)),
404 );
405 } else {
406 flattened.push(requirement);
408 }
409 }
410 }
411
412 flattened.retain(|req| req.name != *name);
414
415 for req in &requirements {
419 if req.name == *name {
420 if !req.source.is_empty() {
421 flattened.push(Requirement {
422 name: req.name.clone(),
423 extras: Box::new([]),
424 groups: req.groups.clone(),
425 source: req.source.clone(),
426 origin: req.origin.clone(),
427 marker: req.marker,
428 });
429 }
430 }
431 }
432
433 Self(flattened.into_boxed_slice())
434 }
435}
436
437impl IntoIterator for FlatRequiresDist {
438 type Item = Requirement;
439 type IntoIter = <Box<[Requirement]> as IntoIterator>::IntoIter;
440
441 fn into_iter(self) -> Self::IntoIter {
442 Box::into_iter(self.0)
443 }
444}
445
446#[cfg(test)]
447mod test {
448 use std::fmt::Write;
449 use std::path::Path;
450 use std::str::FromStr;
451
452 use indoc::indoc;
453 use insta::assert_snapshot;
454 use tempfile::TempDir;
455
456 use uv_auth::CredentialsCache;
457 use uv_configuration::NoSources;
458 use uv_distribution_types::IndexLocations;
459 use uv_normalize::PackageName;
460 use uv_pep508::Requirement;
461 use uv_workspace::{DiscoveryOptions, ProjectWorkspace, WorkspaceCache};
462
463 use crate::RequiresDist;
464 use crate::metadata::requires_dist::FlatRequiresDist;
465
466 async fn requires_dist_from_pyproject_toml(
467 temp_dir: &Path,
468 contents: &str,
469 ) -> anyhow::Result<RequiresDist> {
470 fs_err::write(temp_dir.join("pyproject.toml"), contents)?;
471 let project_workspace = ProjectWorkspace::discover(
472 temp_dir,
473 &DiscoveryOptions {
474 stop_discovery_at: Some(temp_dir.to_path_buf()),
475 ..DiscoveryOptions::default()
476 },
477 &WorkspaceCache::default(),
478 )
479 .await?;
480 let pyproject_toml = uv_pypi_types::PyProjectToml::from_toml(contents, "pyproject.toml")?;
481 let requires_dist = uv_pypi_types::RequiresDist::from_pyproject_toml(pyproject_toml)?;
482 Ok(RequiresDist::from_project_workspace(
483 requires_dist,
484 &project_workspace,
485 None,
486 &IndexLocations::default(),
487 &NoSources::default(),
488 true,
489 &CredentialsCache::new(),
490 )?)
491 }
492
493 async fn format_err(input: &str) -> String {
494 let temp_dir = TempDir::new().unwrap();
495 let err = requires_dist_from_pyproject_toml(temp_dir.path(), input)
496 .await
497 .unwrap_err();
498 let mut causes = err.chain();
499 let mut message = String::new();
500 let _ = writeln!(message, "error: {}", causes.next().unwrap());
501 for err in causes {
502 let _ = writeln!(message, " Caused by: {err}");
503 }
504 message
505 .replace(&temp_dir.path().display().to_string(), "[PATH]")
506 .replace('\\', "/")
507 }
508
509 #[tokio::test]
510 async fn wrong_type() {
511 let input = indoc! {r#"
512 [project]
513 name = "foo"
514 version = "0.0.0"
515 dependencies = [
516 "tqdm",
517 ]
518 [tool.uv.sources]
519 tqdm = true
520 "#};
521
522 assert_snapshot!(format_err(input).await, @"
523 error: Failed to parse: `[PATH]/pyproject.toml`
524 Caused by: TOML parse error at line 8, column 8
525 |
526 8 | tqdm = true
527 | ^^^^
528 invalid type: boolean `true`, expected a single source (as a map) or list of sources
529 ");
530 }
531
532 #[tokio::test]
533 async fn too_many_git_specs() {
534 let input = indoc! {r#"
535 [project]
536 name = "foo"
537 version = "0.0.0"
538 dependencies = [
539 "tqdm",
540 ]
541 [tool.uv.sources]
542 tqdm = { git = "https://github.com/tqdm/tqdm", rev = "baaaaaab", tag = "v1.0.0" }
543 "#};
544
545 assert_snapshot!(format_err(input).await, @r#"
546 error: Failed to parse: `[PATH]/pyproject.toml`
547 Caused by: TOML parse error at line 8, column 8
548 |
549 8 | tqdm = { git = "https://github.com/tqdm/tqdm", rev = "baaaaaab", tag = "v1.0.0" }
550 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
551 expected at most one of `rev`, `tag`, or `branch`
552 "#);
553 }
554
555 #[tokio::test]
556 async fn too_many_git_typo() {
557 let input = indoc! {r#"
558 [project]
559 name = "foo"
560 version = "0.0.0"
561 dependencies = [
562 "tqdm",
563 ]
564 [tool.uv.sources]
565 tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" }
566 "#};
567
568 assert_snapshot!(format_err(input).await, @r#"
569 error: Failed to parse: `[PATH]/pyproject.toml`
570 Caused by: TOML parse error at line 8, column 48
571 |
572 8 | tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" }
573 | ^^^
574 unknown field `ref`, expected one of `git`, `subdirectory`, `rev`, `tag`, `branch`, `lfs`, `url`, `path`, `editable`, `package`, `index`, `workspace`, `marker`, `extra`, `group`
575 "#);
576 }
577
578 #[tokio::test]
579 async fn extra_and_group() {
580 let input = indoc! {r#"
581 [project]
582 name = "foo"
583 version = "0.0.0"
584 dependencies = []
585
586 [tool.uv.sources]
587 tqdm = { git = "https://github.com/tqdm/tqdm", extra = "torch", group = "dev" }
588 "#};
589
590 assert_snapshot!(format_err(input).await, @r#"
591 error: Failed to parse: `[PATH]/pyproject.toml`
592 Caused by: TOML parse error at line 7, column 8
593 |
594 7 | tqdm = { git = "https://github.com/tqdm/tqdm", extra = "torch", group = "dev" }
595 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
596 cannot specify both `extra` and `group`
597 "#);
598 }
599
600 #[tokio::test]
601 async fn you_cant_mix_those() {
602 let input = indoc! {r#"
603 [project]
604 name = "foo"
605 version = "0.0.0"
606 dependencies = [
607 "tqdm",
608 ]
609 [tool.uv.sources]
610 tqdm = { path = "tqdm", index = "torch" }
611 "#};
612
613 assert_snapshot!(format_err(input).await, @r#"
614 error: Failed to parse: `[PATH]/pyproject.toml`
615 Caused by: TOML parse error at line 8, column 8
616 |
617 8 | tqdm = { path = "tqdm", index = "torch" }
618 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
619 cannot specify both `path` and `index`
620 "#);
621 }
622
623 #[tokio::test]
624 async fn missing_constraint() {
625 let input = indoc! {r#"
626 [project]
627 name = "foo"
628 version = "0.0.0"
629 dependencies = [
630 "tqdm",
631 ]
632 "#};
633 let temp_dir = TempDir::new().unwrap();
634 assert!(
635 requires_dist_from_pyproject_toml(temp_dir.path(), input)
636 .await
637 .is_ok()
638 );
639 }
640
641 #[tokio::test]
642 async fn invalid_syntax() {
643 let input = indoc! {r#"
644 [project]
645 name = "foo"
646 version = "0.0.0"
647 dependencies = [
648 "tqdm ==4.66.0",
649 ]
650 [tool.uv.sources]
651 tqdm = { url = invalid url to tqdm-4.66.0-py3-none-any.whl" }
652 "#};
653
654 assert_snapshot!(format_err(input).await, @r#"
655 error: Failed to parse: `[PATH]/pyproject.toml`
656 Caused by: TOML parse error at line 8, column 16
657 |
658 8 | tqdm = { url = invalid url to tqdm-4.66.0-py3-none-any.whl" }
659 | ^
660 missing opening quote, expected `"`
661 "#);
662 }
663
664 #[tokio::test]
665 async fn invalid_url() {
666 let input = indoc! {r#"
667 [project]
668 name = "foo"
669 version = "0.0.0"
670 dependencies = [
671 "tqdm ==4.66.0",
672 ]
673 [tool.uv.sources]
674 tqdm = { url = "§invalid#+#*Ä" }
675 "#};
676
677 assert_snapshot!(format_err(input).await, @r#"
678 error: Failed to parse: `[PATH]/pyproject.toml`
679 Caused by: TOML parse error at line 8, column 16
680 |
681 8 | tqdm = { url = "§invalid#+#*Ä" }
682 | ^^^^^^^^^^^^^^^^^
683 relative URL without a base: "§invalid#+#*Ä"
684 "#);
685 }
686
687 #[tokio::test]
688 async fn workspace_and_url_spec() {
689 let input = indoc! {r#"
690 [project]
691 name = "foo"
692 version = "0.0.0"
693 dependencies = [
694 "tqdm @ git+https://github.com/tqdm/tqdm",
695 ]
696 [tool.uv.sources]
697 tqdm = { workspace = true }
698 "#};
699
700 assert_snapshot!(format_err(input).await, @"
701 error: Failed to parse entry: `tqdm`
702 Caused by: `tqdm` references a workspace in `tool.uv.sources` (e.g., `tqdm = { workspace = true }`), but is not a workspace member
703 ");
704 }
705
706 #[tokio::test]
707 async fn missing_workspace_package() {
708 let input = indoc! {r#"
709 [project]
710 name = "foo"
711 version = "0.0.0"
712 dependencies = [
713 "tqdm ==4.66.0",
714 ]
715 [tool.uv.sources]
716 tqdm = { workspace = true }
717 "#};
718
719 assert_snapshot!(format_err(input).await, @"
720 error: Failed to parse entry: `tqdm`
721 Caused by: `tqdm` references a workspace in `tool.uv.sources` (e.g., `tqdm = { workspace = true }`), but is not a workspace member
722 ");
723 }
724
725 #[tokio::test]
726 async fn cant_be_dynamic() {
727 let input = indoc! {r#"
728 [project]
729 name = "foo"
730 version = "0.0.0"
731 dynamic = [
732 "dependencies"
733 ]
734 [tool.uv.sources]
735 tqdm = { workspace = true }
736 "#};
737
738 assert_snapshot!(format_err(input).await, @"error: The following field was marked as dynamic: dependencies");
739 }
740
741 #[tokio::test]
742 async fn missing_project_section() {
743 let input = indoc! {"
744 [tool.uv.sources]
745 tqdm = { workspace = true }
746 "};
747
748 assert_snapshot!(format_err(input).await, @"error: No `project` table found in: [PATH]/pyproject.toml");
749 }
750
751 #[test]
752 fn test_flat_requires_dist_noop() {
753 let name = PackageName::from_str("pkg").unwrap();
754 let requirements = [
755 Requirement::from_str("requests>=2.0.0").unwrap().into(),
756 Requirement::from_str("pytest; extra == 'test'")
757 .unwrap()
758 .into(),
759 Requirement::from_str("black; extra == 'dev'")
760 .unwrap()
761 .into(),
762 ];
763
764 let expected = FlatRequiresDist(
765 [
766 Requirement::from_str("requests>=2.0.0").unwrap().into(),
767 Requirement::from_str("pytest; extra == 'test'")
768 .unwrap()
769 .into(),
770 Requirement::from_str("black; extra == 'dev'")
771 .unwrap()
772 .into(),
773 ]
774 .into(),
775 );
776
777 let actual = FlatRequiresDist::from_requirements(requirements.into(), &name);
778
779 assert_eq!(actual, expected);
780 }
781
782 #[test]
783 fn test_flat_requires_dist_basic() {
784 let name = PackageName::from_str("pkg").unwrap();
785 let requirements = [
786 Requirement::from_str("requests>=2.0.0").unwrap().into(),
787 Requirement::from_str("pytest; extra == 'test'")
788 .unwrap()
789 .into(),
790 Requirement::from_str("pkg[dev]; extra == 'test'")
791 .unwrap()
792 .into(),
793 Requirement::from_str("black; extra == 'dev'")
794 .unwrap()
795 .into(),
796 ];
797
798 let expected = FlatRequiresDist(
799 [
800 Requirement::from_str("requests>=2.0.0").unwrap().into(),
801 Requirement::from_str("pytest; extra == 'test'")
802 .unwrap()
803 .into(),
804 Requirement::from_str("black; extra == 'dev'")
805 .unwrap()
806 .into(),
807 Requirement::from_str("black; extra == 'test'")
808 .unwrap()
809 .into(),
810 ]
811 .into(),
812 );
813
814 let actual = FlatRequiresDist::from_requirements(requirements.into(), &name);
815
816 assert_eq!(actual, expected);
817 }
818
819 #[test]
820 fn test_flat_requires_dist_with_markers() {
821 let name = PackageName::from_str("pkg").unwrap();
822 let requirements = vec![
823 Requirement::from_str("requests>=2.0.0").unwrap().into(),
824 Requirement::from_str("pytest; extra == 'test'")
825 .unwrap()
826 .into(),
827 Requirement::from_str("pkg[dev]; extra == 'test' and sys_platform == 'win32'")
828 .unwrap()
829 .into(),
830 Requirement::from_str("black; extra == 'dev' and sys_platform == 'win32'")
831 .unwrap()
832 .into(),
833 ];
834
835 let expected = FlatRequiresDist(
836 [
837 Requirement::from_str("requests>=2.0.0").unwrap().into(),
838 Requirement::from_str("pytest; extra == 'test'")
839 .unwrap()
840 .into(),
841 Requirement::from_str("black; extra == 'dev' and sys_platform == 'win32'")
842 .unwrap()
843 .into(),
844 Requirement::from_str("black; extra == 'test' and sys_platform == 'win32'")
845 .unwrap()
846 .into(),
847 ]
848 .into(),
849 );
850
851 let actual = FlatRequiresDist::from_requirements(requirements.into(), &name);
852
853 assert_eq!(actual, expected);
854 }
855
856 #[test]
857 fn test_flat_requires_dist_self_constraint() {
858 let name = PackageName::from_str("pkg").unwrap();
859 let requirements = [
860 Requirement::from_str("requests>=2.0.0").unwrap().into(),
861 Requirement::from_str("pytest; extra == 'test'")
862 .unwrap()
863 .into(),
864 Requirement::from_str("black; extra == 'dev'")
865 .unwrap()
866 .into(),
867 Requirement::from_str("pkg[async]==1.0.0").unwrap().into(),
868 ];
869
870 let expected = FlatRequiresDist(
871 [
872 Requirement::from_str("requests>=2.0.0").unwrap().into(),
873 Requirement::from_str("pytest; extra == 'test'")
874 .unwrap()
875 .into(),
876 Requirement::from_str("black; extra == 'dev'")
877 .unwrap()
878 .into(),
879 Requirement::from_str("pkg==1.0.0").unwrap().into(),
880 ]
881 .into(),
882 );
883
884 let actual = FlatRequiresDist::from_requirements(requirements.into(), &name);
885
886 assert_eq!(actual, expected);
887 }
888}