diff --git a/crates/uv-resolver/src/preferences.rs b/crates/uv-resolver/src/preferences.rs index 7658b1730..116d94d87 100644 --- a/crates/uv-resolver/src/preferences.rs +++ b/crates/uv-resolver/src/preferences.rs @@ -4,7 +4,7 @@ use std::str::FromStr; use rustc_hash::FxHashMap; use tracing::trace; -use uv_distribution_types::IndexUrl; +use uv_distribution_types::{IndexUrl, InstalledDist}; use uv_normalize::PackageName; use uv_pep440::{Operator, Version}; use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl}; @@ -115,6 +115,21 @@ impl Preference { })) } + /// Create a [`Preference`] from an installed distribution. + pub fn from_installed(dist: &InstalledDist) -> Option { + let InstalledDist::Registry(dist) = dist else { + return None; + }; + Some(Self { + name: dist.name.clone(), + version: dist.version.clone(), + marker: MarkerTree::TRUE, + index: PreferenceIndex::Any, + fork_markers: vec![], + hashes: HashDigests::empty(), + }) + } + /// Return the [`PackageName`] of the package for this [`Preference`]. pub fn name(&self) -> &PackageName { &self.name diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index a51f1002a..b72ab1302 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -35,8 +35,8 @@ use uv_python::{ use uv_requirements::upgrade::{read_lock_requirements, LockedRequirements}; use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification}; use uv_resolver::{ - FlatIndex, Lock, OptionsBuilder, PythonRequirement, RequiresPython, ResolverEnvironment, - ResolverOutput, + FlatIndex, Lock, OptionsBuilder, Preference, PythonRequirement, RequiresPython, + ResolverEnvironment, ResolverOutput, }; use uv_scripts::Pep723ItemRef; use uv_settings::PythonInstallMirrors; @@ -1632,27 +1632,42 @@ pub(crate) async fn resolve_names( Ok(requirements) } +#[derive(Debug, Clone)] +pub(crate) enum PreferenceSource<'lock> { + /// The preferences should be extracted from a lockfile. + Lock { + lock: &'lock Lock, + install_path: &'lock Path, + }, + /// The preferences will be provided directly as [`Preference`] entries. + Entries(Vec), +} + #[derive(Debug, Clone)] pub(crate) struct EnvironmentSpecification<'lock> { /// The requirements to include in the environment. requirements: RequirementsSpecification, - /// The lockfile from which to extract preferences, along with the install path. - lock: Option<(&'lock Lock, &'lock Path)>, + /// The preferences to respect when resolving. + preferences: Option>, } impl From for EnvironmentSpecification<'_> { fn from(requirements: RequirementsSpecification) -> Self { Self { requirements, - lock: None, + preferences: None, } } } impl<'lock> EnvironmentSpecification<'lock> { + /// Set the [`PreferenceSource`] for the specification. #[must_use] - pub(crate) fn with_lock(self, lock: Option<(&'lock Lock, &'lock Path)>) -> Self { - Self { lock, ..self } + pub(crate) fn with_preferences(self, preferences: PreferenceSource<'lock>) -> Self { + Self { + preferences: Some(preferences), + ..self + } } } @@ -1765,17 +1780,22 @@ pub(crate) async fn resolve_environment( let upgrade = Upgrade::default(); // If an existing lockfile exists, build up a set of preferences. - let LockedRequirements { preferences, git } = spec - .lock - .map(|(lock, install_path)| read_lock_requirements(lock, install_path, &upgrade)) - .transpose()? - .unwrap_or_default(); + let preferences = match spec.preferences { + Some(PreferenceSource::Lock { lock, install_path }) => { + let LockedRequirements { preferences, git } = + read_lock_requirements(lock, install_path, &upgrade)?; - // Populate the Git resolver. - for ResolvedRepositoryReference { reference, sha } in git { - debug!("Inserting Git reference into resolver: `{reference:?}` at `{sha}`"); - state.git().insert(reference, sha); - } + // Populate the Git resolver. + for ResolvedRepositoryReference { reference, sha } in git { + debug!("Inserting Git reference into resolver: `{reference:?}` at `{sha}`"); + state.git().insert(reference, sha); + } + + preferences + } + Some(PreferenceSource::Entries(entries)) => entries, + None => vec![], + }; // Resolve the flat indexes from `--find-links`. let flat_index = { diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 2b6f89416..97243a64d 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -31,7 +31,7 @@ use uv_python::{ VersionFileDiscoveryOptions, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; -use uv_resolver::{Installable, Lock}; +use uv_resolver::{Installable, Lock, Preference}; use uv_scripts::Pep723Item; use uv_settings::PythonInstallMirrors; use uv_shell::runnable::WindowsRunnable; @@ -49,8 +49,9 @@ use crate::commands::project::lock::LockMode; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ default_dependency_groups, script_specification, update_environment, - validate_project_requires_python, EnvironmentSpecification, ProjectEnvironment, ProjectError, - ScriptEnvironment, ScriptInterpreter, UniversalState, WorkspacePython, + validate_project_requires_python, EnvironmentSpecification, PreferenceSource, + ProjectEnvironment, ProjectError, ScriptEnvironment, ScriptInterpreter, UniversalState, + WorkspacePython, }; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::run::run_to_completion; @@ -898,9 +899,14 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl }; // If necessary, create an environment for the ephemeral requirements or command. + let base_site_packages = SitePackages::from_interpreter(&base_interpreter)?; let ephemeral_env = match spec { None => None, - Some(spec) if can_skip_ephemeral(&spec, &base_interpreter, &settings) => None, + Some(spec) + if can_skip_ephemeral(&spec, &base_interpreter, &base_site_packages, &settings) => + { + None + } Some(spec) => { debug!("Syncing ephemeral requirements"); @@ -909,12 +915,24 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl .as_ref() .map(|(lock, path)| lock.build_constraints(path)); + // Read the preferences. + let spec = EnvironmentSpecification::from(spec).with_preferences( + if let Some((lock, install_path)) = base_lock.as_ref() { + // If we have a lockfile, use the locked versions as preferences. + PreferenceSource::Lock { lock, install_path } + } else { + // Otherwise, extract preferences from the base environment. + PreferenceSource::Entries( + base_site_packages + .iter() + .filter_map(Preference::from_installed) + .collect::>(), + ) + }, + ); + let result = CachedEnvironment::from_spec( - EnvironmentSpecification::from(spec).with_lock( - base_lock - .as_ref() - .map(|(lock, install_path)| (lock, install_path.as_ref())), - ), + spec, build_constraints.unwrap_or_default(), &base_interpreter, &settings, @@ -1115,13 +1133,10 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl /// Returns `true` if we can skip creating an additional ephemeral environment in `uv run`. fn can_skip_ephemeral( spec: &RequirementsSpecification, - base_interpreter: &Interpreter, + interpreter: &Interpreter, + site_packages: &SitePackages, settings: &ResolverInstallerSettings, ) -> bool { - let Ok(site_packages) = SitePackages::from_interpreter(base_interpreter) else { - return false; - }; - if !(settings.reinstall.is_none() && settings.reinstall.is_none()) { return false; } @@ -1130,7 +1145,7 @@ fn can_skip_ephemeral( &spec.requirements, &spec.constraints, &spec.overrides, - &base_interpreter.resolver_marker_environment(), + &interpreter.resolver_marker_environment(), ) { // If the requirements are already satisfied, we're done. Ok(SatisfiesResult::Fresh { diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 87957f480..26ed860b2 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -5078,3 +5078,59 @@ fn run_pep723_script_with_constraints_lock() -> Result<()> { Ok(()) } + +/// If a `--with` requirement overlaps with a non-locked script requirement, respect the environment +/// site-packages as preferences. +/// +/// See: +#[test] +fn run_pep723_script_with_constraints() -> Result<()> { + let context = TestContext::new("3.12"); + + let test_script = context.temp_dir.child("main.py"); + test_script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "iniconfig<2", + # ] + # /// + + import iniconfig + + print("Hello, world!") + "# + })?; + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.10" + dependencies = [ + "iniconfig", + ] + "# + })?; + + uv_snapshot!(context.filters(), context.run().arg("--with").arg(".").arg("main.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Hello, world! + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==1.1.1 + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 2 packages in [TIME] + + foo==1.0.0 (from file://[TEMP_DIR]/) + + iniconfig==1.1.1 + "); + + Ok(()) +}