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