diff --git a/conda_self/cli/main_reset.py b/conda_self/cli/main_reset.py index 724e862..30fda6a 100644 --- a/conda_self/cli/main_reset.py +++ b/conda_self/cli/main_reset.py @@ -1,17 +1,64 @@ from __future__ import annotations import sys +from enum import Enum from pathlib import Path from textwrap import dedent from typing import TYPE_CHECKING +from ..constants import RESET_FILE_BASE_PROTECTION, RESET_FILE_INSTALLER + if TYPE_CHECKING: import argparse - from typing import TypedDict - class SnapshotData(TypedDict): - file_path: Path - snapshot_name: str + +class Snapshot(Enum): + """Snapshot modes accepted by ``conda self reset --snapshot``. + + Plain :class:`enum.Enum` for Python 3.10 compatibility; the string values + double as argparse choices and user-facing mode names. Switch to + :class:`enum.StrEnum` when 3.11 becomes the minimum supported version + (mirrors the TODO on conda's ``EnvironmentFormat``). + """ + + CURRENT = "current" + INSTALLER_EXACT = "installer-exact" + INSTALLER_UPDATED = "installer-updated" + BASE_PROTECTION = "base-protection" + + def __str__(self) -> str: + return self.value + + @property + def display_name(self) -> str: + match self: + case Snapshot.CURRENT: + return "current" + case Snapshot.INSTALLER_EXACT: + return "installer-provided (exact)" + case Snapshot.INSTALLER_UPDATED: + return "installer-provided (with updates)" + case Snapshot.BASE_PROTECTION: + return "base-protection" + + @property + def file_path(self) -> Path | None: + """The ``conda-meta/*.txt`` file this snapshot mode reads, if any.""" + match self: + case Snapshot.INSTALLER_EXACT | Snapshot.INSTALLER_UPDATED: + return Path(sys.prefix, "conda-meta", RESET_FILE_INSTALLER) + case Snapshot.BASE_PROTECTION: + return Path(sys.prefix, "conda-meta", RESET_FILE_BASE_PROTECTION) + case Snapshot.CURRENT: + return None + + +# Tried in order when --snapshot is not provided; the first mode whose file +# exists on disk wins, otherwise we fall through to CURRENT. +FALLBACK_ORDER: tuple[Snapshot, ...] = ( + Snapshot.BASE_PROTECTION, + Snapshot.INSTALLER_UPDATED, +) HELP = "Reset 'base' environment to essential packages only." @@ -20,13 +67,16 @@ class SnapshotData(TypedDict): Snapshot to reset the `base` environment to. `current` removes all packages except for `conda`, its plugins, and their dependencies. - `installer` resets the `base` environment to the snapshot provided - by the installer. - `base-protection` resets the `base` environment to the snapshot saved + `installer-exact` restores the `base` environment to exactly what the + installer shipped (may downgrade packages you have updated). + `installer-updated` keeps the packages the installer shipped at their + currently installed versions (no downgrade). + `base-protection` restores the `base` environment to the snapshot saved by `conda doctor --fix` before protecting base. If not set, `conda self` will try to reset to the base-protection snapshot - first, then to the installer-provided, and finally to the current snapshot. + first, then to the installer-provided (preserving updates), and finally + to the current snapshot. """ ).lstrip() @@ -57,7 +107,8 @@ def configure_parser(parser: argparse.ArgumentParser) -> None: add_output_and_prompt_options(parser) parser.add_argument( "--snapshot", - choices=("current", "installer", "base-protection"), + type=Snapshot, + choices=list(Snapshot), help=SNAPSHOT_HELP, ) parser.set_defaults(func=execute) @@ -67,59 +118,52 @@ def execute(args: argparse.Namespace) -> int: from conda.base.context import context from conda.reporters import confirm_yn - from ..constants import RESET_FILE_BASE_PROTECTION, RESET_FILE_INSTALLER from ..query import permanent_dependencies - from ..reset import reset + from ..reset import names_from_explicit, reset if not context.quiet: print(WHAT_TO_EXPECT) - reset_data: dict[str, SnapshotData] = { - "installer": { - "file_path": Path(sys.prefix, "conda-meta", RESET_FILE_INSTALLER), - "snapshot_name": "installer-provided", - }, - "base-protection": { - "file_path": Path(sys.prefix, "conda-meta", RESET_FILE_BASE_PROTECTION), - "snapshot_name": "base-protection", - }, - } - + snapshot: Snapshot | None = args.snapshot reset_file: Path | None = None - snapshot_name = "" - if not args.snapshot: - for snapshot in ("base-protection", "installer"): - snapshot_data = reset_data[snapshot] - if not snapshot_data["file_path"].exists(): - continue - reset_file = snapshot_data["file_path"] - snapshot_name = snapshot_data["snapshot_name"] - break - elif args.snapshot in reset_data: - reset_file = reset_data[args.snapshot]["file_path"] - snapshot_name = reset_data[args.snapshot]["snapshot_name"] - - if reset_file and not reset_file.exists(): + + if snapshot is not None: + reset_file = snapshot.file_path + else: + for fallback in FALLBACK_ORDER: + candidate = fallback.file_path + if candidate is not None and candidate.exists(): + snapshot = fallback + reset_file = candidate + break + + if reset_file is not None and not reset_file.exists(): raise FileNotFoundError( - f"Failed to reset to `{args.snapshot}`.\n" - f"Required file {reset_file} not found." + f"Failed to reset to `{snapshot}`.\nRequired file {reset_file} not found." ) prompt = "Proceed with resetting your 'base' environment" - if snapshot_name: - prompt += f" to the {snapshot_name} snapshot" + if snapshot is not None: + prompt += f" to the {snapshot.display_name} snapshot" confirm_yn(f"{prompt}?[y/n]:\n", default="no", dry_run=context.dry_run) if not context.quiet: print("Resetting 'base' environment...") - uninstallable_packages = ( - permanent_dependencies(add_plugins=True) if not reset_file else set() - ) - reset(uninstallable_packages=uninstallable_packages, snapshot=reset_file) + + match snapshot: + case Snapshot.INSTALLER_UPDATED if reset_file is not None: + keep = permanent_dependencies(add_plugins=True) | names_from_explicit( + reset_file + ) + reset(uninstallable_packages=keep) + case Snapshot.INSTALLER_EXACT | Snapshot.BASE_PROTECTION: + reset(snapshot=reset_file) + case _: + reset(uninstallable_packages=permanent_dependencies(add_plugins=True)) if not context.quiet: - if snapshot_name: - print(SUCCESS_SNAPSHOT.format(snapshot_name=snapshot_name)) + if snapshot is not None: + print(SUCCESS_SNAPSHOT.format(snapshot_name=snapshot.display_name)) else: print(SUCCESS) diff --git a/conda_self/health_checks/base_protection.py b/conda_self/health_checks/base_protection.py index 0e797a5..30b3fe1 100644 --- a/conda_self/health_checks/base_protection.py +++ b/conda_self/health_checks/base_protection.py @@ -75,9 +75,9 @@ def fix(prefix: str, args: Namespace, confirm: ConfirmCallback) -> int: from conda.misc import clone_env from conda.models.environment import Environment - from ..constants import RESET_FILE_BASE_PROTECTION + from ..constants import RESET_FILE_BASE_PROTECTION, RESET_FILE_INSTALLER from ..query import permanent_dependencies - from ..reset import reset + from ..reset import names_from_explicit, reset default_env = DEFAULT_ENV_NAME message = "Protected by Base Environment Protection health fix" @@ -98,8 +98,10 @@ def fix(prefix: str, args: Namespace, confirm: ConfirmCallback) -> int: ) confirm("Proceed?") - # Get packages to keep in base - uninstallable_packages = permanent_dependencies() + # Prefer the installer snapshot for resetting base so that + # installer-provided packages (e.g. mamba in Miniforge) are preserved. + installer_snapshot = base_prefix / "conda-meta" / RESET_FILE_INSTALLER + use_snapshot = installer_snapshot.exists() # Check destination environment dest_prefix_data = PrefixData.from_name(default_env) @@ -136,7 +138,11 @@ def fix(prefix: str, args: Namespace, confirm: ConfirmCallback) -> int: verbose=False, quiet=True, ) - reset(uninstallable_packages=uninstallable_packages) + if use_snapshot: + keep = permanent_dependencies() | names_from_explicit(installer_snapshot) + reset(uninstallable_packages=keep) + else: + reset(uninstallable_packages=permanent_dependencies()) # Freeze base try: diff --git a/conda_self/package_info.py b/conda_self/package_info.py index 42d9891..a3626ec 100644 --- a/conda_self/package_info.py +++ b/conda_self/package_info.py @@ -21,37 +21,37 @@ class CaseSensitiveConfigParser(configparser.ConfigParser): optionxform = staticmethod(str) # type: ignore -def _file_paths_from_extracted_package(pkg_dir: str) -> list[str]: - """Read file paths from an extracted package directory. - - Tries info/paths.json first (canonical per CEP), then falls back - to info/files (deprecated legacy format). - """ - pkg_path = Path(pkg_dir) - - paths_json = pkg_path / "info/paths.json" - if paths_json.is_file(): - data = json.loads(paths_json.read_text()) - return [entry["_path"] for entry in data.get("paths", [])] - - try: - return (pkg_path / "info/files").read_text().splitlines() - except FileNotFoundError: - return [] - - class PackageInfo: def __init__(self, dist_info_path: Path): """Describe the dist-info for a Python package installed as a conda package""" self.dist_info_path = dist_info_path + @classmethod + def read_manifest(cls, pkg_dir: str) -> list[str]: + """Read file paths from an extracted package directory. + + Tries info/paths.json first (canonical per CEP), then falls back + to info/files (deprecated legacy format). + """ + pkg_path = Path(pkg_dir) + + paths_json = pkg_path / "info" / "paths.json" + if paths_json.is_file(): + data = json.loads(paths_json.read_text()) + return [entry["_path"] for entry in data.get("paths", [])] + + try: + return (pkg_path / "info" / "files").read_text().splitlines() + except FileNotFoundError: + return [] + @classmethod def from_record( cls, record: PrefixRecord | PackageCacheRecord ) -> list[PackageInfo]: had_manifest = True if not (paths := getattr(record, "files", None)): - paths = _file_paths_from_extracted_package(record.extracted_package_dir) + paths = cls.read_manifest(record.extracted_package_dir) had_manifest = bool(paths) dist_infos = set() for path in paths: diff --git a/conda_self/reset.py b/conda_self/reset.py index f92e567..9069381 100644 --- a/conda_self/reset.py +++ b/conda_self/reset.py @@ -4,17 +4,32 @@ from typing import TYPE_CHECKING from boltons.setutils import IndexedSet +from conda.base.constants import EXPLICIT_MARKER from conda.base.context import context from conda.core.link import PrefixSetup, UnlinkLinkTransaction from conda.core.prefix_data import PrefixData from conda.core.solve import diff_for_unlink_link_precs from conda.gateways.disk.read import yield_lines from conda.misc import get_package_records_from_explicit +from conda.models.match_spec import MatchSpec if TYPE_CHECKING: from pathlib import Path +def names_from_explicit(path: Path) -> set[str]: + """Extract package names from a CEP-23 ``@EXPLICIT`` file without fetching. + + Parses each URL line with :class:`~conda.models.match_spec.MatchSpec`, + which reads ``name``/``version``/``build`` from the tarball filename and + strips any ``#md5=…``/``#sha256=…`` checksum fragment as a comment. No + network access, unlike :func:`conda.misc.get_package_records_from_explicit`. + """ + return { + MatchSpec(line).name for line in yield_lines(path) if line != EXPLICIT_MARKER + } + + def reset( prefix: str = sys.prefix, uninstallable_packages: set[str] = set(), diff --git a/news/121-installer-updated b/news/121-installer-updated new file mode 100644 index 0000000..8af0513 --- /dev/null +++ b/news/121-installer-updated @@ -0,0 +1,20 @@ +### Enhancements + +* Split `--snapshot installer` into `--snapshot installer-exact` (restores exact installer state, may downgrade) and `--snapshot installer-updated` (keeps installer-shipped packages at currently installed versions, no downgrade). (#121) +* Auto-fallback now prefers `installer-updated` over `installer-exact` when no `--snapshot` is specified. (#121) + +### Bug fixes + +* `conda doctor base-protection --fix` no longer downgrades installer-provided packages like `mamba` in Miniforge installs. (#121) + +### Deprecations + +* + +### Docs + +* + +### Other + +* diff --git a/tests/test_cli_reset.py b/tests/test_cli_reset.py index 871b36c..f727d59 100644 --- a/tests/test_cli_reset.py +++ b/tests/test_cli_reset.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from contextlib import redirect_stdout from typing import TYPE_CHECKING @@ -7,7 +8,11 @@ from conda.base.constants import PREFIX_FROZEN_FILE from conda.cli.main_list import print_explicit -from conda_self.constants import RESET_FILE_BASE_PROTECTION +from conda_self.cli.main_reset import Snapshot +from conda_self.constants import ( + RESET_FILE_BASE_PROTECTION, + RESET_FILE_INSTALLER, +) from conda_self.testing import conda_cli_subprocess, is_installed if TYPE_CHECKING: @@ -17,11 +22,332 @@ from pytest import MonkeyPatch +INSTALLER_SNAPSHOT_CONTENT = ( + "# platform: linux-64\n" + "@EXPLICIT\n" + "https://conda.anaconda.org/conda-forge/linux-64/" + "mamba-1.5.3-py311h3072747_1.conda#abc\n" + "https://conda.anaconda.org/conda-forge/linux-64/" + "pip-24.0-pyhd8ed1ab_0.conda#def\n" +) + + +class FakeRecord: + def __init__(self, name: str): + self.name = name + + +@pytest.fixture +def reset_calls(): + return [] + + +@pytest.fixture +def perm_deps_calls(): + return [] + + +@pytest.fixture +def fake_reset_env( + tmp_path: Path, + monkeypatch: MonkeyPatch, + reset_calls: list, + perm_deps_calls: list, +): + conda_meta = tmp_path / "conda-meta" + conda_meta.mkdir() + monkeypatch.setattr(sys, "prefix", str(tmp_path)) + + def fake_reset(**kwargs): + reset_calls.append(kwargs) + + def fake_perm_deps(**kwargs): + perm_deps_calls.append(kwargs) + return {"conda", "conda-self"} + + monkeypatch.setattr("conda.base.context.context.quiet", True, raising=False) + monkeypatch.setattr("conda_self.reset.reset", fake_reset) + monkeypatch.setattr("conda_self.query.permanent_dependencies", fake_perm_deps) + return tmp_path + + +@pytest.fixture +def stub_transaction(monkeypatch: MonkeyPatch): + """Stub ``conda_self.reset.reset``'s disk dependencies. + + Returns a dict that, after ``reset()`` runs, is populated with the kwargs + that would have been passed to ``PrefixSetup`` (``unlink_precs`` / + ``link_precs`` in particular). Tests seed ``captured["installed"]`` with + a list of ``FakeRecord`` instances to drive ``PrefixData.iter_records``. + """ + captured: dict = {} + + class StubPrefixData: + def __init__(self, *args, **kwargs): + pass + + def iter_records(self): + return iter(captured.get("installed", [])) + + def stub_prefix_setup(**kwargs): + captured.update(kwargs) + return object() + + class StubTxn: + def __init__(self, stp): + pass + + def print_transaction_summary(self): + pass + + def execute(self): + pass + + monkeypatch.setattr("conda_self.reset.PrefixData", StubPrefixData) + monkeypatch.setattr("conda_self.reset.PrefixSetup", stub_prefix_setup) + monkeypatch.setattr("conda_self.reset.UnlinkLinkTransaction", StubTxn) + return captured + + def test_help(conda_cli: CondaCLIFixture): out, err, exc = conda_cli("self", "reset", "--help", raises=SystemExit) assert exc.value.code == 0 +@pytest.mark.parametrize("choice", [s.value for s in Snapshot]) +def test_help_shows_snapshot_choices(conda_cli: CondaCLIFixture, choice: str): + out, err, exc = conda_cli("self", "reset", "--help", raises=SystemExit) + assert choice in out + + +@pytest.mark.parametrize( + "bad_value", + ["installer", "totally-bogus", ""], + ids=["bare-installer", "bogus", "empty"], +) +def test_invalid_snapshot_value_rejected(conda_cli: CondaCLIFixture, bad_value: str): + out, err, exc = conda_cli( + "self", "reset", "--snapshot", bad_value, raises=SystemExit + ) + assert exc.value.code != 0 + + +@pytest.mark.parametrize( + "snapshot_arg, expected_snapshot_file, expected_names", + [ + ("installer-exact", RESET_FILE_INSTALLER, None), + ("installer-updated", None, {"mamba", "pip", "conda", "conda-self"}), + ("current", None, {"conda", "conda-self"}), + ], + ids=["installer-exact", "installer-updated", "current"], +) +def test_snapshot_dispatch( + conda_cli: CondaCLIFixture, + fake_reset_env: Path, + reset_calls: list, + snapshot_arg: str, + expected_snapshot_file: str | None, + expected_names: set[str] | None, +): + installer_snapshot = fake_reset_env / "conda-meta" / RESET_FILE_INSTALLER + installer_snapshot.write_text(INSTALLER_SNAPSHOT_CONTENT) + + conda_cli("self", "reset", "--yes", "--snapshot", snapshot_arg) + + assert len(reset_calls) == 1 + call = reset_calls[0] + if expected_snapshot_file: + assert call["snapshot"] == ( + fake_reset_env / "conda-meta" / expected_snapshot_file + ) + else: + assert "snapshot" not in call + if expected_names is not None: + assert expected_names <= call["uninstallable_packages"] + + +def test_installer_exact_missing_file_raises( + conda_cli: CondaCLIFixture, fake_reset_env: Path +): + conda_cli( + "self", + "reset", + "--yes", + "--snapshot", + "installer-exact", + raises=FileNotFoundError, + ) + + +@pytest.mark.parametrize( + "snapshots_present, expected_snapshot_file, expected_names", + [ + ( + ("base-protection", "installer"), + RESET_FILE_BASE_PROTECTION, + None, + ), + ( + ("installer",), + None, + {"mamba", "pip", "conda", "conda-self"}, + ), + ( + (), + None, + {"conda", "conda-self"}, + ), + ], + ids=[ + "prefers-base-protection", + "installer-updated-when-no-bp", + "current-when-no-snapshots", + ], +) +def test_fallback_ordering( + conda_cli: CondaCLIFixture, + fake_reset_env: Path, + reset_calls: list, + snapshots_present: tuple[str, ...], + expected_snapshot_file: str | None, + expected_names: set[str] | None, +): + if "base-protection" in snapshots_present: + bp = fake_reset_env / "conda-meta" / RESET_FILE_BASE_PROTECTION + bp.write_text(INSTALLER_SNAPSHOT_CONTENT) + if "installer" in snapshots_present: + inst = fake_reset_env / "conda-meta" / RESET_FILE_INSTALLER + inst.write_text(INSTALLER_SNAPSHOT_CONTENT) + + conda_cli("self", "reset", "--yes") + + assert len(reset_calls) == 1 + call = reset_calls[0] + if expected_snapshot_file: + assert call["snapshot"] == ( + fake_reset_env / "conda-meta" / expected_snapshot_file + ) + else: + assert "snapshot" not in call + if expected_names is not None: + assert expected_names <= call["uninstallable_packages"] + + +@pytest.mark.parametrize( + "snapshot, display_name", + [ + (Snapshot.CURRENT, "current"), + (Snapshot.INSTALLER_EXACT, "installer-provided (exact)"), + (Snapshot.INSTALLER_UPDATED, "installer-provided (with updates)"), + (Snapshot.BASE_PROTECTION, "base-protection"), + ], + ids=[s.value for s in Snapshot], +) +def test_snapshot_display_name(snapshot: Snapshot, display_name: str): + assert snapshot.display_name == display_name + + +@pytest.mark.parametrize( + "snapshot, expected_filename", + [ + (Snapshot.CURRENT, None), + (Snapshot.INSTALLER_EXACT, RESET_FILE_INSTALLER), + (Snapshot.INSTALLER_UPDATED, RESET_FILE_INSTALLER), + (Snapshot.BASE_PROTECTION, RESET_FILE_BASE_PROTECTION), + ], + ids=[s.value for s in Snapshot], +) +def test_snapshot_file_path( + snapshot: Snapshot, + expected_filename: str | None, + monkeypatch: MonkeyPatch, + tmp_path: Path, +): + monkeypatch.setattr(sys, "prefix", str(tmp_path)) + if expected_filename is None: + assert snapshot.file_path is None + else: + assert snapshot.file_path == tmp_path / "conda-meta" / expected_filename + + +@pytest.mark.parametrize( + "installed_names, keep, expected_to_remove", + [ + ( + ["conda", "conda-self", "conda-rattler-solver", "numpy", "python"], + { + "conda", + "conda-self", + "python", + "conda-libmamba-solver", + "pip", + "conda-rattler-solver", + }, + ["numpy"], + ), + ( + [ + "conda", + "conda-self", + "python", + "conda-libmamba-solver", + "conda-rattler-solver", + "scipy", + ], + { + "conda", + "conda-self", + "python", + "conda-libmamba-solver", + "pip", + "conda-rattler-solver", + }, + ["scipy"], + ), + ( + [ + "conda", + "conda-self", + "python", + "conda-libmamba-solver", + "pip", + "numpy", + ], + { + "conda", + "conda-self", + "python", + "conda-libmamba-solver", + "pip", + "conda-rattler-solver", + }, + ["numpy"], + ), + ], + ids=[ + "replaced-libmamba-with-rattler", + "both-solvers-installed", + "pristine-plus-numpy", + ], +) +def test_reset_uninstallable_never_installs( + stub_transaction: dict, + installed_names: list[str], + keep: set[str], + expected_to_remove: list[str], +): + from conda_self.reset import reset + + stub_transaction["installed"] = [FakeRecord(n) for n in installed_names] + + reset(prefix="/fake", uninstallable_packages=keep) + + assert sorted(r.name for r in stub_transaction["unlink_precs"]) == ( + expected_to_remove + ) + assert list(stub_transaction["link_precs"]) == [] + + def test_reset( conda_cli: CondaCLIFixture, monkeypatch: MonkeyPatch, @@ -79,7 +405,12 @@ def test_reset_base_protection( assert is_installed(prefix, "conda") assert not is_installed(prefix, f"conda={conda_version}"), "conda not updated" conda_cli( - "install", "constructor", "--override-frozen", "--yes", "--prefix", prefix + "install", + "constructor", + "--override-frozen", + "--yes", + "--prefix", + prefix, ) assert is_installed(prefix, "constructor") diff --git a/tests/test_health_check_base_protection.py b/tests/test_health_check_base_protection.py index 7c77b45..a68cfac 100644 --- a/tests/test_health_check_base_protection.py +++ b/tests/test_health_check_base_protection.py @@ -9,8 +9,11 @@ import pytest from conda.base.constants import PREFIX_FROZEN_FILE from conda.core.prefix_data import PrefixData +from conda.exceptions import CondaValueError +from conda_self.constants import RESET_FILE_INSTALLER from conda_self.health_checks import base_protection +from conda_self.plugin import conda_health_checks if TYPE_CHECKING: from pathlib import Path @@ -18,10 +21,35 @@ from pytest import CaptureFixture, MonkeyPatch +class _FakePrefixData: + def __init__(self, tmp_path: Path): + self.prefix_path = tmp_path / "envs" / "default" + + def is_environment(self) -> bool: + return False + + def exists(self) -> bool: + return False + + +class _FakeEnvironment: + external_packages: list = [] + + +class _FakeConfigFile: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def set_key(self, key, value): + pass + + @pytest.fixture def fake_base_env(tmp_path: Path, monkeypatch: MonkeyPatch) -> Path: """Create a fake base environment by patching sys.prefix.""" - # Create conda-meta directory to make it look like a conda env conda_meta = tmp_path / "conda-meta" conda_meta.mkdir() monkeypatch.setattr(sys, "prefix", str(tmp_path)) @@ -36,138 +64,209 @@ def protected_base_env(fake_base_env: Path) -> Path: return fake_base_env -# -- is_base_environment tests -- - - -def test_is_base_environment_returns_true_for_base(): - """Returns True when prefix matches sys.prefix.""" - assert base_protection.is_base_environment(sys.prefix) is True - - -def test_is_base_environment_returns_false_for_other_prefix(tmp_path: Path): - """Returns False when prefix doesn't match sys.prefix.""" - assert base_protection.is_base_environment(str(tmp_path)) is False - - -# -- is_base_protected tests -- +@pytest.fixture +def reset_calls(): + return [] -def test_is_base_protected_returns_true_when_frozen(protected_base_env: Path): - """Returns True when PREFIX_FROZEN_FILE exists.""" - # Clear the PrefixData cache to pick up our patched sys.prefix - PrefixData._cache_.clear() - assert base_protection.is_base_protected() is True +@pytest.fixture +def perm_deps_calls(): + return [] -def test_is_base_protected_returns_false_when_not_frozen(fake_base_env: Path): - """Returns False when PREFIX_FROZEN_FILE doesn't exist.""" +@pytest.fixture +def fixable_base_env( + fake_base_env: Path, + monkeypatch: MonkeyPatch, + reset_calls: list, + perm_deps_calls: list, +): + """Fake base env wired with stubs for fix() to run.""" PrefixData._cache_.clear() - assert base_protection.is_base_protected() is False - - -# -- check action tests -- + def fake_reset(**kwargs): + reset_calls.append(kwargs) + + def fake_perm_deps(**kwargs): + perm_deps_calls.append(kwargs) + return {"conda", "conda-self"} + + def fake_get_exporter(fmt): + raise CondaValueError("no exporter") + + monkeypatch.setattr("conda.base.context.context.quiet", True, raising=False) + monkeypatch.setattr( + "conda.base.context.context.plugin_manager.get_environment_exporter_by_format", + fake_get_exporter, + ) + monkeypatch.setattr("conda_self.reset.reset", fake_reset) + monkeypatch.setattr("conda.misc.clone_env", lambda *a, **kw: None) + monkeypatch.setattr( + "conda.models.environment.Environment.from_prefix", + lambda *a, **kw: _FakeEnvironment(), + ) + monkeypatch.setattr("conda_self.query.permanent_dependencies", fake_perm_deps) + monkeypatch.setattr( + PrefixData, + "from_name", + lambda *a, **kw: _FakePrefixData(fake_base_env), + ) + monkeypatch.setattr( + "conda.cli.condarc.ConfigurationFile.from_user_condarc", + _FakeConfigFile, + ) -def test_check_skips_non_base_environment(tmp_path: Path, capsys: CaptureFixture): - """Prints skip message when not on base environment.""" - base_protection.check(str(tmp_path), False) - - captured = capsys.readouterr() - assert "Skipping" in captured.out - assert "not running on base environment" in captured.out + return fake_base_env -def test_check_reports_protected_base(protected_base_env: Path, capsys: CaptureFixture): - """Reports OK when base is protected.""" +@pytest.mark.parametrize( + "use_base, expected", + [ + (True, True), + (False, False), + ], + ids=["base", "other"], +) +def test_is_base_environment(tmp_path: Path, use_base: bool, expected: bool): + prefix = sys.prefix if use_base else str(tmp_path) + assert base_protection.is_base_environment(prefix) is expected + + +@pytest.mark.parametrize( + "frozen, expected", + [ + (True, True), + (False, False), + ], + ids=["frozen", "not-frozen"], +) +def test_is_base_protected(fake_base_env: Path, frozen: bool, expected: bool): + if frozen: + (fake_base_env / PREFIX_FROZEN_FILE).write_text("{}") PrefixData._cache_.clear() - base_protection.check(str(protected_base_env), False) - - captured = capsys.readouterr() - assert "protected" in captured.out.lower() - assert "not protected" not in captured.out.lower() - - -def test_check_reports_unprotected_base(fake_base_env: Path, capsys: CaptureFixture): - """Reports X when base is not protected.""" + assert base_protection.is_base_protected() is expected + + +@pytest.mark.parametrize( + "env_fixture, expected_output, unexpected_output", + [ + ("tmp_path", "skipping", None), + ("protected_base_env", "protected", "not protected"), + ("fake_base_env", "not protected", None), + ], + ids=["non-base", "protected", "unprotected"], +) +def test_check( + env_fixture: str, + expected_output: str, + unexpected_output: str | None, + request: pytest.FixtureRequest, + capsys: CaptureFixture, +): + prefix = str(request.getfixturevalue(env_fixture)) PrefixData._cache_.clear() - base_protection.check(str(fake_base_env), False) - - captured = capsys.readouterr() - assert "not protected" in captured.out.lower() - assert "conda doctor --fix" in captured.out - - -# -- fix fixer tests -- - - -def test_fix_skips_non_base_environment(tmp_path: Path, capsys: CaptureFixture): - """Skips when not on base environment.""" - args = Namespace() - confirm_called = [] - - def confirm(msg: str) -> None: - confirm_called.append(msg) - - result = base_protection.fix(str(tmp_path), args, confirm) + base_protection.check(prefix, False) - assert result == 0 captured = capsys.readouterr() - assert "Skipping" in captured.out - assert len(confirm_called) == 0 - - -def test_fix_skips_already_protected(protected_base_env: Path, capsys: CaptureFixture): - """Returns early when base is already protected.""" - args = Namespace() - confirm_called = [] - - def confirm(msg: str) -> None: - confirm_called.append(msg) + assert expected_output in captured.out.lower() + if unexpected_output: + assert unexpected_output not in captured.out.lower() + + +@pytest.mark.parametrize( + "env_fixture, expected_output", + [ + ("tmp_path", "Skipping"), + ("protected_base_env", "already protected"), + ], + ids=["non-base", "already-protected"], +) +def test_fix_skips( + env_fixture: str, + expected_output: str, + request: pytest.FixtureRequest, + capsys: CaptureFixture, +): + prefix = str(request.getfixturevalue(env_fixture)) + confirm_called: list[str] = [] PrefixData._cache_.clear() - result = base_protection.fix(str(protected_base_env), args, confirm) + result = base_protection.fix(prefix, Namespace(), confirm_called.append) assert result == 0 - captured = capsys.readouterr() - assert "already protected" in captured.out - assert len(confirm_called) == 0 + assert expected_output in capsys.readouterr().out + assert confirm_called == [] def test_fix_calls_confirm_callback(fake_base_env: Path): - """Calls confirm callback when proceeding with fix.""" - args = Namespace() - confirm_called = [] + confirm_called: list[str] = [] class UserCancelled(Exception): - """Raised when user cancels.""" + pass def confirm(msg: str) -> None: confirm_called.append(msg) - # Simulate user cancellation to stop execution after first confirm raise UserCancelled() PrefixData._cache_.clear() with pytest.raises(UserCancelled): - base_protection.fix(str(fake_base_env), args, confirm) + base_protection.fix(str(fake_base_env), Namespace(), confirm) assert confirm_called == ["Proceed?"] -# -- plugin registration tests -- +@pytest.mark.parametrize( + "create_snapshot, expect_names_only, expected_keep", + [ + ( + True, + True, + {"conda", "conda-self", "mamba", "pip"}, + ), + ( + False, + True, + {"conda", "conda-self"}, + ), + ], + ids=[ + "with-installer-snapshot", + "without-installer-snapshot", + ], +) +def test_fix_reset_strategy( + fixable_base_env: Path, + reset_calls: list, + perm_deps_calls: list, + create_snapshot: bool, + expect_names_only: bool, + expected_keep: set[str], +): + if create_snapshot: + snapshot = fixable_base_env / "conda-meta" / RESET_FILE_INSTALLER + snapshot.write_text( + "@EXPLICIT\n" + "https://conda.anaconda.org/conda-forge/noarch/" + "mamba-1.5.0-pyh_0.conda#md5=0123456789abcdef0123456789abcdef\n" + "https://conda.anaconda.org/conda-forge/noarch/" + "pip-24.0-pyhd8ed1ab_0.conda\n" + ) + + base_protection.fix(str(fixable_base_env), Namespace(), lambda msg: None) + + assert len(reset_calls) == 1 + assert "snapshot" not in reset_calls[0] + assert expected_keep <= reset_calls[0]["uninstallable_packages"] + assert len(perm_deps_calls) == 1 def test_health_check_registered(): - """Verify the health check is registered correctly.""" - - from conda_self.plugin import conda_health_checks - health_checks = list(conda_health_checks()) assert len(health_checks) == 1 hc = health_checks[0] assert hc.name == "base-protection" assert hc.action == base_protection.check - assert hc.fixer == base_protection.fix assert hc.summary is not None assert hc.fix is not None diff --git a/tests/test_package_info.py b/tests/test_package_info.py index 5369e69..218ae2b 100644 --- a/tests/test_package_info.py +++ b/tests/test_package_info.py @@ -30,7 +30,7 @@ def _make(files=None): def test_finds_dist_info_via_record_files(tmp_path, cache_record): - dist_info = tmp_path / "site-packages/pkg-1.0.dist-info" + dist_info = tmp_path / "site-packages" / "pkg-1.0.dist-info" dist_info.mkdir(parents=True) (dist_info / "entry_points.txt").write_text("[conda]\nplugin = pkg.plugin\n") @@ -49,7 +49,7 @@ def test_finds_dist_info_via_record_files(tmp_path, cache_record): @pytest.mark.parametrize("manifest", ["info_files", "paths_json"]) def test_finds_dist_info_via_manifest(tmp_path, cache_record, manifest): - dist_info = tmp_path / "site-packages/pkg-1.0.dist-info" + dist_info = tmp_path / "site-packages" / "pkg-1.0.dist-info" dist_info.mkdir(parents=True) (dist_info / "entry_points.txt").write_text("[conda]\nplugin = pkg.plugin\n") @@ -89,7 +89,7 @@ def test_finds_dist_info_via_manifest(tmp_path, cache_record, manifest): def test_paths_json_takes_precedence_over_info_files(tmp_path, cache_record): """info/paths.json is the canonical source per the conda package CEP.""" - dist_info = tmp_path / "site-packages/real-1.0.dist-info" + dist_info = tmp_path / "site-packages" / "real-1.0.dist-info" dist_info.mkdir(parents=True) (dist_info / "entry_points.txt").write_text("[conda]\nplugin = real.p\n")