From 52bdee2e85e02e7d2b52769a09f3761678b443c1 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 6 Jun 2024 16:15:28 -0400 Subject: [PATCH] Add support for `--prefix` (#4085) ## Summary Closes #3076. --- crates/uv-interpreter/src/environment.rs | 52 +++++++++++------- crates/uv-interpreter/src/interpreter.rs | 47 ++++++++++++++-- crates/uv-interpreter/src/lib.rs | 2 + crates/uv-interpreter/src/prefix.rs | 43 +++++++++++++++ crates/uv-interpreter/src/target.rs | 5 ++ crates/uv-workspace/src/combine.rs | 1 + crates/uv-workspace/src/settings.rs | 1 + crates/uv/src/cli.rs | 39 +++++++++++--- crates/uv/src/commands/mod.rs | 2 +- crates/uv/src/commands/pip/install.rs | 12 ++++- crates/uv/src/commands/pip/sync.rs | 12 ++++- crates/uv/src/commands/pip/uninstall.rs | 12 ++++- crates/uv/src/main.rs | 3 ++ crates/uv/src/settings.rs | 12 ++++- crates/uv/tests/common/mod.rs | 6 +-- crates/uv/tests/pip_sync.rs | 68 +++++++++++++++++++++++- uv.schema.json | 6 +++ 17 files changed, 280 insertions(+), 43 deletions(-) create mode 100644 crates/uv-interpreter/src/prefix.rs diff --git a/crates/uv-interpreter/src/environment.rs b/crates/uv-interpreter/src/environment.rs index 8b5009dab18b..71b8ed0233d6 100644 --- a/crates/uv-interpreter/src/environment.rs +++ b/crates/uv-interpreter/src/environment.rs @@ -1,18 +1,17 @@ -use itertools::Either; +use std::borrow::Cow; use std::env; use std::path::{Path, PathBuf}; use std::sync::Arc; -use uv_configuration::PreviewMode; - -use same_file::is_same_file; use uv_cache::Cache; +use uv_configuration::PreviewMode; use uv_fs::{LockedFile, Simplified}; use crate::discovery::{InterpreterRequest, SourceSelector, SystemPython}; use crate::virtualenv::{virtualenv_python_executable, PyVenvConfiguration}; use crate::{ - find_default_interpreter, find_interpreter, Error, Interpreter, InterpreterSource, Target, + find_default_interpreter, find_interpreter, Error, Interpreter, InterpreterSource, Prefix, + Target, }; /// A Python environment, consisting of a Python [`Interpreter`] and its associated paths. @@ -159,6 +158,16 @@ impl PythonEnvironment { })) } + /// Create a [`PythonEnvironment`] from an existing [`Interpreter`] and `--prefix` directory. + #[must_use] + pub fn with_prefix(self, prefix: Prefix) -> Self { + let inner = Arc::unwrap_or_clone(self.0); + Self(Arc::new(PythonEnvironmentShared { + interpreter: inner.interpreter.with_prefix(prefix), + ..inner + })) + } + /// Returns the root (i.e., `prefix`) of the Python interpreter. pub fn root(&self) -> &Path { &self.0.root @@ -189,20 +198,27 @@ impl PythonEnvironment { /// /// Some distributions also create symbolic links from `purelib` to `platlib`; in such cases, we /// still deduplicate the entries, returning a single path. - pub fn site_packages(&self) -> impl Iterator { - if let Some(target) = self.0.interpreter.target() { - Either::Left(std::iter::once(target.root())) + pub fn site_packages(&self) -> impl Iterator> { + let target = self.0.interpreter.target().map(Target::site_packages); + + let prefix = self + .0 + .interpreter + .prefix() + .map(|prefix| prefix.site_packages(self.0.interpreter.virtualenv())); + + let interpreter = if target.is_none() && prefix.is_none() { + Some(self.0.interpreter.site_packages()) } else { - let purelib = self.0.interpreter.purelib(); - let platlib = self.0.interpreter.platlib(); - Either::Right(std::iter::once(purelib).chain( - if purelib == platlib || is_same_file(purelib, platlib).unwrap_or(false) { - None - } else { - Some(platlib) - }, - )) - } + None + }; + + target + .into_iter() + .flatten() + .map(Cow::Borrowed) + .chain(prefix.into_iter().flatten().map(Cow::Owned)) + .chain(interpreter.into_iter().flatten().map(Cow::Borrowed)) } /// Returns the path to the `bin` directory inside this environment. diff --git a/crates/uv-interpreter/src/interpreter.rs b/crates/uv-interpreter/src/interpreter.rs index 696ae3e26e81..d88dd457369a 100644 --- a/crates/uv-interpreter/src/interpreter.rs +++ b/crates/uv-interpreter/src/interpreter.rs @@ -5,6 +5,7 @@ use std::process::{Command, ExitStatus}; use configparser::ini::Ini; use fs_err as fs; use once_cell::sync::OnceCell; +use same_file::is_same_file; use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{trace, warn}; @@ -20,7 +21,7 @@ use uv_cache::{Cache, CacheBucket, CachedByTimestamp, Freshness, Timestamp}; use uv_fs::{write_atomic_sync, PythonExt, Simplified}; use crate::pointer_size::PointerSize; -use crate::{PythonVersion, Target, VirtualEnvironment}; +use crate::{Prefix, PythonVersion, Target, VirtualEnvironment}; /// A Python executable and its associated platform markers. #[derive(Debug, Clone)] @@ -38,6 +39,7 @@ pub struct Interpreter { stdlib: PathBuf, tags: OnceCell, target: Option, + prefix: Option, pointer_size: PointerSize, gil_disabled: bool, } @@ -69,6 +71,7 @@ impl Interpreter { stdlib: info.stdlib, tags: OnceCell::new(), target: None, + prefix: None, }) } @@ -100,6 +103,7 @@ impl Interpreter { stdlib: PathBuf::from("/dev/null"), tags: OnceCell::new(), target: None, + prefix: None, pointer_size: PointerSize::_64, gil_disabled: false, } @@ -113,13 +117,12 @@ impl Interpreter { sys_executable: virtualenv.executable, sys_prefix: virtualenv.root, target: None, + prefix: None, ..self } } /// Return a new [`Interpreter`] to install into the given `--target` directory. - /// - /// Initializes the `--target` directory with the expected layout. #[must_use] pub fn with_target(self, target: Target) -> Self { Self { @@ -128,6 +131,15 @@ impl Interpreter { } } + /// Return a new [`Interpreter`] to install into the given `--prefix` directory. + #[must_use] + pub fn with_prefix(self, prefix: Prefix) -> Self { + Self { + prefix: Some(prefix), + ..self + } + } + /// Returns the path to the Python virtual environment. #[inline] pub fn platform(&self) -> &Platform { @@ -166,6 +178,11 @@ impl Interpreter { self.target.is_some() } + /// Returns `true` if the environment is a `--prefix` environment. + pub fn is_prefix(&self) -> bool { + self.prefix.is_some() + } + /// Returns `Some` if the environment is externally managed, optionally including an error /// message from the `EXTERNALLY-MANAGED` file. /// @@ -176,8 +193,8 @@ impl Interpreter { return None; } - // If we're installing into a target directory, it's never externally managed. - if self.is_target() { + // If we're installing into a target or prefix directory, it's never externally managed. + if self.is_target() || self.is_prefix() { return None; } @@ -357,6 +374,11 @@ impl Interpreter { self.target.as_ref() } + /// Return the `--prefix` directory for this interpreter, if any. + pub fn prefix(&self) -> Option<&Prefix> { + self.prefix.as_ref() + } + /// Return the [`Layout`] environment used to install wheels into this interpreter. pub fn layout(&self) -> Layout { Layout { @@ -365,6 +387,8 @@ impl Interpreter { os_name: self.markers.os_name().to_string(), scheme: if let Some(target) = self.target.as_ref() { target.scheme() + } else if let Some(prefix) = self.prefix.as_ref() { + prefix.scheme(&self.virtualenv) } else { Scheme { purelib: self.purelib().to_path_buf(), @@ -387,6 +411,19 @@ impl Interpreter { } } + /// Return an iterator over the `site-packages` directories inside the environment. + pub fn site_packages(&self) -> impl Iterator { + let purelib = self.purelib(); + let platlib = self.platlib(); + std::iter::once(purelib).chain( + if purelib == platlib || is_same_file(purelib, platlib).unwrap_or(false) { + None + } else { + Some(platlib) + }, + ) + } + /// Check if the interpreter matches the given Python version. /// /// If a patch version is present, we will require an exact match. diff --git a/crates/uv-interpreter/src/lib.rs b/crates/uv-interpreter/src/lib.rs index f63e438b4922..1958dcab81fa 100644 --- a/crates/uv-interpreter/src/lib.rs +++ b/crates/uv-interpreter/src/lib.rs @@ -9,6 +9,7 @@ pub use crate::discovery::{ pub use crate::environment::PythonEnvironment; pub use crate::interpreter::Interpreter; pub use crate::pointer_size::PointerSize; +pub use crate::prefix::Prefix; pub use crate::python_version::PythonVersion; pub use crate::target::Target; pub use crate::virtualenv::{Error as VirtualEnvError, PyVenvConfiguration, VirtualEnvironment}; @@ -21,6 +22,7 @@ mod interpreter; pub mod managed; pub mod platform; mod pointer_size; +mod prefix; mod py_launcher; mod python_version; mod target; diff --git a/crates/uv-interpreter/src/prefix.rs b/crates/uv-interpreter/src/prefix.rs new file mode 100644 index 000000000000..4d678fdd666d --- /dev/null +++ b/crates/uv-interpreter/src/prefix.rs @@ -0,0 +1,43 @@ +use std::path::{Path, PathBuf}; + +use pypi_types::Scheme; + +/// A `--prefix` directory into which packages can be installed, separate from a virtual environment +/// or system Python interpreter. +#[derive(Debug, Clone)] +pub struct Prefix(PathBuf); + +impl Prefix { + /// Return the [`Scheme`] for the `--prefix` directory. + pub fn scheme(&self, virtualenv: &Scheme) -> Scheme { + Scheme { + purelib: self.0.join(&virtualenv.purelib), + platlib: self.0.join(&virtualenv.platlib), + scripts: self.0.join(&virtualenv.scripts), + data: self.0.join(&virtualenv.data), + include: self.0.join(&virtualenv.include), + } + } + + /// Return an iterator over the `site-packages` directories inside the environment. + pub fn site_packages(&self, virtualenv: &Scheme) -> impl Iterator { + std::iter::once(self.0.join(&virtualenv.purelib)) + } + + /// Initialize the `--prefix` directory. + pub fn init(&self) -> std::io::Result<()> { + fs_err::create_dir_all(&self.0)?; + Ok(()) + } + + /// Return the path to the `--prefix` directory. + pub fn root(&self) -> &Path { + &self.0 + } +} + +impl From for Prefix { + fn from(path: PathBuf) -> Self { + Self(path) + } +} diff --git a/crates/uv-interpreter/src/target.rs b/crates/uv-interpreter/src/target.rs index e6519c91ee78..4e6156007995 100644 --- a/crates/uv-interpreter/src/target.rs +++ b/crates/uv-interpreter/src/target.rs @@ -19,6 +19,11 @@ impl Target { } } + /// Return an iterator over the `site-packages` directories inside the environment. + pub fn site_packages(&self) -> impl Iterator { + std::iter::once(self.0.as_path()) + } + /// Initialize the `--target` directory. pub fn init(&self) -> std::io::Result<()> { fs_err::create_dir_all(&self.0)?; diff --git a/crates/uv-workspace/src/combine.rs b/crates/uv-workspace/src/combine.rs index e9d56eafffd0..3c357f2731d0 100644 --- a/crates/uv-workspace/src/combine.rs +++ b/crates/uv-workspace/src/combine.rs @@ -72,6 +72,7 @@ impl Combine for PipOptions { .break_system_packages .combine(other.break_system_packages), target: self.target.combine(other.target), + prefix: self.prefix.combine(other.prefix), index_url: self.index_url.combine(other.index_url), extra_index_url: self.extra_index_url.combine(other.extra_index_url), no_index: self.no_index.combine(other.no_index), diff --git a/crates/uv-workspace/src/settings.rs b/crates/uv-workspace/src/settings.rs index 2f83e5e05e09..9316efc18b14 100644 --- a/crates/uv-workspace/src/settings.rs +++ b/crates/uv-workspace/src/settings.rs @@ -58,6 +58,7 @@ pub struct PipOptions { pub system: Option, pub break_system_packages: Option, pub target: Option, + pub prefix: Option, pub index_url: Option, pub extra_index_url: Option>, pub no_index: Option, diff --git a/crates/uv/src/cli.rs b/crates/uv/src/cli.rs index 705f584fc4c1..8a911909c7f9 100644 --- a/crates/uv/src/cli.rs +++ b/crates/uv/src/cli.rs @@ -731,10 +731,21 @@ pub(crate) struct PipSyncArgs { pub(crate) no_break_system_packages: bool, /// Install packages into the specified directory, rather than into the virtual environment - /// or system Python interpreter. - #[arg(long)] + /// or system Python interpreter. The packages will be installed at the top-level of the + /// directory + #[arg(long, conflicts_with = "prefix")] pub(crate) target: Option, + /// Install packages into `lib`, `bin`, and other top-level folders under the specified + /// directory, as if a virtual environment were created at the specified location. + /// + /// In general, prefer the use of `--python` to install into an alternate environment, as + /// scripts and other artifacts installed via `--prefix` will reference the installing + /// interpreter, rather than any interpreter added to the `--prefix` directory, rendering them + /// non-portable. + #[arg(long, conflicts_with = "target")] + pub(crate) prefix: Option, + /// Use legacy `setuptools` behavior when building source distributions without a /// `pyproject.toml`. #[arg(long, overrides_with("no_legacy_setup_py"))] @@ -1087,10 +1098,21 @@ pub(crate) struct PipInstallArgs { pub(crate) no_break_system_packages: bool, /// Install packages into the specified directory, rather than into the virtual environment - /// or system Python interpreter. - #[arg(long)] + /// or system Python interpreter. The packages will be installed at the top-level of the + /// directory + #[arg(long, conflicts_with = "prefix")] pub(crate) target: Option, + /// Install packages into `lib`, `bin`, and other top-level folders under the specified + /// directory, as if a virtual environment were created at the specified location. + /// + /// In general, prefer the use of `--python` to install into an alternate environment, as + /// scripts and other artifacts installed via `--prefix` will reference the installing + /// interpreter, rather than any interpreter added to the `--prefix` directory, rendering them + /// non-portable. + #[arg(long, conflicts_with = "target")] + pub(crate) prefix: Option, + /// Use legacy `setuptools` behavior when building source distributions without a /// `pyproject.toml`. #[arg(long, overrides_with("no_legacy_setup_py"))] @@ -1300,10 +1322,13 @@ pub(crate) struct PipUninstallArgs { #[arg(long, overrides_with("break_system_packages"))] pub(crate) no_break_system_packages: bool, - /// Uninstall packages from the specified directory, rather than from the virtual environment - /// or system Python interpreter. - #[arg(long)] + /// Uninstall packages from the specified `--target` directory. + #[arg(long, conflicts_with = "prefix")] pub(crate) target: Option, + + /// Uninstall packages from the specified `--prefix` directory. + #[arg(long, conflicts_with = "target")] + pub(crate) prefix: Option, } #[derive(Args)] diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index eb5b515c9bab..96359b15c97b 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -134,7 +134,7 @@ pub(super) async fn compile_bytecode( let start = std::time::Instant::now(); let mut files = 0; for site_packages in venv.site_packages() { - files += compile_tree(site_packages, venv.python_executable(), cache.root()) + files += compile_tree(&site_packages, venv.python_executable(), cache.root()) .await .with_context(|| { format!( diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index c5c728898969..fbdf67f67795 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -22,7 +22,7 @@ use uv_dispatch::BuildDispatch; use uv_fs::Simplified; use uv_git::GitResolver; use uv_installer::{SatisfiesResult, SitePackages}; -use uv_interpreter::{PythonEnvironment, PythonVersion, SystemPython, Target}; +use uv_interpreter::{Prefix, PythonEnvironment, PythonVersion, SystemPython, Target}; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_resolver::{ DependencyMode, ExcludeNewer, FlatIndex, InMemoryIndex, OptionsBuilder, PreReleaseMode, @@ -68,6 +68,7 @@ pub(crate) async fn pip_install( system: bool, break_system_packages: bool, target: Option, + prefix: Option, concurrency: Concurrency, native_tls: bool, preview: PreviewMode, @@ -129,7 +130,7 @@ pub(crate) async fn pip_install( venv.python_executable().user_display().cyan() ); - // Apply any `--target` directory. + // Apply any `--target` or `--prefix` directories. let venv = if let Some(target) = target { debug!( "Using `--target` directory at {}", @@ -137,6 +138,13 @@ pub(crate) async fn pip_install( ); target.init()?; venv.with_target(target) + } else if let Some(prefix) = prefix { + debug!( + "Using `--prefix` directory at {}", + prefix.root().user_display() + ); + prefix.init()?; + venv.with_prefix(prefix) } else { venv }; diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index 396a0c3949bd..2375cb1856f3 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -21,7 +21,7 @@ use uv_dispatch::BuildDispatch; use uv_fs::Simplified; use uv_git::GitResolver; use uv_installer::SitePackages; -use uv_interpreter::{PythonEnvironment, PythonVersion, SystemPython, Target}; +use uv_interpreter::{Prefix, PythonEnvironment, PythonVersion, SystemPython, Target}; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_resolver::{ DependencyMode, ExcludeNewer, FlatIndex, InMemoryIndex, OptionsBuilder, PreReleaseMode, @@ -60,6 +60,7 @@ pub(crate) async fn pip_sync( system: bool, break_system_packages: bool, target: Option, + prefix: Option, concurrency: Concurrency, native_tls: bool, preview: PreviewMode, @@ -124,7 +125,7 @@ pub(crate) async fn pip_sync( venv.python_executable().user_display().cyan() ); - // Apply any `--target` directory. + // Apply any `--target` or `--prefix` directories. let venv = if let Some(target) = target { debug!( "Using `--target` directory at {}", @@ -132,6 +133,13 @@ pub(crate) async fn pip_sync( ); target.init()?; venv.with_target(target) + } else if let Some(prefix) = prefix { + debug!( + "Using `--prefix` directory at {}", + prefix.root().user_display() + ); + prefix.init()?; + venv.with_prefix(prefix) } else { venv }; diff --git a/crates/uv/src/commands/pip/uninstall.rs b/crates/uv/src/commands/pip/uninstall.rs index c7453fccb3eb..398f6547d451 100644 --- a/crates/uv/src/commands/pip/uninstall.rs +++ b/crates/uv/src/commands/pip/uninstall.rs @@ -13,7 +13,7 @@ use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{KeyringProviderType, PreviewMode}; use uv_fs::Simplified; -use uv_interpreter::{PythonEnvironment, SystemPython, Target}; +use uv_interpreter::{Prefix, PythonEnvironment, SystemPython, Target}; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use crate::commands::{elapsed, ExitStatus}; @@ -27,6 +27,7 @@ pub(crate) async fn pip_uninstall( system: bool, break_system_packages: bool, target: Option, + prefix: Option, cache: Cache, connectivity: Connectivity, native_tls: bool, @@ -57,7 +58,7 @@ pub(crate) async fn pip_uninstall( venv.python_executable().user_display().cyan(), ); - // Apply any `--target` directory. + // Apply any `--target` or `--prefix` directories. let venv = if let Some(target) = target { debug!( "Using `--target` directory at {}", @@ -65,6 +66,13 @@ pub(crate) async fn pip_uninstall( ); target.init()?; venv.with_target(target) + } else if let Some(prefix) = prefix { + debug!( + "Using `--prefix` directory at {}", + prefix.root().user_display() + ); + prefix.init()?; + venv.with_prefix(prefix) } else { venv }; diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index d78595812dca..7df58182bc4b 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -303,6 +303,7 @@ async fn run() -> Result { args.shared.system, args.shared.break_system_packages, args.shared.target, + args.shared.prefix, args.shared.concurrency, globals.native_tls, globals.preview, @@ -379,6 +380,7 @@ async fn run() -> Result { args.shared.system, args.shared.break_system_packages, args.shared.target, + args.shared.prefix, args.shared.concurrency, globals.native_tls, globals.preview, @@ -413,6 +415,7 @@ async fn run() -> Result { args.shared.system, args.shared.break_system_packages, args.shared.target, + args.shared.prefix, cache, globals.connectivity, globals.native_tls, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index ab54ad9d7393..9211a3ebd2e3 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -15,7 +15,7 @@ use uv_configuration::{ Concurrency, ConfigSettings, ExtrasSpecification, IndexStrategy, KeyringProviderType, NoBinary, NoBuild, PreviewMode, Reinstall, SetupPyStrategy, TargetTriple, Upgrade, }; -use uv_interpreter::{PythonVersion, Target}; +use uv_interpreter::{Prefix, PythonVersion, Target}; use uv_normalize::PackageName; use uv_resolver::{AnnotationStyle, DependencyMode, ExcludeNewer, PreReleaseMode, ResolutionMode}; use uv_workspace::{Combine, PipOptions, Workspace}; @@ -539,6 +539,7 @@ impl PipSyncSettings { break_system_packages, no_break_system_packages, target, + prefix, legacy_setup_py, no_legacy_setup_py, no_build_isolation, @@ -577,6 +578,7 @@ impl PipSyncSettings { system: flag(system, no_system), break_system_packages: flag(break_system_packages, no_break_system_packages), target, + prefix, index_url: index_args.index_url.and_then(Maybe::into_option), extra_index_url: index_args.extra_index_url.map(|extra_index_urls| { extra_index_urls @@ -672,6 +674,7 @@ impl PipInstallSettings { break_system_packages, no_break_system_packages, target, + prefix, legacy_setup_py, no_legacy_setup_py, no_build_isolation, @@ -730,6 +733,7 @@ impl PipInstallSettings { system: flag(system, no_system), break_system_packages: flag(break_system_packages, no_break_system_packages), target, + prefix, index_url: index_args.index_url.and_then(Maybe::into_option), extra_index_url: index_args.extra_index_url.map(|extra_index_urls| { extra_index_urls @@ -800,6 +804,7 @@ impl PipUninstallSettings { break_system_packages, no_break_system_packages, target, + prefix, } = args; Self { @@ -814,7 +819,7 @@ impl PipUninstallSettings { system: flag(system, no_system), break_system_packages: flag(break_system_packages, no_break_system_packages), target, - + prefix, keyring_provider, ..PipOptions::default() }, @@ -1073,6 +1078,7 @@ pub(crate) struct PipSharedSettings { pub(crate) extras: ExtrasSpecification, pub(crate) break_system_packages: bool, pub(crate) target: Option, + pub(crate) prefix: Option, pub(crate) index_strategy: IndexStrategy, pub(crate) keyring_provider: KeyringProviderType, pub(crate) no_binary: NoBinary, @@ -1113,6 +1119,7 @@ impl PipSharedSettings { system, break_system_packages, target, + prefix, index_url, extra_index_url, no_index, @@ -1256,6 +1263,7 @@ impl PipSharedSettings { .combine(break_system_packages) .unwrap_or_default(), target: args.target.combine(target).map(Target::from), + prefix: args.prefix.combine(prefix).map(Prefix::from), no_binary: NoBinary::from_args(args.no_binary.combine(no_binary).unwrap_or_default()), compile_bytecode: args .compile_bytecode diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index 6f790c9fe646..04e6977beaf7 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -74,7 +74,7 @@ impl TestContext { .expect("CARGO_MANIFEST_DIR should be doubly nested in workspace") .to_path_buf(); - let site_packages = site_packages_path(&venv, format!("python{python_version}")); + let site_packages = site_packages_path(&venv, &format!("python{python_version}")); let python_version = PythonVersion::from_str(python_version).expect("Tests must use valid Python versions"); @@ -364,12 +364,12 @@ impl TestContext { pub fn site_packages(&self) -> PathBuf { site_packages_path( &self.venv, - format!("{}{}", self.python_kind(), self.python_version), + &format!("{}{}", self.python_kind(), self.python_version), ) } } -fn site_packages_path(venv: &Path, python: String) -> PathBuf { +pub fn site_packages_path(venv: &Path, python: &str) -> PathBuf { if cfg!(unix) { venv.join("lib").join(python).join("site-packages") } else if cfg!(windows) { diff --git a/crates/uv/tests/pip_sync.rs b/crates/uv/tests/pip_sync.rs index b537a40986f3..b675b7872660 100644 --- a/crates/uv/tests/pip_sync.rs +++ b/crates/uv/tests/pip_sync.rs @@ -16,7 +16,9 @@ use url::Url; use common::{create_venv, uv_snapshot, venv_to_interpreter}; use uv_fs::Simplified; -use crate::common::{copy_dir_all, get_bin, run_and_format, TestContext, EXCLUDE_NEWER}; +use crate::common::{ + copy_dir_all, get_bin, run_and_format, site_packages_path, TestContext, EXCLUDE_NEWER, +}; mod common; @@ -5174,6 +5176,70 @@ fn target_no_build_isolation() -> Result<()> { Ok(()) } +/// Sync to a `--prefix` directory. +#[test] +fn prefix() -> Result<()> { + let context = TestContext::new("3.12"); + + // Install `iniconfig` to the target directory. + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("iniconfig==2.0.0")?; + + uv_snapshot!(sync(&context) + .arg("requirements.in") + .arg("--prefix") + .arg("prefix"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Downloaded 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "###); + + // Ensure that we can't import the package. + context.assert_command("import iniconfig").failure(); + + // Ensure that we can import the package by augmenting the `PYTHONPATH`. + Command::new(venv_to_interpreter(&context.venv)) + .arg("-B") + .arg("-c") + .arg("import iniconfig") + .env( + "PYTHONPATH", + site_packages_path(&context.temp_dir.join("prefix"), "python3.12"), + ) + .current_dir(&context.temp_dir) + .assert() + .success(); + + // Upgrade it. + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("iniconfig==1.1.1")?; + + uv_snapshot!(sync(&context) + .arg("requirements.in") + .arg("--prefix") + .arg("prefix"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Downloaded 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - iniconfig==2.0.0 + + iniconfig==1.1.1 + "###); + + Ok(()) +} + /// Ensure that we install packages with markers on them. #[test] fn preserve_markers() -> Result<()> { diff --git a/uv.schema.json b/uv.schema.json index df1c50dc30d1..0dd2e0923c10 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -501,6 +501,12 @@ "null" ] }, + "prefix": { + "type": [ + "string", + "null" + ] + }, "prerelease": { "anyOf": [ {