Skip to content

Commit beac404

Browse files
committed
feat: add choices constraint to task arguments
Add an optional 'choices' field to TaskArg that restricts the allowed values for a task argument. When a value is provided that is not in the choices list, a clear error message is shown. The default value, if specified, is validated against choices at parse time. Closes #3535
1 parent bbbbe0f commit beac404

File tree

6 files changed

+247
-7
lines changed

6 files changed

+247
-7
lines changed

crates/pixi_manifest/src/task.rs

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,25 @@ pub struct TaskArg {
414414

415415
/// The default value of the argument
416416
pub default: Option<String>,
417+
418+
/// The allowed values for the argument
419+
pub choices: Option<Vec<String>>,
420+
}
421+
422+
impl TaskArg {
423+
pub fn validate_value(&self, value: &str) -> Result<(), String> {
424+
if let Some(choices) = &self.choices
425+
&& !choices.iter().any(|c| c == value)
426+
{
427+
return Err(format!(
428+
"argument '{}' received '{}', but must be one of: {}",
429+
self.name.as_str(),
430+
value,
431+
choices.join(", "),
432+
));
433+
}
434+
Ok(())
435+
}
417436
}
418437

419438
impl std::str::FromStr for TaskArg {
@@ -423,6 +442,7 @@ impl std::str::FromStr for TaskArg {
423442
Ok(TaskArg {
424443
name: ArgName::from_str(s)?,
425444
default: None,
445+
choices: None,
426446
})
427447
}
428448
}
@@ -871,10 +891,18 @@ impl From<Task> for Item {
871891
if let Some(args) = &process.args {
872892
let mut args_array = Array::new();
873893
for arg in args {
874-
if let Some(default) = &arg.default {
894+
if arg.default.is_some() || arg.choices.is_some() {
875895
let mut arg_table = Table::new().into_inline_table();
876896
arg_table.insert("arg", arg.name.as_str().into());
877-
arg_table.insert("default", default.into());
897+
if let Some(default) = &arg.default {
898+
arg_table.insert("default", default.into());
899+
}
900+
if let Some(choices) = &arg.choices {
901+
arg_table.insert(
902+
"choices",
903+
Value::Array(Array::from_iter(choices.iter())),
904+
);
905+
}
878906
args_array.push(Value::InlineTable(arg_table));
879907
} else {
880908
args_array.push(Value::String(toml_edit::Formatted::new(
@@ -943,10 +971,18 @@ impl From<Task> for Item {
943971
if let Some(args_vec) = &alias.args {
944972
let mut args = Vec::new();
945973
for arg in args_vec {
946-
if let Some(default) = &arg.default {
974+
if arg.default.is_some() || arg.choices.is_some() {
947975
let mut arg_table = Table::new().into_inline_table();
948976
arg_table.insert("arg", arg.name.as_str().into());
949-
arg_table.insert("default", default.into());
977+
if let Some(default) = &arg.default {
978+
arg_table.insert("default", default.into());
979+
}
980+
if let Some(choices) = &arg.choices {
981+
arg_table.insert(
982+
"choices",
983+
Value::Array(Array::from_iter(choices.iter())),
984+
);
985+
}
950986
args.push(Value::InlineTable(arg_table));
951987
} else {
952988
args.push(Value::String(toml_edit::Formatted::new(

crates/pixi_manifest/src/toml/task.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ impl<'de> toml_span::Deserialize<'de> for TaskArg {
5858
return Ok(TaskArg {
5959
name,
6060
default: None,
61+
choices: None,
6162
});
6263
}
6364
ValueInner::Table(table) => TableHelper::from((table, value.span)),
@@ -66,10 +67,32 @@ impl<'de> toml_span::Deserialize<'de> for TaskArg {
6667

6768
let name = th.required::<TomlFromStr<ArgName>>("arg")?.into_inner();
6869
let default = th.optional::<String>("default");
70+
let choices = th.optional::<Vec<String>>("choices");
6971

7072
th.finalize(None)?;
7173

72-
Ok(TaskArg { name, default })
74+
if let (Some(default_val), Some(choices_val)) = (&default, &choices)
75+
&& !choices_val.contains(default_val)
76+
{
77+
return Err(DeserError::from(toml_span::Error {
78+
kind: ErrorKind::Custom(
79+
format!(
80+
"default value '{}' is not one of the allowed choices: {}",
81+
default_val,
82+
choices_val.join(", ")
83+
)
84+
.into(),
85+
),
86+
span: value.span,
87+
line_info: None,
88+
}));
89+
}
90+
91+
Ok(TaskArg {
92+
name,
93+
default,
94+
choices,
95+
})
7396
}
7497
}
7598

@@ -372,4 +395,56 @@ mod test {
372395
"#
373396
), @"test, depends-on = 'foo with args'");
374397
}
398+
399+
#[test]
400+
fn test_task_arg_with_choices() {
401+
let input = r#"
402+
cmd = "echo {{ target }}"
403+
args = [{ arg = "target", choices = ["debug", "release"] }]
404+
"#;
405+
let parsed = TomlTask::from_toml_str(input).unwrap();
406+
let task = parsed.value;
407+
let args = task.args().unwrap();
408+
assert_eq!(args.len(), 1);
409+
assert_eq!(args[0].name.as_str(), "target");
410+
assert!(args[0].default.is_none());
411+
assert_eq!(
412+
args[0].choices.as_ref().unwrap(),
413+
&vec!["debug".to_string(), "release".to_string()]
414+
);
415+
}
416+
417+
#[test]
418+
fn test_task_arg_with_choices_and_valid_default() {
419+
let input = r#"
420+
cmd = "echo {{ target }}"
421+
args = [{ arg = "target", default = "debug", choices = ["debug", "release"] }]
422+
"#;
423+
let parsed = TomlTask::from_toml_str(input).unwrap();
424+
let task = parsed.value;
425+
let args = task.args().unwrap();
426+
assert_eq!(args[0].default.as_deref(), Some("debug"));
427+
assert_eq!(
428+
args[0].choices.as_ref().unwrap(),
429+
&vec!["debug".to_string(), "release".to_string()]
430+
);
431+
}
432+
433+
#[test]
434+
fn test_task_arg_with_choices_and_invalid_default() {
435+
insta::assert_snapshot!(expect_parse_failure(
436+
r#"
437+
cmd = "echo {{ target }}"
438+
args = [{ arg = "target", default = "invalid", choices = ["debug", "release"] }]
439+
"#
440+
), @r###"
441+
× default value 'invalid' is not one of the allowed choices: debug, release
442+
╭─[pixi.toml:3:21]
443+
2 │ cmd = "echo {{ target }}"
444+
3 │ args = [{ arg = "target", default = "invalid", choices = ["debug", "release"] }]
445+
· ───────────────────────────────────────────────────────────────────────
446+
4 │
447+
╰────
448+
"###);
449+
}
375450
}

crates/pixi_task/src/task_graph.rs

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,20 @@ impl<'p> TaskGraph<'p> {
553553
));
554554
};
555555

556+
if let Err(msg) = arg.validate_value(&arg_value) {
557+
return Err(TaskGraphError::InvalidArgValue {
558+
arg: arg_name.to_owned(),
559+
task: task_name.to_string(),
560+
value: arg_value,
561+
choices: arg
562+
.choices
563+
.as_ref()
564+
.map(|c| c.join(", "))
565+
.unwrap_or_default(),
566+
message: msg,
567+
});
568+
}
569+
556570
typed_args.push(TypedArg {
557571
name: arg_name.to_owned(),
558572
value: arg_value,
@@ -623,6 +637,17 @@ pub enum TaskGraphError {
623637

624638
#[error("Positional argument '{0}' found after named argument for task {1}")]
625639
PositionalAfterNamedArgument(String, String),
640+
641+
#[error(
642+
"invalid value for argument '{arg}' of task '{task}': received '{value}', expected one of: {choices}"
643+
)]
644+
InvalidArgValue {
645+
arg: String,
646+
task: String,
647+
value: String,
648+
choices: String,
649+
message: String,
650+
},
626651
}
627652

628653
#[cfg(test)]
@@ -635,7 +660,7 @@ mod test {
635660

636661
use crate::{
637662
task_environment::SearchEnvironments,
638-
task_graph::{PreferExecutable, TaskGraph, join_args_with_single_quotes},
663+
task_graph::{PreferExecutable, TaskGraph, TaskGraphError, join_args_with_single_quotes},
639664
};
640665

641666
fn commands_in_order(
@@ -1091,6 +1116,98 @@ mod test {
10911116
assert!(!cmd.contains("{{"));
10921117
}
10931118

1119+
fn graph_error(project_str: &str, run_args: &[&str]) -> TaskGraphError {
1120+
let project = Workspace::from_str(Path::new("pixi.toml"), project_str).unwrap();
1121+
let search_envs = SearchEnvironments::from_opt_env(&project, None, None);
1122+
TaskGraph::from_cmd_args(
1123+
&project,
1124+
&search_envs,
1125+
run_args.iter().map(|arg| arg.to_string()).collect(),
1126+
false,
1127+
PreferExecutable::TaskFirst,
1128+
false,
1129+
)
1130+
.unwrap_err()
1131+
}
1132+
1133+
#[test]
1134+
fn test_choices_valid_value() {
1135+
assert_eq!(
1136+
commands_in_order(
1137+
r#"
1138+
[project]
1139+
name = "pixi"
1140+
channels = []
1141+
platforms = ["linux-64"]
1142+
[tasks.build]
1143+
cmd = "echo {{ target }}"
1144+
args = [{ arg = "target", choices = ["debug", "release"] }]
1145+
"#,
1146+
&["build", "debug"],
1147+
None,
1148+
None,
1149+
false,
1150+
PreferExecutable::TaskFirst,
1151+
false,
1152+
),
1153+
vec!["echo debug"]
1154+
);
1155+
}
1156+
1157+
#[test]
1158+
fn test_choices_invalid_value() {
1159+
let err = graph_error(
1160+
r#"
1161+
[project]
1162+
name = "pixi"
1163+
channels = []
1164+
platforms = ["linux-64"]
1165+
[tasks.build]
1166+
cmd = "echo {{ target }}"
1167+
args = [{ arg = "target", choices = ["debug", "release"] }]
1168+
"#,
1169+
&["build", "profile"],
1170+
);
1171+
let msg = err.to_string();
1172+
assert!(
1173+
msg.contains("target"),
1174+
"error should mention arg name: {msg}"
1175+
);
1176+
assert!(
1177+
msg.contains("profile"),
1178+
"error should mention provided value: {msg}"
1179+
);
1180+
assert!(msg.contains("debug"), "error should mention choices: {msg}");
1181+
assert!(
1182+
msg.contains("release"),
1183+
"error should mention choices: {msg}"
1184+
);
1185+
}
1186+
1187+
#[test]
1188+
fn test_choices_with_valid_default() {
1189+
assert_eq!(
1190+
commands_in_order(
1191+
r#"
1192+
[project]
1193+
name = "pixi"
1194+
channels = []
1195+
platforms = ["linux-64"]
1196+
[tasks.build]
1197+
cmd = "echo {{ target }}"
1198+
args = [{ arg = "target", default = "debug", choices = ["debug", "release"] }]
1199+
"#,
1200+
&["build"],
1201+
None,
1202+
None,
1203+
false,
1204+
PreferExecutable::TaskFirst,
1205+
false,
1206+
),
1207+
vec!["echo debug"]
1208+
);
1209+
}
1210+
10941211
#[test]
10951212
fn test_prefer_executable_always() {
10961213
let project = r#"

schema/model.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,9 @@ class TaskArgs(StrictBaseModel):
394394

395395
arg: TaskArgName = Field(description="The name of the argument")
396396
default: str | None = Field(None, description="The default value of the argument")
397+
choices: list[str] | None = Field(
398+
None, description="Allowed values for the argument", min_length=1
399+
)
397400

398401

399402
class DependsOn(StrictBaseModel):

schema/schema.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2148,6 +2148,15 @@
21482148
},
21492149
"pattern": "^[a-zA-Z_][a-zA-Z\\d_]*$"
21502150
},
2151+
"choices": {
2152+
"title": "Choices",
2153+
"description": "Allowed values for the argument",
2154+
"type": "array",
2155+
"items": {
2156+
"type": "string"
2157+
},
2158+
"minItems": 1
2159+
},
21512160
"default": {
21522161
"title": "Default",
21532162
"description": "The default value of the argument",

tests/integration_python/test_main_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1022,7 +1022,7 @@ def test_pixi_task_list_json(pixi: Path, tmp_pixi_workspace: Path) -> None:
10221022
"cmd": "echo 'Hello {{name | title}}'",
10231023
"description": None,
10241024
"depends_on": [],
1025-
"args": [{"name": "name", "default": "World"}],
1025+
"args": [{"name": "name", "default": "World", "choices": None}],
10261026
"cwd": None,
10271027
"default_environment": None,
10281028
"env": None,

0 commit comments

Comments
 (0)