Skip to content
Merged
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ To bypass protection for a single command, pass `--override-frozen` or set
`CONDA_OVERRIDE_FROZEN=1`. To disable it permanently, add `override_frozen: true`
to your `.condarc`.

## Configuration

### Permanent packages

By default, `conda self reset` keeps only `conda`, `conda-self`, and their
plugins installed. To keep additional packages (and their dependencies) in
the base environment, add them to the `self_permanent_packages` setting in
your `.condarc`:

```yaml
plugins:
self_permanent_packages:
- anaconda-anon-usage
```

Or use `conda config`:

```bash
conda config --add plugins.self_permanent_packages anaconda-anon-usage
```

## Installation

1. `conda install -n base conda-self`
Expand Down
2 changes: 2 additions & 0 deletions conda_self/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

PERMANENT_PACKAGES: Final = ("conda", "conda-self")

SELF_PERMANENT_PACKAGES_SETTING: Final = "self_permanent_packages"

DEFAULT_ENV_NAME: Final = "default"

RESET_FILE_INSTALLER = "initial-state.explicit.txt"
Expand Down
18 changes: 17 additions & 1 deletion conda_self/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

from typing import TYPE_CHECKING

from conda.common.configuration import PrimitiveParameter, SequenceParameter
from conda.plugins.hookspec import hookimpl
from conda.plugins.types import CondaHealthCheck, CondaSubcommand
from conda.plugins.types import CondaHealthCheck, CondaSetting, CondaSubcommand

from .cli import configure_parser, execute
from .constants import PERMANENT_PACKAGES, SELF_PERMANENT_PACKAGES_SETTING

if TYPE_CHECKING:
from collections.abc import Iterable
Expand Down Expand Up @@ -36,3 +38,17 @@ def conda_health_checks() -> Iterable[CondaHealthCheck]:
summary="Check if base is frozen to prevent accidental modifications",
fix="Clone base to 'default' environment, reset base, and freeze it",
)


@hookimpl
def conda_settings() -> Iterable[CondaSetting]:
"""Register conda-self plugin settings."""
yield CondaSetting(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

when I run a conda config --add plugins.self_permanent_packages anaconda-anon-usage I can see that the config has been added to my condarc. However, it does not appear when I run conda config --show-sources. Is this a bug in core conda?

Copy link
Copy Markdown
Member Author

@jezdez jezdez Apr 14, 2026

Choose a reason for hiding this comment

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

Good catch, this is a bug in conda core. conda config --show-sources calls context.collect_all(), which only iterates over Context.raw_data and never consults context.plugins.collect_all(). So plugin settings under the plugins: key are invisible in the output even though they work correctly at runtime.

Filed upstream: conda/conda#15912

name=SELF_PERMANENT_PACKAGES_SETTING,
description=(
f"Additional packages (besides {', '.join(PERMANENT_PACKAGES)})"
" to always keep in the 'base' environment. "
"These packages and their dependencies will not be removed."
),
parameter=SequenceParameter(PrimitiveParameter("", element_type=str)),
)
3 changes: 2 additions & 1 deletion conda_self/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ def permanent_dependencies(add_plugins: bool = False) -> set[str]:
installed = list(PrefixData(sys.prefix, interoperability=True).iter_records())
prefix_graph = PrefixGraph(installed)

protect = [*PERMANENT_PACKAGES]
protect = [*PERMANENT_PACKAGES, *context.plugins.self_permanent_packages]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It looks like this is also affecting the conda self remove command. So, if a user runs

$  pixi run conda config --add plugins.self_permanent_packages anaconda-anon-usage
$ pixi run conda self remove anaconda-anon-usage

They will get an error:

SpecsCanNotBeRemoved: Packages '['anaconda-anon-usage']' can not be removed.

Should conda self remove get a flag like --force that allows a user to override the protection for this setting?

Copy link
Copy Markdown
Member Author

@jezdez jezdez Apr 14, 2026

Choose a reason for hiding this comment

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

Good point, this is working as designed (the user explicitly configured the package as permanent, so blocking removal is expected), but the UX could definitely be smoother. Rather than adding scope to this PR, let's track a --force flag separately.

Filed: #127

For now the workaround is to remove the package from plugins.self_permanent_packages in .condarc first, then run conda self remove.


if add_plugins:
for record in installed:
with suppress(NoDistInfoDirFound):
Expand Down
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import sys

import pytest
from conda.plugins.hookspec import CondaSpecs
from conda.plugins.manager import CondaPluginManager

pytest_plugins = (
# Add testing fixtures and internal pytest plugins here
Expand All @@ -18,3 +20,11 @@ def conda_channel() -> str:
@pytest.fixture
def python_version() -> str:
return f"{sys.version_info.major}.{sys.version_info.minor}"


@pytest.fixture
def plugin_manager(mocker) -> CondaPluginManager:
pm = CondaPluginManager()
pm.add_hookspecs(CondaSpecs)
mocker.patch("conda.plugins.manager.get_plugin_manager", return_value=pm)
return pm
67 changes: 66 additions & 1 deletion tests/test_query.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,72 @@
from conda_self.constants import PERMANENT_PACKAGES
import pytest
from conda.base.context import context, reset_context
from conda.common.configuration import YamlRawParameter
from conda.common.serialize import yaml
from conda.plugins.manager import CondaPluginManager

from conda_self import plugin as conda_self_plugin
from conda_self.constants import PERMANENT_PACKAGES, SELF_PERMANENT_PACKAGES_SETTING
from conda_self.query import permanent_dependencies

CONDARC_PERMANENT_PACKAGES = f"""\
plugins:
{SELF_PERMANENT_PACKAGES_SETTING}:
- python
"""


def test_permanent_dependencies():
must_keep = permanent_dependencies()
assert set(PERMANENT_PACKAGES).issubset(must_keep)


@pytest.fixture()
def clear_plugins_context_cache():
try:
del context.plugins
except AttributeError:
pass


@pytest.fixture()
def self_plugin_manager(
plugin_manager: CondaPluginManager, clear_plugins_context_cache
):
"""Load the conda-self plugin module (including conda_settings)."""
plugin_manager.load_plugins(conda_self_plugin)
yield plugin_manager


@pytest.fixture()
def permanent_packages_condarc(self_plugin_manager):
"""Load a .condarc that sets self_permanent_packages to ['python']."""
reset_context()
context._set_raw_data(
{
"testdata": YamlRawParameter.make_raw_parameters(
"testdata", yaml.loads(CONDARC_PERMANENT_PACKAGES)
)
}
)
yield self_plugin_manager
reset_context()


def test_permanent_dependencies_with_setting(permanent_packages_condarc):
"""Packages listed in the self_permanent_packages setting are kept."""
must_keep = permanent_dependencies()

assert set(PERMANENT_PACKAGES).issubset(must_keep)
assert "python" in must_keep


def test_permanent_dependencies_setting_empty_by_default(self_plugin_manager):
"""Without condarc config, the setting defaults to an empty list."""
assert getattr(context.plugins, SELF_PERMANENT_PACKAGES_SETTING) == ()


def test_permanent_dependencies_without_setting():
"""Works normally when no plugin settings are loaded."""
must_keep = permanent_dependencies()

assert set(PERMANENT_PACKAGES).issubset(must_keep)