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