From fc51e3a71088fa1ac6b410c52a3c1d7f9a22b1aa Mon Sep 17 00:00:00 2001 From: SigureMo Date: Fri, 1 May 2026 05:40:40 +0800 Subject: [PATCH 1/3] Refactor forking into a dedicated command --- README.md | 12 + src/cli/args.rs | 26 +- src/cli/env.rs | 27 +- src/cli/fork.rs | 24 ++ src/cli/install.rs | 10 +- src/cli/link.rs | 10 +- src/cli/mod.rs | 1 + src/main.rs | 38 +- src/store/venv_store.rs | 1 + src/venv/create.rs | 33 ++ src/venv/fork.rs | 711 ++++++++++++++++++++++++++++++++ src/{backend.rs => venv/mod.rs} | 98 +++-- 12 files changed, 926 insertions(+), 65 deletions(-) create mode 100644 src/cli/fork.rs create mode 100644 src/venv/create.rs create mode 100644 src/venv/fork.rs rename src/{backend.rs => venv/mod.rs} (86%) diff --git a/README.md b/README.md index bc984d1..aa327a5 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,16 @@ $ meowda deactivate # Recreate environment (clear existing packages) $ meowda create my-env -p 3.12 --clear +# Fork from the current Python environment +$ meowda fork cloned-env + +# Fork from another managed environment +$ meowda fork cloned-tools --from tools + +# Fork from any external virtual environment or Python executable +$ meowda fork cloned-ci --from /path/to/.venv +$ meowda fork cloned-system --from /path/to/python + # Install specific versions or from requirements $ meowda install "django>=4.0,<5.0" "pytest==7.4.0" $ meowda install -r requirements.txt @@ -143,6 +153,8 @@ Add to your `settings.json`: **Environment Management** - `meowda create -p ` - Create environment +- `meowda fork ` - Fork from the current active environment +- `meowda fork --from ` - Fork from another managed environment or any Python environment path/executable - `meowda activate ` - Activate environment - `meowda deactivate` - Deactivate current environment - `meowda remove ` - Remove environment diff --git a/src/cli/args.rs b/src/cli/args.rs index 73b53dc..be62a04 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -22,6 +22,8 @@ pub struct Args { pub enum Commands { #[clap(about = "Create a new virtual environment")] Create(CreateArgs), + #[clap(about = "Fork a virtual environment from an existing environment or Python executable")] + Fork(ForkArgs), #[clap(about = "Remove a virtual environment")] Remove(RemoveArgs), #[command(subcommand)] @@ -55,13 +57,29 @@ pub enum Commands { pub struct CreateArgs { #[arg(help = "Name of the virtual environment")] pub name: String, + #[arg(short, long, help = "Python version/path to use (default: 3.13)")] + pub python: Option, #[arg( short, long, - default_value = "3.13", - help = "Python version/path to use" + default_value = "false", + help = "Clear existing virtual environment" + )] + pub clear: bool, + #[clap(flatten)] + pub scope: ScopeArgs, +} + +#[derive(Debug, Parser, PartialEq)] +pub struct ForkArgs { + #[arg(help = "Name of the virtual environment")] + pub name: String, + #[arg( + long = "from", + value_name = "SOURCE", + help = "Fork from a managed environment name, a virtual environment path, or a Python executable (defaults to the current active Python environment, or the default Python if none is active)" )] - pub python: String, + pub source: Option, #[arg( short, long, @@ -91,6 +109,8 @@ pub struct InitArgs { pub enum EnvCommandsArgs { #[clap(about = "Create a new virtual environment")] Create(CreateArgs), + #[clap(about = "Fork a virtual environment from an existing environment or Python executable")] + Fork(ForkArgs), #[clap(about = "Remove a virtual environment")] Remove(RemoveArgs), #[clap(about = "List all virtual environments")] diff --git a/src/cli/env.rs b/src/cli/env.rs index 67f5652..6e7c2a0 100644 --- a/src/cli/env.rs +++ b/src/cli/env.rs @@ -1,22 +1,29 @@ -use crate::backend::{EnvInfo, VenvBackend}; use crate::cli::args::{CreateArgs, DirArgs, ListArgs, RemoveArgs}; use crate::store::venv_store::{ScopeType, VenvScope, VenvStore}; +use crate::venv::{CreateOptions, EnvInfo, VenvService}; use anstream::println; use anyhow::Result; use owo_colors::OwoColorize; -pub async fn create(args: CreateArgs, backend: &VenvBackend) -> Result<()> { +pub async fn create(args: CreateArgs, venv_service: &VenvService) -> Result<()> { let scope_type = args.scope.try_into_scope_type()?; let store = VenvStore::from_scope_type(scope_type)?; store.init_if_needed()?; - backend - .create(&store, &args.name, &args.python, args.clear) + venv_service + .create( + &store, + &args.name, + CreateOptions { + python: args.python.as_deref(), + clear: args.clear, + }, + ) .await?; println!("Virtual environment '{}' created successfully.", args.name); Ok(()) } -pub async fn remove(args: RemoveArgs, backend: &VenvBackend) -> Result<()> { +pub async fn remove(args: RemoveArgs, venv_service: &VenvService) -> Result<()> { let scope_type = args.scope.try_into_scope_type()?; let detected_venv_scope = crate::cli::utils::search_venv(scope_type, &args.name)?; let store = VenvStore::from_specified_scope(detected_venv_scope)?; @@ -26,7 +33,7 @@ pub async fn remove(args: RemoveArgs, backend: &VenvBackend) -> Result<()> { args.name ); } - backend.remove(&store, &args.name).await?; + venv_service.remove(&store, &args.name).await?; println!("Virtual environment '{}' removed successfully.", args.name); Ok(()) } @@ -59,8 +66,8 @@ fn show_envs(envs: &[EnvInfo], shadowed_names: &[String]) -> Result<()> { Ok(()) } -pub async fn list(args: ListArgs, backend: &VenvBackend) -> Result<()> { - let all_envs = backend.list().await?; +pub async fn list(args: ListArgs, venv_service: &VenvService) -> Result<()> { + let all_envs = venv_service.list().await?; let scope_type = args.scope.try_into_scope_type()?; let show_local = matches!(scope_type, ScopeType::Local | ScopeType::Unspecified); let show_global = matches!(scope_type, ScopeType::Global | ScopeType::Unspecified); @@ -89,10 +96,10 @@ pub async fn list(args: ListArgs, backend: &VenvBackend) -> Result<()> { Ok(()) } -pub async fn dir(args: DirArgs, backend: &VenvBackend) -> Result<()> { +pub async fn dir(args: DirArgs, venv_service: &VenvService) -> Result<()> { let scope_type = args.scope.try_into_scope_type()?; let store = VenvStore::from_scope_type(scope_type)?; - let path = backend.dir(&store)?; + let path = venv_service.dir(&store)?; println!("{}", path.display()); Ok(()) } diff --git a/src/cli/fork.rs b/src/cli/fork.rs new file mode 100644 index 0000000..c3c06b9 --- /dev/null +++ b/src/cli/fork.rs @@ -0,0 +1,24 @@ +use crate::cli::args::ForkArgs; +use crate::store::venv_store::VenvStore; +use crate::venv::{ForkOptions, VenvService}; +use anstream::println; +use anyhow::Result; + +pub async fn fork(args: ForkArgs, venv_service: &VenvService) -> Result<()> { + let scope_type = args.scope.try_into_scope_type()?; + let store = VenvStore::from_scope_type(scope_type)?; + store.init_if_needed()?; + venv_service + .fork( + &store, + &args.name, + ForkOptions { + scope_type, + source: args.source.as_deref(), + clear: args.clear, + }, + ) + .await?; + println!("Virtual environment '{}' forked successfully.", args.name); + Ok(()) +} diff --git a/src/cli/install.rs b/src/cli/install.rs index 0d44031..0a25872 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -1,15 +1,15 @@ -use crate::backend::VenvBackend; use crate::cli::args::{InstallArgs, UninstallArgs}; +use crate::venv::VenvService; use anyhow::Result; -pub async fn install(args: InstallArgs, backend: &VenvBackend) -> Result<()> { +pub async fn install(args: InstallArgs, venv_service: &VenvService) -> Result<()> { let extra_args: Vec<&str> = args.extra_args.iter().map(|s| s.as_str()).collect(); - backend.install(&extra_args).await?; + venv_service.install(&extra_args).await?; Ok(()) } -pub async fn uninstall(args: UninstallArgs, backend: &VenvBackend) -> Result<()> { +pub async fn uninstall(args: UninstallArgs, venv_service: &VenvService) -> Result<()> { let extra_args: Vec<&str> = args.extra_args.iter().map(|s| s.as_str()).collect(); - backend.uninstall(&extra_args).await?; + venv_service.uninstall(&extra_args).await?; Ok(()) } diff --git a/src/cli/link.rs b/src/cli/link.rs index 72a742b..41270be 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -1,13 +1,13 @@ -use crate::backend::VenvBackend; use crate::cli::args::{LinkArgs, UnlinkArgs}; +use crate::venv::VenvService; use anyhow::Result; -pub async fn link(args: LinkArgs, backend: &VenvBackend) -> Result<()> { - backend.link(&args.name, &args.path).await?; +pub async fn link(args: LinkArgs, venv_service: &VenvService) -> Result<()> { + venv_service.link(&args.name, &args.path).await?; Ok(()) } -pub async fn unlink(args: UnlinkArgs, backend: &VenvBackend) -> Result<()> { - backend.unlink(&args.name).await?; +pub async fn unlink(args: UnlinkArgs, venv_service: &VenvService) -> Result<()> { + venv_service.unlink(&args.name).await?; Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f350521..4212897 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,7 @@ pub mod activate; pub mod args; pub mod env; +pub mod fork; pub mod init; pub mod install; pub mod link; diff --git a/src/main.rs b/src/main.rs index ca8d852..d433d2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,15 @@ -mod backend; mod cli; mod envs; mod store; +mod venv; use anstream::eprintln; use clap::Parser; #[tokio::main] async fn main() -> Result<(), Box> { let args = cli::args::Args::parse(); - let backend = match backend::VenvBackend::new() { - Ok(backend) => backend, + let venv_service = match venv::VenvService::new() { + Ok(venv_service) => venv_service, Err(e) => { eprintln!("{e}"); std::process::exit(1); @@ -17,19 +17,29 @@ async fn main() -> Result<(), Box> { }; let result = match args.command { - cli::args::Commands::Create(create_args) => cli::env::create(create_args, &backend).await, - cli::args::Commands::Remove(remove_args) => cli::env::remove(remove_args, &backend).await, + cli::args::Commands::Create(create_args) => { + cli::env::create(create_args, &venv_service).await + } + cli::args::Commands::Fork(fork_args) => cli::fork::fork(fork_args, &venv_service).await, + cli::args::Commands::Remove(remove_args) => { + cli::env::remove(remove_args, &venv_service).await + } cli::args::Commands::Env(env_args) => match env_args { cli::args::EnvCommandsArgs::Create(create_args) => { - cli::env::create(create_args, &backend).await + cli::env::create(create_args, &venv_service).await + } + cli::args::EnvCommandsArgs::Fork(fork_args) => { + cli::fork::fork(fork_args, &venv_service).await } cli::args::EnvCommandsArgs::Remove(remove_args) => { - cli::env::remove(remove_args, &backend).await + cli::env::remove(remove_args, &venv_service).await } cli::args::EnvCommandsArgs::List(list_args) => { - cli::env::list(list_args, &backend).await + cli::env::list(list_args, &venv_service).await + } + cli::args::EnvCommandsArgs::Dir(dir_args) => { + cli::env::dir(dir_args, &venv_service).await } - cli::args::EnvCommandsArgs::Dir(dir_args) => cli::env::dir(dir_args, &backend).await, }, cli::args::Commands::Init(init_args) => cli::init::init(init_args).await, cli::args::Commands::_GenerateInitScript => cli::init::generate_init_script().await, @@ -41,13 +51,15 @@ async fn main() -> Result<(), Box> { cli::activate::detect_activate_venv_path(activate_args).await } cli::args::Commands::Install(install_args) => { - cli::install::install(install_args, &backend).await + cli::install::install(install_args, &venv_service).await } cli::args::Commands::Uninstall(uninstall_args) => { - cli::install::uninstall(uninstall_args, &backend).await + cli::install::uninstall(uninstall_args, &venv_service).await + } + cli::args::Commands::Link(link_args) => cli::link::link(link_args, &venv_service).await, + cli::args::Commands::Unlink(unlink_args) => { + cli::link::unlink(unlink_args, &venv_service).await } - cli::args::Commands::Link(link_args) => cli::link::link(link_args, &backend).await, - cli::args::Commands::Unlink(unlink_args) => cli::link::unlink(unlink_args, &backend).await, }; if let Err(e) = result { diff --git a/src/store/venv_store.rs b/src/store/venv_store.rs index af75d99..1d1e3a8 100644 --- a/src/store/venv_store.rs +++ b/src/store/venv_store.rs @@ -50,6 +50,7 @@ pub fn get_candidate_scopes(scope_type: ScopeType) -> Result> { Ok(scopes) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ScopeType { Local, Global, diff --git a/src/venv/create.rs b/src/venv/create.rs new file mode 100644 index 0000000..275b407 --- /dev/null +++ b/src/venv/create.rs @@ -0,0 +1,33 @@ +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::Command; + +pub(super) fn create_uv_venv( + uv_path: &str, + venv_path: &Path, + python: &str, + seed: bool, + include_system_site_packages: bool, +) -> Result<()> { + let venv_path_str = venv_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("Invalid path for virtual environment"))?; + + let mut command = Command::new(uv_path); + command.args(["venv", venv_path_str, "--python", python]); + if seed { + command.arg("--seed"); + } + if include_system_site_packages { + command.arg("--system-site-packages"); + } + + let status = command.status().context("Failed to execute uv command")?; + if !status.success() { + anyhow::bail!( + "Failed to create virtual environment. Check Python version/source environment and try again" + ); + } + + Ok(()) +} diff --git a/src/venv/fork.rs b/src/venv/fork.rs new file mode 100644 index 0000000..2d48a1c --- /dev/null +++ b/src/venv/fork.rs @@ -0,0 +1,711 @@ +use super::{EnvConfig, VenvService, create::create_uv_venv}; +use crate::store::venv_store::{ScopeType, VenvStore, get_candidate_scopes}; +use anyhow::{Context, Result}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const PYTHON_INFO_SEPARATOR: char = '\u{1f}'; +const PYTHON_INFO_SCRIPT: &str = r#"import sys +import sysconfig + +separator = "\x1f" + +def emit(key, value): + if value is None: + value = "" + print(f"{key}{separator}{value}") + +emit("executable", sys.executable) +emit("base_executable", getattr(sys, "_base_executable", sys.executable)) +emit("prefix", sys.prefix) +emit("base_prefix", getattr(sys, "base_prefix", sys.prefix)) +emit("real_prefix", getattr(sys, "real_prefix", "")) +emit("scripts", sysconfig.get_path("scripts") or "") +emit("purelib", sysconfig.get_path("purelib") or "") +emit("platlib", sysconfig.get_path("platlib") or "") +"#; + +#[derive(Debug, Clone)] +pub(super) struct PythonEnvLayout { + python: PathBuf, + base_python: PathBuf, + prefix: PathBuf, + purelib: PathBuf, + platlib: PathBuf, + scripts_dir: PathBuf, + include_system_site_packages: bool, + requested_prefix: Option, +} + +impl PythonEnvLayout { + pub(super) fn prefix(&self) -> &Path { + &self.prefix + } +} + +#[derive(Debug, Clone)] +struct RewritePaths { + replacements: Vec<(String, String)>, +} + +enum ForkSource { + Directory(PathBuf), + Python(String), +} + +pub(super) fn resolve_current_source() -> Result { + inspect_resolved_source(resolve_current_source_spec()?) +} + +pub(super) fn resolve_named_source(source: &str, scope_type: ScopeType) -> Result { + inspect_resolved_source(resolve_source_spec(source, scope_type)?).with_context(|| { + format!( + "Failed to resolve fork source '{source}' in the selected scope or as a Python executable" + ) + }) +} + +pub(super) fn create_with_source( + uv_path: &str, + source: &PythonEnvLayout, + target_path: &Path, +) -> Result<()> { + let target_path = normalize_path(target_path)?; + let source_prefix = normalize_path(&source.prefix)?; + if source_prefix == target_path { + anyhow::bail!("Fork source and target cannot be the same environment"); + } + + create_uv_venv( + uv_path, + &target_path, + source.base_python.to_string_lossy().as_ref(), + false, + source.include_system_site_packages, + )?; + + let target_python = python_path_in_venv(&target_path); + let mut target = inspect_python_env(target_python.to_string_lossy().as_ref())?; + target.requested_prefix = Some(target_path.clone()); + let rewrite = build_rewrite_paths(source, &target); + copy_site_packages(source, &target, &rewrite)?; + copy_scripts(source, &target, &rewrite)?; + Ok(()) +} + +fn normalize_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + if path.exists() { + path.canonicalize() + .with_context(|| format!("Failed to canonicalize path '{}'", path.display())) + } else { + std::path::absolute(path) + .with_context(|| format!("Failed to resolve path '{}'", path.display())) + } +} + +fn python_path_in_venv(venv_path: &Path) -> PathBuf { + #[cfg(windows)] + { + return venv_path.join("Scripts").join("python.exe"); + } + + #[cfg(not(windows))] + { + venv_path.join("bin").join("python") + } +} + +fn is_text_file(path: &Path) -> Result { + let bytes = + fs::read(path).with_context(|| format!("Failed to read file '{}'", path.display()))?; + if bytes.contains(&0) { + return Ok(false); + } + Ok(std::str::from_utf8(&bytes).is_ok()) +} + +fn is_package_metadata(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| matches!(ext, "pth" | "egg-link")) +} + +fn apply_rewrite(path: &Path, rewrite: &RewritePaths) -> Result<()> { + if !is_text_file(path)? { + return Ok(()); + } + + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read file '{}' as text", path.display()))?; + let mut updated = content.clone(); + for (from, to) in &rewrite.replacements { + updated = updated.replace(from, to); + } + if updated != content { + fs::write(path, updated) + .with_context(|| format!("Failed to rewrite file '{}'", path.display()))?; + } + Ok(()) +} + +fn copy_permissions(source: &Path, target: &Path) -> Result<()> { + let permissions = fs::metadata(source) + .with_context(|| format!("Failed to read metadata for '{}'", source.display()))? + .permissions(); + fs::set_permissions(target, permissions) + .with_context(|| format!("Failed to update permissions for '{}'", target.display())) +} + +#[cfg(unix)] +fn create_symlink(target: &Path, link_path: &Path, _is_dir: bool) -> Result<()> { + std::os::unix::fs::symlink(target, link_path).with_context(|| { + format!( + "Failed to create symlink '{}' -> '{}'", + link_path.display(), + target.display() + ) + }) +} + +#[cfg(windows)] +fn create_symlink(target: &Path, link_path: &Path, is_dir: bool) -> Result<()> { + if is_dir { + std::os::windows::fs::symlink_dir(target, link_path) + } else { + std::os::windows::fs::symlink_file(target, link_path) + } + .with_context(|| { + format!( + "Failed to create symlink '{}' -> '{}'", + link_path.display(), + target.display() + ) + }) +} + +fn rewrite_symlink_target(target: &Path, rewrite: &RewritePaths) -> PathBuf { + let target_string = target.to_string_lossy().into_owned(); + for (from, to) in &rewrite.replacements { + if let Some(relative) = target_string.strip_prefix(from) { + return PathBuf::from(format!("{to}{relative}")); + } + } + + target.to_path_buf() +} + +fn add_replacement_pair( + replacements: &mut Vec<(String, String)>, + from: impl AsRef, + to: impl AsRef, +) { + let from = from.as_ref().to_string_lossy().into_owned(); + let to = to.as_ref().to_string_lossy().into_owned(); + if from.is_empty() || to.is_empty() || from == to { + return; + } + if replacements + .iter() + .any(|(existing_from, existing_to)| existing_from == &from && existing_to == &to) + { + return; + } + replacements.push((from, to)); +} + +fn build_rewrite_paths(source: &PythonEnvLayout, target: &PythonEnvLayout) -> RewritePaths { + let mut replacements = Vec::new(); + add_replacement_pair(&mut replacements, &source.python, &target.python); + add_replacement_pair(&mut replacements, &source.prefix, &target.prefix); + + if let (Some(source_prefix), Some(target_prefix)) = ( + source.requested_prefix.as_ref(), + target.requested_prefix.as_ref(), + ) { + add_replacement_pair(&mut replacements, source_prefix, target_prefix); + } + + RewritePaths { replacements } +} + +fn copy_file( + source: &Path, + target: &Path, + rewrite: &RewritePaths, + rewrite_text_files: bool, +) -> Result<()> { + fs::copy(source, target).with_context(|| { + format!( + "Failed to copy file '{}' to '{}'", + source.display(), + target.display() + ) + })?; + copy_permissions(source, target)?; + if rewrite_text_files || is_package_metadata(source) { + apply_rewrite(target, rewrite)?; + } + Ok(()) +} + +fn copy_path( + source: &Path, + target: &Path, + rewrite: &RewritePaths, + rewrite_text_files: bool, +) -> Result<()> { + let metadata = fs::symlink_metadata(source) + .with_context(|| format!("Failed to inspect '{}'", source.display()))?; + if metadata.file_type().is_symlink() { + let link_target = fs::read_link(source) + .with_context(|| format!("Failed to read symlink '{}'", source.display()))?; + let rewritten_target = rewrite_symlink_target(&link_target, rewrite); + let link_is_dir = fs::metadata(source) + .map(|meta| meta.is_dir()) + .unwrap_or(false); + create_symlink(&rewritten_target, target, link_is_dir)?; + return Ok(()); + } + + if metadata.is_dir() { + fs::create_dir_all(target) + .with_context(|| format!("Failed to create directory '{}'", target.display()))?; + copy_permissions(source, target)?; + for entry in fs::read_dir(source) + .with_context(|| format!("Failed to read directory '{}'", source.display()))? + { + let entry = entry + .with_context(|| format!("Failed to read entry inside '{}'", source.display()))?; + copy_path( + &entry.path(), + &target.join(entry.file_name()), + rewrite, + rewrite_text_files, + )?; + } + return Ok(()); + } + + copy_file(source, target, rewrite, rewrite_text_files) +} + +fn copy_directory_contents( + source_dir: &Path, + target_dir: &Path, + rewrite: &RewritePaths, + rewrite_text_files: bool, +) -> Result<()> { + if !source_dir.exists() { + return Ok(()); + } + fs::create_dir_all(target_dir) + .with_context(|| format!("Failed to create '{}'", target_dir.display()))?; + for entry in fs::read_dir(source_dir) + .with_context(|| format!("Failed to read directory '{}'", source_dir.display()))? + { + let entry = entry + .with_context(|| format!("Failed to read entry inside '{}'", source_dir.display()))?; + copy_path( + &entry.path(), + &target_dir.join(entry.file_name()), + rewrite, + rewrite_text_files, + )?; + } + Ok(()) +} + +fn is_core_script(name: &str) -> bool { + let lowered = name.to_ascii_lowercase(); + lowered.starts_with("activate") || lowered.starts_with("python") +} + +fn parse_python_layout(output: &str) -> Result { + let values = output + .lines() + .filter_map(|line| line.split_once(PYTHON_INFO_SEPARATOR)) + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect::>(); + + let required = |key: &str| -> Result { + let value = values + .get(key) + .ok_or_else(|| anyhow::anyhow!("Missing '{key}' in Python environment inspection"))?; + if value.is_empty() { + anyhow::bail!("Python environment inspection returned an empty '{key}'"); + } + Ok(PathBuf::from(value)) + }; + + let prefix = required("prefix")?; + let base_prefix = required("base_prefix")?; + let real_prefix = values.get("real_prefix").cloned().unwrap_or_default(); + let include_system_site_packages = EnvConfig::parse(prefix.join("pyvenv.cfg")) + .map(|config| config.include_system_site_packages) + .unwrap_or(false); + + Ok(PythonEnvLayout { + python: required("executable")?, + base_python: required("base_executable")?, + prefix: prefix.clone(), + purelib: required("purelib")?, + platlib: required("platlib")?, + scripts_dir: required("scripts")?, + include_system_site_packages: prefix.join("pyvenv.cfg").exists() + && (prefix != base_prefix || !real_prefix.is_empty() || include_system_site_packages), + requested_prefix: None, + }) +} + +fn inspect_python_env(python: &str) -> Result { + let output = Command::new(python) + .args(["-c", PYTHON_INFO_SCRIPT]) + .output() + .with_context(|| format!("Failed to inspect Python environment with '{python}'"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "Failed to inspect Python environment with '{}': {}", + python, + stderr.trim() + ); + } + + parse_python_layout(String::from_utf8_lossy(&output.stdout).as_ref()) +} + +fn find_python_in_directory(dir: &Path) -> Result> { + #[cfg(windows)] + { + let candidates = [ + dir.join("Scripts").join("python.exe"), + dir.join("python.exe"), + ]; + for candidate in candidates { + if candidate.exists() { + return Ok(Some(candidate)); + } + } + Ok(None) + } + + #[cfg(not(windows))] + { + let bin_dir = dir.join("bin"); + if !bin_dir.is_dir() { + return Ok(None); + } + + let preferred = ["python", "python3"]; + for name in preferred { + let candidate = bin_dir.join(name); + if candidate.exists() { + return Ok(Some(candidate)); + } + } + + let mut fallback = None; + for entry in fs::read_dir(&bin_dir) + .with_context(|| format!("Failed to read '{}'", bin_dir.display()))? + { + let entry = entry + .with_context(|| format!("Failed to read entry inside '{}'", bin_dir.display()))?; + if entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("python")) + { + fallback = Some(entry.path()); + break; + } + } + Ok(fallback) + } +} + +fn resolve_managed_env(name: &str, scope_type: ScopeType) -> Result> { + for scope in get_candidate_scopes(scope_type)? { + let store = VenvStore::from_specified_scope(scope)?; + if store.is_ready() && store.exists(name) { + return Ok(Some(store.path().join(name))); + } + } + Ok(None) +} + +fn env_root_from_python_path(path: &Path) -> Option { + let parent = path.parent()?; + let dirname = parent.file_name()?.to_str()?; + if matches!(dirname, "bin" | "Scripts") { + return parent.parent().map(Path::to_path_buf); + } + None +} + +fn resolve_current_source_spec() -> Result { + if let Some(current_venv) = VenvService::detect_current_venv() { + return Ok(ForkSource::Directory(current_venv)); + } + + for candidate in ["python", "python3"] { + if Command::new(candidate) + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()) + { + return Ok(ForkSource::Python(candidate.to_string())); + } + } + + anyhow::bail!( + "Unable to resolve the current Python environment. Activate one first or pass an explicit path" + ) +} + +fn resolve_source_spec(source: &str, scope_type: ScopeType) -> Result { + let source_path = PathBuf::from(source); + if source_path.exists() { + if source_path.is_dir() { + let absolute = normalize_path(&source_path)?; + return Ok(ForkSource::Directory(absolute)); + } + + let absolute = std::path::absolute(&source_path).with_context(|| { + format!( + "Failed to resolve Python executable path '{}'", + source_path.display() + ) + })?; + return Ok(ForkSource::Python(absolute.to_string_lossy().into_owned())); + } + + if let Some(managed_env) = resolve_managed_env(source, scope_type)? { + return Ok(ForkSource::Directory(normalize_path(managed_env)?)); + } + + Ok(ForkSource::Python(source.to_string())) +} + +fn inspect_resolved_source(source: ForkSource) -> Result { + match source { + ForkSource::Directory(path) => { + if path.join("pyvenv.cfg").exists() { + let python = find_python_in_directory(&path)?.ok_or_else(|| { + anyhow::anyhow!( + "Virtual environment '{}' does not contain a usable Python executable", + path.display() + ) + })?; + let mut layout = inspect_python_env(&python.to_string_lossy())?; + layout.requested_prefix = Some(path); + return Ok(layout); + } + + if let Some(python) = find_python_in_directory(&path)? { + let mut layout = inspect_python_env(&python.to_string_lossy())?; + layout.requested_prefix = Some(path); + return Ok(layout); + } + + anyhow::bail!( + "Fork source '{}' is not a Python environment or Python executable", + path.display() + ) + } + ForkSource::Python(python) => { + let mut layout = inspect_python_env(&python)?; + layout.requested_prefix = env_root_from_python_path(Path::new(&python)); + Ok(layout) + } + } +} + +fn copy_site_packages( + source: &PythonEnvLayout, + target: &PythonEnvLayout, + rewrite: &RewritePaths, +) -> Result<()> { + let mut copied_pairs = HashSet::new(); + for (source_dir, target_dir) in [ + (&source.purelib, &target.purelib), + (&source.platlib, &target.platlib), + ] { + let key = format!( + "{}=>{}", + source_dir.to_string_lossy(), + target_dir.to_string_lossy() + ); + if !copied_pairs.insert(key) { + continue; + } + + copy_directory_contents(source_dir, target_dir, rewrite, false)?; + } + + Ok(()) +} + +fn copy_scripts( + source: &PythonEnvLayout, + target: &PythonEnvLayout, + rewrite: &RewritePaths, +) -> Result<()> { + if !source.scripts_dir.exists() { + return Ok(()); + } + + fs::create_dir_all(&target.scripts_dir) + .with_context(|| format!("Failed to create '{}'", target.scripts_dir.display()))?; + for entry in fs::read_dir(&source.scripts_dir) + .with_context(|| format!("Failed to read '{}'", source.scripts_dir.display()))? + { + let entry = entry.with_context(|| { + format!( + "Failed to read entry inside '{}'", + source.scripts_dir.display() + ) + })?; + let name = entry.file_name(); + if name.to_str().is_some_and(is_core_script) { + continue; + } + + copy_path( + &entry.path(), + &target.scripts_dir.join(&name), + rewrite, + true, + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn package_metadata_rewrite_updates_source_paths() -> Result<()> { + let temp = tempdir()?; + let source_root = temp.path().join("source"); + let target_root = temp.path().join("target"); + let source_file = source_root.join("lib/python3.12/site-packages/demo.pth"); + let target_file = target_root.join("lib/python3.12/site-packages/demo.pth"); + fs::create_dir_all(source_file.parent().expect("parent exists"))?; + fs::create_dir_all(target_file.parent().expect("parent exists"))?; + fs::write( + &source_file, + format!( + "{}\n{}\n", + source_root.display(), + source_root.join("bin/python").display() + ), + )?; + + let rewrite = RewritePaths { + replacements: vec![ + ( + source_root + .join("bin/python") + .to_string_lossy() + .into_owned(), + target_root + .join("bin/python") + .to_string_lossy() + .into_owned(), + ), + ( + source_root.to_string_lossy().into_owned(), + target_root.to_string_lossy().into_owned(), + ), + ], + }; + + copy_file(&source_file, &target_file, &rewrite, false)?; + + let rewritten = fs::read_to_string(target_file)?; + assert!(rewritten.contains(target_root.to_string_lossy().as_ref())); + assert!(!rewritten.contains(source_root.to_string_lossy().as_ref())); + Ok(()) + } + + #[test] + fn script_copy_skips_python_and_activate_entries() { + assert!(is_core_script("python")); + assert!(is_core_script("python3.12")); + assert!(is_core_script("activate.fish")); + assert!(!is_core_script("ruff")); + } + + #[test] + fn parse_python_layout_reads_required_fields() -> Result<()> { + let output = [ + "executable\u{1f}/tmp/source/bin/python", + "base_executable\u{1f}/usr/bin/python3.12", + "prefix\u{1f}/tmp/source", + "base_prefix\u{1f}/usr", + "real_prefix\u{1f}", + "scripts\u{1f}/tmp/source/bin", + "purelib\u{1f}/tmp/source/lib/python3.12/site-packages", + "platlib\u{1f}/tmp/source/lib/python3.12/site-packages", + ] + .join("\n"); + + let layout = parse_python_layout(&output)?; + assert_eq!(layout.prefix, PathBuf::from("/tmp/source")); + assert_eq!(layout.base_python, PathBuf::from("/usr/bin/python3.12")); + assert_eq!(layout.scripts_dir, PathBuf::from("/tmp/source/bin")); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn resolve_source_spec_preserves_venv_python_symlink_path() -> Result<()> { + let temp = tempdir()?; + let source_root = temp.path().join("source-env"); + let bin_dir = source_root.join("bin"); + fs::create_dir_all(&bin_dir)?; + + let base_python = temp.path().join("python3.14"); + fs::write(&base_python, "#!/bin/sh\n")?; + let venv_python = bin_dir.join("python"); + std::os::unix::fs::symlink(&base_python, &venv_python)?; + + let resolved = resolve_source_spec( + venv_python.to_string_lossy().as_ref(), + ScopeType::Unspecified, + )?; + match resolved { + ForkSource::Python(path) => { + assert_eq!(PathBuf::from(path), std::path::absolute(&venv_python)?); + } + ForkSource::Directory(_) => { + anyhow::bail!("expected a Python executable source"); + } + } + + Ok(()) + } + + #[test] + fn resolve_source_spec_treats_current_as_regular_source_name() -> Result<()> { + let resolved = resolve_source_spec("current", ScopeType::Unspecified)?; + match resolved { + ForkSource::Python(path) => { + assert_eq!(path, "current"); + } + ForkSource::Directory(path) => { + anyhow::bail!( + "expected 'current' to remain an explicit source name, got '{}'", + path.display() + ); + } + } + + Ok(()) + } +} diff --git a/src/backend.rs b/src/venv/mod.rs similarity index 86% rename from src/backend.rs rename to src/venv/mod.rs index daba08e..829cb98 100644 --- a/src/backend.rs +++ b/src/venv/mod.rs @@ -1,3 +1,6 @@ +mod create; +mod fork; + use crate::store::venv_store::{ScopeType, VenvScope, VenvStore, get_candidate_scopes}; use anyhow::{Context, Result}; use owo_colors::OwoColorize; @@ -5,6 +8,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; use tracing::info; +use self::create::create_uv_venv; +use self::fork::{create_with_source, resolve_current_source, resolve_named_source}; + #[derive(Debug, Clone)] pub struct EnvInfo { pub name: String, @@ -47,7 +53,7 @@ impl EnvConfig { for line in content.lines() { let line = line.trim(); if !line.contains('=') { - continue; // Skip lines without '=' (e.g., comments or empty lines) + continue; } let (key, value) = line.split_once('=').with_context(|| { format!( @@ -68,7 +74,7 @@ impl EnvConfig { "version" | "version_info" => { version = Some(value.to_string()); } - _ => continue, // Ignore unknown keys + _ => continue, } } @@ -83,11 +89,22 @@ impl EnvConfig { } } -pub struct VenvBackend { +pub struct VenvService { uv_path: String, } -impl VenvBackend { +pub struct CreateOptions<'a> { + pub python: Option<&'a str>, + pub clear: bool, +} + +pub struct ForkOptions<'a> { + pub scope_type: ScopeType, + pub source: Option<&'a str>, + pub clear: bool, +} + +impl VenvService { pub fn new() -> Result { let uv_path = "uv"; if !Self::check_uv_available(uv_path) { @@ -96,13 +113,12 @@ impl VenvBackend { ); } - Ok(VenvBackend { + Ok(VenvService { uv_path: uv_path.to_string(), }) } fn check_uv_available(uv_path: &str) -> bool { - // check if uv is available by commanding `uv --version` Command::new(uv_path) .arg("--version") .output() @@ -116,7 +132,7 @@ impl VenvBackend { Ok(()) } - fn detect_current_venv() -> Option { + pub(super) fn detect_current_venv() -> Option { std::env::var("VIRTUAL_ENV") .ok() .and_then(|s| std::path::absolute(PathBuf::from(s)).ok()) @@ -144,17 +160,15 @@ impl VenvBackend { Ok(site_package_dir) } - // Venv management methods pub async fn create( &self, store: &VenvStore, name: &str, - python: &str, - clear: bool, + options: CreateOptions<'_>, ) -> Result<()> { let _lock = store.lock().await?; if store.exists(name) { - if clear { + if options.clear { Self::remove_venv(store, name)?; } else { anyhow::bail!( @@ -164,25 +178,50 @@ impl VenvBackend { } } let venv_path = store.path().join(name); - let venv_path_str = venv_path - .to_str() - .ok_or_else(|| anyhow::anyhow!("Invalid path for virtual environment"))?; - - let status = Command::new(&self.uv_path) - .args(["venv", venv_path_str, "--python", python, "--seed"]) - .status() - .context("Failed to execute uv command")?; + create_uv_venv( + &self.uv_path, + &venv_path, + options.python.unwrap_or("3.13"), + true, + false, + )?; + info!( + "Created virtual environment '{}' at {}", + name.green(), + venv_path.display().to_string().blue() + ); + Ok(()) + } - if !status.success() { - anyhow::bail!( - "Failed to create virtual environment. Check Python version and try again" - ); + pub async fn fork( + &self, + store: &VenvStore, + name: &str, + options: ForkOptions<'_>, + ) -> Result<()> { + let source_layout = options + .source + .map(|source| resolve_named_source(source, options.scope_type)) + .transpose()? + .unwrap_or(resolve_current_source()?); + let _lock = store.lock().await?; + if store.exists(name) { + if options.clear { + Self::remove_venv(store, name)?; + } else { + anyhow::bail!( + "Virtual environment '{}' already exists. Use --clear to recreate it", + name + ); + } } - + let venv_path = store.path().join(name); + create_with_source(&self.uv_path, &source_layout, &venv_path)?; info!( - "Created virtual environment '{}' at {}", + "Forked virtual environment '{}' from {} to {}", name.green(), - venv_path_str.blue() + source_layout.prefix().display().to_string().blue(), + venv_path.display().to_string().blue() ); Ok(()) } @@ -211,7 +250,6 @@ impl VenvBackend { e.file_name().to_str().map(|name| { let env_path = e.path(); let is_active = if let Some(current) = current_venv { - // Compare the actual environment paths env_path.canonicalize().ok() == current.canonicalize().ok() } else { false @@ -251,12 +289,10 @@ impl VenvBackend { Ok(results) } - // File management methods pub fn dir(&self, store: &VenvStore) -> Result { Ok(store.path().clone()) } - // Package management methods fn check_env_is_managed(current_venv: &PathBuf) -> Result { let scopes = get_candidate_scopes(ScopeType::Unspecified)?; for scope in scopes { @@ -271,6 +307,7 @@ impl VenvBackend { current_venv.display() ); } + pub async fn install(&self, extra_args: &[&str]) -> Result<()> { let current_venv = Self::detect_current_venv() .ok_or_else(|| anyhow::anyhow!("No virtual environment is currently activated.\nPlease activate a virtual environment first with: meowda activate "))?; @@ -291,6 +328,7 @@ impl VenvBackend { println!("Packages installed successfully."); Ok(()) } + pub async fn uninstall(&self, extra_args: &[&str]) -> Result<()> { let current_venv = Self::detect_current_venv() .ok_or_else(|| anyhow::anyhow!("No virtual environment is currently activated.\nPlease activate a virtual environment first with: meowda activate "))?; @@ -311,6 +349,7 @@ impl VenvBackend { println!("Packages uninstalled successfully."); Ok(()) } + pub async fn link(&self, project_name: &str, project_path: &str) -> Result<()> { let current_venv = Self::detect_current_venv() .ok_or_else(|| anyhow::anyhow!("No virtual environment is currently activated.\nPlease activate a virtual environment first with: meowda activate "))?; @@ -332,6 +371,7 @@ impl VenvBackend { println!("Project linked successfully."); Ok(()) } + pub async fn unlink(&self, project_name: &str) -> Result<()> { let current_venv = Self::detect_current_venv() .ok_or_else(|| anyhow::anyhow!("No virtual environment is currently activated.\nPlease activate a virtual environment first with: meowda activate "))?; From 2e094a2dad4da2e770d12f774f51f62be180f83c Mon Sep 17 00:00:00 2001 From: SigureMo Date: Fri, 1 May 2026 05:44:51 +0800 Subject: [PATCH 2/3] add co-author Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 6250d5a24b3c22532f6c6274a247db1ed4736d3a Mon Sep 17 00:00:00 2001 From: SigureMo Date: Fri, 1 May 2026 06:10:12 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20fix:=20address=20fork=20revi?= =?UTF-8?q?ew=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli/args.rs | 14 +---- src/venv/fork.rs | 150 +++++++++++++++++++++++++++++++++++++++-------- src/venv/mod.rs | 7 ++- 3 files changed, 132 insertions(+), 39 deletions(-) diff --git a/src/cli/args.rs b/src/cli/args.rs index be62a04..750bc4c 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -59,12 +59,7 @@ pub struct CreateArgs { pub name: String, #[arg(short, long, help = "Python version/path to use (default: 3.13)")] pub python: Option, - #[arg( - short, - long, - default_value = "false", - help = "Clear existing virtual environment" - )] + #[arg(short, long, help = "Clear existing virtual environment")] pub clear: bool, #[clap(flatten)] pub scope: ScopeArgs, @@ -80,12 +75,7 @@ pub struct ForkArgs { help = "Fork from a managed environment name, a virtual environment path, or a Python executable (defaults to the current active Python environment, or the default Python if none is active)" )] pub source: Option, - #[arg( - short, - long, - default_value = "false", - help = "Clear existing virtual environment" - )] + #[arg(short, long, help = "Clear existing virtual environment")] pub clear: bool, #[clap(flatten)] pub scope: ScopeArgs, diff --git a/src/venv/fork.rs b/src/venv/fork.rs index 2d48a1c..f596397 100644 --- a/src/venv/fork.rs +++ b/src/venv/fork.rs @@ -72,11 +72,7 @@ pub(super) fn create_with_source( source: &PythonEnvLayout, target_path: &Path, ) -> Result<()> { - let target_path = normalize_path(target_path)?; - let source_prefix = normalize_path(&source.prefix)?; - if source_prefix == target_path { - anyhow::bail!("Fork source and target cannot be the same environment"); - } + let target_path = ensure_distinct_source_target(source, target_path)?; create_uv_venv( uv_path, @@ -95,6 +91,18 @@ pub(super) fn create_with_source( Ok(()) } +pub(super) fn ensure_distinct_source_target( + source: &PythonEnvLayout, + target_path: &Path, +) -> Result { + let target_path = normalize_path(target_path)?; + let source_prefix = normalize_path(&source.prefix)?; + if source_prefix == target_path { + anyhow::bail!("Fork source and target cannot be the same environment"); + } + Ok(target_path) +} + fn normalize_path(path: impl AsRef) -> Result { let path = path.as_ref(); if path.exists() { @@ -160,7 +168,7 @@ fn copy_permissions(source: &Path, target: &Path) -> Result<()> { } #[cfg(unix)] -fn create_symlink(target: &Path, link_path: &Path, _is_dir: bool) -> Result<()> { +fn create_symlink(target: &Path, link_path: &Path, _is_dir: Option) -> Result<()> { std::os::unix::fs::symlink(target, link_path).with_context(|| { format!( "Failed to create symlink '{}' -> '{}'", @@ -171,19 +179,60 @@ fn create_symlink(target: &Path, link_path: &Path, _is_dir: bool) -> Result<()> } #[cfg(windows)] -fn create_symlink(target: &Path, link_path: &Path, is_dir: bool) -> Result<()> { - if is_dir { - std::os::windows::fs::symlink_dir(target, link_path) - } else { - std::os::windows::fs::symlink_file(target, link_path) +fn create_symlink(target: &Path, link_path: &Path, is_dir: Option) -> Result<()> { + let try_dir = || { + std::os::windows::fs::symlink_dir(target, link_path).with_context(|| { + format!( + "Failed to create directory symlink '{}' -> '{}'", + link_path.display(), + target.display() + ) + }) + }; + let try_file = || { + std::os::windows::fs::symlink_file(target, link_path).with_context(|| { + format!( + "Failed to create file symlink '{}' -> '{}'", + link_path.display(), + target.display() + ) + }) + }; + + match is_dir { + Some(true) => try_dir(), + Some(false) => try_file(), + None => match try_dir() { + Ok(()) => Ok(()), + Err(dir_err) => match try_file() { + Ok(()) => Ok(()), + Err(file_err) => Err(anyhow::anyhow!( + "Failed to create symlink '{}' -> '{}': {} ; {}", + link_path.display(), + target.display(), + dir_err, + file_err + )), + }, + }, } - .with_context(|| { - format!( - "Failed to create symlink '{}' -> '{}'", - link_path.display(), - target.display() - ) - }) +} + +fn resolve_link_target(source: &Path, link_target: &Path) -> PathBuf { + if link_target.is_absolute() { + return link_target.to_path_buf(); + } + + source + .parent() + .map(|parent| parent.join(link_target)) + .unwrap_or_else(|| link_target.to_path_buf()) +} + +fn symlink_target_is_dir(source: &Path, link_target: &Path) -> Option { + fs::metadata(resolve_link_target(source, link_target)) + .map(|meta| meta.is_dir()) + .ok() } fn rewrite_symlink_target(target: &Path, rewrite: &RewritePaths) -> PathBuf { @@ -263,9 +312,7 @@ fn copy_path( let link_target = fs::read_link(source) .with_context(|| format!("Failed to read symlink '{}'", source.display()))?; let rewritten_target = rewrite_symlink_target(&link_target, rewrite); - let link_is_dir = fs::metadata(source) - .map(|meta| meta.is_dir()) - .unwrap_or(false); + let link_is_dir = symlink_target_is_dir(source, &link_target); create_symlink(&rewritten_target, target, link_is_dir)?; return Ok(()); } @@ -341,8 +388,6 @@ fn parse_python_layout(output: &str) -> Result { }; let prefix = required("prefix")?; - let base_prefix = required("base_prefix")?; - let real_prefix = values.get("real_prefix").cloned().unwrap_or_default(); let include_system_site_packages = EnvConfig::parse(prefix.join("pyvenv.cfg")) .map(|config| config.include_system_site_packages) .unwrap_or(false); @@ -354,8 +399,7 @@ fn parse_python_layout(output: &str) -> Result { purelib: required("purelib")?, platlib: required("platlib")?, scripts_dir: required("scripts")?, - include_system_site_packages: prefix.join("pyvenv.cfg").exists() - && (prefix != base_prefix || !real_prefix.is_empty() || include_system_site_packages), + include_system_site_packages, requested_prefix: None, }) } @@ -662,6 +706,62 @@ mod tests { Ok(()) } + #[test] + fn parse_python_layout_respects_include_system_site_packages_config() -> Result<()> { + let temp = tempdir()?; + let source_root = temp.path().join("source"); + fs::create_dir_all(&source_root)?; + fs::write( + source_root.join("pyvenv.cfg"), + "include-system-site-packages = false\n", + )?; + + let output = [ + format!( + "executable\u{1f}{}", + source_root.join("bin/python").display() + ), + "base_executable\u{1f}/usr/bin/python3.12".to_string(), + format!("prefix\u{1f}{}", source_root.display()), + "base_prefix\u{1f}/usr".to_string(), + "real_prefix\u{1f}".to_string(), + format!("scripts\u{1f}{}", source_root.join("bin").display()), + format!( + "purelib\u{1f}{}", + source_root.join("lib/python3.12/site-packages").display() + ), + format!( + "platlib\u{1f}{}", + source_root.join("lib/python3.12/site-packages").display() + ), + ] + .join("\n"); + + let layout = parse_python_layout(&output)?; + assert!(!layout.include_system_site_packages); + Ok(()) + } + + #[test] + fn ensure_distinct_source_target_rejects_same_environment() -> Result<()> { + let temp = tempdir()?; + let source_root = temp.path().join("source-env"); + fs::create_dir_all(&source_root)?; + let layout = PythonEnvLayout { + python: source_root.join("bin/python"), + base_python: PathBuf::from("/usr/bin/python3.12"), + prefix: source_root.clone(), + purelib: source_root.join("lib/python3.12/site-packages"), + platlib: source_root.join("lib/python3.12/site-packages"), + scripts_dir: source_root.join("bin"), + include_system_site_packages: false, + requested_prefix: None, + }; + + assert!(ensure_distinct_source_target(&layout, &source_root).is_err()); + Ok(()) + } + #[cfg(unix)] #[test] fn resolve_source_spec_preserves_venv_python_symlink_path() -> Result<()> { diff --git a/src/venv/mod.rs b/src/venv/mod.rs index 829cb98..b8d4381 100644 --- a/src/venv/mod.rs +++ b/src/venv/mod.rs @@ -9,7 +9,9 @@ use std::process::Command; use tracing::info; use self::create::create_uv_venv; -use self::fork::{create_with_source, resolve_current_source, resolve_named_source}; +use self::fork::{ + create_with_source, ensure_distinct_source_target, resolve_current_source, resolve_named_source, +}; #[derive(Debug, Clone)] pub struct EnvInfo { @@ -204,7 +206,9 @@ impl VenvService { .map(|source| resolve_named_source(source, options.scope_type)) .transpose()? .unwrap_or(resolve_current_source()?); + let venv_path = store.path().join(name); let _lock = store.lock().await?; + ensure_distinct_source_target(&source_layout, &venv_path)?; if store.exists(name) { if options.clear { Self::remove_venv(store, name)?; @@ -215,7 +219,6 @@ impl VenvService { ); } } - let venv_path = store.path().join(name); create_with_source(&self.uv_path, &source_layout, &venv_path)?; info!( "Forked virtual environment '{}' from {} to {}",