diff --git a/crates/pixi_cli/src/task.rs b/crates/pixi_cli/src/task.rs index 2eb6516495..a9b1379a82 100644 --- a/crates/pixi_cli/src/task.rs +++ b/crates/pixi_cli/src/task.rs @@ -14,7 +14,7 @@ use miette::IntoDiagnostic; use pixi_api::WorkspaceContext; use pixi_manifest::{ EnvironmentName, FeatureName, - task::{Alias, CmdArgs, Dependency, Execute, Task, TaskArg, TaskName, quote}, + task::{Alias, CmdArgs, Dependency, Execute, Task, TaskArg, TaskName, TemplateString, quote}, }; use rattler_conda_types::Platform; use serde::Serialize; @@ -206,7 +206,7 @@ impl From for Task { } else { let mut env = IndexMap::new(); for (key, value) in value.env { - env.insert(key, value); + env.insert(key, TemplateString::from(value)); } Some(env) }; @@ -500,7 +500,7 @@ pub struct TaskInfo { depends_on: Vec, args: Option>, cwd: Option, - env: Option>, + env: Option>, default_environment: Option, clean_env: bool, inputs: Option>, diff --git a/crates/pixi_manifest/src/task.rs b/crates/pixi_manifest/src/task.rs index c23ea03945..079ecc123f 100644 --- a/crates/pixi_manifest/src/task.rs +++ b/crates/pixi_manifest/src/task.rs @@ -230,7 +230,7 @@ impl Task { } } /// Returns the environment variables for the task to run in. - pub fn env(&self) -> Option<&IndexMap> { + pub fn env(&self) -> Option<&IndexMap> { match self { Task::Plain(_) => None, Task::Custom(_) => None, @@ -363,7 +363,7 @@ pub struct Execute { pub cwd: Option, /// A list of environment variables to set before running the command - pub env: Option>, + pub env: Option>, /// A default environment to run the task in. pub default_environment: Option, @@ -970,7 +970,10 @@ impl From for Item { table.insert("cwd", cwd.to_string_lossy().to_string().into()); } if let Some(env) = &process.env { - table.insert("env", Value::InlineTable(env.into_iter().collect())); + table.insert( + "env", + Value::InlineTable(env.iter().map(|(k, v)| (k, v.source())).collect()), + ); } if let Some(description) = &process.description { table.insert("description", description.into()); diff --git a/crates/pixi_task/src/executable_task.rs b/crates/pixi_task/src/executable_task.rs index 2ff1e00e28..9916c8b9c4 100644 --- a/crates/pixi_task/src/executable_task.rs +++ b/crates/pixi_task/src/executable_task.rs @@ -19,7 +19,10 @@ use pixi_core::{ workspace::get_activated_environment_variables, workspace::{Environment, HasWorkspaceRef}, }; -use pixi_manifest::{Task, TaskName, task::ArgValues, task::TemplateStringError}; +use pixi_manifest::{ + Task, TaskName, + task::{ArgValues, TaskRenderContext, TemplateStringError}, +}; use pixi_progress::await_in_progress; use rattler_lock::LockFile; use thiserror::Error; @@ -157,7 +160,8 @@ impl<'p> ExecutableTask<'p> { .map_err(FailedToParseShellScript::ArgumentReplacement)?; if let Some(task) = task { // Get the export specific environment variables - let export = get_export_specific_task_env(self.task.as_ref()); + let export = get_export_specific_task_env(self.task.as_ref(), &context) + .map_err(FailedToParseShellScript::ArgumentReplacement)?; // Append the command line arguments verbatim let extra = self.args.extra_args(); @@ -498,16 +502,22 @@ fn get_output_writer_and_handle() -> (ShellPipeWriter, JoinHandle) { /// task script. At runtime they are interpreted by `deno_task_shell`, not by an /// external OS shell, so `$VAR`-style expansion follows deno-task-shell’s /// semantics. -fn get_export_specific_task_env(task: &Task) -> String { - // Append the environment variables if they don't exist +fn get_export_specific_task_env( + task: &Task, + context: &TaskRenderContext, +) -> Result { + // Append the environment variables if they don’t exist let mut export = String::new(); if let Some(env) = task.env() { for (key, value) in env { - tracing::debug!("Setting environment variable: {}=\"{}\"", key, value); - export.push_str(&format!("export \"{key}={value}\";\n")); + let rendered = value.render(context)?; + // Escape double quotes so the export statement remains valid shell. + let escaped = rendered.replace('"', "\\\""); + tracing::debug!("Setting environment variable: {}=\"{}\"", key, escaped); + export.push_str(&format!("export \"{key}={escaped}\";\n")); } } - export + Ok(export) } /// Determine the environment variables to use when executing a command. The @@ -557,6 +567,7 @@ pub async fn get_task_env( #[cfg(test)] mod tests { use super::*; + use pixi_manifest::task::{ArgValues, TypedArg}; use std::path::Path; const PROJECT_BOILERPLATE: &str = r#" @@ -585,11 +596,82 @@ mod tests { .task(&TaskName::from("test"), None) .unwrap(); - let export = get_export_specific_task_env(task); + let context = TaskRenderContext::default(); + let export = get_export_specific_task_env(task, &context).unwrap(); assert_eq!(export, "export \"FOO=bar\";\nexport \"BAR=$FOO\";\n"); } + #[test] + fn test_export_specific_task_env_with_template() { + let file_contents = r#" + [tasks] + test = {cmd = "test", env = {BACKEND = "{{ backend }}"}, args = [{arg = "backend", default = "Numba"}]} + "#; + let workspace = Workspace::from_str( + Path::new("pixi.toml"), + &format!("{PROJECT_BOILERPLATE}\n{file_contents}"), + ) + .unwrap(); + + let task = workspace + .default_environment() + .task(&TaskName::from("test"), None) + .unwrap(); + + // Test with explicit arg value + let args = ArgValues::TypedArgs { + args: vec![TypedArg { + name: "backend".into(), + value: "CuPy".into(), + }], + extra: vec![], + }; + let context = TaskRenderContext { + args: Some(&args), + ..Default::default() + }; + let export = get_export_specific_task_env(task, &context).unwrap(); + assert_eq!(export, "export \"BACKEND=CuPy\";\n"); + + // Test with default context (no args renders the default) + let default_args = ArgValues::TypedArgs { + args: vec![TypedArg { + name: "backend".into(), + value: "Numba".into(), + }], + extra: vec![], + }; + let context = TaskRenderContext { + args: Some(&default_args), + ..Default::default() + }; + let export = get_export_specific_task_env(task, &context).unwrap(); + assert_eq!(export, "export \"BACKEND=Numba\";\n"); + } + + #[test] + fn test_export_env_escapes_double_quotes() { + let file_contents = r#" + [tasks] + test = {cmd = "test", env = {JSON = '{"key": "value"}'}} + "#; + let workspace = Workspace::from_str( + Path::new("pixi.toml"), + &format!("{PROJECT_BOILERPLATE}\n{file_contents}"), + ) + .unwrap(); + + let task = workspace + .default_environment() + .task(&TaskName::from("test"), None) + .unwrap(); + + let context = TaskRenderContext::default(); + let export = get_export_specific_task_env(task, &context).unwrap(); + assert_eq!(export, "export \"JSON={\\\"key\\\": \\\"value\\\"}\";\n"); + } + #[test] fn test_as_script() { let file_contents = r#" diff --git a/docs/reference/pixi_manifest.md b/docs/reference/pixi_manifest.md index 818678dbb2..ab933f461c 100644 --- a/docs/reference/pixi_manifest.md +++ b/docs/reference/pixi_manifest.md @@ -407,6 +407,7 @@ alias = { depends-on=["depending"] } download = { cmd="curl -o file.txt https://example.com/file.txt" , outputs=["file.txt"] } build = { cmd="npm build", cwd="frontend", inputs=["frontend/package.json", "frontend/*.js"] } run = { cmd="python run.py $ARGUMENT", env={ ARGUMENT="value" }} # Set an environment variable +backend = { cmd="pytest", env={ BACKEND="{{ backend }}" }, args=[{arg="backend", default="numpy"}] } # Template strings in env format = { cmd="black $INIT_CWD" } # runs black where you run pixi run format clean-env = { cmd="python isolated.py", clean-env=true } # Only on Unix! test = { cmd="pytest", default-environment="test" } # Set a default pixi environment diff --git a/tests/data/workspace-discovery/implicit-tables-pyproject/pyproject.toml b/tests/data/workspace-discovery/implicit-tables-pyproject/pyproject.toml index d2ab47e78d..65aeea48e2 100644 --- a/tests/data/workspace-discovery/implicit-tables-pyproject/pyproject.toml +++ b/tests/data/workspace-discovery/implicit-tables-pyproject/pyproject.toml @@ -7,4 +7,4 @@ version = "0.1.0" [tool.pixi] workspace.channels = ["conda-forge"] -workspace.platforms = ["osx-arm64"] \ No newline at end of file +workspace.platforms = ["osx-arm64"]