diff --git a/crates/puffin-resolver/src/dependency_mode.rs b/crates/puffin-resolver/src/dependency_mode.rs new file mode 100644 index 000000000..b107e25da --- /dev/null +++ b/crates/puffin-resolver/src/dependency_mode.rs @@ -0,0 +1,20 @@ +#[derive(Debug, Default, Clone, Copy)] +pub enum DependencyMode { + /// Include all dependencies, whether direct or transitive. + #[default] + Transitive, + /// Exclude transitive dependencies, only resolving the root package's immediate dependencies. + Direct, +} + +impl DependencyMode { + /// Returns `true` if transitive dependencies should be included. + pub fn is_transitive(self) -> bool { + matches!(self, Self::Transitive) + } + + /// Returns `true` if (only) direct dependencies should be excluded. + pub fn is_direct(self) -> bool { + matches!(self, Self::Direct) + } +} diff --git a/crates/puffin-resolver/src/lib.rs b/crates/puffin-resolver/src/lib.rs index 778da5b3a..332d5073d 100644 --- a/crates/puffin-resolver/src/lib.rs +++ b/crates/puffin-resolver/src/lib.rs @@ -1,3 +1,4 @@ +pub use dependency_mode::DependencyMode; pub use error::ResolveError; pub use finder::{DistFinder, Reporter as FinderReporter}; pub use manifest::Manifest; @@ -10,6 +11,7 @@ pub use resolver::{ }; mod candidate_selector; +mod dependency_mode; mod error; mod finder; mod manifest; diff --git a/crates/puffin-resolver/src/options.rs b/crates/puffin-resolver/src/options.rs index 06a5c0eac..55e6e5f07 100644 --- a/crates/puffin-resolver/src/options.rs +++ b/crates/puffin-resolver/src/options.rs @@ -1,12 +1,13 @@ use chrono::{DateTime, Utc}; -use crate::{PreReleaseMode, ResolutionMode}; +use crate::{DependencyMode, PreReleaseMode, ResolutionMode}; /// Options for resolving a manifest. #[derive(Debug, Default, Copy, Clone)] pub struct Options { pub resolution_mode: ResolutionMode, pub prerelease_mode: PreReleaseMode, + pub dependency_mode: DependencyMode, pub exclude_newer: Option>, } @@ -15,6 +16,7 @@ pub struct Options { pub struct OptionsBuilder { resolution_mode: ResolutionMode, prerelease_mode: PreReleaseMode, + dependency_mode: DependencyMode, exclude_newer: Option>, } @@ -38,6 +40,13 @@ impl OptionsBuilder { self } + /// Sets the dependency mode. + #[must_use] + pub fn dependency_mode(mut self, dependency_mode: DependencyMode) -> Self { + self.dependency_mode = dependency_mode; + self + } + /// Sets the exclusion date. #[must_use] pub fn exclude_newer(mut self, exclude_newer: Option>) -> Self { @@ -50,6 +59,7 @@ impl OptionsBuilder { Options { resolution_mode: self.resolution_mode, prerelease_mode: self.prerelease_mode, + dependency_mode: self.dependency_mode, exclude_newer: self.exclude_newer, } } diff --git a/crates/puffin-resolver/src/resolver/mod.rs b/crates/puffin-resolver/src/resolver/mod.rs index e90896b6f..09b2d8cd1 100644 --- a/crates/puffin-resolver/src/resolver/mod.rs +++ b/crates/puffin-resolver/src/resolver/mod.rs @@ -50,7 +50,7 @@ pub use crate::resolver::provider::ResolverProvider; use crate::resolver::reporter::Facade; pub use crate::resolver::reporter::{BuildId, Reporter}; use crate::version_map::VersionMap; -use crate::Options; +use crate::{DependencyMode, Options}; mod allowed_urls; mod index; @@ -63,6 +63,7 @@ pub struct Resolver<'a, Provider: ResolverProvider> { constraints: Vec, overrides: Overrides, allowed_urls: AllowedUrls, + dependency_mode: DependencyMode, markers: &'a MarkerEnvironment, python_requirement: PythonRequirement, selector: CandidateSelector, @@ -173,6 +174,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { visited: DashSet::default(), selector, allowed_urls, + dependency_mode: options.dependency_mode, project: manifest.project, requirements: manifest.requirements, constraints: manifest.constraints, @@ -644,6 +646,11 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { } PubGrubPackage::Package(package_name, extra, url) => { + // If we're excluding transitive dependencies, short-circuit. + if self.dependency_mode.is_direct() { + return Ok(Dependencies::Available(DependencyConstraints::default())); + } + // Wait for the metadata to be available. let dist = match url { Some(url) => PubGrubDistribution::from_url(package_name, url), diff --git a/crates/puffin/src/commands/pip_install.rs b/crates/puffin/src/commands/pip_install.rs index 4e2f22544..795cd3e85 100644 --- a/crates/puffin/src/commands/pip_install.rs +++ b/crates/puffin/src/commands/pip_install.rs @@ -26,8 +26,8 @@ use puffin_installer::{ use puffin_interpreter::{Interpreter, Virtualenv}; use puffin_normalize::PackageName; use puffin_resolver::{ - InMemoryIndex, Manifest, Options, OptionsBuilder, PreReleaseMode, ResolutionGraph, - ResolutionMode, Resolver, + DependencyMode, InMemoryIndex, Manifest, Options, OptionsBuilder, PreReleaseMode, + ResolutionGraph, ResolutionMode, Resolver, }; use puffin_traits::{InFlight, SetupPyStrategy}; use requirements_txt::EditableRequirement; @@ -46,6 +46,7 @@ pub(crate) async fn pip_install( extras: &ExtrasSpecification<'_>, resolution_mode: ResolutionMode, prerelease_mode: PreReleaseMode, + dependency_mode: DependencyMode, index_locations: IndexLocations, reinstall: &Reinstall, link_mode: LinkMode, @@ -154,6 +155,7 @@ pub(crate) async fn pip_install( let options = OptionsBuilder::new() .resolution_mode(resolution_mode) .prerelease_mode(prerelease_mode) + .dependency_mode(dependency_mode) .exclude_newer(exclude_newer) .build(); diff --git a/crates/puffin/src/main.rs b/crates/puffin/src/main.rs index ec56497c8..363a55c17 100644 --- a/crates/puffin/src/main.rs +++ b/crates/puffin/src/main.rs @@ -15,7 +15,7 @@ use puffin_cache::{Cache, CacheArgs, Refresh}; use puffin_installer::{NoBinary, Reinstall}; use puffin_interpreter::PythonVersion; use puffin_normalize::{ExtraName, PackageName}; -use puffin_resolver::{PreReleaseMode, ResolutionMode}; +use puffin_resolver::{DependencyMode, PreReleaseMode, ResolutionMode}; use puffin_traits::SetupPyStrategy; use requirements::ExtrasSpecification; @@ -445,6 +445,11 @@ struct PipInstallArgs { #[clap(long)] refresh_package: Vec, + /// Ignore package dependencies, instead only installing those packages explicitly listed + /// on the command line or in the requirements files. + #[clap(long)] + no_deps: bool, + /// The method to use when installing packages from the global cache. #[clap(long, value_enum, default_value_t = install_wheel_rs::linker::LinkMode::default())] link_mode: install_wheel_rs::linker::LinkMode, @@ -802,6 +807,11 @@ async fn run() -> Result { }; let reinstall = Reinstall::from_args(args.reinstall, args.reinstall_package); let no_binary = NoBinary::from_args(args.no_binary, args.no_binary_package); + let dependency_mode = if args.no_deps { + DependencyMode::Direct + } else { + DependencyMode::Transitive + }; commands::pip_install( &requirements, &constraints, @@ -809,6 +819,7 @@ async fn run() -> Result { &extras, args.resolution, args.prerelease, + dependency_mode, index_urls, &reinstall, args.link_mode, diff --git a/crates/puffin/tests/pip_install.rs b/crates/puffin/tests/pip_install.rs index c0aab3f8e..f675f21b3 100644 --- a/crates/puffin/tests/pip_install.rs +++ b/crates/puffin/tests/pip_install.rs @@ -1099,3 +1099,48 @@ fn install_executable_hardlink() -> Result<()> { Ok(()) } + +/// Install a package from the command line into a virtual environment, ignoring its dependencies. +#[test] +fn no_deps() -> Result<()> { + let temp_dir = assert_fs::TempDir::new()?; + let cache_dir = assert_fs::TempDir::new()?; + let venv = create_venv_py312(&temp_dir, &cache_dir); + + // Install Flask. + insta::with_settings!({ + filters => INSTA_FILTERS.to_vec() + }, { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .arg("pip") + .arg("install") + .arg("Flask") + .arg("--no-deps") + .arg("--strict") + .arg("--cache-dir") + .arg(cache_dir.path()) + .arg("--exclude-newer") + .arg(EXCLUDE_NEWER) + .env("VIRTUAL_ENV", venv.as_os_str()) + .current_dir(&temp_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Downloaded 1 package in [TIME] + Installed 1 package in [TIME] + + flask==3.0.0 + warning: The package `flask` requires `werkzeug >=3.0.0`, but it's not installed. + warning: The package `flask` requires `jinja2 >=3.1.2`, but it's not installed. + warning: The package `flask` requires `itsdangerous >=2.1.2`, but it's not installed. + warning: The package `flask` requires `click >=8.1.3`, but it's not installed. + warning: The package `flask` requires `blinker >=1.6.2`, but it's not installed. + "###); + }); + + assert_command(&venv, "import flask", &temp_dir).failure(); + + Ok(()) +}