diff --git a/docs/reference/cli/pixi/exec.md b/docs/reference/cli/pixi/exec.md index 1179002afa..8cae4feb2a 100644 --- a/docs/reference/cli/pixi/exec.md +++ b/docs/reference/cli/pixi/exec.md @@ -33,6 +33,8 @@ pixi exec [OPTIONS] [COMMAND]... : If specified a new environment is always created even if one already exists - `--list ` : Before executing the command, list packages in the environment Specify `--list=some_regex` to filter the shown packages +- `--no-modify-ps1` +: Disable modification of the PS1 prompt to indicate the temporary environment ## Config Options - `--auth-file ` diff --git a/src/cli/exec.rs b/src/cli/exec.rs index 8b5e560e46..1a85666b6e 100644 --- a/src/cli/exec.rs +++ b/src/cli/exec.rs @@ -1,4 +1,4 @@ -use std::{path::Path, str::FromStr, sync::LazyLock}; +use std::{collections::BTreeSet, collections::HashMap, path::Path, str::FromStr, sync::LazyLock}; use clap::{Parser, ValueHint}; use itertools::Itertools; @@ -12,7 +12,7 @@ use rattler::{ }; use rattler_conda_types::{GenericVirtualPackage, MatchSpec, PackageName, Platform}; use rattler_solve::{SolverImpl, SolverTask, resolvo::Solver}; -use rattler_virtual_packages::{VirtualPackage, VirtualPackageOverrides}; +use rattler_virtual_packages::{VirtualPackageOverrides, VirtualPackages}; use reqwest_middleware::ClientWithMiddleware; use uv_configuration::RAYON_INITIALIZE; @@ -59,6 +59,10 @@ pub struct Args { #[clap(long = "list", num_args = 0..=1, default_missing_value = "", require_equals = true)] pub list: Option, + /// Disable modification of the PS1 prompt to indicate the temporary environment + #[clap(long)] + pub no_modify_ps1: bool, + #[clap(flatten)] pub config: ConfigCli, } @@ -68,23 +72,82 @@ pub async fn execute(args: Args) -> miette::Result<()> { let config = Config::with_cli_config(&args.config); let cache_dir = pixi_config::get_cache_dir().context("failed to determine cache directory")?; - let mut command_args = args.command.iter(); - let command = command_args.next().ok_or_else(|| miette::miette!(help ="i.e when specifying specs explicitly use a command at the end: `pixi exec -s python==3.12 python`", "missing required command to execute",))?; + let mut command_iter = args.command.iter(); + let command = command_iter.next().ok_or_else(|| miette::miette!(help ="i.e when specifying specs explicitly use a command at the end: `pixi exec -s python==3.12 python`", "missing required command to execute",))?; let (_, client) = build_reqwest_clients(Some(&config), None)?; + // Determine the specs for installation and for the environment name. + let mut name_specs = args.specs.clone(); + name_specs.extend(args.with.clone()); + + let mut install_specs = name_specs.clone(); + + // Guess a package from the command if no specs were provided at all OR if --with is used + let should_guess_package = name_specs.is_empty() || !args.with.is_empty(); + if should_guess_package { + install_specs.push(guess_package_spec(command)); + } + // Create the environment to run the command in. - let prefix = create_exec_prefix(&args, &cache_dir, &config, &client).await?; + let prefix = create_exec_prefix( + &args, + &install_specs, + &cache_dir, + &config, + &client, + should_guess_package, + ) + .await?; // Get environment variables from the activation - let activation_env = run_activation(&prefix).await?; + let mut activation_env = run_activation(&prefix).await?; + + // Collect unique package names for environment naming + let package_names: BTreeSet = name_specs + .iter() + .filter_map(|spec| spec.name.as_ref().map(|n| n.as_normalized().to_string())) + .collect(); + + if !package_names.is_empty() { + let env_name = format!("temp:{}", package_names.into_iter().format(",")); + + activation_env.insert("PIXI_ENVIRONMENT_NAME".into(), env_name.clone()); + + if !args.no_modify_ps1 && std::env::current_dir().is_ok() { + let (prompt_var, prompt_value) = if cfg!(windows) { + ("_PIXI_PROMPT", format!("(pixi:{}) $P$G", env_name)) + } else { + ("PS1", format!(r"(pixi:{}) [\w] \$", env_name)) + }; + + activation_env.insert(prompt_var.into(), prompt_value); + + if cfg!(windows) { + activation_env.insert("PROMPT".into(), String::from("$P$G")); + } + } + } // Ignore CTRL+C so that the child is responsible for its own signal handling. let _ctrl_c = tokio::spawn(async { while tokio::signal::ctrl_c().await.is_ok() {} }); // Spawn the command - let status = std::process::Command::new(command) - .args(command_args) - .envs(activation_env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + let mut cmd = std::process::Command::new(command); + let command_args_vec: Vec<_> = command_iter.collect(); + cmd.args(&command_args_vec); + + // On Windows, when using cmd.exe or cmd, we need to pass the full environment + // because cmd.exe requires access to all environment variables (including prompt variables) + // to properly display the modified prompt + if cfg!(windows) && (command.to_lowercase().ends_with("cmd.exe") || command == "cmd") { + let mut env = std::env::vars().collect::>(); + env.extend(activation_env); + cmd.envs(env); + } else { + cmd.envs(activation_env.iter().map(|(k, v)| (k.as_str(), v.as_str()))); + } + + let status = cmd .status() .into_diagnostic() .with_context(|| format!("failed to execute '{}'", &command))?; @@ -96,12 +159,15 @@ pub async fn execute(args: Args) -> miette::Result<()> { /// Creates a prefix for the `pixi exec` command. pub async fn create_exec_prefix( args: &Args, + specs: &[MatchSpec], cache_dir: &Path, config: &Config, client: &ClientWithMiddleware, + has_guessed_package: bool, ) -> miette::Result { let command = args.command.first().expect("missing required command"); - let specs = args.specs.clone(); + let specs = specs.to_vec(); + let channels = args .channels .resolve_from_config(config)? @@ -109,7 +175,8 @@ pub async fn create_exec_prefix( .map(|c| c.base_url.to_string()) .collect(); - let environment_hash = EnvironmentHash::new(command.clone(), specs, channels, args.platform); + let environment_hash = + EnvironmentHash::new(command.clone(), specs.clone(), channels, args.platform); let prefix = Prefix::new( cache_dir @@ -148,23 +215,6 @@ pub async fn create_exec_prefix( // Construct a gateway to get repodata. let gateway = config.gateway().with_client(client.clone()).finish(); - // Determine the specs to use for the environment - let specs = if args.specs.is_empty() { - let command = args.command.first().expect("missing required command"); - let guessed_spec = guess_package_spec(command); - - tracing::debug!( - "no specs provided, guessed {} from command {command}", - guessed_spec - ); - - let mut with_specs = args.with.clone(); - with_specs.push(guessed_spec); - with_specs - } else { - args.specs.clone() - }; - let channels = args.channels.resolve_from_config(config)?; // Get the repodata for the specs @@ -180,13 +230,12 @@ pub async fn create_exec_prefix( .context("failed to get repodata")?; // Determine virtual packages of the current platform - let virtual_packages = VirtualPackage::detect(&VirtualPackageOverrides::from_env()) - .into_diagnostic() - .context("failed to determine virtual packages")? - .iter() - .cloned() - .map(GenericVirtualPackage::from) - .collect(); + let virtual_packages: Vec = + VirtualPackages::detect(&VirtualPackageOverrides::from_env()) + .into_diagnostic() + .context("failed to determine virtual packages")? + .into_generic_virtual_packages() + .collect(); // Solve the environment tracing::info!( @@ -196,16 +245,46 @@ pub async fn create_exec_prefix( .unwrap_or(prefix.root()) .display() ); - let specs_clone = specs.clone(); - let solved_records = wrap_in_progress("solving environment", move || { + let solve_result = wrap_in_progress("solving environment", || { Solver.solve(SolverTask { - specs: specs_clone, - virtual_packages, - ..SolverTask::from_iter(&repodata) + specs: specs.clone(), + virtual_packages: virtual_packages.clone(), + ..SolverTask::from_iter(&repodata.clone()) }) - }) - .into_diagnostic() - .context("failed to solve environment")?; + }); + + let (solved_records, final_specs) = match solve_result { + Ok(records) => (records, specs.to_vec()), + Err(err) if has_guessed_package && !args.with.is_empty() => { + // If solving failed and we guessed a package while using --with, + // try again without the guessed package (last spec) + let guessed_package_name = specs[specs.len() - 1] + .name + .as_ref() + .map(|name| name.as_source()) + .unwrap_or(""); + tracing::debug!( + "Solver failed with guessed package '{}', retrying without it: {}", + guessed_package_name, + err + ); + let records = wrap_in_progress("retrying solve without guessed package", || { + Solver.solve(SolverTask { + specs: specs[..specs.len() - 1].to_vec(), + virtual_packages: virtual_packages.clone(), + ..SolverTask::from_iter(&repodata.clone()) + }) + }) + .into_diagnostic() + .context("failed to solve environment even without guessed package")?; + (records, specs[..specs.len() - 1].to_vec()) + } + Err(err) => { + return Err(err) + .into_diagnostic() + .context("failed to solve environment"); + } + }; // Force the initialization of the rayon thread pool to avoid implicit creation // by the Installer. @@ -232,7 +311,7 @@ pub async fn create_exec_prefix( write_guard.finish().await.into_diagnostic()?; if let Some(ref regex) = args.list { - list_exec_environment(specs, solved_records, regex.clone())?; + list_exec_environment(final_specs, solved_records, regex.clone())?; } Ok(prefix) diff --git a/tests/integration_python/common.py b/tests/integration_python/common.py index 450044a413..2df690db1c 100644 --- a/tests/integration_python/common.py +++ b/tests/integration_python/common.py @@ -4,7 +4,8 @@ from contextlib import contextmanager from enum import IntEnum from pathlib import Path -from typing import Generator +import sys +from typing import Generator, Optional, Sequence, Tuple from rattler import Platform @@ -33,12 +34,12 @@ class ExitCode(IntEnum): class Output: - command: list[Path | str] + command: Sequence[Path | str] stdout: str stderr: str returncode: int - def __init__(self, command: list[Path | str], stdout: str, stderr: str, returncode: int): + def __init__(self, command: Sequence[Path | str], stdout: str, stderr: str, returncode: int): self.command = command self.stdout = stdout self.stderr = stderr @@ -49,7 +50,7 @@ def __str__(self) -> str: def verify_cli_command( - command: list[Path | str], + command: Sequence[Path | str], expected_exit_code: ExitCode = ExitCode.SUCCESS, stdout_contains: str | list[str] | None = None, stdout_excludes: str | list[str] | None = None, @@ -167,3 +168,34 @@ def cwd(path: str | Path) -> Generator[None, None, None]: yield finally: os.chdir(oldpwd) + + +def run_and_get_env(pixi: Path, *args: str, env_var: str) -> Tuple[Optional[str], Output]: + if sys.platform.startswith("win"): + cmd = [str(pixi), "exec", *args, "--", "cmd", "/c", f"echo %{env_var}%"] + else: + cmd = [str(pixi), "exec", *args, "--", "printenv", env_var] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + value = result.stdout.strip() + + output = Output( + command=cmd, + stdout=value, + stderr=result.stderr.strip(), + returncode=result.returncode, + ) + + return (value if value and value != f"%{env_var}%" else None, output) + except Exception as e: + print(f"Error running command: {e}") + print(f"Command: {' '.join(cmd)}") + raise diff --git a/tests/integration_python/test_exec.py b/tests/integration_python/test_exec.py index 9b4f8fe18c..7395889448 100644 --- a/tests/integration_python/test_exec.py +++ b/tests/integration_python/test_exec.py @@ -4,7 +4,7 @@ import pytest -from .common import ExitCode, verify_cli_command +from .common import ExitCode, verify_cli_command, run_and_get_env @pytest.mark.skipif( @@ -56,6 +56,83 @@ def test_exec_list(pixi: Path, dummy_channel_1: str) -> None: stdout_excludes="dummy-b", ) + # List specific package + verify_cli_command( + [pixi, "exec", "--channel", dummy_channel_1, "--list=dummy-g", "dummy-g"], + stdout_contains=["dummy-g"], + stdout_excludes=["dummy-b"], + ) + + +def test_pixi_environment_name_and_ps1(pixi: Path, dummy_channel_1: str) -> None: + """Test that PIXI_ENVIRONMENT_NAME and PS1/PROMPT are set correctly.""" + # Test with single package + env_value, _ = run_and_get_env( + pixi, "--channel", dummy_channel_1, "-s", "dummy-a", env_var="PIXI_ENVIRONMENT_NAME" + ) + assert env_value == "temp:dummy-a" + + # Test with multiple packages (should be sorted) + env_value, _ = run_and_get_env( + pixi, + "--channel", + dummy_channel_1, + "-s", + "dummy-c", + "-s", + "dummy-a", + env_var="PIXI_ENVIRONMENT_NAME", + ) + assert env_value == "temp:dummy-a,dummy-c" + + # Test with --with flag + env_value, _ = run_and_get_env( + pixi, + "--channel", + dummy_channel_1, + "--with", + "dummy-b", + "--with", + "dummy-c", + env_var="PIXI_ENVIRONMENT_NAME", + ) + assert env_value == "temp:dummy-b,dummy-c" + + # Test with no specs (should not set the variable) + env_value, _ = run_and_get_env( + pixi, "--channel", dummy_channel_1, env_var="PIXI_ENVIRONMENT_NAME" + ) + assert env_value is None + + # Test PS1 modification + if sys.platform.startswith("win"): + prompt_var = "_PIXI_PROMPT" + expected_prompt = "(pixi:temp:dummy-a) $P$G" + else: + prompt_var = "PS1" + expected_prompt = r"(pixi:temp:dummy-a) [\w] \$" + + # Test with default behavior (prompt should be modified) + prompt, _ = run_and_get_env( + pixi, "--channel", dummy_channel_1, "-s", "dummy-a", env_var=prompt_var + ) + assert prompt == expected_prompt + + # Test with --no-modify-ps1 (prompt should not be modified) + prompt, _ = run_and_get_env( + pixi, + "--channel", + dummy_channel_1, + "--no-modify-ps1", + "-s", + "dummy-a", + env_var=prompt_var, + ) + if sys.platform.startswith("win"): + assert prompt is None + else: + assert prompt is None or "(pixi:temp:dummy-a)" not in prompt + @pytest.mark.skipif( sys.platform.startswith("win"), @@ -64,13 +141,13 @@ def test_exec_list(pixi: Path, dummy_channel_1: str) -> None: def test_exec_with(pixi: Path, dummy_channel_1: str) -> None: # A package is guessed from the command when `--with` is provided verify_cli_command( - [pixi, "exec", "--channel", dummy_channel_1, "--list", "--spec=dummy-a", "dummy-b"], - stdout_excludes="dummy-b", + [pixi, "exec", "--channel", dummy_channel_1, "--list", "--spec=dummy-a", "dummy-f"], + stdout_excludes="dummy-f", expected_exit_code=ExitCode.FAILURE, ) verify_cli_command( - [pixi, "exec", "--channel", dummy_channel_1, "--list", "--with=dummy-a", "dummy-b"], - stdout_contains="dummy-b", + [pixi, "exec", "--channel", dummy_channel_1, "--list", "--with=dummy-a", "dummy-f"], + stdout_contains="dummy-f", ) # Correct behaviour with multiple 'with' options