Merge markers when applying constraints (#4648)

## Summary

When a constraint is applied to a requirement with a marker, the marker
needs to be propagated to the constraint.

If both the constraint and the requirement have a marker, they need to
be merged together (via `and`).

Closes https://github.com/astral-sh/uv/issues/4575.
This commit is contained in:
Charlie Marsh 2024-06-29 12:51:04 -04:00 committed by GitHub
parent 0bb99952f6
commit ea6185e082
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 196 additions and 122 deletions

View file

@ -1218,7 +1218,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
dev: Option<&'a GroupName>, dev: Option<&'a GroupName>,
name: Option<&PackageName>, name: Option<&PackageName>,
markers: &'a MarkerTree, markers: &'a MarkerTree,
) -> Vec<&'a Requirement> { ) -> Vec<Cow<'a, Requirement>> {
// Start with the requirements for the current extra of the package (for an extra // 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 // requirement) or the non-extra (regular) dependencies (if extra is None), plus
// the constraints for the current package. // the constraints for the current package.
@ -1227,9 +1227,9 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
} else { } else {
Either::Right(dependencies.iter()) Either::Right(dependencies.iter())
}; };
let mut requirements: Vec<&Requirement> = self let mut requirements = self
.requirements_for_extra(regular_and_dev_dependencies, extra, markers) .requirements_for_extra(regular_and_dev_dependencies, extra, markers)
.collect(); .collect::<Vec<_>>();
// Check if there are recursive self inclusions and we need to go into the expensive branch. // Check if there are recursive self inclusions and we need to go into the expensive branch.
if !requirements if !requirements
@ -1245,21 +1245,23 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
let mut queue: VecDeque<_> = requirements let mut queue: VecDeque<_> = requirements
.iter() .iter()
.filter(|req| name == Some(&req.name)) .filter(|req| name == Some(&req.name))
.flat_map(|req| &req.extras) .flat_map(|req| req.extras.iter().cloned())
.collect(); .collect();
while let Some(extra) = queue.pop_front() { while let Some(extra) = queue.pop_front() {
if !seen.insert(extra) { if !seen.insert(extra.clone()) {
continue; continue;
} }
// Add each transitively included extra. for requirement in self.requirements_for_extra(dependencies, Some(&extra), markers) {
queue.extend( if name == Some(&requirement.name) {
self.requirements_for_extra(dependencies, Some(extra), markers) // Add each transitively included extra.
.filter(|req| name == Some(&req.name)) queue.extend(requirement.extras.iter().cloned());
.flat_map(|req| &req.extras), } else {
); // Add the requirements for that extra.
// Add the requirements for that extra requirements.push(requirement);
requirements.extend(self.requirements_for_extra(dependencies, Some(extra), markers)); }
}
} }
// Drop all the self-requirements now that we flattened them out. // Drop all the self-requirements now that we flattened them out.
requirements.retain(|req| name != Some(&req.name)); requirements.retain(|req| name != Some(&req.name));
@ -1268,12 +1270,15 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
/// The set of the regular and dev dependencies, filtered by Python version, /// The set of the regular and dev dependencies, filtered by Python version,
/// the markers of this fork and the requested extra. /// the markers of this fork and the requested extra.
fn requirements_for_extra<'a>( fn requirements_for_extra<'data, 'parameters>(
&'a self, &'data self,
dependencies: impl IntoIterator<Item = &'a Requirement>, dependencies: impl IntoIterator<Item = &'data Requirement> + 'parameters,
extra: Option<&'a ExtraName>, extra: Option<&'parameters ExtraName>,
markers: &'a MarkerTree, markers: &'parameters MarkerTree,
) -> impl Iterator<Item = &'a Requirement> { ) -> impl Iterator<Item = Cow<'data, Requirement>> + 'parameters
where
'data: 'parameters,
{
self.overrides self.overrides
.apply(dependencies) .apply(dependencies)
.filter(move |requirement| { .filter(move |requirement| {
@ -1322,7 +1327,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
true true
}) })
.flat_map(move |requirement| { .flat_map(move |requirement| {
iter::once(requirement).chain( iter::once(Cow::Borrowed(requirement)).chain(
self.constraints self.constraints
.get(&requirement.name) .get(&requirement.name)
.into_iter() .into_iter()
@ -1359,7 +1364,27 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
} }
true 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 let Some(marker) = requirement.marker.as_ref() {
let marker = constraint.marker.as_ref().map(|m| {
MarkerTree::And(vec![marker.clone(), m.clone()])
}).or_else(|| Some(marker.clone()));
Cow::Owned(Requirement {
name: constraint.name.clone(),
extras: constraint.extras.clone(),
source: constraint.source.clone(),
origin: constraint.origin.clone(),
marker
})
} else {
Cow::Borrowed(constraint)
}
})
) )
}) })
} }

View file

@ -6390,7 +6390,8 @@ fn universal_cycles() -> Result<()> {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in"); let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str(indoc::indoc! {r" requirements_in.write_str(indoc::indoc! {r"
poetry testtools==2.3.0
fixtures==3.0.0
"})?; "})?;
uv_snapshot!(context.filters(), windows_filters=false, context.pip_compile() uv_snapshot!(context.filters(), windows_filters=false, context.pip_compile()
@ -6401,114 +6402,162 @@ fn universal_cycles() -> Result<()> {
----- stdout ----- ----- stdout -----
# This file was autogenerated by uv via the following command: # This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] requirements.in --universal # uv pip compile --cache-dir [CACHE_DIR] requirements.in --universal
build==1.1.1 argparse==1.4.0
# via poetry # via unittest2
cachecontrol==0.14.0 extras==1.0.0
# via poetry # via testtools
certifi==2024.2.2 fixtures==3.0.0
# via requests
cffi==1.16.0 ; sys_platform == 'darwin' or (platform_python_implementation != 'PyPy' and sys_platform == 'linux')
# via
# cryptography
# xattr
charset-normalizer==3.3.2
# via requests
cleo==2.1.0
# via poetry
colorama==0.4.6 ; os_name == 'nt'
# via build
crashtest==0.4.1
# via
# cleo
# poetry
cryptography==42.0.5 ; sys_platform == 'linux'
# via secretstorage
distlib==0.3.8
# via virtualenv
dulwich==0.21.7
# via poetry
fastjsonschema==2.19.1
# via poetry
filelock==3.13.1
# via
# cachecontrol
# virtualenv
idna==3.6
# via requests
installer==0.7.0
# via poetry
jaraco-classes==3.3.1
# via keyring
jeepney==0.8.0 ; sys_platform == 'linux'
# via
# keyring
# secretstorage
keyring==24.3.1
# via poetry
more-itertools==10.2.0
# via jaraco-classes
msgpack==1.0.8
# via cachecontrol
packaging==24.0
# via
# build
# poetry
pexpect==4.9.0
# via poetry
pkginfo==1.10.0
# via poetry
platformdirs==4.2.0
# via
# poetry
# virtualenv
poetry==1.8.2
# via # via
# -r requirements.in # -r requirements.in
# poetry-plugin-export # testtools
poetry-core==1.9.0 linecache2==1.0.0
# via traceback2
pbr==6.0.0
# via # via
# poetry # fixtures
# poetry-plugin-export # testtools
poetry-plugin-export==1.7.1 python-mimeparse==1.6.0
# via poetry # via testtools
ptyprocess==0.7.0 six==1.16.0
# via pexpect
pycparser==2.21 ; sys_platform == 'darwin' or (platform_python_implementation != 'PyPy' and sys_platform == 'linux')
# via cffi
pyproject-hooks==1.0.0
# via # via
# build # fixtures
# poetry # testtools
pywin32-ctypes==0.2.2 ; sys_platform == 'win32' # unittest2
# via keyring testtools==2.3.0
rapidfuzz==3.7.0
# via cleo
requests==2.31.0
# via # via
# cachecontrol # -r requirements.in
# poetry # fixtures
# requests-toolbelt traceback2==1.4.0
requests-toolbelt==1.0.0
# via poetry
secretstorage==3.3.3 ; sys_platform == 'linux'
# via keyring
shellingham==1.5.4
# via poetry
tomlkit==0.12.4
# via poetry
trove-classifiers==2024.3.3
# via poetry
urllib3==2.2.1
# via # via
# dulwich # testtools
# requests # unittest2
virtualenv==20.25.1 unittest2==1.1.0
# via poetry # via testtools
xattr==1.1.0 ; sys_platform == 'darwin'
# via poetry
----- stderr ----- ----- stderr -----
Resolved 41 packages in [TIME] Resolved 10 packages in [TIME]
"###
);
Ok(())
}
/// Perform a universal resolution with a constraint.
#[test]
fn universal_constraint() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str(indoc::indoc! {r"
anyio ; sys_platform == 'win32'
"})?;
let constraints_txt = context.temp_dir.child("constraints.txt");
constraints_txt.write_str(indoc::indoc! {r"
anyio==3.0.0
"})?;
uv_snapshot!(context.filters(), windows_filters=false, context.pip_compile()
.arg("requirements.in")
.arg("-c")
.arg("constraints.txt")
.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 -c constraints.txt --universal
anyio==3.0.0 ; sys_platform == 'win32'
# via
# -c constraints.txt
# -r requirements.in
idna==3.6 ; sys_platform == 'win32'
# via anyio
sniffio==1.3.1 ; sys_platform == 'win32'
# via anyio
----- stderr -----
Resolved 3 packages in [TIME]
"###
);
Ok(())
}
/// Perform a universal resolution with a constraint, where the constraint itself has a marker.
#[test]
fn universal_constraint_marker() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str(indoc::indoc! {r"
anyio ; sys_platform == 'win32'
"})?;
let constraints_txt = context.temp_dir.child("constraints.txt");
constraints_txt.write_str(indoc::indoc! {r"
anyio==3.0.0 ; os_name == 'nt'
"})?;
uv_snapshot!(context.filters(), windows_filters=false, context.pip_compile()
.arg("requirements.in")
.arg("-c")
.arg("constraints.txt")
.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 -c constraints.txt --universal
anyio==3.0.0 ; sys_platform == 'win32' or (os_name == 'nt' and sys_platform == 'win32')
# via
# -c constraints.txt
# -r requirements.in
idna==3.6 ; sys_platform == 'win32' or (os_name == 'nt' and sys_platform == 'win32')
# via anyio
sniffio==1.3.1 ; sys_platform == 'win32' or (os_name == 'nt' and sys_platform == 'win32')
# via anyio
----- stderr -----
Resolved 3 packages in [TIME]
"###
);
Ok(())
}
/// Perform a universal resolution with a divergent requirement, and a third requirement that's
/// compatible with both forks.
///
/// This currently fails, but should succeed.
///
/// See: <https://github.com/astral-sh/uv/issues/4640>
#[test]
fn universal_multi_version() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str(indoc::indoc! {r"
iniconfig
iniconfig==2.0.0 ; python_version > '3.12'
iniconfig==1.0.0 ; python_version == '3.12'
"})?;
let constraints_txt = context.temp_dir.child("constraints.txt");
constraints_txt.write_str(indoc::indoc! {r"
anyio==3.0.0 ; os_name == 'nt'
"})?;
uv_snapshot!(context.filters(), windows_filters=false, context.pip_compile()
.arg("requirements.in")
.arg("-c")
.arg("constraints.txt")
.arg("--universal"), @r###"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because iniconfig{python_version > '3.12'}==2.0.0 depends on iniconfig==2.0.0 and you require iniconfig{python_version > '3.12'}==2.0.0, we can conclude that your requirements and iniconfig{python_version == '3.12'}==1.0.0 are incompatible.
And because you require iniconfig{python_version == '3.12'}==1.0.0, we can conclude that the requirements are unsatisfiable.
"### "###
); );