Always narrow markers by Python version (#6076)

## Summary

Using https://github.com/astral-sh/uv/issues/6064 as a motivating
example: at present, on main, we're not properly propagating the
`Requires-Python` simplifications. In that case, for example, we end up
solving for a branch with `python_version < 3.11`, and a branch `>=
3.11`, even though `Requires-Python` is `>=3.11`. Later, when we get to
the graph, we apply version simplification based on `Requires-Python`,
which causes us to _remove_ the `python_version < 3.11` markers
entirely, leaving us with duplicate dependencies for `pylint`.

This PR instead tries to ensure that we always apply this narrowing to
requirements and forks, so that we don't need to apply the same
simplification when constructing the graph at all.

Closes https://github.com/astral-sh/uv/issues/6064.

Closes #6059.
This commit is contained in:
Charlie Marsh 2024-08-15 11:50:00 -04:00 committed by GitHub
parent f988e43ebd
commit fe0b873352
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 699 additions and 584 deletions

View file

@ -105,6 +105,19 @@ impl Lock {
python_full_version.clone(),
);
}
for markers in &mut package.fork_markers {
*markers = markers.clone().simplify_python_versions(
python_version.clone(),
python_full_version.clone(),
);
}
}
for markers in &mut lock.fork_markers {
*markers = markers
.clone()
.simplify_python_versions(python_version.clone(), python_full_version.clone());
}
}

View file

@ -1,5 +1,4 @@
use pep440_rs::{Version, VersionSpecifiers};
use pep508_rs::MarkerTree;
use uv_python::{Interpreter, PythonVersion};
use crate::{RequiresPython, RequiresPythonBound};
@ -70,17 +69,6 @@ impl PythonRequirement {
pub fn target(&self) -> Option<&PythonTarget> {
self.target.as_ref()
}
/// Return a [`MarkerTree`] representing the Python requirement.
///
/// See: [`RequiresPython::to_marker_tree`]
pub fn to_marker_tree(&self) -> Option<MarkerTree> {
if let Some(PythonTarget::RequiresPython(requires_python)) = self.target.as_ref() {
Some(requires_python.to_marker_tree())
} else {
None
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]

View file

@ -6,8 +6,7 @@ use itertools::Itertools;
use pubgrub::Range;
use distribution_filename::WheelFilename;
use pep440_rs::{Operator, Version, VersionSpecifier, VersionSpecifiers};
use pep508_rs::{MarkerExpression, MarkerTree, MarkerValueVersion};
use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers};
#[derive(thiserror::Error, Debug)]
pub enum RequiresPythonError {
@ -196,65 +195,6 @@ impl RequiresPython {
}
}
/// Returns this `Requires-Python` specifier as an equivalent marker
/// expression utilizing the `python_version` marker field.
///
/// This is useful for comparing a `Requires-Python` specifier with
/// arbitrary marker expressions. For example, one can ask whether the
/// returned marker expression is disjoint with another marker expression.
/// If it is, then one can conclude that the `Requires-Python` specifier
/// excludes the dependency with that other marker expression.
///
/// If this `Requires-Python` specifier has no constraints, then this
/// returns a marker tree that evaluates to `true` for all possible marker
/// environments.
pub fn to_marker_tree(&self) -> MarkerTree {
let (op, version) = match self.bound.as_ref() {
// If we see this anywhere, then it implies the marker
// tree we would generate would always evaluate to
// `true` because every possible Python version would
// satisfy it.
Bound::Unbounded => return MarkerTree::TRUE,
Bound::Excluded(version) => (Operator::GreaterThan, version.clone().without_local()),
Bound::Included(version) => {
(Operator::GreaterThanEqual, version.clone().without_local())
}
};
// For the `python_version` marker expression, it specifically only
// supports truncate major/minor versions of Python. This means that
// a `Requires-Python: 3.10.1` is satisfied by `python_version ==
// '3.10'`. So for disjointness checking, we need to ensure that the
// marker expression we generate for `Requires-Python` doesn't try to
// be overly selective about the patch version. We do this by keeping
// this part of our marker limited to the major and minor version
// components only.
let version_major_minor_only = Version::new(version.release().iter().take(2));
let expr_python_version = MarkerExpression::Version {
key: MarkerValueVersion::PythonVersion,
// OK because a version specifier is only invalid when the
// version is local (which is impossible here because we
// strip it above) or if the operator is ~= (which is also
// impossible here).
specifier: VersionSpecifier::from_version(op, version_major_minor_only).unwrap(),
};
let expr_python_full_version = MarkerExpression::Version {
key: MarkerValueVersion::PythonFullVersion,
// For `python_full_version`, we can use the entire
// version as-is.
//
// OK because a version specifier is only invalid when the
// version is local (which is impossible here because we
// strip it above) or if the operator is ~= (which is also
// impossible here).
specifier: VersionSpecifier::from_version(op, version).unwrap(),
};
let mut conjunction = MarkerTree::TRUE;
conjunction.and(MarkerTree::expression(expr_python_version));
conjunction.and(MarkerTree::expression(expr_python_full_version));
conjunction
}
/// Returns `false` if the wheel's tags state it can't be used in the given Python version
/// range.
///

View file

@ -3,7 +3,6 @@ use petgraph::{
graph::{Graph, NodeIndex},
Directed, Direction,
};
use pubgrub::Range;
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
use distribution_types::{
@ -169,21 +168,6 @@ impl ResolutionGraph {
.and_then(PythonTarget::as_requires_python)
.cloned();
// Normalize any markers.
if let Some(ref requires_python) = requires_python {
for edge in petgraph.edge_indices() {
petgraph[edge] = petgraph[edge].clone().simplify_python_versions(
Range::from(requires_python.bound_major_minor().clone()),
Range::from(requires_python.bound().clone()),
);
}
// The above simplification may turn some markers into
// "always false." In which case, we should remove that
// edge since it can never be traversed in any marker
// environment.
petgraph.retain_edges(|graph, edge| !graph[edge].is_false());
}
let fork_markers = if let [resolution] = resolutions {
match resolution.markers {
ResolverMarkers::Universal { .. } | ResolverMarkers::SpecificEnvironment(_) => {

View file

@ -103,14 +103,6 @@ struct ResolverState<InstalledPackages: InstalledPackagesProvider> {
markers: ResolverMarkers,
python_requirement: PythonRequirement,
workspace_members: BTreeSet<PackageName>,
/// This is derived from `PythonRequirement` once at initialization
/// time. It's used in universal mode to filter our dependencies with
/// a `python_version` marker expression that has no overlap with the
/// `Requires-Python` specifier.
///
/// This is non-None if and only if the resolver is operating in
/// universal mode. (i.e., when `markers` is `None`.)
requires_python: Option<MarkerTree>,
selector: CandidateSelector,
index: InMemoryIndex,
installed_packages: InstalledPackages,
@ -233,11 +225,6 @@ impl<Provider: ResolverProvider, InstalledPackages: InstalledPackagesProvider>
preferences: manifest.preferences,
exclusions: manifest.exclusions,
hasher: hasher.clone(),
requires_python: if markers.marker_environment().is_some() {
None
} else {
python_requirement.to_marker_tree()
},
markers,
python_requirement: python_requirement.clone(),
installed_packages,
@ -322,7 +309,6 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
root,
self.markers.clone(),
self.python_requirement.clone(),
self.requires_python.clone(),
);
let mut preferences = self.preferences.clone();
let mut forked_states =
@ -343,7 +329,11 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
'FORK: while let Some(mut state) = forked_states.pop() {
if let ResolverMarkers::Fork(markers) = &state.markers {
if let Some(requires_python) = state.requires_python.as_ref() {
if let Some(requires_python) = state
.python_requirement
.target()
.and_then(|target| target.as_requires_python())
{
debug!(
"Solving split {:?} (requires-python: {:?})",
markers, requires_python
@ -540,7 +530,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
&version,
&state.fork_urls,
&state.markers,
state.requires_python.as_ref(),
&state.python_requirement,
)?;
match forked_deps {
ForkedDependencies::Unavailable(reason) => {
@ -1172,9 +1162,10 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
version: &Version,
fork_urls: &ForkUrls,
markers: &ResolverMarkers,
requires_python: Option<&MarkerTree>,
python_requirement: &PythonRequirement,
) -> Result<ForkedDependencies, ResolveError> {
let result = self.get_dependencies(package, version, fork_urls, markers, requires_python);
let result =
self.get_dependencies(package, version, fork_urls, markers, python_requirement);
match markers {
ResolverMarkers::SpecificEnvironment(_) => result.map(|deps| match deps {
Dependencies::Available(deps) | Dependencies::Unforkable(deps) => {
@ -1182,7 +1173,9 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
}
Dependencies::Unavailable(err) => ForkedDependencies::Unavailable(err),
}),
ResolverMarkers::Universal { .. } | ResolverMarkers::Fork(_) => Ok(result?.fork()),
ResolverMarkers::Universal { .. } | ResolverMarkers::Fork(_) => {
Ok(result?.fork(python_requirement))
}
}
}
@ -1194,7 +1187,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
version: &Version,
fork_urls: &ForkUrls,
markers: &ResolverMarkers,
requires_python: Option<&MarkerTree>,
python_requirement: &PythonRequirement,
) -> Result<Dependencies, ResolveError> {
let url = package.name().and_then(|name| fork_urls.get(name));
let dependencies = match &**package {
@ -1207,7 +1200,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
None,
None,
markers,
requires_python,
python_requirement,
);
requirements
@ -1321,7 +1314,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
dev.as_ref(),
Some(name),
markers,
requires_python,
python_requirement,
);
let mut dependencies = requirements
@ -1445,7 +1438,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
dev: Option<&'a GroupName>,
name: Option<&PackageName>,
markers: &'a ResolverMarkers,
requires_python: Option<&'a MarkerTree>,
python_requirement: &'a PythonRequirement,
) -> Vec<Cow<'a, Requirement>> {
// Start with the requirements for the current extra of the package (for an extra
// requirement) or the non-extra (regular) dependencies (if extra is None), plus
@ -1460,7 +1453,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
regular_and_dev_dependencies,
extra,
markers,
requires_python,
python_requirement,
)
.collect::<Vec<_>>();
@ -1485,7 +1478,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
continue;
}
for requirement in
self.requirements_for_extra(dependencies, Some(&extra), markers, requires_python)
self.requirements_for_extra(dependencies, Some(&extra), markers, python_requirement)
{
if name == Some(&requirement.name) {
// Add each transitively included extra.
@ -1510,32 +1503,44 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
dependencies: impl IntoIterator<Item = &'data Requirement> + 'parameters,
extra: Option<&'parameters ExtraName>,
markers: &'parameters ResolverMarkers,
requires_python: Option<&'parameters MarkerTree>,
python_requirement: &'parameters PythonRequirement,
) -> impl Iterator<Item = Cow<'data, Requirement>> + 'parameters
where
'data: 'parameters,
{
self.overrides
.apply(dependencies)
.filter(move |requirement| {
.filter_map(move |requirement| {
// If the requirement would not be selected with any Python version
// supported by the root, skip it.
if !satisfies_requires_python(requires_python, requirement) {
trace!(
"skipping {requirement} because of Requires-Python {requires_python:?}",
// OK because this filter only applies when there is a present
// Requires-Python specifier.
requires_python = requires_python.unwrap()
let requirement = if let Some(requires_python) = python_requirement.target().and_then(|target| target.as_requires_python()).filter(|_| !requirement.marker.is_true()) {
let marker = requirement.marker.clone().simplify_python_versions(
Range::from(requires_python.bound_major_minor().clone()),
Range::from(requires_python.bound().clone()),
);
return false;
}
if marker.is_false() {
debug!("skipping {requirement} because of Requires-Python: {requires_python}");
return None;
}
Cow::Owned(Requirement {
name: requirement.name.clone(),
extras: requirement.extras.clone(),
source: requirement.source.clone(),
origin: requirement.origin.clone(),
marker
})
} else {
requirement
};
// If we're in a fork in universal mode, ignore any dependency that isn't part of
// this fork (but will be part of another fork).
if let ResolverMarkers::Fork(markers) = markers {
if !possible_to_satisfy_markers(markers, requirement) {
trace!("skipping {requirement} because of context resolver markers {markers:?}");
return false;
if markers.is_disjoint(&requirement.marker) {
debug!("skipping {requirement} because of context resolver markers {markers:?}");
return None;
}
}
@ -1544,23 +1549,23 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
Some(source_extra) => {
// Only include requirements that are relevant for the current extra.
if requirement.evaluate_markers(markers.marker_environment(), &[]) {
return false;
return None;
}
if !requirement.evaluate_markers(
markers.marker_environment(),
std::slice::from_ref(source_extra),
) {
return false;
return None;
}
}
None => {
if !requirement.evaluate_markers(markers.marker_environment(), &[]) {
return false;
return None;
}
}
}
true
Some(requirement)
})
.flat_map(move |requirement| {
iter::once(requirement.clone()).chain(
@ -1568,21 +1573,57 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
.get(&requirement.name)
.into_iter()
.flatten()
.filter(move |constraint| {
if !satisfies_requires_python(requires_python, constraint) {
trace!(
"skipping {constraint} because of Requires-Python {requires_python:?}",
requires_python = requires_python.unwrap()
.filter_map(move |constraint| {
// If the requirement would not be selected with any Python version
// supported by the root, skip it.
let constraint = if let Some(requires_python) = python_requirement.target().and_then(|target| target.as_requires_python()).filter(|_| !constraint.marker.is_true()) {
let mut marker = constraint.marker.clone().simplify_python_versions(
Range::from(requires_python.bound_major_minor().clone()),
Range::from(requires_python.bound().clone()),
);
return false;
}
marker.and(requirement.marker.clone());
// Additionally, if the requirement is `requests ; sys_platform == 'darwin'`
// and the constraint is `requests ; python_version == '3.6'`, the
// constraint should only apply when _both_ markers are true.
if marker.is_false() {
debug!("skipping {constraint} because of Requires-Python: {requires_python}");
return None;
}
Cow::Owned(Requirement {
name: constraint.name.clone(),
extras: constraint.extras.clone(),
source: constraint.source.clone(),
origin: constraint.origin.clone(),
marker
})
} else {
// Additionally, if the requirement is `requests ; sys_platform == 'darwin'`
// and the constraint is `requests ; python_version == '3.6'`, the
// constraint should only apply when _both_ markers are true.
if requirement.marker.is_true() {
Cow::Borrowed(constraint)
} else {
let mut marker = constraint.marker.clone();
marker.and(requirement.marker.clone());
Cow::Owned(Requirement {
name: constraint.name.clone(),
extras: constraint.extras.clone(),
source: constraint.source.clone(),
origin: constraint.origin.clone(),
marker
})
}
};
// If we're in a fork in universal mode, ignore any dependency that isn't part of
// this fork (but will be part of another fork).
if let ResolverMarkers::Fork(markers) = markers {
if !possible_to_satisfy_markers(markers, constraint) {
trace!("skipping {constraint} because of context resolver markers {markers:?}");
return false;
if markers.is_disjoint(&constraint.marker) {
debug!("skipping {constraint} because of context resolver markers {markers:?}");
return None;
}
}
@ -1593,36 +1634,17 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
markers.marker_environment(),
std::slice::from_ref(source_extra),
) {
return false;
return None;
}
}
None => {
if !constraint.evaluate_markers(markers.marker_environment(), &[]) {
return false;
return None;
}
}
}
true
})
.map(move |constraint| {
// If the requirement is `requests ; sys_platform == 'darwin'` and the
// constraint is `requests ; python_version == '3.6'`, the constraint
// should only apply when _both_ markers are true.
if requirement.marker.is_true() {
Cow::Borrowed(constraint)
} else {
let mut marker = constraint.marker.clone();
marker.and(requirement.marker.clone());
Cow::Owned(Requirement {
name: constraint.name.clone(),
extras: constraint.extras.clone(),
source: constraint.source.clone(),
origin: constraint.origin.clone(),
marker
})
}
Some(constraint)
})
)
})
@ -2074,8 +2096,6 @@ struct ForkState {
/// The top fork has a narrower Python compatibility range, and thus can find a
/// solution that omits Python 3.8 support.
python_requirement: PythonRequirement,
/// The [`MarkerTree`] corresponding to the [`PythonRequirement`].
requires_python: Option<MarkerTree>,
}
impl ForkState {
@ -2084,7 +2104,6 @@ impl ForkState {
root: PubGrubPackage,
markers: ResolverMarkers,
python_requirement: PythonRequirement,
requires_python: Option<MarkerTree>,
) -> Self {
Self {
pubgrub,
@ -2095,7 +2114,6 @@ impl ForkState {
added_dependencies: FxHashMap::default(),
markers,
python_requirement,
requires_python,
}
}
@ -2257,11 +2275,6 @@ impl ForkState {
if let Some(target) = python_requirement.target() {
debug!("Narrowed `requires-python` bound to: {target}");
}
self.requires_python = if self.requires_python.is_some() {
python_requirement.to_marker_tree()
} else {
None
};
self.python_requirement = python_requirement;
}
@ -2617,7 +2630,7 @@ impl Dependencies {
/// A fork *only* occurs when there are multiple dependencies with the same
/// name *and* those dependency specifications have corresponding marker
/// expressions that are completely disjoint with one another.
fn fork(self) -> ForkedDependencies {
fn fork(self, python_requirement: &PythonRequirement) -> ForkedDependencies {
let deps = match self {
Dependencies::Available(deps) => deps,
Dependencies::Unforkable(deps) => return ForkedDependencies::Unforked(deps),
@ -2635,7 +2648,7 @@ impl Dependencies {
let Forks {
mut forks,
diverging_packages,
} = Forks::new(name_to_deps);
} = Forks::new(name_to_deps, python_requirement);
if forks.is_empty() {
ForkedDependencies::Unforked(vec![])
} else if forks.len() == 1 {
@ -2693,7 +2706,10 @@ struct Forks {
}
impl Forks {
fn new(name_to_deps: BTreeMap<PackageName, Vec<PubGrubDependency>>) -> Forks {
fn new(
name_to_deps: BTreeMap<PackageName, Vec<PubGrubDependency>>,
python_requirement: &PythonRequirement,
) -> Forks {
let mut forks = vec![Fork {
dependencies: vec![],
markers: MarkerTree::TRUE,
@ -2722,6 +2738,7 @@ impl Forks {
continue;
}
for dep in deps {
// We assume that the marker has already been Python-simplified.
let mut markers = dep.package.marker().cloned().unwrap_or(MarkerTree::TRUE);
if markers.is_false() {
// If the markers can never be satisfied, then we
@ -2747,9 +2764,10 @@ impl Forks {
new.push(fork);
continue;
}
let not_markers = markers.negate();
let not_markers = simplify_python(markers.negate(), python_requirement);
let mut new_markers = markers.clone();
new_markers.and(fork.markers.negate());
new_markers.and(simplify_python(fork.markers.negate(), python_requirement));
if !fork.markers.is_disjoint(&not_markers) {
let mut new_fork = fork.clone();
new_fork.intersect(not_markers);
@ -2856,24 +2874,17 @@ impl PartialOrd for Fork {
}
}
/// Returns true if and only if the given requirement's marker expression has a
/// possible true value given the `requires_python` specifier given.
///
/// While this is always called, a `requires_python` is only non-None when in
/// universal resolution mode. In non-universal mode, `requires_python` is
/// `None` and this always returns `true`.
fn satisfies_requires_python(
requires_python: Option<&MarkerTree>,
requirement: &Requirement,
) -> bool {
let Some(requires_python) = requires_python else {
return true;
};
possible_to_satisfy_markers(requires_python, requirement)
}
/// Returns true if and only if the given requirement's marker expression has a
/// possible true value given the `markers` expression given.
fn possible_to_satisfy_markers(markers: &MarkerTree, requirement: &Requirement) -> bool {
!markers.is_disjoint(&requirement.marker)
/// Simplify a [`MarkerTree`] based on a [`PythonRequirement`].
fn simplify_python(marker: MarkerTree, python_requirement: &PythonRequirement) -> MarkerTree {
if let Some(requires_python) = python_requirement
.target()
.and_then(|target| target.as_requires_python())
{
marker.simplify_python_versions(
Range::from(requires_python.bound_major_minor().clone()),
Range::from(requires_python.bound().clone()),
)
} else {
marker
}
}

View file

@ -67,7 +67,7 @@ fn branching_urls_overlapping() -> Result<()> {
----- stdout -----
----- stderr -----
error: Requirements contain conflicting URLs for package `iniconfig` in split `python_version >= '3.11' and python_version < '3.12'`:
error: Requirements contain conflicting URLs for package `iniconfig` in split `python_version < '3.12'`:
- https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl
- https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl
"###
@ -723,7 +723,7 @@ fn branching_urls_of_different_sources_conflict() -> Result<()> {
----- stdout -----
----- stderr -----
error: Requirements contain conflicting URLs for package `iniconfig` in split `python_version >= '3.11' and python_version < '3.12'`:
error: Requirements contain conflicting URLs for package `iniconfig` in split `python_version < '3.12'`:
- git+https://github.com/pytest-dev/iniconfig@93f5930e668c0d1ddf4597e38dd0dea4e2665e7a
- https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl
"###

View file

@ -1826,10 +1826,6 @@ fn update() -> Result<()> {
lock, @r###"
version = 1
requires-python = ">=3.12"
environment-markers = [
"python_version <= '3.7'",
"python_version > '3.7'",
]
[options]
exclude-newer = "2024-03-25 00:00:00 UTC"

View file

@ -8126,8 +8126,8 @@ fn unconditional_overlapping_marker_disjoint_version_constraints() -> Result<()>
----- stderr -----
warning: `uv lock` is experimental and may change without warning
× No solution found when resolving dependencies for split (python_version > '3.10'):
Because only datasets{python_version > '3.10'}<2.19 is available and your project depends on datasets{python_version > '3.10'}>=2.19, we can conclude that your project's requirements are unsatisfiable.
× No solution found when resolving dependencies:
Because your project depends on datasets<2.19 and datasets>=2.19, we can conclude that your project's requirements are unsatisfiable.
"###);
Ok(())
@ -9429,3 +9429,228 @@ fn lock_reorder() -> Result<()> {
Ok(())
}
#[test]
fn lock_narrowed_python_version() -> Result<()> {
let context = TestContext::new("3.12");
let dependency = context.temp_dir.child("dependency");
dependency.child("pyproject.toml").write_str(
r#"
[project]
name = "dependency"
version = "0.1.0"
requires-python = ">=3.7"
dependencies = ["iniconfig"]
"#,
)?;
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.7"
dependencies = ["dependency ; python_version < '3.9'", "dependency ; python_version > '3.10'"]
[tool.uv.sources]
dependency = { path = "./dependency" }
"#,
)?;
let lockfile = context.temp_dir.join("uv.lock");
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv lock` is experimental and may change without warning
warning: `uv.sources` is experimental and may change without warning
Resolved 3 packages in [TIME]
"###);
let lock = fs_err::read_to_string(&lockfile).unwrap();
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r###"
version = 1
requires-python = ">=3.7"
environment-markers = [
"python_version < '3.9'",
"python_version >= '3.9' and python_version <= '3.10'",
"python_version > '3.10'",
]
[options]
exclude-newer = "2024-03-25 00:00:00 UTC"
[[package]]
name = "dependency"
version = "0.1.0"
source = { directory = "dependency" }
dependencies = [
{ name = "iniconfig", marker = "python_version < '3.9' or python_version > '3.10'" },
]
[package.metadata]
requires-dist = [{ name = "iniconfig" }]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
]
[[package]]
name = "project"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "dependency", marker = "python_version < '3.9' or python_version > '3.10'" },
]
[package.metadata]
requires-dist = [
{ name = "dependency", marker = "python_version < '3.9'", directory = "dependency" },
{ name = "dependency", marker = "python_version > '3.10'", directory = "dependency" },
]
"###
);
});
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv lock` is experimental and may change without warning
warning: `uv.sources` is experimental and may change without warning
Resolved 3 packages in [TIME]
"###);
Ok(())
}
/// When resolving, we should skip forks that have an upper-bound on Python version that's below
/// our `requires-python` constraint.
///
/// See: <https://github.com/astral-sh/uv/issues/6059>
#[test]
fn lock_exclude_unnecessary_python_forks() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "foo"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"anyio ; sys_platform == 'darwin'",
"anyio ; python_version > '3.10'"
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv lock` is experimental and may change without warning
Resolved 4 packages 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"
environment-markers = [
"sys_platform == 'darwin'",
"sys_platform != 'darwin'",
]
[options]
exclude-newer = "2024-03-25 00:00:00 UTC"
[[package]]
name = "anyio"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584 },
]
[[package]]
name = "foo"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "anyio" },
]
[package.metadata]
requires-dist = [
{ name = "anyio", marker = "sys_platform == 'darwin'" },
{ name = "anyio", marker = "python_version > '3.10'" },
]
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
"###
);
});
// Re-run with `--locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv lock` is experimental and may change without warning
Resolved 4 packages in [TIME]
"###);
Ok(())
}

View file

@ -8035,7 +8035,7 @@ fn universal_requires_python_incomplete() -> Result<()> {
/// [2]: https://github.com/astral-sh/uv/pull/4707
/// [3]: https://github.com/astral-sh/uv/pull/5597
#[test]
fn universal_no_repeated_unconditional_distributions() -> Result<()> {
fn universal_no_repeated_unconditional_distributions_1() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str(indoc::indoc! {r"
@ -8135,6 +8135,60 @@ fn universal_no_repeated_unconditional_distributions() -> Result<()> {
Ok(())
}
/// This test captures a case[1] that was broken by marker normalization.
///
/// [1]: https://github.com/astral-sh/uv/issues/6064
#[test]
fn universal_no_repeated_unconditional_distributions_2() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str(indoc::indoc! {r"
pylint
dill==0.3.1.1
"})?;
uv_snapshot!(context.filters(), windows_filters=false, context.pip_compile()
.arg("requirements.in")
.arg("-p")
.arg("3.11")
.arg("--universal"), @r###"
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] requirements.in -p 3.11 --universal
astroid==2.13.5
# via pylint
colorama==0.4.6 ; sys_platform == 'win32'
# via pylint
dill==0.3.1.1
# via
# -r requirements.in
# pylint
isort==5.13.2
# via pylint
lazy-object-proxy==1.10.0
# via astroid
mccabe==0.7.0
# via pylint
platformdirs==4.2.0
# via pylint
pylint==2.15.8
# via -r requirements.in
tomlkit==0.12.4
# via pylint
wrapt==1.16.0
# via astroid
----- stderr -----
warning: The requested Python version 3.11 is not available; 3.12.[X] will be used to build dependencies instead.
Resolved 10 packages in [TIME]
"###
);
Ok(())
}
/// Solve for upper bounds before solving for lower bounds. A solution that satisfies `pylint < 3`
/// can also work for `pylint > 2`, but the inverse isn't true (due to maximum version selection).
#[test]

View file

@ -5,9 +5,8 @@ expression: lock
version = 1
requires-python = ">=3.11, <3.12"
environment-markers = [
"python_version < '3.11'",
"python_version >= '3.11.[X]' and python_version < '3.12'",
"python_version >= '3.12' and python_version < '3.13'",
"python_version < '3.12'",
"python_version < '3.13'",
"python_version >= '3.13'",
]
@ -878,7 +877,7 @@ dependencies = [
{ name = "envier" },
{ name = "opentelemetry-api" },
{ name = "protobuf" },
{ name = "setuptools", marker = "python_version >= '3.12'" },
{ name = "setuptools" },
{ name = "six" },
{ name = "sqlparse" },
{ name = "typing-extensions" },