Omit lockfile version when additional fields are dynamic (#11468)

## 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 https://github.com/astral-sh/uv/issues/11460.
This commit is contained in:
Charlie Marsh 2025-02-12 20:44:14 -05:00 committed by GitHub
parent 967d2f9fca
commit a4bd73f922
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 225 additions and 71 deletions

View File

@ -45,7 +45,9 @@ use uv_metadata::read_archive_metadata;
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_pep440::{release_specifiers_to_ranges, Version}; use uv_pep440::{release_specifiers_to_ranges, Version};
use uv_platform_tags::Tags; 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_types::{BuildContext, BuildStack, SourceBuildTrait};
use uv_workspace::pyproject::ToolUvSources; use uv_workspace::pyproject::ToolUvSources;
use zip::ZipArchive; use zip::ZipArchive;
@ -1890,13 +1892,27 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
return Ok(None); return Ok(None);
}; };
// Parse the metadata. // Parse the `pyproject.toml`.
let metadata = match ResolutionMetadata::parse_pyproject_toml(&content, source.version()) { let pyproject_toml = match PyProjectToml::from_toml(&content) {
Ok(metadata) => metadata, Ok(metadata) => metadata,
Err( Err(
uv_pypi_types::MetadataError::Pep508Error(_) uv_pypi_types::MetadataError::InvalidPyprojectTomlSyntax(..)
| uv_pypi_types::MetadataError::DynamicField(_) | uv_pypi_types::MetadataError::InvalidPyprojectTomlSchema(..),
| uv_pypi_types::MetadataError::FieldNotFound(_) ) => {
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, | uv_pypi_types::MetadataError::PoetrySyntax,
) => { ) => {
debug!("Failed to extract static metadata from GitHub API for: {url}"); debug!("Failed to extract static metadata from GitHub API for: {url}");
@ -2417,8 +2433,29 @@ impl StaticMetadata {
source_root: &Path, source_root: &Path,
subdirectory: Option<&Path>, subdirectory: Option<&Path>,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
// 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),
};
// 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`. // Attempt to read static metadata from the `pyproject.toml`.
match read_pyproject_toml(source_root, subdirectory, source.version()).await { if let Some(pyproject_toml) = pyproject_toml {
match ResolutionMetadata::parse_pyproject_toml(pyproject_toml, source.version()) {
Ok(metadata) => { Ok(metadata) => {
debug!("Found static `pyproject.toml` for: {source}"); debug!("Found static `pyproject.toml` for: {source}");
@ -2433,33 +2470,21 @@ impl StaticMetadata {
} }
} }
Err( Err(
err @ Error::PyprojectToml(uv_pypi_types::MetadataError::DynamicField("version")), err @ (uv_pypi_types::MetadataError::Pep508Error(_)
) 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(_)
| uv_pypi_types::MetadataError::DynamicField(_) | uv_pypi_types::MetadataError::DynamicField(_)
| uv_pypi_types::MetadataError::FieldNotFound(_) | uv_pypi_types::MetadataError::FieldNotFound(_)
| uv_pypi_types::MetadataError::PoetrySyntax, | uv_pypi_types::MetadataError::PoetrySyntax),
)),
) => { ) => {
debug!("No static `pyproject.toml` available for: {source} ({err:?})"); debug!("No static `pyproject.toml` available for: {source} ({err:?})");
} }
Err(err) => return Err(err), Err(err) => return Err(Error::PyprojectToml(err)),
}
} }
// If the source distribution is a source tree, avoid reading `PKG-INFO` or `egg-info`, // If the source distribution is a source tree, avoid reading `PKG-INFO` or `egg-info`,
// since they could be out-of-date. // since they could be out-of-date.
if source.is_source_tree() { 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. // Attempt to read static metadata from the `PKG-INFO` file.
@ -2470,6 +2495,15 @@ impl StaticMetadata {
// Validate the metadata, but ignore it if the metadata doesn't match. // Validate the metadata, but ignore it if the metadata doesn't match.
match validate_metadata(source, &metadata) { match validate_metadata(source, &metadata) {
Ok(()) => { Ok(()) => {
// If necessary, mark the metadata as dynamic.
let metadata = if dynamic {
ResolutionMetadata {
dynamic: true,
..metadata
}
} else {
metadata
};
return Ok(Self::Some(metadata)); return Ok(Self::Some(metadata));
} }
Err(err) => { Err(err) => {
@ -2499,6 +2533,15 @@ impl StaticMetadata {
// Validate the metadata, but ignore it if the metadata doesn't match. // Validate the metadata, but ignore it if the metadata doesn't match.
match validate_metadata(source, &metadata) { match validate_metadata(source, &metadata) {
Ok(()) => { Ok(()) => {
// If necessary, mark the metadata as dynamic.
let metadata = if dynamic {
ResolutionMetadata {
dynamic: true,
..metadata
}
} else {
metadata
};
return Ok(Self::Some(metadata)); return Ok(Self::Some(metadata));
} }
Err(err) => { Err(err) => {
@ -2526,7 +2569,7 @@ impl StaticMetadata {
Err(err) => return Err(err), Err(err) => return Err(err),
} }
Ok(Self::None) Ok(if dynamic { Self::Dynamic } else { Self::None })
} }
} }
@ -2845,8 +2888,7 @@ async fn read_pkg_info(
async fn read_pyproject_toml( async fn read_pyproject_toml(
source_tree: &Path, source_tree: &Path,
subdirectory: Option<&Path>, subdirectory: Option<&Path>,
sdist_version: Option<&Version>, ) -> Result<PyProjectToml, Error> {
) -> Result<ResolutionMetadata, Error> {
// Read the `pyproject.toml` file. // Read the `pyproject.toml` file.
let pyproject_toml = match subdirectory { let pyproject_toml = match subdirectory {
Some(subdirectory) => source_tree.join(subdirectory).join("pyproject.toml"), Some(subdirectory) => source_tree.join(subdirectory).join("pyproject.toml"),
@ -2860,11 +2902,9 @@ async fn read_pyproject_toml(
Err(err) => return Err(Error::CacheRead(err)), Err(err) => return Err(Error::CacheRead(err)),
}; };
// Parse the metadata. let pyproject_toml = PyProjectToml::from_toml(&content)?;
let metadata = ResolutionMetadata::parse_pyproject_toml(&content, sdist_version)
.map_err(Error::PyprojectToml)?;
Ok(metadata) Ok(pyproject_toml)
} }
/// Return the [`pypi_types::RequiresDist`] from a `pyproject.toml`, if it can be statically extracted. /// Return the [`pypi_types::RequiresDist`] from a `pyproject.toml`, if it can be statically extracted.

View File

@ -165,11 +165,9 @@ impl ResolutionMetadata {
/// If we're coming from a source distribution, we may already know the version (unlike for a /// 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. /// source tree), so we can tolerate dynamic versions.
pub fn parse_pyproject_toml( pub fn parse_pyproject_toml(
content: &str, pyproject_toml: PyProjectToml,
sdist_version: Option<&Version>, sdist_version: Option<&Version>,
) -> Result<Self, MetadataError> { ) -> Result<Self, MetadataError> {
let pyproject_toml = PyProjectToml::from_toml(content)?;
let project = pyproject_toml let project = pyproject_toml
.project .project
.ok_or(MetadataError::FieldNotFound("project"))?; .ok_or(MetadataError::FieldNotFound("project"))?;
@ -333,7 +331,8 @@ mod tests {
[project] [project]
name = "asdf" 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")))); assert!(matches!(meta, Err(MetadataError::FieldNotFound("version"))));
let s = r#" let s = r#"
@ -341,7 +340,8 @@ mod tests {
name = "asdf" name = "asdf"
dynamic = ["version"] 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")))); assert!(matches!(meta, Err(MetadataError::DynamicField("version"))));
let s = r#" let s = r#"
@ -349,7 +349,8 @@ mod tests {
name = "asdf" name = "asdf"
version = "1.0" 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.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.version, Version::new([1, 0]));
assert!(meta.requires_python.is_none()); assert!(meta.requires_python.is_none());
@ -362,7 +363,8 @@ mod tests {
version = "1.0" version = "1.0"
requires-python = ">=3.6" 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.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.version, Version::new([1, 0]));
assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap())); assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap()));
@ -376,7 +378,8 @@ mod tests {
requires-python = ">=3.6" requires-python = ">=3.6"
dependencies = ["foo"] 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.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.version, Version::new([1, 0]));
assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap())); assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap()));
@ -393,7 +396,8 @@ mod tests {
[project.optional-dependencies] [project.optional-dependencies]
dotenv = ["bar"] 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.name, PackageName::from_str("asdf").unwrap());
assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.version, Version::new([1, 0]));
assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap())); assert_eq!(meta.requires_python, Some(">=3.6".parse().unwrap()));

View File

@ -23,6 +23,7 @@ pub use metadata10::Metadata10;
pub use metadata12::Metadata12; pub use metadata12::Metadata12;
pub use metadata23::Metadata23; pub use metadata23::Metadata23;
pub use metadata_resolver::ResolutionMetadata; pub use metadata_resolver::ResolutionMetadata;
pub use pyproject_toml::PyProjectToml;
pub use requires_dist::RequiresDist; pub use requires_dist::RequiresDist;
pub use requires_txt::RequiresTxt; pub use requires_txt::RequiresTxt;

View File

@ -12,13 +12,13 @@ use crate::MetadataError;
/// A `pyproject.toml` as specified in PEP 517. /// A `pyproject.toml` as specified in PEP 517.
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub(super) struct PyProjectToml { pub struct PyProjectToml {
pub(super) project: Option<Project>, pub project: Option<Project>,
pub(super) tool: Option<Tool>, pub(super) tool: Option<Tool>,
} }
impl PyProjectToml { 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) let pyproject_toml: toml_edit::ImDocument<_> = toml_edit::ImDocument::from_str(toml)
.map_err(MetadataError::InvalidPyprojectTomlSyntax)?; .map_err(MetadataError::InvalidPyprojectTomlSyntax)?;
let pyproject_toml: Self = PyProjectToml::deserialize(pyproject_toml.into_deserializer()) let pyproject_toml: Self = PyProjectToml::deserialize(pyproject_toml.into_deserializer())
@ -35,20 +35,20 @@ impl PyProjectToml {
/// See <https://packaging.python.org/en/latest/specifications/pyproject-toml>. /// See <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[serde(try_from = "PyprojectTomlWire")] #[serde(try_from = "PyprojectTomlWire")]
pub(super) struct Project { pub struct Project {
/// The name of the project /// The name of the project
pub(super) name: PackageName, pub name: PackageName,
/// The version of the project as supported by PEP 440 /// 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 /// The Python version requirements of the project
pub(super) requires_python: Option<String>, pub requires_python: Option<String>,
/// Project dependencies /// Project dependencies
pub(super) dependencies: Option<Vec<String>>, pub dependencies: Option<Vec<String>>,
/// Optional dependencies /// 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 /// Specifies which fields listed by PEP 621 were intentionally unspecified
/// so another tool can/will provide such metadata dynamically. /// so another tool can/will provide such metadata dynamically.
pub(super) dynamic: Option<Vec<String>>, pub dynamic: Option<Vec<String>>,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]

View File

@ -21,6 +21,7 @@ pub struct RequiresDist {
pub name: PackageName, pub name: PackageName,
pub requires_dist: Vec<Requirement<VerbatimParsedUrl>>, pub requires_dist: Vec<Requirement<VerbatimParsedUrl>>,
pub provides_extras: Vec<ExtraName>, pub provides_extras: Vec<ExtraName>,
#[serde(default)]
pub dynamic: bool, pub dynamic: bool,
} }

View File

@ -17063,7 +17063,7 @@ fn lock_invalid_project_table() -> Result<()> {
----- stderr ----- ----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
× Failed to build `b @ file://[TEMP_DIR]/b` × Failed to build `b @ file://[TEMP_DIR]/b`
Failed to extract static metadata from `pyproject.toml` Failed to parse metadata from built wheel
TOML parse error at line 2, column 10 TOML parse error at line 2, column 10
| |
2 | [project.urls] 2 | [project.urls]
@ -19757,6 +19757,114 @@ fn lock_dynamic_version() -> Result<()> {
Ok(()) Ok(())
} }
/// Lock a package with a dynamic version and dynamic dependencies.
#[test]
fn lock_dynamic_version_dependencies() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
requires-python = ">=3.12"
dynamic = ["dependencies", "version"]
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[tool.uv]
cache-keys = [{ file = "pyproject.toml" }, { file = "src/project/__init__.py" }]
[tool.setuptools.dynamic]
version = { attr = "project.__version__" }
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
"#,
)?;
context
.temp_dir
.child("src")
.child("project")
.child("__init__.py")
.write_str("__version__ = '0.1.0'")?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
"###);
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "project"
source = { editable = "." }
"###
);
});
// Bump the version.
context
.temp_dir
.child("src")
.child("project")
.child("__init__.py")
.write_str("__version__ = '0.1.1'")?;
// Re-run with `--locked`. We should accept the lockfile, since dynamic versions are omitted.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
"###);
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock")).unwrap();
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "project"
source = { editable = "." }
"###
);
});
Ok(())
}
/// Validating a lockfile with a dynamic version (but static dependencies) shouldn't require /// Validating a lockfile with a dynamic version (but static dependencies) shouldn't require
/// building the package. /// building the package.
#[test] #[test]