Skip to content

Commit 0d8b2c2

Browse files
authored
feat(exec): accept relative paths (#4945)
1 parent 3c85748 commit 0d8b2c2

File tree

4 files changed

+350
-12
lines changed

4 files changed

+350
-12
lines changed

crates/pixi_cli/src/exec.rs

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use rattler_virtual_packages::{VirtualPackageOverrides, VirtualPackages};
1818
use reqwest_middleware::ClientWithMiddleware;
1919
use uv_configuration::RAYON_INITIALIZE;
2020

21-
use crate::cli_config::ChannelsConfig;
21+
use crate::{cli_config::ChannelsConfig, match_spec_or_path::MatchSpecOrPath};
2222

2323
/// Run a command and install it in a temporary environment.
2424
///
@@ -33,12 +33,12 @@ pub struct Args {
3333
/// Matchspecs of package to install.
3434
/// If this is not provided, the package is guessed from the command.
3535
#[clap(long = "spec", short = 's', value_name = "SPEC")]
36-
pub specs: Vec<MatchSpec>,
36+
pub specs: Vec<MatchSpecOrPath>,
3737

3838
/// Matchspecs of package to install, while also guessing a package
3939
/// from the command.
4040
#[clap(long, short = 'w', conflicts_with = "specs")]
41-
pub with: Vec<MatchSpec>,
41+
pub with: Vec<MatchSpecOrPath>,
4242

4343
#[clap(flatten)]
4444
channels: ChannelsConfig,
@@ -75,13 +75,21 @@ pub async fn execute(args: Args) -> miette::Result<()> {
7575
let (_, client) = build_reqwest_clients(Some(&config), None)?;
7676

7777
// Determine the specs for installation and for the environment name.
78-
let mut name_specs = args.specs.clone();
79-
name_specs.extend(args.with.clone());
78+
let exec_specs = to_exec_match_specs(&args.specs)?;
79+
let exec_with = to_exec_match_specs(&args.with)?;
8080

81-
let mut install_specs = name_specs.clone();
81+
let mut install_specs = exec_specs.clone();
82+
install_specs.extend(exec_with.clone());
83+
84+
let mut display_names: Vec<String> = args
85+
.specs
86+
.iter()
87+
.filter_map(|spec| spec.display_name())
88+
.collect();
89+
display_names.extend(args.with.iter().filter_map(|spec| spec.display_name()));
8290

8391
// Guess a package from the command if no specs were provided at all OR if --with is used
84-
let should_guess_package = name_specs.is_empty() || !args.with.is_empty();
92+
let should_guess_package = args.specs.is_empty() || !args.with.is_empty();
8593
if should_guess_package {
8694
install_specs.push(guess_package_spec(command));
8795
}
@@ -101,10 +109,7 @@ pub async fn execute(args: Args) -> miette::Result<()> {
101109
let mut activation_env = run_activation(&prefix).await?;
102110

103111
// Collect unique package names for environment naming
104-
let package_names: BTreeSet<String> = name_specs
105-
.iter()
106-
.filter_map(|spec| spec.name.as_ref().map(|n| n.as_normalized().to_string()))
107-
.collect();
112+
let package_names: BTreeSet<String> = display_names.into_iter().collect();
108113

109114
if !package_names.is_empty() {
110115
let env_name = format!("temp:{}", package_names.into_iter().format(","));
@@ -380,3 +385,14 @@ async fn run_activation(
380385
) -> miette::Result<std::collections::HashMap<String, String>> {
381386
wrap_in_progress("running activation", move || prefix.run_activation()).await
382387
}
388+
389+
fn to_exec_match_specs(specs: &[MatchSpecOrPath]) -> miette::Result<Vec<MatchSpec>> {
390+
specs
391+
.iter()
392+
.cloned()
393+
.map(|spec| {
394+
spec.into_exec_match_spec()
395+
.map_err(|err| miette::miette!(err))
396+
})
397+
.collect()
398+
}

crates/pixi_cli/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub mod init;
3636
pub mod install;
3737
pub mod list;
3838
pub mod lock;
39+
pub(crate) mod match_spec_or_path;
3940
pub mod reinstall;
4041
pub mod remove;
4142
pub mod run;
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
use std::{
2+
env,
3+
path::{Component, Path},
4+
str::FromStr,
5+
};
6+
7+
use dunce::canonicalize;
8+
use pixi_spec::PathSpec;
9+
use rattler_conda_types::{MatchSpec, PackageName, ParseStrictness, package::ArchiveIdentifier};
10+
11+
/// Represents either a regular conda MatchSpec or a filesystem path to a conda artifact.
12+
#[derive(Debug, Clone)]
13+
pub enum MatchSpecOrPath {
14+
MatchSpec(Box<MatchSpec>),
15+
Path(PathSpec),
16+
}
17+
18+
impl MatchSpecOrPath {
19+
pub fn as_match_spec(&self) -> Option<&MatchSpec> {
20+
if let Self::MatchSpec(spec) = self {
21+
Some(spec.as_ref())
22+
} else {
23+
None
24+
}
25+
}
26+
27+
pub fn is_path(&self) -> bool {
28+
matches!(self, Self::Path(_))
29+
}
30+
31+
pub fn display_name(&self) -> Option<String> {
32+
match self {
33+
Self::MatchSpec(spec) => spec
34+
.name
35+
.as_ref()
36+
.map(|name| name.as_normalized().to_string()),
37+
Self::Path(path_spec) => path_spec
38+
.path
39+
.file_name()
40+
.map(|fname| fname.to_string())
41+
.or_else(|| Some(path_spec.path.as_str().to_string())),
42+
}
43+
}
44+
45+
/// Convert into a MatchSpec suitable for execution, turning paths into file URLs.
46+
pub fn into_exec_match_spec(self) -> Result<MatchSpec, String> {
47+
match self {
48+
Self::MatchSpec(spec) => Ok(*spec),
49+
Self::Path(path_spec) => path_spec_to_match_spec(path_spec),
50+
}
51+
}
52+
53+
/// Returns the underlying PathSpec, if any.
54+
pub fn into_path_spec(self) -> Result<PathSpec, String> {
55+
match self {
56+
Self::Path(path) => Ok(path),
57+
Self::MatchSpec(_) => Err("expected a path dependency".into()),
58+
}
59+
}
60+
}
61+
62+
impl FromStr for MatchSpecOrPath {
63+
type Err = String;
64+
65+
fn from_str(value: &str) -> Result<Self, Self::Err> {
66+
// Check if this is a URL pointing to a conda package
67+
// Rattler's MatchSpec parser doesn't recognize URLs with schemes, so we handle them here
68+
if let Ok(url) = url::Url::parse(value) {
69+
if let Some(archive) = ArchiveIdentifier::try_from_url(&url) {
70+
// This is a URL to a conda package
71+
let name = PackageName::try_from(archive.name)
72+
.map_err(|e| format!("invalid package name: {e}"))?;
73+
74+
return Ok(Self::MatchSpec(Box::new(MatchSpec {
75+
name: Some(name),
76+
url: Some(url),
77+
..MatchSpec::default()
78+
})));
79+
}
80+
}
81+
82+
match MatchSpec::from_str(value, ParseStrictness::Lenient) {
83+
Ok(spec) => Ok(Self::MatchSpec(Box::new(spec))),
84+
Err(parse_err) => {
85+
if looks_like_path(value) {
86+
let path_spec = build_path_spec(value)?;
87+
Ok(Self::Path(path_spec))
88+
} else {
89+
Err(parse_err.to_string())
90+
}
91+
}
92+
}
93+
}
94+
}
95+
96+
fn build_path_spec(value: &str) -> Result<PathSpec, String> {
97+
let provided = Path::new(value);
98+
let joined = if provided.is_absolute() {
99+
provided.to_path_buf()
100+
} else {
101+
let cwd = env::current_dir()
102+
.map_err(|err| format!("failed to determine current directory: {err}"))?;
103+
cwd.join(provided)
104+
};
105+
106+
// Use canonical path when available to avoid duplicate cache keys, but fall back silently.
107+
let absolute = canonicalize(&joined).unwrap_or(joined);
108+
let path_str = absolute
109+
.to_str()
110+
.ok_or_else(|| format!("path '{}' is not valid UTF-8", absolute.display()))?;
111+
112+
Ok(PathSpec::new(path_str.to_string()))
113+
}
114+
115+
fn looks_like_path(value: &str) -> bool {
116+
if value.is_empty() {
117+
return false;
118+
}
119+
120+
if value.contains("::") {
121+
return false;
122+
}
123+
124+
let path = Path::new(value);
125+
if path.is_absolute() {
126+
return true;
127+
}
128+
129+
let mut components = path.components();
130+
let Some(first) = components.next() else {
131+
return false;
132+
};
133+
134+
let starts_with_dot = matches!(first, Component::CurDir | Component::ParentDir);
135+
let has_multiple_components = components.next().is_some();
136+
let looks_like_archive = value.ends_with(".conda") || value.ends_with(".tar.bz2");
137+
138+
starts_with_dot
139+
|| has_multiple_components
140+
|| value.contains(std::path::MAIN_SEPARATOR)
141+
|| value.contains('/')
142+
|| value.contains('\\')
143+
|| looks_like_archive
144+
}
145+
146+
fn path_spec_to_match_spec(path_spec: PathSpec) -> Result<MatchSpec, String> {
147+
let path = Path::new(path_spec.path.as_str());
148+
149+
// Invariant for if we ever change stuff around
150+
debug_assert!(
151+
path.is_absolute(),
152+
"path_spec_to_match_spec expects absolute paths"
153+
);
154+
155+
let url = url::Url::from_file_path(path)
156+
.map_err(|_| format!("failed to convert '{}' into a file:// url", path.display()))?;
157+
158+
// Extract package name from the archive
159+
let archive = ArchiveIdentifier::try_from_url(&url)
160+
.ok_or_else(|| format!("failed to parse package archive from '{url}'"))?;
161+
162+
let name =
163+
PackageName::try_from(archive.name).map_err(|e| format!("invalid package name: {e}"))?;
164+
165+
Ok(MatchSpec {
166+
name: Some(name),
167+
url: Some(url),
168+
..MatchSpec::default()
169+
})
170+
}
171+
172+
#[cfg(test)]
173+
mod tests {
174+
use super::*;
175+
176+
#[test]
177+
fn detects_relative_like_inputs() {
178+
assert!(looks_like_path("./pkg/file.conda"));
179+
assert!(looks_like_path("pkg/file.conda"));
180+
assert!(looks_like_path("file.tar.bz2"));
181+
assert!(looks_like_path("file.conda"));
182+
assert!(!looks_like_path("python>=3.12"));
183+
assert!(!looks_like_path("conda-forge::python"));
184+
}
185+
186+
#[test]
187+
fn parses_https_url() {
188+
let result = MatchSpecOrPath::from_str(
189+
"https://conda.anaconda.org/conda-forge/noarch/tzdata-2024b-hc8b5060_0.conda",
190+
);
191+
assert!(result.is_ok(), "Failed to parse HTTPS URL: {result:?}");
192+
let spec_or_path = result.unwrap();
193+
match spec_or_path {
194+
MatchSpecOrPath::MatchSpec(spec) => {
195+
assert_eq!(
196+
spec.name.as_ref().map(|n| n.as_normalized()),
197+
Some("tzdata")
198+
);
199+
assert!(spec.url.is_some());
200+
}
201+
_ => panic!("Expected MatchSpec, got Path"),
202+
}
203+
}
204+
205+
#[test]
206+
fn parses_file_url() {
207+
let result = MatchSpecOrPath::from_str("file:///tmp/test-package-1.0.0-h123_0.conda");
208+
assert!(result.is_ok());
209+
let spec_or_path = result.unwrap();
210+
match spec_or_path {
211+
MatchSpecOrPath::MatchSpec(spec) => {
212+
assert_eq!(
213+
spec.name.as_ref().map(|n| n.as_normalized()),
214+
Some("test-package")
215+
);
216+
assert!(spec.url.is_some());
217+
assert_eq!(spec.url.as_ref().unwrap().scheme(), "file");
218+
}
219+
_ => panic!("Expected MatchSpec, got Path"),
220+
}
221+
}
222+
}

0 commit comments

Comments
 (0)