Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions crates/socket-patch-cli/tests/in_process_python_envs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
72 changes: 54 additions & 18 deletions crates/socket-patch-core/src/crawlers/python_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,9 +582,7 @@ async fn pdm_saved_interpreter(cwd: &Path) -> Option<PathBuf> {
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::<toml_edit::DocumentMut>().ok()?;
doc.get("python")?.get("path")?.as_str()?.trim().to_string()
}
Expand Down Expand Up @@ -639,6 +637,19 @@ async fn uv_project_environment_site_packages(
var: &impl Fn(&str) -> Option<String>,
) -> Option<Vec<PathBuf>> {
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",
Expand All @@ -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`.
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 0 additions & 3 deletions crates/socket-patch-core/src/utils/digest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
127 changes: 92 additions & 35 deletions crates/socket-patch-core/tests/crawler_python_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
}
Expand Down
Loading