Skip to main content

uv_distribution/metadata/
requires_dist.rs

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    /// Lower by considering `tool.uv` in `pyproject.toml` if present, used for Git and directory
30    /// dependencies.
31    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        // Collect any `tool.uv.index` entries.
105        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        // Collect any `tool.uv.sources` and `tool.uv.dev_dependencies` from `pyproject.toml`.
116        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        // Now that we've resolved the dependency groups, we can validate that each source references
133        // a valid extra or group, if present.
134        Self::validate_sources(project_sources, &metadata, &dependency_groups)?;
135
136        // Lower the dependency groups.
137        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                        // Check if sources should be disabled for this specific package
145                        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        // Lower the requirements.
184        let requires_dist = Box::into_iter(metadata.requires_dist);
185        let requires_dist = requires_dist
186            .flat_map(|requirement| {
187                // Check if sources should be disabled for this specific package
188                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    /// Validate the sources for a given [`uv_pypi_types::RequiresDist`].
232    ///
233    /// If a source is requested with an `extra` or `group`, ensure that the relevant dependency is
234    /// present in the relevant `project.optional-dependencies` or `dependency-groups` section.
235    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 the extra doesn't exist at all, error.
244                    if !metadata.provides_extra.contains(extra) {
245                        return Err(MetadataError::MissingSourceExtra(
246                            name.clone(),
247                            extra.clone(),
248                        ));
249                    }
250
251                    // If there is no such requirement with the extra, error.
252                    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                    // If the group doesn't exist at all, error.
265                    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 there is no such requirement with the group, error.
273                    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/// Like [`uv_pypi_types::RequiresDist`], but with any recursive (or self-referential) dependencies
304/// resolved.
305///
306/// For example, given:
307/// ```toml
308/// [project]
309/// name = "example"
310/// version = "0.1.0"
311/// requires-python = ">=3.13.0"
312/// dependencies = []
313///
314/// [project.optional-dependencies]
315/// all = [
316///     "example[async]",
317/// ]
318/// async = [
319///     "fastapi",
320/// ]
321/// ```
322///
323/// A build backend could return:
324/// ```txt
325/// Metadata-Version: 2.2
326/// Name: example
327/// Version: 0.1.0
328/// Requires-Python: >=3.13.0
329/// Provides-Extra: all
330/// Requires-Dist: example[async]; extra == "all"
331/// Provides-Extra: async
332/// Requires-Dist: fastapi; extra == "async"
333/// ```
334///
335/// Or:
336/// ```txt
337/// Metadata-Version: 2.4
338/// Name: example
339/// Version: 0.1.0
340/// Requires-Python: >=3.13.0
341/// Provides-Extra: all
342/// Requires-Dist: fastapi; extra == 'all'
343/// Provides-Extra: async
344/// Requires-Dist: fastapi; extra == 'async'
345/// ```
346///
347/// The [`FlatRequiresDist`] struct is used to flatten out the recursive dependencies, i.e., convert
348/// from the former to the latter.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct FlatRequiresDist(Box<[Requirement]>);
351
352impl FlatRequiresDist {
353    /// Flatten a set of requirements, resolving any self-references.
354    pub fn from_requirements(requirements: Box<[Requirement]>, name: &PackageName) -> Self {
355        // If there are no self-references, we can return early.
356        if requirements.iter().all(|req| req.name != *name) {
357            return Self(requirements);
358        }
359
360        // Memoize the top level extras, in the same order as `requirements`
361        let top_level_extras: Vec<_> = requirements
362            .iter()
363            .map(|req| req.marker.top_level_extra_name())
364            .collect();
365
366        // Transitively process all extras that are recursively included.
367        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            // Find the requirements for the extra.
380            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                    // Add each transitively included extra.
398                    queue.extend(
399                        requirement
400                            .extras
401                            .iter()
402                            .cloned()
403                            .map(|extra| (extra, requirement.marker)),
404                    );
405                } else {
406                    // Add the requirements for that extra.
407                    flattened.push(requirement);
408                }
409            }
410        }
411
412        // Drop all the self-references now that we've flattened them out.
413        flattened.retain(|req| req.name != *name);
414
415        // Retain any self-constraints for that extra, e.g., if `project[foo]` includes
416        // `project[bar]>1.0`, as a dependency, we need to propagate `project>1.0`, in addition to
417        // transitively expanding `project[bar]`.
418        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}