diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index dfdee4f52..cff029c9e 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -358,27 +358,27 @@ async fn find_local_venv_site_packages_with( load_poetry_project(cwd, var).await }; - // 1. Check VIRTUAL_ENV env var. Pipenv ignores it under `PIPENV_ACTIVE` - // (a `pipenv shell` started in another project) and - // `PIPENV_IGNORE_VIRTUALENVS`, so for a Pipenv project the activated venv - // then belongs to something else and must not be patched. Poetry ignores - // it once `poetry env use` recorded an env for the project (see - // [`poetry_active_prefix`]). PDM likewise skips an activated venv under - // `PDM_IGNORE_ACTIVE_VENV`. - let pdm_ignores_active = pdm_env_flag(var, "PDM_IGNORE_ACTIVE_VENV") - && pdm_drives_project(cwd).await; - let active_prefix = match &poetry { - Some(project) => poetry_active_prefix(project, var), - None if pdm_ignores_active => None, - None if !pipenv || pipenv_uses_virtual_env(var) => var("VIRTUAL_ENV"), - None => None, - }; - if let Some(virtual_env) = active_prefix { - let venv_path = PathBuf::from(&virtual_env); - let matches = find_site_packages_under(&venv_path, "site-packages").await; - results.extend(matches); - if !results.is_empty() { - return results; + // Poetry versions can use different placeholder roots. Apply its + // recorded-env / active-shell / in-project precedence within each + // compatible placement, then retain their ordered union. + if let Some(project) = &poetry { + let found = poetry_project_site_packages(cwd, project, var).await; + if !found.is_empty() { + return found; + } + } else { + let pdm_ignores_active = + pdm_env_flag(var, "PDM_IGNORE_ACTIVE_VENV") && pdm_drives_project(cwd).await; + let active_prefix = if !pdm_ignores_active && (!pipenv || pipenv_uses_virtual_env(var)) { + var("VIRTUAL_ENV") + } else { + None + }; + if let Some(prefix) = active_prefix { + let found = find_site_packages_under(Path::new(&prefix), "site-packages").await; + if !found.is_empty() { + return found; + } } } @@ -391,20 +391,6 @@ async fn find_local_venv_site_packages_with( return pipenv_project_site_packages(cwd, var).await; } - // 3. Poetry decides for itself whether `./.venv` is the project's env - // (`EnvManager.in_project_venv_exists`): only an existing `./.venv`, and - // only when `virtualenvs.in-project` is not explicitly `false`. When - // Poetry would NOT use `./.venv` (`in-project = false`, or no `.venv` at - // all, even with `in-project = true`), its out-of-tree env is probed - // first so a stray `.venv` / `venv` left by another tool does not shadow - // the env Poetry installed into. - if let Some(project) = poetry.as_ref().filter(|p| !p.in_project_venv_exists(cwd)) { - let found = poetry_virtualenv_site_packages(cwd, project, var).await; - if !found.is_empty() { - return found; - } - } - // 4. Check .venv and venv in cwd for venv_dir in &[".venv", "venv"] { let venv_path = cwd.join(venv_dir); @@ -737,11 +723,15 @@ struct PoetryVirtualenvConfig { /// `virtualenvs.in-project` — `true` means `./.venv` once it exists; /// until then Poetry keeps using its out-of-tree env. in_project: Option, - /// `virtualenvs.path` — may carry Poetry's `{cache-dir}` / - /// `{project-dir}` placeholders and a leading `~`. + /// `virtualenvs.path` — may carry `{key}` placeholders (see + /// [`poetry_process`]) and a leading `~`. path: Option, - /// `cache-dir` — the parent of the default `virtualenvs` root. + /// `cache-dir` — the parent of the default `virtualenvs` root. Goes + /// through the same placeholder processing as `path`. cache_dir: Option, + /// `data-dir` (defaulted since Poetry 2.1) — what `{data-dir}` expands to; see + /// [`poetry_default_data_dirs`]. + data_dir: Option, } impl PoetryVirtualenvConfig { @@ -752,6 +742,7 @@ impl PoetryVirtualenvConfig { self.in_project = self.in_project.or(other.in_project); self.path = self.path.or(other.path); self.cache_dir = self.cache_dir.or(other.cache_dir); + self.data_dir = self.data_dir.or(other.data_dir); self } @@ -767,6 +758,7 @@ impl PoetryVirtualenvConfig { in_project: flag("POETRY_VIRTUALENVS_IN_PROJECT"), path: var("POETRY_VIRTUALENVS_PATH").filter(|v| !v.trim().is_empty()), cache_dir: var("POETRY_CACHE_DIR").filter(|v| !v.trim().is_empty()), + data_dir: var("POETRY_DATA_DIR").filter(|v| !v.trim().is_empty()), } } @@ -802,6 +794,10 @@ impl PoetryVirtualenvConfig { .get("cache-dir") .and_then(toml_edit::Item::as_str) .map(str::to_string), + data_dir: doc + .get("data-dir") + .and_then(toml_edit::Item::as_str) + .map(str::to_string), } } } @@ -970,10 +966,10 @@ fn poetry_project_names(pyproject: &str) -> Vec { names } -/// The root directory Poetry would place this project's virtualenvs under, -/// or `None` when Poetry would not create one (`virtualenvs.create = false`, -/// or no home to resolve the default against). `virtualenvs.in-project` -/// does not change it: with no `./.venv`, Poetry keeps using the env here. +/// The current model's path, for pure configuration tests. Discovery +/// considers every compatible placement instead of guessing the version +/// from the existence of a parent directory. +#[cfg(test)] fn poetry_virtualenvs_root( cwd: &Path, config: &PoetryVirtualenvConfig, @@ -982,36 +978,225 @@ fn poetry_virtualenvs_root( if config.create == Some(false) { return None; } - poetry_virtualenvs_path(cwd, config, var) + poetry_virtualenvs_paths(cwd, config, var) + .into_iter() + .next() } -/// Poetry's `config.virtualenvs_path`: `virtualenvs.path` with its -/// placeholders and `~` expanded, else `/virtualenvs`. Also where -/// `envs.toml` lives, which Poetry reads whatever `virtualenvs.create` says. -fn poetry_virtualenvs_path( +/// Possible `Config.virtualenvs_path` values, most recent model first. +/// Older Poetry lacks the default data-dir setting, and older macOS +/// platformdirs ignores XDG_DATA_HOME. None of these roots wins merely +/// because a different project created its parent directory. +fn poetry_virtualenvs_paths( cwd: &Path, config: &PoetryVirtualenvConfig, var: &impl Fn(&str) -> Option, -) -> Option { - let cache_dir = config - .cache_dir - .as_deref() - .map(|c| expand_home(c, var)) - .or_else(|| poetry_default_cache_dir(var))?; - match config.path.as_deref() { - Some(template) => { - let expanded = template - .replace("{cache-dir}", &cache_dir.to_string_lossy()) - .replace("{project-dir}", &cwd.to_string_lossy()); - let path = expand_home(&expanded, var); - Some(if path.is_absolute() { - path +) -> Vec { + let defaults = poetry_default_data_dirs(var); + let mut candidates = Vec::new(); + for generation in [ + PoetryPlaceholders::Current, + PoetryPlaceholders::NoDataDir, + PoetryPlaceholders::Poetry11, + ] { + for data_dir in &defaults { + if let Some(path) = + poetry_virtualenvs_path_for(cwd, config, data_dir.as_deref(), generation, var) + { + if !candidates.contains(&path) { + candidates.push(path); + } + } + } + } + candidates +} + +/// Resolve only referenced settings. In particular an unused cyclic +/// data-dir must not invalidate an explicit virtualenvs.path. There are +/// only two supported keys; detecting a repeated key bounds recursion. +struct PoetryPathResolver<'a, F> { + config: &'a PoetryVirtualenvConfig, + default_data: Option<&'a Path>, + generation: PoetryPlaceholders, + var: &'a F, + resolving: Vec<&'static str>, +} + +impl Option> PoetryPathResolver<'_, F> { + fn process(&mut self, value: &str) -> Option { + let mut invalid = false; + let generation = self.generation; + let result = poetry_process(value, generation, |key| match self.resolve(key) { + Ok(value) => value, + Err(()) => { + invalid = true; + None + } + }); + (!invalid).then_some(result) + } + + fn resolve(&mut self, key: &str) -> Result, ()> { + let (key, raw) = match key { + "cache-dir" => ( + "cache-dir", + self.config.cache_dir.clone().or_else(|| { + poetry_default_cache_dir(self.var).map(|p| p.to_string_lossy().into_owned()) + }), + ), + "data-dir" => ( + "data-dir", + self.config.data_dir.clone().or_else(|| { + // Explicit file/env keys work in older Poetry too; only + // the default setting was introduced in Poetry 2.1. + (self.generation == PoetryPlaceholders::Current) + .then(|| self.default_data.map(|p| p.to_string_lossy().into_owned())) + .flatten() + }), + ), + _ => return Ok(None), + }; + let Some(raw) = raw else { + // A cache-dir exists in every supported generation. Without + // its home/default we cannot infer a literal relative path. + return if key == "cache-dir" { + Err(()) } else { - cwd.join(path) - }) + Ok(None) + }; + }; + if self.resolving.contains(&key) { + return Err(()); + } + self.resolving.push(key); + let result = self.process(&raw).map(Some).ok_or(()); + self.resolving.pop(); + result + } +} + +fn poetry_virtualenvs_path_for( + cwd: &Path, + config: &PoetryVirtualenvConfig, + data_dir: Option<&Path>, + generation: PoetryPlaceholders, + var: &impl Fn(&str) -> Option, +) -> Option { + let mut resolver = PoetryPathResolver { + config, + default_data: data_dir, + generation, + var, + resolving: Vec::new(), + }; + let template = config.path.as_deref().unwrap_or("{cache-dir}/virtualenvs"); + let processed = resolver.process(template)?; + let path = expand_home(&processed, var); + Some(if path.is_absolute() { + path + } else { + cwd.join(path) + }) +} + +/// How a Poetry generation treats `{key}` placeholders in a config value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PoetryPlaceholders { + /// Poetry >= 2.1: `{cache-dir}` and `{data-dir}` resolve, any other + /// `{key}` is kept literally. + Current, + /// Poetry 1.2 - 2.0: no default `data-dir` setting; an explicit + /// file/environment value still resolves like any configured key. + NoDataDir, + /// Poetry 1.1: an unknown `{key}` is replaced with nothing. + Poetry11, +} + +/// Poetry's `Config.process()`: every `{key}` (non-greedy, as in +/// `re.sub(r"{(.+?)}", ...)`) is replaced with the value `resolve` gives +/// for that config key. A key with no value is kept as-is, except on +/// Poetry 1.1, which drops it. Poetry has no `{project-dir}` key (#608). +fn poetry_process( + value: &str, + generation: PoetryPlaceholders, + mut resolve: impl FnMut(&str) -> Option, +) -> String { + let mut out = String::with_capacity(value.len()); + let mut rest = value; + while let Some(open) = rest.find('{') { + let after = &rest[open + 1..]; + // `.+?` needs at least one character before the closing brace. + let close = after + .char_indices() + .skip(1) + .find(|&(_, c)| c == '}') + .map(|(i, _)| i); + let Some(close) = close else { + break; + }; + out.push_str(&rest[..open]); + let key = &after[..close]; + match resolve(key) { + Some(resolved) => out.push_str(&resolved), + None if generation == PoetryPlaceholders::Poetry11 => {} + None => { + out.push('{'); + out.push_str(key); + out.push('}'); + } } - None => Some(cache_dir.join("virtualenvs")), + rest = &after[close + 1..]; } + out.push_str(rest); + out +} + +/// Config defaults differ from the installer: current macOS +/// platformdirs honors XDG_DATA_HOME, but older supported releases use +/// Library/Application Support. Keep both until project evidence resolves +/// placement; POETRY_HOME overrides either dependency generation. +fn poetry_default_data_dirs(var: &impl Fn(&str) -> Option) -> Vec> { + let installer = poetry_installer_data_dir(var); + let mut dirs = Vec::new(); + if cfg!(target_os = "macos") && var("POETRY_HOME").is_none_or(|v| v.trim().is_empty()) { + if let Some(xdg) = var("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + { + dirs.push(Some(xdg.join("pypoetry"))); + } + } + if !dirs.contains(&installer) { + dirs.push(installer); + } + dirs +} + +/// The official installer's data directory: POETRY_HOME, otherwise its +/// platform default. On macOS the installer itself uses Library even +/// when Poetry's platformdirs dependency honors XDG_DATA_HOME. +fn poetry_installer_data_dir(var: &impl Fn(&str) -> Option) -> Option { + if let Some(home) = var("POETRY_HOME").filter(|v| !v.trim().is_empty()) { + return Some(expand_home(&home, var)); + } + let home = var("HOME") + .or_else(|| var("USERPROFILE")) + .map(PathBuf::from); + let base = if cfg!(windows) { + var("APPDATA") + .filter(|v| !v.trim().is_empty()) + .map(PathBuf::from) + .or_else(|| home.map(|h| h.join("AppData").join("Roaming")))? + } else if cfg!(target_os = "macos") { + home?.join("Library").join("Application Support") + } else { + var("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| home.map(|h| h.join(".local").join("share")))? + }; + Some(base.join("pypoetry")) } fn expand_home(raw: &str, var: &impl Fn(&str) -> Option) -> PathBuf { @@ -1037,59 +1222,48 @@ fn expand_home(raw: &str, var: &impl Fn(&str) -> Option) -> PathBuf { pub async fn find_poetry_virtualenv_site_packages(cwd: &Path) -> Vec { let var = |name: &str| std::env::var(name).ok(); match load_poetry_project(cwd, &var).await { - Some(project) => poetry_virtualenv_site_packages(cwd, &project, &var).await, + Some(project) => poetry_virtualenv_site_packages(&project).await, None => Vec::new(), } } -/// What venv discovery needs to know about a Poetry project: the candidate -/// env names, the layered `virtualenvs.*` configuration, and the env -/// `poetry env use` activated for it, if any. +/// One compatible placement, with its activation record kept in the +/// same root. Version-dependent roots must never share an activated minor. +struct PoetryPlacement { + root: PathBuf, + activated: Option, + env_names: Vec, +} + struct PoetryProject { - names: Vec, config: PoetryVirtualenvConfig, - /// The `--py` directory `envs.toml` records for the - /// project (see [`poetry_activated_env`]). - activated: Option, + placements: Vec, } impl PoetryProject { - /// Poetry's `EnvManager.in_project_venv_exists`: `./.venv` is the env - /// only when it is a directory and `virtualenvs.in-project` is not an - /// explicit `false` (`use_in_project_venv`). `in-project = true` with no - /// `./.venv` falls through to the out-of-tree env like an unset one. + /// An existing `./.venv` wins unless `in-project` is explicitly false. + /// Setting it true with no directory still allows an out-of-tree env. fn in_project_venv_exists(&self, cwd: &Path) -> bool { self.config.in_project != Some(false) && cwd.join(".venv").is_dir() } } -/// The prefix of the venv Poetry would take from the shell instead of the -/// project's own env, per `EnvManager.get()`: `VIRTUAL_ENV`, else -/// `CONDA_PREFIX`, but not inside conda's `base` env, and only when -/// `envs.toml` has no entry for the project (`poetry env use` wins over an -/// activated venv). +/// EnvManager.get takes VIRTUAL_ENV, then a non-base CONDA_PREFIX, only +/// when this placement has no `poetry env use` record for the project. fn poetry_active_prefix( - project: &PoetryProject, + placement: Option<&PoetryPlacement>, var: &impl Fn(&str) -> Option, ) -> Option { - if project.activated.is_some() { + if placement.is_some_and(|p| p.activated.is_some()) { return None; } let prefix = var("VIRTUAL_ENV").or_else(|| var("CONDA_PREFIX"))?; (var("CONDA_DEFAULT_ENV").as_deref() != Some("base")).then_some(prefix) } -/// The env directory `envs.toml` (under `virtualenvs.path`) records for the -/// project: `[-] minor = "X.Y"` names `--pyX.Y`, -/// what `poetry env use` writes and `EnvManager.get()` reads first. Takes -/// the first candidate name (in Poetry's precedence order) with an entry. -async fn poetry_activated_env( - cwd: &Path, - names: &[String], - config: &PoetryVirtualenvConfig, - var: &impl Fn(&str) -> Option, -) -> Option { - let root = poetry_virtualenvs_path(cwd, config, var)?; +/// Read this root's first matching `[-] minor = "X.Y"` +/// record. An activation must never borrow a minor from another root. +async fn poetry_activated_env(cwd: &Path, names: &[String], root: &Path) -> Option { let text = read_regular_to_string(&root.join("envs.toml")).await.ok()?; let doc = text.parse::().ok()?; let normalized = poetry_normalized_cwd(cwd); @@ -1100,8 +1274,7 @@ async fn poetry_activated_env( }) } -/// `None` for a non-Poetry project (no `poetry.lock`, `poetry.toml` or -/// `[tool.poetry`) or an unreadable / unparseable `pyproject.toml`. +/// `None` for a non-Poetry project or unreadable/unparseable pyproject. async fn load_poetry_project( cwd: &Path, var: &impl Fn(&str) -> Option, @@ -1131,56 +1304,118 @@ async fn load_poetry_project( None => PoetryVirtualenvConfig::default(), }; let config = PoetryVirtualenvConfig::from_env(var).or(local).or(user); - let activated = poetry_activated_env(cwd, &names, &config, var).await; - Some(PoetryProject { - names, - config, - activated, - }) + let mut placements = Vec::new(); + for root in poetry_virtualenvs_paths(cwd, &config, var) { + let activated = poetry_activated_env(cwd, &names, &root).await; + let env_names = match &activated { + Some(name) => vec![name.clone()], + None => poetry_env_names(cwd, &names, &root).await, + }; + // A missing root may still use the active shell in that Poetry + // generation. Keep its precedence independent of another root's + // activation, but only enumerate envs owned by this project. + placements.push(PoetryPlacement { + root, + activated, + env_names, + }); + } + Some(PoetryProject { config, placements }) } -/// The out-of-tree venvs for `project`: the env `envs.toml` activated when -/// there is one (Poetry uses nothing else), otherwise the first candidate -/// name (in Poetry's precedence order) that has at least one -/// `--py*` dir. -async fn poetry_virtualenv_site_packages( - cwd: &Path, - project: &PoetryProject, - var: &impl Fn(&str) -> Option, -) -> Vec { - let Some(root) = poetry_virtualenvs_root(cwd, &project.config, var) else { - return Vec::new(); - }; - if let Some(activated) = &project.activated { - return find_site_packages_under(&root.join(activated), "site-packages").await; - } - let Ok(mut entries) = tokio::fs::read_dir(&root).await else { +/// Matching environment directories for the first project-name spelling +/// present in one root, preserving Poetry's existing name precedence. +async fn poetry_env_names(cwd: &Path, names: &[String], root: &Path) -> Vec { + let Ok(mut entries) = tokio::fs::read_dir(root).await else { return Vec::new(); }; let mut dir_names = Vec::new(); while let Ok(Some(entry)) = entries.next_entry().await { - if let Some(name) = entry.file_name().to_str() { - dir_names.push(name.to_string()); + if crate::utils::fs::entry_is_dir(&entry).await { + if let Some(name) = entry.file_name().to_str() { + dir_names.push(name.to_string()); + } } } let normalized = poetry_normalized_cwd(cwd); - let mut venvs: Vec = project - .names + let mut matched = names .iter() .map(|name| format!("{}-py", poetry_env_name_prefix(name, &normalized))) .map(|prefix| { dir_names .iter() - .filter(|dir| dir.starts_with(&prefix)) - .map(|dir| root.join(dir)) + .filter(|name| name.starts_with(&prefix)) + .cloned() .collect::>() }) .find(|found| !found.is_empty()) .unwrap_or_default(); - venvs.sort(); + matched.sort(); + matched +} + +async fn poetry_placement_site_packages(placement: &PoetryPlacement) -> Vec { let mut results = Vec::new(); - for venv in venvs { - results.extend(find_site_packages_under(&venv, "site-packages").await); + for name in &placement.env_names { + results.extend(find_site_packages_under(&placement.root.join(name), "site-packages").await); + } + results +} + +async fn poetry_virtualenv_site_packages(project: &PoetryProject) -> Vec { + if project.config.create == Some(false) { + return Vec::new(); + } + let mut results = Vec::new(); + for placement in &project.placements { + for path in poetry_placement_site_packages(placement).await { + if !results.contains(&path) { + results.push(path); + } + } + } + results +} + +/// Apply EnvManager.get's precedence separately for each compatible root. +/// With no recorded environment anywhere, the ordinary active/in-project +/// rules still apply once. Unioning only afterwards preserves both +/// runtimes when one Poetry generation recorded an env and another uses +/// the active shell; single-root activation still vetoes an unrelated shell. +async fn poetry_project_site_packages( + cwd: &Path, + project: &PoetryProject, + var: &impl Fn(&str) -> Option, +) -> Vec { + let placements: Vec> = if project.placements.is_empty() { + vec![None] + } else { + project.placements.iter().map(Some).collect() + }; + let mut results = Vec::new(); + for placement in placements { + let mut found = Vec::new(); + if let Some(active) = poetry_active_prefix(placement, var) { + found = find_site_packages_under(Path::new(&active), "site-packages").await; + } + if found.is_empty() { + if project.in_project_venv_exists(cwd) { + found = find_site_packages_under(&cwd.join(".venv"), "site-packages").await; + // Keep the existing local inventory when Poetry selects + // its in-project env. A healthy .venv must not hide stale + // bytes in a sibling venv from hosted verification/VEX. + found.extend(find_site_packages_under(&cwd.join("venv"), "site-packages").await); + } else if project.config.create != Some(false) { + if let Some(placement) = placement { + found = poetry_placement_site_packages(placement).await; + } + } + } + for path in found { + if !results.contains(&path) { + results.push(path); + } + } } results } @@ -1621,7 +1856,8 @@ fn run_site_query() -> Option { /// /// Queries `python3` for site-packages paths, then checks well-known system /// locations including Homebrew, conda, uv tools and interpreters, pipx -/// venvs, PDM's global project and interpreters, pip --user, etc. +/// venvs, Poetry's installer venv, PDM's global project and interpreters, +/// pip --user, etc. pub async fn get_global_python_site_packages() -> Vec { let mut results = Vec::new(); let mut seen = HashSet::new(); @@ -1825,6 +2061,31 @@ pub async fn get_global_python_site_packages() -> Vec { } } + // Poetry's own venv from the official installer + // (`install.python-poetry.org`): `/venv`, where the data dir + // is `$POETRY_HOME` or the installer's platform default (#640). Both are + // scanned: an install made before `POETRY_HOME` was set (or unset) is + // still a real install. + { + let var = |name: &str| std::env::var(name).ok(); + let without_poetry_home = |name: &str| { + if name == "POETRY_HOME" { + None + } else { + std::env::var(name).ok() + } + }; + let data_dirs = [ + poetry_installer_data_dir(&var), + poetry_installer_data_dir(&without_poetry_home), + ]; + for data_dir in data_dirs.into_iter().flatten() { + for m in find_env_site_packages(&data_dir.join("venv")).await { + add_path(m, &mut seen, &mut results); + } + } + } + // uv-managed Python interpreters (`uv python install 3.X`), one per // child of uv's python dir (`cpython-3.X.*-`). The typical // flow is `uv venv` + `uv pip install`, where the venv layout is @@ -3547,13 +3808,15 @@ mod tests { poetry_virtualenvs_root(cwd, &merged, &var), Some(PathBuf::from("/srv/poetry-cache/venvs")) ); + // Poetry has no `{project-dir}` key, so `Config.process()` keeps + // the text and the relative result lands under the cwd (#608). let project_local = PoetryVirtualenvConfig { path: Some("{project-dir}/.envs".into()), ..Default::default() }; assert_eq!( poetry_virtualenvs_root(cwd, &project_local, &var), - Some(PathBuf::from("/home/dev/proj/.envs")) + Some(PathBuf::from("/home/dev/proj/{project-dir}/.envs")) ); let tilde = PoetryVirtualenvConfig { path: Some("~/venvs".into()), @@ -3588,6 +3851,147 @@ mod tests { ); } + /// `virtualenvs.path` and `cache-dir` go through Poetry's + /// `Config.process()`: `{cache-dir}` and `{data-dir}` (Poetry >= 2.1) + /// are substituted, any other `{key}` is kept literally (#608). + #[test] + fn poetry_virtualenvs_path_mirrors_config_process() { + let cwd = Path::new("/home/dev/proj"); + let root = |config: PoetryVirtualenvConfig, vars: &[(&str, &str)]| { + let vars: Vec<(String, String)> = vars + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let var = move |k: &str| { + vars.iter() + .find(|(name, _)| name == k) + .map(|(_, v)| v.clone()) + }; + poetry_virtualenvs_root(cwd, &config, &var) + }; + let path = |p: &str| PoetryVirtualenvConfig { + path: Some(p.into()), + ..Default::default() + }; + + // `{data-dir}` follows the `data-dir` setting (POETRY_DATA_DIR, then + // the config files), then POETRY_HOME. + let env = |k: &str| match k { + "POETRY_DATA_DIR" => Some("/srv/pd".to_string()), + _ => None, + }; + assert_eq!( + root( + PoetryVirtualenvConfig::from_env(env).or(path("{data-dir}/venvs")), + &[("HOME", "/home/dev"), ("POETRY_HOME", "/opt/poetry")] + ), + Some(PathBuf::from("/srv/pd/venvs")) + ); + assert_eq!( + root( + path("{data-dir}/venvs"), + &[("HOME", "/home/dev"), ("POETRY_HOME", "/opt/poetry")] + ), + Some(PathBuf::from("/opt/poetry/venvs")) + ); + let from_toml = PoetryVirtualenvConfig::from_toml( + "data-dir = \"~/pdata\"\n[virtualenvs]\npath = \"{data-dir}/venvs\"\n", + ); + assert_eq!( + root( + from_toml, + &[("HOME", "/home/dev"), ("POETRY_HOME", "/opt/poetry")] + ), + Some(PathBuf::from("/home/dev/pdata/venvs")) + ); + let env = PoetryVirtualenvConfig::from_env(|k| match k { + "POETRY_DATA_DIR" => Some("/env/pd".into()), + _ => None, + }); + assert_eq!(env.data_dir.as_deref(), Some("/env/pd")); + + // `cache-dir` is processed too, so `{data-dir}` inside it moves + // the default `/virtualenvs` root. + let cache = PoetryVirtualenvConfig { + cache_dir: Some("{data-dir}/cache".into()), + ..Default::default() + }; + assert_eq!( + root(cache, &[("HOME", "/home/dev"), ("POETRY_HOME", "/srv/pd")]), + Some(PathBuf::from("/srv/pd/cache/virtualenvs")) + ); + + // Unknown keys stay literal; `{cache-dir}` still resolves next to + // them. + let unknown = PoetryVirtualenvConfig { + cache_dir: Some("/c".into()), + ..path("{cache-dir}/{nope}/venvs") + }; + assert_eq!( + root(unknown, &[("HOME", "/home/dev")]), + Some(PathBuf::from("/c/{nope}/venvs")) + ); + } + + /// Poetry's default data dir is platformdirs' roaming user data dir. + #[cfg(all(not(target_os = "macos"), not(windows)))] + #[test] + fn poetry_data_dir_defaults_to_xdg_data_home_on_linux() { + let cwd = Path::new("/home/dev/proj"); + let config = PoetryVirtualenvConfig { + path: Some("{data-dir}/venvs".into()), + ..Default::default() + }; + let home_only = |k: &str| (k == "HOME").then(|| "/home/dev".to_string()); + assert_eq!( + poetry_virtualenvs_root(cwd, &config, &home_only), + Some(PathBuf::from("/home/dev/.local/share/pypoetry/venvs")) + ); + let xdg = |k: &str| match k { + "HOME" => Some("/home/dev".to_string()), + "XDG_DATA_HOME" => Some("/xdg".to_string()), + _ => None, + }; + assert_eq!( + poetry_virtualenvs_root(cwd, &config, &xdg), + Some(PathBuf::from("/xdg/pypoetry/venvs")) + ); + // platformdirs ignores a relative XDG_DATA_HOME. + let relative = |k: &str| match k { + "HOME" => Some("/home/dev".to_string()), + "XDG_DATA_HOME" => Some("rel".to_string()), + _ => None, + }; + assert_eq!( + poetry_virtualenvs_root(cwd, &config, &relative), + Some(PathBuf::from("/home/dev/.local/share/pypoetry/venvs")) + ); + } + + /// Poetry 1.1 drops unknown placeholders. Its project-owned env must + /// remain visible even if an unrelated literal-generation parent exists. + #[cfg(not(windows))] + #[tokio::test] + async fn poetry_virtualenvs_path_falls_back_to_poetry_1_1_placement() { + let tmp = tempfile::tempdir().unwrap(); + let (project, legacy, prefix) = poetry_fixture(tmp.path(), "", &["3.11"]); + let template = format!("{{project-dir}}{}", legacy.display()); + std::fs::write( + project.join("poetry.toml"), + format!("[virtualenvs]\npath = {template:?}\n"), + ) + .unwrap(); + std::fs::create_dir_all(project.join(&template)).unwrap(); + let var = poetry_env(tmp.path(), &[]); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![poetry_site( + &legacy.join(format!("{prefix}-py3.11")), + "3.11" + )] + ); + } + /// End to end against the filesystem: a Poetry project with no `.venv` /// finds the virtualenv(s) Poetry placed under `virtualenvs.path`, every /// interpreter minor, and stops looking once the project opts into @@ -3970,6 +4374,211 @@ mod tests { ); } + #[test] + fn poetry_data_dir_recursively_resolves_only_referenced_settings() { + let cwd = Path::new("/project"); + let var = |key: &str| (key == "HOME").then(|| "/home/dev".to_string()); + let config = PoetryVirtualenvConfig::from_toml( + "cache-dir = '/cache'\ndata-dir = '{cache-dir}/data'\n[virtualenvs]\npath = '{data-dir}/venvs'\n", + ); + assert_eq!( + poetry_virtualenvs_root(cwd, &config, &var), + Some(PathBuf::from("/cache/data/venvs")) + ); + // Explicit settings exist even in generations without a default + // data-dir. Each model therefore resolves to the same path. + assert_eq!( + poetry_virtualenvs_paths(cwd, &config, &var), + vec![PathBuf::from("/cache/data/venvs")] + ); + let cyclic = PoetryVirtualenvConfig::from_toml( + "cache-dir = '{data-dir}/cache'\ndata-dir = '{cache-dir}/data'\n[virtualenvs]\npath = '{data-dir}/venvs'\n", + ); + assert_eq!(poetry_virtualenvs_root(cwd, &cyclic, &var), None); + let explicit = PoetryVirtualenvConfig { + path: Some("/explicit/venvs".into()), + ..cyclic + }; + assert_eq!( + poetry_virtualenvs_root(cwd, &explicit, &var), + Some(PathBuf::from("/explicit/venvs")), + "an unused cyclic key must not invalidate an explicit path" + ); + } + + #[tokio::test] + async fn poetry_in_project_keeps_local_inventory_without_overriding_selected_env() { + let tmp = tempfile::tempdir().unwrap(); + let (project, root, prefix) = poetry_fixture(tmp.path(), "", &["3.11"]); + let local = poetry_site(&project.join(".venv"), "3.12"); + let sibling = poetry_site(&project.join("venv"), "3.13"); + std::fs::create_dir_all(&local).unwrap(); + std::fs::create_dir_all(&sibling).unwrap(); + let var = poetry_env(tmp.path(), &[]); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![local, sibling], + "a healthy .venv must not hide a stale copy in the local venv inventory" + ); + + let active = tmp.path().join("active"); + let active_site = poetry_site(&active, "3.14"); + std::fs::create_dir_all(&active_site).unwrap(); + let active_env = [("VIRTUAL_ENV", active.to_string_lossy().into_owned())]; + let active_var = poetry_env(tmp.path(), &active_env); + assert_eq!( + find_local_venv_site_packages_with(&project, &active_var).await, + vec![active_site], + "an explicitly selected shell env still takes precedence over local inventory" + ); + std::fs::write( + project.join("poetry.toml"), + format!( + "[virtualenvs]\npath = {:?}\nin-project = false\n", + root.to_string_lossy() + ), + ) + .unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![poetry_site(&root.join(format!("{prefix}-py3.11")), "3.11")], + "a selected out-of-tree env still takes precedence over local inventory" + ); + } + + #[tokio::test] + async fn poetry_placeholder_generations_keep_each_project_env_and_activation() { + let tmp = tempfile::tempdir().unwrap(); + let (project, _, prefix) = poetry_fixture(tmp.path(), "", &[]); + std::fs::write( + project.join("poetry.toml"), + "[virtualenvs]\npath = '{data-dir}/venvs'\n", + ) + .unwrap(); + let data = tmp.path().join("data"); + let current = data.join("venvs"); + let legacy = project.join("{data-dir}").join("venvs"); + let modern311 = poetry_site(¤t.join(format!("{prefix}-py3.11")), "3.11"); + let modern312 = poetry_site(¤t.join(format!("{prefix}-py3.12")), "3.12"); + let legacy311 = poetry_site(&legacy.join(format!("{prefix}-py3.11")), "3.11"); + let legacy312 = poetry_site(&legacy.join(format!("{prefix}-py3.12")), "3.12"); + std::fs::create_dir_all(&legacy311).unwrap(); + std::fs::create_dir_all(&legacy312).unwrap(); + std::fs::create_dir_all(poetry_site(¤t.join("other-AAAAAAAA-py3.11"), "3.11")) + .unwrap(); + std::fs::write( + current.join("envs.toml"), + "[other-AAAAAAAA]\nminor = '3.11'\n", + ) + .unwrap(); + let env = [("POETRY_HOME", data.to_string_lossy().into_owned())]; + let var = poetry_env(tmp.path(), &env); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![legacy311.clone(), legacy312.clone()], + "an unrelated current-generation parent must not hide the legacy project's env" + ); + // Both versions can be installed and keep environments for the same + // project. Each envs.toml must select a minor only within its own root. + std::fs::create_dir_all(&modern311).unwrap(); + std::fs::create_dir_all(&modern312).unwrap(); + std::fs::write( + current.join("envs.toml"), + format!("[{prefix}]\nminor = '3.12'\n"), + ) + .unwrap(); + std::fs::write( + legacy.join("envs.toml"), + format!("[{prefix}]\nminor = '3.11'\n"), + ) + .unwrap(); + let expected = vec![modern312.clone(), legacy311.clone()]; + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + expected + ); + let active = tmp.path().join("unrelated-active"); + let active_site = poetry_site(&active, "3.11"); + std::fs::create_dir_all(&active_site).unwrap(); + let env = [ + ("POETRY_HOME", data.to_string_lossy().into_owned()), + ("VIRTUAL_ENV", active.to_string_lossy().into_owned()), + ]; + let var = poetry_env(tmp.path(), &env); + // A different generation can use the active shell even when its + // root has never been created. Only the legacy activation remains. + std::fs::remove_dir_all(¤t).unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![active_site.clone(), legacy311.clone()] + ); + let no_active = [("POETRY_HOME", data.to_string_lossy().into_owned())]; + let no_active_var = poetry_env(tmp.path(), &no_active); + std::fs::write( + project.join("poetry.toml"), + "[virtualenvs]\npath = '{data-dir}/venvs'\ncreate = false\n", + ) + .unwrap(); + assert!(find_local_venv_site_packages_with(&project, &no_active_var) + .await + .is_empty()); + std::fs::write( + project.join("poetry.toml"), + "[virtualenvs]\npath = '{data-dir}/venvs'\n", + ) + .unwrap(); + let local = poetry_site(&project.join(".venv"), "3.11"); + std::fs::create_dir_all(&local).unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &no_active_var).await, + vec![local] + ); + std::fs::remove_dir_all(project.join(".venv")).unwrap(); + std::fs::remove_file(legacy.join("envs.toml")).unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![active_site] + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn poetry_data_dir_keeps_current_and_legacy_macos_platformdirs_envs() { + let tmp = tempfile::tempdir().unwrap(); + let (project, _, prefix) = poetry_fixture(tmp.path(), "", &[]); + std::fs::write( + project.join("poetry.toml"), + "[virtualenvs]\npath = '{data-dir}/venvs'\n", + ) + .unwrap(); + let xdg = tmp.path().join("xdg"); + let current = xdg.join("pypoetry").join("venvs"); + let legacy = tmp + .path() + .join("home/Library/Application Support/pypoetry/venvs"); + let current_site = poetry_site(¤t.join(format!("{prefix}-py3.11")), "3.11"); + let legacy_site = poetry_site(&legacy.join(format!("{prefix}-py3.12")), "3.12"); + std::fs::create_dir_all(¤t_site).unwrap(); + let env = [("XDG_DATA_HOME", xdg.to_string_lossy().into_owned())]; + let var = poetry_env(tmp.path(), &env); + assert_eq!( + poetry_installer_data_dir(&var), + Some(tmp.path().join("home/Library/Application Support/pypoetry")), + "the official macOS installer does not use XDG_DATA_HOME" + ); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![current_site.clone()] + ); + // The same supported Poetry version may use an older platformdirs + // dependency. Retain that project's Library placement too. + std::fs::create_dir_all(&legacy_site).unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![current_site, legacy_site] + ); + } + #[test] fn test_canonicalize_pypi_name_basic() { assert_eq!(canonicalize_pypi_name("Requests"), "requests"); diff --git a/crates/socket-patch-core/tests/crawler_python_e2e.rs b/crates/socket-patch-core/tests/crawler_python_e2e.rs index b534ac9e7..f61a14cd3 100644 --- a/crates/socket-patch-core/tests/crawler_python_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_python_e2e.rs @@ -1764,3 +1764,141 @@ async fn read_python_metadata_missing_version_falls_back_to_dir_name() { let result = read_python_metadata(&dist_info).await; assert_eq!(result, Some(("requests".to_string(), "2.28.0".to_string()))); } + +// ── Poetry installer venv discovery (#640) ──────────────────── + +/// Run `get_global_python_site_packages` with HOME, POETRY_HOME, +/// XDG_DATA_HOME and APPDATA rebound (`None` unsets), restoring them +/// after. Poetry's official installer resolves its venv from these. +async fn global_site_packages_with_poetry_env( + home: &Path, + poetry_home: Option<&Path>, + xdg_data_home: Option<&Path>, + appdata: Option<&Path>, +) -> Vec { + let keys = ["HOME", "POETRY_HOME", "XDG_DATA_HOME", "APPDATA"]; + let saved: Vec<(&str, Option)> = keys + .into_iter() + .map(|k| (k, std::env::var(k).ok())) + .collect(); + std::env::set_var("HOME", home); + for (key, value) in [ + ("POETRY_HOME", poetry_home), + ("XDG_DATA_HOME", xdg_data_home), + ("APPDATA", appdata), + ] { + match value { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + } + let result = get_global_python_site_packages().await; + for (key, value) in saved { + match value { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + } + result +} + +/// The site-packages of the installer venv under Poetry's data dir. +fn poetry_installer_site_packages(data_dir: &Path) -> std::path::PathBuf { + let venv = data_dir.join("venv"); + if cfg!(windows) { + venv.join("Lib").join("site-packages") + } else { + venv.join("lib").join("python3.11").join("site-packages") + } +} + +/// The official installer (`install.python-poetry.org`) on Linux puts +/// Poetry and its dependencies in `~/.local/share/pypoetry/venv`. +#[cfg(all(not(target_os = "macos"), not(windows)))] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_poetry_installer_venv_linux() { + let tmp = tempfile::tempdir().unwrap(); + let sp = + poetry_installer_site_packages(&tmp.path().join(".local").join("share").join("pypoetry")); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + let result = global_site_packages_with_poetry_env(tmp.path(), None, None, None).await; + assert!( + result.iter().any(|p| p == &sp), + "Poetry installer venv must surface; got {result:?}" + ); +} + +/// The installer follows `$XDG_DATA_HOME` on Linux. +#[cfg(all(not(target_os = "macos"), not(windows)))] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_poetry_installer_venv_under_xdg() { + let tmp = tempfile::tempdir().unwrap(); + let xdg = tmp.path().join("xdg-data"); + let sp = poetry_installer_site_packages(&xdg.join("pypoetry")); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + let result = global_site_packages_with_poetry_env(tmp.path(), None, Some(&xdg), None).await; + assert!( + result.iter().any(|p| p == &sp), + "Poetry installer venv under XDG_DATA_HOME must surface; got {result:?}" + ); +} + +/// The installer's macOS default is `~/Library/Application Support/pypoetry`. +#[cfg(target_os = "macos")] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_poetry_installer_venv_macos() { + let tmp = tempfile::tempdir().unwrap(); + let sp = poetry_installer_site_packages( + &tmp.path() + .join("Library") + .join("Application Support") + .join("pypoetry"), + ); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + let result = global_site_packages_with_poetry_env(tmp.path(), None, None, None).await; + assert!( + result.iter().any(|p| p == &sp), + "macOS Poetry installer venv must surface; got {result:?}" + ); +} + +/// The installer's Windows default is `%APPDATA%\pypoetry`. +#[cfg(windows)] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_poetry_installer_venv_windows() { + let tmp = tempfile::tempdir().unwrap(); + let appdata = tmp.path().join("AppData").join("Roaming"); + let sp = poetry_installer_site_packages(&appdata.join("pypoetry")); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + let result = global_site_packages_with_poetry_env(tmp.path(), None, None, Some(&appdata)).await; + assert!( + result.iter().any(|p| p == &sp), + "%APPDATA%\\pypoetry venv must surface; got {result:?}" + ); +} + +/// `POETRY_HOME` relocates the installer venv to `$POETRY_HOME/venv`, on +/// every OS. +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_poetry_installer_venv_under_poetry_home() { + let tmp = tempfile::tempdir().unwrap(); + let poetry_home = tmp.path().join("opt").join("poetry"); + let sp = poetry_installer_site_packages(&poetry_home); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + let result = + global_site_packages_with_poetry_env(tmp.path(), Some(&poetry_home), None, None).await; + assert!( + result.iter().any(|p| p == &sp), + "$POETRY_HOME/venv must surface; got {result:?}" + ); +}