Skip to main content

uv_build_backend/
metadata.rs

1use indexmap::IndexMap;
2use itertools::Itertools;
3use serde::{Deserialize, Deserializer};
4use std::borrow::Cow;
5use std::collections::{BTreeMap, Bound};
6use std::ffi::OsStr;
7use std::fmt::Display;
8use std::fmt::Write;
9use std::path::{Path, PathBuf};
10use std::str::{self, FromStr};
11use tracing::{debug, trace, warn};
12use version_ranges::Ranges;
13use walkdir::WalkDir;
14
15use uv_fs::Simplified;
16use uv_globfilter::{GlobDirFilter, PortableGlobParser};
17use uv_normalize::{ExtraName, PackageName};
18use uv_pep440::{Version, VersionSpecifiers};
19use uv_pep508::{
20    ExtraOperator, MarkerExpression, MarkerTree, MarkerValueExtra, Requirement, VersionOrUrl,
21};
22use uv_pypi_types::{Keywords, Metadata23, ProjectUrls, VerbatimParsedUrl};
23
24use crate::serde_verbatim::SerdeVerbatim;
25use crate::{BuildBackendSettings, Error, error_on_venv};
26
27/// By default, we ignore generated python files.
28pub(crate) const DEFAULT_EXCLUDES: &[&str] = &["__pycache__", "*.pyc", "*.pyo"];
29
30#[derive(Debug, Error)]
31pub enum ValidationError {
32    /// The spec isn't clear about what the values in that field would be, and we only support the
33    /// default value (UTF-8).
34    #[error(
35        "Charsets other than UTF-8 are not supported. Please convert your README to UTF-8 and remove `project.readme.charset`."
36    )]
37    ReadmeCharset,
38    #[error(
39        "Unknown Readme extension `{0}`, can't determine content type. Please use a support extension (`.md`, `.rst`, `.txt`) or set the content type manually."
40    )]
41    UnknownExtension(String),
42    #[error("Can't infer content type because `{}` does not have an extension. Please use a support extension (`.md`, `.rst`, `.txt`) or set the content type manually.", _0.user_display())]
43    MissingExtension(PathBuf),
44    #[error("Unsupported content type: {0}")]
45    UnsupportedContentType(String),
46    #[error("`project.description` must be a single line")]
47    DescriptionNewlines,
48    #[error("Dynamic metadata is not supported")]
49    Dynamic,
50    #[error(
51        "When `project.license-files` is defined, `project.license` must be an SPDX expression string"
52    )]
53    MixedLicenseGenerations,
54    #[error(
55        "Entrypoint groups must consist of letters and numbers separated by dots, invalid group: {0}"
56    )]
57    InvalidGroup(String),
58    #[error("Use `project.scripts` instead of `project.entry-points.console_scripts`")]
59    ReservedScripts,
60    #[error("Use `project.gui-scripts` instead of `project.entry-points.gui_scripts`")]
61    ReservedGuiScripts,
62    #[error("`project.license` is not a valid SPDX expression: {0}")]
63    InvalidSpdx(String, #[source] spdx::error::ParseError),
64    #[error("`{field}` glob `{glob}` did not match any files")]
65    LicenseGlobNoMatches { field: String, glob: String },
66    #[error("License file `{}` must be UTF-8 encoded", _0)]
67    LicenseFileNotUtf8(String),
68}
69
70/// Check if the build backend is matching the currently running uv version.
71pub fn check_direct_build(source_tree: &Path, name: impl Display) -> bool {
72    #[derive(Deserialize)]
73    #[serde(rename_all = "kebab-case")]
74    struct PyProjectToml {
75        build_system: BuildSystem,
76    }
77
78    let pyproject_toml: PyProjectToml =
79        match fs_err::read_to_string(source_tree.join("pyproject.toml"))
80            .map_err(|err| err.to_string())
81            .and_then(|pyproject_toml| {
82                toml::from_str(&pyproject_toml).map_err(|err| err.to_string())
83            }) {
84            Ok(pyproject_toml) => pyproject_toml,
85            Err(err) => {
86                debug!(
87                    "Not using uv build backend direct build for source tree `{name}`, \
88                    failed to parse pyproject.toml: {err}"
89                );
90                return false;
91            }
92        };
93    match pyproject_toml
94        .build_system
95        .check_build_system(uv_version::version())
96        .as_slice()
97    {
98        // No warnings -> match
99        [] => true,
100        // Any warning -> no match
101        [first, others @ ..] => {
102            debug!(
103                "Not using uv build backend direct build of `{name}`, pyproject.toml does not match: {first}"
104            );
105            for other in others {
106                trace!("Further uv build backend direct build of `{name}` mismatch: {other}");
107            }
108            false
109        }
110    }
111}
112
113/// A package name as provided in a `pyproject.toml`.
114#[derive(Debug, Clone)]
115struct VerbatimPackageName {
116    /// The package name as given in the `pyproject.toml`.
117    given: String,
118    /// The normalized package name.
119    normalized: PackageName,
120}
121
122impl<'de> Deserialize<'de> for VerbatimPackageName {
123    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
124    where
125        D: Deserializer<'de>,
126    {
127        let given = <Cow<'_, str>>::deserialize(deserializer)?;
128        let normalized = PackageName::from_str(&given).map_err(serde::de::Error::custom)?;
129        Ok(Self {
130            given: given.to_string(),
131            normalized,
132        })
133    }
134}
135
136/// A `pyproject.toml` as specified in PEP 517.
137#[derive(Deserialize, Debug, Clone)]
138#[serde(
139    rename_all = "kebab-case",
140    expecting = "The project table needs to follow \
141    https://packaging.python.org/en/latest/guides/writing-pyproject-toml"
142)]
143pub struct PyProjectToml {
144    /// Project metadata
145    project: Project,
146    /// uv-specific configuration
147    tool: Option<Tool>,
148    /// Build-related data
149    build_system: BuildSystem,
150}
151
152impl PyProjectToml {
153    pub(crate) fn name(&self) -> &PackageName {
154        &self.project.name.normalized
155    }
156
157    pub(crate) fn version(&self) -> &Version {
158        &self.project.version
159    }
160
161    pub(crate) fn parse(path: &Path) -> Result<Self, Error> {
162        let contents = fs_err::read_to_string(path)?;
163        let pyproject_toml =
164            toml::from_str(&contents).map_err(|err| Error::Toml(path.to_path_buf(), err))?;
165        Ok(pyproject_toml)
166    }
167
168    pub(crate) fn readme(&self) -> Option<&Readme> {
169        self.project.readme.as_ref()
170    }
171
172    /// The license files that need to be included in the source distribution.
173    pub(crate) fn license_files_source_dist(&self) -> impl Iterator<Item = &str> {
174        let license_file = self
175            .project
176            .license
177            .as_ref()
178            .and_then(|license| license.file())
179            .into_iter();
180        let license_files = self
181            .project
182            .license_files
183            .iter()
184            .flatten()
185            .map(String::as_str);
186        license_files.chain(license_file)
187    }
188
189    /// The license files that need to be included in the wheel.
190    pub(crate) fn license_files_wheel(&self) -> impl Iterator<Item = &str> {
191        // The pre-PEP 639 `license = { file = "..." }` is included inline in `METADATA`.
192        self.project
193            .license_files
194            .iter()
195            .flatten()
196            .map(String::as_str)
197    }
198
199    pub(crate) fn settings(&self) -> Option<&BuildBackendSettings> {
200        self.tool.as_ref()?.uv.as_ref()?.build_backend.as_ref()
201    }
202
203    /// See [`BuildSystem::check_build_system`].
204    pub fn check_build_system(&self, uv_version: &str) -> Vec<String> {
205        self.build_system.check_build_system(uv_version)
206    }
207
208    /// Validate and convert a `pyproject.toml` to core metadata.
209    ///
210    /// <https://packaging.python.org/en/latest/guides/writing-pyproject-toml/>
211    /// <https://packaging.python.org/en/latest/specifications/pyproject-toml/>
212    /// <https://packaging.python.org/en/latest/specifications/core-metadata/>
213    pub(crate) fn to_metadata(&self, root: &Path) -> Result<Metadata23, Error> {
214        let summary = if let Some(description) = &self.project.description {
215            if description.contains('\n') {
216                return Err(ValidationError::DescriptionNewlines.into());
217            }
218            Some(description.clone())
219        } else {
220            None
221        };
222
223        let supported_content_types = ["text/plain", "text/x-rst", "text/markdown"];
224        let (description, description_content_type) = match &self.project.readme {
225            Some(Readme::String(path)) => {
226                let content = fs_err::read_to_string(root.join(path))?;
227                let content_type = match path.extension().and_then(OsStr::to_str) {
228                    Some("txt") => "text/plain",
229                    Some("rst") => "text/x-rst",
230                    Some("md") => "text/markdown",
231                    Some(unknown) => {
232                        return Err(ValidationError::UnknownExtension(unknown.to_owned()).into());
233                    }
234                    None => return Err(ValidationError::MissingExtension(path.clone()).into()),
235                }
236                .to_string();
237                (Some(content), Some(content_type))
238            }
239            Some(Readme::File {
240                file,
241                content_type,
242                charset,
243            }) => {
244                let content = fs_err::read_to_string(root.join(file))?;
245                if !supported_content_types.contains(&content_type.as_str()) {
246                    return Err(
247                        ValidationError::UnsupportedContentType(content_type.clone()).into(),
248                    );
249                }
250                if charset.as_ref().is_some_and(|charset| charset != "UTF-8") {
251                    return Err(ValidationError::ReadmeCharset.into());
252                }
253                (Some(content), Some(content_type.clone()))
254            }
255            Some(Readme::Text {
256                text,
257                content_type,
258                charset,
259            }) => {
260                if !supported_content_types.contains(&content_type.as_str()) {
261                    return Err(
262                        ValidationError::UnsupportedContentType(content_type.clone()).into(),
263                    );
264                }
265                if charset.as_ref().is_some_and(|charset| charset != "UTF-8") {
266                    return Err(ValidationError::ReadmeCharset.into());
267                }
268                (Some(text.clone()), Some(content_type.clone()))
269            }
270            None => (None, None),
271        };
272
273        if self
274            .project
275            .dynamic
276            .as_ref()
277            .is_some_and(|dynamic| !dynamic.is_empty())
278        {
279            return Err(ValidationError::Dynamic.into());
280        }
281
282        let author = self
283            .project
284            .authors
285            .as_ref()
286            .map(|authors| {
287                authors
288                    .iter()
289                    .filter_map(|author| match author {
290                        Contact::Name { name } => Some(name),
291                        Contact::Email { .. } => None,
292                        Contact::NameEmail { name, .. } => Some(name),
293                    })
294                    .join(", ")
295            })
296            .filter(|author| !author.is_empty());
297        let author_email = self
298            .project
299            .authors
300            .as_ref()
301            .map(|authors| {
302                authors
303                    .iter()
304                    .filter_map(|author| match author {
305                        Contact::Name { .. } => None,
306                        Contact::Email { email } => Some(email.clone()),
307                        Contact::NameEmail { name, email } => Some(format!("{name} <{email}>")),
308                    })
309                    .join(", ")
310            })
311            .filter(|author_email| !author_email.is_empty());
312        let maintainer = self
313            .project
314            .maintainers
315            .as_ref()
316            .map(|maintainers| {
317                maintainers
318                    .iter()
319                    .filter_map(|maintainer| match maintainer {
320                        Contact::Name { name } => Some(name),
321                        Contact::Email { .. } => None,
322                        Contact::NameEmail { name, .. } => Some(name),
323                    })
324                    .join(", ")
325            })
326            .filter(|maintainer| !maintainer.is_empty());
327        let maintainer_email = self
328            .project
329            .maintainers
330            .as_ref()
331            .map(|maintainers| {
332                maintainers
333                    .iter()
334                    .filter_map(|maintainer| match maintainer {
335                        Contact::Name { .. } => None,
336                        Contact::Email { email } => Some(email.clone()),
337                        Contact::NameEmail { name, email } => Some(format!("{name} <{email}>")),
338                    })
339                    .join(", ")
340            })
341            .filter(|maintainer_email| !maintainer_email.is_empty());
342
343        // Using PEP 639 bumps the METADATA version
344        let metadata_version = if self.project.license_files.is_some()
345            || matches!(self.project.license, Some(License::Spdx(_)))
346        {
347            debug!("Found PEP 639 license declarations, using METADATA 2.4");
348            "2.4"
349        } else {
350            "2.3"
351        };
352
353        let (license, license_expression, license_files) = self.license_metadata(root)?;
354
355        // TODO(konsti): https://peps.python.org/pep-0753/#label-normalization (Draft)
356        let project_urls = ProjectUrls::new(self.project.urls.clone().unwrap_or_default());
357
358        let extras = self
359            .project
360            .optional_dependencies
361            .iter()
362            .flat_map(|optional_dependencies| optional_dependencies.keys())
363            .collect::<Vec<_>>();
364
365        let requires_dist =
366            self.project
367                .dependencies
368                .iter()
369                .flatten()
370                .cloned()
371                .chain(self.project.optional_dependencies.iter().flat_map(
372                    |optional_dependencies| {
373                        optional_dependencies
374                            .iter()
375                            .flat_map(|(extra, requirements)| {
376                                requirements.iter().cloned().map(|mut requirement| {
377                                    requirement.marker.and(MarkerTree::expression(
378                                        MarkerExpression::Extra {
379                                            operator: ExtraOperator::Equal,
380                                            name: MarkerValueExtra::Extra(extra.clone()),
381                                        },
382                                    ));
383                                    requirement
384                                })
385                            })
386                    },
387                ))
388                .collect::<Vec<_>>();
389
390        Ok(Metadata23 {
391            metadata_version: metadata_version.to_string(),
392            name: self.project.name.given.clone(),
393            version: self.project.version.to_string(),
394            // Not supported.
395            platforms: vec![],
396            // Not supported.
397            supported_platforms: vec![],
398            summary,
399            description,
400            description_content_type,
401            keywords: self.project.keywords.clone().map(Keywords::new),
402            home_page: None,
403            download_url: None,
404            author,
405            author_email,
406            maintainer,
407            maintainer_email,
408            license,
409            license_expression,
410            license_files,
411            classifiers: self.project.classifiers.clone().unwrap_or_default(),
412            requires_dist: requires_dist.iter().map(ToString::to_string).collect(),
413            provides_extra: extras.iter().map(ToString::to_string).collect(),
414            // Not commonly set.
415            provides_dist: vec![],
416            // Not supported.
417            obsoletes_dist: vec![],
418            requires_python: self
419                .project
420                .requires_python
421                .as_ref()
422                .map(ToString::to_string),
423            // Not used by other tools, not supported.
424            requires_external: vec![],
425            project_urls,
426            dynamic: vec![],
427        })
428    }
429
430    /// Parse and validate the old (PEP 621) and new (PEP 639) license files.
431    #[expect(clippy::type_complexity)]
432    fn license_metadata(
433        &self,
434        root: &Path,
435    ) -> Result<(Option<String>, Option<String>, Vec<String>), Error> {
436        // TODO(konsti): Issue a warning on old license metadata once PEP 639 is universal.
437        let (license, license_expression, license_files) = if let Some(license_globs) =
438            &self.project.license_files
439        {
440            let license_expression = match &self.project.license {
441                None => None,
442                Some(License::Spdx(license_expression)) => Some(license_expression.clone()),
443                Some(License::Text { .. } | License::File { .. }) => {
444                    return Err(ValidationError::MixedLicenseGenerations.into());
445                }
446            };
447
448            let mut license_files = Vec::new();
449            let mut license_globs_parsed = Vec::with_capacity(license_globs.len());
450            let mut license_glob_matchers = Vec::with_capacity(license_globs.len());
451
452            for license_glob in license_globs {
453                let pep639_glob =
454                    PortableGlobParser::Pep639
455                        .parse(license_glob)
456                        .map_err(|err| Error::PortableGlob {
457                            field: license_glob.to_owned(),
458                            source: err,
459                        })?;
460                license_glob_matchers.push(pep639_glob.compile_matcher());
461                license_globs_parsed.push(pep639_glob);
462            }
463
464            // Track whether each user-specified glob matched so we can flag the unmatched ones.
465            let mut license_globs_matched = vec![false; license_globs_parsed.len()];
466
467            let license_globs =
468                GlobDirFilter::from_globs(&license_globs_parsed).map_err(|err| {
469                    Error::GlobSetTooLarge {
470                        field: "project.license-files".to_string(),
471                        source: err,
472                    }
473                })?;
474
475            for entry in WalkDir::new(root)
476                .sort_by_file_name()
477                .into_iter()
478                .filter_entry(|entry| {
479                    license_globs.match_directory(
480                        entry
481                            .path()
482                            .strip_prefix(root)
483                            .expect("walkdir starts with root"),
484                    )
485                })
486            {
487                let entry = entry.map_err(|err| Error::WalkDir {
488                    root: root.to_path_buf(),
489                    err,
490                })?;
491
492                let relative = entry
493                    .path()
494                    .strip_prefix(root)
495                    .expect("walkdir starts with root");
496
497                if !license_globs.match_path(relative) {
498                    trace!("Not a license files match: {}", relative.user_display());
499                    continue;
500                }
501
502                let file_type = entry.file_type();
503
504                if !(file_type.is_file() || file_type.is_symlink()) {
505                    trace!(
506                        "Not a file or symlink in license files match: {}",
507                        relative.user_display()
508                    );
509                    continue;
510                }
511
512                error_on_venv(entry.file_name(), entry.path())?;
513
514                debug!("License files match: {}", relative.user_display());
515
516                for (matched, matcher) in license_globs_matched
517                    .iter_mut()
518                    .zip(license_glob_matchers.iter())
519                {
520                    if *matched {
521                        continue;
522                    }
523
524                    if matcher.is_match(relative) {
525                        *matched = true;
526                    }
527                }
528
529                license_files.push(relative.portable_display().to_string());
530            }
531
532            if let Some((pattern, _)) = license_globs_parsed
533                .into_iter()
534                .zip(license_globs_matched)
535                .find(|(_, matched)| !matched)
536            {
537                return Err(ValidationError::LicenseGlobNoMatches {
538                    field: "project.license-files".to_string(),
539                    glob: pattern.to_string(),
540                }
541                .into());
542            }
543
544            for license_file in &license_files {
545                let file_path = root.join(license_file);
546                let bytes = fs_err::read(&file_path)?;
547                if str::from_utf8(&bytes).is_err() {
548                    return Err(ValidationError::LicenseFileNotUtf8(license_file.clone()).into());
549                }
550            }
551
552            // The glob order may be unstable
553            license_files.sort();
554
555            (None, license_expression, license_files)
556        } else {
557            match &self.project.license {
558                None => (None, None, Vec::new()),
559                Some(License::Spdx(license_expression)) => {
560                    (None, Some(license_expression.clone()), Vec::new())
561                }
562                Some(License::Text { text }) => (Some(text.clone()), None, Vec::new()),
563                Some(License::File { file }) => {
564                    let text = fs_err::read_to_string(root.join(file))?;
565                    (Some(text), None, Vec::new())
566                }
567            }
568        };
569
570        // Check that the license expression is a valid SPDX identifier.
571        if let Some(license_expression) = &license_expression {
572            if let Err(err) = spdx::Expression::parse(license_expression) {
573                return Err(ValidationError::InvalidSpdx(license_expression.clone(), err).into());
574            }
575        }
576
577        Ok((license, license_expression, license_files))
578    }
579
580    /// Validate and convert the entrypoints in `pyproject.toml`, including console and GUI scripts,
581    /// to an `entry_points.txt`.
582    ///
583    /// <https://packaging.python.org/en/latest/specifications/entry-points/>
584    ///
585    /// Returns `None` if no entrypoints were defined.
586    pub(crate) fn to_entry_points(&self) -> Result<Option<String>, ValidationError> {
587        let mut writer = String::new();
588
589        if self.project.scripts.is_none()
590            && self.project.gui_scripts.is_none()
591            && self.project.entry_points.is_none()
592        {
593            return Ok(None);
594        }
595
596        if let Some(scripts) = &self.project.scripts {
597            Self::write_group(&mut writer, "console_scripts", scripts)?;
598        }
599        if let Some(gui_scripts) = &self.project.gui_scripts {
600            Self::write_group(&mut writer, "gui_scripts", gui_scripts)?;
601        }
602        for (group, entries) in self.project.entry_points.iter().flatten() {
603            if group == "console_scripts" {
604                return Err(ValidationError::ReservedScripts);
605            }
606            if group == "gui_scripts" {
607                return Err(ValidationError::ReservedGuiScripts);
608            }
609            Self::write_group(&mut writer, group, entries)?;
610        }
611        Ok(Some(writer))
612    }
613
614    /// Write a group to `entry_points.txt`.
615    fn write_group<'a>(
616        writer: &mut String,
617        group: &str,
618        entries: impl IntoIterator<Item = (&'a String, &'a String)>,
619    ) -> Result<(), ValidationError> {
620        if !group
621            .chars()
622            .next()
623            .map(|c| c.is_alphanumeric() || c == '_')
624            .unwrap_or(false)
625            || !group
626                .chars()
627                .all(|c| c.is_alphanumeric() || c == '.' || c == '_')
628        {
629            return Err(ValidationError::InvalidGroup(group.to_string()));
630        }
631
632        let _ = writeln!(writer, "[{group}]");
633        for (name, object_reference) in entries {
634            if !name
635                .chars()
636                .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
637            {
638                warn!(
639                    "Entrypoint names should consist of letters, numbers, dots, underscores and \
640                    dashes; non-compliant name: {name}"
641                );
642            }
643
644            // TODO(konsti): Validate that the object references are valid Python identifiers.
645            let _ = writeln!(writer, "{name} = {object_reference}");
646        }
647        writer.push('\n');
648        Ok(())
649    }
650}
651
652/// The `[project]` section of a pyproject.toml as specified in
653/// <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
654///
655/// This struct does not have schema export; the schema is shared between all Python tools, and we
656/// should update the shared schema instead.
657#[derive(Deserialize, Debug, Clone)]
658#[serde(rename_all = "kebab-case")]
659struct Project {
660    /// The name of the project.
661    name: VerbatimPackageName,
662    /// The version of the project.
663    version: Version,
664    /// The summary description of the project in one line.
665    description: Option<String>,
666    /// The full description of the project (i.e. the README).
667    readme: Option<Readme>,
668    /// The Python version requirements of the project.
669    requires_python: Option<VersionSpecifiers>,
670    /// The license under which the project is distributed.
671    ///
672    /// Supports both the current standard and the provisional PEP 639.
673    license: Option<License>,
674    /// The paths to files containing licenses and other legal notices to be distributed with the
675    /// project.
676    ///
677    /// From the provisional PEP 639
678    license_files: Option<Vec<String>>,
679    /// The people or organizations considered to be the "authors" of the project.
680    authors: Option<Vec<Contact>>,
681    /// The people or organizations considered to be the "maintainers" of the project.
682    maintainers: Option<Vec<Contact>>,
683    /// The keywords for the project.
684    keywords: Option<Vec<String>>,
685    /// Trove classifiers which apply to the project.
686    classifiers: Option<Vec<String>>,
687    /// A table of URLs where the key is the URL label and the value is the URL itself.
688    ///
689    /// PyPI shows all URLs with their name. For some known patterns, they add favicons.
690    /// main: <https://github.com/pypi/warehouse/blob/main/warehouse/templates/packaging/detail.html>
691    /// archived: <https://github.com/pypi/warehouse/blob/e3bd3c3805ff47fff32b67a899c1ce11c16f3c31/warehouse/templates/packaging/detail.html>
692    urls: Option<IndexMap<String, String>>,
693    /// The console entrypoints of the project.
694    ///
695    /// The key of the table is the name of the entry point and the value is the object reference.
696    scripts: Option<BTreeMap<String, String>>,
697    /// The GUI entrypoints of the project.
698    ///
699    /// The key of the table is the name of the entry point and the value is the object reference.
700    gui_scripts: Option<BTreeMap<String, String>>,
701    /// Entrypoints groups of the project.
702    ///
703    /// The key of the table is the name of the entry point and the value is the object reference.
704    entry_points: Option<BTreeMap<String, BTreeMap<String, String>>>,
705    /// The dependencies of the project.
706    dependencies: Option<Vec<Requirement>>,
707    /// The optional dependencies of the project.
708    optional_dependencies: Option<BTreeMap<ExtraName, Vec<Requirement>>>,
709    /// Specifies which fields listed by PEP 621 were intentionally unspecified so another tool
710    /// can/will provide such metadata dynamically.
711    ///
712    /// Not supported, an error if anything but the default empty list.
713    dynamic: Option<Vec<String>>,
714}
715
716/// The optional `project.readme` key in a pyproject.toml as specified in
717/// <https://packaging.python.org/en/latest/specifications/pyproject-toml/#readme>.
718#[derive(Deserialize, Debug, Clone)]
719#[serde(untagged, rename_all_fields = "kebab-case")]
720pub(crate) enum Readme {
721    /// Relative path to the README.
722    String(PathBuf),
723    /// Relative path to the README.
724    File {
725        file: PathBuf,
726        content_type: String,
727        charset: Option<String>,
728    },
729    /// The full description of the project as an inline value.
730    Text {
731        text: String,
732        content_type: String,
733        charset: Option<String>,
734    },
735}
736
737impl Readme {
738    /// If the readme is a file, return the path to the file.
739    pub(crate) fn path(&self) -> Option<&Path> {
740        match self {
741            Self::String(path) => Some(path),
742            Self::File { file, .. } => Some(file),
743            Self::Text { .. } => None,
744        }
745    }
746}
747
748/// The optional `project.license` key in a pyproject.toml as specified in
749/// <https://packaging.python.org/en/latest/specifications/pyproject-toml/#license>.
750#[derive(Deserialize, Debug, Clone)]
751#[serde(untagged)]
752pub(crate) enum License {
753    /// An SPDX Expression.
754    ///
755    /// From the provisional PEP 639.
756    Spdx(String),
757    Text {
758        /// The full text of the license.
759        text: String,
760    },
761    File {
762        /// The file containing the license text.
763        file: String,
764    },
765}
766
767impl License {
768    fn file(&self) -> Option<&str> {
769        if let Self::File { file } = self {
770            Some(file)
771        } else {
772            None
773        }
774    }
775}
776
777/// A `project.authors` or `project.maintainers` entry as specified in
778/// <https://packaging.python.org/en/latest/specifications/pyproject-toml/#authors-maintainers>.
779///
780/// The entry is derived from the email format of `John Doe <john.doe@example.net>`. You need to
781/// provide at least name or email.
782#[derive(Deserialize, Debug, Clone)]
783// deny_unknown_fields prevents using the name field when the email is not a string.
784#[serde(
785    untagged,
786    deny_unknown_fields,
787    expecting = "a table with 'name' and/or 'email' keys"
788)]
789pub(crate) enum Contact {
790    /// TODO(konsti): RFC 822 validation.
791    NameEmail { name: String, email: String },
792    /// TODO(konsti): RFC 822 validation.
793    Name { name: String },
794    /// TODO(konsti): RFC 822 validation.
795    Email { email: String },
796}
797
798/// The `tool` section as specified in PEP 517.
799#[derive(Deserialize, Debug, Clone)]
800#[serde(rename_all = "kebab-case")]
801pub(crate) struct Tool {
802    /// uv-specific configuration
803    uv: Option<ToolUv>,
804}
805
806/// The `tool.uv` section with build configuration.
807#[derive(Deserialize, Debug, Clone)]
808#[serde(rename_all = "kebab-case")]
809pub(crate) struct ToolUv {
810    /// Configuration for building source distributions and wheels with the uv build backend
811    build_backend: Option<BuildBackendSettings>,
812}
813
814/// The `[build-system]` section of a pyproject.toml as specified in PEP 517.
815#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
816#[serde(rename_all = "kebab-case")]
817struct BuildSystem {
818    /// PEP 508 dependencies required to execute the build system.
819    requires: Vec<SerdeVerbatim<Requirement<VerbatimParsedUrl>>>,
820    /// A string naming a Python object that will be used to perform the build.
821    build_backend: Option<String>,
822    /// <https://peps.python.org/pep-0517/#in-tree-build-backends>
823    backend_path: Option<Vec<String>>,
824}
825
826impl BuildSystem {
827    /// Check if the `[build-system]` table matches the uv build backend expectations and return
828    /// a list of warnings if it looks suspicious.
829    ///
830    /// Example of a valid table:
831    ///
832    /// ```toml
833    /// [build-system]
834    /// requires = ["uv_build>=0.4.15,<0.5.0"]
835    /// build-backend = "uv_build"
836    /// ```
837    pub(crate) fn check_build_system(&self, uv_version: &str) -> Vec<String> {
838        let mut warnings = Vec::new();
839        if self.build_backend.as_deref() != Some("uv_build") {
840            warnings.push(format!(
841                r#"The value for `build_system.build-backend` should be `"uv_build"`, not `"{}"`"#,
842                self.build_backend.clone().unwrap_or_default()
843            ));
844        }
845
846        let uv_version =
847            Version::from_str(uv_version).expect("uv's own version is not PEP 440 compliant");
848        let next_minor = uv_version.release().get(1).copied().unwrap_or_default() + 1;
849        let next_breaking = Version::new([0, next_minor]);
850
851        let expected = || {
852            format!(
853                "Expected a single uv requirement in `build-system.requires`, found `{}`",
854                toml::to_string(&self.requires).unwrap_or_default()
855            )
856        };
857
858        let [uv_requirement] = &self.requires.as_slice() else {
859            warnings.push(expected());
860            return warnings;
861        };
862        if uv_requirement.name.as_str() != "uv-build" {
863            warnings.push(expected());
864            return warnings;
865        }
866        let bounded = match &uv_requirement.version_or_url {
867            None => false,
868            Some(VersionOrUrl::Url(_)) => {
869                // We can't validate the url
870                true
871            }
872            Some(VersionOrUrl::VersionSpecifier(specifier)) => {
873                // We don't check how wide the range is (that's up to the user), we just
874                // check that the current version is compliant, to avoid accidentally using a
875                // too new or too old uv, and we check that an upper bound exists. The latter
876                // is very important to allow making breaking changes in uv without breaking
877                // the existing immutable source distributions on pypi.
878                if !specifier.contains(&uv_version) {
879                    // This is allowed to happen when testing prereleases, but we should still warn.
880                    warnings.push(format!(
881                        r#"`build_system.requires = ["{uv_requirement}"]` does not contain the
882                        current uv version {uv_version}"#,
883                    ));
884                }
885                Ranges::from(specifier.clone())
886                    .bounding_range()
887                    .map(|bounding_range| bounding_range.1 != Bound::Unbounded)
888                    .unwrap_or(false)
889            }
890        };
891
892        if !bounded {
893            warnings.push(format!(
894                "`build_system.requires = [\"{}\"]` is missing an \
895                upper bound on the `uv_build` version such as `<{next_breaking}`. \
896                Without bounding the `uv_build` version, the source distribution will break \
897                when a future, breaking version of `uv_build` is released.",
898                // Use an underscore consistently, to avoid confusing users between a package name with dash and a
899                // module name with underscore
900                uv_requirement.verbatim()
901            ));
902        }
903
904        warnings
905    }
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911    use indoc::{formatdoc, indoc};
912    use insta::assert_snapshot;
913    use std::iter;
914    use tempfile::TempDir;
915
916    fn extend_project(payload: &str) -> String {
917        formatdoc! {r#"
918            [project]
919            name = "hello-world"
920            version = "0.1.0"
921            {payload}
922
923            [build-system]
924            requires = ["uv_build>=0.4.15,<0.5.0"]
925            build-backend = "uv_build"
926        "#
927        }
928    }
929
930    fn format_err(err: impl std::error::Error) -> String {
931        let mut formatted = err.to_string();
932        for source in iter::successors(err.source(), |&err| err.source()) {
933            let _ = write!(formatted, "\n  Caused by: {source}");
934        }
935        formatted
936    }
937
938    #[test]
939    fn uppercase_package_name() {
940        let contents = r#"
941            [project]
942            name = "Hello-World"
943            version = "0.1.0"
944
945            [build-system]
946            requires = ["uv_build>=0.4.15,<0.5.0"]
947            build-backend = "uv_build"
948        "#;
949        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
950        let temp_dir = TempDir::new().unwrap();
951
952        let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap();
953        assert_snapshot!(metadata.core_metadata_format(), @"
954        Metadata-Version: 2.3
955        Name: Hello-World
956        Version: 0.1.0
957        ");
958    }
959
960    #[test]
961    fn valid() {
962        let temp_dir = TempDir::new().unwrap();
963
964        fs_err::write(
965            temp_dir.path().join("Readme.md"),
966            indoc! {r"
967            # Foo
968
969            This is the foo library.
970        "},
971        )
972        .unwrap();
973
974        fs_err::write(
975            temp_dir.path().join("License.txt"),
976            indoc! {r#"
977                THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
978                INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
979                PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
980                HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
981                CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
982                OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
983        "#},
984        )
985        .unwrap();
986
987        let contents = indoc! {r#"
988            # See https://github.com/pypa/sampleproject/blob/main/pyproject.toml for another example
989
990            [project]
991            name = "hello-world"
992            version = "0.1.0"
993            description = "A Python package"
994            readme = "Readme.md"
995            requires_python = ">=3.12"
996            license = { file = "License.txt" }
997            authors = [{ name = "Ferris the crab", email = "ferris@rustacean.net" }]
998            maintainers = [{ name = "Konsti", email = "konstin@mailbox.org" }]
999            keywords = ["demo", "example", "package"]
1000            classifiers = [
1001                "Development Status :: 6 - Mature",
1002                "License :: OSI Approved :: MIT License",
1003                # https://github.com/pypa/trove-classifiers/issues/17
1004                "License :: OSI Approved :: Apache Software License",
1005                "Programming Language :: Python",
1006            ]
1007            dependencies = ["flask>=3,<4", "sqlalchemy[asyncio]>=2.0.35,<3"]
1008            # We don't support dynamic fields, the default empty array is the only allowed value.
1009            dynamic = []
1010
1011            [project.optional-dependencies]
1012            postgres = ["psycopg>=3.2.2,<4"]
1013            mysql = ["pymysql>=1.1.1,<2"]
1014
1015            [project.urls]
1016            "Homepage" = "https://github.com/astral-sh/uv"
1017            "Repository" = "https://astral.sh"
1018
1019            [project.scripts]
1020            foo = "foo.cli:__main__"
1021
1022            [project.gui-scripts]
1023            foo-gui = "foo.gui"
1024
1025            [project.entry-points.bar_group]
1026            foo-bar = "foo:bar"
1027
1028            [build-system]
1029            requires = ["uv_build>=0.4.15,<0.5.0"]
1030            build-backend = "uv_build"
1031        "#
1032        };
1033
1034        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
1035        let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap();
1036
1037        assert_snapshot!(metadata.core_metadata_format(), @r#"
1038        Metadata-Version: 2.3
1039        Name: hello-world
1040        Version: 0.1.0
1041        Summary: A Python package
1042        Keywords: demo,example,package
1043        Author: Ferris the crab
1044        Author-email: Ferris the crab <ferris@rustacean.net>
1045        License: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
1046                 INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
1047                 PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
1048                 HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
1049                 CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
1050                 OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1051        Classifier: Development Status :: 6 - Mature
1052        Classifier: License :: OSI Approved :: MIT License
1053        Classifier: License :: OSI Approved :: Apache Software License
1054        Classifier: Programming Language :: Python
1055        Requires-Dist: flask>=3,<4
1056        Requires-Dist: sqlalchemy[asyncio]>=2.0.35,<3
1057        Requires-Dist: pymysql>=1.1.1,<2 ; extra == 'mysql'
1058        Requires-Dist: psycopg>=3.2.2,<4 ; extra == 'postgres'
1059        Maintainer: Konsti
1060        Maintainer-email: Konsti <konstin@mailbox.org>
1061        Project-URL: Homepage, https://github.com/astral-sh/uv
1062        Project-URL: Repository, https://astral.sh
1063        Provides-Extra: mysql
1064        Provides-Extra: postgres
1065        Description-Content-Type: text/markdown
1066
1067        # Foo
1068
1069        This is the foo library.
1070        "#);
1071
1072        assert_snapshot!(pyproject_toml.to_entry_points().unwrap().unwrap(), @"
1073        [console_scripts]
1074        foo = foo.cli:__main__
1075
1076        [gui_scripts]
1077        foo-gui = foo.gui
1078
1079        [bar_group]
1080        foo-bar = foo:bar
1081        ");
1082    }
1083
1084    #[test]
1085    fn readme() {
1086        let temp_dir = TempDir::new().unwrap();
1087
1088        fs_err::write(
1089            temp_dir.path().join("Readme.md"),
1090            indoc! {r"
1091            # Foo
1092
1093            This is the foo library.
1094        "},
1095        )
1096        .unwrap();
1097
1098        fs_err::write(
1099            temp_dir.path().join("License.txt"),
1100            indoc! {r#"
1101                THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
1102                INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
1103                PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
1104                HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
1105                CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
1106                OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1107        "#},
1108        )
1109        .unwrap();
1110
1111        let contents = indoc! {r#"
1112            # See https://github.com/pypa/sampleproject/blob/main/pyproject.toml for another example
1113
1114            [project]
1115            name = "hello-world"
1116            version = "0.1.0"
1117            description = "A Python package"
1118            readme = { file = "Readme.md", content-type = "text/markdown" }
1119            requires_python = ">=3.12"
1120
1121            [build-system]
1122            requires = ["uv_build>=0.4.15,<0.5"]
1123            build-backend = "uv_build"
1124        "#
1125        };
1126
1127        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
1128        let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap();
1129
1130        assert_snapshot!(metadata.core_metadata_format(), @"
1131        Metadata-Version: 2.3
1132        Name: hello-world
1133        Version: 0.1.0
1134        Summary: A Python package
1135        Description-Content-Type: text/markdown
1136
1137        # Foo
1138
1139        This is the foo library.
1140        ");
1141    }
1142
1143    #[test]
1144    fn self_extras() {
1145        let temp_dir = TempDir::new().unwrap();
1146
1147        fs_err::write(
1148            temp_dir.path().join("Readme.md"),
1149            indoc! {r"
1150            # Foo
1151
1152            This is the foo library.
1153        "},
1154        )
1155        .unwrap();
1156
1157        fs_err::write(
1158            temp_dir.path().join("License.txt"),
1159            indoc! {r#"
1160                THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
1161                INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
1162                PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
1163                HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
1164                CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
1165                OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1166        "#},
1167        )
1168        .unwrap();
1169
1170        let contents = indoc! {r#"
1171            # See https://github.com/pypa/sampleproject/blob/main/pyproject.toml for another example
1172
1173            [project]
1174            name = "hello-world"
1175            version = "0.1.0"
1176            description = "A Python package"
1177            readme = "Readme.md"
1178            requires_python = ">=3.12"
1179            license = { file = "License.txt" }
1180            authors = [{ name = "Ferris the crab", email = "ferris@rustacean.net" }]
1181            maintainers = [{ name = "Konsti", email = "konstin@mailbox.org" }]
1182            keywords = ["demo", "example", "package"]
1183            classifiers = [
1184                "Development Status :: 6 - Mature",
1185                "License :: OSI Approved :: MIT License",
1186                # https://github.com/pypa/trove-classifiers/issues/17
1187                "License :: OSI Approved :: Apache Software License",
1188                "Programming Language :: Python",
1189            ]
1190            dependencies = ["flask>=3,<4", "sqlalchemy[asyncio]>=2.0.35,<3"]
1191            # We don't support dynamic fields, the default empty array is the only allowed value.
1192            dynamic = []
1193
1194            [project.optional-dependencies]
1195            postgres = ["psycopg>=3.2.2,<4 ; sys_platform == 'linux'"]
1196            mysql = ["pymysql>=1.1.1,<2"]
1197            databases = ["hello-world[mysql]", "hello-world[postgres]"]
1198            all = ["hello-world[databases]", "hello-world[postgres]", "hello-world[mysql]"]
1199
1200            [project.urls]
1201            "Homepage" = "https://github.com/astral-sh/uv"
1202            "Repository" = "https://astral.sh"
1203
1204            [project.scripts]
1205            foo = "foo.cli:__main__"
1206
1207            [project.gui-scripts]
1208            foo-gui = "foo.gui"
1209
1210            [project.entry-points.bar_group]
1211            foo-bar = "foo:bar"
1212
1213            [build-system]
1214            requires = ["uv_build>=0.4.15,<0.5.0"]
1215            build-backend = "uv_build"
1216        "#
1217        };
1218
1219        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
1220        let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap();
1221
1222        assert_snapshot!(metadata.core_metadata_format(), @r#"
1223        Metadata-Version: 2.3
1224        Name: hello-world
1225        Version: 0.1.0
1226        Summary: A Python package
1227        Keywords: demo,example,package
1228        Author: Ferris the crab
1229        Author-email: Ferris the crab <ferris@rustacean.net>
1230        License: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
1231                 INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
1232                 PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
1233                 HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
1234                 CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
1235                 OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1236        Classifier: Development Status :: 6 - Mature
1237        Classifier: License :: OSI Approved :: MIT License
1238        Classifier: License :: OSI Approved :: Apache Software License
1239        Classifier: Programming Language :: Python
1240        Requires-Dist: flask>=3,<4
1241        Requires-Dist: sqlalchemy[asyncio]>=2.0.35,<3
1242        Requires-Dist: hello-world[databases] ; extra == 'all'
1243        Requires-Dist: hello-world[postgres] ; extra == 'all'
1244        Requires-Dist: hello-world[mysql] ; extra == 'all'
1245        Requires-Dist: hello-world[mysql] ; extra == 'databases'
1246        Requires-Dist: hello-world[postgres] ; extra == 'databases'
1247        Requires-Dist: pymysql>=1.1.1,<2 ; extra == 'mysql'
1248        Requires-Dist: psycopg>=3.2.2,<4 ; sys_platform == 'linux' and extra == 'postgres'
1249        Maintainer: Konsti
1250        Maintainer-email: Konsti <konstin@mailbox.org>
1251        Project-URL: Homepage, https://github.com/astral-sh/uv
1252        Project-URL: Repository, https://astral.sh
1253        Provides-Extra: all
1254        Provides-Extra: databases
1255        Provides-Extra: mysql
1256        Provides-Extra: postgres
1257        Description-Content-Type: text/markdown
1258
1259        # Foo
1260
1261        This is the foo library.
1262        "#);
1263
1264        assert_snapshot!(pyproject_toml.to_entry_points().unwrap().unwrap(), @"
1265        [console_scripts]
1266        foo = foo.cli:__main__
1267
1268        [gui_scripts]
1269        foo-gui = foo.gui
1270
1271        [bar_group]
1272        foo-bar = foo:bar
1273        ");
1274    }
1275
1276    #[test]
1277    fn build_system_valid() {
1278        let contents = extend_project("");
1279        let pyproject_toml: PyProjectToml = toml::from_str(&contents).unwrap();
1280        assert_snapshot!(
1281            pyproject_toml.check_build_system("0.4.15+test").join("\n"),
1282            @""
1283        );
1284    }
1285
1286    #[test]
1287    fn build_system_no_bound() {
1288        let contents = indoc! {r#"
1289            [project]
1290            name = "hello-world"
1291            version = "0.1.0"
1292
1293            [build-system]
1294            requires = ["uv_build"]
1295            build-backend = "uv_build"
1296        "#};
1297        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
1298        assert_snapshot!(
1299            pyproject_toml.check_build_system("0.4.15+test").join("\n"),
1300            @r#"`build_system.requires = ["uv_build"]` is missing an upper bound on the `uv_build` version such as `<0.5`. Without bounding the `uv_build` version, the source distribution will break when a future, breaking version of `uv_build` is released."#
1301        );
1302    }
1303
1304    #[test]
1305    fn build_system_multiple_packages() {
1306        let contents = indoc! {r#"
1307            [project]
1308            name = "hello-world"
1309            version = "0.1.0"
1310
1311            [build-system]
1312            requires = ["uv_build>=0.4.15,<0.5.0", "wheel"]
1313            build-backend = "uv_build"
1314        "#};
1315        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
1316        assert_snapshot!(
1317            pyproject_toml.check_build_system("0.4.15+test").join("\n"),
1318            @"Expected a single uv requirement in `build-system.requires`, found ``"
1319        );
1320    }
1321
1322    #[test]
1323    fn build_system_no_requires_uv() {
1324        let contents = indoc! {r#"
1325            [project]
1326            name = "hello-world"
1327            version = "0.1.0"
1328
1329            [build-system]
1330            requires = ["setuptools"]
1331            build-backend = "uv_build"
1332        "#};
1333        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
1334        assert_snapshot!(
1335            pyproject_toml.check_build_system("0.4.15+test").join("\n"),
1336            @"Expected a single uv requirement in `build-system.requires`, found ``"
1337        );
1338    }
1339
1340    #[test]
1341    fn build_system_not_uv() {
1342        let contents = indoc! {r#"
1343            [project]
1344            name = "hello-world"
1345            version = "0.1.0"
1346
1347            [build-system]
1348            requires = ["uv_build>=0.4.15,<0.5.0"]
1349            build-backend = "setuptools"
1350        "#};
1351        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
1352        assert_snapshot!(
1353            pyproject_toml.check_build_system("0.4.15+test").join("\n"),
1354            @r#"The value for `build_system.build-backend` should be `"uv_build"`, not `"setuptools"`"#
1355        );
1356    }
1357
1358    #[test]
1359    fn minimal() {
1360        let contents = extend_project("");
1361
1362        let metadata = toml::from_str::<PyProjectToml>(&contents)
1363            .unwrap()
1364            .to_metadata(Path::new("/do/not/read"))
1365            .unwrap();
1366
1367        assert_snapshot!(metadata.core_metadata_format(), @"
1368        Metadata-Version: 2.3
1369        Name: hello-world
1370        Version: 0.1.0
1371        ");
1372    }
1373
1374    #[test]
1375    fn invalid_readme_spec() {
1376        let contents = extend_project(indoc! {r#"
1377            readme = { path = "Readme.md" }
1378        "#
1379        });
1380
1381        let err = toml::from_str::<PyProjectToml>(&contents).unwrap_err();
1382        assert_snapshot!(format_err(err), @r#"
1383        TOML parse error at line 4, column 10
1384          |
1385        4 | readme = { path = "Readme.md" }
1386          |          ^^^^^^^^^^^^^^^^^^^^^^
1387        data did not match any variant of untagged enum Readme
1388        "#);
1389    }
1390
1391    #[test]
1392    fn missing_readme() {
1393        let contents = extend_project(indoc! {r#"
1394            readme = "Readme.md"
1395        "#
1396        });
1397
1398        let err = toml::from_str::<PyProjectToml>(&contents)
1399            .unwrap()
1400            .to_metadata(Path::new("/do/not/read"))
1401            .unwrap_err();
1402        // Strip away OS specific part.
1403        let err = err
1404            .to_string()
1405            .replace('\\', "/")
1406            .split_once(':')
1407            .unwrap()
1408            .0
1409            .to_string();
1410        assert_snapshot!(err, @"failed to open file `/do/not/read/Readme.md`");
1411    }
1412
1413    #[test]
1414    fn multiline_description() {
1415        let contents = extend_project(indoc! {r#"
1416            description = "Hi :)\nThis is my project"
1417        "#
1418        });
1419
1420        let err = toml::from_str::<PyProjectToml>(&contents)
1421            .unwrap()
1422            .to_metadata(Path::new("/do/not/read"))
1423            .unwrap_err();
1424        assert_snapshot!(format_err(err), @"
1425        Invalid project metadata
1426          Caused by: `project.description` must be a single line
1427        ");
1428    }
1429
1430    #[test]
1431    fn mixed_licenses() {
1432        let contents = extend_project(indoc! {r#"
1433            license-files = ["licenses/*"]
1434            license =  { text = "MIT" }
1435        "#
1436        });
1437
1438        let err = toml::from_str::<PyProjectToml>(&contents)
1439            .unwrap()
1440            .to_metadata(Path::new("/do/not/read"))
1441            .unwrap_err();
1442        assert_snapshot!(format_err(err), @"
1443        Invalid project metadata
1444          Caused by: When `project.license-files` is defined, `project.license` must be an SPDX expression string
1445        ");
1446    }
1447
1448    #[test]
1449    fn valid_license() {
1450        let contents = extend_project(indoc! {r#"
1451            license = "MIT OR Apache-2.0"
1452        "#
1453        });
1454        let metadata = toml::from_str::<PyProjectToml>(&contents)
1455            .unwrap()
1456            .to_metadata(Path::new("/do/not/read"))
1457            .unwrap();
1458        assert_snapshot!(metadata.core_metadata_format(), @"
1459        Metadata-Version: 2.4
1460        Name: hello-world
1461        Version: 0.1.0
1462        License-Expression: MIT OR Apache-2.0
1463        ");
1464    }
1465
1466    #[test]
1467    fn invalid_license() {
1468        let contents = extend_project(indoc! {r#"
1469            license = "MIT XOR Apache-2"
1470        "#
1471        });
1472        let err = toml::from_str::<PyProjectToml>(&contents)
1473            .unwrap()
1474            .to_metadata(Path::new("/do/not/read"))
1475            .unwrap_err();
1476        // TODO(konsti): We mess up the indentation in the error.
1477        assert_snapshot!(format_err(err), @"
1478        Invalid project metadata
1479          Caused by: `project.license` is not a valid SPDX expression: MIT XOR Apache-2
1480          Caused by: MIT XOR Apache-2
1481            ^^^ unknown term
1482        ");
1483    }
1484
1485    #[test]
1486    fn dynamic() {
1487        let contents = extend_project(indoc! {r#"
1488            dynamic = ["dependencies"]
1489        "#
1490        });
1491
1492        let err = toml::from_str::<PyProjectToml>(&contents)
1493            .unwrap()
1494            .to_metadata(Path::new("/do/not/read"))
1495            .unwrap_err();
1496        assert_snapshot!(format_err(err), @"
1497        Invalid project metadata
1498          Caused by: Dynamic metadata is not supported
1499        ");
1500    }
1501
1502    fn script_error(contents: &str) -> String {
1503        let err = toml::from_str::<PyProjectToml>(contents)
1504            .unwrap()
1505            .to_entry_points()
1506            .unwrap_err();
1507        format_err(err)
1508    }
1509
1510    #[test]
1511    fn invalid_entry_point_group() {
1512        let contents = extend_project(indoc! {r#"
1513            [project.entry-points."a@b"]
1514            foo = "bar"
1515        "#
1516        });
1517        assert_snapshot!(script_error(&contents), @"Entrypoint groups must consist of letters and numbers separated by dots, invalid group: a@b");
1518    }
1519
1520    #[test]
1521    fn invalid_entry_point_conflict_scripts() {
1522        let contents = extend_project(indoc! {r#"
1523            [project.entry-points.console_scripts]
1524            foo = "bar"
1525        "#
1526        });
1527        assert_snapshot!(script_error(&contents), @"Use `project.scripts` instead of `project.entry-points.console_scripts`");
1528    }
1529
1530    #[test]
1531    fn invalid_entry_point_conflict_gui_scripts() {
1532        let contents = extend_project(indoc! {r#"
1533            [project.entry-points.gui_scripts]
1534            foo = "bar"
1535        "#
1536        });
1537        assert_snapshot!(script_error(&contents), @"Use `project.gui-scripts` instead of `project.entry-points.gui_scripts`");
1538    }
1539}