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