Skip to content

Commit

Permalink
Omit lockfile version when additional fields are dynamic (#11468)
Browse files Browse the repository at this point in the history
## Summary

Just a logic issue... If we see a dynamic field that isn't `"version"`,
we end up _not_ propagating the fact that `"version"` is also dynamic.

Closes #11460.
  • Loading branch information
charliermarsh authored Feb 13, 2025
1 parent 967d2f9 commit a4bd73f
Show file tree
Hide file tree
Showing 6 changed files with 221 additions and 67 deletions.
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

0 comments on commit a4bd73f

Please sign in to comment.