Track supported Python range in lockfile (#4065)

## Summary

This PR adds the `Requires-Python` range to the user's lockfile. This
will enable us to validate it when installing.

For now, we repeat the `Requires-Python` back to the user;
alternatively, though, we could detect the supported Python range
automatically.

See: https://github.com/astral-sh/uv/issues/4052
This commit is contained in:
Charlie Marsh 2024-06-05 16:21:59 -04:00 committed by GitHub
parent 8596525d97
commit 642cef0dad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 58 additions and 9 deletions

View file

@ -20,7 +20,7 @@ use distribution_types::{
GitSourceDist, IndexUrl, PathBuiltDist, PathSourceDist, RegistryBuiltDist, RegistryBuiltWheel,
RegistrySourceDist, RemoteSource, Resolution, ResolvedDist, ToUrlError,
};
use pep440_rs::Version;
use pep440_rs::{Version, VersionSpecifiers};
use pep508_rs::{MarkerEnvironment, MarkerTree, VerbatimUrl};
use platform_tags::{TagCompatibility, TagPriority, Tags};
use pypi_types::{HashDigest, ParsedArchiveUrl, ParsedGitUrl};
@ -36,6 +36,8 @@ use crate::{lock, ResolutionGraph};
pub struct Lock {
version: u32,
distributions: Vec<Distribution>,
/// The range of supported Python versions.
requires_python: Option<VersionSpecifiers>,
/// A map from distribution ID to index in `distributions`.
///
/// This can be used to quickly lookup the full distribution for any ID
@ -87,15 +89,21 @@ impl Lock {
}
}
let lock = Self::new(locked_dists.into_values().collect())?;
let distributions = locked_dists.into_values().collect();
let requires_python = graph.requires_python.clone();
let lock = Self::new(distributions, requires_python)?;
Ok(lock)
}
/// Initialize a [`Lock`] from a list of [`Distribution`] entries.
fn new(distributions: Vec<Distribution>) -> Result<Self, LockError> {
fn new(
distributions: Vec<Distribution>,
requires_python: Option<VersionSpecifiers>,
) -> Result<Self, LockError> {
let wire = LockWire {
version: 1,
distributions,
requires_python,
};
Self::try_from(wire)
}
@ -196,6 +204,8 @@ struct LockWire {
version: u32,
#[serde(rename = "distribution")]
distributions: Vec<Distribution>,
#[serde(rename = "requires-python")]
requires_python: Option<VersionSpecifiers>,
}
impl From<Lock> for LockWire {
@ -203,6 +213,7 @@ impl From<Lock> for LockWire {
LockWire {
version: lock.version,
distributions: lock.distributions,
requires_python: lock.requires_python,
}
}
}
@ -215,6 +226,10 @@ impl Lock {
let mut doc = toml_edit::DocumentMut::new();
doc.insert("version", value(i64::from(self.version)));
if let Some(ref requires_python) = self.requires_python {
doc.insert("requires-python", value(requires_python.to_string()));
}
let mut distributions = ArrayOfTables::new();
for dist in &self.distributions {
let mut table = Table::new();
@ -344,6 +359,7 @@ impl TryFrom<LockWire> for Lock {
Ok(Lock {
version: wire.version,
distributions: wire.distributions,
requires_python: wire.requires_python,
by_id,
})
}

View file

@ -60,13 +60,14 @@ impl PythonRequirement {
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum RequiresPython {
/// The `RequiresPython` specifier is a single version specifier, as provided via
/// The [`RequiresPython`] specifier is a single version specifier, as provided via
/// `--python-version` on the command line.
///
/// The use of a separate enum variant allows us to use a verbatim representation when reporting
/// back to the user.
Specifier(StringVersion),
/// The `RequiresPython` specifier is a set of version specifiers.
/// The [`RequiresPython`] specifier is a set of version specifiers, as extracted from the
/// `Requires-Python` field in a `pyproject.toml` or `METADATA` file.
Specifiers(VersionSpecifiers),
}
@ -93,6 +94,14 @@ impl RequiresPython {
}
}
}
/// Returns the [`VersionSpecifiers`] for the [`RequiresPython`] specifier.
pub fn as_specifiers(&self) -> Option<&VersionSpecifiers> {
match self {
RequiresPython::Specifier(_) => None,
RequiresPython::Specifiers(specifiers) => Some(specifiers),
}
}
}
impl std::fmt::Display for RequiresPython {

View file

@ -9,7 +9,7 @@ use rustc_hash::{FxHashMap, FxHashSet};
use distribution_types::{
Dist, DistributionMetadata, Name, ResolutionDiagnostic, VersionId, VersionOrUrlRef,
};
use pep440_rs::{Version, VersionSpecifier};
use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers};
use pep508_rs::{MarkerEnvironment, MarkerTree};
use pypi_types::{ParsedUrlError, Yanked};
use uv_git::GitResolver;
@ -17,10 +17,13 @@ use uv_normalize::{ExtraName, PackageName};
use crate::preferences::Preferences;
use crate::pubgrub::{PubGrubDistribution, PubGrubPackageInner};
use crate::python_requirement::RequiresPython;
use crate::redirect::url_to_precise;
use crate::resolution::AnnotatedDist;
use crate::resolver::Resolution;
use crate::{InMemoryIndex, Manifest, MetadataResponse, ResolveError, VersionsResponse};
use crate::{
InMemoryIndex, Manifest, MetadataResponse, PythonRequirement, ResolveError, VersionsResponse,
};
/// A complete resolution graph in which every node represents a pinned package and every edge
/// represents a dependency between two pinned packages.
@ -28,6 +31,8 @@ use crate::{InMemoryIndex, Manifest, MetadataResponse, ResolveError, VersionsRes
pub struct ResolutionGraph {
/// The underlying graph.
pub(crate) petgraph: Graph<AnnotatedDist, Version, Directed>,
/// The range of supported Python versions.
pub(crate) requires_python: Option<VersionSpecifiers>,
/// Any diagnostics that were encountered while building the graph.
pub(crate) diagnostics: Vec<ResolutionDiagnostic>,
}
@ -39,9 +44,10 @@ impl ResolutionGraph {
index: &InMemoryIndex,
preferences: &Preferences,
git: &GitResolver,
python: &PythonRequirement,
resolution: Resolution,
) -> anyhow::Result<Self, ResolveError> {
// Collect all marker expressions from relevant pubgrub packages.
// Collect all marker expressions from relevant PubGrub packages.
let mut markers: FxHashMap<(&PackageName, &Version, &Option<ExtraName>), MarkerTree> =
FxHashMap::default();
for (package, versions) in &resolution.packages {
@ -267,8 +273,17 @@ impl ResolutionGraph {
}
}
// Extract the `Requires-Python` range, if provided.
// TODO(charlie): Infer the supported Python range from the `Requires-Python` of the
// included packages.
let requires_python = python
.target()
.and_then(RequiresPython::as_specifiers)
.cloned();
Ok(Self {
petgraph,
requires_python,
diagnostics,
})
}

View file

@ -557,7 +557,13 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
for resolution in resolutions {
combined.union(resolution);
}
ResolutionGraph::from_state(&self.index, &self.preferences, &self.git, combined)
ResolutionGraph::from_state(
&self.index,
&self.preferences,
&self.git,
&self.python_requirement,
combined,
)
}
/// Visit a [`PubGrubPackage`] prior to selection. This should be called on a [`PubGrubPackage`]

View file

@ -73,6 +73,7 @@ Ok(
optional_dependencies: {},
},
],
requires_python: None,
by_id: {
DistributionId {
name: PackageName(

View file

@ -1038,6 +1038,7 @@ fn lock_requires_python() -> Result<()> {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.7"
[[distribution]]
name = "dataclasses"
@ -1163,6 +1164,7 @@ fn lock_requires_python() -> Result<()> {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.7.9, <4"
[[distribution]]
name = "attrs"