diff --git a/README.md b/README.md index 6aa5752..2d5f12b 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/conda_self/constants.py b/conda_self/constants.py index ff641ee..c02b843 100644 --- a/conda_self/constants.py +++ b/conda_self/constants.py @@ -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" diff --git a/conda_self/plugin.py b/conda_self/plugin.py index a55ece6..93fb06c 100644 --- a/conda_self/plugin.py +++ b/conda_self/plugin.py @@ -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 @@ -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( + 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)), + ) diff --git a/conda_self/query.py b/conda_self/query.py index 6c1227f..0fd8d1b 100644 --- a/conda_self/query.py +++ b/conda_self/query.py @@ -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] + if add_plugins: for record in installed: with suppress(NoDistInfoDirFound): diff --git a/tests/conftest.py b/tests/conftest.py index 7fa82ea..fb0e5fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 @@ -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 diff --git a/tests/test_query.py b/tests/test_query.py index 121afe7..b3a13b6 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -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)