use indexmap::IndexMap;
use itertools::Itertools;
use serde::{Deserialize, Deserializer};
use std::borrow::Cow;
use std::collections::{BTreeMap, Bound};
use std::ffi::OsStr;
use std::fmt::Display;
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::str::{self, FromStr};
use tracing::{debug, trace, warn};
use version_ranges::Ranges;
use walkdir::WalkDir;
use uv_fs::Simplified;
use uv_globfilter::{GlobDirFilter, PortableGlobParser};
use uv_normalize::{ExtraName, PackageName};
use uv_pep440::{Version, VersionSpecifiers};
use uv_pep508::{
ExtraOperator, MarkerExpression, MarkerTree, MarkerValueExtra, Requirement, VersionOrUrl,
};
use uv_pypi_types::{Keywords, Metadata23, ProjectUrls, VerbatimParsedUrl};
use crate::serde_verbatim::SerdeVerbatim;
use crate::{BuildBackendSettings, Error, error_on_venv};
pub(crate) const DEFAULT_EXCLUDES: &[&str] = &["__pycache__", "*.pyc", "*.pyo"];
#[derive(Debug, Error)]
pub enum ValidationError {
#[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("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),
#[error("`{field}` glob `{glob}` did not match any files")]
LicenseGlobNoMatches { field: String, glob: String },
#[error("License file `{}` must be UTF-8 encoded", _0)]
LicenseFileNotUtf8(String),
}
pub fn check_direct_build(source_tree: &Path, name: impl Display) -> bool {
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct PyProjectToml {
build_system: BuildSystem,
}
let pyproject_toml: PyProjectToml =
match fs_err::read_to_string(source_tree.join("pyproject.toml"))
.map_err(|err| err.to_string())
.and_then(|pyproject_toml| {
toml::from_str(&pyproject_toml).map_err(|err| err.to_string())
}) {
Ok(pyproject_toml) => pyproject_toml,
Err(err) => {
debug!(
"Not using uv build backend direct build for source tree `{name}`, \
failed to parse pyproject.toml: {err}"
);
return false;
}
};
match pyproject_toml
.build_system
.check_build_system(uv_version::version())
.as_slice()
{
[] => true,
[first, others @ ..] => {
debug!(
"Not using uv build backend direct build of `{name}`, pyproject.toml does not match: {first}"
);
for other in others {
trace!("Further uv build backend direct build of `{name}` mismatch: {other}");
}
false
}
}
}
#[derive(Debug, Clone)]
struct VerbatimPackageName {
given: String,
normalized: PackageName,
}
impl<'de> Deserialize<'de> for VerbatimPackageName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let given = <Cow<'_, str>>::deserialize(deserializer)?;
let normalized = PackageName::from_str(&given).map_err(serde::de::Error::custom)?;
Ok(Self {
given: given.to_string(),
normalized,
})
}
}
#[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 struct PyProjectToml {
project: Project,
tool: Option<Tool>,
build_system: BuildSystem,
}
impl PyProjectToml {
pub(crate) fn name(&self) -> &PackageName {
&self.project.name.normalized
}
pub(crate) fn version(&self) -> &Version {
&self.project.version
}
pub(crate) fn parse(path: &Path) -> Result<Self, Error> {
let contents = fs_err::read_to_string(path)?;
let pyproject_toml =
toml::from_str(&contents).map_err(|err| Error::Toml(path.to_path_buf(), err))?;
Ok(pyproject_toml)
}
pub(crate) fn readme(&self) -> Option<&Readme> {
self.project.readme.as_ref()
}
pub(crate) fn license_files_source_dist(&self) -> impl Iterator<Item = &str> {
let license_file = self
.project
.license
.as_ref()
.and_then(|license| license.file())
.into_iter();
let license_files = self
.project
.license_files
.iter()
.flatten()
.map(String::as_str);
license_files.chain(license_file)
}
pub(crate) fn license_files_wheel(&self) -> impl Iterator<Item = &str> {
self.project
.license_files
.iter()
.flatten()
.map(String::as_str)
}
pub(crate) fn settings(&self) -> Option<&BuildBackendSettings> {
self.tool.as_ref()?.uv.as_ref()?.build_backend.as_ref()
}
pub fn check_build_system(&self, uv_version: &str) -> Vec<String> {
self.build_system.check_build_system(uv_version)
}
pub(crate) fn to_metadata(&self, root: &Path) -> Result<Metadata23, Error> {
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::read_to_string(root.join(path))?;
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::read_to_string(root.join(file))?;
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());
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"
};
let (license, license_expression, license_files) = self.license_metadata(root)?;
let project_urls = ProjectUrls::new(self.project.urls.clone().unwrap_or_default());
let extras = self
.project
.optional_dependencies
.iter()
.flat_map(|optional_dependencies| optional_dependencies.keys())
.collect::<Vec<_>>();
let requires_dist =
self.project
.dependencies
.iter()
.flatten()
.cloned()
.chain(self.project.optional_dependencies.iter().flat_map(
|optional_dependencies| {
optional_dependencies
.iter()
.flat_map(|(extra, requirements)| {
requirements.iter().cloned().map(|mut requirement| {
requirement.marker.and(MarkerTree::expression(
MarkerExpression::Extra {
operator: ExtraOperator::Equal,
name: MarkerValueExtra::Extra(extra.clone()),
},
));
requirement
})
})
},
))
.collect::<Vec<_>>();
Ok(Metadata23 {
metadata_version: metadata_version.to_string(),
name: self.project.name.given.clone(),
version: self.project.version.to_string(),
platforms: vec![],
supported_platforms: vec![],
summary,
description,
description_content_type,
keywords: self.project.keywords.clone().map(Keywords::new),
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: requires_dist.iter().map(ToString::to_string).collect(),
provides_extra: extras.iter().map(ToString::to_string).collect(),
provides_dist: vec![],
obsoletes_dist: vec![],
requires_python: self
.project
.requires_python
.as_ref()
.map(ToString::to_string),
requires_external: vec![],
project_urls,
dynamic: vec![],
})
}
#[expect(clippy::type_complexity)]
fn license_metadata(
&self,
root: &Path,
) -> Result<(Option<String>, Option<String>, Vec<String>), Error> {
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();
let mut license_globs_parsed = Vec::with_capacity(license_globs.len());
let mut license_glob_matchers = Vec::with_capacity(license_globs.len());
for license_glob in license_globs {
let pep639_glob =
PortableGlobParser::Pep639
.parse(license_glob)
.map_err(|err| Error::PortableGlob {
field: license_glob.to_owned(),
source: err,
})?;
license_glob_matchers.push(pep639_glob.compile_matcher());
license_globs_parsed.push(pep639_glob);
}
let mut license_globs_matched = vec![false; license_globs_parsed.len()];
let license_globs =
GlobDirFilter::from_globs(&license_globs_parsed).map_err(|err| {
Error::GlobSetTooLarge {
field: "project.license-files".to_string(),
source: err,
}
})?;
for entry in WalkDir::new(root)
.sort_by_file_name()
.into_iter()
.filter_entry(|entry| {
license_globs.match_directory(
entry
.path()
.strip_prefix(root)
.expect("walkdir starts with root"),
)
})
{
let entry = entry.map_err(|err| Error::WalkDir {
root: root.to_path_buf(),
err,
})?;
let relative = entry
.path()
.strip_prefix(root)
.expect("walkdir starts with root");
if !license_globs.match_path(relative) {
trace!("Not a license files match: {}", relative.user_display());
continue;
}
let file_type = entry.file_type();
if !(file_type.is_file() || file_type.is_symlink()) {
trace!(
"Not a file or symlink in license files match: {}",
relative.user_display()
);
continue;
}
error_on_venv(entry.file_name(), entry.path())?;
debug!("License files match: {}", relative.user_display());
for (matched, matcher) in license_globs_matched
.iter_mut()
.zip(license_glob_matchers.iter())
{
if *matched {
continue;
}
if matcher.is_match(relative) {
*matched = true;
}
}
license_files.push(relative.portable_display().to_string());
}
if let Some((pattern, _)) = license_globs_parsed
.into_iter()
.zip(license_globs_matched)
.find(|(_, matched)| !matched)
{
return Err(ValidationError::LicenseGlobNoMatches {
field: "project.license-files".to_string(),
glob: pattern.to_string(),
}
.into());
}
for license_file in &license_files {
let file_path = root.join(license_file);
let bytes = fs_err::read(&file_path)?;
if str::from_utf8(&bytes).is_err() {
return Err(ValidationError::LicenseFileNotUtf8(license_file.clone()).into());
}
}
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::read_to_string(root.join(file))?;
(Some(text), None, Vec::new())
}
}
};
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());
}
}
Ok((license, license_expression, license_files))
}
pub(crate) fn to_entry_points(&self) -> Result<Option<String>, ValidationError> {
let mut writer = String::new();
if self.project.scripts.is_none()
&& self.project.gui_scripts.is_none()
&& self.project.entry_points.is_none()
{
return Ok(None);
}
if let Some(scripts) = &self.project.scripts {
Self::write_group(&mut writer, "console_scripts", scripts)?;
}
if let Some(gui_scripts) = &self.project.gui_scripts {
Self::write_group(&mut writer, "gui_scripts", gui_scripts)?;
}
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)?;
}
Ok(Some(writer))
}
fn write_group<'a>(
writer: &mut String,
group: &str,
entries: impl IntoIterator<Item = (&'a String, &'a String)>,
) -> 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()));
}
let _ = writeln!(writer, "[{group}]");
for (name, object_reference) in entries {
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
{
warn!(
"Entrypoint names should consist of letters, numbers, dots, underscores and \
dashes; non-compliant name: {name}"
);
}
let _ = writeln!(writer, "{name} = {object_reference}");
}
writer.push('\n');
Ok(())
}
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
struct Project {
name: VerbatimPackageName,
version: Version,
description: Option<String>,
readme: Option<Readme>,
requires_python: Option<VersionSpecifiers>,
license: Option<License>,
license_files: Option<Vec<String>>,
authors: Option<Vec<Contact>>,
maintainers: Option<Vec<Contact>>,
keywords: Option<Vec<String>>,
classifiers: Option<Vec<String>>,
urls: Option<IndexMap<String, String>>,
scripts: Option<BTreeMap<String, String>>,
gui_scripts: Option<BTreeMap<String, String>>,
entry_points: Option<BTreeMap<String, BTreeMap<String, String>>>,
dependencies: Option<Vec<Requirement>>,
optional_dependencies: Option<BTreeMap<ExtraName, Vec<Requirement>>>,
dynamic: Option<Vec<String>>,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(untagged, rename_all_fields = "kebab-case")]
pub(crate) enum Readme {
String(PathBuf),
File {
file: PathBuf,
content_type: String,
charset: Option<String>,
},
Text {
text: String,
content_type: String,
charset: Option<String>,
},
}
impl Readme {
pub(crate) fn path(&self) -> Option<&Path> {
match self {
Self::String(path) => Some(path),
Self::File { file, .. } => Some(file),
Self::Text { .. } => None,
}
}
}
#[derive(Deserialize, Debug, Clone)]
#[serde(untagged)]
pub(crate) enum License {
Spdx(String),
Text {
text: String,
},
File {
file: String,
},
}
impl License {
fn file(&self) -> Option<&str> {
if let Self::File { file } = self {
Some(file)
} else {
None
}
}
}
#[derive(Deserialize, Debug, Clone)]
#[serde(
untagged,
deny_unknown_fields,
expecting = "a table with 'name' and/or 'email' keys"
)]
pub(crate) enum Contact {
NameEmail { name: String, email: String },
Name { name: String },
Email { email: String },
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct Tool {
uv: Option<ToolUv>,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct ToolUv {
build_backend: Option<BuildBackendSettings>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
struct BuildSystem {
requires: Vec<SerdeVerbatim<Requirement<VerbatimParsedUrl>>>,
build_backend: Option<String>,
backend_path: Option<Vec<String>>,
}
impl BuildSystem {
pub(crate) fn check_build_system(&self, uv_version: &str) -> Vec<String> {
let mut warnings = Vec::new();
if self.build_backend.as_deref() != Some("uv_build") {
warnings.push(format!(
r#"The value for `build_system.build-backend` should be `"uv_build"`, not `"{}"`"#,
self.build_backend.clone().unwrap_or_default()
));
}
let uv_version =
Version::from_str(uv_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.requires).unwrap_or_default()
)
};
let [uv_requirement] = &self.requires.as_slice() else {
warnings.push(expected());
return warnings;
};
if uv_requirement.name.as_str() != "uv-build" {
warnings.push(expected());
return warnings;
}
let bounded = match &uv_requirement.version_or_url {
None => false,
Some(VersionOrUrl::Url(_)) => {
true
}
Some(VersionOrUrl::VersionSpecifier(specifier)) => {
if !specifier.contains(&uv_version) {
warnings.push(format!(
r#"`build_system.requires = ["{uv_requirement}"]` does not contain the
current uv version {uv_version}"#,
));
}
Ranges::from(specifier.clone())
.bounding_range()
.map(|bounding_range| bounding_range.1 != Bound::Unbounded)
.unwrap_or(false)
}
};
if !bounded {
warnings.push(format!(
"`build_system.requires = [\"{}\"]` is missing an \
upper bound on the `uv_build` version such as `<{next_breaking}`. \
Without bounding the `uv_build` version, the source distribution will break \
when a future, breaking version of `uv_build` is released.",
uv_requirement.verbatim()
));
}
warnings
}
}
#[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_build>=0.4.15,<0.5.0"]
build-backend = "uv_build"
"#
}
}
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()) {
let _ = write!(formatted, "\n Caused by: {source}");
}
formatted
}
#[test]
fn uppercase_package_name() {
let contents = r#"
[project]
name = "Hello-World"
version = "0.1.0"
[build-system]
requires = ["uv_build>=0.4.15,<0.5.0"]
build-backend = "uv_build"
"#;
let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
let temp_dir = TempDir::new().unwrap();
let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap();
assert_snapshot!(metadata.core_metadata_format(), @"
Metadata-Version: 2.3
Name: Hello-World
Version: 0.1.0
");
}
#[test]
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_build>=0.4.15,<0.5.0"]
build-backend = "uv_build"
"#
};
let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
let metadata = pyproject_toml.to_metadata(temp_dir.path()).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
Author-email: Ferris the crab <ferris@rustacean.net>
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
Requires-Dist: pymysql>=1.1.1,<2 ; extra == 'mysql'
Requires-Dist: psycopg>=3.2.2,<4 ; extra == 'postgres'
Maintainer: Konsti
Maintainer-email: Konsti <konstin@mailbox.org>
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().unwrap(), @"
[console_scripts]
foo = foo.cli:__main__
[gui_scripts]
foo-gui = foo.gui
[bar_group]
foo-bar = foo:bar
");
}
#[test]
fn readme() {
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 = { file = "Readme.md", content-type = "text/markdown" }
requires_python = ">=3.12"
[build-system]
requires = ["uv_build>=0.4.15,<0.5"]
build-backend = "uv_build"
"#
};
let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap();
assert_snapshot!(metadata.core_metadata_format(), @"
Metadata-Version: 2.3
Name: hello-world
Version: 0.1.0
Summary: A Python package
Description-Content-Type: text/markdown
# Foo
This is the foo library.
");
}
#[test]
fn self_extras() {
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 ; sys_platform == 'linux'"]
mysql = ["pymysql>=1.1.1,<2"]
databases = ["hello-world[mysql]", "hello-world[postgres]"]
all = ["hello-world[databases]", "hello-world[postgres]", "hello-world[mysql]"]
[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_build>=0.4.15,<0.5.0"]
build-backend = "uv_build"
"#
};
let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
let metadata = pyproject_toml.to_metadata(temp_dir.path()).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
Author-email: Ferris the crab <ferris@rustacean.net>
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
Requires-Dist: hello-world[databases] ; extra == 'all'
Requires-Dist: hello-world[postgres] ; extra == 'all'
Requires-Dist: hello-world[mysql] ; extra == 'all'
Requires-Dist: hello-world[mysql] ; extra == 'databases'
Requires-Dist: hello-world[postgres] ; extra == 'databases'
Requires-Dist: pymysql>=1.1.1,<2 ; extra == 'mysql'
Requires-Dist: psycopg>=3.2.2,<4 ; sys_platform == 'linux' and extra == 'postgres'
Maintainer: Konsti
Maintainer-email: Konsti <konstin@mailbox.org>
Project-URL: Homepage, https://github.com/astral-sh/uv
Project-URL: Repository, https://astral.sh
Provides-Extra: all
Provides-Extra: databases
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().unwrap(), @"
[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 = toml::from_str(&contents).unwrap();
assert_snapshot!(
pyproject_toml.check_build_system("0.4.15+test").join("\n"),
@""
);
}
#[test]
fn build_system_no_bound() {
let contents = indoc! {r#"
[project]
name = "hello-world"
version = "0.1.0"
[build-system]
requires = ["uv_build"]
build-backend = "uv_build"
"#};
let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
assert_snapshot!(
pyproject_toml.check_build_system("0.4.15+test").join("\n"),
@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."#
);
}
#[test]
fn build_system_multiple_packages() {
let contents = indoc! {r#"
[project]
name = "hello-world"
version = "0.1.0"
[build-system]
requires = ["uv_build>=0.4.15,<0.5.0", "wheel"]
build-backend = "uv_build"
"#};
let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
assert_snapshot!(
pyproject_toml.check_build_system("0.4.15+test").join("\n"),
@"Expected a single uv requirement in `build-system.requires`, found ``"
);
}
#[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_build"
"#};
let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
assert_snapshot!(
pyproject_toml.check_build_system("0.4.15+test").join("\n"),
@"Expected a single uv requirement in `build-system.requires`, found ``"
);
}
#[test]
fn build_system_not_uv() {
let contents = indoc! {r#"
[project]
name = "hello-world"
version = "0.1.0"
[build-system]
requires = ["uv_build>=0.4.15,<0.5.0"]
build-backend = "setuptools"
"#};
let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
assert_snapshot!(
pyproject_toml.check_build_system("0.4.15+test").join("\n"),
@r#"The value for `build_system.build-backend` should be `"uv_build"`, not `"setuptools"`"#
);
}
#[test]
fn minimal() {
let contents = extend_project("");
let metadata = toml::from_str::<PyProjectToml>(&contents)
.unwrap()
.to_metadata(Path::new("/do/not/read"))
.unwrap();
assert_snapshot!(metadata.core_metadata_format(), @"
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 = toml::from_str::<PyProjectToml>(&contents).unwrap_err();
assert_snapshot!(format_err(err), @r#"
TOML parse error at line 4, column 10
|
4 | readme = { path = "Readme.md" }
| ^^^^^^^^^^^^^^^^^^^^^^
data did not match any variant of untagged enum Readme
"#);
}
#[test]
fn missing_readme() {
let contents = extend_project(indoc! {r#"
readme = "Readme.md"
"#
});
let err = toml::from_str::<PyProjectToml>(&contents)
.unwrap()
.to_metadata(Path::new("/do/not/read"))
.unwrap_err();
let err = err
.to_string()
.replace('\\', "/")
.split_once(':')
.unwrap()
.0
.to_string();
assert_snapshot!(err, @"failed to open file `/do/not/read/Readme.md`");
}
#[test]
fn multiline_description() {
let contents = extend_project(indoc! {r#"
description = "Hi :)\nThis is my project"
"#
});
let err = toml::from_str::<PyProjectToml>(&contents)
.unwrap()
.to_metadata(Path::new("/do/not/read"))
.unwrap_err();
assert_snapshot!(format_err(err), @"
Invalid project metadata
Caused by: `project.description` must be a single line
");
}
#[test]
fn mixed_licenses() {
let contents = extend_project(indoc! {r#"
license-files = ["licenses/*"]
license = { text = "MIT" }
"#
});
let err = toml::from_str::<PyProjectToml>(&contents)
.unwrap()
.to_metadata(Path::new("/do/not/read"))
.unwrap_err();
assert_snapshot!(format_err(err), @"
Invalid project metadata
Caused by: When `project.license-files` is defined, `project.license` must be an SPDX expression string
");
}
#[test]
fn valid_license() {
let contents = extend_project(indoc! {r#"
license = "MIT OR Apache-2.0"
"#
});
let metadata = toml::from_str::<PyProjectToml>(&contents)
.unwrap()
.to_metadata(Path::new("/do/not/read"))
.unwrap();
assert_snapshot!(metadata.core_metadata_format(), @"
Metadata-Version: 2.4
Name: hello-world
Version: 0.1.0
License-Expression: MIT OR Apache-2.0
");
}
#[test]
fn invalid_license() {
let contents = extend_project(indoc! {r#"
license = "MIT XOR Apache-2"
"#
});
let err = toml::from_str::<PyProjectToml>(&contents)
.unwrap()
.to_metadata(Path::new("/do/not/read"))
.unwrap_err();
assert_snapshot!(format_err(err), @"
Invalid project metadata
Caused by: `project.license` is not a valid SPDX expression: MIT XOR Apache-2
Caused by: MIT XOR Apache-2
^^^ unknown term
");
}
#[test]
fn dynamic() {
let contents = extend_project(indoc! {r#"
dynamic = ["dependencies"]
"#
});
let err = toml::from_str::<PyProjectToml>(&contents)
.unwrap()
.to_metadata(Path::new("/do/not/read"))
.unwrap_err();
assert_snapshot!(format_err(err), @"
Invalid project metadata
Caused by: Dynamic metadata is not supported
");
}
fn script_error(contents: &str) -> String {
let err = toml::from_str::<PyProjectToml>(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_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`");
}
}