Skip to content

Commit 7ef2f56

Browse files
committed
feat: run conda-script files via pixi run --experimental --script
Wires the conda-script proposal (#3751) into the CLI. A `--script` file with a `/// conda-script` block routes to a new path behind the `--experimental` flag, which is inert for PEP 723 scripts so a shebang line can pass it unconditionally. The block synthesizes a workspace through the existing script machinery: `[dependencies]` fills the default feature while `[tool.pixi.dependencies]` and `[tool.pixi.pypi-dependencies]` become a second feature of the default environment, so both spec sets reach the solver as a union. The environment resolves for the host, an adjacent lock file wins when present, and `pixi lock --script FILE` writes `FILE.pixi.lock` for conda-script files too. The entrypoint runs through the mini-shell with `${SCRIPT}` and `${CACHE}` defined, cwd left at the invocation directory and CLI arguments appended to the last command.
1 parent f820980 commit 7ef2f56

15 files changed

Lines changed: 871 additions & 28 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pixi_cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pixi_command_dispatcher = { workspace = true }
5252
pixi_compute_reporters = { workspace = true }
5353
pixi_config = { workspace = true }
5454
pixi_consts = { workspace = true }
55+
pixi_conda_script = { workspace = true }
5556
pixi_core = { workspace = true }
5657
pixi_diff = { workspace = true }
5758
pixi_git = { workspace = true }
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
use std::{collections::HashMap, ffi::OsString, path::Path};
2+
3+
use miette::{IntoDiagnostic, NamedSource, Report};
4+
use pixi_conda_script::{
5+
CondaScriptManifest,
6+
shell::{ShellContext, execute_sequence, parse_sequence},
7+
};
8+
use pixi_core::{
9+
Workspace,
10+
environment::sanity_check_workspace,
11+
lock_file::{ReinstallPackages, UpdateLockFileOptions, UpdateMode},
12+
workspace::virtual_packages::{
13+
EnvironmentRunnability, classify_environment_runnability,
14+
verify_current_platform_can_run_environment, verify_run_platform,
15+
},
16+
};
17+
use pixi_manifest::WithWarnings;
18+
use pixi_task::get_task_env;
19+
use tracing::Level;
20+
21+
use crate::{process_exit, run::Args, shared::install_platform::resolve_install_platform};
22+
23+
/// Reads the `conda-script` block of a local `--script` file.
24+
///
25+
/// Returns `Ok(None)` when the file has no block, so the caller falls back to
26+
/// the PEP 723 path.
27+
pub(crate) fn detect(path: &Path) -> miette::Result<Option<CondaScriptManifest>> {
28+
CondaScriptManifest::from_path(path).map_err(Report::new)
29+
}
30+
31+
/// Solves and installs the environment of a `conda-script` file, then runs
32+
/// its entrypoint through the mini-shell with the CLI arguments appended.
33+
pub(crate) async fn execute_run(
34+
manifest: CondaScriptManifest,
35+
args: Args,
36+
config: pixi_config::Config,
37+
) -> miette::Result<()> {
38+
let script_path = manifest.path().to_owned();
39+
let entrypoint = manifest.metadata().entrypoint.clone();
40+
41+
let WithWarnings {
42+
value: workspace,
43+
warnings,
44+
} = Workspace::from_conda_script(manifest, config)?;
45+
for warning in warnings {
46+
tracing::warn!("{warning}");
47+
}
48+
sanity_check_workspace(&workspace).await?;
49+
50+
let environment = workspace.default_environment();
51+
let allow_installs = args.lock_and_install_config.allow_installs();
52+
let user_platform = resolve_install_platform(&workspace, args.platform.as_ref())?;
53+
let run_platform = user_platform
54+
.clone()
55+
.or_else(|| environment.installed_resolved_platform_name());
56+
let best_declared_platform = environment.named_or_best_declared_platform(run_platform.as_ref());
57+
if allow_installs
58+
&& best_declared_platform.is_none()
59+
&& let Some(name) = user_platform.as_ref()
60+
{
61+
return Err(miette::miette!(
62+
"platform '{}' is not part of environment '{}'",
63+
name,
64+
environment.name(),
65+
));
66+
}
67+
if allow_installs {
68+
environment.emit_emulation_warning();
69+
}
70+
71+
// Select and parse the entrypoint before solving, so a syntax error or a
72+
// missing platform key surfaces without waiting for the environment.
73+
let activation_platform = best_declared_platform
74+
.cloned()
75+
.unwrap_or_else(|| environment.activation_platform());
76+
let subdir = activation_platform.subdir();
77+
let Some(command) = entrypoint.select(subdir) else {
78+
return Err(miette::miette!(
79+
help = "add a matching key to the `entrypoint` table, for example `unix`, `win` or the exact platform",
80+
"the entrypoint has no command for platform '{subdir}'"
81+
));
82+
};
83+
let sequence = parse_sequence(command).map_err(|error| {
84+
Report::new(error).with_source_code(NamedSource::new("entrypoint", command.to_owned()))
85+
})?;
86+
87+
let progress = pixi_reporters::TopLevelProgress::from_global();
88+
let mut lock_file = workspace
89+
.resolve_lock_file(
90+
Some(progress.clone()),
91+
UpdateLockFileOptions {
92+
lock_file_usage: args.lock_and_install_config.lock_file_usage()?,
93+
no_install: args.lock_and_install_config.no_install(),
94+
max_concurrent_solves: workspace.config().max_concurrent_solves(),
95+
..Default::default()
96+
},
97+
)
98+
.await?
99+
.0;
100+
lock_file.target_platform = user_platform.clone();
101+
102+
if allow_installs && user_platform.is_none() {
103+
let runnability =
104+
classify_environment_runnability(&environment, Some(lock_file.as_lock_file()));
105+
if runnability == EnvironmentRunnability::Unsupported {
106+
return Err(
107+
match verify_current_platform_can_run_environment(
108+
&environment,
109+
Some(lock_file.as_lock_file()),
110+
) {
111+
Err(err) => err.into(),
112+
Ok(()) => environment.unsupported_platform_error().into(),
113+
},
114+
);
115+
}
116+
}
117+
118+
if allow_installs {
119+
lock_file
120+
.prefix(
121+
&environment,
122+
UpdateMode::QuickValidate,
123+
&ReinstallPackages::default(),
124+
&pixi_core::environment::InstallFilter::default(),
125+
)
126+
.await?;
127+
verify_run_platform(&environment, user_platform.as_ref())?;
128+
}
129+
progress.on_clear();
130+
lock_file.command_dispatcher.clear_filesystem_caches().await;
131+
132+
let command_env = get_task_env(
133+
&environment,
134+
&activation_platform,
135+
args.clean_env,
136+
Some(lock_file.as_lock_file()),
137+
workspace.config().force_activate(),
138+
workspace.config().experimental_activation_cache_usage(),
139+
)
140+
.await?;
141+
142+
if args.dry_run {
143+
pixi_progress::println!(
144+
"{}{}\n\n",
145+
console::Emoji("🌵 ", ""),
146+
console::style("Dry-run mode enabled - no tasks will be executed.")
147+
.yellow()
148+
.bold()
149+
);
150+
}
151+
152+
if tracing::enabled!(Level::WARN) {
153+
let file_name = script_path
154+
.file_name()
155+
.expect("an absolute script path always has a file name")
156+
.to_string_lossy();
157+
pixi_progress::println!(
158+
"{}{}{}{}{}",
159+
console::Emoji("✨ ", ""),
160+
console::style("Pixi script (").bold(),
161+
console::style(file_name).green().bold(),
162+
console::style("): ").bold(),
163+
command,
164+
);
165+
}
166+
167+
if args.dry_run {
168+
return Ok(());
169+
}
170+
171+
let cache_dir = workspace.pixi_dir().join("cache");
172+
fs_err::create_dir_all(&cache_dir).into_diagnostic()?;
173+
let script = script_path
174+
.into_os_string()
175+
.into_string()
176+
.map_err(|_| miette::miette!("the script path must contain only valid UTF-8 characters"))?;
177+
let cache = cache_dir
178+
.into_os_string()
179+
.into_string()
180+
.map_err(|_| miette::miette!("the cache path must contain only valid UTF-8 characters"))?;
181+
182+
let context = ShellContext {
183+
variables: HashMap::from([("SCRIPT".to_owned(), script), ("CACHE".to_owned(), cache)]),
184+
env: command_env
185+
.into_iter()
186+
.map(|(key, value)| (OsString::from(key), OsString::from(value)))
187+
.collect(),
188+
cwd: std::env::current_dir().into_diagnostic()?,
189+
};
190+
let code = execute_sequence(&sequence, &args.task, &context)
191+
.await
192+
.map_err(Report::new)?;
193+
if code != 0 {
194+
process_exit::exit_with_code(code);
195+
}
196+
Ok(())
197+
}

crates/pixi_cli/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pub mod cli_config;
2626
pub mod cli_interface;
2727
pub mod command_info;
2828
pub mod completion;
29+
mod conda_script;
2930
pub mod config;
3031
pub mod exec;
3132
pub mod global;

crates/pixi_cli/src/lock.rs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,33 @@ pub struct Args {
4343
}
4444

4545
pub async fn execute(args: Args) -> miette::Result<()> {
46-
let mut workspace = WorkspaceLocator::for_cli()
47-
.with_global_config_source(args.config_source.source())
48-
.with_search_start(args.workspace_config.workspace_locator_start())
49-
.with_cli_config(args.config.clone())
50-
.locate()?;
46+
let conda_script = match args.workspace_config.script.as_deref() {
47+
Some(path) => crate::conda_script::detect(path)?,
48+
None => None,
49+
};
50+
let mut workspace = if let Some(manifest) = conda_script {
51+
let root = manifest
52+
.path()
53+
.parent()
54+
.expect("an absolute script path always has a parent")
55+
.to_owned();
56+
let config = pixi_config::Config::load_with(&root, &args.config_source.source())
57+
.merge_config(args.config.clone().into());
58+
let pixi_manifest::WithWarnings {
59+
value: workspace,
60+
warnings,
61+
} = pixi_core::Workspace::from_conda_script(manifest, config)?;
62+
for warning in warnings {
63+
tracing::warn!("{warning}");
64+
}
65+
workspace
66+
} else {
67+
WorkspaceLocator::for_cli()
68+
.with_global_config_source(args.config_source.source())
69+
.with_search_start(args.workspace_config.workspace_locator_start())
70+
.with_cli_config(args.config.clone())
71+
.locate()?
72+
};
5173

5274
// Apply backend override if provided (primarily for testing)
5375
if let Some(backend_override) = args

crates/pixi_cli/src/run.rs

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ pub struct Args {
7575
#[arg(long = "executable", short = 'x')]
7676
pub executable: bool,
7777

78+
/// Enable experimental `--script` features; currently the `conda-script`
79+
/// block proposed in https://github.com/prefix-dev/pixi/issues/3751.
80+
///
81+
/// The flag is inert for PEP 723 scripts, so a shebang line can pass it
82+
/// unconditionally.
83+
#[arg(long, requires = "script")]
84+
pub experimental: bool,
85+
7886
#[clap(flatten)]
7987
pub workspace_config: ScriptWorkspaceConfig,
8088

@@ -168,6 +176,7 @@ pub async fn execute(mut args: Args) -> miette::Result<()> {
168176

169177
let cli_config = args
170178
.activation_config
179+
.clone()
171180
.merge_config(args.config.clone().into());
172181

173182
let is_script = args.workspace_config.script.is_some();
@@ -235,11 +244,36 @@ pub async fn execute(mut args: Args) -> miette::Result<()> {
235244
stdin_script_command = Some(prepared.command);
236245
workspace
237246
}
238-
Some(RunScriptInput::Local(path)) => WorkspaceLocator::for_cli()
239-
.with_global_config_source(global_config_source)
240-
.with_search_start(pixi_core::workspace::DiscoveryStart::Script(path))
241-
.with_cli_config(cli_config)
242-
.locate()?,
247+
Some(RunScriptInput::Local(path)) => {
248+
// A conda-script block takes this file off the PEP 723 path; a
249+
// file with both kinds of block is rejected by the detection.
250+
if let Some(manifest) = crate::conda_script::detect(&path)? {
251+
if !args.experimental {
252+
return Err(miette::miette!(
253+
help =
254+
"conda-script support is experimental; add `--experimental` to run it",
255+
"{} contains a conda-script block",
256+
path.display()
257+
));
258+
}
259+
let root = manifest
260+
.path()
261+
.parent()
262+
.expect("an absolute script path always has a parent")
263+
.to_owned();
264+
let config = pixi_config::Config::load_with(&root, &global_config_source)
265+
.merge_config(cli_config);
266+
if not_hidden {
267+
global_multi_progress().set_draw_target(ProgressDrawTarget::stderr_with_hz(20));
268+
}
269+
return crate::conda_script::execute_run(manifest, args, config).await;
270+
}
271+
WorkspaceLocator::for_cli()
272+
.with_global_config_source(global_config_source)
273+
.with_search_start(pixi_core::workspace::DiscoveryStart::Script(path))
274+
.with_cli_config(cli_config)
275+
.locate()?
276+
}
243277
None => WorkspaceLocator::for_cli()
244278
.with_global_config_source(global_config_source)
245279
.with_search_start(args.workspace_config.workspace_locator_start())
@@ -876,3 +910,16 @@ async fn listen_and_forward_all_signals(kill_signal: KillSignal) {
876910
}
877911
futures::future::join_all(futures).await;
878912
}
913+
914+
#[cfg(test)]
915+
mod tests {
916+
use clap::Parser;
917+
918+
use super::Args;
919+
920+
#[test]
921+
fn experimental_requires_a_script() {
922+
assert!(Args::try_parse_from(["run", "--experimental", "--script", "main.c"]).is_ok());
923+
assert!(Args::try_parse_from(["run", "--experimental", "task"]).is_err());
924+
}
925+
}

crates/pixi_conda_script/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ rattler_conda_types = { workspace = true }
2121
thiserror = { workspace = true }
2222
tokio = { workspace = true, features = ["process"] }
2323
toml-span = { workspace = true }
24+
toml_edit = { workspace = true }
2425

2526
[dev-dependencies]
2627
insta = { workspace = true }

0 commit comments

Comments
 (0)