diff --git a/Cargo.lock b/Cargo.lock index 294f5f84..3ee3ea80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -602,6 +602,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "toml 0.8.14", ] diff --git a/crates/pet-poetry/Cargo.toml b/crates/pet-poetry/Cargo.toml index 9cf2577f..0d24f26a 100644 --- a/crates/pet-poetry/Cargo.toml +++ b/crates/pet-poetry/Cargo.toml @@ -22,5 +22,8 @@ sha2 = "0.10.6" base64 = "0.22.0" toml = "0.8.14" +[dev-dependencies] +tempfile = "3.12" + [features] ci = [] diff --git a/crates/pet-poetry/src/lib.rs b/crates/pet-poetry/src/lib.rs index b4981d1b..184d4cc8 100644 --- a/crates/pet-poetry/src/lib.rs +++ b/crates/pet-poetry/src/lib.rs @@ -35,10 +35,10 @@ lazy_static! { .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 a path looks like a Poetry environment in the cache directory +/// Poetry cache environments have names like: {name}-{hash}-py{version} +/// and are located in cache directories containing "pypoetry/virtualenvs" +fn is_poetry_cache_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/ @@ -62,6 +62,56 @@ fn is_poetry_environment(path: &Path) -> bool { false } +/// Check if a .venv directory is an in-project Poetry environment +/// This is for the case when virtualenvs.in-project = true is set. +/// We check if the parent directory has Poetry configuration files. +fn is_in_project_poetry_environment(path: &Path) -> bool { + // Check if this is a .venv directory + let dir_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + if dir_name != ".venv" { + return false; + } + + // Check if the parent directory has Poetry configuration + if let Some(parent) = path.parent() { + // Check for poetry.toml - a local Poetry configuration file + // Its presence indicates this project uses Poetry + let poetry_toml = parent.join("poetry.toml"); + if poetry_toml.is_file() { + trace!( + "Found in-project Poetry environment: {:?} with poetry.toml at {:?}", + path, + poetry_toml + ); + return true; + } + + // Check if pyproject.toml contains Poetry configuration + let pyproject_toml = parent.join("pyproject.toml"); + if pyproject_toml.is_file() { + if let Ok(contents) = std::fs::read_to_string(&pyproject_toml) { + // Look for [tool.poetry] or poetry as build backend + if contents.contains("[tool.poetry]") + || contents.contains("poetry.core.masonry.api") + || contents.contains("poetry-core") + { + trace!( + "Found in-project Poetry environment: {:?} with pyproject.toml at {:?}", + path, + pyproject_toml + ); + return true; + } + } + } + } + + false +} + pub trait PoetryLocator: Send + Sync { fn find_and_report_missing_envs( &self, @@ -203,9 +253,9 @@ impl Locator for Poetry { // 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) { + if is_poetry_cache_environment(prefix) { trace!( - "Identified Poetry environment by path pattern: {:?}", + "Identified Poetry environment by cache path pattern: {:?}", prefix ); return environment::create_poetry_env( @@ -214,6 +264,18 @@ impl Locator for Poetry { None, // No manager available in this fallback case ); } + + // Check for in-project .venv Poetry environment + if is_in_project_poetry_environment(prefix) { + trace!("Identified in-project Poetry environment: {:?}", prefix); + // For in-project .venv, the project directory is the parent + let project_dir = prefix.parent().unwrap_or(prefix).to_path_buf(); + return environment::create_poetry_env( + prefix, + project_dir, + 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 index ea7ad073..2c12c9c2 100644 --- a/crates/pet-poetry/tests/path_identification_test.rs +++ b/crates/pet-poetry/tests/path_identification_test.rs @@ -7,20 +7,25 @@ //! - 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 environment is an in-project .venv with virtualenvs.in-project = true //! -//! 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"). +//! The fix adds fallback path-based detection that checks: +//! 1. If the environment path matches Poetry's cache naming pattern +//! ({name}-{8-char-hash}-py{version}) in "pypoetry/virtualenvs" +//! 2. If the environment is an in-project .venv with Poetry configuration: +//! - poetry.toml exists in the parent directory, OR +//! - pyproject.toml contains [tool.poetry] or poetry-core build backend +use std::fs; use std::path::PathBuf; #[cfg(test)] mod tests { use super::*; - // Helper function to test the regex pattern matching + // Helper function to test the regex pattern matching for cache environments // This tests the core logic without needing actual filesystem structures - fn test_poetry_path_pattern(path_str: &str) -> bool { + fn test_poetry_cache_path_pattern(path_str: &str) -> bool { use regex::Regex; let path = PathBuf::from(path_str); let path_str = path.to_str().unwrap_or_default(); @@ -34,45 +39,81 @@ mod tests { false } + // Helper function to test in-project poetry environment detection + // Requires actual filesystem structure + fn test_in_project_poetry_env(path: &std::path::Path) -> bool { + // Check if this is a .venv directory + let dir_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + if dir_name != ".venv" { + return false; + } + + // Check if the parent directory has Poetry configuration + if let Some(parent) = path.parent() { + // Check for poetry.toml - a local Poetry configuration file + let poetry_toml = parent.join("poetry.toml"); + if poetry_toml.is_file() { + return true; + } + + // Check if pyproject.toml contains Poetry configuration + let pyproject_toml = parent.join("pyproject.toml"); + if pyproject_toml.is_file() { + if let Ok(contents) = std::fs::read_to_string(&pyproject_toml) { + if contents.contains("[tool.poetry]") + || contents.contains("poetry.core.masonry.api") + || contents.contains("poetry-core") + { + return true; + } + } + } + } + false + } + #[test] fn test_poetry_path_pattern_macos() { - assert!(test_poetry_path_pattern( + assert!(test_poetry_cache_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( + assert!(test_poetry_cache_path_pattern( "/home/user/.cache/pypoetry/virtualenvs/myproject-a1B2c3D4-py3.10" )); } #[test] fn test_poetry_path_pattern_windows() { - assert!(test_poetry_path_pattern( + assert!(test_poetry_cache_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( + assert!(test_poetry_cache_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")); + assert!(!test_poetry_cache_path_pattern("/home/user/projects/myenv")); + assert!(!test_poetry_cache_path_pattern("/home/user/.venv")); + assert!(!test_poetry_cache_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( + assert!(!test_poetry_cache_path_pattern( "/home/user/virtualenvs/myproject-a1B2c3D4-py3.10" )); } @@ -80,10 +121,10 @@ mod tests { #[test] fn test_poetry_path_wrong_hash_length_rejected() { // Hash should be exactly 8 characters - assert!(!test_poetry_path_pattern( + assert!(!test_poetry_cache_path_pattern( "/home/user/.cache/pypoetry/virtualenvs/myproject-a1B2c3D456-py3.10" )); - assert!(!test_poetry_path_pattern( + assert!(!test_poetry_cache_path_pattern( "/home/user/.cache/pypoetry/virtualenvs/myproject-a1B2c3-py3.10" )); } @@ -91,13 +132,161 @@ mod tests { #[test] fn test_real_world_poetry_paths() { // Test actual Poetry paths from the bug report and real usage - assert!(test_poetry_path_pattern( + assert!(test_poetry_cache_path_pattern( "/Users/eleanorboyd/Library/Caches/pypoetry/virtualenvs/nestedpoetry-yJwtIF_Q-py3.11" )); // Another real-world example from documentation - assert!(test_poetry_path_pattern( + assert!(test_poetry_cache_path_pattern( "/Users/donjayamanne/.cache/pypoetry/virtualenvs/poetry-demo-gNT2WXAV-py3.9" )); } + + // Tests for in-project Poetry environment detection (issue #282) + + #[test] + fn test_in_project_poetry_env_with_tool_poetry() { + let temp_dir = tempfile::tempdir().unwrap(); + let project_dir = temp_dir.path(); + let venv_dir = project_dir.join(".venv"); + + // Create .venv directory + fs::create_dir(&venv_dir).unwrap(); + + // Create pyproject.toml with [tool.poetry] section + let pyproject_content = r#" +[tool.poetry] +name = "my-project" +version = "0.1.0" +description = "" +authors = ["Test User "] + +[tool.poetry.dependencies] +python = "^3.10" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" +"#; + fs::write(project_dir.join("pyproject.toml"), pyproject_content).unwrap(); + + // Test that the .venv is recognized as a Poetry environment + assert!(test_in_project_poetry_env(&venv_dir)); + } + + #[test] + fn test_in_project_poetry_env_with_poetry_core_backend() { + let temp_dir = tempfile::tempdir().unwrap(); + let project_dir = temp_dir.path(); + let venv_dir = project_dir.join(".venv"); + + // Create .venv directory + fs::create_dir(&venv_dir).unwrap(); + + // Create pyproject.toml with poetry.core.masonry.api as build backend + let pyproject_content = r#" +[project] +name = "my-project" +version = "0.1.0" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" +"#; + fs::write(project_dir.join("pyproject.toml"), pyproject_content).unwrap(); + + // Test that the .venv is recognized as a Poetry environment + assert!(test_in_project_poetry_env(&venv_dir)); + } + + #[test] + fn test_in_project_non_poetry_env_rejected() { + let temp_dir = tempfile::tempdir().unwrap(); + let project_dir = temp_dir.path(); + let venv_dir = project_dir.join(".venv"); + + // Create .venv directory + fs::create_dir(&venv_dir).unwrap(); + + // Create pyproject.toml without Poetry configuration + let pyproject_content = r#" +[project] +name = "my-project" +version = "0.1.0" + +[build-system] +requires = ["setuptools>=45"] +build-backend = "setuptools.build_meta" +"#; + fs::write(project_dir.join("pyproject.toml"), pyproject_content).unwrap(); + + // Test that the .venv is NOT recognized as a Poetry environment + assert!(!test_in_project_poetry_env(&venv_dir)); + } + + #[test] + fn test_in_project_env_no_poetry_config_rejected() { + let temp_dir = tempfile::tempdir().unwrap(); + let project_dir = temp_dir.path(); + let venv_dir = project_dir.join(".venv"); + + // Create .venv directory without any Poetry configuration files + fs::create_dir(&venv_dir).unwrap(); + + // Test that the .venv is NOT recognized as a Poetry environment + assert!(!test_in_project_poetry_env(&venv_dir)); + } + + #[test] + fn test_in_project_poetry_env_with_poetry_toml() { + let temp_dir = tempfile::tempdir().unwrap(); + let project_dir = temp_dir.path(); + let venv_dir = project_dir.join(".venv"); + + // Create .venv directory + fs::create_dir(&venv_dir).unwrap(); + + // Create poetry.toml with in-project setting (no pyproject.toml with Poetry config) + let poetry_toml_content = r#" +[virtualenvs] +in-project = true +"#; + fs::write(project_dir.join("poetry.toml"), poetry_toml_content).unwrap(); + + // Create minimal pyproject.toml without Poetry-specific config + let pyproject_content = r#" +[project] +name = "my-project" +version = "0.1.0" + +[build-system] +requires = ["setuptools>=45"] +build-backend = "setuptools.build_meta" +"#; + fs::write(project_dir.join("pyproject.toml"), pyproject_content).unwrap(); + + // Test that the .venv is recognized as a Poetry environment due to poetry.toml + assert!(test_in_project_poetry_env(&venv_dir)); + } + + #[test] + fn test_non_venv_directory_rejected() { + let temp_dir = tempfile::tempdir().unwrap(); + let project_dir = temp_dir.path(); + let custom_venv = project_dir.join("myenv"); + + // Create custom env directory (not named .venv) + fs::create_dir(&custom_venv).unwrap(); + + // Create pyproject.toml with Poetry configuration + let pyproject_content = r#" +[tool.poetry] +name = "my-project" +version = "0.1.0" +"#; + fs::write(project_dir.join("pyproject.toml"), pyproject_content).unwrap(); + + // Test that non-.venv directories are NOT recognized + assert!(!test_in_project_poetry_env(&custom_venv)); + } }