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
42 changes: 42 additions & 0 deletions crates/pixi/tests/integration_rust/add_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1208,3 +1208,45 @@ preview = ['pixi-build']
insta::assert_snapshot!(workspace.workspace.provenance.read().unwrap().into_inner());
});
}

/// Test that `pixi remove <pkg>` without `--pypi` falls back to removing
/// pypi deps when the package isn't found in conda deps. Regression test
/// for https://github.com/prefix-dev/pixi/issues/1567
#[tokio::test]
async fn remove_pypi_dep_without_flag() {
setup_tracing();

let platform = Platform::current();
let pixi = PixiControl::from_manifest(&format!(
r#"
[workspace]
name = "test-remove-fallback"
channels = ["https://prefix.dev/conda-forge"]
platforms = ["{platform}"]

[dependencies]
python = ">=3.12"

[pypi-dependencies]
requests = "*"
"#
))
.unwrap();

// Sanity check: pypi dep exists
let workspace = pixi.workspace().unwrap();
let pypi_deps = workspace.default_environment().pypi_dependencies(None);
assert!(pypi_deps.names().any(|n| n.as_source() == "requests"));

// Remove without --pypi flag
pixi.remove("requests")
.with_install(false)
.with_frozen(true)
.await
.unwrap();

// Should be gone now
let workspace = pixi.workspace().unwrap();
let pypi_deps = workspace.default_environment().pypi_dependencies(None);
assert!(!pypi_deps.names().any(|n| n.as_source() == "requests"));
}
6 changes: 6 additions & 0 deletions crates/pixi/tests/integration_rust/common/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,12 @@ impl HasNoInstallConfig for RemoveBuilder {
}
}

impl HasLockFileUpdateConfig for RemoveBuilder {
fn lock_file_update_config(&mut self) -> &mut LockFileUpdateConfig {
&mut self.args.lock_file_update_config
}
}

impl IntoFuture for RemoveBuilder {
type Output = miette::Result<()>;
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + 'static>>;
Expand Down
10 changes: 9 additions & 1 deletion crates/pixi_cli/src/cli_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,15 @@ impl DependencyConfig {
&self,
operation: &str,
implicit_constraints: HashMap<String, String>,
) {
self.display_success_with_type(operation, implicit_constraints, self.dependency_type());
}

pub(crate) fn display_success_with_type(
&self,
operation: &str,
implicit_constraints: HashMap<String, String>,
dependency_type: DependencyType,
) {
for package in self.specs.clone() {
eprintln!(
Expand All @@ -348,7 +357,6 @@ impl DependencyConfig {
}

// Print if it is something different from host and dep
let dependency_type = self.dependency_type();
if !matches!(
dependency_type,
DependencyType::CondaDependency(SpecType::Run)
Expand Down
31 changes: 29 additions & 2 deletions crates/pixi_cli/src/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use clap::Parser;
use pixi_api::{WorkspaceContext, workspace::DependencyOptions};
use pixi_config::ConfigCli;
use pixi_core::{DependencyType, WorkspaceLocator};
use pixi_manifest::{FeaturesExt, SpecType};

use crate::{cli_config::LockFileUpdateConfig, has_specs::HasSpecs};
use crate::{
Expand Down Expand Up @@ -58,7 +59,33 @@ pub async fn execute(args: Args) -> miette::Result<()> {

let workspace_ctx = WorkspaceContext::new(CliInterface {}, workspace.clone());

match args.dependency_config.dependency_type() {
let mut dep_type = args.dependency_config.dependency_type();

// Fall back to pypi removal if deps aren't found in conda.
// Only when no explicit type flag (--host, --build) was passed.
if matches!(dep_type, DependencyType::CondaDependency(SpecType::Run)) {
let spec_type = SpecType::Run;
let specs = args.dependency_config.specs()?;
let env = workspace.default_environment();
let conda_deps = env.dependencies(spec_type, None);
Comment on lines +68 to +70
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

args.dependency_config.specs()? is parsed here for the fallback check and then parsed again in the conda removal branch. Consider parsing once and reusing the specs map to avoid duplicate work and keep the control flow simpler.

Copilot uses AI. Check for mistakes.

let all_missing_from_conda = specs.keys().all(|name| !conda_deps.contains_key(name));

if all_missing_from_conda {
let pypi_deps = env.pypi_dependencies(None);
Comment on lines +70 to +75
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback detection uses env.dependencies(spec_type, None) / env.pypi_dependencies(None), which merges across all features in the environment and ignores args.dependency_config.platforms and the selected feature. Since the actual removal uses DependencyOptions (feature + platforms), this can prevent fallback (or trigger it incorrectly) for feature/platform-specific removals. Consider checking presence in the exact target(s) being modified instead.

Suggested change
let conda_deps = env.dependencies(spec_type, None);
let all_missing_from_conda = specs
.keys()
.all(|name| !conda_deps.contains_key(name));
if all_missing_from_conda {
let pypi_deps = env.pypi_dependencies(None);
let feature = args.dependency_config.feature.as_deref();
let conda_deps = env.dependencies(spec_type, feature);
let all_missing_from_conda = specs
.keys()
.all(|name| !conda_deps.contains_key(name));
if all_missing_from_conda {
let pypi_deps = env.pypi_dependencies(feature);

Copilot uses AI. Check for mistakes.
let any_in_pypi = specs.keys().any(|name| {
pypi_deps
.names()
.any(|pypi_name| pypi_name.as_source() == name.as_source())
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyPI presence is detected by comparing pypi_name.as_source() to name.as_source(). as_source() preserves the original string and won’t match common normalized equivalents (e.g. foo-bar vs foo_bar, case differences), so the fallback may fail even when the PyPI dep exists. Prefer comparing normalized forms (e.g. pypi_name.as_normalized() vs a normalized name derived from the input) or using contains_key/get on the PyPI dependency map with an appropriate normalized key type.

Suggested change
.any(|pypi_name| pypi_name.as_source() == name.as_source())
.any(|pypi_name| pypi_name.as_normalized() == name.as_normalized())

Copilot uses AI. Check for mistakes.
});

if any_in_pypi {
dep_type = DependencyType::PypiDependency;
}
}
}

match dep_type {
DependencyType::CondaDependency(spec_type) => {
workspace_ctx
.remove_conda_deps(
Expand All @@ -82,7 +109,7 @@ pub async fn execute(args: Args) -> miette::Result<()> {
};

args.dependency_config
.display_success("Removed", Default::default());
.display_success_with_type("Removed", Default::default(), dep_type);

Ok(())
}
Loading