-
Notifications
You must be signed in to change notification settings - Fork 384
feat(exec): add conda-script support #4881
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
Open
wolfv
wants to merge
1
commit into
prefix-dev:main
Choose a base branch
from
wolfv:pixi-exec-conda-script
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
@@ -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); | ||
| } | ||
| } | ||
|
|
||
| // 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)); | ||
| } | ||
|
|
@@ -94,6 +179,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { | |
| &config, | ||
| &client, | ||
| should_guess_package, | ||
| channels_from_metadata.as_deref(), | ||
| ) | ||
| .await?; | ||
|
|
||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
|
@@ -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)); | ||
|
|
@@ -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 | ||
|
|
@@ -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 { | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.