From f8ffc32368ecbbdf6f6fce28d4147c1813e368d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 13:44:19 +0000 Subject: [PATCH 1/3] Respect workspace-level [cache.pypi-mapping] and remove global-only cache path Workspace-level `[cache.*]` overrides were ignored for the conda-pypi mapping cache because the mapping client resolved its path through a global-only code path (the free `cache_dir_for`, reading only system + user config). A `[cache.pypi-mapping]` set in a workspace `.pixi/config.toml` was silently dropped, so the netfs redirect kept firing and the "cache for PypiMapping ... is on a network filesystem" warning persisted (issue #6281). Make `MappingClient::builder` take the resolved cache path as a parameter and resolve it through the workspace-merged config at the call site (`project.config().cache_dir_for(PypiMapping)`), keeping the `pypi_mapping` crate agnostic about which config layer wins. Also remove the global-only free `cache_dir_for(kind)` entirely so per-kind cache paths always flow through `Config::cache_dir_for`: - `pixi clean cache` now resolves the cache config from the workspace when available, falling back to the global config outside a workspace. - `DetachedEnvironments::path` takes the cache config to consult. `GLOBAL_CACHE_CONFIG` is kept only for `get_cache_dir` (cache root). Add a regression test that a workspace-merged `[cache.pypi-mapping]` override wins over a forced netfs redirect. --- .../integration_rust/solve_group_tests.rs | 99 ++++++++++++++++--- .../pixi_api/src/workspace/reinstall/mod.rs | 15 +-- crates/pixi_cli/src/clean.rs | 49 +++++---- crates/pixi_cli/src/install.rs | 6 +- crates/pixi_config/src/lib.rs | 98 +++++++++++++----- crates/pixi_core/src/lock_file/update.rs | 18 +++- crates/pixi_core/src/workspace/mod.rs | 6 +- crates/pypi_mapping/src/lib.rs | 13 ++- 8 files changed, 229 insertions(+), 75 deletions(-) diff --git a/crates/pixi/tests/integration_rust/solve_group_tests.rs b/crates/pixi/tests/integration_rust/solve_group_tests.rs index b462f291f2..2e12df054c 100644 --- a/crates/pixi/tests/integration_rust/solve_group_tests.rs +++ b/crates/pixi/tests/integration_rust/solve_group_tests.rs @@ -285,7 +285,14 @@ async fn test_purl_are_missing_for_non_conda_forge() { channel: Some("dummy-channel".to_owned()), }; - let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish(); + let mapping_client = pypi_mapping::MappingClient::builder( + client.clone(), + project + .config() + .cache_dir_for(pixi_config::CacheKind::PypiMapping) + .unwrap(), + ) + .finish(); mapping_client .amend_purls(&MappingSource::Prefix, vec![&mut repo_data_record], None) .await @@ -329,7 +336,14 @@ async fn test_purl_are_generated_using_custom_mapping() { MappingLocation::Memory(compressed_mapping), )]); - let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish(); + let mapping_client = pypi_mapping::MappingClient::builder( + client.clone(), + project + .config() + .cache_dir_for(pixi_config::CacheKind::PypiMapping) + .unwrap(), + ) + .finish(); mapping_client .amend_purls( &MappingSource::Custom(Arc::new(CustomMapping::new(source))), @@ -371,7 +385,14 @@ async fn test_compressed_mapping_catch_not_pandoc_not_a_python_package() { let packages = vec![&mut repo_data_record]; - let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish(); + let mapping_client = pypi_mapping::MappingClient::builder( + client.clone(), + project + .config() + .cache_dir_for(pixi_config::CacheKind::PypiMapping) + .unwrap(), + ) + .finish(); mapping_client .amend_purls(&MappingSource::Prefix, packages, None) .await @@ -417,7 +438,14 @@ async fn test_dont_record_not_present_package_as_purl() { channel: Some("https://conda.anaconda.org/conda-forge/".to_owned()), }; - let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish(); + let mapping_client = pypi_mapping::MappingClient::builder( + client.clone(), + project + .config() + .cache_dir_for(pixi_config::CacheKind::PypiMapping) + .unwrap(), + ) + .finish(); mapping_client .amend_purls( project.pypi_name_mapping_source().unwrap(), @@ -530,7 +558,14 @@ async fn test_we_record_not_present_package_as_purl_for_custom_mapping() { let mut packages = vec![repo_data_record, boltons_repo_data_record]; - let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish(); + let mapping_client = pypi_mapping::MappingClient::builder( + client.clone(), + project + .config() + .cache_dir_for(pixi_config::CacheKind::PypiMapping) + .unwrap(), + ) + .finish(); mapping_client .amend_purls( project.pypi_name_mapping_source().unwrap(), @@ -602,7 +637,14 @@ async fn test_custom_mapping_channel_with_suffix() { let mut packages = vec![repo_data_record]; - let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish(); + let mapping_client = pypi_mapping::MappingClient::builder( + client.clone(), + project + .config() + .cache_dir_for(pixi_config::CacheKind::PypiMapping) + .unwrap(), + ) + .finish(); mapping_client .amend_purls( project.pypi_name_mapping_source().unwrap(), @@ -659,7 +701,14 @@ async fn test_repo_data_record_channel_with_suffix() { let mut packages = vec![repo_data_record]; - let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish(); + let mapping_client = pypi_mapping::MappingClient::builder( + client.clone(), + project + .config() + .cache_dir_for(pixi_config::CacheKind::PypiMapping) + .unwrap(), + ) + .finish(); mapping_client .amend_purls( project.pypi_name_mapping_source().unwrap(), @@ -715,7 +764,14 @@ async fn test_path_channel() { let mut packages = vec![repo_data_record]; - let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish(); + let mapping_client = pypi_mapping::MappingClient::builder( + client.clone(), + project + .config() + .cache_dir_for(pixi_config::CacheKind::PypiMapping) + .unwrap(), + ) + .finish(); mapping_client .amend_purls( project.pypi_name_mapping_source().unwrap(), @@ -793,7 +849,14 @@ async fn test_file_url_as_mapping_location() { let mut packages = vec![repo_data_record]; - let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish(); + let mapping_client = pypi_mapping::MappingClient::builder( + client.clone(), + project + .config() + .cache_dir_for(pixi_config::CacheKind::PypiMapping) + .unwrap(), + ) + .finish(); mapping_client .amend_purls( project.pypi_name_mapping_source().unwrap(), @@ -855,7 +918,14 @@ async fn test_disabled_mapping() { let mut packages = vec![boltons_repo_data_record]; - let mapping_client = pypi_mapping::MappingClient::builder(blocked_client.into()).finish(); + let mapping_client = pypi_mapping::MappingClient::builder( + blocked_client.into(), + project + .config() + .cache_dir_for(pixi_config::CacheKind::PypiMapping) + .unwrap(), + ) + .finish(); mapping_client .amend_purls( project.pypi_name_mapping_source().unwrap(), @@ -1192,7 +1262,14 @@ async fn test_missing_mapping_file_error_includes_path() { channel: Some("https://conda.anaconda.org/conda-forge/".to_owned()), }; - let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish(); + let mapping_client = pypi_mapping::MappingClient::builder( + client.clone(), + project + .config() + .cache_dir_for(pixi_config::CacheKind::PypiMapping) + .unwrap(), + ) + .finish(); let result = mapping_client .amend_purls( &MappingSource::Custom(Arc::new(CustomMapping::new(source))), diff --git a/crates/pixi_api/src/workspace/reinstall/mod.rs b/crates/pixi_api/src/workspace/reinstall/mod.rs index d7128b1591..92fa38116e 100644 --- a/crates/pixi_api/src/workspace/reinstall/mod.rs +++ b/crates/pixi_api/src/workspace/reinstall/mod.rs @@ -59,12 +59,15 @@ pub async fn reinstall( let installed_envs: Vec<_> = environments.iter().map(|env| env.name().clone()).collect(); // Message what's installed - let detached_envs_message = - if let Ok(Some(path)) = workspace.config().detached_environments().path() { - format!(" in '{}'", console::style(path.display()).bold()) - } else { - "".to_string() - }; + let detached_envs_message = if let Ok(Some(path)) = workspace + .config() + .detached_environments() + .path(&workspace.config().cache) + { + format!(" in '{}'", console::style(path.display()).bold()) + } else { + "".to_string() + }; let is_cli = interface.is_cli().await; let installed_envs_names: Vec = installed_envs diff --git a/crates/pixi_cli/src/clean.rs b/crates/pixi_cli/src/clean.rs index 168ea63d1b..f1ee754632 100644 --- a/crates/pixi_cli/src/clean.rs +++ b/crates/pixi_cli/src/clean.rs @@ -5,6 +5,7 @@ use pixi_command_dispatcher::{ use pixi_consts::consts; use pixi_core::Workspace; use pixi_core::WorkspaceLocator; +use pixi_core::WorkspaceLocatorError; use pixi_core::workspace::WorkspaceRegistry; use pixi_manifest::EnvironmentName; use pixi_path::AbsPathBuf; @@ -107,8 +108,26 @@ pub struct CacheArgs { } pub async fn execute(args: Args) -> miette::Result<()> { - if let Some(Command::Cache(args)) = args.command { - clean_cache(args).await?; + if let Some(Command::Cache(cache_args)) = args.command { + // Resolve the cache config from the workspace when one is available so + // workspace-level `[cache.*]` overrides are honored, falling back to + // the global (system + user) config when run outside a workspace. + let config = match WorkspaceLocator::for_cli() + .with_global_config_source(args.config_source.source()) + .with_closest_package(false) + .with_search_start(args.workspace_config.workspace_locator_start()) + .locate() + { + Ok(workspace) => workspace.config().clone(), + Err( + WorkspaceLocatorError::WorkspaceNotFound(_) + | WorkspaceLocatorError::MissingWorkspace(_) + | WorkspaceLocatorError::MissingWorkspacePath { .. } + | WorkspaceLocatorError::MissingRegistry(), + ) => pixi_config::Config::load_global(), + Err(err) => return Err(err.into()), + }; + clean_cache(cache_args, &config).await?; return Ok(()); } @@ -191,34 +210,24 @@ pub async fn execute(args: Args) -> miette::Result<()> { } /// Clean the pixi cache folders. -async fn clean_cache(args: CacheArgs) -> miette::Result<()> { +async fn clean_cache(args: CacheArgs, config: &pixi_config::Config) -> miette::Result<()> { let cache_dir = pixi_config::get_cache_dir()?; let mut dirs = vec![]; if args.pypi { - dirs.push(pixi_config::cache_dir_for( - pixi_config::CacheKind::PypiWheels, - )?); + dirs.push(config.cache_dir_for(pixi_config::CacheKind::PypiWheels)?); } if args.conda { - dirs.push(pixi_config::cache_dir_for( - pixi_config::CacheKind::CondaPackages, - )?); + dirs.push(config.cache_dir_for(pixi_config::CacheKind::CondaPackages)?); } if args.repodata { - dirs.push(pixi_config::cache_dir_for( - pixi_config::CacheKind::Repodata, - )?); + dirs.push(config.cache_dir_for(pixi_config::CacheKind::Repodata)?); } if args.mapping { - dirs.push(pixi_config::cache_dir_for( - pixi_config::CacheKind::PypiMapping, - )?); + dirs.push(config.cache_dir_for(pixi_config::CacheKind::PypiMapping)?); } if args.exec { - dirs.push(pixi_config::cache_dir_for( - pixi_config::CacheKind::ExecEnvironments, - )?); + dirs.push(config.cache_dir_for(pixi_config::CacheKind::ExecEnvironments)?); } if args.build_backends { let cache_dirs = CacheDirs::new( @@ -227,9 +236,7 @@ async fn clean_cache(args: CacheArgs) -> miette::Result<()> { .into_assume_dir(), ); dirs.push(cache_dirs.resolve_from_env::().into()); - dirs.push(pixi_config::cache_dir_for( - pixi_config::CacheKind::BuildToolEnvironments, - )?); + dirs.push(config.cache_dir_for(pixi_config::CacheKind::BuildToolEnvironments)?); // TODO: Let's clean deprecated cache directory. // This will be removed in a future release. dirs.push(cache_dir.join(consts::_CACHED_BUILD_ENVS_DIR)); diff --git a/crates/pixi_cli/src/install.rs b/crates/pixi_cli/src/install.rs index de4ca64248..152a8b6c05 100644 --- a/crates/pixi_cli/src/install.rs +++ b/crates/pixi_cli/src/install.rs @@ -243,7 +243,11 @@ pub async fn execute(args: Args) -> miette::Result<()> { .expect("failed to write into message buffer"); } - if let Ok(Some(path)) = workspace.config().detached_environments().path() { + if let Ok(Some(path)) = workspace + .config() + .detached_environments() + .path(&workspace.config().cache) + { write!( &mut message, " in '{}'", diff --git a/crates/pixi_config/src/lib.rs b/crates/pixi_config/src/lib.rs index 1b54f2bf8e..e89a2480c9 100644 --- a/crates/pixi_config/src/lib.rs +++ b/crates/pixi_config/src/lib.rs @@ -143,10 +143,10 @@ static NETFS_REDIRECT_WARNED: LazyLock>> = /// Lazily-loaded global (system + user) cache config. /// -/// Used by the free [`cache_dir_for`] function so process-wide overrides like -/// `[cache.conda-packages]` in `~/.config/pixi/config.toml` are honored even -/// in callers that don't have a [`Config`] handy (e.g. `pypi_mapping`). -/// Workspace-scoped overrides flow through [`Config::cache_dir_for`] instead. +/// Used by [`get_cache_dir`] to resolve the cache *root* for callers that +/// don't have a [`Config`] handy. Per-kind cache directories that should +/// honor workspace-level `[cache.*]` overrides must be resolved through +/// [`Config::cache_dir_for`] instead. static GLOBAL_CACHE_CONFIG: LazyLock = LazyLock::new(|| Config::load_global().cache); /// Describes where the system + user-level config layer comes from. Built from @@ -600,15 +600,6 @@ fn env_netfs_redirect() -> Option { } } -/// Resolve the cache directory for a single [`CacheKind`], consulting the -/// process-wide global config (system + user `config.toml`). -/// -/// Use [`Config::cache_dir_for`] when a workspace-merged [`Config`] is -/// available, since that picks up workspace-level overrides too. -pub fn cache_dir_for(kind: CacheKind) -> miette::Result { - resolve_cache_kind_dir(&GLOBAL_CACHE_CONFIG, kind) -} - fn resolve_cache_kind_dir(cache_cfg: &CacheConfig, kind: CacheKind) -> miette::Result { // Env vars override TOML for per-kind paths. Setting one bypasses the // redirect logic for that kind, mirroring the TOML field's semantics. @@ -974,13 +965,20 @@ impl DetachedEnvironments { // Get the path to the detached-environments directory. None means the default // directory. - pub fn path(&self) -> miette::Result> { + // + // `cache` is the resolved cache config to consult when the default + // directory has to be derived (i.e. when detached-environments is just + // enabled via a boolean). Pass the workspace-merged + // [`Config::cache`] so workspace-level `[cache.detached-environments]` + // overrides are honored. + pub fn path(&self, cache: &CacheConfig) -> miette::Result> { let resolved_self = self.resolve_path()?; match resolved_self { DetachedEnvironments::Path(p) => Ok(Some(p.clone())), - DetachedEnvironments::Boolean(b) if b => { - Ok(Some(cache_dir_for(CacheKind::DetachedEnvironments)?)) - } + DetachedEnvironments::Boolean(b) if b => Ok(Some(resolve_cache_kind_dir( + cache, + CacheKind::DetachedEnvironments, + )?)), _ => Ok(None), } } @@ -2811,7 +2809,7 @@ UNUSED = "unused" let expected_legacy = TlsRootCerts::LegacyNative; assert_eq!(config.tls_root_certs, Some(expected_legacy)); assert_eq!( - config.detached_environments().path().unwrap(), + config.detached_environments().path(&config.cache).unwrap(), Some(PathBuf::from(env!("CARGO_MANIFEST_DIR"))) ); assert_eq!(config.max_concurrent_solves(), 5); @@ -2820,7 +2818,11 @@ UNUSED = "unused" let toml = r"detached-environments = true"; let (config, _) = Config::from_toml(toml, None).unwrap(); assert_eq!( - config.detached_environments().path().unwrap().unwrap(), + config + .detached_environments() + .path(&config.cache) + .unwrap() + .unwrap(), get_cache_dir() .unwrap() .join(consts::ENVIRONMENTS_DIR) @@ -2851,7 +2853,11 @@ UNUSED = "unused" let expected_detached_envs_path = home_dir.join("my/envs"); let (config, _) = Config::from_toml(toml, None).unwrap(); - let actual_detached_envs_path = config.detached_environments().path().unwrap().unwrap(); + let actual_detached_envs_path = config + .detached_environments() + .path(&config.cache) + .unwrap() + .unwrap(); assert_eq!(actual_detached_envs_path, expected_detached_envs_path); } @@ -2862,7 +2868,11 @@ UNUSED = "unused" let toml = r#"detached-environments = "/home/me/envs""#; let (config, _) = Config::from_toml(toml, None).unwrap(); - let actual_detached_envs_path = config.detached_environments().path().unwrap().unwrap(); + let actual_detached_envs_path = config + .detached_environments() + .path(&config.cache) + .unwrap() + .unwrap(); let expected_detached_envs_path = PathBuf::from("/home/me/envs"); assert_eq!(actual_detached_envs_path, expected_detached_envs_path); } @@ -3156,7 +3166,7 @@ UNUSED = "unused" ); assert_eq!(config.tls_no_verify, Some(true)); assert_eq!( - config.detached_environments().path().unwrap(), + config.detached_environments().path(&config.cache).unwrap(), Some(PathBuf::from("/path/to/envs")) ); assert!(config.s3_options.contains_key("bucket1")); @@ -3186,7 +3196,7 @@ UNUSED = "unused" ); assert_eq!(config.tls_no_verify, Some(false)); assert_eq!( - config.detached_environments().path().unwrap(), + config.detached_environments().path(&config.cache).unwrap(), Some(PathBuf::from("/path/to/envs2")) ); assert_eq!(config.max_concurrent_solves(), 5); @@ -3311,7 +3321,11 @@ UNUSED = "unused" .set("detached-environments", Some("true".to_string())) .unwrap(); assert_eq!( - config.detached_environments().path().unwrap().unwrap(), + config + .detached_environments() + .path(&config.cache) + .unwrap() + .unwrap(), get_cache_dir() .unwrap() .join(consts::ENVIRONMENTS_DIR) @@ -3322,7 +3336,7 @@ UNUSED = "unused" .set("detached-environments", Some("/path/to/envs".to_string())) .unwrap(); assert_eq!( - config.detached_environments().path().unwrap(), + config.detached_environments().path(&config.cache).unwrap(), Some(PathBuf::from("/path/to/envs")) ); @@ -3966,6 +3980,40 @@ UNUSED = "unused" assert_eq!(got, PathBuf::from("/explicit/per/kind/path")); } + #[test] + fn cache_dir_for_workspace_pypi_mapping_override_wins() { + // Regression test for #6281: a workspace-level `[cache.pypi-mapping]` + // override (merged on top of the global config) must be honored when + // resolving the conda-pypi mapping cache path. Previously the mapping + // client resolved through a global-only path and silently ignored the + // workspace override, so the netfs redirect kept firing. + let _guard = NETFS_ENV_LOCK.lock().unwrap(); + // Force the redirect so that, without the override, the path would be + // rewritten to node-local scratch and the warning would fire. + let _force = ScopedEnv::set("PIXI_FORCE_NETFS_REDIRECT", "1"); + + // The global (system + user) layer has no per-kind override. + let global = Config::default(); + // The workspace `.pixi/config.toml` sets the mapping cache path. + let workspace = Config { + cache: CacheConfig { + pypi_mapping: Some(PathBuf::from("/workspace/conda-pypi-mapping")), + ..CacheConfig::default() + }, + ..Config::default() + }; + + // `merge_config` mirrors how the workspace config is layered onto the + // global config; this merged `Config` is what the mapping client now + // consults. + let merged = global.merge_config(workspace); + + let got = merged.cache_dir_for(CacheKind::PypiMapping).unwrap(); + assert_eq!(got, PathBuf::from("/workspace/conda-pypi-mapping")); + // And crucially the path is *not* redirected to node-local scratch. + assert!(!got.starts_with(node_local_scratch_dir())); + } + #[test] fn cache_dir_for_config_root_acts_like_user_pin() { let _guard = NETFS_ENV_LOCK.lock().unwrap(); diff --git a/crates/pixi_core/src/lock_file/update.rs b/crates/pixi_core/src/lock_file/update.rs index dbe522d882..1d6605da94 100644 --- a/crates/pixi_core/src/lock_file/update.rs +++ b/crates/pixi_core/src/lock_file/update.rs @@ -1793,11 +1793,19 @@ impl<'p> UpdateContextBuilder<'p> { let client = project.authenticated_client()?.clone(); - let mapping_client = self.mapping_client.unwrap_or_else(|| { - MappingClient::builder(client) - .with_concurrency_limit(project.concurrent_downloads_semaphore()) - .finish() - }); + let mapping_client = match self.mapping_client { + Some(mapping_client) => mapping_client, + None => { + // Resolve through the workspace-merged config so workspace-level + // `[cache.pypi-mapping]` overrides are honored. + let cache_path = project + .config() + .cache_dir_for(pixi_config::CacheKind::PypiMapping)?; + MappingClient::builder(client, cache_path) + .with_concurrency_limit(project.concurrent_downloads_semaphore()) + .finish() + } + }; let pre_resolved_pypi_records = std::mem::take(&mut outdated.locked_pypi_records); diff --git a/crates/pixi_core/src/workspace/mod.rs b/crates/pixi_core/src/workspace/mod.rs index 8e8dc0dc57..73dce1e68d 100644 --- a/crates/pixi_core/src/workspace/mod.rs +++ b/crates/pixi_core/src/workspace/mod.rs @@ -364,7 +364,11 @@ impl Workspace { /// Create the detached-environments path for this project if it is set in /// the config fn detached_environments_path(&self) -> Option { - if let Ok(Some(detached_environments_path)) = self.config().detached_environments().path() { + if let Ok(Some(detached_environments_path)) = self + .config() + .detached_environments() + .path(&self.config().cache) + { Some(detached_environments_path.join(format!( "{}-{}", self.display_name(), diff --git a/crates/pypi_mapping/src/lib.rs b/crates/pypi_mapping/src/lib.rs index 169b31cd20..361f6c677e 100644 --- a/crates/pypi_mapping/src/lib.rs +++ b/crates/pypi_mapping/src/lib.rs @@ -10,7 +10,6 @@ use futures::{StreamExt, stream::FuturesUnordered}; use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions}; use itertools::Itertools; use miette::IntoDiagnostic; -use pixi_config::{CacheKind, cache_dir_for}; use rattler_conda_types::{PackageUrl, RepoDataRecord}; use rattler_networking::LazyClient; use reqwest_middleware::ClientBuilder; @@ -174,13 +173,17 @@ impl From for MappingError { } impl MappingClient { - /// Construct a new `MappingClientBuilder` with the provided `Client`. - pub fn builder(client: LazyClient) -> MappingClientBuilder { + /// Construct a new `MappingClientBuilder` with the provided `Client` and + /// the resolved on-disk `cache_path` for the conda-pypi mapping cache. + /// + /// The caller is responsible for resolving `cache_path` (e.g. through + /// [`pixi_config::Config::cache_dir_for`]) so that workspace-level + /// `[cache.pypi-mapping]` overrides are respected; this crate stays + /// agnostic about which config layer wins. + pub fn builder(client: LazyClient, cache_path: PathBuf) -> MappingClientBuilder { // Construct a client with a retry policy and local caching let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3); let retry_strategy = RetryTransientMiddleware::new_with_policy(retry_policy); - let cache_path = cache_dir_for(CacheKind::PypiMapping) - .expect("failed to determine cache directory for conda-pypi mappings. Please ensure PIXI_CACHE_DIR or XDG_CACHE_HOME is set, or that ~/.cache exists."); let cache_strategy = Cache(HttpCache { mode: CacheMode::Default, manager: CACacheManager { From 28f106709c983e09c14bf79aa22fd2a11ae7d99c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 14:09:07 +0000 Subject: [PATCH 2/3] Add Config::detached_environments_dir convenience method Resolving the detached-environments directory previously required handing the config its own cache config: workspace.config().detached_environments().path(&workspace.config().cache) Add `Config::detached_environments_dir()` that resolves the path against its own `[cache]` settings, and route all callers through it. The lower-level `DetachedEnvironments::path` is now a private helper. --- .../pixi_api/src/workspace/reinstall/mod.rs | 15 +++--- crates/pixi_cli/src/install.rs | 6 +-- crates/pixi_config/src/lib.rs | 49 ++++++++----------- crates/pixi_core/src/workspace/mod.rs | 6 +-- 4 files changed, 28 insertions(+), 48 deletions(-) diff --git a/crates/pixi_api/src/workspace/reinstall/mod.rs b/crates/pixi_api/src/workspace/reinstall/mod.rs index 92fa38116e..2d12fbec4d 100644 --- a/crates/pixi_api/src/workspace/reinstall/mod.rs +++ b/crates/pixi_api/src/workspace/reinstall/mod.rs @@ -59,15 +59,12 @@ pub async fn reinstall( let installed_envs: Vec<_> = environments.iter().map(|env| env.name().clone()).collect(); // Message what's installed - let detached_envs_message = if let Ok(Some(path)) = workspace - .config() - .detached_environments() - .path(&workspace.config().cache) - { - format!(" in '{}'", console::style(path.display()).bold()) - } else { - "".to_string() - }; + let detached_envs_message = + if let Ok(Some(path)) = workspace.config().detached_environments_dir() { + format!(" in '{}'", console::style(path.display()).bold()) + } else { + "".to_string() + }; let is_cli = interface.is_cli().await; let installed_envs_names: Vec = installed_envs diff --git a/crates/pixi_cli/src/install.rs b/crates/pixi_cli/src/install.rs index 152a8b6c05..eae428f151 100644 --- a/crates/pixi_cli/src/install.rs +++ b/crates/pixi_cli/src/install.rs @@ -243,11 +243,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { .expect("failed to write into message buffer"); } - if let Ok(Some(path)) = workspace - .config() - .detached_environments() - .path(&workspace.config().cache) - { + if let Ok(Some(path)) = workspace.config().detached_environments_dir() { write!( &mut message, " in '{}'", diff --git a/crates/pixi_config/src/lib.rs b/crates/pixi_config/src/lib.rs index e89a2480c9..7450e85da1 100644 --- a/crates/pixi_config/src/lib.rs +++ b/crates/pixi_config/src/lib.rs @@ -968,10 +968,10 @@ impl DetachedEnvironments { // // `cache` is the resolved cache config to consult when the default // directory has to be derived (i.e. when detached-environments is just - // enabled via a boolean). Pass the workspace-merged - // [`Config::cache`] so workspace-level `[cache.detached-environments]` - // overrides are honored. - pub fn path(&self, cache: &CacheConfig) -> miette::Result> { + // enabled via a boolean), so workspace-level `[cache.detached-environments]` + // overrides are honored. Callers should use + // [`Config::detached_environments_dir`] rather than calling this directly. + fn path(&self, cache: &CacheConfig) -> miette::Result> { let resolved_self = self.resolve_path()?; match resolved_self { DetachedEnvironments::Path(p) => Ok(Some(p.clone())), @@ -2236,6 +2236,13 @@ impl Config { self.detached_environments.clone().unwrap_or_default() } + /// Resolve the detached-environments directory for this config, honoring + /// this config's `[cache]` settings when the directory has to be derived. + /// Returns `None` when detached-environments is disabled. + pub fn detached_environments_dir(&self) -> miette::Result> { + self.detached_environments().path(&self.cache) + } + pub fn force_activate(&self) -> bool { self.shell.force_activate.unwrap_or(false) } @@ -2785,7 +2792,7 @@ mod tests { #[test] fn test_config_parse() { - // Calls get_cache_dir() via detached_environments().path(); serialize + // Calls get_cache_dir() via detached_environments_dir(); serialize // against other tests that mutate the process env. let _guard = NETFS_ENV_LOCK.lock().unwrap(); let toml = format!( @@ -2809,7 +2816,7 @@ UNUSED = "unused" let expected_legacy = TlsRootCerts::LegacyNative; assert_eq!(config.tls_root_certs, Some(expected_legacy)); assert_eq!( - config.detached_environments().path(&config.cache).unwrap(), + config.detached_environments_dir().unwrap(), Some(PathBuf::from(env!("CARGO_MANIFEST_DIR"))) ); assert_eq!(config.max_concurrent_solves(), 5); @@ -2818,11 +2825,7 @@ UNUSED = "unused" let toml = r"detached-environments = true"; let (config, _) = Config::from_toml(toml, None).unwrap(); assert_eq!( - config - .detached_environments() - .path(&config.cache) - .unwrap() - .unwrap(), + config.detached_environments_dir().unwrap().unwrap(), get_cache_dir() .unwrap() .join(consts::ENVIRONMENTS_DIR) @@ -2853,11 +2856,7 @@ UNUSED = "unused" let expected_detached_envs_path = home_dir.join("my/envs"); let (config, _) = Config::from_toml(toml, None).unwrap(); - let actual_detached_envs_path = config - .detached_environments() - .path(&config.cache) - .unwrap() - .unwrap(); + let actual_detached_envs_path = config.detached_environments_dir().unwrap().unwrap(); assert_eq!(actual_detached_envs_path, expected_detached_envs_path); } @@ -2868,11 +2867,7 @@ UNUSED = "unused" let toml = r#"detached-environments = "/home/me/envs""#; let (config, _) = Config::from_toml(toml, None).unwrap(); - let actual_detached_envs_path = config - .detached_environments() - .path(&config.cache) - .unwrap() - .unwrap(); + let actual_detached_envs_path = config.detached_environments_dir().unwrap().unwrap(); let expected_detached_envs_path = PathBuf::from("/home/me/envs"); assert_eq!(actual_detached_envs_path, expected_detached_envs_path); } @@ -3166,7 +3161,7 @@ UNUSED = "unused" ); assert_eq!(config.tls_no_verify, Some(true)); assert_eq!( - config.detached_environments().path(&config.cache).unwrap(), + config.detached_environments_dir().unwrap(), Some(PathBuf::from("/path/to/envs")) ); assert!(config.s3_options.contains_key("bucket1")); @@ -3196,7 +3191,7 @@ UNUSED = "unused" ); assert_eq!(config.tls_no_verify, Some(false)); assert_eq!( - config.detached_environments().path(&config.cache).unwrap(), + config.detached_environments_dir().unwrap(), Some(PathBuf::from("/path/to/envs2")) ); assert_eq!(config.max_concurrent_solves(), 5); @@ -3321,11 +3316,7 @@ UNUSED = "unused" .set("detached-environments", Some("true".to_string())) .unwrap(); assert_eq!( - config - .detached_environments() - .path(&config.cache) - .unwrap() - .unwrap(), + config.detached_environments_dir().unwrap().unwrap(), get_cache_dir() .unwrap() .join(consts::ENVIRONMENTS_DIR) @@ -3336,7 +3327,7 @@ UNUSED = "unused" .set("detached-environments", Some("/path/to/envs".to_string())) .unwrap(); assert_eq!( - config.detached_environments().path(&config.cache).unwrap(), + config.detached_environments_dir().unwrap(), Some(PathBuf::from("/path/to/envs")) ); diff --git a/crates/pixi_core/src/workspace/mod.rs b/crates/pixi_core/src/workspace/mod.rs index 73dce1e68d..26cd1b0f89 100644 --- a/crates/pixi_core/src/workspace/mod.rs +++ b/crates/pixi_core/src/workspace/mod.rs @@ -364,11 +364,7 @@ impl Workspace { /// Create the detached-environments path for this project if it is set in /// the config fn detached_environments_path(&self) -> Option { - if let Ok(Some(detached_environments_path)) = self - .config() - .detached_environments() - .path(&self.config().cache) - { + if let Ok(Some(detached_environments_path)) = self.config().detached_environments_dir() { Some(detached_environments_path.join(format!( "{}-{}", self.display_name(), From 1ee97edd03eb4c7eea33d83cb12b4c025f42258f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 14:51:23 +0000 Subject: [PATCH 3/3] Remove now-unused pixi_config dependency from pypi_mapping --- Cargo.lock | 1 - crates/pypi_mapping/Cargo.toml | 1 - crates/pypi_mapping/src/lib.rs | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17c672af10..452ca5a4e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7706,7 +7706,6 @@ dependencies = [ "miette 7.6.0", "pep440_rs", "pep508_rs", - "pixi_config", "rattler_conda_types", "rattler_digest", "rattler_networking", diff --git a/crates/pypi_mapping/Cargo.toml b/crates/pypi_mapping/Cargo.toml index 32558e660d..6bc3851d3a 100644 --- a/crates/pypi_mapping/Cargo.toml +++ b/crates/pypi_mapping/Cargo.toml @@ -19,7 +19,6 @@ itertools = { workspace = true } miette = { workspace = true } pep440_rs = { workspace = true } pep508_rs = { workspace = true } -pixi_config = { workspace = true } rattler_conda_types = { workspace = true } rattler_digest = { workspace = true } rattler_networking = { workspace = true } diff --git a/crates/pypi_mapping/src/lib.rs b/crates/pypi_mapping/src/lib.rs index 361f6c677e..95f4fad373 100644 --- a/crates/pypi_mapping/src/lib.rs +++ b/crates/pypi_mapping/src/lib.rs @@ -177,7 +177,7 @@ impl MappingClient { /// the resolved on-disk `cache_path` for the conda-pypi mapping cache. /// /// The caller is responsible for resolving `cache_path` (e.g. through - /// [`pixi_config::Config::cache_dir_for`]) so that workspace-level + /// `pixi_config::Config::cache_dir_for`) so that workspace-level /// `[cache.pypi-mapping]` overrides are respected; this crate stays /// agnostic about which config layer wins. pub fn builder(client: LazyClient, cache_path: PathBuf) -> MappingClientBuilder {