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
52 changes: 30 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,25 @@ Commands to manage your `base` environment safely.

## `conda self`

Manage your conda 'base' environment safely.
Manage conda and its plugins in the base environment.

```
$ conda self
usage: conda self [-V] [-h] {install,remove,reset,update} ...

Manage your conda 'base' environment safely.
Manage conda and its plugins in the base environment.

options:
-V, --version Show the 'conda-self' version number and exit.
-h, --help Show this help message and exit.

subcommands:
{install,remove,reset,update}
install Add conda plugins to the 'base' environment.
remove Remove conda plugins from the 'base' environment.
reset Reset 'base' environment to essential packages only.
update Update 'conda' and/or its plugins in the 'base' environment.
install Install conda plugins in the base environment.
remove Remove conda plugins from the base environment.
reset Reset the base environment.
update Update conda, one conda plugin, or all packages in the
base environment.
```

### Custom channels
Expand All @@ -38,27 +39,32 @@ conda self install my-plugin
This keeps channel configuration consistent across install, update, and
dependency resolution.

Inline channel specs (e.g. `conda-forge::my-plugin`) are not supported and
will result in an error.
Channel-qualified package specs (e.g. `conda-forge::my-plugin`) are not
supported and will result in an error.

## Base Environment Protection

To check if your base environment is protected, run:

```
conda doctor base-protection
conda doctor -n base base-protection
```

To protect your base environment, run:

```
conda doctor base-protection --fix
conda doctor -n base base-protection --fix
```

This will:
1. Clone your current base environment to a new "default" environment
2. Reset base to essential packages only
3. Freeze the base environment to prevent modifications

1. Try to save a snapshot of base in conda's explicit format
2. Clone your current base environment to a new "default" environment
3. Remove conda packages other than conda, conda-self, configured permanent
packages, their dependencies, and installed conda packages named in an
available installer snapshot
4. Mark the base environment as frozen so conda refuses modifications by
default

To see all available health checks, run:

Expand All @@ -68,24 +74,26 @@ conda doctor --list

### Unprotecting base

To remove protection entirely, delete the frozen file:
To remove protection entirely, delete the `conda-meta/frozen` environment
marker file:

```
rm $CONDA_PREFIX/conda-meta/frozen
rm "$(conda info --base)/conda-meta/frozen"
```

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`.
To bypass protection for a single command, pass `--override-frozen`. To disable
frozen-environment checks through configuration, set
`CONDA_PROTECT_FROZEN_ENVS=false` or add `protect_frozen_envs: false` 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`:
The `current` and `installer-updated` reset modes retain `conda`, `conda-self`,
installed conda plugins, configured permanent packages, and their dependencies.
To configure additional permanent packages, add them to the
`plugins.self_permanent_packages` setting in your `.condarc`:

```yaml
plugins:
Expand Down
10 changes: 5 additions & 5 deletions conda_self/cli/main_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
if TYPE_CHECKING:
import argparse

HELP = "Add conda plugins to the 'base' environment."
HELP = "Install conda plugins in the base environment."


def configure_parser(parser: argparse.ArgumentParser) -> None:
Expand All @@ -16,9 +16,9 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--force-reinstall",
action="store_true",
help="Reinstall plugin even if it's already installed.",
help="Reinstall each requested package even if it is already installed.",
)
parser.add_argument("specs", nargs="+", help="Plugins to install")
parser.add_argument("specs", nargs="+", help="Conda plugins to install")
parser.set_defaults(func=execute)


Expand All @@ -40,11 +40,11 @@ def execute(args: argparse.Namespace) -> int:
if specs_with_channels:
joined = ", ".join(specs_with_channels)
raise CondaValueError(
f"Channel specifications are not supported: {joined}\n"
f"Channel-qualified package specs are not supported: {joined}\n"
"Configure channels via `conda config --add channels <channel>` instead."
)

print("Installing plugins:", *args.specs)
print("Installing packages:", *args.specs)

returncode = install_specs_in_protected_env(
args.specs,
Expand Down
17 changes: 8 additions & 9 deletions conda_self/cli/main_remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
if TYPE_CHECKING:
import argparse

HELP = "Remove conda plugins from the 'base' environment."
HELP = "Remove conda plugins from the base environment."


def configure_parser(parser: argparse.ArgumentParser) -> None:
Expand All @@ -18,12 +18,12 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
"--force",
action="store_true",
help=(
"Remove packages even when they are listed as permanent "
"(hard-coded or via `self_permanent_packages`). "
"Confirmation is still required unless `--yes` is passed."
"Bypass conda-self's check for permanent packages and their "
"dependencies. Conda still applies its own transaction checks. "
"Confirmation is still required unless --yes is passed."
),
)
parser.add_argument("specs", nargs="+", help="Plugins to remove/uninstall")
parser.add_argument("specs", nargs="+", help="Conda plugins to remove")
parser.set_defaults(func=execute)


Expand All @@ -43,16 +43,15 @@ def execute(args: argparse.Namespace) -> int:

if protected_specs:
print(
"Warning: the following packages are configured as permanent "
"and will be removed because `--force` was passed:",
"Warning: --force bypassed conda-self's check for the following packages:",
", ".join(protected_specs),
file=sys.stderr,
)

print("Removing plugins:", *args.specs)
print("Removing packages:", *args.specs)

confirm_yn(
"Proceed with removing plugins?[y/n]:\n",
"Proceed with removing packages?[y/n]:\n",
default="no",
dry_run=context.dry_run,
)
Expand Down
61 changes: 32 additions & 29 deletions conda_self/cli/main_reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
class Snapshot(Enum):
"""Snapshot modes accepted by ``conda self reset --snapshot``.

Plain :class:`enum.Enum` for Python 3.10 compatibility; the string values
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``).
Expand Down Expand Up @@ -56,49 +56,52 @@ def file_path(self) -> Path | None:
return None


# Tried in order when --snapshot is not provided; the first mode whose file
# 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."
HELP = "Reset the base environment."
SNAPSHOT_HELP = dedent(
"""
Snapshot to reset the `base` environment to.
`current` removes all packages except for `conda`, its plugins,
and their dependencies.
`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.
Reset mode for the base environment.
`current` removes all conda packages except for `conda`, `conda-self`,
installed conda plugins, configured permanent packages, and their
dependencies.
`installer-exact` restores exactly the conda packages recorded by the
installer and may downgrade updated packages.
`installer-updated` retains the packages kept by `current` and currently
installed conda packages whose names appear in the installer snapshot. It
does not update packages or install missing packages.
`base-protection` restores exactly the conda packages recorded by
`conda doctor -n base base-protection --fix` before protecting base.
The old `installer` spelling is rejected with migration guidance. Choose
`installer-exact` or `installer-updated` explicitly.

If not set, `conda self` will try to reset to the base-protection snapshot
first, then to the installer-provided (preserving updates), and finally
to the current snapshot.
If not set, `conda self` selects `base-protection` when its snapshot file
exists, otherwise `installer-updated` when the installer snapshot file
exists, and otherwise `current`.
"""
).lstrip()

WHAT_TO_EXPECT_ESSENTIALS = dedent(
WHAT_TO_EXPECT_CURRENT = dedent(
"""
This will reset your 'base' to ONLY contain 'conda', its plugins,
and their dependencies.
This resets the base environment to keep conda, conda-self, installed conda
plugins, configured permanent packages, and their dependencies.
All other conda packages are removed.
"""
).lstrip()
WHAT_TO_EXPECT_SNAPSHOT = dedent(
"""
This resets your 'base' to the {snapshot_name} snapshot
and removes any packages outside of it.
This resets the base environment using the '{mode_name}' mode
and removes conda packages not retained by that mode.
"""
).lstrip()
SUCCESS = "Reset the 'base' environment to only the essential packages and plugins.\n"
SUCCESS_SNAPSHOT = "Reset the 'base' environment to {snapshot_name} snapshot.\n"
SUCCESS = "Reset the base environment using the 'current' mode.\n"
SUCCESS_SNAPSHOT = "Reset the base environment using the '{mode_name}' mode.\n"


def configure_parser(parser: argparse.ArgumentParser) -> None:
Expand Down Expand Up @@ -148,22 +151,22 @@ def execute(args: argparse.Namespace) -> int:

if reset_file is not None and not reset_file.exists():
raise FileNotFoundError(
f"Failed to reset to '{snapshot}'.\nRequired file {reset_file} not found."
f"Snapshot file for the '{snapshot}' reset mode not found: {reset_file}"
)

if not context.json and not context.quiet:
if snapshot is not None:
print(WHAT_TO_EXPECT_SNAPSHOT.format(snapshot_name=snapshot.display_name))
print(WHAT_TO_EXPECT_SNAPSHOT.format(mode_name=snapshot.display_name))
else:
print(WHAT_TO_EXPECT_ESSENTIALS)
print(WHAT_TO_EXPECT_CURRENT)

prompt = "Proceed with resetting your 'base' environment"
prompt = "Proceed with resetting the base environment"
if snapshot is not None:
prompt += f" to the {snapshot.display_name} snapshot"
prompt += f" using the '{snapshot.display_name}' mode"
confirm_yn(f"{prompt}?[y/n]:\n", default="no", dry_run=context.dry_run)

if not context.json and not context.quiet:
print("Resetting 'base' environment...")
print("Resetting the base environment...")

match snapshot:
case Snapshot.INSTALLER_UPDATED if reset_file is not None:
Expand All @@ -180,7 +183,7 @@ def execute(args: argparse.Namespace) -> int:
stdout_json_success()
elif not context.quiet:
if snapshot is not None:
print(SUCCESS_SNAPSHOT.format(snapshot_name=snapshot.display_name))
print(SUCCESS_SNAPSHOT.format(mode_name=snapshot.display_name))
else:
print(SUCCESS)

Expand Down
15 changes: 10 additions & 5 deletions conda_self/cli/main_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
if TYPE_CHECKING:
import argparse

HELP = "Update 'conda' and/or its plugins in the 'base' environment."
HELP = "Update conda, one conda plugin, or all packages in the base environment."


def configure_parser(parser: argparse.ArgumentParser) -> None:
Expand All @@ -16,8 +16,10 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--force-reinstall",
action="store_true",
help="Install latest conda available even "
"if currently installed is more recent.",
help=(
"Uninstall and reinstall each requested package, even if it is "
"already installed."
),
)
update_group = parser.add_mutually_exclusive_group()
update_group.add_argument(
Expand All @@ -27,7 +29,7 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
update_group.add_argument(
"--all",
action="store_true",
help="Update conda, all plugins, and dependencies.",
help="Update all installed packages in the base environment.",
)
parser.set_defaults(func=execute)

Expand Down Expand Up @@ -59,7 +61,10 @@ def execute(args: argparse.Namespace) -> int:

quiet = context.quiet or bool(args.quiet)
if not context.json and not quiet:
print(f"Updating {', '.join(info_parts)}...")
if args.all:
print("Updating all installed packages...")
else:
print(f"Updating {', '.join(info_parts)}...")

return install_specs_in_protected_env(
specs=package_names,
Expand Down
10 changes: 7 additions & 3 deletions conda_self/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,21 @@ class NotAPluginError(CondaError):
def __init__(self, specs: list[str]):
names = ", ".join(specs)
if len(specs) == 1:
msg = f"The requested package is not a plugin: {names}"
msg = f"The requested package is not a conda plugin: {names}"
else:
msg = f"The requested packages are not plugins: {names}"
msg = f"The requested packages are not conda plugins: {names}"
super().__init__(msg)


class PluginRemoveError(CondaError):
def __init__(self, specs: list[str]):
names = ", ".join(specs)
noun = _plural("package", len(specs))
super().__init__(f"{noun.capitalize()} can not be removed: {names}")
super().__init__(
f"conda-self protects the requested {noun} from removal: {names}\n"
"Pass --force to bypass this check. Conda's own transaction checks "
"still apply."
)


class NoDistInfoDirFound(CondaError):
Expand Down
Loading