use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::{HostError, ProcessOutput};
pub const DEFAULT_PIP_TIMEOUT_SECS: u64 = 600;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheMetadata {
pub venv_path: String,
#[serde(default)]
pub requirements_file: Option<String>,
pub requirements_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_hash: Option<String>,
#[serde(default)]
pub python_version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CacheVerdict {
Valid,
VenvMissing,
MetadataMissing,
MetadataUnreadable,
RequirementsChanged,
ManifestVersionChanged,
ManifestHashChanged,
}
impl CacheVerdict {
pub fn is_valid(&self) -> bool {
matches!(self, Self::Valid)
}
}
pub fn hash_file_or_empty(path: Option<&Path>) -> String {
let mut hasher = Sha256::new();
match path {
Some(p) => match std::fs::read(p) {
Ok(bytes) => hasher.update(&bytes),
Err(_) => hasher.update(b""),
},
None => hasher.update(b""),
}
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
let _ = write!(hex, "{byte:02x}");
}
hex
}
fn manifest_stem_for_class(class_name: &str) -> String {
let safe: String = class_name
.trim()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
let safe = safe.trim_matches('-');
if safe.is_empty() {
"plugin".to_string()
} else {
safe.to_string()
}
}
pub fn manifest_filename_for_class(class_name: &str) -> String {
format!(
"{}.plugin-manifest.yaml",
manifest_stem_for_class(class_name)
)
}
pub fn evaluate_cache(
venv_path: &Path,
metadata_path: &Path,
expected_requirements_hash: &str,
expected_manifest_version: Option<&str>,
expected_manifest_hash: Option<&str>,
) -> CacheVerdict {
if !venv_path.exists() {
return CacheVerdict::VenvMissing;
}
if !metadata_path.exists() {
return CacheVerdict::MetadataMissing;
}
let Ok(raw) = std::fs::read_to_string(metadata_path) else {
return CacheVerdict::MetadataUnreadable;
};
let Ok(metadata) = serde_json::from_str::<CacheMetadata>(&raw) else {
return CacheVerdict::MetadataUnreadable;
};
if metadata.requirements_hash != expected_requirements_hash {
return CacheVerdict::RequirementsChanged;
}
if let Some(cached) = metadata.manifest_version.as_deref()
&& Some(cached) != expected_manifest_version
{
return CacheVerdict::ManifestVersionChanged;
}
if let Some(cached) = metadata.manifest_hash.as_deref()
&& Some(cached) != expected_manifest_hash
{
return CacheVerdict::ManifestHashChanged;
}
CacheVerdict::Valid
}
const OWNERS_FILENAME: &str = "owners.json";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VenvOwners {
#[serde(default)]
pub owners: BTreeSet<String>,
}
impl VenvOwners {
pub fn load(path: &Path) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
pub fn has_other_owner(&self, class_name: &str) -> bool {
self.owners.iter().any(|o| o != class_name)
}
}
#[derive(Debug, Clone)]
pub struct VenvLayout {
pub plugin_path: PathBuf,
pub venv_path: PathBuf,
pub cache_dir: PathBuf,
pub metadata_path: PathBuf,
pub manifest_path: PathBuf,
pub owners_path: PathBuf,
}
impl VenvLayout {
pub fn resolve(plugin_dirs: &[String], class_name: &str) -> Result<Self, HostError> {
let first = plugin_dirs.first().ok_or_else(|| HostError::Config {
message: "isolated_venv requires at least one plugin directory — \
the venv is built under the first one"
.into(),
})?;
let class_root = class_name.split('.').next().unwrap_or(class_name);
if class_root.is_empty() {
return Err(HostError::Config {
message: format!("`class_name` '{class_name}' has no leading package segment"),
});
}
let plugin_path = Path::new(first).join(class_root);
let venv_path = plugin_path.join(".venv");
let cache_dir = plugin_path.join(".cpex").join("venv_cache");
let metadata_path = cache_dir.join(format!(
"{}_metadata.json",
manifest_stem_for_class(class_name)
));
let manifest_path = plugin_path.join(manifest_filename_for_class(class_name));
let owners_path = cache_dir.join(OWNERS_FILENAME);
Ok(Self {
plugin_path,
venv_path,
cache_dir,
metadata_path,
manifest_path,
owners_path,
})
}
pub fn python_executable(&self) -> PathBuf {
if cfg!(windows) {
self.venv_path.join("Scripts").join("python.exe")
} else {
self.venv_path.join("bin").join("python")
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnsureOutcome {
Reused,
Built {
installed_requirements: bool,
},
SharedUpdate {
installed_requirements: bool,
},
}
#[derive(Debug, Clone)]
pub struct VenvManager {
layout: VenvLayout,
requirements_file: Option<PathBuf>,
manifest_version: Option<String>,
class_name: String,
pip_timeout_secs: u64,
}
impl VenvManager {
pub fn new(
plugin_dirs: &[String],
class_name: &str,
requirements_file: Option<&str>,
manifest_version: Option<&str>,
) -> Result<Self, HostError> {
let layout = VenvLayout::resolve(plugin_dirs, class_name)?;
let requirements_file = requirements_file.map(|rel| {
let path = Path::new(rel);
if path.is_absolute() {
path.to_path_buf()
} else {
layout.plugin_path.join(path)
}
});
Ok(Self {
layout,
requirements_file,
manifest_version: manifest_version.map(str::to_string),
class_name: class_name.to_string(),
pip_timeout_secs: DEFAULT_PIP_TIMEOUT_SECS,
})
}
pub fn with_pip_timeout_secs(mut self, secs: u64) -> Self {
self.pip_timeout_secs = secs;
self
}
pub fn pip_timeout_secs(&self) -> u64 {
self.pip_timeout_secs
}
pub fn layout(&self) -> &VenvLayout {
&self.layout
}
pub fn owners(&self) -> VenvOwners {
VenvOwners::load(&self.layout.owners_path)
}
pub fn python_executable(&self) -> PathBuf {
self.layout.python_executable()
}
pub fn cache_verdict(&self) -> CacheVerdict {
evaluate_cache(
&self.layout.venv_path,
&self.layout.metadata_path,
&hash_file_or_empty(self.requirements_file.as_deref()),
self.manifest_version.as_deref(),
Some(&hash_file_or_empty(Some(&self.layout.manifest_path))),
)
}
pub async fn ensure(&self) -> Result<EnsureOutcome, HostError> {
let verdict = self.cache_verdict();
if verdict.is_valid() {
tracing::info!(venv = %self.layout.venv_path.display(), "reusing cached venv");
self.register_owner().await?;
return Ok(EnsureOutcome::Reused);
}
let shared = self.venv_has_other_owner();
let venv_exists = self.layout.venv_path.exists();
let update_in_place = shared && venv_exists;
tracing::info!(
venv = %self.layout.venv_path.display(),
reason = ?verdict,
class_name = %self.class_name,
update_in_place,
"{}",
if update_in_place {
"updating shared venv in place — another plugin owns it"
} else {
"building venv"
}
);
self.prepare_directories(update_in_place).await?;
if !update_in_place {
self.create_venv().await?;
}
let installed_requirements = self.install_requirements().await?;
self.register_owner().await?;
self.save_metadata().await?;
if update_in_place {
return Ok(EnsureOutcome::SharedUpdate {
installed_requirements,
});
}
Ok(EnsureOutcome::Built {
installed_requirements,
})
}
fn venv_has_other_owner(&self) -> bool {
VenvOwners::load(&self.layout.owners_path).has_other_owner(&self.class_name)
}
async fn register_owner(&self) -> Result<(), HostError> {
let path = self.layout.owners_path.clone();
let cache_dir = self.layout.cache_dir.clone();
let class_name = self.class_name.clone();
tokio::task::spawn_blocking(move || {
let mut owners = VenvOwners::load(&path);
if !owners.owners.insert(class_name) {
return Ok(());
}
std::fs::create_dir_all(&cache_dir).map_err(|e| HostError::VenvBuild {
message: format!("could not create cache dir {}: {e}", cache_dir.display()),
})?;
let json = serde_json::to_string_pretty(&owners).map_err(|e| HostError::VenvBuild {
message: format!("could not serialize the venv owner registry: {e}"),
})?;
std::fs::write(&path, json).map_err(|e| HostError::VenvBuild {
message: format!("could not write venv owners {}: {e}", path.display()),
})
})
.await
.map_err(|e| HostError::VenvBuild {
message: format!("venv owner registration panicked: {e}"),
})?
}
pub async fn release_owner(&self) -> Result<(), HostError> {
let path = self.layout.owners_path.clone();
let class_name = self.class_name.clone();
tokio::task::spawn_blocking(move || {
let mut owners = VenvOwners::load(&path);
if !owners.owners.remove(&class_name) {
return Ok(());
}
if owners.owners.is_empty() {
if path.exists() {
std::fs::remove_file(&path).map_err(|e| HostError::VenvBuild {
message: format!("could not remove venv owners {}: {e}", path.display()),
})?;
}
return Ok(());
}
let json = serde_json::to_string_pretty(&owners).map_err(|e| HostError::VenvBuild {
message: format!("could not serialize the venv owner registry: {e}"),
})?;
std::fs::write(&path, json).map_err(|e| HostError::VenvBuild {
message: format!("could not write venv owners {}: {e}", path.display()),
})
})
.await
.map_err(|e| HostError::VenvBuild {
message: format!("venv owner release panicked: {e}"),
})?
}
async fn prepare_directories(&self, update_in_place: bool) -> Result<(), HostError> {
let venv_path = self.layout.venv_path.clone();
let cache_dir = self.layout.cache_dir.clone();
let plugin_path = self.layout.plugin_path.clone();
tokio::task::spawn_blocking(move || {
std::fs::create_dir_all(&plugin_path).map_err(|e| HostError::VenvBuild {
message: format!("could not create plugin dir {}: {e}", plugin_path.display()),
})?;
std::fs::create_dir_all(&cache_dir).map_err(|e| HostError::VenvBuild {
message: format!("could not create cache dir {}: {e}", cache_dir.display()),
})?;
if !update_in_place && venv_path.exists() {
std::fs::remove_dir_all(&venv_path).map_err(|e| HostError::VenvBuild {
message: format!("could not remove stale venv {}: {e}", venv_path.display()),
})?;
}
Ok(())
})
.await
.map_err(|e| HostError::VenvBuild {
message: format!("venv directory preparation panicked: {e}"),
})?
}
async fn create_venv(&self) -> Result<(), HostError> {
let output = tokio::process::Command::new("python3")
.arg("-m")
.arg("venv")
.arg(&self.layout.venv_path)
.output()
.await
.map_err(|e| HostError::VenvBuild {
message: format!("could not run `python3 -m venv`: {e} (is python3 on PATH?)"),
})?;
if !output.status.success() {
return Err(HostError::VenvCommand {
step: "python3 -m venv",
context: self.layout.venv_path.display().to_string(),
output: ProcessOutput::from_output(&output),
});
}
Ok(())
}
async fn install_requirements(&self) -> Result<bool, HostError> {
let Some(requirements) = self.requirements_file.as_ref().filter(|p| p.exists()) else {
tracing::info!("no requirements file; skipping install");
return Ok(false);
};
let python = self.python_executable();
let mut child = tokio::process::Command::new(&python)
.args(["-m", "pip", "install", "-r"])
.arg(requirements)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| HostError::VenvBuild {
message: format!("could not run pip in {}: {e}", python.display()),
})?;
let stderr = child.stderr.take();
let stderr_task = tokio::spawn(async move {
let mut buf = Vec::new();
if let Some(mut stderr) = stderr {
use tokio::io::AsyncReadExt;
let _ = stderr.read_to_end(&mut buf).await;
}
buf
});
let timeout = std::time::Duration::from_secs(self.pip_timeout_secs);
let status = match tokio::time::timeout(timeout, child.wait()).await {
Ok(result) => result.map_err(|e| HostError::VenvBuild {
message: format!("could not wait on pip in {}: {e}", python.display()),
})?,
Err(_) => {
tracing::warn!(
timeout_secs = self.pip_timeout_secs,
requirements = %requirements.display(),
"pip install exceeded its timeout; killing it"
);
if let Err(e) = child.kill().await {
tracing::warn!("could not kill the timed-out pip process: {e}");
}
stderr_task.abort();
return Err(HostError::VenvBuild {
message: format!(
"pip install -r {} exceeded the {}s timeout and was killed — check \
network reachability to the package index, or raise the pip timeout",
requirements.display(),
self.pip_timeout_secs,
),
});
},
};
if !status.success() {
let stderr = stderr_task.await.unwrap_or_default();
return Err(HostError::VenvCommand {
step: "pip install",
context: requirements.display().to_string(),
output: ProcessOutput {
exit_code: status.code(),
stderr: String::from_utf8_lossy(&stderr).trim().to_string(),
},
});
}
Ok(true)
}
async fn save_metadata(&self) -> Result<(), HostError> {
let metadata = CacheMetadata {
venv_path: self.layout.venv_path.display().to_string(),
requirements_file: self
.requirements_file
.as_ref()
.filter(|p| p.exists())
.map(|p| p.display().to_string()),
requirements_hash: hash_file_or_empty(self.requirements_file.as_deref()),
manifest_version: self.manifest_version.clone(),
manifest_hash: Some(hash_file_or_empty(Some(&self.layout.manifest_path))),
python_version: None,
};
let path = self.layout.metadata_path.clone();
let json = serde_json::to_string_pretty(&metadata).map_err(|e| HostError::VenvBuild {
message: format!("could not serialize cache metadata: {e}"),
})?;
tokio::task::spawn_blocking(move || {
std::fs::write(&path, json).map_err(|e| HostError::VenvBuild {
message: format!("could not write cache metadata {}: {e}", path.display()),
})
})
.await
.map_err(|e| HostError::VenvBuild {
message: format!("cache metadata write panicked: {e}"),
})?
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempDir;
const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
fn scaffold(dir: &TempDir, metadata: &str) -> (PathBuf, PathBuf) {
let venv = dir.path().join(".venv");
std::fs::create_dir_all(&venv).unwrap();
let metadata_path = dir.path().join("metadata.json");
std::fs::write(&metadata_path, metadata).unwrap();
(venv, metadata_path)
}
fn metadata_json(
requirements_hash: &str,
version: Option<&str>,
manifest_hash: Option<&str>,
) -> String {
let mut obj = serde_json::json!({
"venv_path": "/tmp/.venv",
"requirements_file": null,
"requirements_hash": requirements_hash,
"python_version": "3.12.1",
});
if let Some(v) = version {
obj["manifest_version"] = serde_json::json!(v);
}
if let Some(h) = manifest_hash {
obj["manifest_hash"] = serde_json::json!(h);
}
serde_json::to_string(&obj).unwrap()
}
#[test]
fn absent_requirements_file_hashes_to_the_empty_digest() {
assert_eq!(hash_file_or_empty(None), EMPTY_SHA256);
assert_eq!(
hash_file_or_empty(Some(Path::new("/nonexistent/requirements.txt"))),
EMPTY_SHA256
);
}
#[test]
fn requirements_hash_tracks_content_not_path() {
let dir = TempDir::new();
let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt");
std::fs::write(&a, "requests==2.31.0\n").unwrap();
std::fs::write(&b, "requests==2.31.0\n").unwrap();
assert_eq!(hash_file_or_empty(Some(&a)), hash_file_or_empty(Some(&b)));
std::fs::write(&b, "requests==2.32.0\n").unwrap();
assert_ne!(hash_file_or_empty(Some(&a)), hash_file_or_empty(Some(&b)));
}
#[test]
fn manifest_filename_matches_the_python_sanitization_rule() {
assert_eq!(
manifest_filename_for_class("pkg.module.ClassName"),
"pkg-module-ClassName.plugin-manifest.yaml"
);
assert_eq!(
manifest_filename_for_class("my_pkg.filters.PiiFilter"),
"my_pkg-filters-PiiFilter.plugin-manifest.yaml"
);
assert_eq!(
manifest_filename_for_class(" .A. "),
"A.plugin-manifest.yaml"
);
assert_eq!(
manifest_filename_for_class("..."),
"plugin.plugin-manifest.yaml"
);
}
#[test]
fn plugins_sharing_a_package_get_distinct_manifest_filenames() {
assert_ne!(
manifest_filename_for_class("pkg.a.PluginA"),
manifest_filename_for_class("pkg.b.PluginB")
);
}
#[test]
fn valid_when_every_present_signal_agrees() {
let dir = TempDir::new();
let (venv, meta) = scaffold(
&dir,
&metadata_json(EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
);
assert_eq!(
evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
CacheVerdict::Valid
);
}
#[test]
fn invalid_when_the_venv_directory_is_gone() {
let dir = TempDir::new();
let meta = dir.path().join("metadata.json");
std::fs::write(&meta, metadata_json(EMPTY_SHA256, None, None)).unwrap();
assert_eq!(
evaluate_cache(&dir.path().join(".venv"), &meta, EMPTY_SHA256, None, None),
CacheVerdict::VenvMissing
);
}
#[test]
fn invalid_when_metadata_is_absent() {
let dir = TempDir::new();
let venv = dir.path().join(".venv");
std::fs::create_dir_all(&venv).unwrap();
assert_eq!(
evaluate_cache(
&venv,
&dir.path().join("missing.json"),
EMPTY_SHA256,
None,
None
),
CacheVerdict::MetadataMissing
);
}
#[test]
fn invalid_when_metadata_will_not_parse() {
let dir = TempDir::new();
let (venv, meta) = scaffold(&dir, "{ this is not json");
assert_eq!(
evaluate_cache(&venv, &meta, EMPTY_SHA256, None, None),
CacheVerdict::MetadataUnreadable
);
}
#[test]
fn changed_requirements_hash_invalidates() {
let dir = TempDir::new();
let (venv, meta) = scaffold(
&dir,
&metadata_json("old-hash", Some("1.0.0"), Some("abc123")),
);
assert_eq!(
evaluate_cache(&venv, &meta, "new-hash", Some("1.0.0"), Some("abc123")),
CacheVerdict::RequirementsChanged
);
}
#[test]
fn changed_manifest_version_invalidates_when_the_signal_is_present() {
let dir = TempDir::new();
let (venv, meta) = scaffold(
&dir,
&metadata_json(EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
);
assert_eq!(
evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("2.0.0"), Some("abc123")),
CacheVerdict::ManifestVersionChanged
);
}
#[test]
fn changed_manifest_hash_invalidates_when_the_signal_is_present() {
let dir = TempDir::new();
let (venv, meta) = scaffold(
&dir,
&metadata_json(EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
);
assert_eq!(
evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("1.0.0"), Some("def456")),
CacheVerdict::ManifestHashChanged
);
}
#[test]
fn absent_manifest_signals_do_not_invalidate() {
let dir = TempDir::new();
let (venv, meta) = scaffold(&dir, &metadata_json(EMPTY_SHA256, None, None));
assert_eq!(
evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("1.0.0"), Some("abc123")),
CacheVerdict::Valid,
"absent manifest signals mean 'no signal', not 'mismatch'"
);
}
#[test]
fn each_manifest_signal_is_evaluated_independently() {
let dir = TempDir::new();
let (venv, meta) = scaffold(&dir, &metadata_json(EMPTY_SHA256, Some("1.0.0"), None));
assert_eq!(
evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("1.0.0"), Some("any-hash")),
CacheVerdict::Valid,
"an absent manifest_hash is skipped even when manifest_version is present"
);
assert_eq!(
evaluate_cache(&venv, &meta, EMPTY_SHA256, Some("9.9.9"), Some("any-hash")),
CacheVerdict::ManifestVersionChanged,
"the present signal is still enforced"
);
}
#[test]
fn a_present_signal_against_an_unknown_expected_value_invalidates() {
let dir = TempDir::new();
let (venv, meta) = scaffold(&dir, &metadata_json(EMPTY_SHA256, Some("1.0.0"), None));
assert_eq!(
evaluate_cache(&venv, &meta, EMPTY_SHA256, None, None),
CacheVerdict::ManifestVersionChanged
);
}
#[test]
fn requirements_are_checked_before_manifest_signals() {
let dir = TempDir::new();
let (venv, meta) = scaffold(&dir, &metadata_json("old", Some("1.0.0"), Some("abc")));
assert_eq!(
evaluate_cache(&venv, &meta, "new", Some("2.0.0"), Some("def")),
CacheVerdict::RequirementsChanged
);
}
#[test]
fn metadata_round_trips_without_inventing_manifest_keys() {
let metadata = CacheMetadata {
venv_path: "/tmp/x/.venv".into(),
requirements_file: None,
requirements_hash: EMPTY_SHA256.into(),
manifest_version: None,
manifest_hash: None,
python_version: Some("3.12.1".into()),
};
let json = serde_json::to_string(&metadata).unwrap();
assert!(
!json.contains("manifest_version"),
"absent signals must stay absent: {json}"
);
assert!(!json.contains("manifest_hash"));
let parsed: CacheMetadata = serde_json::from_str(&json).unwrap();
assert!(parsed.manifest_version.is_none());
}
#[test]
fn layout_is_keyed_on_the_class_root_so_a_package_shares_one_venv() {
let dirs = vec!["/plugins".to_string()];
let a = VenvLayout::resolve(&dirs, "pkg.a.PluginA").unwrap();
let b = VenvLayout::resolve(&dirs, "pkg.b.PluginB").unwrap();
assert_eq!(a.venv_path, PathBuf::from("/plugins/pkg/.venv"));
assert_eq!(a.venv_path, b.venv_path, "one venv per package root");
assert_eq!(a.cache_dir, PathBuf::from("/plugins/pkg/.cpex/venv_cache"));
assert_ne!(a.manifest_path, b.manifest_path);
}
#[test]
fn layout_uses_the_first_plugin_dir() {
let dirs = vec!["/first".to_string(), "/second".to_string()];
let layout = VenvLayout::resolve(&dirs, "pkg.Plugin").unwrap();
assert_eq!(layout.plugin_path, PathBuf::from("/first/pkg"));
}
#[test]
fn layout_requires_at_least_one_plugin_dir() {
let err = VenvLayout::resolve(&[], "pkg.Plugin").unwrap_err();
assert!(matches!(err, HostError::Config { .. }));
assert!(
err.to_string().contains("plugin directory"),
"the message should explain what is missing: {err}"
);
}
#[test]
fn metadata_filename_is_keyed_on_the_full_class_name() {
let layout = VenvLayout::resolve(&["/plugins".to_string()], "pkg.Plugin").unwrap();
assert_eq!(
layout.metadata_path,
PathBuf::from("/plugins/pkg/.cpex/venv_cache/pkg-Plugin_metadata.json")
);
}
#[test]
fn plugins_sharing_a_package_get_distinct_metadata_files() {
let dirs = vec!["/plugins".to_string()];
let a = VenvLayout::resolve(&dirs, "pkg.a.PluginA").unwrap();
let b = VenvLayout::resolve(&dirs, "pkg.b.PluginB").unwrap();
assert_eq!(a.venv_path, b.venv_path, "still one shared venv");
assert_ne!(a.metadata_path, b.metadata_path);
}
#[test]
fn python_executable_sits_inside_the_venv() {
let layout = VenvLayout::resolve(&["/plugins".to_string()], "pkg.Plugin").unwrap();
let exe = layout.python_executable();
assert!(exe.starts_with(&layout.venv_path));
assert!(exe.ends_with(if cfg!(windows) {
"python.exe"
} else {
"python"
}));
}
fn manager_in(dir: &TempDir, class_name: &str, requirements: Option<&str>) -> VenvManager {
VenvManager::new(
&[dir.path().display().to_string()],
class_name,
requirements,
Some("1.0.0"),
)
.expect("layout resolves")
}
#[test]
fn relative_requirements_paths_resolve_under_the_plugin_path() {
let dir = TempDir::new();
let mgr = manager_in(&dir, "pkg.Plugin", Some("requirements.txt"));
assert_eq!(
mgr.requirements_file.as_deref(),
Some(dir.path().join("pkg").join("requirements.txt").as_path())
);
}
#[test]
fn absolute_requirements_paths_are_left_alone() {
let dir = TempDir::new();
let mgr = manager_in(&dir, "pkg.Plugin", Some("/etc/shared/requirements.txt"));
assert_eq!(
mgr.requirements_file.as_deref(),
Some(Path::new("/etc/shared/requirements.txt"))
);
}
#[test]
fn a_missing_venv_reports_an_invalid_cache() {
let dir = TempDir::new();
let mgr = manager_in(&dir, "pkg.Plugin", None);
assert_eq!(mgr.cache_verdict(), CacheVerdict::VenvMissing);
}
#[tokio::test]
async fn ensure_builds_then_reuses_the_venv() {
if crate::testing::skip_without_python3("ensure_builds_then_reuses_the_venv") {
return;
}
let dir = TempDir::new();
let mgr = manager_in(&dir, "pkg.Plugin", None);
let first = mgr.ensure().await.expect("venv builds");
assert_eq!(
first,
EnsureOutcome::Built {
installed_requirements: false
},
"no requirements file means a fresh venv with no install"
);
assert!(
mgr.python_executable().exists(),
"the interpreter must exist after a build"
);
let second = mgr.ensure().await.expect("cached venv is reusable");
assert_eq!(
second,
EnsureOutcome::Reused,
"unchanged inputs must not rebuild"
);
}
#[tokio::test]
async fn a_changed_requirements_hash_forces_a_rebuild() {
if crate::testing::skip_without_python3("a_changed_requirements_hash_forces_a_rebuild") {
return;
}
let dir = TempDir::new();
std::fs::create_dir_all(dir.path().join("pkg")).unwrap();
let requirements = dir.path().join("pkg").join("requirements.txt");
std::fs::write(&requirements, "").unwrap();
let mgr = manager_in(&dir, "pkg.Plugin", Some("requirements.txt"));
assert!(matches!(
mgr.ensure().await.unwrap(),
EnsureOutcome::Built { .. }
));
assert_eq!(mgr.ensure().await.unwrap(), EnsureOutcome::Reused);
std::fs::write(&requirements, "# a comment changes the hash\n").unwrap();
assert_eq!(mgr.cache_verdict(), CacheVerdict::RequirementsChanged);
assert!(matches!(
mgr.ensure().await.unwrap(),
EnsureOutcome::Built { .. }
));
}
#[tokio::test]
async fn an_absent_requirements_file_builds_and_skips_install() {
if crate::testing::skip_without_python3(
"an_absent_requirements_file_builds_and_skips_install",
) {
return;
}
let dir = TempDir::new();
let mgr = manager_in(&dir, "pkg.Plugin", Some("does-not-exist.txt"));
assert_eq!(
mgr.ensure()
.await
.expect("a missing requirements file is not fatal"),
EnsureOutcome::Built {
installed_requirements: false
}
);
}
#[tokio::test]
async fn two_plugins_sharing_a_package_do_not_thrash_each_others_cache() {
if crate::testing::skip_without_python3(
"two_plugins_sharing_a_package_do_not_thrash_each_others_cache",
) {
return;
}
let dir = TempDir::new();
let a = manager_in(&dir, "pkg.a.PluginA", None);
let b = manager_in(&dir, "pkg.b.PluginB", None);
assert_eq!(
a.layout().venv_path,
b.layout().venv_path,
"one shared venv"
);
assert_ne!(a.layout().metadata_path, b.layout().metadata_path);
assert!(matches!(
a.ensure().await.unwrap(),
EnsureOutcome::Built { .. }
));
assert_eq!(a.ensure().await.unwrap(), EnsureOutcome::Reused);
assert!(matches!(
b.ensure().await.unwrap(),
EnsureOutcome::SharedUpdate { .. }
));
assert_eq!(
a.ensure().await.unwrap(),
EnsureOutcome::Reused,
"B's build must not invalidate A"
);
assert_eq!(b.ensure().await.unwrap(), EnsureOutcome::Reused);
}
#[test]
fn the_owners_file_is_shared_by_every_plugin_in_the_package() {
let dirs = vec!["/plugins".to_string()];
let a = VenvLayout::resolve(&dirs, "pkg.a.PluginA").unwrap();
let b = VenvLayout::resolve(&dirs, "pkg.b.PluginB").unwrap();
assert_eq!(a.venv_path, b.venv_path, "one shared venv");
assert_eq!(
a.owners_path, b.owners_path,
"co-owners of one venv must read the same registry"
);
assert_eq!(
a.owners_path,
PathBuf::from("/plugins/pkg/.cpex/venv_cache/owners.json")
);
}
#[test]
fn a_missing_or_corrupt_owner_registry_reads_as_empty() {
let dir = TempDir::new();
assert!(
VenvOwners::load(&dir.path().join("nope.json"))
.owners
.is_empty()
);
let corrupt = dir.path().join("owners.json");
std::fs::write(&corrupt, "{ not json").unwrap();
assert!(VenvOwners::load(&corrupt).owners.is_empty());
}
#[test]
fn has_other_owner_ignores_the_caller_itself() {
let mut owners = VenvOwners::default();
owners.owners.insert("pkg.a.PluginA".to_string());
assert!(
!owners.has_other_owner("pkg.a.PluginA"),
"a plugin re-initializing is still the sole owner and may rebuild"
);
assert!(
owners.has_other_owner("pkg.b.PluginB"),
"a different plugin in the same package is a co-owner"
);
}
#[tokio::test]
async fn a_cache_hit_still_claims_ownership_of_the_venv() {
if crate::testing::skip_without_python3("a_cache_hit_still_claims_ownership_of_the_venv") {
return;
}
let dir = TempDir::new();
let mgr = manager_in(&dir, "pkg.Plugin", None);
mgr.ensure().await.unwrap();
assert!(mgr.owners().owners.contains("pkg.Plugin"));
mgr.release_owner().await.unwrap();
assert!(mgr.owners().owners.is_empty());
assert_eq!(mgr.ensure().await.unwrap(), EnsureOutcome::Reused);
assert!(
mgr.owners().owners.contains("pkg.Plugin"),
"the reuse path must register ownership too"
);
}
#[tokio::test]
async fn a_second_plugin_rebuilding_does_not_delete_the_shared_venv() {
if crate::testing::skip_without_python3(
"a_second_plugin_rebuilding_does_not_delete_the_shared_venv",
) {
return;
}
let dir = TempDir::new();
let a = manager_in(&dir, "pkg.a.PluginA", None);
let b = manager_in(&dir, "pkg.b.PluginB", None);
assert_eq!(a.layout().venv_path, b.layout().venv_path, "one venv");
assert!(matches!(
a.ensure().await.unwrap(),
EnsureOutcome::Built { .. }
));
assert!(a.python_executable().exists());
let marker = a.layout().venv_path.join("a-was-here.marker");
std::fs::write(&marker, "A's interpreter tree").unwrap();
assert!(!b.cache_verdict().is_valid());
assert_eq!(
b.ensure().await.unwrap(),
EnsureOutcome::SharedUpdate {
installed_requirements: false
},
"a co-owned venv must be updated in place, not recreated"
);
assert!(
marker.exists(),
"B's build deleted the shared venv out from under A"
);
assert!(
a.python_executable().exists(),
"A's interpreter must survive B's build"
);
let owners = a.owners().owners;
assert!(owners.contains("pkg.a.PluginA"), "owners: {owners:?}");
assert!(owners.contains("pkg.b.PluginB"), "owners: {owners:?}");
assert_eq!(a.ensure().await.unwrap(), EnsureOutcome::Reused);
assert_eq!(b.ensure().await.unwrap(), EnsureOutcome::Reused);
}
#[tokio::test]
async fn the_sole_owner_still_gets_a_destructive_rebuild() {
if crate::testing::skip_without_python3("the_sole_owner_still_gets_a_destructive_rebuild") {
return;
}
let dir = TempDir::new();
let mgr = manager_in(&dir, "pkg.Plugin", None);
mgr.ensure().await.unwrap();
let stale = mgr.layout().venv_path.join("stale-dependency.marker");
std::fs::write(&stale, "removed from requirements").unwrap();
std::fs::write(&mgr.layout().manifest_path, "version: 1.0.1\n").unwrap();
assert_eq!(mgr.cache_verdict(), CacheVerdict::ManifestHashChanged);
assert!(
matches!(mgr.ensure().await.unwrap(), EnsureOutcome::Built { .. }),
"the sole owner rebuilds rather than updating in place"
);
assert!(
!stale.exists(),
"a sole-owner rebuild must clear the old venv contents"
);
}
#[tokio::test]
async fn releasing_the_last_owner_restores_destructive_rebuilds() {
if crate::testing::skip_without_python3(
"releasing_the_last_owner_restores_destructive_rebuilds",
) {
return;
}
let dir = TempDir::new();
let a = manager_in(&dir, "pkg.a.PluginA", None);
let b = manager_in(&dir, "pkg.b.PluginB", None);
a.ensure().await.unwrap();
b.ensure().await.unwrap();
assert_eq!(a.owners().owners.len(), 2);
a.release_owner().await.unwrap();
assert!(!a.owners().has_other_owner("pkg.b.PluginB"));
let stale = b.layout().venv_path.join("stale.marker");
std::fs::write(&stale, "x").unwrap();
std::fs::write(&b.layout().manifest_path, "version: 2.0.0\n").unwrap();
assert!(matches!(
b.ensure().await.unwrap(),
EnsureOutcome::Built { .. }
));
assert!(!stale.exists(), "the sole remaining owner rebuilds fully");
b.release_owner().await.unwrap();
assert!(!b.layout().owners_path.exists());
}
#[test]
fn the_default_pip_timeout_is_applied_and_overridable() {
let dir = TempDir::new();
let mgr = manager_in(&dir, "pkg.Plugin", None);
assert_eq!(mgr.pip_timeout_secs(), DEFAULT_PIP_TIMEOUT_SECS);
assert_eq!(mgr.with_pip_timeout_secs(3).pip_timeout_secs(), 3);
}
#[tokio::test]
async fn a_hanging_pip_install_times_out_and_kills_the_child() {
if crate::testing::skip_without_python3(
"a_hanging_pip_install_times_out_and_kills_the_child",
) {
return;
}
let dir = TempDir::new();
std::fs::create_dir_all(dir.path().join("pkg")).unwrap();
std::fs::write(dir.path().join("pkg").join("requirements.txt"), "").unwrap();
let mgr = manager_in(&dir, "pkg.Plugin", Some("requirements.txt")).with_pip_timeout_secs(1);
mgr.prepare_directories(false).await.expect("dirs prepared");
mgr.create_venv().await.expect("the venv builds");
let site = {
let lib = mgr.layout().venv_path.join("lib");
std::fs::read_dir(&lib)
.expect("venv lib dir")
.flatten()
.map(|e| e.path().join("site-packages"))
.find(|p| p.exists())
.expect("site-packages exists in a built venv")
};
let pip_pkg = site.join("pip");
std::fs::create_dir_all(&pip_pkg).unwrap();
std::fs::write(pip_pkg.join("__init__.py"), "").unwrap();
let pidfile = dir.path().join("pip.pid");
std::fs::write(
pip_pkg.join("__main__.py"),
format!(
"import os, time\nopen({:?}, 'w').write(str(os.getpid()))\ntime.sleep(300)\n",
pidfile.display().to_string()
),
)
.unwrap();
let started = std::time::Instant::now();
let err = mgr
.install_requirements()
.await
.expect_err("a pip that never returns must not stall startup forever");
let elapsed = started.elapsed();
assert!(
matches!(err, HostError::VenvBuild { .. }),
"a pip timeout is a venv build failure: {err:?}"
);
let message = err.to_string();
assert!(
message.contains("timeout"),
"the error must name the timeout so an operator can raise it: {message}"
);
assert!(
elapsed < std::time::Duration::from_secs(30),
"the install must return on the timeout, not on pip's own completion: {elapsed:?}"
);
let pid: i32 = std::fs::read_to_string(&pidfile)
.expect("the fake pip recorded its pid")
.trim()
.parse()
.expect("pid is numeric");
let mut alive = true;
for _ in 0..40 {
let status = std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("kill -0 runs");
if !status.success() {
alive = false;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(
!alive,
"the timed-out pip process (pid {pid}) was leaked rather than killed"
);
}
#[tokio::test]
async fn a_failed_pip_install_carries_its_exit_code_and_stderr_as_details() {
if crate::testing::skip_without_python3(
"a_failed_pip_install_carries_its_exit_code_and_stderr_as_details",
) {
return;
}
let dir = TempDir::new();
std::fs::create_dir_all(dir.path().join("pkg")).unwrap();
std::fs::write(dir.path().join("pkg").join("requirements.txt"), "").unwrap();
let mgr = manager_in(&dir, "pkg.Plugin", Some("requirements.txt"));
mgr.prepare_directories(false).await.expect("dirs prepared");
mgr.create_venv().await.expect("the venv builds");
let site = {
let lib = mgr.layout().venv_path.join("lib");
std::fs::read_dir(&lib)
.expect("venv lib dir")
.flatten()
.map(|e| e.path().join("site-packages"))
.find(|p| p.exists())
.expect("site-packages exists in a built venv")
};
let pip_pkg = site.join("pip");
std::fs::create_dir_all(&pip_pkg).unwrap();
std::fs::write(pip_pkg.join("__init__.py"), "").unwrap();
std::fs::write(
pip_pkg.join("__main__.py"),
"import sys\nsys.stderr.write('ERROR: No matching distribution found for nope\\n')\n\
sys.exit(7)\n",
)
.unwrap();
let err = mgr
.install_requirements()
.await
.expect_err("a non-zero pip exit is a build failure");
let details = err.details();
assert_eq!(
details.get("exit_code").and_then(|v| v.as_i64()),
Some(7),
"pip's exit code must reach details, not just the prose: {details:?}"
);
assert_eq!(
details.get("step").and_then(|v| v.as_str()),
Some("pip install"),
"the failing step must be named so a caller can branch on it: {details:?}"
);
let stderr = details
.get("stderr_excerpt")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
stderr.contains("No matching distribution"),
"pip's own diagnosis must survive into details: {stderr:?}"
);
assert_eq!(
err.code(),
"venv_build_failed",
"the stable code stays the same as the message-only variant's"
);
}
#[tokio::test]
async fn a_manifest_edit_invalidates_the_cache() {
if crate::testing::skip_without_python3("a_manifest_edit_invalidates_the_cache") {
return;
}
let dir = TempDir::new();
let mgr = manager_in(&dir, "pkg.Plugin", None);
assert!(matches!(
mgr.ensure().await.unwrap(),
EnsureOutcome::Built { .. }
));
assert_eq!(mgr.ensure().await.unwrap(), EnsureOutcome::Reused);
std::fs::write(&mgr.layout().manifest_path, "version: 1.0.1\n").unwrap();
assert_eq!(mgr.cache_verdict(), CacheVerdict::ManifestHashChanged);
}
#[tokio::test]
async fn a_declared_version_bump_invalidates_the_cache() {
if crate::testing::skip_without_python3("a_declared_version_bump_invalidates_the_cache") {
return;
}
let dir = TempDir::new();
let v1 = manager_in(&dir, "pkg.Plugin", None);
assert!(matches!(
v1.ensure().await.unwrap(),
EnsureOutcome::Built { .. }
));
let v2 = VenvManager::new(
&[dir.path().display().to_string()],
"pkg.Plugin",
None,
Some("2.0.0"),
)
.unwrap();
assert_eq!(v2.cache_verdict(), CacheVerdict::ManifestVersionChanged);
}
#[tokio::test]
async fn metadata_written_by_this_host_is_a_cache_hit_for_it() {
if crate::testing::skip_without_python3(
"metadata_written_by_this_host_is_a_cache_hit_for_it",
) {
return;
}
let dir = TempDir::new();
let mgr = manager_in(&dir, "pkg.Plugin", None);
mgr.ensure().await.unwrap();
let raw = std::fs::read_to_string(&mgr.layout().metadata_path).unwrap();
let parsed: CacheMetadata =
serde_json::from_str(&raw).expect("our own metadata must parse");
assert_eq!(parsed.manifest_version.as_deref(), Some("1.0.0"));
assert!(parsed.manifest_hash.is_some());
assert_eq!(mgr.cache_verdict(), CacheVerdict::Valid);
}
}