Skip to content
Open
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
169 changes: 153 additions & 16 deletions crates/pixi_cli/src/exec.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
use std::{collections::BTreeSet, collections::HashMap, path::Path, str::FromStr, sync::LazyLock};
use std::{
collections::BTreeSet,
collections::HashMap,
path::{Path, PathBuf},
str::FromStr,
sync::LazyLock,
};

use clap::{Parser, ValueHint};
use itertools::Itertools;
use miette::{Context, IntoDiagnostic};
use pixi_config::{self, Config, ConfigCli};
use pixi_core::environment::list::{PackageToOutput, print_package_table};
use pixi_manifest::script_metadata::{ScriptMetadata, ScriptMetadataError};
use pixi_progress::{await_in_progress, global_multi_progress, wrap_in_progress};
use pixi_utils::prefix::Prefix;
use pixi_utils::{AsyncPrefixGuard, EnvironmentHash, reqwest::build_reqwest_clients};
use rattler::{
install::{IndicatifReporter, Installer},
package_cache::PackageCache,
};
use rattler_conda_types::{GenericVirtualPackage, MatchSpec, PackageName, Platform};
use rattler_conda_types::{Channel, GenericVirtualPackage, MatchSpec, PackageName, Platform};
use rattler_solve::{SolverImpl, SolverTask, resolvo::Solver};
use rattler_virtual_packages::{VirtualPackageOverrides, VirtualPackages};
use reqwest_middleware::ClientWithMiddleware;
Expand Down Expand Up @@ -74,14 +81,92 @@ pub async fn execute(args: Args) -> miette::Result<()> {
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)?;

// Check if the first argument is a script file with embedded metadata
let script_path = PathBuf::from(command);
let script_metadata = if script_path.exists() && script_path.is_file() {
match ScriptMetadata::from_file(&script_path) {
Ok(metadata) => {
tracing::info!("Found conda-script metadata in {}", script_path.display());
Some(metadata)
}
Err(ScriptMetadataError::NoMetadataFound) => {
tracing::debug!(
"No conda-script metadata found in {}",
script_path.display()
);
None
}
Err(e) => {
return Err(miette::miette!(
"Failed to parse conda-script metadata from {}: {}",
script_path.display(),
e
));
}
}
} else {
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();
let mut channels_from_metadata: Option<Vec<Channel>> = None;
let mut entrypoint_from_metadata: Option<String> = None;

// If we have script metadata, use it to augment or replace the specs and channels
if let Some(ref metadata) = script_metadata {
if name_specs.is_empty() {
// If no specs were provided via CLI, use the ones from metadata
let deps = metadata
.get_dependencies(args.platform)
.into_diagnostic()
.context("failed to get dependencies from script metadata")?;
install_specs = deps.clone();
name_specs = deps;
tracing::info!(
"Using {} dependencies from script metadata",
name_specs.len()
);
} else {
tracing::debug!("CLI specs provided, ignoring dependencies from script metadata");
}

// Get channels from metadata and convert to Channel type
// We always get channels from metadata when available, and pass them down
// The actual decision of whether to use them is made in create_exec_prefix
let named_channels = metadata
.get_channels()
.into_diagnostic()
.context("failed to get channels from script metadata")?;

// Convert NamedChannelOrUrl to Channel
let channels: Result<Vec<Channel>, _> = named_channels
.iter()
.map(|nc| nc.clone().into_channel(&config.global_channel_config()))
.collect();
channels_from_metadata = Some(
channels
.into_diagnostic()
.context("failed to parse channels from metadata")?,
);
tracing::info!(
"Using {} channels from script metadata",
channels_from_metadata.as_ref().unwrap().len()
);

// Get entrypoint from metadata
entrypoint_from_metadata = metadata.get_entrypoint(args.platform);
if let Some(ref ep) = entrypoint_from_metadata {
tracing::info!("Using entrypoint from script metadata: {}", ep);
}
Comment on lines +120 to +164
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets put this in a seperate function function.

}

// 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();
let should_guess_package =
(name_specs.is_empty() || !args.with.is_empty()) && script_metadata.is_none();
if should_guess_package {
install_specs.push(guess_package_spec(command));
}
Expand All @@ -94,6 +179,7 @@ pub async fn execute(args: Args) -> miette::Result<()> {
&config,
&client,
should_guess_package,
channels_from_metadata.as_deref(),
)
.await?;

Expand Down Expand Up @@ -129,10 +215,41 @@ pub async fn execute(args: Args) -> miette::Result<()> {
// 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() {} });

// Determine the command to run - use entrypoint from metadata if available
let (actual_command, actual_args) = if let Some(ref entrypoint) = entrypoint_from_metadata {
// Replace ${SCRIPT} with the script path in the entrypoint
let expanded_entrypoint = entrypoint.replace("${SCRIPT}", command);

// Parse the entrypoint as a shell command
// For now, we'll use a simple approach - split by spaces
// In the future, we might want to use proper shell parsing
let parts: Vec<&str> = expanded_entrypoint.split_whitespace().collect();
if parts.is_empty() {
return Err(miette::miette!("Empty entrypoint in script metadata"));
}

let cmd = parts[0].to_string();
let mut args: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();

// If the entrypoint doesn't contain ${SCRIPT}, add the script path as the first argument
// This allows simple entrypoints like "python" to work as expected
if !entrypoint.contains("${SCRIPT}") {
args.push(command.to_string());
}

// Add any additional arguments provided via CLI
args.extend(command_iter.map(|s| s.clone()));

(cmd, args)
} else {
// No entrypoint from metadata, use the command as-is
let command_args_vec: Vec<String> = command_iter.map(|s| s.clone()).collect();
(command.to_string(), command_args_vec)
};
Comment on lines +219 to +248
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets also split this out of this already very large function.


// Spawn the command
let mut cmd = std::process::Command::new(command);
let command_args_vec: Vec<_> = command_iter.collect();
cmd.args(&command_args_vec);
let mut cmd = std::process::Command::new(&actual_command);
cmd.args(&actual_args);

// 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)
Expand All @@ -148,7 +265,7 @@ pub async fn execute(args: Args) -> miette::Result<()> {
let status = cmd
.status()
.into_diagnostic()
.with_context(|| format!("failed to execute '{}'", &command))?;
.with_context(|| format!("failed to execute '{}'", &actual_command))?;

// Return the exit code of the command
std::process::exit(status.code().unwrap_or(1));
Expand All @@ -162,19 +279,31 @@ pub async fn create_exec_prefix(
config: &Config,
client: &ClientWithMiddleware,
has_guessed_package: bool,
channels_from_metadata: Option<&[Channel]>,
) -> miette::Result<Prefix> {
let command = args.command.first().expect("missing required command");
let specs = specs.to_vec();

let channels = args
.channels
.resolve_from_config(config)?
.iter()
.map(|c| c.base_url.to_string())
.collect();
// Use channels from metadata if provided, otherwise use channels from args
let channels_for_hash = if let Some(metadata_channels) = channels_from_metadata {
metadata_channels
.iter()
.map(|c| c.base_url.to_string())
.collect()
} else {
args.channels
.resolve_from_config(config)?
.iter()
.map(|c| c.base_url.to_string())
.collect()
};

let environment_hash =
EnvironmentHash::new(command.clone(), specs.clone(), channels, args.platform);
let environment_hash = EnvironmentHash::new(
command.clone(),
specs.clone(),
channels_for_hash,
args.platform,
);

let prefix = Prefix::new(
cache_dir
Expand Down Expand Up @@ -213,7 +342,15 @@ pub async fn create_exec_prefix(
// Construct a gateway to get repodata.
let gateway = config.gateway().with_client(client.clone()).finish();

let channels = args.channels.resolve_from_config(config)?;
// Use channels from metadata if provided, otherwise resolve from args
let channels: Vec<Channel> = if let Some(metadata_channels) = channels_from_metadata {
metadata_channels.to_vec()
} else {
args.channels
.resolve_from_config(config)?
.into_iter()
.collect()
};

// Get the repodata for the specs
let repodata = await_in_progress("fetching repodata for environment", |_| async {
Expand Down
1 change: 1 addition & 0 deletions crates/pixi_manifest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ mod preview;
pub mod pypi;
pub mod pyproject;
mod s3;
pub mod script_metadata;
mod solve_group;
mod spec_type;
mod system_requirements;
Expand Down
Loading
Loading