Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/pixi/tests/integration_rust/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ impl TasksControl<'_> {
platform,
feature: feature_name.non_default().map(str::to_owned),
cwd: None,
default_environment: None,
env: Default::default(),
description: None,
clean_env: false,
Expand Down
13 changes: 13 additions & 0 deletions crates/pixi_cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,19 @@ async fn execute_task(
fn disambiguate_task_interactive<'p>(
problem: &AmbiguousTask<'p>,
) -> Option<TaskAndEnvironment<'p>> {
// If any of the candidate tasks declares a `default-environment` that
// corresponds to one of the candidate environments, prefer that
// environment automatically.
if let Some(idx) = problem.environments.iter().position(|(env, task)| {
if let Some(default_env_name) = task.default_environment() {
default_env_name == env.name()
} else {
false
}
}) {
return Some(problem.environments[idx].clone());
}

Comment on lines +445 to +457
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works for the currently discussed bug, but now you cannot override this using -e flag.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the priority should be

  1. --environment in the pixi run cli (like it's now)
  2. the default-environment field in the task definition. (new)
  3. the default environment when possible (like it's now)
  4. the disambiguation function (like it's now)

let environment_names = problem
.environments
.iter()
Expand Down
10 changes: 9 additions & 1 deletion crates/pixi_cli/src/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ pub struct AddArgs {
#[arg(long, value_parser = parse_key_val)]
pub env: Vec<(String, String)>,

/// Add a default environment for the task.
#[arg(long)]
pub default_environment: Option<EnvironmentName>,

/// A description of the task to be added.
#[arg(long)]
pub description: Option<String>,
Expand Down Expand Up @@ -162,7 +166,6 @@ impl From<AddArgs> for Task {
let depends_on = value.depends_on.unwrap_or_default();
// description or none
let description = value.description;

// Convert the arguments into a single string representation
let cmd_args = value
.commands
Expand All @@ -189,13 +192,15 @@ impl From<AddArgs> for Task {
} else if depends_on.is_empty()
&& value.cwd.is_none()
&& value.env.is_empty()
&& value.default_environment.is_none()
&& description.is_none()
&& value.args.is_none()
{
Self::Plain(cmd_args.into())
} else {
let clean_env = value.clean_env;
let cwd = value.cwd;
let default_environment = value.default_environment;
let env = if value.env.is_empty() {
None
} else {
Expand All @@ -214,6 +219,7 @@ impl From<AddArgs> for Task {
outputs: None,
cwd,
env,
default_environment,
description,
clean_env,
args,
Expand Down Expand Up @@ -495,6 +501,7 @@ pub struct TaskInfo {
args: Option<Vec<TaskArg>>,
cwd: Option<PathBuf>,
env: Option<IndexMap<String, String>>,
default_environment: Option<EnvironmentName>,
clean_env: bool,
inputs: Option<Vec<String>>,
outputs: Option<Vec<String>>,
Expand All @@ -512,6 +519,7 @@ impl From<&Task> for TaskInfo {
args: task.args().map(|args| args.to_vec()),
cwd: task.working_directory().map(PathBuf::from),
env: task.env().cloned(),
default_environment: task.default_environment().cloned(),
clean_env: task.clean_env(),
inputs: task.inputs().map(|inputs| {
inputs
Expand Down
22 changes: 22 additions & 0 deletions crates/pixi_manifest/src/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,14 @@ impl Task {
}
}

// Returns the default environment of the task
pub fn default_environment(&self) -> Option<&EnvironmentName> {
match self {
Task::Execute(exe) => exe.default_environment.as_ref(),
_ => None,
}
}

/// Returns the arguments of the task.
pub fn args(&self) -> Option<&[TaskArg]> {
match self {
Expand Down Expand Up @@ -351,6 +359,9 @@ pub struct Execute {
/// A list of environment variables to set before running the command
pub env: Option<IndexMap<String, String>>,

/// A default environment to run the task in.
pub default_environment: Option<EnvironmentName>,

/// A description of the task
pub description: Option<String>,

Expand Down Expand Up @@ -777,6 +788,10 @@ impl Display for Task {
}
}

let default_environment = self.default_environment();
if let Some(default_environment) = default_environment {
write!(f, ", default_environment = {default_environment}")?;
}
let env = self.env();
if let Some(env) = env
&& !env.is_empty()
Expand Down Expand Up @@ -849,6 +864,13 @@ impl From<Task> for Item {
table.insert("args", Value::Array(args_array));
}

if let Some(default_environment) = &process.default_environment {
table.insert(
"default-environment",
default_environment.as_str().to_string().into(),
);
}

if !process.depends_on.is_empty() {
table.insert(
"depends-on",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
source: crates/pixi_manifest/src/toml/task.rs
expression: "expect_parse_failure(r#\"\n cmd = \"test\"\n depends = [\"a\", \"b\"]\n \"#)"
---
× Unexpected keys, expected only 'cmd', 'inputs', 'outputs', 'depends-on', 'cwd', 'env', 'description', 'clean-env', 'args'
× Unexpected keys, expected only 'cmd', 'inputs', 'outputs', 'depends-on', 'cwd', 'env', 'default-environment', 'description', 'clean-env', 'args'
╭─[pixi.toml:3:13]
2 │ cmd = "test"
3 │ depends = ["a", "b"]
Expand Down
4 changes: 4 additions & 0 deletions crates/pixi_manifest/src/toml/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ impl<'de> toml_span::Deserialize<'de> for TomlTask {
let env = th
.optional::<TomlIndexMap<_, _>>("env")
.map(TomlIndexMap::into_inner);
let default_environment = th
.optional::<TomlFromStr<EnvironmentName>>("default-environment")
.map(TomlFromStr::into_inner);
let description = th.optional("description");
let clean_env = th.optional("clean-env").unwrap_or(false);
let args = th.optional::<Vec<TaskArg>>("args");
Expand Down Expand Up @@ -247,6 +250,7 @@ impl<'de> toml_span::Deserialize<'de> for TomlTask {
depends_on,
cwd,
env,
default_environment,
description,
clean_env,
args,
Expand Down
130 changes: 130 additions & 0 deletions crates/pixi_task/src/task_environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,20 @@ impl<'p, D: TaskDisambiguation<'p>> SearchEnvironments<'p, D> {
let default_env = self.project.default_environment();
// If the default environment has the task
if let Ok(default_env_task) = default_env.task(&name, self.platform) {
// If the task in the default environment declares a `default-environment`
// and that environment exists and can run on this platform, prefer that
// environment instead of returning the default environment.
if let Some(default_env_name) = default_env_task.default_environment()
&& let Some(env) = self
.project
.environments()
.into_iter()
.find(|e| e.name() == default_env_name)
&& verify_current_platform_can_run_environment(&env, None).is_ok()
&& let Ok(task_in_env) = env.task(&name, self.platform)
{
return Ok((env.clone(), task_in_env));
}
// If no other environment has the task name but a different task, return the
// default environment
if !self
Expand Down Expand Up @@ -358,4 +372,120 @@ mod tests {
// different environments
assert!(matches!(result, Err(FindTaskError::AmbiguousTask(_))));
}

#[test]
fn test_default_environment_preferred_when_multiple_envs() {
let manifest_str = r#"
[workspace]
channels = []
platforms = ["linux-64", "win-64", "osx-64", "osx-arm64", "linux-aarch64"]

[tasks]
test = "echo test"
test2 = "echo test2"
dep.depends-on = ["test3", "test6"]

[feature.test.tasks]
test3 = { cmd = "echo test3", default-environment = "three" }
test4 = "echo test4"

[feature.test2.tasks]
test5 = "echo test5"
test6 = { cmd = "echo test6", default-environment = "four" }

[environments]
one = []
two = ["test"]
three = ["test2", "test"]
four = ["test2"]
five = ["test", "test2"]
six = { features = ["test"], no-default-feature = true }
seven = { features = ["test2"], no-default-feature = true }
"#;

let project = Workspace::from_str(Path::new("pixi.toml"), manifest_str).unwrap();

// Build a SearchEnvironments that will prefer a candidate environment
// whose task declares a `default-environment` matching the env name.
let search = SearchEnvironments::from_opt_env(&project, None, None).with_disambiguate_fn(
|amb: &AmbiguousTask| {
amb.environments
.iter()
.find(|(env, task)| {
if let Some(default_env_name) = task.default_environment() {
default_env_name == env.name()
} else {
false
}
})
.cloned()
},
);

// When resolving `test3` we expect the it to pick `three`.
let result = search
.find_task("test3".into(), FindTaskSource::CmdArgs, None)
.expect("should pick default environment");
assert_eq!(result.0.name().as_str(), "three");
}

#[test]
fn test_explicit_environment_overrides_task_default_environment() {
let manifest_str = r#"
[project]
name = "foo"
channels = []
platforms = ["linux-64", "win-64", "osx-64", "osx-arm64", "linux-aarch64"]

[feature.test.tasks]
test3 = { cmd = "echo test3", default-environment = "three" }

[feature.test2.tasks]
test3 = "echo other"

[environments]
default = ["test"]
two = ["test2"]
three = ["test"]
"#;

let project = Workspace::from_str(Path::new("pixi.toml"), manifest_str).unwrap();

// If the user explicitly requests `two`, that should be preferred even
// though the task has a `default-environment` set to `three`.
let explicit_env = project.environment("two").unwrap();
let search = SearchEnvironments::from_opt_env(&project, Some(explicit_env), None);
let result = search.find_task("test3".into(), FindTaskSource::CmdArgs, None);
assert!(result.is_ok());
assert_eq!(result.unwrap().0.name().as_str(), "two");
}

#[test]
fn test_top_level_task_default_environment_is_used() {
let manifest_str = r#"
[workspace]
channels = []
platforms = ["linux-64", "win-64", "osx-64", "osx-arm64", "linux-aarch64"]

[tasks]
test = { cmd = "echo test", default-environment = "test" }

[feature.test.dependencies]

[environments]
test = ["test"]
"#;

let project = Workspace::from_str(Path::new("pixi.toml"), manifest_str).unwrap();

// Build a SearchEnvironments that will apply default behavior.
let search = SearchEnvironments::from_opt_env(&project, None, None);

// Resolve `test` task; since the task declares `default-environment = "test"`
// we expect the resolved environment to be `test` rather than the default.
let result = search
.find_task("test".into(), FindTaskSource::CmdArgs, None)
.expect("should resolve to an environment");
assert_eq!(result.0.name().as_str(), "test");
}
}
2 changes: 2 additions & 0 deletions docs/reference/cli/pixi/task/add.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pixi task add [OPTIONS] <NAME> <COMMAND>...
- <a id="arg---env" href="#arg---env">`--env <ENV>`</a>
: The environment variable to set, use --env key=value multiple times for more than one variable
<br>May be provided more than once.
- <a id="arg---default-environment" href="#arg---default-environment">`--default-environment <DEFAULT_ENVIRONMENT>`</a>
: Add a default environment for the task
- <a id="arg---description" href="#arg---description">`--description <DESCRIPTION>`</a>
: A description of the task to be added
- <a id="arg---clean-env" href="#arg---clean-env">`--clean-env`</a>
Expand Down
5 changes: 4 additions & 1 deletion schema/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,10 +439,13 @@ class TaskInlineTable(StrictBaseModel):
description="A map of environment variables to values, used in the task, these will be overwritten by the shell.",
examples=[{"key": "value"}, {"ARGUMENT": "value"}],
)
default_environment: EnvironmentName | None = Field(
None,
description="A default environment to run the task",
)
description: NonEmptyStr | None = Field(
None,
description="A short description of the task",
examples=["Build the project"],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, remove the example field, as it may be misleading/not required.

)
clean_env: bool | None = Field(
None,
Expand Down
11 changes: 7 additions & 4 deletions schema/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2215,6 +2215,12 @@
"type": "string",
"pattern": "^[^\\\\]+$"
},
"default-environment": {
"title": "Default-Environment",
"description": "A default environment to run the task",
"type": "string",
"pattern": "^[a-z\\d\\-]+$"
},
"depends-on": {
"title": "Depends-On",
"description": "The tasks that this task depends on. Environment variables will **not** be expanded.",
Expand Down Expand Up @@ -2267,10 +2273,7 @@
"title": "Description",
"description": "A short description of the task",
"type": "string",
"minLength": 1,
"examples": [
"Build the project"
]
"minLength": 1
},
"env": {
"title": "Env",
Expand Down
1 change: 1 addition & 0 deletions tests/integration_python/test_main_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,7 @@ def test_pixi_task_list_json(pixi: Path, tmp_pixi_workspace: Path) -> None:
"depends_on": [],
"args": [{"name": "name", "default": "World"}],
"cwd": None,
"default_environment": None,
"env": None,
"clean_env": False,
"inputs": None,
Expand Down
Loading