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_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    /// Lower by considering `tool.uv` in `pyproject.toml` if present, used for Git and directory
31    /// dependencies.
32    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        // Collect any `tool.uv.index` entries.
111        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        // Collect any `tool.uv.sources` and `tool.uv.dev_dependencies` from `pyproject.toml`.
122        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        // Now that we've resolved the dependency groups, we can validate that each source references
139        // a valid extra or group, if present.
140        Self::validate_sources(project_sources, &metadata, &dependency_groups)?;
141
142        // Lower the dependency groups.
143        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                        // Check if sources should be disabled for this specific package
151                        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        // Lower the requirements.
190        let requires_dist = Box::into_iter(metadata.requires_dist);
191        let requires_dist = requires_dist
192            .flat_map(|requirement| {
193                // Check if sources should be disabled for this specific package
194                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    /// Validate the sources for a given [`uv_pypi_types::RequiresDist`].
238    ///
239    /// If a source is requested with an `extra` or `group`, ensure that the relevant dependency is
240    /// present in the relevant `project.optional-dependencies` or `dependency-groups` section.
241    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 the extra doesn't exist at all, error.
250                    if !metadata.provides_extra.contains(extra) {
251                        return Err(MetadataError::MissingSourceExtra(
252                            name.clone(),
253                            extra.clone(),
254                        ));
255                    }
256
257                    // If there is no such requirement with the extra, error.
258                    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                    // If the group doesn't exist at all, error.
271                    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 there is no such requirement with the group, error.
279                    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/// Like [`uv_pypi_types::RequiresDist`], but with any recursive (or self-referential) dependencies
310/// resolved.
311///
312/// For example, given:
313/// ```toml
314/// [project]
315/// name = "example"
316/// version = "0.1.0"
317/// requires-python = ">=3.13.0"
318/// dependencies = []
319///
320/// [project.optional-dependencies]
321/// all = [
322///     "example[async]",
323/// ]
324/// async = [
325///     "fastapi",
326/// ]
327/// ```
328///
329/// A build backend could return:
330/// ```txt
331/// Metadata-Version: 2.2
332/// Name: example
333/// Version: 0.1.0
334/// Requires-Python: >=3.13.0
335/// Provides-Extra: all
336/// Requires-Dist: example[async]; extra == "all"
337/// Provides-Extra: async
338/// Requires-Dist: fastapi; extra == "async"
339/// ```
340///
341/// Or:
342/// ```txt
343/// Metadata-Version: 2.4
344/// Name: example
345/// Version: 0.1.0
346/// Requires-Python: >=3.13.0
347/// Provides-Extra: all
348/// Requires-Dist: fastapi; extra == 'all'
349/// Provides-Extra: async
350/// Requires-Dist: fastapi; extra == 'async'
351/// ```
352///
353/// The [`FlatRequiresDist`] struct is used to flatten out the recursive dependencies, i.e., convert
354/// from the former to the latter.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct FlatRequiresDist(Box<[Requirement]>);
357
358impl FlatRequiresDist {
359    /// Flatten a set of requirements, resolving any self-references.
360    pub fn from_requirements(requirements: Box<[Requirement]>, name: &PackageName) -> Self {
361        // If there are no self-references, we can return early.
362        if requirements.iter().all(|req| req.name != *name) {
363            return Self(requirements);
364        }
365
366        // Memoize the top level extras, in the same order as `requirements`
367        let top_level_extras: Vec<_> = requirements
368            .iter()
369            .map(|req| req.marker.top_level_extra_name())
370            .collect();
371
372        // Transitively process all extras that are recursively included.
373        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            // Find the requirements for the extra.
386            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                    // Add each transitively included extra.
404                    queue.extend(
405                        requirement
406                            .extras
407                            .iter()
408                            .cloned()
409                            .map(|extra| (extra, requirement.marker)),
410                    );
411                } else {
412                    // Add the requirements for that extra.
413                    flattened.push(requirement);
414                }
415            }
416        }
417
418        // Drop all the self-references now that we've flattened them out.
419        flattened.retain(|req| req.name != *name);
420
421        // Retain any self-constraints for that extra, e.g., if `project[foo]` includes
422        // `project[bar]>1.0`, as a dependency, we need to propagate `project>1.0`, in addition to
423        // transitively expanding `project[bar]`.
424        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}