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