Add support for disabling installation from pre-built wheels (#956)

Adds support for disabling installation from pre-built wheels i.e. the
package must be built from source locally.
We will still always use pre-built wheels for metadata during
resolution.

Available via `--no-binary` and `--no-binary-package <name>` flags in
`pip install` and `pip sync`. There is no flag for `pip compile` since
no installation happens there.

```
--no-binary

    Don't install pre-built wheels.
    
    When enabled, all installed packages will be installed from a source distribution. 
    The resolver will still use pre-built wheels for metadata.


--no-binary-package <NO_BINARY_PACKAGE>

    Don't install pre-built wheels for a specific package.
    
    When enabled, the specified packages will be installed from a source distribution. 
    The resolver will still use pre-built wheels for metadata.
```

When packages are already installed, the `--no-binary` flag will have no
affect without the `--reinstall` flag. In the future, I'd like to change
this by tracking if a local distribution is from a pre-built wheel or a
locally-built wheel. However, this is significantly more complex and
different than `pip`'s behavior so deferring for now.

For reference, `pip`'s flag works as follows:

```
--no-binary <format_control>

    Do not use binary packages. Can be supplied multiple times, and each time adds to the
    existing value. Accepts either ":all:" to disable all binary packages, ":none:" to empty the
    set (notice the colons), or one or more package names with commas between them (no colons).
    Note that some packages are tricky to compile and may fail to install when this option is
    used on them.
```

Note we are not matching the exact `pip` interface here because it seems
complicated to use. I think we may want to consider adjusting our
interface for this behavior since we're not entirely compatible anyway
e.g. I think `--force-build` and `--force-build-package` are clearer
names. We could also consider matching the `pip` interface or only
allowing `--no-binary <package>` for compatibility. We can of course do
whatever we want in our _own_ install interfaces later.

Additionally, we may want to further consider the semantics of
`--no-binary`. For example, if I run `pip install pydantic --no-binary`
I expect _just_ Pydantic to be installed without binaries but by default
we will build all of Pydantic's dependencies too.

This work was prompted by #895, as it is much easier to measure
performance gains from building source distributions if we have a flag
to ensure we actually build source distributions. Additionally, this is
a flag I have used frequently in production to debug packages that ship
Cythonized wheels.
This commit is contained in:
Zanie Blue 2024-01-19 11:24:27 -06:00 committed by GitHub
parent 8b49d900bd
commit 33b35f7020
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 482 additions and 49 deletions

View file

@ -4,6 +4,7 @@
use anyhow::Result;
use futures::{stream, Stream, StreamExt, TryStreamExt};
use puffin_traits::NoBinary;
use rustc_hash::FxHashMap;
use distribution_filename::DistFilename;
@ -22,6 +23,7 @@ pub struct DistFinder<'a> {
reporter: Option<Box<dyn Reporter>>,
interpreter: &'a Interpreter,
flat_index: &'a FlatIndex,
no_binary: &'a NoBinary,
}
impl<'a> DistFinder<'a> {
@ -31,6 +33,7 @@ impl<'a> DistFinder<'a> {
client: &'a RegistryClient,
interpreter: &'a Interpreter,
flat_index: &'a FlatIndex,
no_binary: &'a NoBinary,
) -> Self {
Self {
tags,
@ -38,6 +41,7 @@ impl<'a> DistFinder<'a> {
reporter: None,
interpreter,
flat_index,
no_binary,
}
}
@ -112,7 +116,10 @@ impl<'a> DistFinder<'a> {
Ok(Resolution::new(resolution))
}
/// select a version that satisfies the requirement, preferring wheels to source distributions.
/// Select a version that satisfies the requirement.
///
/// Wheels are preferred to source distributions unless `no_binary` excludes wheels
/// for the requirement.
fn select(
&self,
requirement: &Requirement,
@ -120,6 +127,12 @@ impl<'a> DistFinder<'a> {
index: &IndexUrl,
flat_index: Option<&FlatDistributions>,
) -> Option<Dist> {
let no_binary = match self.no_binary {
NoBinary::None => false,
NoBinary::All => true,
NoBinary::Packages(packages) => packages.contains(&requirement.name),
};
// Prioritize the flat index by initializing the "best" matches with its entries.
let matching_override = if let Some(flat_index) = flat_index {
match &requirement.version_or_url {
@ -159,36 +172,38 @@ impl<'a> DistFinder<'a> {
continue;
}
// Find the most-compatible wheel
for (wheel, file) in files.wheels {
// Only add dists compatible with the python version.
// This is relevant for source dists which give no other indication of their
// compatibility and wheels which may be tagged `py3-none-any` but
// have `requires-python: ">=3.9"`
if !file
.requires_python
.as_ref()
.map_or(true, |requires_python| {
requires_python.contains(self.interpreter.version())
})
{
continue;
}
best_version = Some(version.clone());
if let Some(priority) = wheel.compatibility(self.tags) {
if best_wheel
if !no_binary {
// Find the most-compatible wheel
for (wheel, file) in files.wheels {
// Only add dists compatible with the python version.
// This is relevant for source dists which give no other indication of their
// compatibility and wheels which may be tagged `py3-none-any` but
// have `requires-python: ">=3.9"`
if !file
.requires_python
.as_ref()
.map_or(true, |(.., existing)| priority > *existing)
.map_or(true, |requires_python| {
requires_python.contains(self.interpreter.version())
})
{
best_wheel = Some((
Dist::from_registry(
DistFilename::WheelFilename(wheel),
file,
index.clone(),
),
priority,
));
continue;
}
best_version = Some(version.clone());
if let Some(priority) = wheel.compatibility(self.tags) {
if best_wheel
.as_ref()
.map_or(true, |(.., existing)| priority > *existing)
{
best_wheel = Some((
Dist::from_registry(
DistFilename::WheelFilename(wheel),
file,
index.clone(),
),
priority,
));
}
}
}
}

View file

@ -101,6 +101,7 @@ impl<'a, Context: BuildContext + Send + Sync> Resolver<'a, DefaultResolverProvid
.iter()
.chain(manifest.constraints.iter())
.collect(),
build_context.no_binary(),
);
Self::new_custom_io(
manifest,

View file

@ -10,7 +10,7 @@ use platform_tags::Tags;
use puffin_client::{FlatIndex, RegistryClient};
use puffin_distribution::{DistributionDatabase, DistributionDatabaseError};
use puffin_normalize::PackageName;
use puffin_traits::BuildContext;
use puffin_traits::{BuildContext, NoBinary};
use pypi_types::Metadata21;
use crate::python_requirement::PythonRequirement;
@ -55,10 +55,12 @@ pub struct DefaultResolverProvider<'a, Context: BuildContext + Send + Sync> {
python_requirement: PythonRequirement<'a>,
exclude_newer: Option<DateTime<Utc>>,
allowed_yanks: AllowedYanks,
no_binary: &'a NoBinary,
}
impl<'a, Context: BuildContext + Send + Sync> DefaultResolverProvider<'a, Context> {
/// Reads the flat index entries and builds the provider.
#[allow(clippy::too_many_arguments)]
pub fn new(
client: &'a RegistryClient,
fetcher: DistributionDatabase<'a, Context>,
@ -67,6 +69,7 @@ impl<'a, Context: BuildContext + Send + Sync> DefaultResolverProvider<'a, Contex
python_requirement: PythonRequirement<'a>,
exclude_newer: Option<DateTime<Utc>>,
allowed_yanks: AllowedYanks,
no_binary: &'a NoBinary,
) -> Self {
Self {
client,
@ -76,6 +79,7 @@ impl<'a, Context: BuildContext + Send + Sync> DefaultResolverProvider<'a, Contex
python_requirement,
exclude_newer,
allowed_yanks,
no_binary,
}
}
}
@ -99,6 +103,7 @@ impl<'a, Context: BuildContext + Send + Sync> ResolverProvider
&self.allowed_yanks,
self.exclude_newer.as_ref(),
self.flat_index.get(package_name).cloned(),
self.no_binary,
)),
Err(
err @ (puffin_client::Error::PackageNotFound(_)

View file

@ -10,6 +10,7 @@ use pep440_rs::Version;
use platform_tags::Tags;
use puffin_client::{FlatDistributions, SimpleMetadata};
use puffin_normalize::PackageName;
use puffin_traits::NoBinary;
use puffin_warnings::warn_user_once;
use pypi_types::{Hashes, Yanked};
@ -33,11 +34,19 @@ impl VersionMap {
allowed_yanks: &AllowedYanks,
exclude_newer: Option<&DateTime<Utc>>,
flat_index: Option<FlatDistributions>,
no_binary: &NoBinary,
) -> Self {
// If we have packages of the same name from find links, gives them priority, otherwise start empty
let mut version_map: BTreeMap<Version, PrioritizedDistribution> =
flat_index.map(Into::into).unwrap_or_default();
// Check if binaries are allowed for this package
let no_binary = match no_binary {
NoBinary::None => false,
NoBinary::All => true,
NoBinary::Packages(packages) => packages.contains(package_name),
};
// Collect compatible distributions.
for (version, files) in metadata {
for (filename, file) in files.all() {
@ -73,6 +82,11 @@ impl VersionMap {
let hash = file.hashes.clone();
match filename {
DistFilename::WheelFilename(filename) => {
// If pre-built binaries are disabled, skip this wheel
if no_binary {
continue;
};
// To be compatible, the wheel must both have compatible tags _and_ have a
// compatible Python requirement.
let priority = filename.compatibility(tags).filter(|_| {

View file

@ -21,7 +21,7 @@ use puffin_resolver::{
DisplayResolutionGraph, InMemoryIndex, Manifest, PreReleaseMode, ResolutionGraph,
ResolutionMode, ResolutionOptions, Resolver,
};
use puffin_traits::{BuildContext, BuildKind, SetupPyStrategy, SourceBuildTrait};
use puffin_traits::{BuildContext, BuildKind, NoBinary, SetupPyStrategy, SourceBuildTrait};
// Exclude any packages uploaded after this date.
static EXCLUDE_NEWER: Lazy<DateTime<Utc>> = Lazy::new(|| {
@ -54,6 +54,10 @@ impl BuildContext for DummyContext {
false
}
fn no_binary(&self) -> &NoBinary {
&NoBinary::None
}
fn setup_py_strategy(&self) -> SetupPyStrategy {
SetupPyStrategy::default()
}