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
27pub(crate) const DEFAULT_EXCLUDES: &[&str] = &["__pycache__", "*.pyc", "*.pyo"];
29
30#[derive(Debug, Error)]
31pub enum ValidationError {
32 #[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
70pub 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 [] => true,
100 [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#[derive(Debug, Clone)]
115struct VerbatimPackageName {
116 given: String,
118 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#[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: Project,
146 tool: Option<Tool>,
148 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 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 pub(crate) fn license_files_wheel(&self) -> impl Iterator<Item = &str> {
191 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 pub fn check_build_system(&self, uv_version: &str) -> Vec<String> {
205 self.build_system.check_build_system(uv_version)
206 }
207
208 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 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 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 platforms: vec![],
396 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 provides_dist: vec![],
416 obsoletes_dist: vec![],
418 requires_python: self
419 .project
420 .requires_python
421 .as_ref()
422 .map(ToString::to_string),
423 requires_external: vec![],
425 project_urls,
426 dynamic: vec![],
427 })
428 }
429
430 #[expect(clippy::type_complexity)]
432 fn license_metadata(
433 &self,
434 root: &Path,
435 ) -> Result<(Option<String>, Option<String>, Vec<String>), Error> {
436 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 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 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 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 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 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 let _ = writeln!(writer, "{name} = {object_reference}");
646 }
647 writer.push('\n');
648 Ok(())
649 }
650}
651
652#[derive(Deserialize, Debug, Clone)]
658#[serde(rename_all = "kebab-case")]
659struct Project {
660 name: VerbatimPackageName,
662 version: Version,
664 description: Option<String>,
666 readme: Option<Readme>,
668 requires_python: Option<VersionSpecifiers>,
670 license: Option<License>,
674 license_files: Option<Vec<String>>,
679 authors: Option<Vec<Contact>>,
681 maintainers: Option<Vec<Contact>>,
683 keywords: Option<Vec<String>>,
685 classifiers: Option<Vec<String>>,
687 urls: Option<IndexMap<String, String>>,
693 scripts: Option<BTreeMap<String, String>>,
697 gui_scripts: Option<BTreeMap<String, String>>,
701 entry_points: Option<BTreeMap<String, BTreeMap<String, String>>>,
705 dependencies: Option<Vec<Requirement>>,
707 optional_dependencies: Option<BTreeMap<ExtraName, Vec<Requirement>>>,
709 dynamic: Option<Vec<String>>,
714}
715
716#[derive(Deserialize, Debug, Clone)]
719#[serde(untagged, rename_all_fields = "kebab-case")]
720pub(crate) enum Readme {
721 String(PathBuf),
723 File {
725 file: PathBuf,
726 content_type: String,
727 charset: Option<String>,
728 },
729 Text {
731 text: String,
732 content_type: String,
733 charset: Option<String>,
734 },
735}
736
737impl Readme {
738 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#[derive(Deserialize, Debug, Clone)]
751#[serde(untagged)]
752pub(crate) enum License {
753 Spdx(String),
757 Text {
758 text: String,
760 },
761 File {
762 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#[derive(Deserialize, Debug, Clone)]
783#[serde(
785 untagged,
786 deny_unknown_fields,
787 expecting = "a table with 'name' and/or 'email' keys"
788)]
789pub(crate) enum Contact {
790 NameEmail { name: String, email: String },
792 Name { name: String },
794 Email { email: String },
796}
797
798#[derive(Deserialize, Debug, Clone)]
800#[serde(rename_all = "kebab-case")]
801pub(crate) struct Tool {
802 uv: Option<ToolUv>,
804}
805
806#[derive(Deserialize, Debug, Clone)]
808#[serde(rename_all = "kebab-case")]
809pub(crate) struct ToolUv {
810 build_backend: Option<BuildBackendSettings>,
812}
813
814#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
816#[serde(rename_all = "kebab-case")]
817struct BuildSystem {
818 requires: Vec<SerdeVerbatim<Requirement<VerbatimParsedUrl>>>,
820 build_backend: Option<String>,
822 backend_path: Option<Vec<String>>,
824}
825
826impl BuildSystem {
827 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 true
871 }
872 Some(VersionOrUrl::VersionSpecifier(specifier)) => {
873 if !specifier.contains(&uv_version) {
879 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 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 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 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}