From e696693bc9ad3b1856842a5b6e0ea80199a6cbab Mon Sep 17 00:00:00 2001 From: Tobias Hunger Date: Mon, 15 Jun 2026 15:58:47 +0000 Subject: [PATCH 1/2] fix(task): correct `task list` runnability and show every task Show every task in `pixi task list` and dim the ones whose environment cannot run on the current machine. Fixes the runnability check so tasks are no longer wrongly omitted. --- crates/pixi_api/src/workspace/task/mod.rs | 10 +- crates/pixi_cli/src/task.rs | 114 ++++++++++-------- .../src/workspace/virtual_packages.rs | 97 ++++++++++----- docs/workspace/advanced_tasks.md | 18 +-- tests/integration_python/test_main_cli.py | 2 +- 5 files changed, 143 insertions(+), 98 deletions(-) diff --git a/crates/pixi_api/src/workspace/task/mod.rs b/crates/pixi_api/src/workspace/task/mod.rs index 25c0642e60..6ca6fd047a 100644 --- a/crates/pixi_api/src/workspace/task/mod.rs +++ b/crates/pixi_api/src/workspace/task/mod.rs @@ -91,12 +91,10 @@ pub async fn list_tasks( workspace .environments() .iter() - .filter_map( - |env| match classify_environment_runnability(env, lock_file.as_ref()) { - EnvironmentRunnability::Unsupported => None, - runnability => Some((env.clone(), (runnability, env.get_filtered_tasks()))), - }, - ) + .map(|env| { + let runnability = classify_environment_runnability(env, lock_file.as_ref()); + (env.clone(), (runnability, env.get_filtered_tasks())) + }) .collect() }; diff --git a/crates/pixi_cli/src/task.rs b/crates/pixi_cli/src/task.rs index a5bfc354d6..4b90c37549 100644 --- a/crates/pixi_cli/src/task.rs +++ b/crates/pixi_cli/src/task.rs @@ -261,11 +261,12 @@ fn print_heading(value: &str) { } /// How a task's environment runs on this machine, rendered as a dim suffix -/// after the task name: by design (the resolved platform's requirements are -/// met), by accident (only the resolved packages' minimum requirements are), -/// or not at all (only reachable via an explicit `--environment`). +/// after the task name: no dependency (nothing to constrain the platform), by +/// design (the resolved platform's requirements are met), by accident (only +/// the resolved packages' minimum requirements are), or not at all. fn runnability_suffix(runnability: EnvironmentRunnability) -> console::StyledObject<&'static str> { console::style(match runnability { + EnvironmentRunnability::NoDependencies => "(no dependency)", EnvironmentRunnability::ByDesign => "(by design)", EnvironmentRunnability::ByAccident => "(by accident)", EnvironmentRunnability::Unsupported => "(not runnable here)", @@ -273,6 +274,17 @@ fn runnability_suffix(runnability: EnvironmentRunnability) -> console::StyledObj .dim() } +/// Orders runnability from worst to best so a task reachable in several +/// environments keeps the most favourable verdict. +fn runnability_rank(runnability: EnvironmentRunnability) -> u8 { + match runnability { + EnvironmentRunnability::Unsupported => 0, + EnvironmentRunnability::ByAccident => 1, + EnvironmentRunnability::ByDesign => 2, + EnvironmentRunnability::NoDependencies => 3, + } +} + /// Create a human-readable representation of a list of tasks. /// Using a tabwriter for described tasks. fn print_tasks( @@ -297,60 +309,64 @@ fn print_tasks( return Ok(()); } - let mut all_tasks: BTreeMap = BTreeMap::new(); - let mut formatted_descriptions: BTreeMap = BTreeMap::new(); - - task_map.values().for_each(|(runnability, tasks)| { - tasks.iter().for_each(|(taskname, task)| { - // A task defined in several environments gets the best verdict: - // running picks a compatible environment when one exists. - all_tasks - .entry(taskname.clone()) - .and_modify(|existing| { - if *runnability == EnvironmentRunnability::ByDesign { - *existing = EnvironmentRunnability::ByDesign; - } - }) - .or_insert(*runnability); - if let Some(description) = task.description() { - formatted_descriptions.insert( - taskname.clone(), - format!("{}", console::style(description).italic()), - ); + // One row per task, deduplicated across environments, keeping the best + // verdict and the first description seen. + let mut rows: BTreeMap)> = BTreeMap::new(); + for (runnability, tasks) in task_map.values() { + for (taskname, task) in tasks { + let entry = rows.entry(taskname.clone()).or_insert((*runnability, None)); + if runnability_rank(*runnability) > runnability_rank(entry.0) { + entry.0 = *runnability; } - }); - }); + if entry.1.is_none() + && let Some(description) = task.description() + { + entry.1 = Some(description.to_string()); + } + } + } - print_heading("Tasks that can run on this machine:"); - let formatted_tasks: String = all_tasks - .iter() - .map(|(name, runnability)| { - format!( - "{} {}", - name.fancy_display(), - runnability_suffix(*runnability) - ) - }) - .join(", "); - eprintln!("{formatted_tasks}"); + // Align the columns on plain text, then colour whole lines afterwards so + // the ANSI styling never throws off the tab stops. + let mut writer = tabwriter::TabWriter::new(Vec::new()); + writeln!(writer, "Task\tDescription")?; + for (taskname, (_, description)) in &rows { + writeln!( + writer, + "{}\t{}", + taskname.as_str(), + description.as_deref().unwrap_or(""), + )?; + } + writer.flush()?; + let table = String::from_utf8(writer.into_inner().expect("tab-aligned table is buffered")) + .expect("tab-aligned table is valid utf-8"); - let mut writer = tabwriter::TabWriter::new(std::io::stdout()); let header_style = console::Style::new().bold().cyan(); - let header = format!( - "{}\t{}", - header_style.apply_to("Task"), - header_style.apply_to("Description"), - ); - writeln!(writer, "{}", header)?; - for (taskname, row) in formatted_descriptions { - writeln!(writer, "{}\t{}", taskname.fancy_display(), row)?; + let mut output = String::new(); + let mut lines = table.lines(); + if let Some(header) = lines.next() { + output.push_str(&format!("{}\n", header_style.apply_to(header))); + } + for ((_, (runnability, _)), line) in rows.iter().zip(lines) { + match runnability { + EnvironmentRunnability::Unsupported => { + output.push_str(&format!("{}\n", console::style(line).dim())); + } + _ => output.push_str(&format!("{line}\n")), + } } - writer.flush().inspect_err(|e| { - if e.kind() == std::io::ErrorKind::BrokenPipe { + let mut stdout = std::io::stdout(); + if let Err(error) = stdout + .write_all(output.as_bytes()) + .and_then(|()| stdout.flush()) + { + if error.kind() == std::io::ErrorKind::BrokenPipe { std::process::exit(0); } - })?; + return Err(error); + } Ok(()) } diff --git a/crates/pixi_core/src/workspace/virtual_packages.rs b/crates/pixi_core/src/workspace/virtual_packages.rs index c1eb8a1bc2..d4ba8d3172 100644 --- a/crates/pixi_core/src/workspace/virtual_packages.rs +++ b/crates/pixi_core/src/workspace/virtual_packages.rs @@ -446,6 +446,9 @@ pub fn verify_run_platform( /// (only the resolved packages' minimum requirements). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EnvironmentRunnability { + /// The environment has no dependencies, so it installs nothing that could + /// require a virtual package the machine lacks. + NoDependencies, /// The machine satisfies the platform the environment was resolved for. ByDesign, /// The machine only satisfies the resolved packages' minimum requirements. @@ -454,10 +457,42 @@ pub enum EnvironmentRunnability { Unsupported, } +/// Whether `environment` declares any conda or PyPI dependency on any of its +/// platforms. An environment with no dependencies installs nothing, so no +/// package can require a virtual package the machine lacks. +fn environment_has_dependencies(environment: &Environment<'_>) -> bool { + if environment.has_pypi_dependencies() { + return true; + } + if !environment.combined_dependencies(None).is_empty() { + return true; + } + // `combined_dependencies(None)` skips platform-specific tables, so check + // each declared platform for target-specific dependencies too. + let manifest = environment.workspace_manifest(); + let env_platform_names = environment.platforms(); + manifest + .workspace + .platforms + .iter() + .filter(|platform| env_platform_names.contains(platform.name())) + .any(|platform| !environment.combined_dependencies(Some(platform)).is_empty()) +} + /// Classify how the current machine (including virtual-package overrides) -/// runs `environment`: from the resolved/minimum platforms recorded in -/// `conda-meta/pixi` when installed, else from the declared platforms and -/// the lock file's minimal requirements. +/// runs `environment`: +/// +/// - an environment without dependencies installs nothing that could require a +/// virtual package the machine lacks, so platform requirements don't apply; +/// - otherwise it runs by design when the machine satisfies a declared +/// platform's virtual packages (the platform it resolves for); +/// - by accident when the machine only meets the minimum the resolved packages +/// require (computed from the lock file); +/// - and is unsupported when it meets neither. +/// +/// Unlike run-time validation, this never consults the `conda-meta/pixi` +/// marker: the marker records the platform a *previous* install resolved for, +/// which goes stale when the manifest changes. pub fn classify_environment_runnability( environment: &Environment<'_>, lock_file: Option<&LockFile>, @@ -468,36 +503,14 @@ pub fn classify_environment_runnability( return EnvironmentRunnability::ByDesign; } - if let (Some(resolved), Some(minimum)) = environment.installed_platforms() { - let current = environment - .workspace() - .host_platform( - PlatformSource::Defaults, - PlatformOverrides::EnvironmentVariableOverrides, - ) - .subdir(); - let base_subdirs = environment - .workspace_manifest() - .workspace - .candidate_subdirs(current); - let base_capabilities = environment - .workspace() - .host_platform( - PlatformSource::AutoDetected, - PlatformOverrides::EnvironmentVariableOverrides, - ) - .declared_virtual_packages() - .to_vec(); - return match classify_run_platform(&base_subdirs, &base_capabilities, &resolved, &minimum) { - RunPlatformVerdict::Compatible => EnvironmentRunnability::ByDesign, - RunPlatformVerdict::OnlyMinimum => EnvironmentRunnability::ByAccident, - RunPlatformVerdict::BelowMinimum(_) => EnvironmentRunnability::Unsupported, - }; + if !environment_has_dependencies(environment) { + return EnvironmentRunnability::NoDependencies; } if environment.best_declared_platform().is_some() { return EnvironmentRunnability::ByDesign; } + match lock_file.map(|lock| minimum_compatible_declared_platform(environment, lock)) { Some(Ok(_)) => EnvironmentRunnability::ByAccident, Some(Err(_)) | None => EnvironmentRunnability::Unsupported, @@ -561,6 +574,9 @@ mod tests { name = "demo" channels = [] platforms = [{{ name = "gpu", platform = "{current}", cuda = "99" }}] + + [dependencies] + foo = "*" "# ); let workspace = @@ -714,6 +730,9 @@ packages: [] name = "demo" channels = [] platforms = ["{current}"] + + [dependencies] + foo = "*" "# ); let workspace = @@ -724,6 +743,28 @@ packages: [] ); } + /// An environment without dependencies classifies as "no dependencies" + /// even when its declared platform demands virtual packages this machine + /// lacks: nothing it installs can require them. + #[test] + fn classify_runnability_no_dependencies() { + let current = Platform::current(); + let manifest = format!( + r#" + [workspace] + name = "demo" + channels = [] + platforms = [{{ name = "gpu", platform = "{current}", cuda = "99" }}] + "# + ); + let workspace = + crate::Workspace::from_str(std::path::Path::new("pixi.toml"), &manifest).unwrap(); + assert_eq!( + classify_environment_runnability(&workspace.default_environment(), None), + EnvironmentRunnability::NoDependencies, + ); + } + #[test] fn declared_cuda_overrides_default() { let pp = pixi_manifest::PixiPlatform::new( diff --git a/docs/workspace/advanced_tasks.md b/docs/workspace/advanced_tasks.md index dd03bdad5e..6678fb9266 100644 --- a/docs/workspace/advanced_tasks.md +++ b/docs/workspace/advanced_tasks.md @@ -647,24 +647,17 @@ build = { cmd = "build", description = "Build everything" } test = { cmd = "test", description = "Run all tests" } ``` -Now, the command `pixi task list` will not only list all task names but also -the their descriptions. +Now, the command `pixi task list` will not only list all task names but also their descriptions. ```shell pixi task list -Tasks that can run on this machine: ------------------------------------ -build (by design), echo (by design), test (by design) Task Description build Build everything echo Friendly greeting to a Pixi user test Run all tests ``` -Each task is annotated with how the current machine runs its environment: -*by design* when the machine satisfies the platform the environment was -resolved for, or *by accident* when it only meets the resolved packages' -minimum requirements. +Tasks whose environment cannot run on the current machine are shown dimmed. This list can be very helpful to quickly find the right tasks. @@ -676,8 +669,7 @@ Tasks can get quite long: echo-arg = { cmd = "echo {{ ARGUMENT }}", args = [{"arg" = "ARGUMENT", "default" = "hello"}], description = "Display the given argument" } ``` -While it is possible to add line breaks inside the task in your `pixi.toml` to -make the task more readable: +While it is possible to add line breaks inside the task in your `pixi.toml` to make the task more readable: ```toml title="pixi.toml" echo-arg = { @@ -687,9 +679,7 @@ echo-arg = { } ``` -it will not work in `pyproject.toml` because it supports only TOML 1.0, -that doesn't allow line breaks inside the task specification. -Other tools can't parse the `pyproject.toml` anymore. +it will not work in `pyproject.toml` because it supports only TOML 1.0, that doesn't allow line breaks inside the task specification. Other tools can't parse the `pyproject.toml` anymore. So if you use an editable dependency: ```toml title="pyproject.toml" diff --git a/tests/integration_python/test_main_cli.py b/tests/integration_python/test_main_cli.py index b47a200530..7279cdacbf 100644 --- a/tests/integration_python/test_main_cli.py +++ b/tests/integration_python/test_main_cli.py @@ -909,7 +909,7 @@ def test_pixi_task_list_platforms(pixi: Path, tmp_pixi_workspace: Path) -> None: """ manifest.write_text(toml) verify_cli_command( - [pixi, "task", "list", "--manifest-path", manifest], stderr_contains=["foo", "bar"] + [pixi, "task", "list", "--manifest-path", manifest], stdout_contains=["foo", "bar"] ) From 0aed2b7dfc7bee03244742e420602c8ea82bee2f Mon Sep 17 00:00:00 2001 From: Tobias Hunger Date: Tue, 30 Jun 2026 14:22:08 +0000 Subject: [PATCH 2/2] ci: Fix some tests --- crates/pixi_cli/src/run.rs | 27 +++++++---- .../src/workspace/virtual_packages.rs | 21 +++++---- tests/integration_python/test_edge_cases.py | 45 ++++++++++++++++--- 3 files changed, 71 insertions(+), 22 deletions(-) diff --git a/crates/pixi_cli/src/run.rs b/crates/pixi_cli/src/run.rs index b56f76ae7c..2dcedc1233 100644 --- a/crates/pixi_cli/src/run.rs +++ b/crates/pixi_cli/src/run.rs @@ -310,15 +310,19 @@ pub async fn execute(args: Args) -> miette::Result<()> { continue; } + // Classify how this machine runs the task's environment. A `--platform` + // override means the user vouches for the machine, so skip the check. + let runnability = + (args.lock_and_install_config.allow_installs() && user_platform.is_none()).then(|| { + classify_environment_runnability( + &executable_task.run_environment, + Some(lock_file.as_lock_file()), + ) + }); + // Fail before announcing a task whose environment can't run here at // all; by-accident environments proceed and `--platform` overrides. - if args.lock_and_install_config.allow_installs() - && user_platform.is_none() - && classify_environment_runnability( - &executable_task.run_environment, - Some(lock_file.as_lock_file()), - ) == EnvironmentRunnability::Unsupported - { + if runnability == Some(EnvironmentRunnability::Unsupported) { return Err( match verify_current_platform_can_run_environment( &executable_task.run_environment, @@ -406,8 +410,13 @@ pub async fn execute(args: Args) -> miette::Result<()> { let task_env: &_ = match task_envs.entry(executable_task.run_environment.clone()) { Entry::Occupied(env) => env.into_mut(), Entry::Vacant(entry) => { - // Check if we allow installs - if args.lock_and_install_config.allow_installs() { + // A dependency-less environment installs nothing that could + // require a virtual package the machine lacks, so skip the + // prefix build and platform validation -- its tasks run + // anywhere, relying only on the host environment. + if args.lock_and_install_config.allow_installs() + && runnability != Some(EnvironmentRunnability::NoDependencies) + { // No `--platform`: pin to the platform this environment was // last installed for, not a sibling's bare subdir. if user_platform.is_none() { diff --git a/crates/pixi_core/src/workspace/virtual_packages.rs b/crates/pixi_core/src/workspace/virtual_packages.rs index d4ba8d3172..f446ce99e3 100644 --- a/crates/pixi_core/src/workspace/virtual_packages.rs +++ b/crates/pixi_core/src/workspace/virtual_packages.rs @@ -482,10 +482,12 @@ fn environment_has_dependencies(environment: &Environment<'_>) -> bool { /// Classify how the current machine (including virtual-package overrides) /// runs `environment`: /// -/// - an environment without dependencies installs nothing that could require a -/// virtual package the machine lacks, so platform requirements don't apply; -/// - otherwise it runs by design when the machine satisfies a declared -/// platform's virtual packages (the platform it resolves for); +/// - it runs by design when the machine satisfies a declared platform's virtual +/// packages (the platform it resolves for), so its prefix builds normally even +/// when it has no dependencies; +/// - otherwise an environment without dependencies installs nothing that could +/// require a virtual package the machine lacks, so platform requirements don't +/// apply; /// - by accident when the machine only meets the minimum the resolved packages /// require (computed from the lock file); /// - and is unsupported when it meets neither. @@ -503,14 +505,17 @@ pub fn classify_environment_runnability( return EnvironmentRunnability::ByDesign; } - if !environment_has_dependencies(environment) { - return EnvironmentRunnability::NoDependencies; - } - + // A machine that satisfies a declared platform runs the environment as + // resolved, so build its prefix normally -- even without dependencies, an + // empty prefix still backs activation env vars. if environment.best_declared_platform().is_some() { return EnvironmentRunnability::ByDesign; } + if !environment_has_dependencies(environment) { + return EnvironmentRunnability::NoDependencies; + } + match lock_file.map(|lock| minimum_compatible_declared_platform(environment, lock)) { Some(Ok(_)) => EnvironmentRunnability::ByAccident, Some(Err(_)) | None => EnvironmentRunnability::Unsupported, diff --git a/tests/integration_python/test_edge_cases.py b/tests/integration_python/test_edge_cases.py index 19f8525621..c5434858da 100644 --- a/tests/integration_python/test_edge_cases.py +++ b/tests/integration_python/test_edge_cases.py @@ -341,10 +341,12 @@ def test_help_warning_when_platform_not_supported(pixi: Path, tmp_pixi_workspace manifest_toml["workspace"]["platforms"] = [foreign_platform] manifest_path.write_text(tomli_w.dumps(manifest_toml)) - # Check if the command throws an error for unsupported platform + # The dependency-less environment runs anywhere, so `bla` is treated as a + # free-form command and reported as not found. When no environment supports + # the current platform, the not-found message points at adding it. verify_cli_command( [pixi, "run", "--manifest-path", tmp_pixi_workspace, "bla"], - ExitCode.FAILURE, + ExitCode.COMMAND_NOT_FOUND, stderr_contains=["pixi workspace platform add"], ) @@ -448,16 +450,23 @@ def test_install_fails_on_unsupported_platform(pixi: Path, tmp_pixi_workspace: P ) -def test_run_fails_on_unsupported_platform(pixi: Path, tmp_pixi_workspace: Path) -> None: - """Test that pixi run fails when run on an unsupported platform (issue #5071)""" +def test_run_fails_on_unsupported_platform_with_dependency( + pixi: Path, tmp_pixi_workspace: Path, dummy_channel_1: str +) -> None: + """An environment with a dependency resolves for its declared platform; on a + machine that platform can't run, `pixi run` fails instead of executing the + task (issue #5071).""" other_platform = get_other_platform() manifest = tmp_pixi_workspace.joinpath("pixi.toml") toml = f""" [workspace] name = "test" -channels = [] +channels = ["{dummy_channel_1}"] platforms = ["{other_platform}"] +[dependencies] +dummy-a = "*" + [tasks] hello = "echo hello from unsupported platform test" """ @@ -471,6 +480,32 @@ def test_run_fails_on_unsupported_platform(pixi: Path, tmp_pixi_workspace: Path) ) +def test_run_succeeds_on_unsupported_platform_without_dependency( + pixi: Path, tmp_pixi_workspace: Path +) -> None: + """An environment without dependencies installs nothing that could require a + virtual package the machine lacks, so its tasks run on any platform even when + the workspace only declares another platform.""" + other_platform = get_other_platform() + manifest = tmp_pixi_workspace.joinpath("pixi.toml") + toml = f""" +[workspace] +name = "test" +channels = [] +platforms = ["{other_platform}"] + +[tasks] +hello = "echo hello from unsupported platform test" +""" + manifest.write_text(toml) + + verify_cli_command( + [pixi, "run", "--manifest-path", manifest, "hello"], + ExitCode.SUCCESS, + stdout_contains="hello from unsupported platform test", + ) + + def test_run_as_is_works_on_unsupported_platform(pixi: Path, tmp_pixi_workspace: Path) -> None: """Test that pixi run --as-is works even on an unsupported platform (issue #5071)