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
136 changes: 90 additions & 46 deletions conda_self/cli/main_reset.py
Original file line number Diff line number Diff line change
@@ -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."
Expand All @@ -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()

Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down
16 changes: 11 additions & 5 deletions conda_self/health_checks/base_protection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Comment thread
jezdez marked this conversation as resolved.
use_snapshot = installer_snapshot.exists()

# Check destination environment
dest_prefix_data = PrefixData.from_name(default_env)
Expand Down Expand Up @@ -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:
Expand Down
40 changes: 20 additions & 20 deletions conda_self/package_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions conda_self/reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
20 changes: 20 additions & 0 deletions news/121-installer-updated
Original file line number Diff line number Diff line change
@@ -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

* <news item>

### Docs

* <news item>

### Other

* <news item>
Loading