From 92538ada7ccb7957e11064cfd69c2f1821cb6428 Mon Sep 17 00:00:00 2001 From: konsti Date: Mon, 7 Oct 2024 10:38:40 +0200 Subject: [PATCH] Metadata transformation for the build backend (#7781) --- Cargo.lock | 39 + Cargo.toml | 2 + crates/uv-build-backend/Cargo.toml | 41 + crates/uv-build-backend/src/lib.rs | 166 +++ crates/uv-build-backend/src/metadata.rs | 1038 +++++++++++++++++ crates/uv-build-backend/src/pep639_glob.rs | 136 +++ .../uv-pypi-types/src/metadata/metadata23.rs | 172 ++- crates/uv/Cargo.toml | 1 + crates/uv/src/commands/build_backend.rs | 65 +- .../commands/{build.rs => build_frontend.rs} | 2 +- crates/uv/src/commands/mod.rs | 4 +- crates/uv/src/lib.rs | 36 +- 12 files changed, 1630 insertions(+), 72 deletions(-) create mode 100644 crates/uv-build-backend/Cargo.toml create mode 100644 crates/uv-build-backend/src/lib.rs create mode 100644 crates/uv-build-backend/src/metadata.rs create mode 100644 crates/uv-build-backend/src/pep639_glob.rs rename crates/uv/src/commands/{build.rs => build_frontend.rs} (99%) diff --git a/Cargo.lock b/Cargo.lock index e0add76a3..7be6f474d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3372,6 +3372,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spdx" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47317bbaf63785b53861e1ae2d11b80d6b624211d42cb20efcd210ee6f8a14bc" +dependencies = [ + "smallvec", +] + [[package]] name = "spin" version = "0.9.8" @@ -4168,6 +4177,7 @@ dependencies = [ "unicode-width", "url", "uv-auth", + "uv-build-backend", "uv-cache", "uv-cache-info", "uv-cache-key", @@ -4257,6 +4267,35 @@ dependencies = [ "uv-types", ] +[[package]] +name = "uv-build-backend" +version = "0.1.0" +dependencies = [ + "async_zip", + "fs-err", + "glob", + "indoc", + "insta", + "itertools 0.13.0", + "serde", + "spdx", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "toml", + "tracing", + "uv-distribution-filename", + "uv-fs", + "uv-normalize", + "uv-pep440", + "uv-pep508", + "uv-pubgrub", + "uv-pypi-types", + "uv-version", + "uv-warnings", +] + [[package]] name = "uv-build-frontend" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index b363b49ef..4ff12fd16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ license = "MIT OR Apache-2.0" [workspace.dependencies] uv-auth = { path = "crates/uv-auth" } +uv-build-backend = { path = "crates/uv-build-backend" } uv-build-frontend = { path = "crates/uv-build-frontend" } uv-cache = { path = "crates/uv-cache" } uv-cache-info = { path = "crates/uv-cache-info" } @@ -143,6 +144,7 @@ serde-untagged = { version = "0.1.6" } serde_json = { version = "1.0.128" } sha2 = { version = "0.10.8" } smallvec = { version = "1.13.2" } +spdx = { version = "0.10.6" } syn = { version = "2.0.77" } sys-info = { version = "0.9.1" } target-lexicon = { version = "0.12.16" } diff --git a/crates/uv-build-backend/Cargo.toml b/crates/uv-build-backend/Cargo.toml new file mode 100644 index 000000000..9ecdcbf60 --- /dev/null +++ b/crates/uv-build-backend/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "uv-build-backend" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +homepage.workspace = true +documentation.workspace = true +repository.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +uv-distribution-filename = { workspace = true } +uv-fs = { workspace = true } +uv-normalize = { workspace = true } +uv-pep440 = { workspace = true } +uv-pep508 = { workspace = true } +uv-pubgrub = { workspace = true } +uv-pypi-types = { workspace = true } +uv-version = { workspace = true } +uv-warnings = { workspace = true } + +async_zip = { workspace = true } +fs-err = { workspace = true, features = ["tokio"] } +glob = { workspace = true } +itertools = { workspace = true } +serde = { workspace = true } +spdx = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } + +[lints] +workspace = true + +[dev-dependencies] +indoc = { version = "2.0.5" } +insta = { version = "1.40.0" } +tempfile = { version = "3.12.0" } diff --git a/crates/uv-build-backend/src/lib.rs b/crates/uv-build-backend/src/lib.rs new file mode 100644 index 000000000..f1e747ae0 --- /dev/null +++ b/crates/uv-build-backend/src/lib.rs @@ -0,0 +1,166 @@ +mod metadata; +mod pep639_glob; + +use crate::metadata::{PyProjectToml, ValidationError}; +use crate::pep639_glob::Pep639GlobError; +use async_zip::base::write::ZipFileWriter; +use async_zip::error::ZipError; +use async_zip::{Compression, ZipEntryBuilder, ZipString}; +use glob::{GlobError, PatternError}; +use std::io; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use uv_distribution_filename::WheelFilename; + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error("Invalid pyproject.toml")] + Toml(#[from] toml::de::Error), + #[error("Invalid pyproject.toml")] + Validation(#[from] ValidationError), + #[error("Invalid `project.license-files` glob expression: `{0}`")] + Pep639Glob(String, #[source] Pep639GlobError), + #[error("The `project.license-files` entry is not a valid glob pattern: `{0}`")] + Pattern(String, #[source] PatternError), + /// [`GlobError`] is a wrapped io error. + #[error(transparent)] + Glob(#[from] GlobError), + #[error("Failed to write wheel zip archive")] + Zip(#[from] ZipError), +} + +/// Allow dispatching between writing to a directory, writing to zip and writing to a `.tar.gz`. +trait AsyncDirectoryWrite: Sized { + async fn write_bytes( + &mut self, + directory: &Path, + filename: &str, + bytes: &[u8], + ) -> Result<(), Error>; + + #[allow(clippy::unused_async)] // https://github.com/rust-lang/rust-clippy/issues/11660 + async fn close(self) -> Result<(), Error> { + Ok(()) + } +} + +/// Zip archive (wheel) writer. +struct AsyncZipWriter(ZipFileWriter>); + +impl AsyncDirectoryWrite for AsyncZipWriter { + async fn write_bytes( + &mut self, + directory: &Path, + filename: &str, + bytes: &[u8], + ) -> Result<(), Error> { + self.0 + .write_entry_whole( + ZipEntryBuilder::new( + ZipString::from(format!("{}/{}", directory.display(), filename)), + // TODO(konsti): Editables use stored. + Compression::Deflate, + ) + // https://github.com/Majored/rs-async-zip/issues/150 + .unix_permissions(0o644), + bytes, + ) + .await?; + Ok(()) + } + + async fn close(self) -> Result<(), Error> { + self.0.close().await?; + Ok(()) + } +} + +struct AsyncFsWriter { + root: PathBuf, +} + +/// File system writer. +impl AsyncDirectoryWrite for AsyncFsWriter { + async fn write_bytes( + &mut self, + directory: &Path, + filename: &str, + bytes: &[u8], + ) -> Result<(), Error> { + fs_err::tokio::create_dir_all(self.root.join(directory)).await?; + fs_err::tokio::write(self.root.join(directory).join(filename), bytes).await?; + Ok(()) + } +} + +/// Build a wheel from the source tree and place it in the output directory. +pub async fn build(source_tree: &Path, wheel_dir: &Path) -> Result { + let contents = fs_err::tokio::read_to_string(source_tree.join("pyproject.toml")).await?; + let pyproject_toml = PyProjectToml::parse(&contents)?; + pyproject_toml.check_build_system(); + + let filename = WheelFilename { + name: pyproject_toml.name().clone(), + version: pyproject_toml.version().clone(), + build_tag: None, + python_tag: vec!["py3".to_string()], + abi_tag: vec!["none".to_string()], + platform_tag: vec!["any".to_string()], + }; + + // TODO(konsti): async-zip doesn't like a buffered writer + let wheel_file = fs_err::tokio::File::create(wheel_dir.join(filename.to_string())).await?; + let mut wheel_writer = AsyncZipWriter(ZipFileWriter::with_tokio(wheel_file)); + write_dist_info(&mut wheel_writer, &pyproject_toml, source_tree).await?; + wheel_writer.close().await?; + Ok(filename) +} + +/// Write the dist-info directory to the output directory without building the wheel. +pub async fn metadata(source_tree: &Path, metadata_directory: &Path) -> Result { + let contents = fs_err::tokio::read_to_string(source_tree.join("pyproject.toml")).await?; + let pyproject_toml = PyProjectToml::parse(&contents)?; + pyproject_toml.check_build_system(); + + let mut wheel_writer = AsyncFsWriter { + root: metadata_directory.to_path_buf(), + }; + write_dist_info(&mut wheel_writer, &pyproject_toml, source_tree).await?; + wheel_writer.close().await?; + + Ok(format!( + "{}-{}.dist-info", + pyproject_toml.name().as_dist_info_name(), + pyproject_toml.version() + )) +} + +/// Add `METADATA` and `entry_points.txt` to the dist-info directory. +async fn write_dist_info( + writer: &mut impl AsyncDirectoryWrite, + pyproject_toml: &PyProjectToml, + root: &Path, +) -> Result<(), Error> { + let dist_info_dir = PathBuf::from(format!( + "{}-{}.dist-info", + pyproject_toml.name().as_dist_info_name(), + pyproject_toml.version() + )); + + let metadata = pyproject_toml + .to_metadata(root) + .await? + .core_metadata_format(); + writer + .write_bytes(&dist_info_dir, "METADATA", metadata.as_bytes()) + .await?; + + let entrypoint = pyproject_toml.to_entry_points()?; + writer + .write_bytes(&dist_info_dir, "entry_points.txt", entrypoint.as_bytes()) + .await?; + + Ok(()) +} diff --git a/crates/uv-build-backend/src/metadata.rs b/crates/uv-build-backend/src/metadata.rs new file mode 100644 index 000000000..6322aaec0 --- /dev/null +++ b/crates/uv-build-backend/src/metadata.rs @@ -0,0 +1,1038 @@ +use crate::pep639_glob::parse_pep639_glob; +use crate::Error; +use itertools::Itertools; +use serde::Deserialize; +use std::collections::{BTreeMap, Bound}; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use tracing::debug; +use uv_fs::Simplified; +use uv_normalize::{ExtraName, PackageName}; +use uv_pep440::{Version, VersionSpecifiers}; +use uv_pep508::{Requirement, VersionOrUrl}; +use uv_pubgrub::PubGrubSpecifier; +use uv_pypi_types::{Metadata23, VerbatimParsedUrl}; +use uv_warnings::warn_user_once; + +#[derive(Debug, Error)] +pub enum ValidationError { + /// The spec isn't clear about what the values in that field would be, and we only support the + /// default value (UTF-8). + #[error("Charsets other than UTF-8 are not supported. Please convert your README to UTF-8 and remove `project.readme.charset`.")] + ReadmeCharset, + #[error("Unknown Readme extension `{0}`, can't determine content type. Please use a support extension (`.md`, `.rst`, `.txt`) or set the content type manually.")] + UnknownExtension(String), + #[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())] + MissingExtension(PathBuf), + #[error("Unsupported content type: `{0}`")] + UnsupportedContentType(String), + #[error("`project.description` must be a single line")] + DescriptionNewlines, + #[error("Dynamic metadata is not supported")] + Dynamic, + #[error("When `project.license-files` is defined, `project.license` must be an SPDX expression string")] + MixedLicenseGenerations, + #[error("Entrypoint groups must consist of letters and numbers separated by dots, invalid group: `{0}`")] + InvalidGroup(String), + #[error( + "Entrypoint names must consist of letters, numbers, dots and dashes; invalid name: `{0}`" + )] + InvalidName(String), + #[error("Use `project.scripts` instead of `project.entry-points.console_scripts`")] + ReservedScripts, + #[error("Use `project.gui-scripts` instead of `project.entry-points.gui_scripts`")] + ReservedGuiScripts, + #[error("`project.license` is not a valid SPDX expression: `{0}`")] + InvalidSpdx(String, #[source] spdx::error::ParseError), +} + +/// A `pyproject.toml` as specified in PEP 517. +#[derive(Deserialize, Debug, Clone)] +#[serde( + rename_all = "kebab-case", + expecting = "The project table needs to follow \ + https://packaging.python.org/en/latest/guides/writing-pyproject-toml" +)] +pub(crate) struct PyProjectToml { + /// Project metadata + project: Project, + /// Build-related data + build_system: BuildSystem, +} + +impl PyProjectToml { + pub(crate) fn name(&self) -> &PackageName { + &self.project.name + } + + pub(crate) fn version(&self) -> &Version { + &self.project.version + } + + pub(crate) fn parse(contents: &str) -> Result { + Ok(toml::from_str(contents)?) + } + + /// Warn if the `[build-system]` table looks suspicious. + /// + /// Example of a valid table: + /// + /// ```toml + /// [build-system] + /// requires = ["uv>=0.4.15,<5"] + /// build-backend = "uv" + /// ``` + /// + /// Returns whether all checks passed. + pub(crate) fn check_build_system(&self) -> bool { + let mut passed = true; + if self.build_system.build_backend.as_deref() != Some("uv") { + warn_user_once!( + r#"The value for `build_system.build-backend` should be `"uv"`, not `"{}"`"#, + self.build_system.build_backend.clone().unwrap_or_default() + ); + passed = false; + } + + let uv_version = Version::from_str(uv_version::version()) + .expect("uv's own version is not PEP 440 compliant"); + let next_minor = uv_version.release().get(1).copied().unwrap_or_default() + 1; + let next_breaking = Version::new([0, next_minor]); + + let expected = || { + format!( + "Expected a single uv requirement in `build-system.requires`, found `{}`", + toml::to_string(&self.build_system.requires).unwrap_or_default() + ) + }; + + let [uv_requirement] = &self.build_system.requires.as_slice() else { + warn_user_once!("{}", expected()); + return false; + }; + if uv_requirement.name.as_str() != "uv" { + warn_user_once!("{}", expected()); + return false; + } + let bounded = match &uv_requirement.version_or_url { + None => false, + Some(VersionOrUrl::Url(_)) => { + // We can't validate the url + true + } + Some(VersionOrUrl::VersionSpecifier(specifier)) => { + // We don't check how wide the range is (that's up to the user), we just + // check that the current version is compliant, to avoid accidentally using a + // too new or too old uv, and we check that an upper bound exists. The latter + // is very important to allow making breaking changes in uv without breaking + // the existing immutable source distributions on pypi. + if !specifier.contains(&uv_version) { + // This is allowed to happen when testing prereleases, but we should still warn. + warn_user_once!( + r#"`build_system.requires = ["{uv_requirement}"]` does not contain the + current uv version {}"#, + uv_version::version() + ); + passed = false; + } + PubGrubSpecifier::from_pep440_specifiers(specifier) + .ok() + .and_then(|specifier| Some(specifier.bounding_range()?.1 != Bound::Unbounded)) + .unwrap_or(false) + } + }; + + if !bounded { + warn_user_once!( + r#"`build_system.requires = ["{uv_requirement}"]` is missing an + upper bound on the uv version such as `<{next_breaking}`. + Without bounding the uv version, the source distribution will break + when a future, breaking version of uv is released."#, + ); + passed = false; + } + + passed + } + + /// Validate and convert a `pyproject.toml` to core metadata. + /// + /// + /// + /// + pub(crate) async fn to_metadata(&self, root: &Path) -> Result { + let summary = if let Some(description) = &self.project.description { + if description.contains('\n') { + return Err(ValidationError::DescriptionNewlines.into()); + } + Some(description.clone()) + } else { + None + }; + + let supported_content_types = ["text/plain", "text/x-rst", "text/markdown"]; + let (description, description_content_type) = match &self.project.readme { + Some(Readme::String(path)) => { + let content = fs_err::tokio::read_to_string(root.join(path)).await?; + let content_type = match path.extension().and_then(OsStr::to_str) { + Some("txt") => "text/plain", + Some("rst") => "text/x-rst", + Some("md") => "text/markdown", + Some(unknown) => { + return Err(ValidationError::UnknownExtension(unknown.to_owned()).into()) + } + None => return Err(ValidationError::MissingExtension(path.clone()).into()), + } + .to_string(); + (Some(content), Some(content_type)) + } + Some(Readme::File { + file, + content_type, + charset, + }) => { + let content = fs_err::tokio::read_to_string(root.join(file)).await?; + if !supported_content_types.contains(&content_type.as_str()) { + return Err( + ValidationError::UnsupportedContentType(content_type.clone()).into(), + ); + } + if charset.as_ref().is_some_and(|charset| charset != "UTF-8") { + return Err(ValidationError::ReadmeCharset.into()); + } + (Some(content), Some(content_type.clone())) + } + Some(Readme::Text { + text, + content_type, + charset, + }) => { + if !supported_content_types.contains(&content_type.as_str()) { + return Err( + ValidationError::UnsupportedContentType(content_type.clone()).into(), + ); + } + if charset.as_ref().is_some_and(|charset| charset != "UTF-8") { + return Err(ValidationError::ReadmeCharset.into()); + } + (Some(text.clone()), Some(content_type.clone())) + } + None => (None, None), + }; + + if self + .project + .dynamic + .as_ref() + .is_some_and(|dynamic| !dynamic.is_empty()) + { + return Err(ValidationError::Dynamic.into()); + } + + let author = self + .project + .authors + .as_ref() + .map(|authors| { + authors + .iter() + .filter_map(|author| match author { + Contact::Name { name } => Some(name), + Contact::Email { .. } => None, + Contact::NameEmail { name, .. } => Some(name), + }) + .join(", ") + }) + .filter(|author| !author.is_empty()); + let author_email = self + .project + .authors + .as_ref() + .map(|authors| { + authors + .iter() + .filter_map(|author| match author { + Contact::Name { .. } => None, + Contact::Email { email } => Some(email.clone()), + Contact::NameEmail { name, email } => Some(format!("{name} <{email}>")), + }) + .join(", ") + }) + .filter(|author_email| !author_email.is_empty()); + let maintainer = self + .project + .maintainers + .as_ref() + .map(|maintainers| { + maintainers + .iter() + .filter_map(|maintainer| match maintainer { + Contact::Name { name } => Some(name), + Contact::Email { .. } => None, + Contact::NameEmail { name, .. } => Some(name), + }) + .join(", ") + }) + .filter(|maintainer| !maintainer.is_empty()); + let maintainer_email = self + .project + .maintainers + .as_ref() + .map(|maintainers| { + maintainers + .iter() + .filter_map(|maintainer| match maintainer { + Contact::Name { .. } => None, + Contact::Email { email } => Some(email.clone()), + Contact::NameEmail { name, email } => Some(format!("{name} <{email}>")), + }) + .join(", ") + }) + .filter(|maintainer_email| !maintainer_email.is_empty()); + + // Using PEP 639 bumps the METADATA version + let metadata_version = if self.project.license_files.is_some() + || matches!(self.project.license, Some(License::Spdx(_))) + { + debug!("Found PEP 639 license declarations, using METADATA 2.4"); + "2.4" + } else { + "2.3" + }; + + // TODO(konsti): Issue a warning on old license metadata once PEP 639 is universal. + let (license, license_expression, license_files) = + if let Some(license_globs) = &self.project.license_files { + let license_expression = match &self.project.license { + None => None, + Some(License::Spdx(license_expression)) => Some(license_expression.clone()), + Some(License::Text { .. } | License::File { .. }) => { + return Err(ValidationError::MixedLicenseGenerations.into()) + } + }; + + let mut license_files = Vec::new(); + for license_glob in license_globs { + let pep639_glob = parse_pep639_glob(license_glob) + .map_err(|err| Error::Pep639Glob(license_glob.to_string(), err))?; + let absolute_glob = PathBuf::from(glob::Pattern::escape( + root.simplified().to_string_lossy().as_ref(), + )) + .join(pep639_glob.to_string()) + .to_string_lossy() + .to_string(); + for license_file in glob::glob(&absolute_glob) + .map_err(|err| Error::Pattern(absolute_glob.to_string(), err))? + { + let license_file = license_file + .map_err(Error::Glob)? + .to_string_lossy() + .to_string(); + if !license_files.contains(&license_file) { + license_files.push(license_file); + } + } + } + // The glob order may be unstable + license_files.sort(); + + (None, license_expression, license_files) + } else { + match &self.project.license { + None => (None, None, Vec::new()), + Some(License::Spdx(license_expression)) => { + (None, Some(license_expression.clone()), Vec::new()) + } + Some(License::Text { text }) => (Some(text.clone()), None, Vec::new()), + Some(License::File { file }) => { + let text = fs_err::tokio::read_to_string(root.join(file)).await?; + (Some(text), None, Vec::new()) + } + } + }; + + // Check that the license expression is a valid SPDX identifier. + if let Some(license_expression) = &license_expression { + if let Err(err) = spdx::Expression::parse(license_expression) { + return Err(ValidationError::InvalidSpdx(license_expression.clone(), err).into()); + } + } + + // TODO(konsti): https://peps.python.org/pep-0753/#label-normalization (Draft) + let project_urls = self + .project + .urls + .iter() + .flatten() + .map(|(key, value)| format!("{key}, {value}")) + .collect(); + + let extras = self + .project + .optional_dependencies + .iter() + .flat_map(|optional_dependencies| optional_dependencies.keys()) + .map(ToString::to_string) + .collect(); + + Ok(Metadata23 { + metadata_version: metadata_version.to_string(), + name: self.project.name.to_string(), + version: self.project.version.to_string(), + // Not supported. + platforms: vec![], + // Not supported. + supported_platforms: vec![], + summary, + description, + description_content_type, + keywords: self + .project + .keywords + .as_ref() + .map(|keywords| keywords.join(",")), + home_page: None, + download_url: None, + author, + author_email, + maintainer, + maintainer_email, + license, + license_expression, + license_files, + classifiers: self.project.classifiers.clone().unwrap_or_default(), + requires_dist: self + .project + .dependencies + .iter() + .flatten() + .map(ToString::to_string) + .collect(), + // Not commonly set. + provides_dist: vec![], + // Not supported. + obsoletes_dist: vec![], + requires_python: self + .project + .requires_python + .as_ref() + .map(ToString::to_string), + // Not used by other tools, not supported. + requires_external: vec![], + project_urls, + provides_extras: extras, + dynamic: vec![], + }) + } + + /// Validate and convert the entrypoints in `pyproject.toml`, including console and GUI scripts, + /// to an `entry_points.txt`. + /// + /// + pub(crate) fn to_entry_points(&self) -> Result { + let mut writer = String::new(); + + Self::write_group( + &mut writer, + "console_scripts", + self.project.scripts.iter().flatten(), + )?; + Self::write_group( + &mut writer, + "gui_scripts", + self.project.gui_scripts.iter().flatten(), + )?; + for (group, entries) in self.project.entry_points.iter().flatten() { + if group == "console_scripts" { + return Err(ValidationError::ReservedScripts); + } + if group == "gui_scripts" { + return Err(ValidationError::ReservedGuiScripts); + } + Self::write_group(&mut writer, group, entries.iter())?; + } + Ok(writer) + } + + /// Write a group to `entry_points.txt`. + fn write_group<'a>( + writer: &mut String, + group: &str, + entries: impl Iterator, + ) -> Result<(), ValidationError> { + if !group + .chars() + .next() + .map(|c| c.is_alphanumeric() || c == '_') + .unwrap_or(false) + || !group + .chars() + .all(|c| c.is_alphanumeric() || c == '.' || c == '_') + { + return Err(ValidationError::InvalidGroup(group.to_string())); + } + + writer.push_str(&format!("[{group}]\n")); + for (name, object_reference) in entries { + // More strict than the spec, we enforce the recommendation + if !name + .chars() + .all(|c| c.is_alphanumeric() || c == '.' || c == '-') + { + return Err(ValidationError::InvalidName(name.to_string())); + } + + // TODO(konsti): Validate that the object references are valid Python identifiers. + writer.push_str(&format!("{name} = {object_reference}\n")); + } + writer.push('\n'); + Ok(()) + } +} + +/// The `[project]` section of a pyproject.toml as specified in +/// . +/// +/// This struct does not have schema export; the schema is shared between all Python tools, and we +/// should update the shared schema instead. +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "kebab-case")] +struct Project { + /// The name of the project. + name: PackageName, + /// The version of the project. + version: Version, + /// The summary description of the project in one line. + description: Option, + /// The full description of the project (i.e. the README). + readme: Option, + /// The Python version requirements of the project. + requires_python: Option, + /// The license under which the project is distributed. + /// + /// Supports both the current standard and the provisional PEP 639. + license: Option, + /// The paths to files containing licenses and other legal notices to be distributed with the + /// project. + /// + /// From the provisional PEP 639 + license_files: Option>, + /// The people or organizations considered to be the "authors" of the project. + authors: Option>, + /// The people or organizations considered to be the "maintainers" of the project. + maintainers: Option>, + /// The keywords for the project. + keywords: Option>, + /// Trove classifiers which apply to the project. + classifiers: Option>, + /// A table of URLs where the key is the URL label and the value is the URL itself. + /// + /// PyPI shows all URLs with their name. For some known patterns, they add favicons. + /// main: + /// archived: + urls: Option>, + /// The console entrypoints of the project. + /// + /// The key of the table is the name of the entry point and the value is the object reference. + scripts: Option>, + /// The GUI entrypoints of the project. + /// + /// The key of the table is the name of the entry point and the value is the object reference. + gui_scripts: Option>, + /// Entrypoints groups of the project. + /// + /// The key of the table is the name of the entry point and the value is the object reference. + entry_points: Option>>, + /// The dependencies of the project. + dependencies: Option>, + /// The optional dependencies of the project. + optional_dependencies: Option>>, + /// Specifies which fields listed by PEP 621 were intentionally unspecified so another tool + /// can/will provide such metadata dynamically. + /// + /// Not supported, an error if anything but the default empty list. + dynamic: Option>, +} + +/// The optional `project.readme` key in a pyproject.toml as specified in +/// . +#[derive(Deserialize, Debug, Clone)] +#[serde(untagged, rename_all = "kebab-case")] +enum Readme { + /// Relative path to the README. + String(PathBuf), + /// Relative path to the README. + File { + file: PathBuf, + content_type: String, + charset: Option, + }, + /// The full description of the project as inline value. + Text { + text: String, + content_type: String, + charset: Option, + }, +} + +/// The optional `project.license` key in a pyproject.toml as specified in +/// . +#[derive(Deserialize, Debug, Clone)] +#[serde(untagged)] +enum License { + /// An SPDX Expression. + /// + /// From the provisional PEP 639. + Spdx(String), + Text { + /// The full text of the license. + text: String, + }, + File { + /// The file containing the license text. + file: PathBuf, + }, +} + +/// A `project.authors` or `project.maintainers` entry as specified in +/// . +/// +/// The entry is derived from the email format of `John Doe `. You need to +/// provide at least name or email. +#[derive(Deserialize, Debug, Clone)] +#[serde(untagged, expecting = "a table with 'name' and/or 'email' keys")] +enum Contact { + /// TODO(konsti): RFC 822 validation. + Name { name: String }, + /// TODO(konsti): RFC 822 validation. + Email { email: String }, + /// TODO(konsti): RFC 822 validation. + NameEmail { name: String, email: String }, +} + +/// The `[build-system]` section of a pyproject.toml as specified in PEP 517. +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +struct BuildSystem { + /// PEP 508 dependencies required to execute the build system. + requires: Vec>, + /// A string naming a Python object that will be used to perform the build. + build_backend: Option, + /// + backend_path: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::{formatdoc, indoc}; + use insta::assert_snapshot; + use std::iter; + use tempfile::TempDir; + + fn extend_project(payload: &str) -> String { + formatdoc! {r#" + [project] + name = "hello-world" + version = "0.1.0" + {payload} + + [build-system] + requires = ["uv>=0.4.15,<5"] + build-backend = "uv" + "# + } + } + + fn format_err(err: impl std::error::Error) -> String { + let mut formatted = err.to_string(); + for source in iter::successors(err.source(), |&err| err.source()) { + formatted += &format!("\n Caused by: {source}"); + } + formatted + } + + #[tokio::test] + async fn valid() { + let temp_dir = TempDir::new().unwrap(); + + fs_err::write( + temp_dir.path().join("Readme.md"), + indoc! {r" + # Foo + + This is the foo library. + "}, + ) + .unwrap(); + + fs_err::write( + temp_dir.path().join("License.txt"), + indoc! {r#" + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + "#}, + ) + .unwrap(); + + let contents = indoc! {r#" + # See https://github.com/pypa/sampleproject/blob/main/pyproject.toml for another example + + [project] + name = "hello-world" + version = "0.1.0" + description = "A Python package" + readme = "Readme.md" + requires_python = ">=3.12" + license = { file = "License.txt" } + authors = [{ name = "Ferris the crab", email = "ferris@rustacean.net" }] + maintainers = [{ name = "Konsti", email = "konstin@mailbox.org" }] + keywords = ["demo", "example", "package"] + classifiers = [ + "Development Status :: 6 - Mature", + "License :: OSI Approved :: MIT License", + # https://github.com/pypa/trove-classifiers/issues/17 + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + ] + dependencies = ["flask>=3,<4", "sqlalchemy[asyncio]>=2.0.35,<3"] + # We don't support dynamic fields, the default empty array is the only allowed value. + dynamic = [] + + [project.optional-dependencies] + postgres = ["psycopg>=3.2.2,<4"] + mysql = ["pymysql>=1.1.1,<2"] + + [project.urls] + "Homepage" = "https://github.com/astral-sh/uv" + "Repository" = "https://astral.sh" + + [project.scripts] + foo = "foo.cli:__main__" + + [project.gui-scripts] + foo-gui = "foo.gui" + + [project.entry-points.bar_group] + foo-bar = "foo:bar" + + [build-system] + requires = ["uv>=0.4.15,<5"] + build-backend = "uv" + "# + }; + + let pyproject_toml = PyProjectToml::parse(contents).unwrap(); + let metadata = pyproject_toml.to_metadata(temp_dir.path()).await.unwrap(); + + assert_snapshot!(metadata.core_metadata_format(), @r###" + Metadata-Version: 2.3 + Name: hello-world + Version: 0.1.0 + Summary: A Python package + Keywords: demo,example,package + Author: Ferris the crab + License: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + Classifier: Development Status :: 6 - Mature + Classifier: License :: OSI Approved :: MIT License + Classifier: License :: OSI Approved :: Apache Software License + Classifier: Programming Language :: Python + Requires-Dist: flask>=3,<4 + Requires-Dist: sqlalchemy[asyncio]>=2.0.35,<3 + Maintainer: Konsti + Project-URL: Homepage, https://github.com/astral-sh/uv + Project-URL: Repository, https://astral.sh + Provides-Extra: mysql + Provides-Extra: postgres + Description-Content-Type: text/markdown + + # Foo + + This is the foo library. + "###); + + assert_snapshot!(pyproject_toml.to_entry_points().unwrap(), @r###" + [console_scripts] + foo = foo.cli:__main__ + + [gui_scripts] + foo-gui = foo.gui + + [bar_group] + foo-bar = foo:bar + + "###); + } + + #[test] + fn build_system_valid() { + let contents = extend_project(""); + let pyproject_toml = PyProjectToml::parse(&contents).unwrap(); + assert!(pyproject_toml.check_build_system()); + } + + #[test] + fn build_system_no_bound() { + let contents = indoc! {r#" + [project] + name = "hello-world" + version = "0.1.0" + + [build-system] + requires = ["uv"] + build-backend = "uv" + "#}; + let pyproject_toml = PyProjectToml::parse(contents).unwrap(); + assert!(!pyproject_toml.check_build_system()); + } + + #[test] + fn build_system_multiple_packages() { + let contents = indoc! {r#" + [project] + name = "hello-world" + version = "0.1.0" + + [build-system] + requires = ["uv>=0.4.15,<5", "wheel"] + build-backend = "uv" + "#}; + let pyproject_toml = PyProjectToml::parse(contents).unwrap(); + assert!(!pyproject_toml.check_build_system()); + } + + #[test] + fn build_system_no_requires_uv() { + let contents = indoc! {r#" + [project] + name = "hello-world" + version = "0.1.0" + + [build-system] + requires = ["setuptools"] + build-backend = "uv" + "#}; + let pyproject_toml = PyProjectToml::parse(contents).unwrap(); + assert!(!pyproject_toml.check_build_system()); + } + + #[test] + fn build_system_not_uv() { + let contents = indoc! {r#" + [project] + name = "hello-world" + version = "0.1.0" + + [build-system] + requires = ["uv>=0.4.15,<5"] + build-backend = "setuptools" + "#}; + let pyproject_toml = PyProjectToml::parse(contents).unwrap(); + assert!(!pyproject_toml.check_build_system()); + } + + #[tokio::test] + async fn minimal() { + let contents = extend_project(""); + + let metadata = PyProjectToml::parse(&contents) + .unwrap() + .to_metadata(Path::new("/do/not/read")) + .await + .unwrap(); + + assert_snapshot!(metadata.core_metadata_format(), @r###" + Metadata-Version: 2.3 + Name: hello-world + Version: 0.1.0 + "###); + } + + #[test] + fn invalid_readme_spec() { + let contents = extend_project(indoc! {r#" + readme = { path = "Readme.md" } + "# + }); + + let err = PyProjectToml::parse(&contents).unwrap_err(); + assert_snapshot!(format_err(err), @r###" + Invalid pyproject.toml + Caused by: TOML parse error at line 4, column 10 + | + 4 | readme = { path = "Readme.md" } + | ^^^^^^^^^^^^^^^^^^^^^^ + data did not match any variant of untagged enum Readme + "###); + } + + #[tokio::test] + async fn missing_readme() { + let contents = extend_project(indoc! {r#" + readme = "Readme.md" + "# + }); + + let err = PyProjectToml::parse(&contents) + .unwrap() + .to_metadata(Path::new("/do/not/read")) + .await + .unwrap_err(); + // Simplified for windows compatibility. + assert_snapshot!(err.to_string().replace('\\', "/"), @r###" + failed to read from file `/do/not/read/Readme.md` + "###); + } + + #[tokio::test] + async fn multiline_description() { + let contents = extend_project(indoc! {r#" + description = "Hi :)\nThis is my project" + "# + }); + + let err = PyProjectToml::parse(&contents) + .unwrap() + .to_metadata(Path::new("/do/not/read")) + .await + .unwrap_err(); + assert_snapshot!(format_err(err), @r###" + Invalid pyproject.toml + Caused by: `project.description` must be a single line + "###); + } + + #[tokio::test] + async fn mixed_licenses() { + let contents = extend_project(indoc! {r#" + license-files = ["licenses/*"] + license = { text = "MIT" } + "# + }); + + let err = PyProjectToml::parse(&contents) + .unwrap() + .to_metadata(Path::new("/do/not/read")) + .await + .unwrap_err(); + assert_snapshot!(format_err(err), @r###" + Invalid pyproject.toml + Caused by: When `project.license-files` is defined, `project.license` must be an SPDX expression string + "###); + } + + #[tokio::test] + async fn valid_license() { + let contents = extend_project(indoc! {r#" + license = "MIT OR Apache-2.0" + "# + }); + let metadata = PyProjectToml::parse(&contents) + .unwrap() + .to_metadata(Path::new("/do/not/read")) + .await + .unwrap(); + assert_snapshot!(metadata.core_metadata_format(), @r###" + Metadata-Version: 2.4 + Name: hello-world + Version: 0.1.0 + License-Expression: MIT OR Apache-2.0 + "###); + } + + #[tokio::test] + async fn invalid_license() { + let contents = extend_project(indoc! {r#" + license = "MIT XOR Apache-2" + "# + }); + let err = PyProjectToml::parse(&contents) + .unwrap() + .to_metadata(Path::new("/do/not/read")) + .await + .unwrap_err(); + // TODO(konsti): We mess up the indentation in the error. + assert_snapshot!(format_err(err), @r###" + Invalid pyproject.toml + Caused by: `project.license` is not a valid SPDX expression: `MIT XOR Apache-2` + Caused by: MIT XOR Apache-2 + ^^^ unknown term + "###); + } + + #[tokio::test] + async fn dynamic() { + let contents = extend_project(indoc! {r#" + dynamic = ["dependencies"] + "# + }); + + let err = PyProjectToml::parse(&contents) + .unwrap() + .to_metadata(Path::new("/do/not/read")) + .await + .unwrap_err(); + assert_snapshot!(format_err(err), @r###" + Invalid pyproject.toml + Caused by: Dynamic metadata is not supported + "###); + } + + fn script_error(contents: &str) -> String { + let err = PyProjectToml::parse(contents) + .unwrap() + .to_entry_points() + .unwrap_err(); + format_err(err) + } + + #[test] + fn invalid_entry_point_group() { + let contents = extend_project(indoc! {r#" + [project.entry-points."a@b"] + foo = "bar" + "# + }); + assert_snapshot!(script_error(&contents), @"Entrypoint groups must consist of letters and numbers separated by dots, invalid group: `a@b`"); + } + + #[test] + fn invalid_entry_point_name() { + let contents = extend_project(indoc! {r#" + [project.scripts] + "a@b" = "bar" + "# + }); + assert_snapshot!(script_error(&contents), @"Entrypoint names must consist of letters, numbers, dots and dashes; invalid name: `a@b`"); + } + + #[test] + fn invalid_entry_point_conflict_scripts() { + let contents = extend_project(indoc! {r#" + [project.entry-points.console_scripts] + foo = "bar" + "# + }); + assert_snapshot!(script_error(&contents), @"Use `project.scripts` instead of `project.entry-points.console_scripts`"); + } + + #[test] + fn invalid_entry_point_conflict_gui_scripts() { + let contents = extend_project(indoc! {r#" + [project.entry-points.gui_scripts] + foo = "bar" + "# + }); + assert_snapshot!(script_error(&contents), @"Use `project.gui-scripts` instead of `project.entry-points.gui_scripts`"); + } +} diff --git a/crates/uv-build-backend/src/pep639_glob.rs b/crates/uv-build-backend/src/pep639_glob.rs new file mode 100644 index 000000000..b0fb4a2be --- /dev/null +++ b/crates/uv-build-backend/src/pep639_glob.rs @@ -0,0 +1,136 @@ +//! Implementation of PEP 639 cross-language restricted globs. + +use glob::{Pattern, PatternError}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Pep639GlobError { + #[error(transparent)] + PatternError(#[from] PatternError), + #[error("The parent directory operator (`..`) at position {pos} is not allowed in license file globs")] + ParentDirectory { pos: usize }, + #[error("Glob contains invalid character at position {pos}: `{invalid}`")] + InvalidCharacter { pos: usize, invalid: char }, + #[error("Glob contains invalid character in range at position {pos}: `{invalid}`")] + InvalidCharacterRange { pos: usize, invalid: char }, +} + +/// Parse a PEP 639 `license-files` glob. +/// +/// The syntax is more restricted than regular globbing in Python or Rust for platform independent +/// results. Since [`glob::Pattern`] is a superset over this format, we can use it after validating +/// that no unsupported features are in the string. +/// +/// From [PEP 639](https://peps.python.org/pep-0639/#add-license-files-key): +/// +/// > Its value is an array of strings which MUST contain valid glob patterns, +/// > as specified below: +/// > +/// > - Alphanumeric characters, underscores (`_`), hyphens (`-`) and dots (`.`) +/// > MUST be matched verbatim. +/// > +/// > - Special glob characters: `*`, `?`, `**` and character ranges: `[]` +/// > containing only the verbatim matched characters MUST be supported. +/// > Within `[...]`, the hyphen indicates a range (e.g. `a-z`). +/// > Hyphens at the start or end are matched literally. +/// > +/// > - Path delimiters MUST be the forward slash character (`/`). +/// > Patterns are relative to the directory containing `pyproject.toml`, +/// > therefore the leading slash character MUST NOT be used. +/// > +/// > - Parent directory indicators (`..`) MUST NOT be used. +/// > +/// > Any characters or character sequences not covered by this specification are +/// > invalid. Projects MUST NOT use such values. +/// > Tools consuming this field MAY reject invalid values with an error. +pub(crate) fn parse_pep639_glob(glob: &str) -> Result { + let mut chars = glob.chars().enumerate().peekable(); + // A `..` is on a parent directory indicator at the start of the string or after a directory + // separator. + let mut start_or_slash = true; + while let Some((pos, c)) = chars.next() { + if c.is_alphanumeric() || matches!(c, '_' | '-' | '*' | '?') { + start_or_slash = false; + } else if c == '.' { + if start_or_slash && matches!(chars.peek(), Some((_, '.'))) { + return Err(Pep639GlobError::ParentDirectory { pos }); + } + start_or_slash = false; + } else if c == '/' { + start_or_slash = true; + } else if c == '[' { + for (pos, c) in chars.by_ref() { + // TODO: https://discuss.python.org/t/pep-639-round-3-improving-license-clarity-with-better-package-metadata/53020/98 + if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { + // Allowed. + } else if c == ']' { + break; + } else { + return Err(Pep639GlobError::InvalidCharacterRange { pos, invalid: c }); + } + } + start_or_slash = false; + } else { + return Err(Pep639GlobError::InvalidCharacter { pos, invalid: c }); + } + } + Ok(Pattern::new(glob)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + + #[test] + fn test_error() { + let parse_err = |glob| parse_pep639_glob(glob).unwrap_err().to_string(); + assert_snapshot!( + parse_err(".."), + @"The parent directory operator (`..`) at position 0 is not allowed in license file globs" + ); + assert_snapshot!( + parse_err("licenses/.."), + @"The parent directory operator (`..`) at position 9 is not allowed in license file globs" + ); + assert_snapshot!( + parse_err("licenses/LICEN!E.txt"), + @"Glob contains invalid character at position 14: `!`" + ); + assert_snapshot!( + parse_err("licenses/LICEN[!C]E.txt"), + @"Glob contains invalid character in range at position 15: `!`" + ); + assert_snapshot!( + parse_err("licenses/LICEN[C?]E.txt"), + @"Glob contains invalid character in range at position 16: `?`" + ); + assert_snapshot!(parse_err("******"), @"Pattern syntax error near position 2: wildcards are either regular `*` or recursive `**`"); + assert_snapshot!( + parse_err(r"licenses\eula.txt"), + @r"Glob contains invalid character at position 8: `\`" + ); + } + + #[test] + fn test_valid() { + let cases = [ + "licenses/*.txt", + "licenses/**/*.txt", + "LICEN[CS]E.txt", + "LICEN?E.txt", + "[a-z].txt", + "[a-z._-].txt", + "*/**", + "LICENSE..txt", + "LICENSE_file-1.txt", + // (google translate) + "licenses/라이센스*.txt", + "licenses/ライセンス*.txt", + "licenses/执照*.txt", + ]; + for case in cases { + parse_pep639_glob(case).unwrap(); + } + } +} diff --git a/crates/uv-pypi-types/src/metadata/metadata23.rs b/crates/uv-pypi-types/src/metadata/metadata23.rs index d4d1d034e..f10a73968 100644 --- a/crates/uv-pypi-types/src/metadata/metadata23.rs +++ b/crates/uv-pypi-types/src/metadata/metadata23.rs @@ -2,6 +2,7 @@ use crate::metadata::Headers; use crate::MetadataError; +use std::fmt::Display; use std::str; use std::str::FromStr; @@ -9,11 +10,12 @@ use std::str::FromStr; /// . #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Metadata23 { - /// Version of the file format; legal values are `1.0`, `1.1`, `1.2`, `2.1`, `2.2` and `2.3`. + /// Version of the file format; legal values are `1.0`, `1.1`, `1.2`, `2.1`, `2.2`, `2.3` and + /// `2.4`. pub metadata_version: String, /// The name of the distribution. pub name: String, - /// A string containing the distribution’s version number. + /// A string containing the distribution's version number. pub version: String, /// A Platform specification describing an operating system supported by the distribution /// which is not listed in the “Operating System” Trove classifiers. @@ -25,55 +27,77 @@ pub struct Metadata23 { pub summary: Option, /// A longer description of the distribution that can run to several paragraphs. pub description: Option, + /// A string stating the markup syntax (if any) used in the distribution's description, + /// so that tools can intelligently render the description. + /// + /// Known values: `text/plain`, `text/markdown` and `text/x-rst`. + pub description_content_type: Option, /// A list of additional keywords, separated by commas, to be used to /// assist searching for the distribution in a larger catalog. pub keywords: Option, - /// A string containing the URL for the distribution’s home page. + /// A string containing the URL for the distribution's home page. + /// + /// Deprecated by PEP 753. pub home_page: Option, /// A string containing the URL from which this version of the distribution can be downloaded. + /// + /// Deprecated by PEP 753. pub download_url: Option, - /// A string containing the author’s name at a minimum; additional contact information may be provided. + /// A string containing the author's name at a minimum; additional contact information may be + /// provided. pub author: Option, - /// A string containing the author’s e-mail address. It can contain a name and e-mail address in the legal forms for a RFC-822 `From:` header. + /// A string containing the author's e-mail address. It can contain a name and e-mail address in + /// the legal forms for an RFC-822 `From:` header. pub author_email: Option, - /// Text indicating the license covering the distribution where the license is not a selection from the `License` Trove classifiers or an SPDX license expression. + /// A string containing the maintainer's name at a minimum; additional contact information may + /// be provided. + /// + /// Note that this field is intended for use when a project is being maintained by someone other + /// than the original author: + /// it should be omitted if it is identical to `author`. + pub maintainer: Option, + /// A string containing the maintainer's e-mail address. + /// It can contain a name and e-mail address in the legal forms for a RFC-822 `From:` header. + /// + /// Note that this field is intended for use when a project is being maintained by someone other + /// than the original author: it should be omitted if it is identical to `author_email`. + pub maintainer_email: Option, + /// Text indicating the license covering the distribution where the license is not a selection + /// from the `License` Trove classifiers or an SPDX license expression. pub license: Option, /// An SPDX expression indicating the license covering the distribution. + /// + /// Introduced by PEP 639, requires metadata version 2.4. pub license_expression: Option, /// Paths to files containing the text of the licenses covering the distribution. + /// + /// Introduced by PEP 639, requires metadata version 2.4. pub license_files: Vec, /// Each entry is a string giving a single classification value for the distribution. pub classifiers: Vec, - /// Each entry contains a string naming some other distutils project required by this distribution. + /// Each entry contains a string naming some other distutils project required by this + /// distribution. pub requires_dist: Vec, - /// Each entry contains a string naming a Distutils project which is contained within this distribution. + /// Each entry contains a string naming a Distutils project which is contained within this + /// distribution. pub provides_dist: Vec, - /// Each entry contains a string describing a distutils project’s distribution which this distribution renders obsolete, + /// Each entry contains a string describing a distutils project's distribution which this + /// distribution renders obsolete, /// meaning that the two projects should not be installed at the same time. pub obsoletes_dist: Vec, - /// A string containing the maintainer’s name at a minimum; additional contact information may be provided. - /// - /// Note that this field is intended for use when a project is being maintained by someone other than the original author: - /// it should be omitted if it is identical to `author`. - pub maintainer: Option, - /// A string containing the maintainer’s e-mail address. - /// It can contain a name and e-mail address in the legal forms for a RFC-822 `From:` header. - /// - /// Note that this field is intended for use when a project is being maintained by someone other than the original author: - /// it should be omitted if it is identical to `author_email`. - pub maintainer_email: Option, - /// This field specifies the Python version(s) that the distribution is guaranteed to be compatible with. + /// This field specifies the Python version(s) that the distribution is guaranteed to be + /// compatible with. pub requires_python: Option, - /// Each entry contains a string describing some dependency in the system that the distribution is to be used. + /// Each entry contains a string describing some dependency in the system that the distribution + /// is to be used. pub requires_external: Vec, - /// A string containing a browsable URL for the project and a label for it, separated by a comma. + /// A string containing a browsable URL for the project and a label for it, separated by a + /// comma. pub project_urls: Vec, /// A string containing the name of an optional feature. Must be a valid Python identifier. - /// May be used to make a dependency conditional on whether the optional feature has been requested. + /// May be used to make a dependency conditional on whether the optional feature has been + /// requested. pub provides_extras: Vec, - /// A string stating the markup syntax (if any) used in the distribution’s description, - /// so that tools can intelligently render the description. - pub description_content_type: Option, /// A string containing the name of another core metadata field. pub dynamic: Vec, } @@ -130,11 +154,14 @@ impl Metadata23 { supported_platforms, summary, description, + description_content_type, keywords, home_page, download_url, author, author_email, + maintainer, + maintainer_email, license, license_expression, license_files, @@ -142,16 +169,101 @@ impl Metadata23 { requires_dist, provides_dist, obsoletes_dist, - maintainer, - maintainer_email, requires_python, requires_external, project_urls, provides_extras, - description_content_type, dynamic, }) } + + /// Convert to the pseudo-email format used by Python's METADATA. + /// + /// > The standard file format for metadata (including in wheels and installed projects) is + /// > based on the format of email headers. However, email formats have been revised several + /// > times, and exactly which email RFC applies to packaging metadata is not specified. In the + /// > absence of a precise definition, the practical standard is set by what the standard + /// > library `email.parser` module can parse using the `compat32` policy. + /// - + /// + /// # Example + /// + /// ```text + /// Metadata-Version: 2.3 + /// Name: hello-world + /// Version: 0.1.0 + /// License: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + /// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A [...] + /// ``` + pub fn core_metadata_format(&self) -> String { + fn write_str(writer: &mut String, key: &str, value: impl Display) { + let value = value.to_string(); + let mut lines = value.lines(); + if let Some(line) = lines.next() { + writer.push_str(&format!("{key}: {line}\n")); + } else { + // The value is an empty string + writer.push_str(&format!("{key}: \n")); + } + for line in lines { + // Python implementations vary + // https://github.com/pypa/pyproject-metadata/pull/150/files#diff-7d938dbc255a08c2cfab1b4f1f8d1f6519c9312dd0a39d7793fa778474f1fbd1L135-R141 + writer.push_str(&format!("{}{}\n", " ".repeat(key.len() + 2), line)); + } + } + fn write_opt_str(writer: &mut String, key: &str, value: &Option) { + if let Some(value) = value { + write_str(writer, key, value); + } + } + fn write_all( + writer: &mut String, + key: &str, + values: impl IntoIterator, + ) { + for value in values { + write_str(writer, key, value); + } + } + + let mut writer = String::new(); + write_str(&mut writer, "Metadata-Version", &self.metadata_version); + write_str(&mut writer, "Name", &self.name); + write_str(&mut writer, "Version", &self.version); + write_all(&mut writer, "Platform", &self.platforms); + write_all(&mut writer, "Supported-Platform", &self.supported_platforms); + write_all(&mut writer, "Summary", &self.summary); + write_opt_str(&mut writer, "Keywords", &self.keywords); + write_opt_str(&mut writer, "Home-Page", &self.home_page); + write_opt_str(&mut writer, "Download-URL", &self.download_url); + write_opt_str(&mut writer, "Author", &self.author); + write_opt_str(&mut writer, "Author-email", &self.author_email); + write_opt_str(&mut writer, "License", &self.license); + write_opt_str(&mut writer, "License-Expression", &self.license_expression); + write_all(&mut writer, "License-File", &self.license_files); + write_all(&mut writer, "Classifier", &self.classifiers); + write_all(&mut writer, "Requires-Dist", &self.requires_dist); + write_all(&mut writer, "Provides-Dist", &self.provides_dist); + write_all(&mut writer, "Obsoletes-Dist", &self.obsoletes_dist); + write_opt_str(&mut writer, "Maintainer", &self.maintainer); + write_opt_str(&mut writer, "Maintainer-email", &self.maintainer_email); + write_opt_str(&mut writer, "Requires-Python", &self.requires_python); + write_all(&mut writer, "Requires-External", &self.requires_external); + write_all(&mut writer, "Project-URL", &self.project_urls); + write_all(&mut writer, "Provides-Extra", &self.provides_extras); + write_opt_str( + &mut writer, + "Description-Content-Type", + &self.description_content_type, + ); + write_all(&mut writer, "Dynamic", &self.dynamic); + + if let Some(description) = &self.description { + writer.push('\n'); + writer.push_str(description); + } + writer + } } impl FromStr for Metadata23 { diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index eaf90c58f..efc9ac770 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -15,6 +15,7 @@ workspace = true [dependencies] uv-auth = { workspace = true } +uv-build-backend = { workspace = true } uv-cache = { workspace = true } uv-cache-info = { workspace = true } uv-cache-key = { workspace = true } diff --git a/crates/uv/src/commands/build_backend.rs b/crates/uv/src/commands/build_backend.rs index 57ecd0c25..f8b159e2a 100644 --- a/crates/uv/src/commands/build_backend.rs +++ b/crates/uv/src/commands/build_backend.rs @@ -1,41 +1,58 @@ +#![allow(clippy::print_stdout)] + use crate::commands::ExitStatus; use anyhow::Result; +use std::env; use std::path::Path; -pub(crate) fn build_sdist(_sdist_directory: &Path) -> Result { +#[expect(clippy::unused_async)] +pub(crate) async fn build_sdist(_sdist_directory: &Path) -> Result { todo!() } -pub(crate) fn build_wheel( +pub(crate) async fn build_wheel( + wheel_directory: &Path, + _metadata_directory: Option<&Path>, +) -> Result { + let filename = uv_build_backend::build(&env::current_dir()?, wheel_directory).await?; + println!("{filename}"); + Ok(ExitStatus::Success) +} + +#[expect(clippy::unused_async)] +pub(crate) async fn build_editable( _wheel_directory: &Path, _metadata_directory: Option<&Path>, ) -> Result { todo!() } -pub(crate) fn build_editable( +#[expect(clippy::unused_async)] +pub(crate) async fn get_requires_for_build_sdist() -> Result { + todo!() +} + +#[expect(clippy::unused_async)] +pub(crate) async fn get_requires_for_build_wheel() -> Result { + todo!() +} + +pub(crate) async fn prepare_metadata_for_build_wheel( + metadata_directory: &Path, +) -> Result { + let filename = uv_build_backend::metadata(&env::current_dir()?, metadata_directory).await?; + println!("{filename}"); + Ok(ExitStatus::Success) +} + +#[expect(clippy::unused_async)] +pub(crate) async fn get_requires_for_build_editable() -> Result { + todo!() +} + +#[expect(clippy::unused_async)] +pub(crate) async fn prepare_metadata_for_build_editable( _wheel_directory: &Path, - _metadata_directory: Option<&Path>, ) -> Result { todo!() } - -pub(crate) fn get_requires_for_build_sdist() -> Result { - todo!() -} - -pub(crate) fn get_requires_for_build_wheel() -> Result { - todo!() -} - -pub(crate) fn prepare_metadata_for_build_wheel(_wheel_directory: &Path) -> Result { - todo!() -} - -pub(crate) fn get_requires_for_build_editable() -> Result { - todo!() -} - -pub(crate) fn prepare_metadata_for_build_editable(_wheel_directory: &Path) -> Result { - todo!() -} diff --git a/crates/uv/src/commands/build.rs b/crates/uv/src/commands/build_frontend.rs similarity index 99% rename from crates/uv/src/commands/build.rs rename to crates/uv/src/commands/build_frontend.rs index 0ecabe614..7b388674e 100644 --- a/crates/uv/src/commands/build.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -38,7 +38,7 @@ use crate::settings::{ResolverSettings, ResolverSettingsRef}; /// Build source distributions and wheels. #[allow(clippy::fn_params_excessive_bools)] -pub(crate) async fn build( +pub(crate) async fn build_frontend( project_dir: &Path, src: Option, package: Option, diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 572cda1b3..b660d6dc4 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -7,7 +7,7 @@ use std::path::Path; use std::time::Duration; use std::{fmt::Display, fmt::Write, process::ExitCode}; -pub(crate) use build::build; +pub(crate) use build_frontend::build_frontend; pub(crate) use cache_clean::cache_clean; pub(crate) use cache_dir::cache_dir; pub(crate) use cache_prune::cache_prune; @@ -60,8 +60,8 @@ pub(crate) use version::version; use crate::printer::Printer; -mod build; pub(crate) mod build_backend; +mod build_frontend; mod cache_clean; mod cache_dir; mod cache_prune; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 4e236d470..4b36cc4e6 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -685,7 +685,7 @@ async fn run(cli: Cli) -> Result { .map(RequirementsSource::from_constraints_txt) .collect::>(); - commands::build( + commands::build_frontend( &project_dir, args.src, args.package, @@ -1115,36 +1115,42 @@ async fn run(cli: Cli) -> Result { } Commands::BuildBackend { command } => match command { BuildBackendCommand::BuildSdist { sdist_directory } => { - commands::build_backend::build_sdist(&sdist_directory) + commands::build_backend::build_sdist(&sdist_directory).await } BuildBackendCommand::BuildWheel { wheel_directory, metadata_directory, - } => commands::build_backend::build_wheel( - &wheel_directory, - metadata_directory.as_deref(), - ), + } => { + commands::build_backend::build_wheel( + &wheel_directory, + metadata_directory.as_deref(), + ) + .await + } BuildBackendCommand::BuildEditable { wheel_directory, metadata_directory, - } => commands::build_backend::build_editable( - &wheel_directory, - metadata_directory.as_deref(), - ), + } => { + commands::build_backend::build_editable( + &wheel_directory, + metadata_directory.as_deref(), + ) + .await + } BuildBackendCommand::GetRequiresForBuildSdist => { - commands::build_backend::get_requires_for_build_sdist() + commands::build_backend::get_requires_for_build_sdist().await } BuildBackendCommand::GetRequiresForBuildWheel => { - commands::build_backend::get_requires_for_build_wheel() + commands::build_backend::get_requires_for_build_wheel().await } BuildBackendCommand::PrepareMetadataForBuildWheel { wheel_directory } => { - commands::build_backend::prepare_metadata_for_build_wheel(&wheel_directory) + commands::build_backend::prepare_metadata_for_build_wheel(&wheel_directory).await } BuildBackendCommand::GetRequiresForBuildEditable => { - commands::build_backend::get_requires_for_build_editable() + commands::build_backend::get_requires_for_build_editable().await } BuildBackendCommand::PrepareMetadataForBuildEditable { wheel_directory } => { - commands::build_backend::prepare_metadata_for_build_editable(&wheel_directory) + commands::build_backend::prepare_metadata_for_build_editable(&wheel_directory).await } }, }