use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
pub fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("cpex-hosts-python-{}-{unique}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("create temp dir");
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Default for TempDir {
fn default() -> Self {
Self::new()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
pub fn python3_available() -> bool {
std::process::Command::new("python3")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub const REQUIRE_ENV: &str = "CPEX_REQUIRE_PYTHON_E2E";
pub fn skips_are_failures() -> bool {
std::env::var(REQUIRE_ENV).is_ok_and(|v| !v.is_empty() && v != "0")
}
pub fn skip(test_name: &str, reason: &str) {
assert!(
!skips_are_failures(),
"{test_name} cannot run and {REQUIRE_ENV} is set: {reason}\n\nThis lane is supposed to \
have a complete Python end-to-end environment. Fix the environment (python3 on PATH, and \
{SOURCE_ENV} pointing at a cpex Python checkout with \
cpex/framework/isolated/worker.py) rather than unsetting {REQUIRE_ENV} — a silent skip \
here reports coverage that does not exist."
);
println!("SKIP {test_name}: {reason}");
}
#[must_use]
pub fn skip_without_python3(test_name: &str) -> bool {
if python3_available() {
return false;
}
skip(test_name, "python3 not found on PATH");
true
}
pub const SOURCE_ENV: &str = "CPEX_PYTHON_SOURCE";
pub fn python_source() -> Result<PathBuf, String> {
if let Ok(explicit) = std::env::var(SOURCE_ENV) {
let path = PathBuf::from(&explicit);
if path.join("pyproject.toml").is_file() {
return Ok(path);
}
return Err(format!("{SOURCE_ENV}={explicit} has no pyproject.toml"));
}
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.ok_or("could not locate the repository root")?;
[
repo_root.join("cpex-python"),
repo_root.join("../cpex-python"),
repo_root.to_path_buf(),
]
.iter()
.find(|p| {
p.join("pyproject.toml").is_file() && p.join("cpex/framework/isolated/worker.py").is_file()
})
.cloned()
.ok_or_else(|| {
format!(
"no cpex Python source tree found — set {SOURCE_ENV} to a checkout of the Python side \
(the branch carrying cpex/framework/isolated/worker.py)"
)
})
}
pub fn worker_consumes_credentials(source: &Path) -> bool {
std::fs::read_to_string(source.join("cpex/framework/isolated/worker.py"))
.map(|s| s.contains("reconstruct_credential_payload") && s.contains("CREDENTIAL_FIELD"))
.unwrap_or(false)
}
pub fn worker_delivers_extensions(source: &Path) -> bool {
std::fs::read_to_string(source.join("cpex/framework/isolated/worker.py"))
.map(|s| s.contains("EXTENSIONS_FIELD") && s.contains("extensions="))
.unwrap_or(false)
}
pub fn scaffold_plugin(
dir: &TempDir,
source: &Path,
package: &str,
module: &str,
plugin_source: &str,
) -> PathBuf {
let plugin_dir = dir.path().join("plugins");
let package_dir = plugin_dir.join(package);
std::fs::create_dir_all(&package_dir).expect("create package dir");
std::fs::write(package_dir.join("__init__.py"), "").expect("write __init__.py");
std::fs::write(package_dir.join(format!("{module}.py")), plugin_source)
.expect("write plugin module");
std::fs::write(
package_dir.join("requirements.txt"),
format!("{}\n", source.display()),
)
.expect("write requirements.txt");
plugin_dir
}
pub fn prebuild_venv(
plugin_dir: &Path,
source: &Path,
class_name: &str,
package: &str,
) -> Result<(), String> {
let venv = crate::venv::VenvManager::new(
&[plugin_dir.display().to_string()],
class_name,
Some("requirements.txt"),
Some("1.0.0"),
)
.map_err(|e| format!("could not resolve the venv layout: {e}"))?;
let layout = venv.layout();
std::fs::create_dir_all(&layout.cache_dir).map_err(|e| e.to_string())?;
let run = |program: &Path, args: &[&std::ffi::OsStr]| -> Result<(), String> {
let output = std::process::Command::new(program)
.args(args)
.output()
.map_err(|e| format!("could not run {}: {e}", program.display()))?;
if output.status.success() {
return Ok(());
}
Err(format!(
"{} exited {}: {}",
program.display(),
output.status,
String::from_utf8_lossy(&output.stderr).trim()
))
};
run(
Path::new("python3"),
&["-m".as_ref(), "venv".as_ref(), layout.venv_path.as_os_str()],
)?;
let python = layout.python_executable();
run(
&python,
&[
"-m".as_ref(),
"pip".as_ref(),
"install".as_ref(),
"-q".as_ref(),
source.as_os_str(),
],
)?;
let mcp_client = source.join("cpex/framework/external/mcp/client.py");
let mcp_client_source = std::fs::read_to_string(&mcp_client)
.map_err(|e| format!("could not inspect {}: {e}", mcp_client.display()))?;
if mcp_client_source.contains("McpError") {
run(
&python,
&[
"-m".as_ref(),
"pip".as_ref(),
"install".as_ref(),
"-q".as_ref(),
"mcp<1.26".as_ref(),
],
)?;
}
let requirements = plugin_dir.join(package).join("requirements.txt");
let metadata = serde_json::json!({
"venv_path": layout.venv_path.display().to_string(),
"requirements_file": requirements.display().to_string(),
"requirements_hash": crate::venv::hash_file_or_empty(Some(&requirements)),
"manifest_version": "1.0.0",
"manifest_hash": crate::venv::hash_file_or_empty(None),
});
std::fs::write(
&layout.metadata_path,
serde_json::to_string_pretty(&metadata).unwrap(),
)
.map_err(|e| e.to_string())?;
if !venv.cache_verdict().is_valid() {
return Err(format!(
"the pre-built venv is not recognized as cached ({:?}) — the metadata written here \
disagrees with what the host computes",
venv.cache_verdict()
));
}
Ok(())
}
pub const TEMP_DIR_KIND: &str = "isolated_venv_temp_dir";
pub struct TempDirFactory {
pub plugin_dir: PathBuf,
pub worker_cwd: PathBuf,
}
impl ppe::praxis_policy_core::factory::PluginFactory for TempDirFactory {
fn create(
&self,
config: &ppe::praxis_policy_core::plugin::PluginConfig,
) -> Result<
ppe::praxis_policy_core::factory::PluginInstance,
Box<ppe::praxis_policy_core::error::PluginError>,
> {
use std::sync::Arc;
use ppe::praxis_policy_core::registry::AnyHookHandler;
use crate::plugin::{IsolatedPythonPlugin, PythonHookAdapter};
let plugin = Arc::new(
IsolatedPythonPlugin::from_config(config)?
.with_plugin_dirs(vec![self.plugin_dir.display().to_string()])
.with_worker_cwd(self.worker_cwd.clone()),
);
let handlers = config
.hooks
.iter()
.map(|hook| -> (&'static str, Arc<dyn AnyHookHandler>) {
let leaked: &'static str = Box::leak(hook.clone().into_boxed_str());
(
leaked,
Arc::new(PythonHookAdapter::new(Arc::clone(&plugin), leaked)),
)
})
.collect();
Ok(ppe::praxis_policy_core::factory::PluginInstance { plugin, handlers })
}
}