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