Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
64d59ed
Refactor `conda migrate` command to support subcommands and enhance h…
jezdez Oct 24, 2025
d9fbc54
Merge branch 'main' into migrate-base
jezdez Nov 7, 2025
5b2ba70
Replace conda migrate with conda fix base task
jezdez Dec 17, 2025
3c100d3
Rename fix tasks to health fixes to match conda API
jezdez Dec 17, 2025
4c55b13
Make health fixes import conditional for older conda versions
jezdez Dec 17, 2025
032cf79
Apply pre-commit fixes
jezdez Dec 17, 2025
a440117
Move argparse import to TYPE_CHECKING block
jezdez Dec 17, 2025
52aca94
Remove health fix API since conda doctor --fix replaces it
jezdez Dec 17, 2025
453e283
Add health check for base environment protection
jezdez Dec 17, 2025
8e3b67a
Use OK_MARK and X_MARK from conda.base.constants
jezdez Dec 17, 2025
cdc6a33
Refactor health check into health_checks module
jezdez Dec 17, 2025
0067648
Move hookimpl back to plugin.py with lazy import
jezdez Dec 17, 2025
489dbac
Remove protect subcommand, use conda doctor --fix instead
jezdez Dec 17, 2025
8a9c33d
Consolidate base protection into health check fix
jezdez Dec 17, 2025
fccb4bb
Clean up base_protection module
jezdez Dec 17, 2025
c6c4e7b
Use health check name in frozen file message
jezdez Dec 17, 2025
17f9f92
Simplify health fix output - remove verbose strings
jezdez Dec 17, 2025
8ae9521
Restore context.quiet checks for progress output
jezdez Dec 17, 2025
df54a22
Use set_keys API instead of deprecated _read_rc/_write_rc
jezdez Dec 17, 2025
494f559
Show fix suggestion without requiring verbose mode
jezdez Dec 17, 2025
dc5f809
Use user_rc_path for default_activation_env setting
jezdez Dec 17, 2025
b84b071
Use ConfigurationFile context manager for config updates
jezdez Dec 17, 2025
8ffb1ca
Remove migrate CLI tests (command removed)
jezdez Dec 17, 2025
b160fe6
Use explicit id for base-protection health check
jezdez Dec 17, 2025
b2deaf0
Update README with new positional check id syntax
jezdez Dec 17, 2025
f086a3d
Use simplified health check naming
jezdez Dec 17, 2025
1bddac1
Merge branch 'main' into fix-base-task
jezdez Dec 19, 2025
9d09d0e
Merge branch 'main' into fix-base-task
jezdez Dec 19, 2025
03d340f
Update health check API to match conda 26.1.0 and add canary testing
jezdez Jan 26, 2026
ed6f98c
Fix: revert to conda >=25.7.0 since 26.1.0 is not released yet
jezdez Jan 26, 2026
bc58987
Skip non-canary tests until conda 26.1.0 is released
jezdez Jan 26, 2026
b49e351
Fix canary test: use Python 3.12 and restrict to linux-64
jezdez Jan 26, 2026
803593f
Use setup-miniconda instead of pixi for canary testing
jezdez Jan 26, 2026
2f7875b
Fix setup-miniconda action sha
jezdez Jan 26, 2026
c8b74b2
Install conda from canary channel in test environment
jezdez Jan 26, 2026
666cc24
Fix verification: ConfirmCallback is TYPE_CHECKING only
jezdez Jan 26, 2026
7ab3a4b
Add tests for base-protection health check
jezdez Jan 26, 2026
84eb339
Refactor tests to use pytest fixtures instead of mocks
jezdez Jan 26, 2026
0a4c6b8
Make health check backward compatible with older conda versions
jezdez Jan 26, 2026
0f92612
Fix test assertions for canary builds
jezdez Jan 26, 2026
085d5d7
Fix linting issues
jezdez Jan 26, 2026
b8d7ea1
Use try/except and hasattr for API detection
jezdez Jan 26, 2026
ba21a7f
Skip additional integration tests for canary builds
jezdez Jan 26, 2026
19a73c7
Update for conda 26.1.0 release
jezdez Feb 4, 2026
27a663e
Clean up outdated comments and remove backwards compat code
jezdez Feb 4, 2026
339357e
Bump conda requirement to >=26.1.0 in recipe
jezdez Feb 4, 2026
c51a1a5
Remove conda-canary skipif decorators from tests
jezdez Feb 4, 2026
65201ff
Update pixi.lock
jezdez Feb 4, 2026
d1e7518
Fix test matrix: conda not available on defaults for macOS
jezdez Feb 4, 2026
f6355e0
Fix: only osx-64 lacks conda on defaults, not osx-arm64
jezdez Feb 4, 2026
35cd8b6
Remove redundant reset() before rm_rf when recreating environment
jezdez Feb 26, 2026
2211cb2
Clarify health check summary to explain what 'protected' means
jezdez Feb 26, 2026
d75c018
Show skip message when health check runs on non-base environment
jezdez Feb 26, 2026
3267f3f
Remove unreachable getattr(args, ...) for default_env and message
jezdez Feb 26, 2026
9038871
Suppress transaction spinner output when --quiet is used
jezdez Feb 26, 2026
d19a10c
Add TODO noting explicit format doesn't capture pip packages
jezdez Feb 26, 2026
c58edc6
Use environment exporter plugin for snapshots
jezdez Feb 26, 2026
ffbd29d
Merge remote-tracking branch 'origin/main' into fix-base-task
jezdez Feb 26, 2026
31d6307
Apply suggestion from @jezdez
jezdez Feb 26, 2026
c5a4d53
Apply suggestions from code review
jezdez Feb 26, 2026
271d898
Revert conda version to >=26.1.0 and update pixi.lock
jezdez Feb 26, 2026
bb14151
Merge branch 'main' into fix-base-task
jezdez Mar 5, 2026
b5f3269
Add main channel to build recipe command
jezdez Mar 5, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
environments: ${{ env.PIXI_ENV_NAME }}

- name: Build recipe
run: pixi run --environment ${{ env.PIXI_ENV_NAME }} build --channel conda-forge
run: pixi run --environment ${{ env.PIXI_ENV_NAME }} build --channel conda-forge --channel main

- name: Upload to conda-canary
# Only publish canary builds after successful pushes to main in the canonical repo
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ jobs:
if: matrix.channel == 'defaults'
shell: bash
run: |
# Use defaults channel instead of conda-forge
sed -i.bak 's|channels = \["conda-forge"\]|channels = ["https://repo.anaconda.com/pkgs/main", "https://repo.anaconda.com/pkgs/r", "https://repo.anaconda.com/pkgs/msys2"]|g' pyproject.toml
# Remove osx-64 (conda not available on defaults for macOS Intel)
sed -i.bak 's|platforms = \["linux-64", "osx-64", "osx-arm64", "win-64"\]|platforms = ["linux-64", "osx-arm64", "win-64"]|g' pyproject.toml
rm pixi.lock
- uses: prefix-dev/setup-pixi@a0af7a228712d6121d37aba47adf55c1332c9c2e # v0.9.4
with:
Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# conda-self

A `self` command to manage your `base` environment safely.
Commands to manage your `base` environment safely.

## `conda self`

Manage your conda 'base' environment safely.

```
$ conda self
Expand All @@ -19,6 +23,32 @@ subcommands:
reset Reset 'base' environment to essential packages only.
update Update 'conda' and/or its plugins in the 'base' environment.
```

## Base Environment Protection

To check if your base environment is protected, run:

```
conda doctor base-protection
```

To protect your base environment, run:

```
conda doctor 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

To see all available health checks, run:

```
conda doctor --list
```

## Installation

1. `conda install -n base conda-self`
Expand Down
9 changes: 9 additions & 0 deletions conda_self/health_checks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Copyright (C) 2012 Anaconda, Inc
# SPDX-License-Identifier: BSD-3-Clause
"""Health checks for conda-self."""

from __future__ import annotations

from . import base_protection

plugins = [base_protection]
148 changes: 148 additions & 0 deletions conda_self/health_checks/base_protection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Copyright (C) 2012 Anaconda, Inc
Copy link
Contributor

Choose a reason for hiding this comment

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

Are these (seemingly old) copyright notices needed?

Copy link
Contributor

Choose a reason for hiding this comment

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

This is consistent with the copyright in conda. Though it doesn't look like any other file in conda-self includes these.

# SPDX-License-Identifier: BSD-3-Clause
"""Health check: Base environment protection.

Checks if the base environment is protected (frozen) and offers to
protect it by cloning to a default environment and resetting base.
"""

from __future__ import annotations

import sys
from typing import TYPE_CHECKING

from conda.base.constants import OK_MARK, PREFIX_FROZEN_FILE, X_MARK
from conda.core.prefix_data import PrefixData

if TYPE_CHECKING:
from argparse import Namespace

from conda.plugins.types import ConfirmCallback


def is_base_environment(prefix: str) -> bool:
"""Check if the given prefix is the base environment."""
return prefix == sys.prefix


def is_base_protected() -> bool:
"""Check if the base environment is protected (frozen)."""
frozen_file = PrefixData(sys.prefix).prefix_path / PREFIX_FROZEN_FILE
return frozen_file.exists()


def check(prefix: str, _verbose: bool) -> None:
"""Health check: Verify base environment protection status.

Only runs when checking the base environment.
"""
if not is_base_environment(prefix):
print("Skipping base protection: not running on base environment.\n")
return

if is_base_protected():
print(f"{OK_MARK} Base environment is protected (frozen).\n")
else:
print(f"{X_MARK} Base environment is not protected.\n")
print(" Run `conda doctor --fix` to protect it.\n")


def fix(prefix: str, args: Namespace, confirm: ConfirmCallback) -> int:
"""Fix: Protect the base environment.

This clones the base environment to a new 'default' environment,
resets base to essentials, and freezes it.
"""
from pathlib import Path

from conda.base.context import context

if not is_base_environment(prefix):
print("Skipping: not running on base environment.")
return 0

if is_base_protected():
print("Base environment is already protected.")
return 0

default_env = "default"
message = "Protected by Base Environment Protection health fix"

if not context.quiet:
print(f"This will clone 'base' to '{default_env}', reset base, and freeze it.")
confirm("Proceed?")

import io
import json
from contextlib import nullcontext, redirect_stdout
from datetime import datetime

from conda.cli.condarc import ConfigurationFile
from conda.exceptions import CondaOSError
from conda.gateways.disk.delete import rm_rf
from conda.misc import clone_env
from conda.models.environment import Environment

from ..query import permanent_dependencies
from ..reset import reset

base_prefix = Path(sys.prefix)

# Get packages to keep in base
uninstallable_packages = permanent_dependencies()

# Check destination environment
dest_prefix_data = PrefixData.from_name(default_env)

if dest_prefix_data.is_environment():
confirm(f"Environment '{default_env}' already exists. Remove and recreate?")
rm_rf(dest_prefix_data.prefix_path)
elif dest_prefix_data.exists():
confirm(f"Directory exists at '{dest_prefix_data.prefix_path}'. Continue?")

# Take a snapshot using the environment exporter plugin system,
# which captures both conda and pip-installed packages.
env = Environment.from_prefix(
str(base_prefix), name="base", platform=context.subdir
)
exporter = context.plugin_manager.get_environment_exporter_by_format("yaml")
snapshot_file = (
base_prefix
/ "conda-meta"
/ f"environment.{datetime.now():%Y-%m-%d-%H-%M-%S}.yml"
)
if not context.quiet:
print(f"Saving snapshot to {snapshot_file}")
snapshot_file.write_text(exporter.export(env))

if not context.quiet:
print(f"Cloning 'base' to '{default_env}'...")
print("Resetting 'base' environment...")

# Suppress conda's transaction spinner output when --quiet
stdout_ctx = redirect_stdout(io.StringIO()) if context.quiet else nullcontext()
with stdout_ctx:
clone_env(
str(base_prefix),
str(dest_prefix_data.prefix_path),
verbose=False,
quiet=True,
)
reset(uninstallable_packages=uninstallable_packages)
Copy link
Contributor

Choose a reason for hiding this comment

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

This will keep pip-installed packages in the new protected base environment still, doesn't it? This essentially re-implements conda self migrate, but as a conda doctor fix. Should there be some kind of warning if we encounter non-conda packages?


# Freeze base
try:
frozen_path = base_prefix / PREFIX_FROZEN_FILE
frozen_path.write_text(json.dumps({"message": message}) if message else "")
except OSError as e:
raise CondaOSError(f"Could not protect environment: {e}") from e

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it would be nice to add an @EXPLICIT file to enable resetting to the post-migration state. Similar to what was done here: marcoesters@c03fd8f#diff-ab450c1dd96dee7dffda61f8532a2e4b44c1f3b3adbcb5f0fd9c3aae8c76dca5R152-R157

# Update default activation environment
if not context.quiet:
print(f"Setting default environment to '{default_env}'")
with ConfigurationFile.from_user_condarc() as config:
config.set_key("default_activation_env", str(dest_prefix_data.prefix_path))

if not context.quiet:
print(f"\nDone! To use your packages: conda activate {default_env}")
return 0
28 changes: 21 additions & 7 deletions conda_self/plugin.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
"""
Plugin definition for 'conda self' subcommand.
"""
"""Plugin hook implementations for conda-self."""

from __future__ import annotations

from typing import TYPE_CHECKING

from conda import plugins
from conda.plugins.hookspec import hookimpl
from conda.plugins.types import CondaHealthCheck, CondaSubcommand

from .cli import configure_parser, execute

if TYPE_CHECKING:
from collections.abc import Iterable


@plugins.hookimpl
def conda_subcommands() -> Iterable[plugins.CondaSubcommand]:
yield plugins.CondaSubcommand(
@hookimpl
def conda_subcommands() -> Iterable[CondaSubcommand]:
"""Expose the `self` subcommand."""
yield CondaSubcommand(
name="self",
action=execute,
configure_parser=configure_parser,
summary="Manage your conda 'base' environment safely.",
)


@hookimpl
def conda_health_checks() -> Iterable[CondaHealthCheck]:
"""Register the base environment protection health check."""
from .health_checks import base_protection

yield CondaHealthCheck(
name="base-protection",
action=base_protection.check,
fixer=base_protection.fix,
summary="Check if base is frozen to prevent accidental modifications",
fix="Clone base to 'default' environment, reset base, and freeze it",
)
Loading
Loading