diff --git a/Cargo.lock b/Cargo.lock index 6307d9d464c62..e6a8560f79875 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5901,6 +5901,7 @@ dependencies = [ "tempfile", "tokio", "tokio-util", + "toml", "uv-cache", "uv-client", "uv-configuration", diff --git a/crates/uv-bench/Cargo.toml b/crates/uv-bench/Cargo.toml index c3fd1933e03e5..596c6244928b5 100644 --- a/crates/uv-bench/Cargo.toml +++ b/crates/uv-bench/Cargo.toml @@ -23,6 +23,11 @@ name = "uv" path = "benches/uv.rs" harness = false +[[bench]] +name = "workspace_discovery" +path = "benches/workspace_discovery.rs" +harness = false + [dev-dependencies] uv-cache = { workspace = true } uv-client = { workspace = true } @@ -55,6 +60,7 @@ jiff = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } +toml = { workspace = true } [features] static = ["uv-extract/static"] diff --git a/crates/uv-bench/benches/workspace_discovery.rs b/crates/uv-bench/benches/workspace_discovery.rs new file mode 100644 index 0000000000000..7eb347bbfd0a9 --- /dev/null +++ b/crates/uv-bench/benches/workspace_discovery.rs @@ -0,0 +1,275 @@ +//! Run workspace discovery in large synthetic workspace with many non-trivial to parse +//! `pyproject.toml` files. + +use std::hint::black_box; +use std::path::{Path, PathBuf}; + +use criterion::{Criterion, criterion_group, criterion_main, measurement::WallTime}; + +use uv_cache::Cache; +use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache}; + +/// Mirroring the airflow workspace size at time of writing. +const MEMBER_COUNT: usize = 127; +const OPTIONAL_DEPENDENCY_GROUP_COUNT: usize = 122; +const DEPENDENCY_GROUP_COUNT: usize = 64; +const UNUSED_ROOT_TABLE_COUNT: usize = 128; +const UNUSED_MEMBER_TABLE_COUNT: usize = 16; + +fn provider_name(member_index: usize) -> String { + format!("workspace-discovery-provider-{member_index:03}") +} + +fn provider_requirement(member_index: usize) -> String { + format!("{}>=0.0.0", provider_name(member_index)) +} + +/// Create a synthetic workspace with a root and [`MEMBER_COUNT`] members, returning the +/// directories to run discovery from. +fn create_workspace(root: &Path) -> Vec { + let mut discovery_roots = Vec::with_capacity(MEMBER_COUNT + 1); + discovery_roots.push(root.to_path_buf()); + + for member_index in 0..MEMBER_COUNT { + let member_root = root + .join("packages") + .join(format!("provider-{member_index:03}")); + fs_err::create_dir_all(&member_root).expect("Failed to create workspace member directory"); + fs_err::write( + member_root.join("pyproject.toml"), + member_pyproject(member_index), + ) + .expect("Failed to write workspace member pyproject.toml"); + discovery_roots.push(member_root); + } + + fs_err::write(root.join("pyproject.toml"), root_pyproject()) + .expect("Failed to write workspace root pyproject.toml"); + + discovery_roots +} + +fn root_pyproject() -> String { + let dependencies: Vec = (0..MEMBER_COUNT).map(provider_requirement).collect(); + + let optional_dependencies: toml::Table = (0..OPTIONAL_DEPENDENCY_GROUP_COUNT) + .map(|group_index| { + let dependencies: Vec = (0..5) + .map(|offset| { + let member_index = (group_index * 7 + offset * 11) % MEMBER_COUNT; + format!( + "{}; python_version >= '3.12'", + provider_requirement(member_index) + ) + }) + .collect(); + ( + format!("provider-set-{group_index:03}"), + toml::Value::from(dependencies), + ) + }) + .collect(); + + let dependency_groups: toml::Table = (0..DEPENDENCY_GROUP_COUNT) + .map(|group_index| { + let dependencies: Vec = (0..4) + .map(|offset| { + let member_index = (group_index * 13 + offset * 17) % MEMBER_COUNT; + provider_requirement(member_index) + }) + .collect(); + ( + format!("development-set-{group_index:03}"), + toml::Value::from(dependencies), + ) + }) + .collect(); + + let sources: toml::Table = (0..MEMBER_COUNT) + .map(|member_index| { + ( + provider_name(member_index), + toml::Value::from(toml::toml! { workspace = true }), + ) + }) + .collect(); + + // Generate some unrelated work for the toml parser, mimicking real tool configuration. + let generated: toml::Table = (0..UNUSED_ROOT_TABLE_COUNT) + .map(|table_index| { + let enabled = table_index % 2 == 0; + let owner = table_index % 12; + ( + format!("root-section-{table_index:03}"), + toml::toml! { + enabled = (enabled) + label = (format!("Generated root metadata section {table_index:03}")) + owner = (format!("workspace-team-{owner:02}")) + tags = ["benchmark", "root", (format!("section-{table_index:03}"))] + include = ["packages/provider-*", "plugins/**/*.py", "tests/**/*.py"] + exclude = ["build/**", "dist/**", ".cache/**"] + settings = { retries = 3, timeout = 30, strict = false } + } + .into(), + ) + }) + .collect(); + + let pyproject = toml::toml! { + dependency-groups = (dependency_groups) + + [build-system] + requires = ["uv_build>=0.11.0,<10000"] + build-backend = "uv_build" + + [project] + name = "workspace-discovery-benchmark" + version = "0.0.0" + description = "A generated workspace used to benchmark repeated workspace discovery" + requires-python = ">=3.12" + license = "MIT" + keywords = ["benchmark", "generated", "monorepo", "workspace"] + classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Typing :: Typed", + ] + dependencies = (dependencies) + optional-dependencies = (optional_dependencies) + + [project.urls] + Documentation = "https://example.com/docs" + Repository = "https://example.com/repository" + Issues = "https://example.com/issues" + + [project.scripts] + workspace-discovery-admin = "workspace_discovery.cli:main" + workspace-discovery-report = "workspace_discovery.reports:main" + + [project.entry-points."workspace_discovery.hooks"] + initialize = "workspace_discovery.hooks:initialize" + validate = "workspace_discovery.hooks:validate" + finalize = "workspace_discovery.hooks:finalize" + + [tool.uv] + sources = (sources) + + [tool.uv.workspace] + members = ["packages/*"] + + [tool.linter] + generated = (generated) + }; + + toml::to_string_pretty(&pyproject).expect("Failed to serialize root pyproject.toml") +} + +fn member_pyproject(member_index: usize) -> String { + let mut dependencies: Vec = (1..=4) + .filter_map(|offset| member_index.checked_sub(offset)) + .map(provider_requirement) + .collect(); + // That's a real, usable PyPI dependency. + dependencies.push("anyio>=4,<5".to_string()); + + let entry_points: toml::Table = [( + format!("provider-{member_index:03}"), + format!("workspace_discovery_provider_{member_index:03}.plugin:Provider").into(), + )] + .into_iter() + .collect(); + + // Add some unrelated work for the toml parser, mimicking real tool configuration. + let generated: toml::Table = (0..UNUSED_MEMBER_TABLE_COUNT) + .map(|table_index| { + let enabled = table_index % 3 != 0; + let priority = table_index % 5; + ( + format!("member-section-{table_index:02}"), + toml::toml! { + member = (member_index) + label = (format!("Provider {member_index:03} metadata section {table_index:02}")) + enabled = (enabled) + capabilities = ["discover", "validate", "report", "archive"] + paths = ["src/**/*.py", "tests/**/*.py", "resources/**/*"] + metadata = { priority = (priority), retries = 2, experimental = true } + } + .into(), + ) + }) + .collect(); + + let pyproject = toml::toml! { + [project] + name = (provider_name(member_index)) + version = "0.0.0" + description = (format!("Generated provider package {member_index:03} for workspace discovery benchmarks")) + requires-python = ">=3.11" + license = "MIT" + keywords = ["generated", "provider", "workspace"] + classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Typing :: Typed", + ] + dependencies = (dependencies) + + [project.optional-dependencies] + diagnostics = ["rich>=13", "typing-extensions>=4.10"] + integrations = ["httpx>=0.27", "platformdirs>=4"] + + [dependency-groups] + test = ["pytest>=8", "pytest-asyncio>=0.24"] + lint = ["tqdm", "mypy>=1.15"] + + [project.urls] + Documentation = "https://example.com/providers" + Source = "https://example.com/repository" + + [project.entry-points] + "workspace_discovery.providers" = (entry_points) + + [tool.linter] + generated = (generated) + }; + + toml::to_string_pretty(&pyproject).expect("Failed to serialize member pyproject.toml") +} + +fn discover_workspace_from_all_members(c: &mut Criterion) { + let dir = tempfile::tempdir().expect("Failed to create temporary directory"); + let discovery_roots = create_workspace(dir.path()); + let cache = Cache::from_path(dir.path().join(".uv-cache")); + let options = DiscoveryOptions::default(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create Tokio runtime"); + + c.bench_function("discover_workspace_from_all_members", |b| { + b.iter(|| { + let workspace_cache = WorkspaceCache::default(); + for root in &discovery_roots { + let workspace = runtime + .block_on(Workspace::discover( + root, + &options, + &cache, + &workspace_cache, + )) + .expect("Failed to discover benchmark workspace"); + black_box(workspace); + } + }); + }); +} + +criterion_group!(workspace_discovery, discover_workspace_from_all_members); +criterion_main!(workspace_discovery); diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index 467bbcfa59278..03761a75166f4 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -22,7 +22,7 @@ use uv_configuration::KeyringProviderType; use uv_distribution_filename::{DistFilename, SourceDistFilename, WheelFilename}; use uv_distribution_types::{ BuiltDist, File, IndexCapabilities, IndexFormat, IndexLocations, IndexMetadataRef, - IndexStatusCodeDecision, IndexStatusCodeStrategy, IndexUrl, IndexUrls, Name, + IndexStatusCodeDecision, IndexStatusCodeStrategy, IndexUrl, Name, }; use uv_git::{GIT_LFS, GitError, GitHttpSettings, GitResolver, Reporter}; use uv_metadata::{read_metadata_async_seek, read_metadata_async_stream}; @@ -164,49 +164,30 @@ impl<'a> RegistryClientBuilder<'a> { Ok(()) } - pub fn build(mut self) -> Result { - self.cache_index_credentials()?; - let index_urls = self.index_locations.index_urls(); - - // Build a base client - let builder = self - .base_client_builder - .indexes(Indexes::from(&self.index_locations)); - - let client = builder.build()?; - - let read_timeout = client.read_timeout(); - let connectivity = client.connectivity(); - - // Wrap in the cache middleware. - let client = CachedClient::new(client); - - Ok(RegistryClient { - index_urls, - index_strategy: self.index_strategy, - torch_backend: self.torch_backend, - cache: self.cache, - connectivity, - client, - read_timeout, - flat_indexes: Arc::default(), - pyx_token_store: PyxTokenStore::from_settings().ok(), - }) + pub fn build(self) -> Result { + self.build_inner(None) } /// Share the underlying client between two different middleware configurations. - pub fn wrap_existing( + pub fn wrap_existing(self, existing: &BaseClient) -> Result { + self.build_inner(Some(existing)) + } + + fn build_inner( mut self, - existing: &BaseClient, + existing: Option<&BaseClient>, ) -> Result { self.cache_index_credentials()?; - let index_urls = self.index_locations.index_urls(); // Wrap in any relevant middleware and handle connectivity. - let client = self + let builder = self .base_client_builder - .indexes(Indexes::from(&self.index_locations)) - .wrap_existing(existing); + .indexes(Indexes::from(&self.index_locations)); + let client = if let Some(existing) = existing { + builder.wrap_existing(existing) + } else { + builder.build()? + }; let read_timeout = client.read_timeout(); let connectivity = client.connectivity(); @@ -215,7 +196,7 @@ impl<'a> RegistryClientBuilder<'a> { let client = CachedClient::new(client); Ok(RegistryClient { - index_urls, + indexes: self.index_locations, index_strategy: self.index_strategy, torch_backend: self.torch_backend, cache: self.cache, @@ -231,8 +212,8 @@ impl<'a> RegistryClientBuilder<'a> { /// A client for fetching packages from a `PyPI`-compatible index. #[derive(Debug, Clone)] pub struct RegistryClient { - /// The index URLs to use for fetching packages. - index_urls: IndexUrls, + /// The indexes to use for fetching packages. + indexes: IndexLocations, /// The strategy to use when fetching across multiple indexes. index_strategy: IndexStrategy, /// The strategy to use when selecting a PyTorch backend, if any. @@ -305,7 +286,9 @@ impl RegistryClient { .map(|indexes| indexes.map(IndexMetadataRef::from)) }) .map(Either::Left) - .unwrap_or_else(|| Either::Right(self.index_urls.indexes().map(IndexMetadataRef::from))) + .unwrap_or_else(|| { + Either::Right(self.indexes.fetch_indexes().map(IndexMetadataRef::from)) + }) } /// Return the appropriate [`IndexStrategy`] for the given [`PackageName`]. @@ -337,7 +320,7 @@ impl RegistryClient { ) -> Result, Error> { // If `--no-index` is specified, avoid fetching regardless of whether the index is implicit, // explicit, etc. - if self.index_urls.no_index() { + if self.indexes.no_index() { return Err(ErrorKind::NoIndex(package_name.to_string()).into()); } @@ -357,7 +340,7 @@ impl RegistryClient { match index.format { IndexFormat::Simple => { let status_code_strategy = - self.index_urls.status_code_strategy_for(index.url); + self.indexes.status_code_strategy_for(index.url); match self .simple_detail_single_index( package_name, @@ -455,7 +438,7 @@ impl RegistryClient { package_name: &PackageName, download_concurrency: &Semaphore, ) -> Result, Error> { - Ok(futures::stream::iter(self.index_urls.flat_indexes()) + Ok(futures::stream::iter(self.indexes.flat_indexes()) .map(async |index| { let _permit = download_concurrency.acquire().await; self.flat_single_index(package_name, index.url()).await @@ -545,7 +528,7 @@ impl RegistryClient { ); let cache_control = match self.connectivity { Connectivity::Online => { - if let Some(header) = self.index_urls.simple_api_cache_control_for(index) { + if let Some(header) = self.indexes.simple_api_cache_control_for(index) { CacheControl::Override(header) } else { CacheControl::from( @@ -805,7 +788,7 @@ impl RegistryClient { ); let cache_control = match self.connectivity { Connectivity::Online => { - if let Some(header) = self.index_urls.simple_api_cache_control_for(index) { + if let Some(header) = self.indexes.simple_api_cache_control_for(index) { CacheControl::Override(header) } else { CacheControl::from( @@ -1091,7 +1074,7 @@ impl RegistryClient { ); let cache_control = match self.connectivity { Connectivity::Online => { - if let Some(header) = self.index_urls.artifact_cache_control_for(index) { + if let Some(header) = self.indexes.artifact_cache_control_for(index) { CacheControl::Override(header) } else { CacheControl::from( @@ -1168,7 +1151,7 @@ impl RegistryClient { let cache_control = match self.connectivity { Connectivity::Online => { if let Some(index) = index { - if let Some(header) = self.index_urls.artifact_cache_control_for(index) { + if let Some(header) = self.indexes.artifact_cache_control_for(index) { CacheControl::Override(header) } else { CacheControl::from( diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs index 4f98f16bbe32b..c049d375b2acf 100644 --- a/crates/uv-distribution-types/src/index_url.rs +++ b/crates/uv-distribution-types/src/index_url.rs @@ -365,6 +365,15 @@ impl<'a> IndexLocations { .filter(|index| !index.explicit) } + /// Return an iterator over all [`Index`] entries to fetch in order. + /// + /// Unlike [`IndexLocations::indexes`], indexes with duplicate raw URLs are excluded. + pub fn fetch_indexes(&'a self) -> impl Iterator + 'a { + let mut seen = FxHashSet::default(); + self.indexes() + .filter(move |index| seen.insert(index.raw_url())) + } + /// Return an iterator over all simple [`Index`] entries in order. /// /// If `no_index` was enabled, then this always returns an empty iterator. @@ -391,15 +400,6 @@ impl<'a> IndexLocations { self.no_index } - /// Clone the index locations into a [`IndexUrls`] instance. - pub fn index_urls(&'a self) -> IndexUrls { - IndexUrls { - indexes: self.indexes.clone(), - flat_indexes: self.flat_index.clone(), - no_index: self.no_index, - } - } - /// Return a vector containing all allowed [`Index`] entries. /// /// This includes explicit indexes, implicit indexes, flat indexes, and the default index. @@ -457,140 +457,15 @@ impl<'a> IndexLocations { } } - /// Return the Simple API cache control header for an [`IndexUrl`], if configured. - pub fn simple_api_cache_control_for(&self, url: &IndexUrl) -> Option { - for index in &self.indexes { - if is_same_index(index.url(), url) { - return index.simple_api_cache_control(); - } - } - None - } - - /// Return the artifact cache control header for an [`IndexUrl`], if configured. - pub fn artifact_cache_control_for(&self, url: &IndexUrl) -> Option { - for index in &self.indexes { - if is_same_index(index.url(), url) { - return index.artifact_cache_control(); - } - } - None - } - - /// Return the `exclude-newer` setting for a given index, if the index is configured. - pub fn exclude_newer_for(&self, url: &IndexUrl) -> Option<&ExcludeNewerOverride> { - for index in &self.indexes { - if is_same_index(index.url(), url) { - return index.exclude_newer(); - } - } - None - } -} - -impl From<&IndexLocations> for uv_auth::Indexes { - fn from(index_locations: &IndexLocations) -> Self { - Self::from_indexes(index_locations.allowed_indexes().into_iter().map(|index| { - let mut url = index.url().url().clone(); - url.set_username("").ok(); - url.set_password(None).ok(); - let mut root_url = index.url().root().unwrap_or_else(|| url.clone()); - root_url.set_username("").ok(); - root_url.set_password(None).ok(); - uv_auth::Index { - url, - root_url, - auth_policy: index.authenticate, - } - })) - } -} - -/// The index URLs to use for fetching packages. -/// -/// This type merges the legacy `--index-url` and `--extra-index-url` options, along with the -/// uv-specific `--index` and `--default-index`. -#[derive(Default, Debug, Clone, PartialEq, Eq)] -pub struct IndexUrls { - indexes: Vec, - flat_indexes: Vec, - no_index: bool, -} - -impl<'a> IndexUrls { - pub fn from_indexes(indexes: Vec) -> Self { - Self { - indexes, - flat_indexes: Vec::new(), - no_index: false, - } - } - - /// Return an iterator over the configured flat-index locations. - pub fn flat_indexes(&'a self) -> impl Iterator + 'a { - self.flat_indexes.iter() - } - - /// Return the default [`Index`] entry. - /// - /// If `--no-index` is set, return `None`. - /// - /// If no index is provided, use the `PyPI` index. - fn default_index(&'a self) -> Option<&'a Index> { - if self.no_index { - None - } else { - let mut seen = FxHashSet::default(); - self.indexes - .iter() - .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name))) - .find(|index| index.default) - .or_else(|| Some(&DEFAULT_INDEX)) - } - } - - /// Return an iterator over the implicit [`Index`] entries. - /// - /// Default and explicit indexes are excluded. - fn implicit_indexes(&'a self) -> impl Iterator + 'a { - if self.no_index { - Either::Left(std::iter::empty()) - } else { - let mut seen = FxHashSet::default(); - Either::Right( - self.indexes - .iter() - .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name))) - .filter(|index| !index.default && !index.explicit), - ) - } - } - - /// Return an iterator over all [`IndexUrl`] entries in order. - /// - /// Prioritizes the `[tool.uv.index]` definitions over the `--extra-index-url` definitions - /// over the `--index-url` definition. - /// - /// If `no_index` was enabled, then this always returns an empty - /// iterator. - pub fn indexes(&'a self) -> impl Iterator + 'a { - let mut seen = FxHashSet::default(); - self.implicit_indexes() - .chain(self.default_index()) - .filter(|index| !index.explicit) - .filter(move |index| seen.insert(index.raw_url())) // Filter out redundant raw URLs - } - /// Return an iterator over all user-defined [`Index`] entries in order. /// /// Prioritizes the `[tool.uv.index]` definitions over the `--extra-index-url` definitions /// over the `--index-url` definition. /// - /// Unlike [`IndexUrl::indexes`], this includes explicit indexes and does _not_ insert PyPI - /// as a fallback default. + /// Unlike [`IndexLocations::indexes`], this includes explicit indexes and does _not_ insert + /// PyPI as a fallback default. /// - /// If `no_index` was enabled, then this always returns an empty - /// iterator. + /// If `no_index` was enabled, then this always returns an empty iterator. pub fn defined_indexes(&'a self) -> impl Iterator + 'a { if self.no_index { return Either::Left(std::iter::empty()); @@ -612,39 +487,54 @@ impl<'a> IndexUrls { Either::Right(non_default.into_iter().chain(default)) } - /// Return the `--no-index` flag. - pub fn no_index(&self) -> bool { - self.no_index + /// Return the configured index matching the given URL. + fn index_for_url(&self, url: &IndexUrl) -> Option<&Index> { + self.indexes + .iter() + .find(|index| is_same_index(index.url(), url)) } /// Return the [`IndexStatusCodeStrategy`] for an [`IndexUrl`]. pub fn status_code_strategy_for(&self, url: &IndexUrl) -> IndexStatusCodeStrategy { - for index in &self.indexes { - if is_same_index(index.url(), url) { - return index.status_code_strategy(); - } - } - IndexStatusCodeStrategy::Default + self.index_for_url(url).map_or( + IndexStatusCodeStrategy::Default, + Index::status_code_strategy, + ) } /// Return the Simple API cache control header for an [`IndexUrl`], if configured. pub fn simple_api_cache_control_for(&self, url: &IndexUrl) -> Option { - for index in &self.indexes { - if is_same_index(index.url(), url) { - return index.simple_api_cache_control(); - } - } - None + self.index_for_url(url) + .and_then(Index::simple_api_cache_control) } /// Return the artifact cache control header for an [`IndexUrl`], if configured. pub fn artifact_cache_control_for(&self, url: &IndexUrl) -> Option { - for index in &self.indexes { - if is_same_index(index.url(), url) { - return index.artifact_cache_control(); + self.index_for_url(url) + .and_then(Index::artifact_cache_control) + } + + /// Return the `exclude-newer` setting for a given index, if the index is configured. + pub fn exclude_newer_for(&self, url: &IndexUrl) -> Option<&ExcludeNewerOverride> { + self.index_for_url(url).and_then(Index::exclude_newer) + } +} + +impl From<&IndexLocations> for uv_auth::Indexes { + fn from(index_locations: &IndexLocations) -> Self { + Self::from_indexes(index_locations.allowed_indexes().into_iter().map(|index| { + let mut url = index.url().url().clone(); + url.set_username("").ok(); + url.set_password(None).ok(); + let mut root_url = index.url().root().unwrap_or_else(|| url.clone()); + root_url.set_username("").ok(); + root_url.set_password(None).ok(); + uv_auth::Index { + url, + root_url, + auth_policy: index.authenticate, } - } - None + })) } } @@ -767,6 +657,20 @@ mod tests { )); } + #[test] + fn fetch_indexes_deduplicates_raw_urls() { + let url = IndexUrl::from_str("https://index.example.com/simple").unwrap(); + let mut first = Index::from(url.clone()); + first.name = Some(IndexName::from_str("first").unwrap()); + let mut second = Index::from(url); + second.name = Some(IndexName::from_str("second").unwrap()); + second.default = true; + let locations = IndexLocations::new(vec![first, second], Vec::new(), false); + + assert_eq!(locations.indexes().count(), 2); + assert_eq!(locations.fetch_indexes().count(), 1); + } + #[test] fn test_cache_control_lookup() { use std::str::FromStr; @@ -806,25 +710,25 @@ mod tests { }, ]; - let index_urls = IndexUrls::from_indexes(indexes); + let index_locations = IndexLocations::new(indexes, Vec::new(), false); let url1 = IndexUrl::from_str("https://index1.example.com/simple").unwrap(); assert_eq!( - index_urls.simple_api_cache_control_for(&url1), + index_locations.simple_api_cache_control_for(&url1), Some(HeaderValue::from_static("max-age=300")) ); assert_eq!( - index_urls.artifact_cache_control_for(&url1), + index_locations.artifact_cache_control_for(&url1), Some(HeaderValue::from_static("max-age=1800")) ); let url2 = IndexUrl::from_str("https://index2.example.com/simple").unwrap(); - assert_eq!(index_urls.simple_api_cache_control_for(&url2), None); - assert_eq!(index_urls.artifact_cache_control_for(&url2), None); + assert_eq!(index_locations.simple_api_cache_control_for(&url2), None); + assert_eq!(index_locations.artifact_cache_control_for(&url2), None); let url3 = IndexUrl::from_str("https://index3.example.com/simple").unwrap(); - assert_eq!(index_urls.simple_api_cache_control_for(&url3), None); - assert_eq!(index_urls.artifact_cache_control_for(&url3), None); + assert_eq!(index_locations.simple_api_cache_control_for(&url3), None); + assert_eq!(index_locations.artifact_cache_control_for(&url3), None); } #[test] @@ -844,21 +748,10 @@ mod tests { exclude_newer: None, }]; - let index_urls = IndexUrls::from_indexes(indexes.clone()); let index_locations = IndexLocations::new(indexes, Vec::new(), false); let pytorch_url = IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap(); - // IndexUrls should return the default for PyTorch - assert_eq!(index_urls.simple_api_cache_control_for(&pytorch_url), None); - assert_eq!( - index_urls.artifact_cache_control_for(&pytorch_url), - Some(HeaderValue::from_static( - "max-age=365000000, immutable, public", - )) - ); - - // IndexLocations should also return the default for PyTorch assert_eq!( index_locations.simple_api_cache_control_for(&pytorch_url), None @@ -891,22 +784,10 @@ mod tests { exclude_newer: None, }]; - let index_urls = IndexUrls::from_indexes(indexes.clone()); let index_locations = IndexLocations::new(indexes, Vec::new(), false); let pytorch_url = IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap(); - // User settings should override defaults - assert_eq!( - index_urls.simple_api_cache_control_for(&pytorch_url), - Some(HeaderValue::from_static("no-cache")) - ); - assert_eq!( - index_urls.artifact_cache_control_for(&pytorch_url), - Some(HeaderValue::from_static("max-age=3600")) - ); - - // Same for IndexLocations assert_eq!( index_locations.simple_api_cache_control_for(&pytorch_url), Some(HeaderValue::from_static("no-cache")) @@ -934,21 +815,10 @@ mod tests { exclude_newer: None, }]; - let index_urls = IndexUrls::from_indexes(indexes.clone()); let index_locations = IndexLocations::new(indexes, Vec::new(), false); let nvidia_url = IndexUrl::from_str("https://pypi.nvidia.com").unwrap(); - // IndexUrls should return the default for NVIDIA - assert_eq!(index_urls.simple_api_cache_control_for(&nvidia_url), None); - assert_eq!( - index_urls.artifact_cache_control_for(&nvidia_url), - Some(HeaderValue::from_static( - "max-age=365000000, immutable, public", - )) - ); - - // IndexLocations should also return the default for NVIDIA assert_eq!( index_locations.simple_api_cache_control_for(&nvidia_url), None diff --git a/crates/uv-distribution-types/src/requires_python.rs b/crates/uv-distribution-types/src/requires_python.rs index f2c906206863a..03226de6d431e 100644 --- a/crates/uv-distribution-types/src/requires_python.rs +++ b/crates/uv-distribution-types/src/requires_python.rs @@ -612,6 +612,11 @@ impl SimplifiedMarkerTree { pub fn as_simplified_marker_tree(self) -> MarkerTree { self.0 } + + /// Combine this simplified marker with another via a conjunction. + pub fn and(&mut self, marker: Self) { + self.0.and(marker.0); + } } #[cfg(test)] diff --git a/crates/uv-distribution/src/index/registry_wheel_index.rs b/crates/uv-distribution/src/index/registry_wheel_index.rs index f77acf5c1f056..f2924a9f58cfe 100644 --- a/crates/uv-distribution/src/index/registry_wheel_index.rs +++ b/crates/uv-distribution/src/index/registry_wheel_index.rs @@ -5,13 +5,15 @@ use rustc_hash::{FxHashMap, FxHashSet}; use uv_cache::{Cache, CacheBucket, WheelCache}; use uv_cache_info::CacheInfo; +use uv_distribution_filename::WheelFilename; use uv_distribution_types::{ BuildInfo, BuildVariables, CachedRegistryDist, ConfigSettings, ExtraBuildRequirement, ExtraBuildRequires, ExtraBuildVariables, Hashed, Index, IndexLocations, IndexUrl, - PackageConfigSettings, + PackageConfigSettings, RegistryBuiltDist, RegistrySourceDist, }; use uv_fs::{directories, files}; use uv_normalize::PackageName; +use uv_pep440::Version; use uv_platform_tags::Tags; use uv_types::HashStrategy; @@ -20,7 +22,7 @@ use crate::source::{HTTP_REVISION, HttpRevisionPointer, LOCAL_REVISION, LocalRev /// An entry in the [`RegistryWheelIndex`]. #[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub struct IndexEntry<'index> { +struct IndexEntry<'index> { /// The cached distribution. dist: CachedRegistryDist, /// Whether the wheel was built from source (true), or downloaded from the registry directly (false). @@ -29,20 +31,41 @@ pub struct IndexEntry<'index> { index: &'index Index, } -impl<'index> IndexEntry<'index> { - /// Return the cached distribution. - pub fn dist(&self) -> &CachedRegistryDist { - &self.dist +impl IndexEntry<'_> { + fn matches_wheel( + &self, + index: &IndexUrl, + filename: &WheelFilename, + no_build: bool, + no_binary: bool, + ) -> bool { + self.matches_index_and_build_policy(index, no_build, no_binary) + && self.dist.filename == *filename } - /// Return whether the wheel was built from source. - pub fn is_built(&self) -> bool { - self.built + fn matches_source( + &self, + index: &IndexUrl, + name: &PackageName, + version: &Version, + no_build: bool, + no_binary: bool, + ) -> bool { + self.matches_index_and_build_policy(index, no_build, no_binary) + && self.dist.filename.name == *name + && self.dist.filename.version == *version } - /// Return the index from which the wheel was downloaded. - pub fn index(&self) -> &'index Index { - self.index + fn matches_index_and_build_policy( + &self, + index: &IndexUrl, + no_build: bool, + no_binary: bool, + ) -> bool { + if *self.index.url() != *index { + return false; + } + if self.built { !no_build } else { !no_binary } } } @@ -85,10 +108,45 @@ impl<'a> RegistryWheelIndex<'a> { } } + /// Return a cached wheel that satisfies a registry wheel requirement. + pub fn wheel( + &mut self, + wheel: &'a RegistryBuiltDist, + no_build: bool, + no_binary: bool, + ) -> Option<&CachedRegistryDist> { + let wheel = wheel.best_wheel(); + self.get(&wheel.filename.name).find_map(|entry| { + entry + .matches_wheel(&wheel.index, &wheel.filename, no_build, no_binary) + .then_some(&entry.dist) + }) + } + + /// Return a cached wheel that satisfies a registry source distribution requirement. + pub fn source( + &mut self, + source: &'a RegistrySourceDist, + no_build: bool, + no_binary: bool, + ) -> Option<&CachedRegistryDist> { + self.get(&source.name).find_map(|entry| { + entry + .matches_source( + &source.index, + &source.name, + &source.version, + no_build, + no_binary, + ) + .then_some(&entry.dist) + }) + } + /// Return an iterator over available wheels for a given package. /// /// If the package is not yet indexed, this will index the package by reading from the cache. - pub fn get(&mut self, name: &'a PackageName) -> impl Iterator> { + fn get(&mut self, name: &'a PackageName) -> impl Iterator> { self.get_impl(name).iter().rev() } diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index a6e5e94feac8b..6c7c66d577b8c 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -399,21 +399,7 @@ impl<'a> Planner<'a> { // Identify any cached distributions that satisfy the requirement. match dist.as_ref() { Dist::Built(BuiltDist::Registry(wheel)) => { - if let Some(distribution) = registry_index.get(wheel.name()).find_map(|entry| { - if *entry.index().url() != wheel.best_wheel().index { - return None; - } - if entry.dist().filename != wheel.best_wheel().filename { - return None; - } - if entry.is_built() && no_build { - return None; - } - if !entry.is_built() && no_binary { - return None; - } - Some(entry.dist()) - }) { + if let Some(distribution) = registry_index.wheel(wheel, no_build, no_binary) { debug!("Registry requirement already cached: {distribution}"); cached.push(CachedDist::Registry(distribution.clone())); continue; @@ -607,24 +593,7 @@ impl<'a> Planner<'a> { } } Dist::Source(SourceDist::Registry(sdist)) => { - if let Some(distribution) = registry_index.get(sdist.name()).find_map(|entry| { - if *entry.index().url() != sdist.index { - return None; - } - if entry.dist().filename.name != sdist.name { - return None; - } - if entry.dist().filename.version != sdist.version { - return None; - } - if entry.is_built() && no_build { - return None; - } - if !entry.is_built() && no_binary { - return None; - } - Some(entry.dist()) - }) { + if let Some(distribution) = registry_index.source(sdist, no_build, no_binary) { debug!("Registry requirement already cached: {distribution}"); cached.push(CachedDist::Registry(distribution.clone())); continue; diff --git a/crates/uv-pep508/src/marker/algebra.rs b/crates/uv-pep508/src/marker/algebra.rs index 8007fae6d4096..df467321292ce 100644 --- a/crates/uv-pep508/src/marker/algebra.rs +++ b/crates/uv-pep508/src/marker/algebra.rs @@ -524,12 +524,16 @@ impl InternerGuard<'_> { } } - // Restrict the output of a given boolean variable in the tree. + // Restrict the output of selected boolean variables in the tree. // // If the provided function `f` returns a `Some` boolean value, the tree will be simplified - // with the assumption that the given variable is restricted to that value. If the function + // with the assumption that each variable is restricted to that value. If the function // returns `None`, the variable will not be affected. - pub(crate) fn restrict(&mut self, i: NodeId, f: &impl Fn(&Variable) -> Option) -> NodeId { + pub(crate) fn restrict_by( + &mut self, + i: NodeId, + f: &impl Fn(&Variable) -> Option, + ) -> NodeId { if matches!(i, NodeId::TRUE | NodeId::FALSE) { return i; } @@ -540,15 +544,108 @@ impl InternerGuard<'_> { // Restrict this variable to the given output by merging it // with the relevant child. let node = if value { high } else { low }; - return self.restrict(node.negate(i), f); + return self.restrict_by(node.negate(i), f); } } // Restrict all nodes recursively. - let children = node.children.map(i, |node| self.restrict(node, f)); + let children = node.children.map(i, |node| self.restrict_by(node, f)); self.create_node(node.var.clone(), children) } + /// Restrict a marker by assuming that another marker is true. + /// + /// The returned marker is equivalent to `value` wherever `assumption` is true. Its value + /// outside of `assumption` is unspecified, which lets us eliminate decisions that are only + /// needed to restate the assumption. + pub(crate) fn restrict(&mut self, value: NodeId, assumption: NodeId) -> NodeId { + let mut cache = FxHashMap::default(); + self.restrict_cached(value, assumption, &mut cache) + } + + fn restrict_cached( + &mut self, + value: NodeId, + assumption: NodeId, + cache: &mut FxHashMap<(NodeId, NodeId), NodeId>, + ) -> NodeId { + if assumption.is_true() || matches!(value, NodeId::TRUE | NodeId::FALSE) { + return value; + } + if assumption.is_false() { + return NodeId::FALSE; + } + if value == assumption { + return NodeId::TRUE; + } + if value == assumption.not() { + return NodeId::FALSE; + } + if let Some(&result) = cache.get(&(value, assumption)) { + return result; + } + + let value_node = self.shared.node(value); + let assumption_node = self.shared.node(assumption); + let result = match value_node.var.cmp(&assumption_node.var) { + Ordering::Less => { + let children = value_node.children.map(value, |value| { + self.restrict_cached(value, assumption, cache) + }); + self.create_node(value_node.var.clone(), children) + } + Ordering::Greater => { + // The value does not depend on this variable. Existentially quantify it out of the + // assumption, and continue with the remaining variables. + let mut quantified_assumption = NodeId::FALSE; + for child in assumption_node.children.nodes() { + quantified_assumption = + self.or(quantified_assumption, child.negate(assumption)); + } + self.restrict_cached(value, quantified_assumption, cache) + } + Ordering::Equal => { + // Split both trees into matching ranges. Replace any ranges that are unreachable + // under the assumption with the first reachable child, simplifying them out of the + // resulting marker. + let mut fallback = None; + value_node.children.apply( + value, + &assumption_node.children, + assumption, + |value, assumption| { + if assumption.is_false() { + NodeId::FALSE + } else { + let result = self.restrict_cached(value, assumption, cache); + fallback.get_or_insert(result); + result + } + }, + ); + let Some(fallback) = fallback else { + return NodeId::FALSE; + }; + let children = value_node.children.apply( + value, + &assumption_node.children, + assumption, + |value, assumption| { + if assumption.is_false() { + fallback + } else { + self.restrict_cached(value, assumption, cache) + } + }, + ); + self.create_node(value_node.var.clone(), children) + } + }; + + cache.insert((value, assumption), result); + result + } + /// Returns a new tree where the only nodes remaining are non-`extra` /// nodes. /// diff --git a/crates/uv-pep508/src/marker/tree.rs b/crates/uv-pep508/src/marker/tree.rs index fd3888d490314..8da6fde444ce4 100644 --- a/crates/uv-pep508/src/marker/tree.rs +++ b/crates/uv-pep508/src/marker/tree.rs @@ -1268,6 +1268,20 @@ impl MarkerTree { ) } + /// Restrict this marker by assuming that `assumption` is true. + /// + /// The returned marker is equivalent to this marker wherever `assumption` is true, but may + /// have a different value outside of that context. Before evaluating the simplified marker, + /// callers should conjoin `assumption` to restore its standalone meaning. + /// + /// For example, restricting + /// `sys_platform == 'linux' and python_version < '3.11'` under the assumption + /// `sys_platform == 'linux'` produces `python_version < '3.11'`. + #[must_use] + pub fn restrict(self, assumption: Self) -> Self { + Self(INTERNER.lock().restrict(self.0, assumption.0)) + } + /// Remove the extras from a marker, returning `None` if the marker tree evaluates to `true`. /// /// Any `extra` markers that are always `true` given the provided extras will be removed. @@ -1402,14 +1416,14 @@ impl MarkerTree { } fn simplify_extras_with_impl(self, is_extra: &impl Fn(&ExtraName) -> bool) -> Self { - Self(INTERNER.lock().restrict(self.0, &|var| match var { + Self(INTERNER.lock().restrict_by(self.0, &|var| match var { Variable::Extra(name) => is_extra(name.extra()).then_some(true), _ => None, })) } fn simplify_not_extras_with_impl(self, is_extra: &impl Fn(&ExtraName) -> bool) -> Self { - Self(INTERNER.lock().restrict(self.0, &|var| match var { + Self(INTERNER.lock().restrict_by(self.0, &|var| match var { Variable::Extra(name) => is_extra(name.extra()).then_some(false), _ => None, })) @@ -1948,6 +1962,57 @@ mod test { ); } + #[test] + fn restrict() { + let environment = m( + "(platform_machine == 'x86_64' and sys_platform == 'darwin') or \ + (platform_machine == 'x86_64' and sys_platform == 'linux') or \ + (platform_machine == 'AMD64' and sys_platform == 'win32')", + ); + let marker = m( + "((platform_machine == 'x86_64' and sys_platform == 'darwin') or \ + (platform_machine == 'x86_64' and sys_platform == 'linux') or \ + (platform_machine == 'AMD64' and sys_platform == 'win32')) and \ + python_version < '3.11'", + ); + + let simplified = marker.restrict(environment); + assert_eq!(simplified, m("python_version < '3.11'")); + + let mut reconstructed = simplified; + reconstructed.and(environment); + assert_eq!(reconstructed, marker); + assert_eq!(environment.restrict(environment), MarkerTree::TRUE); + + let marker = m("python_version >= '3.12'"); + let assumption = m("sys_platform == 'linux' or python_version >= '3.12'"); + assert_eq!(marker.restrict(assumption), marker); + + for (marker, assumption) in [ + ("python_version < '3.11'", "sys_platform == 'linux'"), + ("sys_platform == 'linux'", "python_version < '3.11'"), + ( + "sys_platform == 'linux' or python_version < '3.11'", + "sys_platform == 'darwin' or python_version >= '3.10'", + ), + ( + "extra == 'foo' and sys_platform == 'linux'", + "extra == 'foo' or sys_platform == 'darwin'", + ), + ("python_version < '3.11'", "python_version >= '3.12'"), + ] { + let marker = m(marker); + let assumption = m(assumption); + let simplified = marker.restrict(assumption); + + let mut expected = marker; + expected.and(assumption); + let mut reconstructed = simplified; + reconstructed.and(assumption); + assert_eq!(reconstructed, expected); + } + } + #[test] fn release_only() { assert!(m("python_full_version > '3.10' or python_full_version <= '3.10'").is_true()); diff --git a/crates/uv-resolver/src/lock/export/pylock_toml.rs b/crates/uv-resolver/src/lock/export/pylock_toml.rs index 8ceb044b2e008..f5e5f6dd4d611 100644 --- a/crates/uv-resolver/src/lock/export/pylock_toml.rs +++ b/crates/uv-resolver/src/lock/export/pylock_toml.rs @@ -40,7 +40,6 @@ use uv_small_str::SmallString; use crate::lock::export::ExportableRequirements; use crate::lock::{Source, WheelTagHint, each_element_on_its_line_array, is_wheel_unreachable}; -use crate::resolution::ResolutionGraphNode; use crate::{Installable, LockError, ResolverOutput}; #[derive(Debug, thiserror::Error)] @@ -390,13 +389,7 @@ impl<'lock> PylockToml { // Convert each node to a `pylock.toml`-style package. let mut packages = Vec::with_capacity(resolution.graph.node_count()); - for node_index in resolution.graph.node_indices() { - let ResolutionGraphNode::Dist(node) = &resolution.graph[node_index] else { - continue; - }; - if !node.is_base() { - continue; - } + for (node_index, node) in resolution.base_dists() { let ResolvedDist::Installable { dist, version } = &node.dist else { continue; }; diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 3c7d2b36d73cd..e3ebef32bc6f7 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -305,14 +305,21 @@ pub struct Lock { /// Package selections from a [`Lock`] for a named direct dependency. /// -/// The dependency can come from a dependency group, the production packages, or both. +/// The dependency can come from the lock manifest, a dependency group, the production packages, +/// or a combination thereof. #[derive(Debug)] pub struct DependencySelection<'lock> { + root: Option<&'lock Package>, production: Option<&'lock Package>, groups: BTreeMap<&'lock GroupName, &'lock Package>, } impl<'lock> DependencySelection<'lock> { + /// Returns the package selected by a direct requirement on the lock manifest. + pub fn root(&self) -> Option<&'lock Package> { + self.root + } + /// Returns the package selected by the production dependency. pub fn production(&self) -> Option<&'lock Package> { self.production @@ -350,27 +357,14 @@ impl Lock { // Determine the set of packages included at multiple versions. let mut seen = FxHashSet::default(); let mut duplicates = FxHashSet::default(); - for node_index in resolution.graph.node_indices() { - let ResolutionGraphNode::Dist(dist) = &resolution.graph[node_index] else { - continue; - }; - if !dist.is_base() { - continue; - } + for (_, dist) in resolution.base_dists() { if !seen.insert(dist.name()) { duplicates.insert(dist.name()); } } // Lock all base packages. - for node_index in resolution.graph.node_indices() { - let ResolutionGraphNode::Dist(dist) = &resolution.graph[node_index] else { - continue; - }; - if !dist.is_base() { - continue; - } - + for (node_index, dist) in resolution.base_dists() { // If there are multiple distributions for the same package, include the markers of all // forks that included the current distribution. // @@ -820,16 +814,17 @@ impl Lock { /// Returns the environment-specific direct dependency selections for a lock target. /// /// If `project_name` is provided, dependencies attached to that package are used. Otherwise, - /// dependency groups attached directly to the lock manifest are used. + /// requirements and dependency groups attached directly to the lock manifest are used. pub fn dependency_selection<'lock>( &'lock self, project_name: Option<&PackageName>, dependency_name: &PackageName, marker_environment: &MarkerEnvironment, ) -> Result, String> { - let (production, groups) = if let Some(project_name) = project_name { + let (root, production, groups) = if let Some(project_name) = project_name { let Some(project) = self.find_by_name(project_name)? else { return Ok(DependencySelection { + root: None, production: None, groups: BTreeMap::new(), }); @@ -847,11 +842,16 @@ impl Lock { groups.insert(group, package); } } - (production, groups) + (None, production, groups) } else { - // Lock-manifest dependency groups only record requirements, not resolved package IDs. - // Select the environment-specific package once, then associate it with each group that - // has an applicable direct requirement. + let root_applies = self.manifest.requirements.iter().any(|requirement| { + &requirement.name == dependency_name + && requirement.marker.evaluate(marker_environment, &[]) + }); + + // Lock-manifest requirements and dependency groups only record requirements, not + // resolved package IDs. Select the environment-specific package once, then associate + // it with every applicable direct requirement. let mut applicable_groups = self .manifest .dependency_groups @@ -866,16 +866,22 @@ impl Lock { .then_some(group) }) .peekable(); - let groups = if applicable_groups.peek().is_some() - && let Some(package) = self.find_by_markers(dependency_name, marker_environment)? - { - applicable_groups.map(|group| (group, package)).collect() + let package = if root_applies || applicable_groups.peek().is_some() { + self.find_by_markers(dependency_name, marker_environment)? } else { - BTreeMap::new() + None }; - (None, groups) + let root = root_applies.then_some(package).flatten(); + let groups = package.map_or_else(BTreeMap::new, |package| { + applicable_groups.map(|group| (group, package)).collect() + }); + (root, None, groups) }; - Ok(DependencySelection { production, groups }) + Ok(DependencySelection { + root, + production, + groups, + }) } /// Returns the package selected by a dependency group on a non-virtual project. @@ -1206,19 +1212,16 @@ impl Lock { self.fork_markers.as_slice() } + /// The marker describing the universe of this resolution. + fn fork_markers_union(&self) -> MarkerTree { + fork_markers_union(&self.fork_markers, &self.requires_python) + } + /// Checks whether the fork markers cover the entire supported marker space. /// /// Returns the actually covered and the expected marker space on validation error. pub fn check_marker_coverage(&self) -> Result<(), (MarkerTree, MarkerTree)> { - let fork_markers_union = if self.fork_markers().is_empty() { - self.requires_python.to_marker_tree() - } else { - let mut fork_markers_union = MarkerTree::FALSE; - for fork_marker in self.fork_markers() { - fork_markers_union.or(fork_marker.pep508()); - } - fork_markers_union - }; + let fork_markers_union = self.fork_markers_union(); let mut environments_union = if !self.supported_environments.is_empty() { let mut environments_union = MarkerTree::FALSE; for fork_marker in &self.supported_environments { @@ -1250,15 +1253,7 @@ impl Lock { &self, new_requires_python: &RequiresPython, ) -> Result<(), (MarkerTree, MarkerTree)> { - let fork_markers_union = if self.fork_markers().is_empty() { - self.requires_python.to_marker_tree() - } else { - let mut fork_markers_union = MarkerTree::FALSE; - for fork_marker in self.fork_markers() { - fork_markers_union.or(fork_marker.pep508()); - } - fork_markers_union - }; + let fork_markers_union = self.fork_markers_union(); let new_requires_python = new_requires_python.to_marker_tree(); if fork_markers_union.is_disjoint(new_requires_python) { Err((fork_markers_union, new_requires_python)) @@ -1293,6 +1288,11 @@ impl Lock { } } + // The simplified marker space covered by this resolution. + let simplified_environment = + SimplifiedMarkerTree::new(&self.requires_python, self.fork_markers_union()) + .as_simplified_marker_tree(); + if !self.supported_environments.is_empty() { let supported_environments = each_element_on_its_line_array( self.supported_environments @@ -1605,7 +1605,11 @@ impl Lock { let mut packages = ArrayOfTables::new(); for dist in &self.packages { - packages.push(dist.to_toml(&self.requires_python, &dist_count_by_name)?); + packages.push(dist.to_toml( + &self.requires_python, + simplified_environment, + &dist_count_by_name, + )?); } doc.insert("package", Item::ArrayOfTables(packages)); @@ -2909,10 +2913,20 @@ impl TryFrom for Lock { unambiguous_package_ids.insert(dist.id.name.clone(), dist.id.clone()); } + let fork_markers = wire + .fork_markers + .into_iter() + .map(|simplified_marker| simplified_marker.into_marker(&wire.requires_python)) + .map(UniversalMarker::from_combined) + .collect::>(); + let environment = SimplifiedMarkerTree::new( + &wire.requires_python, + fork_markers_union(&fork_markers, &wire.requires_python), + ); let packages = wire .packages .into_iter() - .map(|dist| dist.unwire(&wire.requires_python, &unambiguous_package_ids)) + .map(|dist| dist.unwire(&wire.requires_python, environment, &unambiguous_package_ids)) .collect::, _>>()?; let supported_environments = wire .supported_environments @@ -2924,12 +2938,6 @@ impl TryFrom for Lock { .into_iter() .map(|simplified_marker| simplified_marker.into_marker(&wire.requires_python)) .collect(); - let fork_markers = wire - .fork_markers - .into_iter() - .map(|simplified_marker| simplified_marker.into_marker(&wire.requires_python)) - .map(UniversalMarker::from_combined) - .collect(); let mut options = wire.options; if options.exclude_newer.exclude_newer_span.is_some() { options.exclude_newer.exclude_newer = None; @@ -3653,6 +3661,7 @@ impl Package { fn to_toml( &self, requires_python: &RequiresPython, + simplified_environment: MarkerTree, dist_count_by_name: &FxHashMap, ) -> Result { let mut table = Table::new(); @@ -3670,7 +3679,7 @@ impl Package { if !self.dependencies.is_empty() { let deps = each_element_on_its_line_array(self.dependencies.iter().map(|dep| { - dep.to_toml(requires_python, dist_count_by_name) + dep.to_toml(simplified_environment, dist_count_by_name) .into_inline_table() })); table.insert("dependencies", value(deps)); @@ -3680,7 +3689,7 @@ impl Package { let mut optional_deps = Table::new(); for (extra, deps) in &self.optional_dependencies { let deps = each_element_on_its_line_array(deps.iter().map(|dep| { - dep.to_toml(requires_python, dist_count_by_name) + dep.to_toml(simplified_environment, dist_count_by_name) .into_inline_table() })); if !deps.is_empty() { @@ -3696,7 +3705,7 @@ impl Package { let mut dependency_groups = Table::new(); for (extra, deps) in &self.dependency_groups { let deps = each_element_on_its_line_array(deps.iter().map(|dep| { - dep.to_toml(requires_python, dist_count_by_name) + dep.to_toml(simplified_environment, dist_count_by_name) .into_inline_table() })); if !deps.is_empty() { @@ -4006,6 +4015,7 @@ impl PackageWire { fn unwire( self, requires_python: &RequiresPython, + environment: SimplifiedMarkerTree, unambiguous_package_ids: &FxHashMap, ) -> Result { // Consistency check @@ -4029,7 +4039,7 @@ impl PackageWire { let unwire_deps = |deps: Vec| -> Result, LockError> { deps.into_iter() - .map(|dep| dep.unwire(requires_python, unambiguous_package_ids)) + .map(|dep| dep.unwire(requires_python, environment, unambiguous_package_ids)) .collect() }; @@ -5723,7 +5733,7 @@ impl Dependency { /// Returns the TOML representation of this dependency. fn to_toml( &self, - _requires_python: &RequiresPython, + simplified_environment: MarkerTree, dist_count_by_name: &FxHashMap, ) -> Table { let mut table = Table::new(); @@ -5737,7 +5747,13 @@ impl Dependency { .collect::(); table.insert("extra", value(extra_array)); } - if let Some(marker) = self.simplified_marker.try_to_string() { + // Avoid restating the resolution's environment on every dependency edge. + if let Some(marker) = self + .simplified_marker + .as_simplified_marker_tree() + .restrict(simplified_environment) + .try_to_string() + { table.insert("marker", value(marker)); } @@ -5793,13 +5809,16 @@ impl DependencyWire { fn unwire( self, requires_python: &RequiresPython, + environment: SimplifiedMarkerTree, unambiguous_package_ids: &FxHashMap, ) -> Result { - let complexified_marker = self.marker.into_marker(requires_python); + let mut simplified_marker = self.marker; + simplified_marker.and(environment); + let complexified_marker = simplified_marker.into_marker(requires_python); Ok(Dependency { package_id: self.package_id.unwire(unambiguous_package_ids)?, extra: self.extra, - simplified_marker: self.marker, + simplified_marker, complexified_marker: UniversalMarker::from_combined(complexified_marker), }) } @@ -6948,6 +6967,21 @@ fn each_element_on_its_line_array(elements: impl Iterator MarkerTree { + if fork_markers.is_empty() { + return requires_python.to_marker_tree(); + } + let mut environment = MarkerTree::FALSE; + for fork_marker in fork_markers { + environment.or(fork_marker.pep508()); + } + environment +} + /// Returns the simplified string-ified version of each marker given. /// /// Note that the marker strings returned will include conflict markers if they @@ -7306,6 +7340,36 @@ source = { registry = "https://example.com/simple" } assert!(std::ptr::eq(preferred, production)); } + #[test] + fn dependency_selection_resolves_lock_manifest_requirement() { + let lock: Lock = toml::from_str( + r#" +version = 1 +revision = 3 +requires-python = ">=3.12" + +[manifest] +requirements = [{ name = "ty" }] + +[[package]] +name = "ty" +version = "1.0.0" +source = { registry = "https://example.com/simple" } +"#, + ) + .expect("valid lock"); + let dependency_name = PackageName::from_str("ty").expect("valid package name"); + let marker_environment = marker_environment(); + + let selection = lock + .dependency_selection(None, &dependency_name, &marker_environment) + .expect("unique root package"); + let root = selection.root().expect("root dependency"); + + assert_eq!(root.name(), &dependency_name); + assert!(selection.production().is_none()); + } + #[test] fn dependency_selection_returns_any_selection_error() { let lock: Lock = toml::from_str( diff --git a/crates/uv-resolver/src/resolution/output.rs b/crates/uv-resolver/src/resolution/output.rs index ab35e194c194d..1519787a4c2ea 100644 --- a/crates/uv-resolver/src/resolution/output.rs +++ b/crates/uv-resolver/src/resolution/output.rs @@ -598,14 +598,24 @@ impl ResolverOutput { }) } + /// Returns an iterator over the base distributions in the graph. + pub(crate) fn base_dists(&self) -> impl Iterator { + self.graph + .node_indices() + .filter_map(move |node_index| match &self.graph[node_index] { + ResolutionGraphNode::Root => None, + ResolutionGraphNode::Dist(dist) => dist.is_base().then_some((node_index, dist)), + }) + } + /// Return the number of distinct packages in the graph. pub fn len(&self) -> usize { - self.dists().filter(|dist| dist.is_base()).count() + self.base_dists().count() } /// Return `true` if there are no packages in the graph. pub fn is_empty(&self) -> bool { - !self.dists().any(AnnotatedDist::is_base) + self.base_dists().next().is_none() } /// Returns `true` if the graph contains the given package. diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index 7eba4bfc3ac29..84d97b1d9344a 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -26,7 +26,7 @@ use uv_distribution_types::{ ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations, PackageConfigSettings, Requirement, SourceDist, }; -use uv_fs::{Simplified, relative_to}; +use uv_fs::{Simplified, normalize_path, relative_to}; use uv_install_wheel::LinkMode; use uv_normalize::PackageName; use uv_pep440::Version; @@ -371,6 +371,20 @@ async fn build_impl( vec![AnnotatedSource::from(src)] }; + // Build backends can include arbitrary files from the source directory in the distribution. + // Reject an active cache within the source to avoid including cache contents in the build. + for source in &packages { + if let Source::Directory(source_dir) = &source.source + && is_path_within(cache.root(), source_dir) + { + return Err(anyhow::anyhow!( + "The cache directory `{}` is inside the build source directory `{}`", + cache.root().user_display(), + source_dir.user_display() + )); + } + } + let results: Vec<_> = futures::future::join_all(packages.into_iter().map(|source| { let future = build_package( source.clone(), @@ -1255,6 +1269,19 @@ impl Source<'_> { } } +/// Return `true` if `path` is within `directory`, resolving symlinks when possible. +fn is_path_within(path: &Path, directory: &Path) -> bool { + if let Ok(path) = fs_err::canonicalize(path) + && let Ok(directory) = fs_err::canonicalize(directory) + { + return path.starts_with(directory); + } + + let path = normalize_path(path); + let directory = normalize_path(directory); + path.starts_with(directory.as_ref()) +} + /// We run all builds in parallel, so we wait until all builds are done to show the success messages /// in order. #[derive(Debug, Clone)] diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 9eb3abd1b1cf7..30b99d38fb52f 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -23,8 +23,8 @@ use uv_configuration::{ use uv_dispatch::BuildDispatch; use uv_distribution::{DistributionDatabase, LoweredExtraBuildDependencies}; use uv_distribution_types::{ - Identifier, Index, IndexName, IndexUrl, IndexUrls, NameRequirementSpecification, Requirement, - RequirementSource, UnresolvedRequirement, + Identifier, Index, IndexLocations, IndexName, IndexUrl, NameRequirementSpecification, + Requirement, RequirementSource, UnresolvedRequirement, }; use uv_fs::{LockedFile, LockedFileError, Simplified}; use uv_git::store_credentials; @@ -688,8 +688,8 @@ pub(crate) async fn add( // Add any indexes that were provided on the command-line, in priority order. if !raw { - let urls = IndexUrls::from_indexes(indexes); - let mut indexes = urls.defined_indexes().collect::>(); + let locations = IndexLocations::new(indexes, Vec::new(), false); + let mut indexes = locations.defined_indexes().collect::>(); indexes.reverse(); for index in indexes { toml.add_index(index)?; diff --git a/crates/uv/src/commands/project/check.rs b/crates/uv/src/commands/project/check.rs index 2cab5ebd7eb4c..a67ea26fda811 100644 --- a/crates/uv/src/commands/project/check.rs +++ b/crates/uv/src/commands/project/check.rs @@ -304,6 +304,26 @@ pub(crate) async fn check( Err(err) => return Err(err.into()), }; + let marker_environment = venv.interpreter().resolver_marker_environment(); + if ty_path.is_none() + && ty_version.is_none() + && result + .lock() + .dependency_selection( + None, + &PackageName::from_str("ty")?, + marker_environment.markers(), + ) + .map_err(anyhow::Error::msg)? + .root() + .is_some() + { + locked_ty_path = Some( + venv.scripts() + .join(format!("ty{}", std::env::consts::EXE_SUFFIX)), + ); + } + let target = InstallTarget::Script { script, lock: result.lock(), @@ -522,7 +542,7 @@ pub(crate) async fn check( )?; project::sync::store_credentials_from_target(target, &client_builder)?; let ty_state = state.fork(); - let environment = match CachedEnvironment::from_resolution( + let environment = match CachedEnvironment::from_locked_resolution( &resolution, result .lock() diff --git a/crates/uv/src/commands/project/environment.rs b/crates/uv/src/commands/project/environment.rs index d910f0c4e6ecb..9f85649abee34 100644 --- a/crates/uv/src/commands/project/environment.rs +++ b/crates/uv/src/commands/project/environment.rs @@ -14,14 +14,14 @@ use uv_cache::{Cache, CacheBucket}; use uv_cache_info::CacheInfo; use uv_cache_key::{cache_digest, hash_digest}; use uv_client::BaseClientBuilder; -use uv_configuration::{Concurrency, Constraints, TargetTriple}; +use uv_configuration::{Concurrency, Constraints, HashCheckingMode, TargetTriple}; use uv_distribution_types::{ BuiltDist, Dist, Identifier, Node, Resolution, ResolvedDist, SourceDist, }; use uv_fs::PythonExt; use uv_preview::Preview; use uv_python::{Interpreter, PythonEnvironment, canonicalize_executable}; -use uv_types::SourceTreeEditablePolicy; +use uv_types::{HashStrategy, SourceTreeEditablePolicy}; use uv_workspace::WorkspaceCache; /// An ephemeral [`PythonEnvironment`] for running an individual command. @@ -115,6 +115,21 @@ struct CachedEnvironmentDist { cache_info: Option, } +fn cached_environment_resolution_hash( + resolution_hash: String, + hash_strategy: &HashStrategy, +) -> String { + match hash_strategy { + // Preserve existing cache identities for environments materialized without verification. + HashStrategy::None | HashStrategy::Generate(_) => resolution_hash, + // Never reuse an environment materialized without hash verification for a lock-backed + // resolution with the same distributions and expected hashes. + HashStrategy::Verify(_) | HashStrategy::Require(_) => { + hash_digest(&("verify", resolution_hash)) + } + } +} + impl CachedEnvironment { /// Get or create an [`CachedEnvironment`] based on a given set of requirements. pub(crate) async fn from_spec( @@ -159,6 +174,7 @@ impl CachedEnvironment { Self::from_resolution( &resolution, + HashStrategy::default(), build_constraints, &interpreter, settings, @@ -174,17 +190,15 @@ impl CachedEnvironment { .await } - /// Get or create a [`CachedEnvironment`] from an existing [`Resolution`]. + /// Get or create a [`CachedEnvironment`] from a lock-backed [`Resolution`]. /// /// Prefer [`Self::from_spec`] when starting from unresolved requirements; it selects the base /// interpreter and resolves the requirements for that interpreter before delegating here. /// - /// This method is intended for callers that already have a concrete [`Resolution`], and - /// performs environment reuse or creation and installation without invoking the resolver. - /// `interpreter` must be the base interpreter for which `resolution` was produced. In - /// particular, callers materializing a universal lock must derive its markers and tags from - /// the same interpreter. - pub(crate) async fn from_resolution( + /// This method verifies the hashes recorded in `resolution`. `interpreter` must be the base + /// interpreter for which `resolution` was produced. In particular, callers materializing a + /// universal lock must derive its markers and tags from the same interpreter. + pub(crate) async fn from_locked_resolution( resolution: &Resolution, build_constraints: Constraints, interpreter: &Interpreter, @@ -197,6 +211,40 @@ impl CachedEnvironment { cache: &Cache, printer: Printer, preview: Preview, + ) -> Result { + let hash_strategy = HashStrategy::from_resolution(resolution, HashCheckingMode::Verify)?; + Self::from_resolution( + resolution, + hash_strategy, + build_constraints, + interpreter, + settings, + client_builder, + state, + install, + installer_metadata, + concurrency, + cache, + printer, + preview, + ) + .await + } + + async fn from_resolution( + resolution: &Resolution, + hash_strategy: HashStrategy, + build_constraints: Constraints, + interpreter: &Interpreter, + settings: &ResolverInstallerSettings, + client_builder: &BaseClientBuilder<'_>, + state: &PlatformState, + install: Box, + installer_metadata: bool, + concurrency: &Concurrency, + cache: &Cache, + printer: Printer, + preview: Preview, ) -> Result { // Hash the resolution by hashing the generated lockfile. let resolution_hash = { @@ -224,7 +272,7 @@ impl CachedEnvironment { .distribution_id() .cmp(&right.dist.distribution_id()) }); - hash_digest(&distributions) + cached_environment_resolution_hash(hash_digest(&distributions), &hash_strategy) }; // Construct a hash for the environment. @@ -269,6 +317,7 @@ impl CachedEnvironment { sync_environment( venv, resolution, + hash_strategy, Modifications::Exact, build_constraints, settings.into(), @@ -336,3 +385,26 @@ impl CachedEnvironment { } } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use uv_types::HashStrategy; + + use super::{cached_environment_resolution_hash, hash_digest}; + + #[test] + fn verified_cached_environment_uses_separate_resolution_hash() { + let resolution_hash = hash_digest(&["ty==0.0.17"]); + let unverified = + cached_environment_resolution_hash(resolution_hash.clone(), &HashStrategy::None); + let verified = cached_environment_resolution_hash( + resolution_hash.clone(), + &HashStrategy::Verify(Arc::default()), + ); + + assert_eq!(unverified, resolution_hash); + assert_ne!(verified, unverified); + } +} diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 62986176cefad..41dc373446ef6 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1098,7 +1098,7 @@ fn discover_project_environment( } /// Return whether to use centralized project environments for this invocation. -fn centralized_environments_enabled( +pub(crate) fn centralized_environments_enabled( selection: &ProjectEnvironmentSelection, cache: &Cache, ) -> bool { @@ -1115,7 +1115,7 @@ fn centralized_environments_enabled( } /// Return whether `path` is a link into the current cache's environment bucket. -fn is_centralized_environment_link(path: &Path, cache: &Cache) -> bool { +pub(crate) fn is_centralized_environment_link(path: &Path, cache: &Cache) -> bool { let Ok(target) = fs_err::read_link(path) else { return false; }; @@ -1139,7 +1139,7 @@ fn is_centralized_environment_link(path: &Path, cache: &Cache) -> bool { } /// Return the centralized environment path for a given workspace and interpreter. -fn centralized_environment_root( +pub(crate) fn centralized_environment_root( workspace: &Workspace, interpreter: &Interpreter, upgradeable: bool, @@ -1202,12 +1202,13 @@ pub(crate) enum LinkErrorReporting { Log, } -/// Point the workspace's `.venv` to the centralized environment. -fn update_project_environment_link( +/// Point the workspace's `.venv` to the centralized environment, returning whether the link was +/// successfully updated. +pub(crate) fn update_project_environment_link( environment: &PythonEnvironment, workspace: &Workspace, link_error_reporting: LinkErrorReporting, -) { +) -> bool { let link = workspace.install_path().join(".venv"); let report_error = |message: &str, err: &std::io::Error| match link_error_reporting { LinkErrorReporting::User => { @@ -1220,22 +1221,26 @@ fn update_project_environment_link( if uv_fs::is_virtualenv_base(&link) { if let Err(err) = uv_fs::remove_virtualenv(&link) { report_error("Failed to remove existing local virtual environment", &err); - return; + return false; } } else { // On Windows, copying a junction can produce an empty directory. #[cfg(windows)] if let Err(err) = fs_err::remove_dir(&link) { report_error("Failed to create link to project environment", &err); - return; + return false; } } } // TODO(tk): When directory links are unavailable, write `.venv` as a file containing the // environment path. - if let Err(err) = uv_fs::replace_symlink(environment.root(), &link) { - report_error("Failed to create link to project environment", &err); + match uv_fs::replace_symlink(environment.root(), &link) { + Ok(()) => true, + Err(err) => { + report_error("Failed to create link to project environment", &err); + false + } } } @@ -2535,6 +2540,7 @@ pub(crate) async fn resolve_environment( pub(crate) async fn sync_environment( venv: PythonEnvironment, resolution: &Resolution, + hasher: HashStrategy, modifications: Modifications, build_constraints: Constraints, settings: InstallerSettingsRef<'_>, @@ -2594,7 +2600,6 @@ pub(crate) async fn sync_environment( // optional on the downstream APIs. let build_hasher = HashStrategy::default(); let dry_run = DryRun::default(); - let hasher = HashStrategy::default(); let workspace_cache = WorkspaceCache::default(); // Resolve the flat indexes from `--find-links`. diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index f4094e6598eb1..f986f02064a67 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -28,7 +28,7 @@ use uv_python::{ use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; use uv_tool::InstalledTools; -use uv_types::SourceTreeEditablePolicy; +use uv_types::{HashStrategy, SourceTreeEditablePolicy}; use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::WorkspaceCache; @@ -765,6 +765,7 @@ pub(crate) async fn install( match sync_environment( environment, &resolution.into(), + HashStrategy::default(), Modifications::Exact, Constraints::from_requirements(build_constraints.iter().cloned()), (&settings).into(), diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 7e57ad8db7c6d..c7fce18d8ff60 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -22,7 +22,7 @@ use uv_python::{ use uv_requirements::RequirementsSpecification; use uv_settings::{Combine, PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; use uv_tool::{InstalledTools, Tool}; -use uv_types::SourceTreeEditablePolicy; +use uv_types::{HashStrategy, SourceTreeEditablePolicy}; use uv_workspace::WorkspaceCache; use crate::commands::pip::loggers::{ @@ -367,6 +367,7 @@ async fn upgrade_tool( let environment = sync_environment( environment, &resolution.into(), + HashStrategy::default(), Modifications::Exact, build_constraints, (&settings).into(), diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index af197b3180126..53862e3401d93 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -22,7 +22,7 @@ use uv_distribution_types::{ use uv_fs::Simplified; use uv_install_wheel::LinkMode; use uv_normalize::DefaultGroups; -use uv_preview::{Preview, PreviewFeature}; +use uv_preview::Preview; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, }; @@ -32,18 +32,17 @@ use uv_shell::{Shell, shlex_posix, shlex_windows}; use uv_types::{ AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, HashStrategy, SourceTreeEditablePolicy, }; -use uv_virtualenv::OnExisting; +use uv_virtualenv::{OnExisting, RemovalReason}; use uv_warnings::warn_user; -use uv_workspace::{ - DiscoveryOptions, ProjectEnvironmentSelection, VirtualProject, WorkspaceCache, - WorkspaceErrorKind, -}; +use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceErrorKind}; use crate::commands::ExitStatus; use crate::commands::pip::loggers::{DefaultInstallLogger, InstallLogger}; use crate::commands::pip::operations::{Changelog, report_interpreter}; use crate::commands::project::{ - WorkspacePython, lock_project_environment, validate_project_requires_python, + LinkErrorReporting, WorkspacePython, centralized_environment_root, + centralized_environments_enabled, is_centralized_environment_link, lock_project_environment, + update_project_environment_link, validate_project_requires_python, }; use crate::commands::reporters::PythonDownloadReporter; use crate::printer::Printer; @@ -131,32 +130,13 @@ pub(crate) async fn venv( let project_environment = project .as_ref() .map(VirtualProject::workspace) - .filter(|workspace| path.is_none() && workspace.install_path() == project_dir); - - let project_environment_selection = - project_environment.map(|workspace| workspace.environment_selection(Some(false))); + .filter(|workspace| path.is_none() && workspace.install_path() == project_dir) + .map(|workspace| (workspace, workspace.environment_selection(Some(false)))); - if project_environment_selection + let centralized_workspace = project_environment .as_ref() - .is_some_and(ProjectEnvironmentSelection::is_default) - && preview.is_enabled(PreviewFeature::CentralizedProjectEnvs) - { - warn_user!( - "The `centralized-project-envs` preview feature currently has no effect on `uv venv`" - ); - } - - // Determine the default path; either the virtual environment for the project or `.venv`. - let path = path.unwrap_or_else(|| { - project_environment_selection.map_or_else( - || PathBuf::from(".venv"), - |selection| { - selection - .explicit_path() - .map_or_else(|| project_dir.join(".venv"), Path::to_path_buf) - }, - ) - }); + .filter(|(_, selection)| centralized_environments_enabled(selection, cache)) + .map(|(workspace, _)| *workspace); let reporter = PythonDownloadReporter::single(printer); @@ -199,6 +179,24 @@ pub(crate) async fn venv( python.into_interpreter() }; + let upgradeable = python_request + .as_ref() + .is_none_or(|request| !request.includes_patch()); + + // Determine the default path. + let path = if let Some(workspace) = centralized_workspace { + centralized_environment_root(workspace, &interpreter, upgradeable, cache) + } else { + path.or_else(|| { + project_environment.as_ref().map(|(_, selection)| { + selection + .explicit_path() + .map_or_else(|| project_dir.join(".venv"), Path::to_path_buf) + }) + }) + .unwrap_or_else(|| PathBuf::from(".venv")) + }; + // Check if the discovered Python version is incompatible with the current workspace if let Some(requires_python) = requires_python { match validate_project_requires_python( @@ -215,19 +213,26 @@ pub(crate) async fn venv( } } - writeln!( - printer.stderr(), - "Creating virtual environment {}at: {}", - if seed { "with seed packages " } else { "" }, - path.user_display().cyan() - )?; - - let upgradeable = python_request - .as_ref() - .is_none_or(|request| !request.includes_patch()); + let with_seed = if seed { " with seed packages" } else { "" }; + if centralized_workspace.is_some() { + writeln!( + printer.stderr(), + "Creating virtual environment `{}`{with_seed}", + path.file_name() + .unwrap_or(path.as_os_str()) + .to_string_lossy() + .cyan(), + )?; + } else { + writeln!( + printer.stderr(), + "Creating virtual environment{with_seed} at: {}", + path.user_display().cyan() + )?; + } // Lock the project environment to avoid synchronization issues. - let _lock = if let Some(workspace) = project_environment { + let _lock = if let Some((workspace, _)) = project_environment.as_ref() { lock_project_environment(workspace) .await .inspect_err(|err| { @@ -238,6 +243,21 @@ pub(crate) async fn venv( None }; + let on_existing = match on_existing { + OnExisting::Prompt | OnExisting::Remove(_) if centralized_workspace.is_some() => { + // Centralized environments are managed by uv, so replace them without prompting. + OnExisting::Remove(RemovalReason::ManagedEnvironment) + } + OnExisting::Prompt | OnExisting::Remove(_) + if is_centralized_environment_link(&path, cache) => + { + // Remove `.venv` without following it into the cache. + uv_fs::remove_symlink(&path).map_err(|err| VenvError::Creation(err.into()))?; + on_existing + } + _ => on_existing, + }; + // Create the virtual environment. let venv = uv_virtualenv::create_venv( &path, @@ -355,30 +375,36 @@ pub(crate) async fn venv( DefaultInstallLogger.on_complete(&changelog, printer, DryRun::Disabled)?; } + // Determine the appropriate environment path. + let scripts = if let Some(workspace) = centralized_workspace + && update_project_environment_link(&venv, workspace, LinkErrorReporting::User) + && let Ok(suffix) = venv.scripts().strip_prefix(&path) + { + workspace.install_path().join(".venv").join(suffix) + } else { + venv.scripts().to_path_buf() + }; + // Determine the appropriate activation command. let activation = match Shell::from_env() { None => None, - Some(Shell::Bash | Shell::Zsh | Shell::Ksh) => Some(format!( - "source {}", - shlex_posix(venv.scripts().join("activate")) - )), + Some(Shell::Bash | Shell::Zsh | Shell::Ksh) => { + Some(format!("source {}", shlex_posix(scripts.join("activate")))) + } Some(Shell::Fish) => Some(format!( "source {}", - shlex_posix(venv.scripts().join("activate.fish")) + shlex_posix(scripts.join("activate.fish")) )), Some(Shell::Nushell) => Some(format!( "overlay use {}", - shlex_posix(venv.scripts().join("activate.nu")) + shlex_posix(scripts.join("activate.nu")) )), Some(Shell::Csh) => Some(format!( "source {}", - shlex_posix(venv.scripts().join("activate.csh")) - )), - Some(Shell::Powershell) => Some(shlex_windows( - venv.scripts().join("activate"), - Shell::Powershell, + shlex_posix(scripts.join("activate.csh")) )), - Some(Shell::Cmd) => Some(shlex_windows(venv.scripts().join("activate"), Shell::Cmd)), + Some(Shell::Powershell) => Some(shlex_windows(scripts.join("activate"), Shell::Powershell)), + Some(Shell::Cmd) => Some(shlex_windows(scripts.join("activate"), Shell::Cmd)), }; if let Some(act) = activation { writeln!(printer.stderr(), "Activate with: {}", act.green())?; diff --git a/crates/uv/tests/build/build.rs b/crates/uv/tests/build/build.rs index 292608eca88f0..20187e6728815 100644 --- a/crates/uv/tests/build/build.rs +++ b/crates/uv/tests/build/build.rs @@ -2595,45 +2595,6 @@ fn build_with_symlink() -> Result<()> { Ok(()) } -#[test] -fn build_with_hardlink() -> Result<()> { - let context = uv_test::test_context!("3.12"); - context - .temp_dir - .child("pyproject.toml.real") - .write_str(indoc! {r#" - [project] - name = "hardlinked" - version = "0.1.0" - requires-python = ">=3.12" - - [build-system] - requires = ["hatchling"] - build-backend = "hatchling.build" - "#})?; - fs_err::hard_link( - context.temp_dir.child("pyproject.toml.real"), - context.temp_dir.child("pyproject.toml"), - )?; - context - .temp_dir - .child("src/hardlinked/__init__.py") - .touch()?; - fs_err::remove_dir_all(&context.venv)?; - uv_snapshot!(context.filters(), context.build(), @" - success: true - exit_code: 0 - ----- stdout ----- - - ----- stderr ----- - Building source distribution... - Building wheel from source distribution... - Successfully built dist/hardlinked-0.1.0.tar.gz - Successfully built dist/hardlinked-0.1.0-py3-none-any.whl - "); - Ok(()) -} - /// This is bad project layout that is allowed: A project that defines PEP 621 metadata, but no /// PEP 517 build system not a setup.py, so we fallback to setuptools implicitly. #[test] diff --git a/crates/uv/tests/build/cache.rs b/crates/uv/tests/build/cache.rs index 83b5f3d051d95..30ce8ad9ff3cf 100644 --- a/crates/uv/tests/build/cache.rs +++ b/crates/uv/tests/build/cache.rs @@ -1,11 +1,150 @@ use anyhow::Result; use assert_fs::prelude::*; +use predicates::prelude::predicate; use std::process::Command; #[cfg(unix)] use uv_fs::create_symlink; use uv_test::{get_bin, uv_snapshot}; +/// When the active cache directory is inside an explicit build source, we should error before +/// invoking the build backend or creating the output directory. +#[test] +fn build_rejects_cache_inside_source() -> Result<()> { + let mut context = uv_test::test_context!("3.12"); + let project = context.temp_dir.child("project"); + + project.child("pyproject.toml").write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [build-system] + requires = ["uv_build>=0.5.15,<10000"] + build-backend = "uv_build" + "#, + )?; + project.child("src/project/__init__.py").touch()?; + + context.cache_dir = project.child(".uv-cache"); + + uv_snapshot!(context.filters(), context.build().arg("--sdist").arg("project"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The cache directory `project/.uv-cache` is inside the build source directory `project` + "); + + project.child("dist").assert(predicate::path::missing()); + + Ok(()) +} + +/// When the canonical cache directory is inside an explicit build source, we should error even if +/// the configured cache path itself is outside the source. +#[test] +#[cfg(unix)] +fn build_rejects_symlinked_cache_inside_source() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let project = context.temp_dir.child("project"); + + project.child("pyproject.toml").write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [build-system] + requires = ["uv_build>=0.5.15,<10000"] + build-backend = "uv_build" + "#, + )?; + project.child("src/project/__init__.py").touch()?; + + let cache_dir = project.child(".uv-cache"); + cache_dir.create_dir_all()?; + let cache_link = context.temp_dir.child("cache-link"); + create_symlink(cache_dir.path(), cache_link.path())?; + + let mut command = Command::new(get_bin!()); + command + .arg("build") + .arg("--sdist") + .arg("project") + .arg("--cache-dir") + .arg(cache_link.path()); + context.add_shared_env(&mut command, false); + command.current_dir(context.temp_dir.path()); + + uv_snapshot!(context.filters(), command, @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The cache directory `cache-link` is inside the build source directory `project` + "); + + project.child("dist").assert(predicate::path::missing()); + + Ok(()) +} + +/// A cache in the workspace root is allowed when building a member that does not contain it. +#[test] +fn build_allows_cache_outside_selected_source() -> Result<()> { + let mut context = uv_test::test_context!("3.12"); + let workspace = context.temp_dir.child("workspace"); + let member = workspace.child("member"); + + workspace.child("pyproject.toml").write_str( + r#" + [tool.uv.workspace] + members = ["member"] + "#, + )?; + member.child("pyproject.toml").write_str( + r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.12" + + [build-system] + requires = ["uv_build>=0.5.15,<10000"] + build-backend = "uv_build" + "#, + )?; + member.child("src/member/__init__.py").touch()?; + + context.cache_dir = workspace.child(".uv-cache"); + + uv_snapshot!(context.filters(), context.build() + .arg("--sdist") + .arg("--package") + .arg("member") + .current_dir(&workspace), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Building source distribution (uv build backend)... + Successfully built dist/member-0.1.0.tar.gz + "); + + workspace + .child("dist/member-0.1.0.tar.gz") + .assert(predicate::path::is_file()); + + Ok(()) +} + /// When the project directory defaults to a current directory inside the cache directory, we should /// error before using the cache. #[test] diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index 863366afae497..e38ffbc43a558 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -18341,7 +18341,7 @@ fn lock_narrowed_python_version_upper() -> Result<()> { version = "0.1.0" source = { directory = "dependency" } dependencies = [ - { name = "iniconfig", marker = "python_full_version >= '3.10'" }, + { name = "iniconfig" }, ] [package.metadata] @@ -18361,7 +18361,7 @@ fn lock_narrowed_python_version_upper() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "dependency", marker = "python_full_version >= '3.10'" }, + { name = "dependency" }, ] [package.metadata] @@ -18660,11 +18660,11 @@ fn lock_constrained_environment() -> Result<()> { version = "24.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform != 'win32'" }, - { name = "mypy-extensions", marker = "sys_platform != 'win32'" }, - { name = "packaging", marker = "sys_platform != 'win32'" }, - { name = "pathspec", marker = "sys_platform != 'win32'" }, - { name = "platformdirs", marker = "sys_platform != 'win32'" }, + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8f/5f/bac24a952668c7482cfdb4ebf91ba57a796c9da8829363a772040c1a3312/black-24.3.0.tar.gz", hash = "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f", size = 634292, upload-time = "2024-03-15T19:35:43.699Z" } wheels = [ @@ -18724,7 +18724,7 @@ fn lock_constrained_environment() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "black", marker = "sys_platform != 'win32'" }, + { name = "black" }, ] [package.metadata] @@ -18989,11 +18989,11 @@ fn lock_constrained_environment_non_project() -> Result<()> { version = "24.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform != 'win32'" }, - { name = "mypy-extensions", marker = "sys_platform != 'win32'" }, - { name = "packaging", marker = "sys_platform != 'win32'" }, - { name = "pathspec", marker = "sys_platform != 'win32'" }, - { name = "platformdirs", marker = "sys_platform != 'win32'" }, + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8f/5f/bac24a952668c7482cfdb4ebf91ba57a796c9da8829363a772040c1a3312/black-24.3.0.tar.gz", hash = "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f", size = 634292, upload-time = "2024-03-15T19:35:43.699Z" } wheels = [ @@ -19008,7 +19008,7 @@ fn lock_constrained_environment_non_project() -> Result<()> { version = "0.1.0" source = { virtual = "child" } dependencies = [ - { name = "black", marker = "sys_platform != 'win32'" }, + { name = "black" }, ] [package.metadata] @@ -30468,7 +30468,7 @@ fn lock_arm() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "numpy", marker = "platform_machine == 'arm64'" }, + { name = "numpy" }, ] [package.metadata] @@ -30544,7 +30544,7 @@ fn lock_x86_64() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "numpy", marker = "platform_machine == 'x86_64'" }, + { name = "numpy" }, ] [package.metadata] @@ -30617,7 +30617,7 @@ fn lock_x86() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "numpy", marker = "platform_machine == 'i686'" }, + { name = "numpy" }, ] [package.metadata] @@ -33073,7 +33073,7 @@ fn windows_arm() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "pywin32", marker = "platform_machine == 'AMD64' and sys_platform == 'win32'" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, ] [package.metadata] @@ -35788,7 +35788,7 @@ fn lock_android() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "deltachat-rpc-server", marker = "sys_platform == 'android'" }, + { name = "deltachat-rpc-server" }, ] [package.metadata] @@ -35891,7 +35891,7 @@ fn lock_required_intersection() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "numpy", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "numpy" }, ] [package.metadata] @@ -36560,7 +36560,7 @@ fn lock_supported_environment_abi3_wheel() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "abi3-package", marker = "python_full_version < '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "abi3-package" }, ] [package.metadata] diff --git a/crates/uv/tests/lock_scenarios/lock_conflict.rs b/crates/uv/tests/lock_scenarios/lock_conflict.rs index e3e5744b50ac7..fe18bdc6b9ed5 100644 --- a/crates/uv/tests/lock_scenarios/lock_conflict.rs +++ b/crates/uv/tests/lock_scenarios/lock_conflict.rs @@ -2413,7 +2413,7 @@ fn groups_respect_supported_environments_when_filtering_wheels() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "markupsafe", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'group-7-project-a' and extra == 'group-7-project-b') or (sys_platform != 'linux' and extra == 'group-7-project-a' and extra == 'group-7-project-b')" }, + { name = "markupsafe" }, ] [package.metadata] @@ -2429,6 +2429,197 @@ fn groups_respect_supported_environments_when_filtering_wheels() -> Result<()> { Ok(()) } +/// When using `tool.uv.environments`, do not repeat the marker of the `tool.uv.environments` +/// universe for all dependency edges, even when conflicts are involved. +#[test] +fn extra_conflict_environments_omit_redundant_markers() -> Result<()> { + let context = uv_test::test_context!("3.12").with_exclude_newer("2025-09-28T00:00:00Z"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "bar" + version = "0.1.0" + requires-python = ">=3.10.0" + dependencies = [ + "tqdm", + "anyio", + ] + + [project.optional-dependencies] + a = ["tqdm<2"] + b = ["tqdm>=2"] + + [tool.uv] + conflicts = [ + [{ extra = "a" }, { extra = "b" }], + ] + environments = [ + "sys_platform == 'darwin' and platform_machine == 'x86_64'", + "sys_platform == 'linux' and platform_machine == 'x86_64'", + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 8 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + // The `anyio` dependency is shared by every fork, so it carries no marker, + // while the forked `numpy` dependencies keep theirs. + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, + @r#" + version = 1 + revision = 3 + requires-python = ">=3.10.0" + resolution-markers = [ + "platform_machine == 'x86_64' and sys_platform == 'darwin'", + "platform_machine == 'x86_64' and sys_platform == 'linux'", + ] + supported-markers = [ + "platform_machine == 'x86_64' and sys_platform == 'darwin'", + "platform_machine == 'x86_64' and sys_platform == 'linux'", + ] + conflicts = [[ + { package = "bar", extra = "a" }, + { package = "bar", extra = "b" }, + ]] + + [options] + exclude-newer = "2025-09-28T00:00:00Z" + + [[package]] + name = "anyio" + version = "4.11.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'extra-3-bar-a' and extra == 'extra-3-bar-b')" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-3-bar-a' and extra == 'extra-3-bar-b')" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, + ] + + [[package]] + name = "bar" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "anyio" }, + { name = "tqdm", version = "1.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-3-bar-a'" }, + { name = "tqdm", version = "4.67.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-3-bar-b' or extra != 'extra-3-bar-a'" }, + ] + + [package.optional-dependencies] + a = [ + { name = "tqdm", version = "1.0", source = { registry = "https://pypi.org/simple" } }, + ] + b = [ + { name = "tqdm", version = "4.67.1", source = { registry = "https://pypi.org/simple" } }, + ] + + [package.metadata] + requires-dist = [ + { name = "anyio" }, + { name = "tqdm" }, + { name = "tqdm", marker = "extra == 'a'", specifier = "<2" }, + { name = "tqdm", marker = "extra == 'b'", specifier = ">=2" }, + ] + provides-extras = ["a", "b"] + + [[package]] + name = "exceptiongroup" + version = "1.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-3-bar-a' and extra == 'extra-3-bar-b')" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, + ] + + [[package]] + name = "idna" + version = "3.10" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + ] + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + ] + + [[package]] + name = "tqdm" + version = "1.0" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + "platform_machine == 'x86_64' and sys_platform == 'darwin'", + "platform_machine == 'x86_64' and sys_platform == 'linux'", + ] + sdist = { url = "https://files.pythonhosted.org/packages/ba/50/e6c90ecbc3a736ca8af22a52b3e665d32797b9f0cf6a79b7f4bd95dc2153/tqdm-1.0.tar.gz", hash = "sha256:d4972cfd62cf50bf88f20749b536258a3f48b31515dea3ad5edd5fe52e742c6c", size = 1756, upload-time = "2013-10-26T20:06:45.223Z" } + + [[package]] + name = "tqdm" + version = "4.67.1" + source = { registry = "https://pypi.org/simple" } + resolution-markers = [ + "platform_machine == 'x86_64' and sys_platform == 'darwin'", + "platform_machine == 'x86_64' and sys_platform == 'linux'", + ] + sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.15.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + ] + "# + ); + }); + + // Assert the idempotence of `uv lock` when resolving from the lockfile (`--locked`). + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 8 packages in [TIME] + "); + + Ok(()) +} + /// This tests a case where we declare an extra and a group as conflicting. #[test] fn mixed() -> Result<()> { diff --git a/crates/uv/tests/pip_install/pip_install.rs b/crates/uv/tests/pip_install/pip_install.rs index c633ad1b52584..4b8eac2f0b688 100644 --- a/crates/uv/tests/pip_install/pip_install.rs +++ b/crates/uv/tests/pip_install/pip_install.rs @@ -3436,9 +3436,9 @@ fn install_only_binary_all_and_no_binary_all() { context.assert_command("import anyio").failure(); } -/// Binary dependencies in the cache should be reused when the user provides `--no-build`. +/// Cached registry wheels should respect `--no-build` and `--no-binary`. #[test] -fn install_no_binary_cache() { +fn install_build_policy_cache() { let context = uv_test::test_context!("3.12"); // Install a binary distribution. @@ -3494,6 +3494,53 @@ fn install_no_binary_cache() { + idna==3.6 " ); + + // Re-create the virtual environment. + context.venv().arg("--clear").assert().success(); + + // Re-install with `--no-binary`. The locally built wheel should be reused from the source + // distribution cache, while the downloaded wheel should remain excluded. + uv_snapshot!( + context + .pip_install() + .arg("idna") + .arg("--no-binary") + .arg(":all:") + .arg("--offline"), + @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Installed 1 package in [TIME] + + idna==3.6 + " + ); + + // Re-create the virtual environment. + context.venv().arg("--clear").assert().success(); + + // Re-install with `--no-build`. The downloaded wheel should be reused from the wheel cache, + // while the locally built wheel should remain excluded. + uv_snapshot!( + context + .pip_install() + .arg("idna") + .arg("--no-build") + .arg("--offline"), + @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Installed 1 package in [TIME] + + idna==3.6 + " + ); } /// Respect `--only-binary` flags in `requirements.txt` diff --git a/crates/uv/tests/project/check.rs b/crates/uv/tests/project/check.rs index 58a40df516f27..a9bbc8d6aed1a 100644 --- a/crates/uv/tests/project/check.rs +++ b/crates/uv/tests/project/check.rs @@ -574,6 +574,75 @@ fn check_uses_exact_ty_version_from_selected_included_group() -> Result<()> { Ok(()) } +/// Ensure that the cached environment for a locked tool rejects invalid lockfile hashes. +#[test] +#[cfg(feature = "test-pypi")] +fn check_locked_tool_rejects_invalid_hash() -> Result<()> { + let context = + uv_test::test_context!("3.12").with_filter((r"sha256:[0-9a-f]{64}", "sha256:[HASH]")); + + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [dependency-groups] + dev = ["ty==0.0.17"] + "#})?; + context.temp_dir.child("main.py").write_str("x = 1")?; + + context + .lock() + .arg("--exclude-newer") + .arg("2026-02-15T00:00:00Z") + .assert() + .success(); + + let mut lock = context.read("uv.lock"); + let hash_indices = lock + .match_indices("sha256:") + .map(|(index, _)| index + "sha256:".len()) + .collect::>(); + assert!(!hash_indices.is_empty()); + for index in hash_indices.into_iter().rev() { + let replacement = if lock.as_bytes()[index] == b'0' { + "1" + } else { + "0" + }; + lock.replace_range(index..=index, replacement); + } + context.temp_dir.child("uv.lock").write_str(&lock)?; + + uv_snapshot!( + context.filters(), + context.check().arg("--no-sync").arg("--frozen"), + @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. + × Failed to download `ty==0.0.17` + ╰─▶ Hash mismatch for `ty==0.0.17` + + Expected: + sha256:[HASH] + + Computed: + sha256:[HASH] + " + ); + + Ok(()) +} + #[test] #[cfg(feature = "test-pypi")] fn check_uses_ty_version_from_production_dependency() -> Result<()> { @@ -899,6 +968,266 @@ fn check_script() -> Result<()> { Ok(()) } +#[test] +#[cfg(feature = "test-pypi")] +fn check_script_uses_ty_version_from_forked_lock() -> Result<()> { + let context = + uv_test::test_context!("3.12").with_filter((r"ty 0\.0\.17(?: \([^)]*\))?", "ty 0.0.17")); + + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! {r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "ty==0.0.16 ; python_version < '3.12'", + # "ty==0.0.17 ; python_version >= '3.12'", + # ] + # /// + + value: int = 1 + "#})?; + + uv_snapshot!( + context.filters(), + context + .check() + .arg("--script") + .arg(script.path()) + .arg("--show-version"), + @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. + Installed 1 package in [TIME] + Using ty 0.0.17 + " + ); + + assert!(!context.temp_dir.child("script.py.lock").exists()); + + Ok(()) +} + +#[test] +#[cfg(feature = "test-pypi")] +fn check_script_uses_ty_from_path_with_transitive_dependency() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let ty = context.temp_dir.child("ty"); + ty.create_dir_all()?; + ty.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "ty" + version = "1.2.3" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [project.scripts] + ty = "ty:main" + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#})?; + let ty_package = ty.child("src").child("ty"); + ty_package.create_dir_all()?; + ty_package.child("__init__.py").write_str(indoc! {r#" + import sys + + import iniconfig + + def main(): + assert iniconfig is not None + if "--version" in sys.argv: + print("ty 1.2.3") + else: + print("All checks passed!") + "#})?; + + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["ty"] + # + # [tool.uv.sources] + # ty = { path = "ty" } + # /// + + value: int = 1 + "#})?; + + uv_snapshot!( + context.filters(), + context + .check() + .arg("--script") + .arg(script.path()) + .arg("--show-version"), + @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. + Installed 2 packages in [TIME] + Using ty 1.2.3 + " + ); + + Ok(()) +} + +#[test] +#[cfg(feature = "test-pypi")] +fn check_script_ty_override_precedence() -> Result<()> { + let context = uv_test::test_context!("3.12") + .with_filter((r"ty 0\.0\.17(?: \([^)]*\))?", "ty 0.0.17")) + .with_filter(( + r"(?m)^WARN Failed to fetch `ty` from .+; falling back to .+\n", + "", + )); + let tool_dir = context.root.child("tools"); + let bin_dir = context.root.child("tool-bin"); + + context + .tool_install() + .arg("ty==0.0.17") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::UV_EXCLUDE_NEWER, "2026-02-15T00:00:00Z") + .assert() + .success(); + + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["ty==0.0.16"] + # /// + + value: int = 1 + "#})?; + let ty_path = bin_dir.child(format!("ty{}", std::env::consts::EXE_SUFFIX)); + + uv_snapshot!( + context.filters(), + context + .check() + .arg("--script") + .arg(script.path()) + .arg("--ty-version") + .arg(">=999.0.0") + .arg("--show-version") + .env(EnvVars::TY, ty_path.as_os_str()), + @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. + Installed 1 package in [TIME] + Using ty 0.0.17 + " + ); + + uv_snapshot!( + context.filters(), + context + .check() + .arg("--script") + .arg(script.path()) + .arg("--ty-version") + .arg("0.0.17") + .arg("--show-version"), + @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. + Using ty 0.0.17 + " + ); + + Ok(()) +} + +#[test] +#[cfg(feature = "test-pypi")] +fn check_script_ignores_transitive_ty_for_tool_selection() -> Result<()> { + let context = uv_test::test_context!("3.12") + .with_filter((r"ty 0\.0\.17(?: \([^)]*\))?", "ty 0.0.17")) + .with_filter(( + r"(?m)^WARN Failed to fetch `ty` from .+; falling back to .+\n", + "", + )); + + let wrapper = context.temp_dir.child("wrapper"); + wrapper.create_dir_all()?; + wrapper.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "wrapper" + version = "1.0.0" + requires-python = ">=3.12" + dependencies = ["ty==0.0.16"] + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#})?; + let wrapper_package = wrapper.child("src").child("wrapper"); + wrapper_package.create_dir_all()?; + wrapper_package.child("__init__.py").write_str("")?; + + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = ["wrapper"] + # + # [tool.uv.sources] + # wrapper = { path = "wrapper" } + # /// + + import wrapper + "#})?; + + uv_snapshot!( + context.filters(), + context + .check() + .arg("--script") + .arg(script.path()) + .arg("--exclude-newer") + .arg("2026-02-15T00:00:00Z") + .arg("--show-version"), + @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + warning: `uv check` is experimental and may change without warning. Pass `--preview-features check-command` to disable this warning. + Installed 2 packages in [TIME] + Using ty 0.0.17 + " + ); + + Ok(()) +} + #[test] fn check_passes_workspace_metadata_to_ty() -> Result<()> { let context = uv_test::test_context!("3.12"); diff --git a/crates/uv/tests/python/venv.rs b/crates/uv/tests/python/venv.rs index be47f7c273533..8c7f34fe19ff7 100644 --- a/crates/uv/tests/python/venv.rs +++ b/crates/uv/tests/python/venv.rs @@ -75,15 +75,13 @@ fn create_venv() { } #[test] -fn create_venv_centralized_project_envs_warning() -> Result<()> { +fn create_centralized_project_environment_bypasses() -> Result<()> { let context = uv_test::test_context_with_versions!(&["3.12"]); - // The feature is project-scoped, so it does not warn outside a project. + // Centralized environments are only enabled for projects. uv_snapshot!(context.filters(), context.venv() .arg("--preview-features") - .arg("centralized-project-envs") - .arg("--python") - .arg("3.12"), @" + .arg("centralized-project-envs"), @" success: true exit_code: 0 ----- stdout ----- @@ -94,6 +92,8 @@ fn create_venv_centralized_project_envs_warning() -> Result<()> { Activate with: source .venv/[BIN]/activate " ); + context.venv.assert(predicates::path::is_dir()); + assert!(fs_err::read_link(context.venv.path()).is_err()); fs_err::remove_dir_all(&context.venv)?; context @@ -105,29 +105,11 @@ fn create_venv_centralized_project_envs_warning() -> Result<()> { version = "0.1.0" "#})?; - uv_snapshot!(context.filters(), context.venv() - .arg("--preview-features") - .arg("centralized-project-envs") - .arg("--python") - .arg("3.12"), @" - success: true - exit_code: 0 - ----- stdout ----- - - ----- stderr ----- - warning: The `centralized-project-envs` preview feature currently has no effect on `uv venv` - Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] - Creating virtual environment at: .venv - Activate with: source .venv/[BIN]/activate - " - ); - + // Explicit project environment paths are not centralized. uv_snapshot!(context.filters(), context.venv() .env(EnvVars::UV_PROJECT_ENVIRONMENT, "explicit") .arg("--preview-features") - .arg("centralized-project-envs") - .arg("--python") - .arg("3.12"), @" + .arg("centralized-project-envs"), @" success: true exit_code: 0 ----- stdout ----- @@ -138,7 +120,23 @@ fn create_venv_centralized_project_envs_warning() -> Result<()> { Activate with: source explicit/[BIN]/activate " ); + let explicit = context.temp_dir.child("explicit"); + explicit.assert(predicates::path::is_dir()); + assert!(fs_err::read_link(explicit.path()).is_err()); + // Pathless invocations outside the project root are not centralized. + let child = context.temp_dir.child("child"); + child.create_dir_all()?; + context + .venv() + .current_dir(child.path()) + .arg("--preview-features") + .arg("centralized-project-envs") + .assert() + .success(); + let environment = child.child(".venv"); + environment.assert(predicates::path::is_dir()); + assert!(fs_err::read_link(environment.path()).is_err()); Ok(()) } @@ -332,6 +330,273 @@ async fn create_venv_project_environment_lock() -> Result<()> { Ok(()) } +#[test] +fn create_centralized_project_environment() -> Result<()> { + let context = uv_test::test_context_with_versions!(&["3.12"]) + .with_filtered_centralized_environment_hashes(); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Explicit paths remain local; expose one through a directory link. + let external = context.temp_dir.child("external"); + context + .venv() + .arg(external.path()) + .arg("--preview-features") + .arg("centralized-project-envs") + .assert() + .success(); + external.child("marker").touch()?; + uv_fs::create_symlink(external.path(), context.temp_dir.child(".venv").path())?; + + // Migrate to centralized storage without removing the linked environment. + uv_snapshot!(context.filters(), context.venv() + .arg("--preview-features") + .arg("centralized-project-envs"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment `project-cp3.12.[X]-[HASH]` + Activate with: source .venv/[BIN]/activate + "#); + + let target = fs_err::read_link(context.temp_dir.child(".venv").path())?; + assert_eq!( + target.parent(), + Some(context.cache_dir.child("environments-v2").path()) + ); + assert!(target.join("pyvenv.cfg").is_file()); + assert!(external.child("marker").is_file()); + + let marker = context.temp_dir.child(".venv").child("marker"); + marker.touch()?; + + // `--allow-existing` preserves the contents of the centralized environment. + context + .venv() + .arg("--allow-existing") + .arg("--preview-features") + .arg("centralized-project-envs") + .assert() + .success(); + assert_eq!( + target, + fs_err::read_link(context.temp_dir.child(".venv").path())? + ); + assert!(marker.exists()); + + // Preserve an existing centralized environment with `--no-clear`. + uv_snapshot!(context.filters(), context.venv() + .arg("--no-clear") + .arg("--preview-features") + .arg("centralized-project-envs"), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment `project-cp3.12.[X]-[HASH]` + error: Failed to create virtual environment + Caused by: A virtual environment already exists at: [CACHE_DIR]/environments-v2/project-cp3.12.[X]-[HASH] + + hint: Use the `--clear` flag or set `UV_VENV_CLEAR=1` to replace the existing virtual environment + "#); + + assert_eq!( + target, + fs_err::read_link(context.temp_dir.child(".venv").path())? + ); + assert!(marker.exists()); + + let environment = context.temp_dir.child(".venv"); + let cache_marker = target.join("marker"); + assert!(cache_marker.is_file()); + + // Without the preview, `--allow-existing` operates on the environment through the link. + context.venv().arg("--allow-existing").assert().success(); + assert_eq!(target, fs_err::read_link(environment.path())?); + assert!(cache_marker.is_file()); + + // Without the preview, `.venv` is replaced locally without clearing its cached target. + context.venv().assert().success(); + + assert!(fs_err::read_link(environment.path()).is_err()); + assert!(cache_marker.is_file()); + let local_marker = environment.child("local-marker"); + local_marker.touch()?; + + // With the preview, `--allow-existing` operates on the cached environment. + context + .venv() + .arg("--allow-existing") + .arg("--preview-features") + .arg("centralized-project-envs") + .assert() + .success(); + + assert_eq!(target, fs_err::read_link(environment.path())?); + assert!(!local_marker.exists()); + assert!(cache_marker.is_file()); + + // A plain invocation recreates the centralized environment without prompting. + uv_snapshot!(context.filters(), context.venv() + .arg("--preview-features") + .arg("centralized-project-envs"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment `project-cp3.12.[X]-[HASH]` + Activate with: source .venv/[BIN]/activate + "#); + + assert_eq!(target, fs_err::read_link(environment.path())?); + assert!(!cache_marker.exists()); + Ok(()) +} + +#[test] +fn create_centralized_project_environment_link_failure() -> Result<()> { + let context = uv_test::test_context_with_versions!(&["3.12"]) + .with_filtered_centralized_environment_hashes() + .with_filter(( + r"(?m)^(warning: Failed to create link to project environment at `[^`]+`): .*$", + "$1: [ERR]", + )); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + let environment = context.temp_dir.child(".venv"); + environment.create_dir_all()?; + environment.child("keep").touch()?; + + uv_snapshot!(context.filters(), context.venv() + .arg("--preview-features") + .arg("centralized-project-envs"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment `project-cp3.12.[X]-[HASH]` + warning: Failed to create link to project environment at `.venv`: [ERR] + Activate with: source [CACHE_DIR]/environments-v2/project-cp3.12.[X]-[HASH]/[BIN]/activate + "#); + + assert!(environment.child("keep").is_file()); + Ok(()) +} + +#[test] +fn create_centralized_project_environment_no_cache() -> Result<()> { + let context = uv_test::test_context_with_versions!(&["3.12"]); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + uv_snapshot!(context.filters(), context.venv() + .arg("--no-cache") + .arg("--preview-features") + .arg("centralized-project-envs"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: The `centralized-project-envs` feature has no effect when `--no-cache` is enabled + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "#); + + assert!(context.temp_dir.child(".venv").is_dir()); + assert!(fs_err::read_link(context.temp_dir.child(".venv").path()).is_err()); + Ok(()) +} + +#[test] +#[cfg(feature = "test-pypi")] +fn create_centralized_project_environment_with_seed_packages() -> Result<()> { + let context = uv_test::test_context_with_versions!(&["3.12"]) + .with_filtered_centralized_environment_hashes(); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + uv_snapshot!(context.filters(), context.venv() + .arg("--seed") + .arg("--preview-features") + .arg("centralized-project-envs"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment `project-cp3.12.[X]-[HASH]` with seed packages + + pip==24.0 + Activate with: source .venv/[BIN]/activate + "#); + + let target = fs_err::read_link(context.temp_dir.child(".venv").path())?; + assert!(target.join("pyvenv.cfg").is_file()); + + // Seed the existing environment without clearing its contents. + let marker = target.join("marker"); + fs_err::write(&marker, "")?; + context + .venv() + .arg("--seed") + .arg("--allow-existing") + .arg("--preview-features") + .arg("centralized-project-envs") + .assert() + .success(); + + assert_eq!( + target, + fs_err::read_link(context.temp_dir.child(".venv").path())? + ); + assert!(marker.is_file()); + Ok(()) +} + #[test] fn virtual_empty() -> Result<()> { // testing how `uv venv` reacts to a pyproject with no `[project]` and nothing useful to it diff --git a/docs/concepts/projects/layout.md b/docs/concepts/projects/layout.md index 696a9a6a22e68..16c7f1412454b 100644 --- a/docs/concepts/projects/layout.md +++ b/docs/concepts/projects/layout.md @@ -69,6 +69,8 @@ discover it. Switching interpreters selects separate cached environments and can Explicit project environment paths, including `UV_PROJECT_ENVIRONMENT` and environments selected with `--active`, are not centralized. The feature has no effect when `--no-cache` is enabled. +The feature also applies to pathless `uv venv` invocations from a project or workspace root. + ## The lockfile uv creates a `uv.lock` file next to the `pyproject.toml`.