Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 4 additions & 6 deletions crates/pixi_api/src/workspace/task/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
};

Expand Down
27 changes: 18 additions & 9 deletions crates/pixi_cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down
114 changes: 65 additions & 49 deletions crates/pixi_cli/src/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,18 +261,30 @@ 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)",
})
.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(
Expand All @@ -297,60 +309,64 @@ fn print_tasks(
return Ok(());
}

let mut all_tasks: BTreeMap<TaskName, EnvironmentRunnability> = BTreeMap::new();
let mut formatted_descriptions: BTreeMap<TaskName, String> = 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<TaskName, (EnvironmentRunnability, Option<String>)> = 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(())
}
Expand Down
106 changes: 76 additions & 30 deletions crates/pixi_core/src/workspace/virtual_packages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -454,10 +457,44 @@ 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`:
///
/// - 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.
///
/// 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>,
Expand All @@ -468,36 +505,17 @@ 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,
};
}

// 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,
Expand Down Expand Up @@ -561,6 +579,9 @@ mod tests {
name = "demo"
channels = []
platforms = [{{ name = "gpu", platform = "{current}", cuda = "99" }}]

[dependencies]
foo = "*"
"#
);
let workspace =
Expand Down Expand Up @@ -714,6 +735,9 @@ packages: []
name = "demo"
channels = []
platforms = ["{current}"]

[dependencies]
foo = "*"
"#
);
let workspace =
Expand All @@ -724,6 +748,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(
Expand Down
Loading
Loading