Skip to content

Commit 38212f8

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 f75f388 commit 38212f8

19 files changed

Lines changed: 987 additions & 34 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.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ pixi_compute_env_vars = { path = "crates/pixi_compute_env_vars" }
109109
pixi_compute_network = { path = "crates/pixi_compute_network" }
110110
pixi_compute_reporters = { path = "crates/pixi_compute_reporters" }
111111
pixi_compute_sources = { path = "crates/pixi_compute_sources" }
112+
pixi_conda_script = { path = "crates/pixi_conda_script" }
112113
pixi_config = { path = "crates/pixi_config" }
113114
pixi_consts = { path = "crates/pixi_consts" }
114115
pixi_core = { path = "crates/pixi_core" }

crates/pixi_cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ pixi_build_frontend = { workspace = true }
5050
pixi_build_types = { workspace = true }
5151
pixi_command_dispatcher = { workspace = true }
5252
pixi_compute_reporters = { workspace = true }
53+
pixi_conda_script = { workspace = true }
5354
pixi_config = { workspace = true }
5455
pixi_consts = { workspace = true }
5556
pixi_core = { workspace = true }
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
use std::{collections::HashMap, ffi::OsString, path::Path};
2+
3+
use miette::{IntoDiagnostic, NamedSource, Report};
4+
use pixi_conda_script::{
5+
CondaScriptError, 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 or when a malformed block
26+
/// appears in a Python file, so the caller falls back to the PEP 723 path: a
27+
/// Python script may contain an accidental line ending in the opening
28+
/// marker, say inside an indented docstring, and must keep working as it did
29+
/// before the conda-script format existed. When `surface_errors` is set (the
30+
/// caller passed `--experimental`) or the file cannot be a PEP 723 script
31+
/// anyway, a block error is reported instead.
32+
pub(crate) fn detect_with_fallback(
33+
path: &Path,
34+
surface_errors: bool,
35+
) -> miette::Result<Option<CondaScriptManifest>> {
36+
match CondaScriptManifest::from_path(path) {
37+
Ok(manifest) => Ok(manifest),
38+
Err(error @ CondaScriptError::Io(_)) => Err(Report::new(error)),
39+
Err(error) => {
40+
let is_python = path
41+
.extension()
42+
.and_then(|extension| extension.to_str())
43+
.is_some_and(|extension| {
44+
extension.eq_ignore_ascii_case("py") || extension.eq_ignore_ascii_case("pyw")
45+
});
46+
if surface_errors || !is_python {
47+
Err(Report::new(error))
48+
} else {
49+
tracing::debug!(
50+
"ignoring a malformed conda-script block in {}: {error}",
51+
path.display()
52+
);
53+
Ok(None)
54+
}
55+
}
56+
}
57+
}
58+
59+
/// Whether the contents carry a conda-script block, well-formed or not.
60+
///
61+
/// Transient script sources use this to explain that conda-script files only
62+
/// run from local paths, instead of reporting a missing PEP 723 block.
63+
pub(crate) fn looks_like_conda_script(contents: &[u8]) -> bool {
64+
!matches!(
65+
CondaScriptManifest::from_source("conda-script-probe", contents),
66+
Ok(None)
67+
)
68+
}
69+
70+
/// Solves and installs the environment of a `conda-script` file, then runs
71+
/// its entrypoint through the mini-shell with the CLI arguments appended.
72+
pub(crate) async fn execute_run(
73+
manifest: CondaScriptManifest,
74+
args: Args,
75+
config: pixi_config::Config,
76+
) -> miette::Result<()> {
77+
let script_path = manifest.path().to_owned();
78+
let entrypoint = manifest.metadata().entrypoint.clone();
79+
80+
let WithWarnings {
81+
value: workspace,
82+
warnings,
83+
} = Workspace::from_conda_script(manifest, config)?;
84+
for warning in warnings {
85+
tracing::warn!("{warning}");
86+
}
87+
sanity_check_workspace(&workspace).await?;
88+
89+
let environment = workspace.default_environment();
90+
let allow_installs = args.lock_and_install_config.allow_installs();
91+
let user_platform = resolve_install_platform(&workspace, args.platform.as_ref())?;
92+
let run_platform = user_platform
93+
.clone()
94+
.or_else(|| environment.installed_resolved_platform_name());
95+
let best_declared_platform = environment.named_or_best_declared_platform(run_platform.as_ref());
96+
if allow_installs
97+
&& best_declared_platform.is_none()
98+
&& let Some(name) = user_platform.as_ref()
99+
{
100+
return Err(miette::miette!(
101+
"platform '{}' is not part of environment '{}'",
102+
name,
103+
environment.name(),
104+
));
105+
}
106+
if allow_installs {
107+
environment.emit_emulation_warning();
108+
}
109+
110+
// Select and parse the entrypoint before solving, so a syntax error or a
111+
// missing platform key surfaces without waiting for the environment.
112+
let activation_platform = best_declared_platform
113+
.cloned()
114+
.unwrap_or_else(|| environment.activation_platform());
115+
let subdir = activation_platform.subdir();
116+
let Some(command) = entrypoint.select(subdir) else {
117+
return Err(miette::miette!(
118+
help = "add a matching key to the `entrypoint` table, for example `unix`, `win` or the exact platform",
119+
"the entrypoint has no command for platform '{subdir}'"
120+
));
121+
};
122+
let sequence = parse_sequence(command).map_err(|error| {
123+
Report::new(error).with_source_code(NamedSource::new("entrypoint", command.to_owned()))
124+
})?;
125+
126+
let progress = pixi_reporters::TopLevelProgress::from_global();
127+
let mut lock_file = workspace
128+
.resolve_lock_file(
129+
Some(progress.clone()),
130+
UpdateLockFileOptions {
131+
lock_file_usage: args.lock_and_install_config.lock_file_usage()?,
132+
no_install: args.lock_and_install_config.no_install(),
133+
max_concurrent_solves: workspace.config().max_concurrent_solves(),
134+
..Default::default()
135+
},
136+
)
137+
.await?
138+
.0;
139+
lock_file.target_platform = user_platform.clone();
140+
141+
if allow_installs && user_platform.is_none() {
142+
let runnability =
143+
classify_environment_runnability(&environment, Some(lock_file.as_lock_file()));
144+
if runnability == EnvironmentRunnability::Unsupported {
145+
return Err(
146+
match verify_current_platform_can_run_environment(
147+
&environment,
148+
Some(lock_file.as_lock_file()),
149+
) {
150+
Err(err) => err.into(),
151+
Ok(()) => environment.unsupported_platform_error().into(),
152+
},
153+
);
154+
}
155+
}
156+
157+
if allow_installs {
158+
lock_file
159+
.prefix(
160+
&environment,
161+
UpdateMode::QuickValidate,
162+
&ReinstallPackages::default(),
163+
&pixi_core::environment::InstallFilter::default(),
164+
)
165+
.await?;
166+
verify_run_platform(&environment, user_platform.as_ref())?;
167+
}
168+
progress.on_clear();
169+
lock_file.command_dispatcher.clear_filesystem_caches().await;
170+
171+
let command_env = get_task_env(
172+
&environment,
173+
&activation_platform,
174+
args.clean_env,
175+
Some(lock_file.as_lock_file()),
176+
workspace.config().force_activate(),
177+
workspace.config().experimental_activation_cache_usage(),
178+
)
179+
.await?;
180+
181+
if args.dry_run {
182+
pixi_progress::println!(
183+
"{}{}\n\n",
184+
console::Emoji("🌵 ", ""),
185+
console::style("Dry-run mode enabled - no tasks will be executed.")
186+
.yellow()
187+
.bold()
188+
);
189+
}
190+
191+
if tracing::enabled!(Level::WARN) {
192+
let file_name = script_path
193+
.file_name()
194+
.expect("an absolute script path always has a file name")
195+
.to_string_lossy();
196+
pixi_progress::println!(
197+
"{}{}{}{}{}",
198+
console::Emoji("✨ ", ""),
199+
console::style("Pixi script (").bold(),
200+
console::style(file_name).green().bold(),
201+
console::style("): ").bold(),
202+
command,
203+
);
204+
}
205+
206+
if args.dry_run {
207+
return Ok(());
208+
}
209+
210+
let cache_dir = workspace.pixi_dir().join("cache");
211+
fs_err::create_dir_all(&cache_dir).into_diagnostic()?;
212+
let script = script_path
213+
.into_os_string()
214+
.into_string()
215+
.map_err(|_| miette::miette!("the script path must contain only valid UTF-8 characters"))?;
216+
let cache = cache_dir
217+
.into_os_string()
218+
.into_string()
219+
.map_err(|_| miette::miette!("the cache path must contain only valid UTF-8 characters"))?;
220+
221+
let context = ShellContext {
222+
variables: HashMap::from([("SCRIPT".to_owned(), script), ("CACHE".to_owned(), cache)]),
223+
env: command_env
224+
.into_iter()
225+
.map(|(key, value)| (OsString::from(key), OsString::from(value)))
226+
.collect(),
227+
cwd: std::env::current_dir().into_diagnostic()?,
228+
};
229+
let code = execute_sequence(&sequence, &args.task, &context)
230+
.await
231+
.map_err(Report::new)?;
232+
if code != 0 {
233+
process_exit::exit_with_code(code);
234+
}
235+
Ok(())
236+
}

crates/pixi_cli/src/init.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,15 @@ async fn initialize_script(
182182
channels: Option<Vec<NamedChannelOrUrl>>,
183183
) -> miette::Result<()> {
184184
let path = std::path::absolute(path).into_diagnostic()?;
185+
// A file with a conda-script block must not get a PEP 723 block on top;
186+
// the two kinds cannot coexist in one file.
187+
if path.is_file() && crate::conda_script::detect_with_fallback(&path, false)?.is_some() {
188+
return Err(miette::miette!(
189+
help = "a file can carry either a PEP 723 block or a conda-script block, not both",
190+
"{} is already a conda-script",
191+
path.display()
192+
));
193+
}
185194
let channels = channels
186195
.unwrap_or_default()
187196
.into_iter()

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_with_fallback(path, false)?,
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

0 commit comments

Comments
 (0)