Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Omit lockfile version when additional fields are dynamic #11468

Merged
merged 1 commit into from
Feb 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 87 additions & 47 deletions crates/uv-distribution/src/source/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ use uv_metadata::read_archive_metadata;
use uv_normalize::PackageName;
use uv_pep440::{release_specifiers_to_ranges, Version};
use uv_platform_tags::Tags;
use uv_pypi_types::{HashAlgorithm, HashDigest, Metadata12, RequiresTxt, ResolutionMetadata};
use uv_pypi_types::{
HashAlgorithm, HashDigest, Metadata12, PyProjectToml, RequiresTxt, ResolutionMetadata,
};
use uv_types::{BuildContext, BuildStack, SourceBuildTrait};
use uv_workspace::pyproject::ToolUvSources;
use zip::ZipArchive;
Expand Down Expand Up @@ -1890,21 +1892,35 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
return Ok(None);
};

// Parse the metadata.
let metadata = match ResolutionMetadata::parse_pyproject_toml(&content, source.version()) {
// Parse the `pyproject.toml`.
let pyproject_toml = match PyProjectToml::from_toml(&content) {
Ok(metadata) => metadata,
Err(
uv_pypi_types::MetadataError::Pep508Error(_)
| uv_pypi_types::MetadataError::DynamicField(_)
| uv_pypi_types::MetadataError::FieldNotFound(_)
| uv_pypi_types::MetadataError::PoetrySyntax,
uv_pypi_types::MetadataError::InvalidPyprojectTomlSyntax(..)
| uv_pypi_types::MetadataError::InvalidPyprojectTomlSchema(..),
) => {
debug!("Failed to extract static metadata from GitHub API for: {url}");
debug!("Failed to read `pyproject.toml` from GitHub API for: {url}");
return Ok(None);
}
Err(err) => return Err(err.into()),
};

// Parse the metadata.
let metadata =
match ResolutionMetadata::parse_pyproject_toml(pyproject_toml, source.version()) {
Ok(metadata) => metadata,
Err(
uv_pypi_types::MetadataError::Pep508Error(..)
| uv_pypi_types::MetadataError::DynamicField(..)
| uv_pypi_types::MetadataError::FieldNotFound(..)
| uv_pypi_types::MetadataError::PoetrySyntax,
) => {
debug!("Failed to extract static metadata from GitHub API for: {url}");
return Ok(None);
}
Err(err) => return Err(err.into()),
};

// Determine whether the project has `tool.uv.sources`. If the project has sources, it must
// be lowered, which requires access to the workspace. For example, it could have workspace
// members that need to be translated to concrete paths on disk.
Expand Down Expand Up @@ -2417,49 +2433,58 @@ impl StaticMetadata {
source_root: &Path,
subdirectory: Option<&Path>,
) -> Result<Self, Error> {
// Attempt to read static metadata from the `pyproject.toml`.
match read_pyproject_toml(source_root, subdirectory, source.version()).await {
Ok(metadata) => {
debug!("Found static `pyproject.toml` for: {source}");
// Attempt to read the `pyproject.toml`.
let pyproject_toml = match read_pyproject_toml(source_root, subdirectory).await {
Ok(pyproject_toml) => Some(pyproject_toml),
Err(Error::MissingPyprojectToml) => {
debug!("No `pyproject.toml` available for: {source}");
None
}
Err(err) => return Err(err),
};

// Validate the metadata, but ignore it if the metadata doesn't match.
match validate_metadata(source, &metadata) {
Ok(()) => {
return Ok(Self::Some(metadata));
}
Err(err) => {
debug!("Ignoring `pyproject.toml` for {source}: {err}");
// Determine whether the version is static or dynamic.
let dynamic = pyproject_toml.as_ref().is_some_and(|pyproject_toml| {
pyproject_toml.project.as_ref().is_some_and(|project| {
project
.dynamic
.as_ref()
.is_some_and(|dynamic| dynamic.iter().any(|field| field == "version"))
})
});

// Attempt to read static metadata from the `pyproject.toml`.
if let Some(pyproject_toml) = pyproject_toml {
match ResolutionMetadata::parse_pyproject_toml(pyproject_toml, source.version()) {
Ok(metadata) => {
debug!("Found static `pyproject.toml` for: {source}");

// Validate the metadata, but ignore it if the metadata doesn't match.
match validate_metadata(source, &metadata) {
Ok(()) => {
return Ok(Self::Some(metadata));
}
Err(err) => {
debug!("Ignoring `pyproject.toml` for {source}: {err}");
}
}
}
}
Err(
err @ Error::PyprojectToml(uv_pypi_types::MetadataError::DynamicField("version")),
) if source.is_source_tree() => {
// In Metadata 2.2, `Dynamic` was introduced to Core Metadata to indicate that a
// given field was marked as dynamic in the originating source tree. However, we may
// be looking at a distribution with a build backend that doesn't support Metadata 2.2. In that case,
// we want to infer the `Dynamic` status from the `pyproject.toml` file, if available.
debug!("No static `pyproject.toml` available for: {source} ({err:?})");
return Ok(Self::Dynamic);
}
Err(
err @ (Error::MissingPyprojectToml
| Error::PyprojectToml(
uv_pypi_types::MetadataError::Pep508Error(_)
Err(
err @ (uv_pypi_types::MetadataError::Pep508Error(_)
| uv_pypi_types::MetadataError::DynamicField(_)
| uv_pypi_types::MetadataError::FieldNotFound(_)
| uv_pypi_types::MetadataError::PoetrySyntax,
)),
) => {
debug!("No static `pyproject.toml` available for: {source} ({err:?})");
| uv_pypi_types::MetadataError::PoetrySyntax),
) => {
debug!("No static `pyproject.toml` available for: {source} ({err:?})");
}
Err(err) => return Err(Error::PyprojectToml(err)),
}
Err(err) => return Err(err),
}

// If the source distribution is a source tree, avoid reading `PKG-INFO` or `egg-info`,
// since they could be out-of-date.
if source.is_source_tree() {
return Ok(Self::None);
return Ok(if dynamic { Self::Dynamic } else { Self::None });
}

// Attempt to read static metadata from the `PKG-INFO` file.
Expand All @@ -2470,6 +2495,15 @@ impl StaticMetadata {
// Validate the metadata, but ignore it if the metadata doesn't match.
match validate_metadata(source, &metadata) {
Ok(()) => {
// If necessary, mark the metadata as dynamic.
let metadata = if dynamic {
ResolutionMetadata {
dynamic: true,
..metadata
}
} else {
metadata
};
return Ok(Self::Some(metadata));
}
Err(err) => {
Expand Down Expand Up @@ -2499,6 +2533,15 @@ impl StaticMetadata {
// Validate the metadata, but ignore it if the metadata doesn't match.
match validate_metadata(source, &metadata) {
Ok(()) => {
// If necessary, mark the metadata as dynamic.
let metadata = if dynamic {
ResolutionMetadata {
dynamic: true,
..metadata
}
} else {
metadata
};
return Ok(Self::Some(metadata));
}
Err(err) => {
Expand Down Expand Up @@ -2526,7 +2569,7 @@ impl StaticMetadata {
Err(err) => return Err(err),
}

Ok(Self::None)
Ok(if dynamic { Self::Dynamic } else { Self::None })
}
}

Expand Down Expand Up @@ -2845,8 +2888,7 @@ async fn read_pkg_info(
async fn read_pyproject_toml(
source_tree: &Path,
subdirectory: Option<&Path>,
sdist_version: Option<&Version>,
) -> Result<ResolutionMetadata, Error> {
) -> Result<PyProjectToml, Error> {
// Read the `pyproject.toml` file.
let pyproject_toml = match subdirectory {
Some(subdirectory) => source_tree.join(subdirectory).join("pyproject.toml"),
Expand All @@ -2860,11 +2902,9 @@ async fn read_pyproject_toml(
Err(err) => return Err(Error::CacheRead(err)),
};

// Parse the metadata.
let metadata = ResolutionMetadata::parse_pyproject_toml(&content, sdist_version)
.map_err(Error::PyprojectToml)?;
let pyproject_toml = PyProjectToml::from_toml(&content)?;

Ok(metadata)
Ok(pyproject_toml)
}

/// Return the [`pypi_types::RequiresDist`] from a `pyproject.toml`, if it can be statically extracted.
Expand Down
22 changes: 13 additions & 9 deletions crates/uv-pypi-types/src/metadata/metadata_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,9 @@ impl ResolutionMetadata {
/// If we're coming from a source distribution, we may already know the version (unlike for a
/// source tree), so we can tolerate dynamic versions.
pub fn parse_pyproject_toml(
content: &str,
pyproject_toml: PyProjectToml,
sdist_version: Option<&Version>,
) -> Result<Self, MetadataError> {
let pyproject_toml = PyProjectToml::from_toml(content)?;

let project = pyproject_toml
.project
.ok_or(MetadataError::FieldNotFound("project"))?;
Expand Down Expand Up @@ -333,23 +331,26 @@ mod tests {
[project]
name = "asdf"
"#;
let meta = ResolutionMetadata::parse_pyproject_toml(s, None);
let pyproject = PyProjectToml::from_toml(s).unwrap();
let meta = ResolutionMetadata::parse_pyproject_toml(pyproject, None);
assert!(matches!(meta, Err(MetadataError::FieldNotFound("version"))));

let s = r#"
[project]
name = "asdf"
dynamic = ["version"]
"#;
let meta = ResolutionMetadata::parse_pyproject_toml(s, None);
let pyproject = PyProjectToml::from_toml(s).unwrap();
let meta = ResolutionMetadata::parse_pyproject_toml(pyproject, None);
assert!(matches!(meta, Err(MetadataError::DynamicField("version"))));

let s = r#"
[project]
name = "asdf"
version = "1.0"
"#;
let meta = ResolutionMetadata::parse_pyproject_toml(s, None).unwrap();
let pyproject = PyProjectToml::from_toml(s).unwrap();
let meta = ResolutionMetadata::parse_pyproject_toml(pyproject, None).unwrap();
assert_eq!(meta.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0]));
assert!(meta.requires_python.is_none());
Expand All @@ -362,7 +363,8 @@ mod tests {
version = "1.0"
requires-python = ">=3.6"
"#;
let meta = ResolutionMetadata::parse_pyproject_toml(s, None).unwrap();
let pyproject = PyProjectToml::from_toml(s).unwrap();
let meta = ResolutionMetadata::parse_pyproject_toml(pyproject, None).unwrap();
assert_eq!(meta.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0]));
assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap()));
Expand All @@ -376,7 +378,8 @@ mod tests {
requires-python = ">=3.6"
dependencies = ["foo"]
"#;
let meta = ResolutionMetadata::parse_pyproject_toml(s, None).unwrap();
let pyproject = PyProjectToml::from_toml(s).unwrap();
let meta = ResolutionMetadata::parse_pyproject_toml(pyproject, None).unwrap();
assert_eq!(meta.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0]));
assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap()));
Expand All @@ -393,7 +396,8 @@ mod tests {
[project.optional-dependencies]
dotenv = ["bar"]
"#;
let meta = ResolutionMetadata::parse_pyproject_toml(s, None).unwrap();
let pyproject = PyProjectToml::from_toml(s).unwrap();
let meta = ResolutionMetadata::parse_pyproject_toml(pyproject, None).unwrap();
assert_eq!(meta.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0]));
assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap()));
Expand Down
1 change: 1 addition & 0 deletions crates/uv-pypi-types/src/metadata/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub use metadata10::Metadata10;
pub use metadata12::Metadata12;
pub use metadata23::Metadata23;
pub use metadata_resolver::ResolutionMetadata;
pub use pyproject_toml::PyProjectToml;
pub use requires_dist::RequiresDist;
pub use requires_txt::RequiresTxt;

Expand Down
20 changes: 10 additions & 10 deletions crates/uv-pypi-types/src/metadata/pyproject_toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ use crate::MetadataError;
/// A `pyproject.toml` as specified in PEP 517.
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub(super) struct PyProjectToml {
pub(super) project: Option<Project>,
pub struct PyProjectToml {
pub project: Option<Project>,
pub(super) tool: Option<Tool>,
}

impl PyProjectToml {
pub(super) fn from_toml(toml: &str) -> Result<Self, MetadataError> {
pub fn from_toml(toml: &str) -> Result<Self, MetadataError> {
let pyproject_toml: toml_edit::ImDocument<_> = toml_edit::ImDocument::from_str(toml)
.map_err(MetadataError::InvalidPyprojectTomlSyntax)?;
let pyproject_toml: Self = PyProjectToml::deserialize(pyproject_toml.into_deserializer())
Expand All @@ -35,20 +35,20 @@ impl PyProjectToml {
/// See <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
#[derive(Deserialize, Debug)]
#[serde(try_from = "PyprojectTomlWire")]
pub(super) struct Project {
pub struct Project {
/// The name of the project
pub(super) name: PackageName,
pub name: PackageName,
/// The version of the project as supported by PEP 440
pub(super) version: Option<Version>,
pub version: Option<Version>,
/// The Python version requirements of the project
pub(super) requires_python: Option<String>,
pub requires_python: Option<String>,
/// Project dependencies
pub(super) dependencies: Option<Vec<String>>,
pub dependencies: Option<Vec<String>>,
/// Optional dependencies
pub(super) optional_dependencies: Option<IndexMap<ExtraName, Vec<String>>>,
pub optional_dependencies: Option<IndexMap<ExtraName, Vec<String>>>,
/// Specifies which fields listed by PEP 621 were intentionally unspecified
/// so another tool can/will provide such metadata dynamically.
pub(super) dynamic: Option<Vec<String>>,
pub dynamic: Option<Vec<String>>,
}

#[derive(Deserialize, Debug)]
Expand Down
1 change: 1 addition & 0 deletions crates/uv-pypi-types/src/metadata/requires_dist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub struct RequiresDist {
pub name: PackageName,
pub requires_dist: Vec<Requirement<VerbatimParsedUrl>>,
pub provides_extras: Vec<ExtraName>,
#[serde(default)]
pub dynamic: bool,
}

Expand Down
Loading
Loading