diff --git a/crates/socket-patch-cli/tests/in_process_python_envs.rs b/crates/socket-patch-cli/tests/in_process_python_envs.rs index c2bf98f5b..a22587fab 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -885,6 +885,84 @@ async fn pdm_pep582_pypackages_is_scanned_not_a_stray_dot_venv() { assert_scan_finds(&project, "pkg:pypi/pep582-pkg@1.0.0").await; } +/// #964: a fresh uv checkout (`pyproject.toml` + `uv.lock`, no `.venv` +/// yet, optionally a CI `UV_PROJECT_ENVIRONMENT` not synced yet) and a +/// script-only directory (`tool.py` + `tool.py.lock`) take their candidates +/// from the lock alone. A package that exists only in the OS Python (here a +/// conda root under the stubbed HOME) is not the project's and must never +/// reach the patch query, in any mode. +#[tokio::test] +#[serial] +async fn uv_fresh_checkout_never_scans_the_system_python() { + use socket_patch_cli::commands::scan::ScanMode; + const UV_LOCK: &str = "version = 1\nrequires-python = \">=3.9\"\n\n\ + [[package]]\nname = \"app\"\nversion = \"0.1.0\"\n\ + source = { virtual = \".\" }\ndependencies = [{ name = \"six\" }]\n\n\ + [[package]]\nname = \"six\"\nversion = \"1.16.0\"\n\ + source = { registry = \"https://pypi.org/simple\" }\n"; + for (shape, uv_env) in [ + ("project", None), + ("project", Some("not-synced")), + ("script", None), + ] { + for mode in [ScanMode::Agent, ScanMode::Vendored, ScanMode::Hosted] { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + if shape == "project" { + std::fs::write( + project.join("pyproject.toml"), + "[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n", + ) + .unwrap(); + std::fs::write(project.join("uv.lock"), UV_LOCK).unwrap(); + } else { + std::fs::write( + project.join("tool.py"), + "# /// script\n# dependencies = [\"six==1.16.0\"]\n# ///\n", + ) + .unwrap(); + std::fs::write(project.join("tool.py.lock"), UV_LOCK).unwrap(); + } + let home = tmp.path().join("home"); + let system = home + .join("anaconda3") + .join("lib") + .join("python3.11") + .join("site-packages"); + std::fs::create_dir_all(&system).unwrap(); + write_dist_info(&system, "system_decoy", "6.6.6"); + + let server = MockServer::start().await; + mock_batch_empty(&server).await; + let prev_home = std::env::var_os("HOME"); + let prev_profile = std::env::var_os("USERPROFILE"); + std::env::set_var("HOME", &home); + std::env::set_var("USERPROFILE", &home); + std::env::remove_var("VIRTUAL_ENV"); + match uv_env { + Some(v) => std::env::set_var("UV_PROJECT_ENVIRONMENT", v), + None => std::env::remove_var("UV_PROJECT_ENVIRONMENT"), + } + let mut args = default_args(&project, server.uri()); + args.mode = Some(mode); + let code = scan_run(args).await; + std::env::remove_var("UV_PROJECT_ENVIRONMENT"); + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match prev_profile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + let context = format!("{shape} UV_PROJECT_ENVIRONMENT={uv_env:?} {mode:?}"); + assert_eq!(code, 0, "{context}"); + assert_not_discovered(&batch_bodies(&server).await, "pkg:pypi/system-decoy@6.6.6"); + } + } +} + /// #525: uv syncs into `UV_PROJECT_ENVIRONMENT`, absolute or relative to the /// project, and ignores an activated `VIRTUAL_ENV` for project commands. #[tokio::test] diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 062dcdc9d..42b9e9588 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -582,9 +582,7 @@ async fn pdm_saved_interpreter(cwd: &Path) -> Option { let saved = match read_regular_to_string(&cwd.join(".pdm-python")).await { Ok(text) => text.trim().to_string(), Err(_) => { - let text = read_regular_to_string(&cwd.join(".pdm.toml")) - .await - .ok()?; + let text = read_regular_to_string(&cwd.join(".pdm.toml")).await.ok()?; let doc = text.parse::().ok()?; doc.get("python")?.get("path")?.as_str()?.trim().to_string() } @@ -639,6 +637,19 @@ async fn uv_project_environment_site_packages( var: &impl Fn(&str) -> Option, ) -> Option> { let env = var("UV_PROJECT_ENVIRONMENT").filter(|v| !v.trim().is_empty())?; + let uv_project = cwd.join("uv.lock").is_file() + || (cwd.join("pyproject.toml").is_file() && !claimed_by_non_uv_manager(cwd).await); + if !uv_project { + return None; + } + let found = find_site_packages_under(&cwd.join(env), "site-packages").await; + (!found.is_empty()).then_some(found) +} + +/// Whether a manager other than uv records or locks the project at `cwd`: +/// Poetry, PDM or Pipenv files, or a lockless Poetry (`[tool.poetry]`) or +/// PDM project. +async fn claimed_by_non_uv_manager(cwd: &Path) -> bool { let other_lock = [ "poetry.lock", "poetry.toml", @@ -649,19 +660,34 @@ async fn uv_project_environment_site_packages( ] .iter() .any(|marker| cwd.join(marker).exists()); - // A lockless Poetry (`[tool.poetry]`) or PDM project is still theirs. - let other_manager = other_lock + other_lock || is_pdm_project(cwd).await || read_regular_to_string(&cwd.join("pyproject.toml")) .await - .is_ok_and(|text| text.contains("[tool.poetry")); - let uv_project = - cwd.join("uv.lock").is_file() || (cwd.join("pyproject.toml").is_file() && !other_manager); - if !uv_project { - return None; + .is_ok_and(|text| text.contains("[tool.poetry")) +} + +/// Whether only uv installs for `cwd`, so its env is only ever uv's own: a +/// `uv.lock` no other manager shares (uv syncs it into `./.venv` or +/// `UV_PROJECT_ENVIRONMENT`), or a directory whose only Python markers are +/// PEP 723 script locks (`*.py.lock`, whose envs live in uv's cache). +async fn uv_owns_project_env(cwd: &Path) -> bool { + if claimed_by_non_uv_manager(cwd).await { + return false; } - let found = find_site_packages_under(&cwd.join(env), "site-packages").await; - (!found.is_empty()).then_some(found) + if cwd.join("uv.lock").is_file() { + return true; + } + let other_marker = [ + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + ] + .iter() + .any(|marker| cwd.join(marker).exists()); + let locks = crate::utils::python_lock::python_lock_paths(cwd).unwrap_or_default(); + !other_marker && !locks.is_empty() && locks.iter().all(|name| name.ends_with(".py.lock")) } /// Whether `cwd` is a Pipenv project: a `Pipfile` or a `Pipfile.lock`. @@ -3022,15 +3048,15 @@ impl PythonCrawler { /// `.venv`, and `venv` directories, then Poetry's and Pipenv's /// out-of-tree virtualenvs. /// 2. If no venv was found AND the cwd looks like a Python - /// project (see `is_python_project`) that is not a Pipenv - /// project (whose env is only ever Pipenv's own), fall through + /// project (see `is_python_project`) whose env is not only ever + /// Pipenv's own (`is_pipenv_project`) or uv's own (see + /// `uv_owns_project_env`), fall through /// to `get_global_python_site_packages`. This mirrors the /// cargo / ruby / go pattern where a project marker /// indicates "scan this ecosystem globally for this project". /// - /// Without the marker fallback, a fresh clone with - /// `pyproject.toml` + `uv.lock` but no `.venv` would silently - /// return zero packages. + /// A fresh uv clone (`uv.lock`, no `.venv` yet) returns nothing: + /// its lock-only packages come from `uv.lock` instead. pub async fn get_site_packages_paths( &self, options: &CrawlerOptions, @@ -3052,6 +3078,12 @@ impl PythonCrawler { if is_pipenv_project(&options.cwd) { return Ok(Vec::new()); } + // A uv project or script lock installs only into uv's own env. With + // none synced yet nothing is installed for it, and its lock-only + // packages come from the lock; the OS Python is never its env (#964). + if uv_owns_project_env(&options.cwd).await { + return Ok(Vec::new()); + } if is_python_project(&options.cwd).await { return Ok(get_global_python_site_packages().await); } @@ -3678,7 +3710,11 @@ mod tests { fake_venv(&tmp.path().join("uv-env"), "venv"); let uv_env = env_of(&[( "UV_PROJECT_ENVIRONMENT", - tmp.path().join("uv-env").join("venv").to_string_lossy().into_owned(), + tmp.path() + .join("uv-env") + .join("venv") + .to_string_lossy() + .into_owned(), )]); assert_eq!( find_local_venv_site_packages_with(&project, &uv_env).await, diff --git a/crates/socket-patch-core/src/utils/digest.rs b/crates/socket-patch-core/src/utils/digest.rs index 105225e5e..630adefa1 100644 --- a/crates/socket-patch-core/src/utils/digest.rs +++ b/crates/socket-patch-core/src/utils/digest.rs @@ -135,9 +135,6 @@ mod tests { /// when you move it onto the helpers above; the test fails on a stale /// entry as well as on a new inline copy. const PENDING_INLINE_DIGESTS: &[&str] = &[ - "crawlers/gradle_cache.rs", - "patch/jvm_jar.rs", - "patch/sidecars/maven.rs", "utils/group_commit.rs", "vendor/jvm/mod.rs", "vendor/maven_repo.rs", diff --git a/crates/socket-patch-core/tests/crawler_python_e2e.rs b/crates/socket-patch-core/tests/crawler_python_e2e.rs index 9bfe6b524..4ba96cc2f 100644 --- a/crates/socket-patch-core/tests/crawler_python_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_python_e2e.rs @@ -1113,51 +1113,111 @@ async fn get_site_packages_paths_falls_back_via_pyproject_marker() { let _ = result; } -/// `uv.lock` alone is also a valid Python-project marker — a fresh -/// clone of a uv-managed repo shouldn't need a venv to be scannable. -/// -/// Stages a real global layout under the stubbed HOME and asserts it -/// surfaces — which can ONLY happen if the `uv.lock` marker triggered -/// the global fallback (no marker returns an empty Vec). +/// #964: a uv project (`uv.lock`) and a PEP 723 script lock (`*.py.lock`) +/// only ever install into uv's own env: `.venv` / `UV_PROJECT_ENVIRONMENT` +/// for a project, uv's cache for a script. With none synced yet nothing is +/// installed for the project, and its lock-only packages come from the lock, +/// so a project-scoped crawl must return nothing rather than fall back to +/// the global interpreters (which vendored mode would then try to vendor). #[tokio::test] #[serial] -async fn get_site_packages_paths_falls_back_via_uv_lock_marker() { +async fn get_site_packages_paths_uv_without_env_never_falls_back_to_global() { + for (files, uv_project_env) in [ + (&[("uv.lock", "version = 1\n")][..], None), + ( + &[ + ("pyproject.toml", "[project]\nname = \"app\"\n"), + ("uv.lock", "version = 1\n"), + ][..], + None, + ), + // A CI-configured env path that hasn't been synced yet. + (&[("uv.lock", "version = 1\n")][..], Some("not-synced")), + // A script-only directory: script envs live in uv's cache. + ( + &[ + ("tool.py", "# /// script\n# dependencies = []\n# ///\n"), + ("tool.py.lock", "version = 1\n"), + ][..], + None, + ), + ] { + let project = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + for (name, body) in files { + tokio::fs::write(project.path().join(name), body) + .await + .unwrap(); + } + + // Stage an anaconda3 layout under the stubbed HOME: global discovery + // scans it on every platform, so seeing it means the fallback ran. + let staged = home + .path() + .join("anaconda3") + .join("lib") + .join("python3.11") + .join("site-packages"); + tokio::fs::create_dir_all(&staged).await.unwrap(); + + let prev_virtual_env = std::env::var("VIRTUAL_ENV").ok(); + std::env::remove_var("VIRTUAL_ENV"); + let prev_uv_env = std::env::var("UV_PROJECT_ENVIRONMENT").ok(); + match uv_project_env { + Some(v) => std::env::set_var("UV_PROJECT_ENVIRONMENT", v), + None => std::env::remove_var("UV_PROJECT_ENVIRONMENT"), + } + let prev_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", home.path()); + let crawler = PythonCrawler; + let opts = CrawlerOptions { + cwd: project.path().to_path_buf(), + global: false, + global_prefix: None, + }; + let result = crawler.get_site_packages_paths(&opts).await.unwrap(); + if let Some(v) = prev_home { + std::env::set_var("HOME", v); + } + match prev_uv_env { + Some(v) => std::env::set_var("UV_PROJECT_ENVIRONMENT", v), + None => std::env::remove_var("UV_PROJECT_ENVIRONMENT"), + } + if let Some(v) = prev_virtual_env { + std::env::set_var("VIRTUAL_ENV", v); + } + + let names: Vec<&str> = files.iter().map(|(name, _)| *name).collect(); + assert!( + result.is_empty(), + "{names:?} (UV_PROJECT_ENVIRONMENT={uv_project_env:?}) must not \ + fall back to the global site-packages; got {result:?}" + ); + } +} + +/// A `uv.lock` beside another manager's record is not uv's alone: Poetry +/// with `virtualenvs.create = false` installs into the interpreter it runs +/// on, so that project keeps the marker fallback. +#[tokio::test] +#[serial] +async fn get_site_packages_paths_uv_lock_beside_poetry_keeps_fallback() { let project = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); tokio::fs::write(project.path().join("uv.lock"), b"version = 1\n") .await .unwrap(); - - // Stage a uv-tools layout under the stubbed HOME so global - // discovery has something concrete to find. - #[cfg(target_os = "macos")] - let staged = home - .path() - .join("Library") - .join("Application Support") - .join("uv") - .join("tools") - .join("black") - .join("lib") - .join("python3.11") - .join("site-packages"); - #[cfg(all(not(target_os = "macos"), not(windows)))] + tokio::fs::write(project.path().join("poetry.lock"), b"") + .await + .unwrap(); let staged = home .path() - .join(".local") - .join("share") - .join("uv") - .join("tools") - .join("black") + .join("anaconda3") .join("lib") .join("python3.11") .join("site-packages"); - #[cfg(windows)] - let staged = home.path().join("uv-fake-staged"); tokio::fs::create_dir_all(&staged).await.unwrap(); - // Ensure an ambient VIRTUAL_ENV can't satisfy discovery via a - // different (venv) arm — the fallback must be the marker path. let prev_virtual_env = std::env::var("VIRTUAL_ENV").ok(); std::env::remove_var("VIRTUAL_ENV"); let prev_home = std::env::var("HOME").ok(); @@ -1179,11 +1239,8 @@ async fn get_site_packages_paths_falls_back_via_uv_lock_marker() { #[cfg(not(windows))] assert!( result.iter().any(|p| p == &staged), - "uv.lock marker must trigger global fallback; got {result:?}" + "uv.lock + poetry.lock must keep the global fallback; got {result:?}" ); - // On Windows the staged layout doesn't match the global crawler's - // search paths (different env var), so the marker-fallback path is - // covered by the pyproject test on Unix only. #[cfg(windows)] let _ = (result, staged); }