diff --git a/crates/pixi/tests/integration_rust/common/mod.rs b/crates/pixi/tests/integration_rust/common/mod.rs index a83387fe3c..a48302997a 100644 --- a/crates/pixi/tests/integration_rust/common/mod.rs +++ b/crates/pixi/tests/integration_rust/common/mod.rs @@ -601,6 +601,7 @@ impl PixiControl { } else { PreferExecutable::TaskFirst }, + args.templated, ) .map_err(RunError::TaskGraphError)?; diff --git a/crates/pixi_cli/src/run.rs b/crates/pixi_cli/src/run.rs index 24d5d84b9a..ea6c74b90e 100644 --- a/crates/pixi_cli/src/run.rs +++ b/crates/pixi_cli/src/run.rs @@ -84,6 +84,14 @@ pub struct Args { #[arg(long)] pub skip_deps: bool, + /// Enable template rendering for the command arguments. + /// + /// By default, arguments passed to `pixi run` on the command line are not + /// processed by the template engine. Use this flag to enable rendering + /// of template variables like `{{ pixi.platform }}`. + #[arg(long)] + pub templated: bool, + /// Run the task in dry-run mode (only print the command that would run) #[clap(short = 'n', long)] pub dry_run: bool, @@ -194,6 +202,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { } else { PreferExecutable::TaskFirst }, + args.templated, )?; tracing::debug!("Task graph: {}", task_graph); diff --git a/crates/pixi_manifest/src/task.rs b/crates/pixi_manifest/src/task.rs index d5145d5549..a6eb203080 100644 --- a/crates/pixi_manifest/src/task.rs +++ b/crates/pixi_manifest/src/task.rs @@ -209,7 +209,13 @@ impl Task { Ok(rendered) => Ok(Some(Cow::Owned(rendered))), Err(e) => Err(e), }, - Task::Custom(custom) => custom.cmd.as_single(context), + Task::Custom(custom) => { + if custom.templated { + custom.cmd.as_single(context) + } else { + custom.cmd.as_single_no_render() + } + } Task::Execute(exe) => exe.cmd.as_single(context), Task::Alias(_) => Ok(None), } @@ -432,6 +438,10 @@ pub struct Custom { /// The working directory for the command relative to the root of the /// project. pub cwd: Option, + + /// Whether to render the command through the template engine. + /// CLI commands default to false to avoid unexpected template errors. + pub templated: bool, } impl From for Task { diff --git a/crates/pixi_task/src/task_graph.rs b/crates/pixi_task/src/task_graph.rs index 3148afff24..29dd5b8fb5 100644 --- a/crates/pixi_task/src/task_graph.rs +++ b/crates/pixi_task/src/task_graph.rs @@ -211,6 +211,7 @@ impl<'p> TaskGraph<'p> { args: Vec, skip_deps: bool, prefer_executable: PreferExecutable, + templated: bool, ) -> Result { // Split 'args' into arguments if it's a single string, supporting commands // like: `"test 1 == 0 || echo failed"` or `"echo foo && echo bar"` or @@ -331,6 +332,7 @@ impl<'p> TaskGraph<'p> { Custom { cmd, cwd: env::current_dir().ok(), + templated, } .into(), ), @@ -642,6 +644,7 @@ mod test { environment_name: Option, skip_deps: bool, prefer_executable: PreferExecutable, + templated: bool, ) -> Vec { let project = Workspace::from_str(Path::new("pixi.toml"), project_str).unwrap(); @@ -654,6 +657,7 @@ mod test { run_args.iter().map(|arg| arg.to_string()).collect(), skip_deps, prefer_executable, + templated, ) .unwrap(); @@ -693,6 +697,7 @@ mod test { None, false, PreferExecutable::TaskFirst, + false, ), vec!["echo root", "echo task1", "echo task2", "echo top '--test'"] ); @@ -718,6 +723,7 @@ mod test { None, false, PreferExecutable::TaskFirst, + false, ), vec!["echo root", "echo task1", "echo task2", "echo top"] ); @@ -745,6 +751,7 @@ mod test { None, false, PreferExecutable::TaskFirst, + false, ), vec!["echo linux", "echo task1", "echo task2", "echo top",] ); @@ -765,6 +772,7 @@ mod test { None, false, PreferExecutable::TaskFirst, + false, ), vec![r#"echo bla"#] ); @@ -791,6 +799,7 @@ mod test { None, false, PreferExecutable::TaskFirst, + false, ), vec![r#"echo build"#] ); @@ -820,6 +829,7 @@ mod test { None, false, PreferExecutable::TaskFirst, + false, ), vec![r#"hello world"#] ); @@ -853,6 +863,7 @@ mod test { Some(EnvironmentName::Named("cuda".to_string())), false, PreferExecutable::TaskFirst, + false, ), vec![r#"python train.py --cuda"#, r#"python test.py --cuda"#] ); @@ -884,6 +895,7 @@ mod test { None, false, PreferExecutable::TaskFirst, + false, ), vec![r#"echo foo"#, r#"echo bar"#] ); @@ -916,6 +928,7 @@ mod test { None, false, PreferExecutable::TaskFirst, + false, ); } @@ -938,7 +951,8 @@ mod test { None, None, true, - PreferExecutable::TaskFirst + PreferExecutable::TaskFirst, + false, ), vec![r#"echo bar"#] ); @@ -949,7 +963,8 @@ mod test { None, None, false, - PreferExecutable::TaskFirst + PreferExecutable::TaskFirst, + false, ), vec!["echo foo", "echo bar"] ); @@ -976,6 +991,102 @@ mod test { assert_eq!(quote_result, r#"'echo' 'it'"'"'s'"#); } + /// Regression test for https://github.com/prefix-dev/pixi/issues/5478 + /// + /// Verifies that `pixi run echo '{{ hello }}'` does not fail when + /// templating is disabled (the default for CLI commands). + #[test] + fn test_custom_command_with_braces_no_template() { + let project = r#" + [project] + name = "pixi" + channels = [] + platforms = ["linux-64", "osx-64", "win-64", "osx-arm64", "linux-riscv64"] + "#; + + let project = + Workspace::from_str(Path::new("pixi.toml"), project).expect("valid workspace"); + + let search_envs = SearchEnvironments::from_opt_env(&project, None, None); + + // Without --templated, {{ hello }} should pass through as-is. + // Multiple args simulate how the shell + clap deliver them: + // pixi run echo '{{ hello }}' + // becomes args = ["echo", "{{ hello }}"] + let graph = TaskGraph::from_cmd_args( + &project, + &search_envs, + vec!["echo".to_string(), "{{ hello }}".to_string()], + false, + PreferExecutable::TaskFirst, + false, + ) + .expect("should not fail with braces when templating is disabled"); + + let order = graph.topological_order(); + assert_eq!(order.len(), 1); + let task = &graph[order[0]]; + let context = pixi_manifest::task::TaskRenderContext { + platform: task.run_environment.best_platform(), + environment_name: task.run_environment.name(), + manifest_path: Some(&project.workspace.provenance.path), + args: task.args.as_ref(), + }; + let cmd = task + .task + .as_single_command(&context) + .expect("should not fail") + .expect("should have a command"); + assert!(cmd.contains("{{ hello }}")); + } + + /// Verifies that `pixi run --templated echo '{{ pixi.platform }}'` + /// renders template variables. + #[test] + fn test_custom_command_with_templated_flag() { + let project = r#" + [project] + name = "pixi" + channels = [] + platforms = ["linux-64", "osx-64", "win-64", "osx-arm64", "linux-riscv64"] + "#; + + let project = + Workspace::from_str(Path::new("pixi.toml"), project).expect("valid workspace"); + + let search_envs = SearchEnvironments::from_opt_env(&project, None, None); + + // With --templated, {{ pixi.platform }} should be rendered. + // Multiple args simulate how the shell + clap deliver them: + // pixi run --templated echo '{{ pixi.platform }}' + // becomes args = ["echo", "{{ pixi.platform }}"] + let graph = TaskGraph::from_cmd_args( + &project, + &search_envs, + vec!["echo".to_string(), "{{ pixi.platform }}".to_string()], + false, + PreferExecutable::TaskFirst, + true, + ) + .expect("should succeed with valid template variable"); + + let order = graph.topological_order(); + let task = &graph[order[0]]; + let context = pixi_manifest::task::TaskRenderContext { + platform: task.run_environment.best_platform(), + environment_name: task.run_environment.name(), + manifest_path: Some(&project.workspace.provenance.path), + args: task.args.as_ref(), + }; + let cmd = task + .task + .as_single_command(&context) + .expect("should render successfully") + .expect("should have a command"); + // The platform should be resolved, not the raw template + assert!(!cmd.contains("{{")); + } + #[test] fn test_prefer_executable_always() { let project = r#" @@ -999,6 +1110,7 @@ mod test { vec!["foo".to_string()], false, PreferExecutable::Always, + false, ) .expect("graph should be created"); diff --git a/docs/reference/cli/pixi/run.md b/docs/reference/cli/pixi/run.md index 1beca611e1..e8961269ba 100644 --- a/docs/reference/cli/pixi/run.md +++ b/docs/reference/cli/pixi/run.md @@ -27,6 +27,8 @@ pixi run [OPTIONS] [TASK]... : Use a clean environment to run the task - `--skip-deps` : Don't run the dependencies of the task ('depends-on' field in the task definition) +- `--templated` +: Enable template rendering for the command arguments - `--dry-run (-n)` : Run the task in dry-run mode (only print the command that would run) - `--help` diff --git a/docs/reference/cli/pixi/run_extender b/docs/reference/cli/pixi/run_extender index f6c62bd3be..e4dd48bedc 100644 --- a/docs/reference/cli/pixi/run_extender +++ b/docs/reference/cli/pixi/run_extender @@ -24,6 +24,10 @@ pixi run --environment cuda python # The PATH should only contain the pixi environment here. pixi run --clean-env "echo \$PATH" +# By default, CLI commands are not processed by the template engine. +# Use --templated to enable template rendering for ad-hoc commands. +pixi run --templated echo '{{ pixi.platform }}' + ``` ## Notes diff --git a/docs/workspace/advanced_tasks.md b/docs/workspace/advanced_tasks.md index 33aab576b1..f3749417c0 100644 --- a/docs/workspace/advanced_tasks.md +++ b/docs/workspace/advanced_tasks.md @@ -275,7 +275,17 @@ pixi run partial-override-with-arg cli-arg ### MiniJinja Templating for Task Arguments -Task commands support MiniJinja templating syntax for accessing and formatting argument values. This provides powerful flexibility when constructing commands. +Task commands defined in the manifest support MiniJinja templating syntax for accessing and formatting argument values. This provides powerful flexibility when constructing commands. + +!!! note "Templating and ad-hoc CLI commands" + MiniJinja templating is only applied to tasks defined in your manifest file (`pixi.toml` / `pyproject.toml`). + Ad-hoc commands passed directly to `pixi run` on the command line are **not** templated by default, + so commands like `pixi run echo '{{ hello }}'` are passed through as-is. + Use the `--templated` flag to opt in to template rendering for CLI commands: + + ```shell + pixi run --templated echo '{{ pixi.platform }}' + ``` Basic syntax for using an argument in your command: