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 mut flattened = requirements.to_vec();
372 let mut seen = FxHashSet::<(ExtraName, MarkerTree)>::default();
373 let mut queue: VecDeque<_> = flattened
374 .iter()
375 .filter(|req| req.name == *name)
376 .flat_map(|req| req.extras.iter().cloned().map(|extra| (extra, req.marker)))
377 .collect();
378 while let Some((extra, marker)) = queue.pop_front() {
379 if !seen.insert((extra.clone(), marker)) {
380 continue;
381 }
382
383 for requirement in &requirements {
386 let production_marker = requirement.marker.simplify_not_extras_with(|_| true);
387 let extra_marker = requirement
388 .marker
389 .simplify_extras(slice::from_ref(&extra))
390 .simplify_not_extras_with(|candidate| candidate != &extra)
391 .and(production_marker.negate());
392 let marker = marker.and(extra_marker);
393 if marker.is_false() {
394 continue;
395 }
396 let requirement = Requirement {
397 name: requirement.name.clone(),
398 extras: requirement.extras.clone(),
399 groups: requirement.groups.clone(),
400 source: requirement.source.clone(),
401 origin: requirement.origin.clone(),
402 marker,
403 };
404 if requirement.name == *name {
405 queue.extend(
407 requirement
408 .extras
409 .iter()
410 .cloned()
411 .map(|extra| (extra, requirement.marker)),
412 );
413 }
414
415 flattened.push(requirement);
417 }
418 }
419
420 let mut self_constraints = vec![];
424 for req in &flattened {
425 if req.name == *name && !req.source.is_empty() {
426 self_constraints.push(Requirement {
427 name: req.name.clone(),
428 extras: Box::new([]),
429 groups: req.groups.clone(),
430 source: req.source.clone(),
431 origin: req.origin.clone(),
432 marker: req.marker,
433 });
434 }
435 }
436
437 flattened.retain(|req| req.name != *name);
439 flattened.extend(self_constraints);
440
441 Self(flattened.into_boxed_slice())
442 }
443}
444
445impl IntoIterator for FlatRequiresDist {
446 type Item = Requirement;
447 type IntoIter = <Box<[Requirement]> as IntoIterator>::IntoIter;
448
449 fn into_iter(self) -> Self::IntoIter {
450 Box::into_iter(self.0)
451 }
452}
453
454#[cfg(test)]
455mod test {
456 use std::fmt::Write;
457 use std::path::Path;
458 use std::str::FromStr;
459
460 use indoc::indoc;
461 use insta::assert_snapshot;
462 use tempfile::TempDir;
463
464 use uv_auth::CredentialsCache;
465 use uv_cache::Cache;
466 use uv_configuration::NoSources;
467 use uv_distribution_types::IndexLocations;
468 use uv_normalize::PackageName;
469 use uv_pep508::Requirement;
470 use uv_workspace::{DiscoveryOptions, ProjectWorkspace, WorkspaceCache};
471
472 use crate::RequiresDist;
473 use crate::metadata::requires_dist::FlatRequiresDist;
474
475 async fn requires_dist_from_pyproject_toml(
476 temp_dir: &Path,
477 contents: &str,
478 ) -> anyhow::Result<RequiresDist> {
479 let workspace_cache = WorkspaceCache::default();
480 fs_err::create_dir_all(temp_dir)?;
481 fs_err::write(temp_dir.join("pyproject.toml"), contents)?;
482 let cache = Cache::from_path(temp_dir.join(".uv_cache"));
483 let project_workspace = ProjectWorkspace::discover(
484 temp_dir,
485 &DiscoveryOptions {
486 stop_discovery_at: Some(temp_dir.to_path_buf()),
487 ..DiscoveryOptions::default()
488 },
489 &cache,
490 &workspace_cache,
491 )
492 .await?;
493 let pyproject_toml = uv_pypi_types::PyProjectToml::from_toml(contents, "pyproject.toml")?;
494 let requires_dist = uv_pypi_types::RequiresDist::from_pyproject_toml(pyproject_toml)?;
495 Ok(RequiresDist::from_project_workspace(
496 requires_dist,
497 &project_workspace,
498 None,
499 &IndexLocations::default(),
500 &NoSources::default(),
501 true,
502 &cache,
503 &workspace_cache,
504 &CredentialsCache::new(),
505 )
506 .await?)
507 }
508
509 async fn format_err(input: &str) -> String {
510 let temp_dir = TempDir::new().unwrap();
511 let err = requires_dist_from_pyproject_toml(temp_dir.path(), input)
512 .await
513 .unwrap_err();
514 let mut causes = err.chain();
515 let mut message = String::new();
516 let _ = writeln!(message, "error: {}", causes.next().unwrap());
517 for err in causes {
518 let _ = writeln!(message, " Caused by: {err}");
519 }
520 message
521 .replace(&temp_dir.path().display().to_string(), "[PATH]")
522 .replace('\\', "/")
523 }
524
525 #[tokio::test]
526 async fn wrong_type() {
527 let input = indoc! {r#"
528 [project]
529 name = "foo"
530 version = "0.0.0"
531 dependencies = [
532 "tqdm",
533 ]
534 [tool.uv.sources]
535 tqdm = true
536 "#};
537
538 assert_snapshot!(format_err(input).await, @"
539 error: Failed to parse: `[PATH]/pyproject.toml`
540 Caused by: TOML parse error at line 8, column 8
541 |
542 8 | tqdm = true
543 | ^^^^
544 invalid type: boolean `true`, expected a single source (as a map) or list of sources
545 ");
546 }
547
548 #[tokio::test]
549 async fn too_many_git_specs() {
550 let input = indoc! {r#"
551 [project]
552 name = "foo"
553 version = "0.0.0"
554 dependencies = [
555 "tqdm",
556 ]
557 [tool.uv.sources]
558 tqdm = { git = "https://github.com/tqdm/tqdm", rev = "baaaaaab", tag = "v1.0.0" }
559 "#};
560
561 assert_snapshot!(format_err(input).await, @r#"
562 error: Failed to parse: `[PATH]/pyproject.toml`
563 Caused by: TOML parse error at line 8, column 8
564 |
565 8 | tqdm = { git = "https://github.com/tqdm/tqdm", rev = "baaaaaab", tag = "v1.0.0" }
566 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
567 expected at most one of `rev`, `tag`, or `branch`
568 "#);
569 }
570
571 #[tokio::test]
572 async fn too_many_git_typo() {
573 let input = indoc! {r#"
574 [project]
575 name = "foo"
576 version = "0.0.0"
577 dependencies = [
578 "tqdm",
579 ]
580 [tool.uv.sources]
581 tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" }
582 "#};
583
584 assert_snapshot!(format_err(input).await, @r#"
585 error: Failed to parse: `[PATH]/pyproject.toml`
586 Caused by: TOML parse error at line 8, column 48
587 |
588 8 | tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" }
589 | ^^^
590 unknown field `ref`, expected one of `git`, `subdirectory`, `rev`, `tag`, `branch`, `lfs`, `url`, `path`, `editable`, `package`, `index`, `workspace`, `marker`, `extra`, `group`
591 "#);
592 }
593
594 #[tokio::test]
595 async fn extra_and_group() {
596 let input = indoc! {r#"
597 [project]
598 name = "foo"
599 version = "0.0.0"
600 dependencies = []
601
602 [tool.uv.sources]
603 tqdm = { git = "https://github.com/tqdm/tqdm", extra = "torch", group = "dev" }
604 "#};
605
606 assert_snapshot!(format_err(input).await, @r#"
607 error: Failed to parse: `[PATH]/pyproject.toml`
608 Caused by: TOML parse error at line 7, column 8
609 |
610 7 | tqdm = { git = "https://github.com/tqdm/tqdm", extra = "torch", group = "dev" }
611 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
612 cannot specify both `extra` and `group`
613 "#);
614 }
615
616 #[tokio::test]
617 async fn you_cant_mix_those() {
618 let input = indoc! {r#"
619 [project]
620 name = "foo"
621 version = "0.0.0"
622 dependencies = [
623 "tqdm",
624 ]
625 [tool.uv.sources]
626 tqdm = { path = "tqdm", index = "torch" }
627 "#};
628
629 assert_snapshot!(format_err(input).await, @r#"
630 error: Failed to parse: `[PATH]/pyproject.toml`
631 Caused by: TOML parse error at line 8, column 8
632 |
633 8 | tqdm = { path = "tqdm", index = "torch" }
634 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
635 cannot specify both `path` and `index`
636 "#);
637 }
638
639 #[tokio::test]
640 async fn missing_constraint() {
641 let input = indoc! {r#"
642 [project]
643 name = "foo"
644 version = "0.0.0"
645 dependencies = [
646 "tqdm",
647 ]
648 "#};
649 let temp_dir = TempDir::new().unwrap();
650 assert!(
651 requires_dist_from_pyproject_toml(temp_dir.path(), input)
652 .await
653 .is_ok()
654 );
655 }
656
657 #[tokio::test]
658 async fn invalid_syntax() {
659 let input = indoc! {r#"
660 [project]
661 name = "foo"
662 version = "0.0.0"
663 dependencies = [
664 "tqdm ==4.66.0",
665 ]
666 [tool.uv.sources]
667 tqdm = { url = invalid url to tqdm-4.66.0-py3-none-any.whl" }
668 "#};
669
670 assert_snapshot!(format_err(input).await, @r#"
671 error: Failed to parse: `[PATH]/pyproject.toml`
672 Caused by: TOML parse error at line 8, column 16
673 |
674 8 | tqdm = { url = invalid url to tqdm-4.66.0-py3-none-any.whl" }
675 | ^
676 missing opening quote, expected `"`
677 "#);
678 }
679
680 #[tokio::test]
681 async fn invalid_url() {
682 let input = indoc! {r#"
683 [project]
684 name = "foo"
685 version = "0.0.0"
686 dependencies = [
687 "tqdm ==4.66.0",
688 ]
689 [tool.uv.sources]
690 tqdm = { url = "§invalid#+#*Ä" }
691 "#};
692
693 assert_snapshot!(format_err(input).await, @r#"
694 error: Failed to parse: `[PATH]/pyproject.toml`
695 Caused by: TOML parse error at line 8, column 16
696 |
697 8 | tqdm = { url = "§invalid#+#*Ä" }
698 | ^^^^^^^^^^^^^^^^^
699 relative URL without a base: "§invalid#+#*Ä"
700 "#);
701 }
702
703 #[tokio::test]
704 async fn workspace_and_url_spec() {
705 let input = indoc! {r#"
706 [project]
707 name = "foo"
708 version = "0.0.0"
709 dependencies = [
710 "tqdm @ git+https://github.com/tqdm/tqdm",
711 ]
712 [tool.uv.sources]
713 tqdm = { workspace = true }
714 "#};
715
716 assert_snapshot!(format_err(input).await, @"
717 error: Failed to parse entry: `tqdm`
718 Caused by: `tqdm` references a workspace in `tool.uv.sources` (e.g., `tqdm = { workspace = true }`), but is not a workspace member
719 ");
720 }
721
722 #[tokio::test]
723 async fn missing_workspace_package() {
724 let input = indoc! {r#"
725 [project]
726 name = "foo"
727 version = "0.0.0"
728 dependencies = [
729 "tqdm ==4.66.0",
730 ]
731 [tool.uv.sources]
732 tqdm = { workspace = true }
733 "#};
734
735 assert_snapshot!(format_err(input).await, @"
736 error: Failed to parse entry: `tqdm`
737 Caused by: `tqdm` references a workspace in `tool.uv.sources` (e.g., `tqdm = { workspace = true }`), but is not a workspace member
738 ");
739 }
740
741 #[tokio::test]
742 async fn cant_be_dynamic() {
743 let input = indoc! {r#"
744 [project]
745 name = "foo"
746 version = "0.0.0"
747 dynamic = [
748 "dependencies"
749 ]
750 [tool.uv.sources]
751 tqdm = { workspace = true }
752 "#};
753
754 assert_snapshot!(format_err(input).await, @"error: The following field was marked as dynamic: dependencies");
755 }
756
757 #[tokio::test]
758 async fn missing_project_section() {
759 let input = indoc! {"
760 [tool.uv.sources]
761 tqdm = { workspace = true }
762 "};
763
764 assert_snapshot!(format_err(input).await, @"error: No `project` table found in: [PATH]/pyproject.toml");
765 }
766
767 #[test]
768 fn test_flat_requires_dist_noop() {
769 let name = PackageName::from_str("pkg").unwrap();
770 let requirements = [
771 Requirement::from_str("requests>=2.0.0").unwrap().into(),
772 Requirement::from_str("pytest; extra == 'test'")
773 .unwrap()
774 .into(),
775 Requirement::from_str("black; extra == 'dev'")
776 .unwrap()
777 .into(),
778 ];
779
780 let expected = FlatRequiresDist(
781 [
782 Requirement::from_str("requests>=2.0.0").unwrap().into(),
783 Requirement::from_str("pytest; extra == 'test'")
784 .unwrap()
785 .into(),
786 Requirement::from_str("black; extra == 'dev'")
787 .unwrap()
788 .into(),
789 ]
790 .into(),
791 );
792
793 let actual = FlatRequiresDist::from_requirements(requirements.into(), &name);
794
795 assert_eq!(actual, expected);
796 }
797
798 #[test]
799 fn test_flat_requires_dist_basic() {
800 let name = PackageName::from_str("pkg").unwrap();
801 let requirements = [
802 Requirement::from_str("requests>=2.0.0").unwrap().into(),
803 Requirement::from_str("pytest; extra == 'test'")
804 .unwrap()
805 .into(),
806 Requirement::from_str("pkg[dev]; extra == 'test'")
807 .unwrap()
808 .into(),
809 Requirement::from_str("black; extra == 'dev'")
810 .unwrap()
811 .into(),
812 ];
813
814 let expected = FlatRequiresDist(
815 [
816 Requirement::from_str("requests>=2.0.0").unwrap().into(),
817 Requirement::from_str("pytest; extra == 'test'")
818 .unwrap()
819 .into(),
820 Requirement::from_str("black; extra == 'dev'")
821 .unwrap()
822 .into(),
823 Requirement::from_str("black; extra == 'test'")
824 .unwrap()
825 .into(),
826 ]
827 .into(),
828 );
829
830 let actual = FlatRequiresDist::from_requirements(requirements.into(), &name);
831
832 assert_eq!(actual, expected);
833 }
834
835 #[test]
836 fn test_flat_requires_dist_with_markers() {
837 let name = PackageName::from_str("pkg").unwrap();
838 let requirements = vec![
839 Requirement::from_str("requests>=2.0.0").unwrap().into(),
840 Requirement::from_str("pytest; extra == 'test'")
841 .unwrap()
842 .into(),
843 Requirement::from_str("pkg[dev]; extra == 'test' and sys_platform == 'win32'")
844 .unwrap()
845 .into(),
846 Requirement::from_str("black; extra == 'dev' and sys_platform == 'win32'")
847 .unwrap()
848 .into(),
849 ];
850
851 let expected = FlatRequiresDist(
852 [
853 Requirement::from_str("requests>=2.0.0").unwrap().into(),
854 Requirement::from_str("pytest; extra == 'test'")
855 .unwrap()
856 .into(),
857 Requirement::from_str("black; extra == 'dev' and sys_platform == 'win32'")
858 .unwrap()
859 .into(),
860 Requirement::from_str("black; extra == 'test' and sys_platform == 'win32'")
861 .unwrap()
862 .into(),
863 ]
864 .into(),
865 );
866
867 let actual = FlatRequiresDist::from_requirements(requirements.into(), &name);
868
869 assert_eq!(actual, expected);
870 }
871
872 #[test]
873 fn test_flat_requires_dist_self_constraint() {
874 let name = PackageName::from_str("pkg").unwrap();
875 let requirements = [
876 Requirement::from_str("requests>=2.0.0").unwrap().into(),
877 Requirement::from_str("pytest; extra == 'test'")
878 .unwrap()
879 .into(),
880 Requirement::from_str("black; extra == 'dev'")
881 .unwrap()
882 .into(),
883 Requirement::from_str("pkg[async]==1.0.0").unwrap().into(),
884 ];
885
886 let expected = FlatRequiresDist(
887 [
888 Requirement::from_str("requests>=2.0.0").unwrap().into(),
889 Requirement::from_str("pytest; extra == 'test'")
890 .unwrap()
891 .into(),
892 Requirement::from_str("black; extra == 'dev'")
893 .unwrap()
894 .into(),
895 Requirement::from_str("pkg==1.0.0").unwrap().into(),
896 ]
897 .into(),
898 );
899
900 let actual = FlatRequiresDist::from_requirements(requirements.into(), &name);
901
902 assert_eq!(actual, expected);
903 }
904}