Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: executable name from path #2136

Merged
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
16 changes: 16 additions & 0 deletions src/cli/global/expose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ use pixi_config::{Config, ConfigCli};

use crate::global::{self, EnvironmentName, ExposedName};

/// Add exposed binaries from an environment to your global environment
///
/// `pixi global expose add python310=python3.10 python3=python3 --environment myenv`
/// will expose the `python3.10` executable as `python310` and the `python3` executable as `python3`
#[derive(Parser, Debug)]
pub struct AddArgs {
/// Add one or more `MAPPING` for environment `ENV` which describe which executables are exposed.
Expand Down Expand Up @@ -38,6 +42,11 @@ fn parse_mapping(input: &str) -> miette::Result<global::Mapping> {
))
})
}

/// Remove exposed binaries from the global environment
///
/// `pixi global expose remove python310 python3 --environment myenv`
/// will remove the exposed names `python310` and `python3` from the environment `myenv`
#[derive(Parser, Debug)]
pub struct RemoveArgs {
/// The exposed names that should be removed
Expand All @@ -54,6 +63,13 @@ pub struct RemoveArgs {
config: ConfigCli,
}

/// Interact with the exposure of binaries in the global environment
///
/// `pixi global expose add python310=python3.10 --environment myenv`
/// will expose the `python3.10` executable as `python310` from the environment `myenv`
///
/// `pixi global expose remove python310 --environment myenv`
/// will remove the exposed name `python310` from the environment `myenv`
#[derive(Parser, Debug)]
#[clap(group(clap::ArgGroup::new("command")))]
pub enum SubCommand {
Expand Down
107 changes: 101 additions & 6 deletions src/global/common.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
use std::{
io::Read,
path::{Path, PathBuf},
};

use super::{EnvironmentName, ExposedName};
use fs_err as fs;
use fs_err::tokio as tokio_fs;
use miette::IntoDiagnostic;
use pixi_config::home_path;
use pixi_consts::consts;
use std::{
io::Read,
path::{Path, PathBuf},
};

/// Global binaries directory, default to `$HOME/.pixi/bin`
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -180,12 +179,75 @@ pub(crate) fn is_text(file_path: impl AsRef<Path>) -> miette::Result<bool> {
Ok(!is_binary(file_path)?)
}

/// Strips known Windows executable extensions from a file name.
pub(crate) fn strip_windows_executable_extension(file_name: String) -> String {
let file_name = file_name.to_lowercase();
// Attempt to retrieve the PATHEXT environment variable
let extensions_list: Vec<String> = if let Ok(pathext) = std::env::var("PATHEXT") {
pathext.split(';').map(|s| s.to_lowercase()).collect()
} else {
// Fallback to a default list if PATHEXT is not set
tracing::debug!("Could not find 'PATHEXT' variable, using a default list");
[
".COM", ".EXE", ".BAT", ".CMD", ".VBS", ".VBE", ".JS", ".JSE", ".WSF", ".WSH", ".MSC",
".CPL",
]
.iter()
.map(|s| s.to_lowercase())
.collect()
};

// Attempt to strip any known Windows executable extension
extensions_list
.iter()
.find_map(|ext| file_name.strip_suffix(ext))
.map(|f| f.to_string())
.unwrap_or(file_name)
}

/// Strips known Unix executable extensions from a file name.
pub(crate) fn strip_unix_executable_extension(file_name: String) -> String {
let file_name = file_name.to_lowercase();

// Define a list of common Unix executable extensions
let extensions_list: Vec<&str> = vec![
".sh", ".bash", ".zsh", ".csh", ".tcsh", ".ksh", ".fish", ".py", ".pl", ".rb", ".lua",
".php", ".tcl", ".awk", ".sed",
];

// Attempt to strip any known Unix executable extension
extensions_list
.iter()
.find_map(|&ext| file_name.strip_suffix(ext))
.map(|f| f.to_string())
.unwrap_or(file_name)
}

/// Strips known executable extensions from a file name based on the target operating system.
///
/// This function acts as a wrapper that calls either `strip_windows_executable_extension`
/// or `strip_unix_executable_extension` depending on the target OS.
pub(crate) fn executable_from_path(path: &Path) -> String {
let file_name = path
.iter()
.last()
.unwrap_or(path.as_os_str())
.to_string_lossy()
.to_string();

if cfg!(target_family = "windows") {
strip_windows_executable_extension(file_name)
} else {
strip_unix_executable_extension(file_name)
}
}

#[cfg(test)]
mod tests {
use super::*;
use fs_err::tokio as tokio_fs;
use itertools::Itertools;

use rstest::rstest;
use tempfile::tempdir;

#[tokio::test]
Expand Down Expand Up @@ -246,4 +308,37 @@ mod tests {

assert_eq!(remaining_dirs, vec!["env1", "env3", "non-conda-env-dir"]);
}

#[rstest]
#[case::python312_linux("python3.12", "python3.12")]
#[case::python3_linux("python3", "python3")]
#[case::python_linux("python", "python")]
#[case::python3121_linux("python3.12.1", "python3.12.1")]
#[case::bash_script("bash.sh", "bash")]
#[case::zsh59("zsh-5.9", "zsh-5.9")]
#[case::python_312config("python3.12-config", "python3.12-config")]
#[case::python3_config("python3-config", "python3-config")]
#[case::x2to3("2to3", "2to3")]
#[case::x2to3312("2to3-3.12", "2to3-3.12")]
fn test_strip_executable_unix(#[case] path: &str, #[case] expected: &str) {
let path = Path::new(path);
let result = strip_unix_executable_extension(path.to_string_lossy().to_string());
assert_eq!(result, expected);
}

#[rstest]
#[case::python_windows("python.exe", "python")]
#[case::python3_windows("python3.exe", "python3")]
#[case::python312_windows("python3.12.exe", "python3.12")]
#[case::bash("bash", "bash")]
#[case::zsh59("zsh-5.9", "zsh-5.9")]
#[case::python_312config("python3.12-config", "python3.12-config")]
#[case::python3_config("python3-config", "python3-config")]
#[case::x2to3("2to3", "2to3")]
#[case::x2to3312("2to3-3.12", "2to3-3.12")]
fn test_strip_executable_windows(#[case] path: &str, #[case] expected: &str) {
let path = Path::new(path);
let result = strip_windows_executable_extension(path.to_string_lossy().to_string());
assert_eq!(result, expected);
}
}
13 changes: 4 additions & 9 deletions src/global/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use std::{

use super::{project::ParsedEnvironment, EnvironmentName, ExposedName};
use crate::{
global::{self, BinDir, EnvDir},
global::{self, common::executable_from_path, BinDir, EnvDir},
prefix::Prefix,
rlimit::try_increase_rlimit_to_sensible,
};
Expand Down Expand Up @@ -127,6 +127,7 @@ pub(crate) async fn expose_executables(
prefix: &Prefix,
bin_dir: &BinDir,
) -> miette::Result<bool> {
tracing::debug!("Exposing executables for environment '{}'", env_name);
// Determine the shell to use for the invocation script
let shell: ShellEnum = if cfg!(windows) {
rattler_shell::shell::CmdExe.into()
Expand Down Expand Up @@ -368,10 +369,7 @@ pub(crate) async fn create_executable_scripts(
.into_diagnostic()?;
}

let executable_name = global_script_path
.file_stem()
.and_then(OsStr::to_str)
.expect("must always have at least a name");
let executable_name = executable_from_path(global_script_path);
match added_or_changed {
AddedOrChanged::Unchanged => {}
AddedOrChanged::Added => eprintln!(
Expand Down Expand Up @@ -454,10 +452,7 @@ pub(crate) async fn sync(
})
.collect_vec();
for file in project.bin_dir.files().await? {
let file_name = file
.file_stem()
.and_then(OsStr::to_str)
.ok_or_else(|| miette::miette!("Could not get file stem of {}", file.display()))?;
let file_name = executable_from_path(&file);
if !exposed_paths.contains(&file) && file_name != "pixi" {
tokio_fs::remove_file(&file).await.into_diagnostic()?;
updated_env = true;
Expand Down
20 changes: 7 additions & 13 deletions src/global/project/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use super::{BinDir, EnvRoot};
use crate::{
global::{common::is_text, find_executables, EnvDir},
global::{
common::{executable_from_path, is_text},
find_executables, EnvDir,
},
prefix::Prefix,
};
pub(crate) use environment::EnvironmentName;
Expand Down Expand Up @@ -77,19 +80,10 @@ impl ExposedData {
/// environment name, platform, channel, and package information, by reading
/// the associated `conda-meta` directory.
pub async fn from_exposed_path(path: &Path, env_root: &EnvRoot) -> miette::Result<Self> {
let exposed = path
.file_stem()
.and_then(OsStr::to_str)
.ok_or_else(|| miette::miette!("Could not get file stem of {}", path.display()))
.and_then(ExposedName::from_str)?;
let exposed = ExposedName::from_str(executable_from_path(path).as_str())?;
let executable_path = extract_executable_from_script(path)?;

let executable = executable_path
.file_stem()
.and_then(OsStr::to_str)
.map(String::from)
.ok_or_else(|| miette::miette!("Could not get file stem of {}", path.display()))?;

let executable = executable_from_path(&executable_path);
let env_path = determine_env_path(&executable_path, env_root.path())?;
let env_name = env_path
.file_name()
Expand Down Expand Up @@ -191,7 +185,7 @@ async fn package_from_conda_meta(

if find_executables(prefix, &prefix_record)
.iter()
.any(|exe_path| exe_path.file_stem().and_then(OsStr::to_str) == Some(executable))
.any(|exe_path| executable_from_path(exe_path) == executable)
{
let platform = match Platform::from_str(
&prefix_record.repodata_record.package_record.subdir,
Expand Down
9 changes: 6 additions & 3 deletions src/prefix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,23 @@ impl Prefix {
/// Processes prefix records (that you can get by using `find_installed_packages`)
/// to filter and collect executable files.
pub fn find_executables(&self, prefix_packages: &[PrefixRecord]) -> Vec<(String, PathBuf)> {
prefix_packages
let executables = prefix_packages
.iter()
.flat_map(|record| {
record
.files
.iter()
.filter(|relative_path| self.is_executable(relative_path))
.filter_map(|path| {
path.file_stem()
path.iter()
.last()
.and_then(OsStr::to_str)
.map(|name| (name.to_string(), path.clone()))
})
})
.collect()
.collect();
tracing::debug!("Found executables: {:?}", executables);
executables
}

/// Checks if the given relative path points to an executable file.
Expand Down
Loading