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