diff --git a/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/env_python_3/conda-meta/history b/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/env_python_3/conda-meta/history index 198ee3a3..fc09724d 100644 --- a/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/env_python_3/conda-meta/history +++ b/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/env_python_3/conda-meta/history @@ -1,8 +1,8 @@ ==> 2024-02-28 23:05:07 <== -# cmd: /Users/donjayamanne/Development/vsc/python-environment-tools/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/some_other_location/conda_install/bin/conda create -n conda1 +# cmd: /home/runner/work/python-environment-tools/python-environment-tools/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/some_other_location/conda_install/bin/conda create -n conda1 # conda version: 23.11.0 ==> 2024-02-28 23:08:59 <== -# cmd: /Users/donjayamanne/Development/vsc/python-environment-tools/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/some_other_location/conda_install/bin/conda install -c conda-forge --name conda1 ipykernel -y +# cmd: /home/runner/work/python-environment-tools/python-environment-tools/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/some_other_location/conda_install/bin/conda install -c conda-forge --name conda1 ipykernel -y # conda version: 23.11.0 +conda-forge/noarch::appnope-0.1.4-pyhd8ed1ab_0 +conda-forge/noarch::asttokens-2.4.1-pyhd8ed1ab_0 diff --git a/crates/pet-poetry/src/lib.rs b/crates/pet-poetry/src/lib.rs index 30960ae1..b4981d1b 100644 --- a/crates/pet-poetry/src/lib.rs +++ b/crates/pet-poetry/src/lib.rs @@ -3,6 +3,7 @@ use env_variables::EnvVariables; use environment_locations::list_environments; +use lazy_static::lazy_static; use log::trace; use manager::PoetryManager; use pet_core::{ @@ -13,8 +14,9 @@ use pet_core::{ Configuration, Locator, LocatorKind, LocatorResult, }; use pet_virtualenv::is_virtualenv; +use regex::Regex; use std::{ - path::PathBuf, + path::{Path, PathBuf}, sync::{Arc, Mutex}, }; use telemetry::report_missing_envs; @@ -28,6 +30,38 @@ pub mod manager; mod pyproject_toml; mod telemetry; +lazy_static! { + static ref POETRY_ENV_NAME_PATTERN: Regex = Regex::new(r"^.+-[A-Za-z0-9_-]{8}-py.*$") + .expect("Error generating RegEx for poetry environment name pattern"); +} + +/// Check if a path looks like a Poetry environment by examining the directory structure +/// Poetry environments typically have names like: {name}-{hash}-py{version} +/// and are located in cache directories or as .venv in project directories +fn is_poetry_environment(path: &Path) -> bool { + // Check if the environment is in a directory that looks like Poetry's virtualenvs cache + // Common patterns: + // - Linux: ~/.cache/pypoetry/virtualenvs/ + // - macOS: ~/Library/Caches/pypoetry/virtualenvs/ + // - Windows: %LOCALAPPDATA%\pypoetry\Cache\virtualenvs\ + let path_str = path.to_str().unwrap_or_default(); + + // Check if path contains typical Poetry cache directory structure + if path_str.contains("pypoetry") && path_str.contains("virtualenvs") { + // Further validate by checking if the directory name matches Poetry's naming pattern + // Pattern: {name}-{8-char-hash}-py or just .venv + if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) { + // Check for Poetry's hash-based naming: name-XXXXXXXX-py + // The hash is 8 characters of base64url encoding + if POETRY_ENV_NAME_PATTERN.is_match(dir_name) { + return true; + } + } + } + + false +} + pub trait PoetryLocator: Send + Sync { fn find_and_report_missing_envs( &self, @@ -153,6 +187,8 @@ impl Locator for Poetry { if !is_virtualenv(env) { return None; } + + // First, check if the environment is in our cache if let Some(result) = self.find_with_cache() { for found_env in result.environments { if let Some(symlinks) = &found_env.symlinks { @@ -162,6 +198,24 @@ impl Locator for Poetry { } } } + + // Fallback: Check if the path looks like a Poetry environment + // This handles cases where the environment wasn't discovered during find() + // (e.g., workspace directories not configured, or pyproject.toml not found) + if let Some(prefix) = &env.prefix { + if is_poetry_environment(prefix) { + trace!( + "Identified Poetry environment by path pattern: {:?}", + prefix + ); + return environment::create_poetry_env( + prefix, + prefix.clone(), // We don't have the project directory, use prefix + None, // No manager available in this fallback case + ); + } + } + None } diff --git a/crates/pet-poetry/tests/path_identification_test.rs b/crates/pet-poetry/tests/path_identification_test.rs new file mode 100644 index 00000000..83fdb8e0 --- /dev/null +++ b/crates/pet-poetry/tests/path_identification_test.rs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for Poetry environment identification by path pattern. +//! +//! This test module verifies that Poetry environments are correctly identified +//! even when they are not discovered during the find() phase. This can happen when: +//! - Workspace directories are not configured +//! - The pyproject.toml is not in the workspace directories +//! - The environment is in the Poetry cache but wasn't enumerated +//! +//! The fix adds a fallback path-based detection that checks if the environment +//! path matches Poetry's naming pattern ({name}-{8-char-hash}-py{version}) and +//! is located in a Poetry cache directory (containing "pypoetry/virtualenvs"). + +use std::path::PathBuf; + +#[cfg(test)] +mod tests { + use super::*; + + // Helper function to test the regex pattern matching + // This tests the core logic without needing actual filesystem structures + fn test_poetry_path_pattern(path_str: &str) -> bool { + use regex::Regex; + let path = PathBuf::from(path_str); + let path_str = path.to_str().unwrap_or_default(); + + if path_str.contains("pypoetry") && path_str.contains("virtualenvs") { + if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) { + let re = Regex::new(r"^.+-[A-Za-z0-9_-]{8}-py.*$").unwrap(); + return re.is_match(dir_name); + } + } + false + } + + #[test] + fn test_poetry_path_pattern_macos() { + assert!(test_poetry_path_pattern( + "/Users/eleanorboyd/Library/Caches/pypoetry/virtualenvs/nestedpoetry-yJwtIF_Q-py3.11" + )); + } + + #[test] + fn test_poetry_path_pattern_linux() { + assert!(test_poetry_path_pattern( + "/home/user/.cache/pypoetry/virtualenvs/myproject-a1B2c3D4-py3.10" + )); + } + + #[test] + fn test_poetry_path_pattern_windows() { + assert!(test_poetry_path_pattern( + r"C:\Users\user\AppData\Local\pypoetry\Cache\virtualenvs\myproject-f7sQRtG5-py3.11" + )); + } + + #[test] + fn test_poetry_path_pattern_no_version() { + assert!(test_poetry_path_pattern( + "/home/user/.cache/pypoetry/virtualenvs/testproject-XyZ12345-py" + )); + } + + #[test] + fn test_non_poetry_path_rejected() { + assert!(!test_poetry_path_pattern("/home/user/projects/myenv")); + assert!(!test_poetry_path_pattern("/home/user/.venv")); + assert!(!test_poetry_path_pattern("/usr/local/venv")); + } + + #[test] + fn test_poetry_path_without_pypoetry_rejected() { + // Should reject paths that look like the pattern but aren't in pypoetry directory + assert!(!test_poetry_path_pattern( + "/home/user/virtualenvs/myproject-a1B2c3D4-py3.10" + )); + } + + #[test] + fn test_poetry_path_wrong_hash_length_rejected() { + // Hash should be exactly 8 characters + assert!(!test_poetry_path_pattern( + "/home/user/.cache/pypoetry/virtualenvs/myproject-a1B2c3D456-py3.10" + )); + assert!(!test_poetry_path_pattern( + "/home/user/.cache/pypoetry/virtualenvs/myproject-a1B2c3-py3.10" + )); + } + + #[test] + fn test_real_world_poetry_paths() { + // Test actual Poetry paths from the bug report and real usage + assert!(test_poetry_path_pattern( + "/Users/eleanorboyd/Library/Caches/pypoetry/virtualenvs/nestedpoetry-yJwtIF_Q-py3.11" + )); + + // Another real-world example from documentation + assert!(test_poetry_path_pattern( + "/Users/donjayamanne/.cache/pypoetry/virtualenvs/poetry-demo-gNT2WXAV-py3.9" + )); + } +}