Skip to content

Commit 4e3c996

Browse files
lucascolleybaszalmstraHofer-Julian
authored
feat(global): direct .conda installation (#4502)
Co-authored-by: Bas Zalmstra <[email protected]> Co-authored-by: Julian Hofer <[email protected]> Co-authored-by: Hofer-Julian <[email protected]>
1 parent 22bb52d commit 4e3c996

File tree

8 files changed

+220
-36
lines changed

8 files changed

+220
-36
lines changed

crates/pixi_cli/src/global/global_specs.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ pub struct GlobalSpecs {
3030
#[clap(long, requires = "git", help_heading = consts::CLAP_GIT_OPTIONS)]
3131
pub subdir: Option<String>,
3232

33-
/// The path to the local directory
33+
/// The path to the local package
3434
#[clap(long, conflicts_with = "git")]
3535
pub path: Option<Utf8NativePathBuf>,
3636
}
@@ -59,6 +59,8 @@ pub enum GlobalSpecsConversionError {
5959
#[error("failed to infer package name")]
6060
#[diagnostic(transparent)]
6161
PackageNameInference(#[from] pixi_global::project::InferPackageNameError),
62+
#[error("Input {0} looks like a path: please pass `--path`.")]
63+
MissingPathArg(String),
6264
}
6365

6466
impl GlobalSpecs {
@@ -92,6 +94,14 @@ impl GlobalSpecs {
9294
.to_typed_path_buf(),
9395
}))
9496
} else {
97+
fn pathlike(s: &str) -> bool {
98+
s.contains(".conda") || s.contains('/') || s.contains('\\')
99+
}
100+
if let Some(pathlike_input) = self.specs.iter().find(|s| pathlike(s)) {
101+
return Err(GlobalSpecsConversionError::MissingPathArg(
102+
pathlike_input.clone(),
103+
));
104+
}
95105
None
96106
};
97107
if let Some(pixi_spec) = git_or_path_spec {

crates/pixi_cli/src/global/install.rs

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use indexmap::IndexMap;
55
use clap::Parser;
66
use fancy_display::FancyDisplay;
77
use itertools::Itertools;
8-
use miette::{Context, IntoDiagnostic, Report};
8+
use miette::Report;
99
use rattler_conda_types::{MatchSpec, NamedChannelOrUrl, Platform};
1010

1111
use crate::global::{global_specs::GlobalSpecs, revert_environment_after_error};
@@ -78,18 +78,13 @@ pub struct Args {
7878

7979
pub async fn execute(args: Args) -> miette::Result<()> {
8080
let config = Config::with_cli_config(&args.config);
81+
82+
// Load the global config and ensure
83+
// that the root_dir is relative to the manifest directory
8184
let project_original = pixi_global::Project::discover_or_create()
8285
.await?
8386
.with_cli_config(config.clone());
84-
85-
// Capture the current working directory for proper relative path resolution
86-
let current_dir = std::env::current_dir()
87-
.into_diagnostic()
88-
.wrap_err("Could not retrieve the current directory")?;
89-
let channel_config = rattler_conda_types::ChannelConfig {
90-
root_dir: current_dir,
91-
..project_original.global_channel_config().clone()
92-
};
87+
let channel_config = project_original.global_channel_config().clone();
9388

9489
let specs = args
9590
.packages

crates/pixi_config/src/lib.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,7 @@ pub struct Config {
654654
pub loaded_from: Vec<PathBuf>,
655655

656656
#[serde(skip, default = "default_channel_config")]
657-
channel_config: ChannelConfig,
657+
pub channel_config: ChannelConfig,
658658

659659
/// Configuration for repodata fetching.
660660
#[serde(alias = "repodata_config")] // BREAK: remove to stop supporting snake_case alias
@@ -1346,8 +1346,11 @@ impl Config {
13461346
// Extended self.mirrors with other.mirrors
13471347
mirrors: self.mirrors,
13481348
loaded_from: other.loaded_from,
1349-
// currently this is always the default so just use the other value
1350-
channel_config: other.channel_config,
1349+
channel_config: if other.channel_config == default_channel_config() {
1350+
self.channel_config
1351+
} else {
1352+
other.channel_config
1353+
},
13511354
repodata_config: self.repodata_config.merge(other.repodata_config),
13521355
pypi_config: self.pypi_config.merge(other.pypi_config),
13531356
s3_options: {

crates/pixi_global/src/project/mod.rs

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ use pixi_core::repodata::Repodata;
3131
use pixi_manifest::PrioritizedChannel;
3232
use pixi_progress::global_multi_progress;
3333
use pixi_reporters::TopLevelProgress;
34+
use pixi_spec::{BinarySpec, PathBinarySpec};
3435
use pixi_spec_containers::DependencyMap;
3536
use pixi_utils::{
3637
executable_from_path,
@@ -40,7 +41,7 @@ use pixi_utils::{
4041
};
4142
use rattler_conda_types::{
4243
ChannelConfig, GenericVirtualPackage, MatchSpec, PackageName, Platform, PrefixRecord,
43-
menuinst::MenuMode,
44+
menuinst::MenuMode, package::ArchiveIdentifier,
4445
};
4546
use rattler_repodata_gateway::Gateway;
4647
// Removed unused rattler_solve imports
@@ -120,7 +121,7 @@ pub struct Project {
120121
/// The manifest for the project
121122
pub manifest: Manifest,
122123
/// The global configuration as loaded from the config file(s)
123-
config: Config,
124+
pub config: Config,
124125
/// Root directory of the global environments
125126
pub(crate) env_root: EnvRoot,
126127
/// Binary directory
@@ -303,7 +304,10 @@ impl Project {
303304
.expect("manifest path should always have a parent")
304305
.to_owned();
305306

306-
let config = Config::load(&root);
307+
// Load the global config and ensure
308+
// that the root_dir is relative to the manifest directory
309+
let mut config = Config::load_global();
310+
config.channel_config.root_dir = root.clone();
307311

308312
let client = OnceCell::new();
309313
let repodata_gateway = OnceCell::new();
@@ -1361,26 +1365,18 @@ impl Project {
13611365
})
13621366
}
13631367

1364-
/// Infer the package name from a PixiSpec (path or git) by examining build
1365-
/// outputs
1366-
pub async fn infer_package_name_from_spec(
1368+
/// Infer the package name from a SourceSpec by examining build outputs
1369+
async fn infer_package_name_from_source_spec(
13671370
&self,
1368-
pixi_spec: &pixi_spec::PixiSpec,
1371+
source_spec: pixi_spec::SourceSpec,
13691372
) -> Result<PackageName, InferPackageNameError> {
1370-
let pinned_source_spec = match pixi_spec.clone().into_source_or_binary() {
1371-
Either::Left(source_spec) => {
1372-
let command_dispatcher = self.command_dispatcher()?;
1373-
let checkout = command_dispatcher
1374-
.pin_and_checkout(source_spec)
1375-
.await
1376-
.map_err(|e| InferPackageNameError::BuildBackendMetadata(Box::new(e)))?;
1373+
let command_dispatcher = self.command_dispatcher()?;
1374+
let checkout = command_dispatcher
1375+
.pin_and_checkout(source_spec)
1376+
.await
1377+
.map_err(|e| InferPackageNameError::BuildBackendMetadata(Box::new(e)))?;
13771378

1378-
checkout.pinned
1379-
}
1380-
Either::Right(_) => {
1381-
return Err(InferPackageNameError::UnsupportedSpecType);
1382-
}
1383-
};
1379+
let pinned_source_spec = checkout.pinned;
13841380

13851381
// Create the metadata spec
13861382
let metadata_spec = BuildBackendMetadataSpec {
@@ -1422,6 +1418,27 @@ impl Project {
14221418
}
14231419
}
14241420
}
1421+
1422+
/// Infer the package name from a PixiSpec (path or git) by examining build
1423+
/// outputs
1424+
pub async fn infer_package_name_from_spec(
1425+
&self,
1426+
pixi_spec: &pixi_spec::PixiSpec,
1427+
) -> Result<PackageName, InferPackageNameError> {
1428+
match pixi_spec.clone().into_source_or_binary() {
1429+
Either::Left(source_spec) => {
1430+
self.infer_package_name_from_source_spec(source_spec).await
1431+
}
1432+
Either::Right(binary_spec) => match binary_spec {
1433+
BinarySpec::Path(PathBinarySpec { path }) => path
1434+
.file_name()
1435+
.and_then(ArchiveIdentifier::try_from_filename)
1436+
.and_then(|iden| PackageName::from_str(&iden.name).ok())
1437+
.ok_or(InferPackageNameError::UnsupportedSpecType),
1438+
_ => Err(InferPackageNameError::UnsupportedSpecType),
1439+
},
1440+
}
1441+
}
14251442
}
14261443

14271444
impl Repodata for Project {

docs/reference/cli/pixi/global/add.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pixi global add [OPTIONS] --environment <ENVIRONMENT> [PACKAGE]...
1818

1919
## Options
2020
- <a id="arg---path" href="#arg---path">`--path <PATH>`</a>
21-
: The path to the local directory
21+
: The path to the local package
2222
- <a id="arg---environment" href="#arg---environment">`--environment (-e) <ENVIRONMENT>`</a>
2323
: Specifies the environment that the dependencies need to be added to
2424
<br>**required**: `true`

docs/reference/cli/pixi/global/install.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pixi global install [OPTIONS] [PACKAGE]...
1818

1919
## Options
2020
- <a id="arg---path" href="#arg---path">`--path <PATH>`</a>
21-
: The path to the local directory
21+
: The path to the local package
2222
- <a id="arg---channel" href="#arg---channel">`--channel (-c) <CHANNEL>`</a>
2323
: The channels to consider as a name or a url. Multiple channels can be specified by using this field multiple times
2424
<br>May be provided more than once.

pixi.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ test-specific-test = { cmd = "pytest -k '{{ test_substring }}'", args = [
8282
], depends-on = [
8383
"build-release",
8484
] }
85+
test-specific-test-debug = { cmd = "pytest --pixi-build=debug -k '{{ test_substring }}'", args = [
86+
"test_substring",
87+
], depends-on = [
88+
"build-debug",
89+
] }
8590
# Update one test channel by passing on value of `mappings.toml`
8691
# e.g. "multiple_versions_channel_1"
8792
update-test-channel = { cmd = "python update-channels.py {{ channel }}", args = [

tests/integration_python/pixi_global/test_global.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import platform
23
import shutil
34
import tomllib
@@ -2211,3 +2212,156 @@ def test_tree_invert(pixi: Path, tmp_path: Path, dummy_channel_1: str) -> None:
22112212
env=env,
22122213
stdout_contains=["dummy-c", "dummy-a 0.1.0"],
22132214
)
2215+
2216+
2217+
class TestCondaFile:
2218+
@pytest.mark.parametrize("path_arg", [True, False])
2219+
def test_install_conda_file(
2220+
self, pixi: Path, tmp_path: Path, shortcuts_channel_1: str, path_arg: bool
2221+
) -> None:
2222+
"""Test directly installing a `.conda` file with `pixi global`"""
2223+
env = {"PIXI_HOME": str(tmp_path), "PIXI_CACHE_DIR": str(tmp_path / "foo")}
2224+
cwd = tmp_path
2225+
2226+
conda_file = tmp_path / "pixi-editor-1.0.0-h4616a5c_0.conda"
2227+
shutil.copyfile(
2228+
Path.from_uri(shortcuts_channel_1) / "noarch" / "pixi-editor-1.0.0-h4616a5c_0.conda",
2229+
conda_file,
2230+
)
2231+
2232+
def check_install(conda_file_path: Path, cwd: Path):
2233+
if path_arg:
2234+
verify_cli_command(
2235+
[pixi, "global", "install", "--path", conda_file_path], env=env, cwd=cwd
2236+
)
2237+
else:
2238+
verify_cli_command(
2239+
[pixi, "global", "install", conda_file_path],
2240+
env=env,
2241+
expected_exit_code=ExitCode.FAILURE,
2242+
stderr_contains="please pass `--path`",
2243+
cwd=cwd,
2244+
)
2245+
2246+
# check absolute path
2247+
check_install(conda_file, cwd)
2248+
2249+
# check relative path in same dir
2250+
cwd = conda_file.parent
2251+
relative_conda_file = conda_file.relative_to(cwd, walk_up=True)
2252+
check_install(relative_conda_file, cwd)
2253+
2254+
# check relative path in subdir
2255+
cwd = conda_file.parent.parent
2256+
relative_conda_file = conda_file.relative_to(cwd, walk_up=True)
2257+
check_install(relative_conda_file, cwd)
2258+
2259+
# check relative path in a 'cousin' relative directory
2260+
cwd = tmp_path
2261+
relative_conda_file = conda_file.relative_to(cwd, walk_up=True)
2262+
check_install(relative_conda_file, cwd)
2263+
2264+
def test_update_sync_conda_file(
2265+
self, pixi: Path, tmp_path: Path, shortcuts_channel_1: str
2266+
) -> None:
2267+
"""Test that `pixi global {update, sync}` work and use the existing file."""
2268+
env = {"PIXI_HOME": str(tmp_path), "PIXI_CACHE_DIR": str(tmp_path / "foo")}
2269+
cwd = tmp_path
2270+
2271+
package_name = "pixi-editor"
2272+
conda_file = tmp_path / "pixi-editor-1.0.0-h4616a5c_0.conda"
2273+
shutil.copyfile(
2274+
Path.from_uri(shortcuts_channel_1) / "noarch" / "pixi-editor-1.0.0-h4616a5c_0.conda",
2275+
conda_file,
2276+
)
2277+
2278+
verify_cli_command(
2279+
[
2280+
pixi,
2281+
"global",
2282+
"install",
2283+
"--path",
2284+
conda_file,
2285+
],
2286+
env=env,
2287+
cwd=cwd,
2288+
)
2289+
2290+
# update with file still there
2291+
verify_cli_command(
2292+
[
2293+
pixi,
2294+
"global",
2295+
"update",
2296+
"pixi-editor",
2297+
],
2298+
env=env,
2299+
cwd=cwd,
2300+
stderr_contains="Environment pixi-editor was already up-to-date.",
2301+
)
2302+
2303+
# sync with file still there
2304+
verify_cli_command(
2305+
[
2306+
pixi,
2307+
"global",
2308+
"sync",
2309+
],
2310+
env=env,
2311+
cwd=cwd,
2312+
stderr_contains="Nothing to do",
2313+
)
2314+
2315+
os.remove(conda_file)
2316+
2317+
# update with file gone
2318+
verify_cli_command(
2319+
[
2320+
pixi,
2321+
"global",
2322+
"update",
2323+
"pixi-editor",
2324+
],
2325+
env=env,
2326+
cwd=cwd,
2327+
stderr_contains="Environment pixi-editor was already up-to-date.",
2328+
)
2329+
2330+
# sync with file gone
2331+
verify_cli_command(
2332+
[
2333+
pixi,
2334+
"global",
2335+
"sync",
2336+
],
2337+
env=env,
2338+
cwd=cwd,
2339+
stderr_contains="Nothing to do",
2340+
)
2341+
2342+
# remove the environment
2343+
# XXX: should this fail instead?
2344+
shutil.rmtree(tmp_path / "envs" / package_name)
2345+
2346+
# update with environment removed
2347+
verify_cli_command(
2348+
[
2349+
pixi,
2350+
"global",
2351+
"update",
2352+
"pixi-editor",
2353+
],
2354+
env=env,
2355+
cwd=cwd,
2356+
)
2357+
2358+
# sync with environment removed
2359+
verify_cli_command(
2360+
[
2361+
pixi,
2362+
"global",
2363+
"sync",
2364+
],
2365+
env=env,
2366+
cwd=cwd,
2367+
)

0 commit comments

Comments
 (0)