|
| 1 | +use std::{collections::HashMap, ffi::OsString, path::Path}; |
| 2 | + |
| 3 | +use miette::{IntoDiagnostic, NamedSource, Report}; |
| 4 | +use pixi_conda_script::{ |
| 5 | + CondaScriptError, CondaScriptManifest, |
| 6 | + shell::{ShellContext, execute_sequence, parse_sequence}, |
| 7 | +}; |
| 8 | +use pixi_core::{ |
| 9 | + Workspace, |
| 10 | + environment::sanity_check_workspace, |
| 11 | + lock_file::{ReinstallPackages, UpdateLockFileOptions, UpdateMode}, |
| 12 | + workspace::virtual_packages::{ |
| 13 | + EnvironmentRunnability, classify_environment_runnability, |
| 14 | + verify_current_platform_can_run_environment, verify_run_platform, |
| 15 | + }, |
| 16 | +}; |
| 17 | +use pixi_manifest::WithWarnings; |
| 18 | +use pixi_task::get_task_env; |
| 19 | +use tracing::Level; |
| 20 | + |
| 21 | +use crate::{process_exit, run::Args, shared::install_platform::resolve_install_platform}; |
| 22 | + |
| 23 | +/// Reads the `conda-script` block of a local `--script` file. |
| 24 | +/// |
| 25 | +/// Returns `Ok(None)` when the file has no block or when a malformed block |
| 26 | +/// appears in a Python file, so the caller falls back to the PEP 723 path: a |
| 27 | +/// Python script may contain an accidental line ending in the opening |
| 28 | +/// marker, say inside an indented docstring, and must keep working as it did |
| 29 | +/// before the conda-script format existed. When `surface_errors` is set (the |
| 30 | +/// caller passed `--experimental`) or the file cannot be a PEP 723 script |
| 31 | +/// anyway, a block error is reported instead. |
| 32 | +pub(crate) fn detect_with_fallback( |
| 33 | + path: &Path, |
| 34 | + surface_errors: bool, |
| 35 | +) -> miette::Result<Option<CondaScriptManifest>> { |
| 36 | + match CondaScriptManifest::from_path(path) { |
| 37 | + Ok(manifest) => Ok(manifest), |
| 38 | + Err(error @ CondaScriptError::Io(_)) => Err(Report::new(error)), |
| 39 | + Err(error) => { |
| 40 | + let is_python = path |
| 41 | + .extension() |
| 42 | + .and_then(|extension| extension.to_str()) |
| 43 | + .is_some_and(|extension| { |
| 44 | + extension.eq_ignore_ascii_case("py") || extension.eq_ignore_ascii_case("pyw") |
| 45 | + }); |
| 46 | + if surface_errors || !is_python { |
| 47 | + Err(Report::new(error)) |
| 48 | + } else { |
| 49 | + tracing::debug!( |
| 50 | + "ignoring a malformed conda-script block in {}: {error}", |
| 51 | + path.display() |
| 52 | + ); |
| 53 | + Ok(None) |
| 54 | + } |
| 55 | + } |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +/// Whether the contents carry a conda-script block, well-formed or not. |
| 60 | +/// |
| 61 | +/// Transient script sources use this to explain that conda-script files only |
| 62 | +/// run from local paths, instead of reporting a missing PEP 723 block. |
| 63 | +pub(crate) fn looks_like_conda_script(contents: &[u8]) -> bool { |
| 64 | + !matches!( |
| 65 | + CondaScriptManifest::from_source("conda-script-probe", contents), |
| 66 | + Ok(None) |
| 67 | + ) |
| 68 | +} |
| 69 | + |
| 70 | +/// Solves and installs the environment of a `conda-script` file, then runs |
| 71 | +/// its entrypoint through the mini-shell with the CLI arguments appended. |
| 72 | +pub(crate) async fn execute_run( |
| 73 | + manifest: CondaScriptManifest, |
| 74 | + args: Args, |
| 75 | + config: pixi_config::Config, |
| 76 | +) -> miette::Result<()> { |
| 77 | + let script_path = manifest.path().to_owned(); |
| 78 | + let entrypoint = manifest.metadata().entrypoint.clone(); |
| 79 | + |
| 80 | + let WithWarnings { |
| 81 | + value: workspace, |
| 82 | + warnings, |
| 83 | + } = Workspace::from_conda_script(manifest, config)?; |
| 84 | + for warning in warnings { |
| 85 | + tracing::warn!("{warning}"); |
| 86 | + } |
| 87 | + sanity_check_workspace(&workspace).await?; |
| 88 | + |
| 89 | + let environment = workspace.default_environment(); |
| 90 | + let allow_installs = args.lock_and_install_config.allow_installs(); |
| 91 | + let user_platform = resolve_install_platform(&workspace, args.platform.as_ref())?; |
| 92 | + let run_platform = user_platform |
| 93 | + .clone() |
| 94 | + .or_else(|| environment.installed_resolved_platform_name()); |
| 95 | + let best_declared_platform = environment.named_or_best_declared_platform(run_platform.as_ref()); |
| 96 | + if allow_installs |
| 97 | + && best_declared_platform.is_none() |
| 98 | + && let Some(name) = user_platform.as_ref() |
| 99 | + { |
| 100 | + return Err(miette::miette!( |
| 101 | + "platform '{}' is not part of environment '{}'", |
| 102 | + name, |
| 103 | + environment.name(), |
| 104 | + )); |
| 105 | + } |
| 106 | + if allow_installs { |
| 107 | + environment.emit_emulation_warning(); |
| 108 | + } |
| 109 | + |
| 110 | + // Select and parse the entrypoint before solving, so a syntax error or a |
| 111 | + // missing platform key surfaces without waiting for the environment. |
| 112 | + let activation_platform = best_declared_platform |
| 113 | + .cloned() |
| 114 | + .unwrap_or_else(|| environment.activation_platform()); |
| 115 | + let subdir = activation_platform.subdir(); |
| 116 | + let Some(command) = entrypoint.select(subdir) else { |
| 117 | + return Err(miette::miette!( |
| 118 | + help = "add a matching key to the `entrypoint` table, for example `unix`, `win` or the exact platform", |
| 119 | + "the entrypoint has no command for platform '{subdir}'" |
| 120 | + )); |
| 121 | + }; |
| 122 | + let sequence = parse_sequence(command).map_err(|error| { |
| 123 | + Report::new(error).with_source_code(NamedSource::new("entrypoint", command.to_owned())) |
| 124 | + })?; |
| 125 | + |
| 126 | + let progress = pixi_reporters::TopLevelProgress::from_global(); |
| 127 | + let mut lock_file = workspace |
| 128 | + .resolve_lock_file( |
| 129 | + Some(progress.clone()), |
| 130 | + UpdateLockFileOptions { |
| 131 | + lock_file_usage: args.lock_and_install_config.lock_file_usage()?, |
| 132 | + no_install: args.lock_and_install_config.no_install(), |
| 133 | + max_concurrent_solves: workspace.config().max_concurrent_solves(), |
| 134 | + ..Default::default() |
| 135 | + }, |
| 136 | + ) |
| 137 | + .await? |
| 138 | + .0; |
| 139 | + lock_file.target_platform = user_platform.clone(); |
| 140 | + |
| 141 | + if allow_installs && user_platform.is_none() { |
| 142 | + let runnability = |
| 143 | + classify_environment_runnability(&environment, Some(lock_file.as_lock_file())); |
| 144 | + if runnability == EnvironmentRunnability::Unsupported { |
| 145 | + return Err( |
| 146 | + match verify_current_platform_can_run_environment( |
| 147 | + &environment, |
| 148 | + Some(lock_file.as_lock_file()), |
| 149 | + ) { |
| 150 | + Err(err) => err.into(), |
| 151 | + Ok(()) => environment.unsupported_platform_error().into(), |
| 152 | + }, |
| 153 | + ); |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + if allow_installs { |
| 158 | + lock_file |
| 159 | + .prefix( |
| 160 | + &environment, |
| 161 | + UpdateMode::QuickValidate, |
| 162 | + &ReinstallPackages::default(), |
| 163 | + &pixi_core::environment::InstallFilter::default(), |
| 164 | + ) |
| 165 | + .await?; |
| 166 | + verify_run_platform(&environment, user_platform.as_ref())?; |
| 167 | + } |
| 168 | + progress.on_clear(); |
| 169 | + lock_file.command_dispatcher.clear_filesystem_caches().await; |
| 170 | + |
| 171 | + let command_env = get_task_env( |
| 172 | + &environment, |
| 173 | + &activation_platform, |
| 174 | + args.clean_env, |
| 175 | + Some(lock_file.as_lock_file()), |
| 176 | + workspace.config().force_activate(), |
| 177 | + workspace.config().experimental_activation_cache_usage(), |
| 178 | + ) |
| 179 | + .await?; |
| 180 | + |
| 181 | + if args.dry_run { |
| 182 | + pixi_progress::println!( |
| 183 | + "{}{}\n\n", |
| 184 | + console::Emoji("🌵 ", ""), |
| 185 | + console::style("Dry-run mode enabled - no tasks will be executed.") |
| 186 | + .yellow() |
| 187 | + .bold() |
| 188 | + ); |
| 189 | + } |
| 190 | + |
| 191 | + if tracing::enabled!(Level::WARN) { |
| 192 | + let file_name = script_path |
| 193 | + .file_name() |
| 194 | + .expect("an absolute script path always has a file name") |
| 195 | + .to_string_lossy(); |
| 196 | + pixi_progress::println!( |
| 197 | + "{}{}{}{}{}", |
| 198 | + console::Emoji("✨ ", ""), |
| 199 | + console::style("Pixi script (").bold(), |
| 200 | + console::style(file_name).green().bold(), |
| 201 | + console::style("): ").bold(), |
| 202 | + command, |
| 203 | + ); |
| 204 | + } |
| 205 | + |
| 206 | + if args.dry_run { |
| 207 | + return Ok(()); |
| 208 | + } |
| 209 | + |
| 210 | + let cache_dir = workspace.pixi_dir().join("cache"); |
| 211 | + fs_err::create_dir_all(&cache_dir).into_diagnostic()?; |
| 212 | + let script = script_path |
| 213 | + .into_os_string() |
| 214 | + .into_string() |
| 215 | + .map_err(|_| miette::miette!("the script path must contain only valid UTF-8 characters"))?; |
| 216 | + let cache = cache_dir |
| 217 | + .into_os_string() |
| 218 | + .into_string() |
| 219 | + .map_err(|_| miette::miette!("the cache path must contain only valid UTF-8 characters"))?; |
| 220 | + |
| 221 | + let context = ShellContext { |
| 222 | + variables: HashMap::from([("SCRIPT".to_owned(), script), ("CACHE".to_owned(), cache)]), |
| 223 | + env: command_env |
| 224 | + .into_iter() |
| 225 | + .map(|(key, value)| (OsString::from(key), OsString::from(value))) |
| 226 | + .collect(), |
| 227 | + cwd: std::env::current_dir().into_diagnostic()?, |
| 228 | + }; |
| 229 | + let code = execute_sequence(&sequence, &args.task, &context) |
| 230 | + .await |
| 231 | + .map_err(Report::new)?; |
| 232 | + if code != 0 { |
| 233 | + process_exit::exit_with_code(code); |
| 234 | + } |
| 235 | + Ok(()) |
| 236 | +} |
0 commit comments