Skip to content

Commit c03fd8f

Browse files
marcoestersjaimergppre-commit-ci[bot]jezdez
authored
Add ability to reset to a lock-file based state (#83)
* Allow resetting from explicit file * Provide CLI to reset to installer and migration lock file * Create lock file on conda migrate * Bump minimum conda version for get_package_records_from_explicit * Update conda version in pyproject.toml * Move reset file names to constants * Increase number of print_explicit calls in migration tests * Improve reset test typing * Add test for a file-based reset * Remove obsolete patches * Add news * Remove issue 55 from news * Update pixi.lock * Explicitly add conda-forge channel * Explicitly set channels in test * Revert "Explicitly set channels in test" This reverts commit 4bb51da. * Ensure consistent Python versions * Remove ambiguity in channel environment variable names * Auto-reset to file-based state * Separate news items * Rename --reset-to to --snapshot * Use diff_for_unlink_link_precs instead of hand-crafted diffs * Run macOS intel tests with conda-forge * Update snapshot help text * Refresh pixi.lock * Update conda_self/reset.py Co-authored-by: jaimergp <jaimergp@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Lint * Apply suggestions from code review Co-authored-by: Jannis Leidel <jannis@leidel.info> * Replace state with snapshot * Use less ambiguous snapshot variable names --------- Co-authored-by: jaimergp <jaimergp@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Jannis Leidel <jannis@leidel.info>
1 parent 41b535a commit c03fd8f

File tree

11 files changed

+2312
-1685
lines changed

11 files changed

+2312
-1685
lines changed

.github/workflows/tests.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ jobs:
2929
python-version: ["310", "311", "312", "313"]
3030
include:
3131
- os: macos-15-intel
32-
channel: defaults
32+
channel: conda-forge
3333
python-version: "310"
3434
- os: macos-latest
35-
channel: conda-forge
35+
channel: defaults
3636
python-version: "313"
3737

3838
steps:
@@ -53,6 +53,8 @@ jobs:
5353
echo "channels: [${{ matrix.channel }}]" > .pixi/envs/test-py${{ matrix.python-version }}/.condarc
5454
pixi run --environment test-py${{ matrix.python-version }} python -m conda info
5555
- name: Run tests
56+
env:
57+
TEST_CONDA_CHANNEL: ${{ matrix.channel }}
5658
run: >
5759
pixi run
5860
--environment test-py${{ matrix.python-version }}

conda_self/cli/main_migrate.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from pathlib import Path
34
from textwrap import dedent
45
from typing import TYPE_CHECKING
56

@@ -85,6 +86,7 @@ def execute(args: argparse.Namespace) -> int:
8586
from conda.misc import clone_env
8687
from conda.reporters import confirm_yn
8788

89+
from ..constants import RESET_FILE_MIGRATE
8890
from ..query import permanent_dependencies
8991
from ..reset import reset
9092

@@ -129,10 +131,10 @@ def execute(args: argparse.Namespace) -> int:
129131

130132
# Take a snapshot of the current base environment by generating the explicit file.
131133
snapshot_filename = f"explicit.{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.txt"
132-
snapshot_dest = f"{src_prefix}/conda-meta/{snapshot_filename}"
134+
snapshot_dest = Path(src_prefix, "conda-meta", snapshot_filename)
133135
if not context.quiet:
134136
print(f"Taking a snapshot of 'base' and saving it to '{snapshot_dest}'...")
135-
with open(snapshot_dest, "w") as f:
137+
with snapshot_dest.open(mode="w") as f:
136138
with redirect_stdout(f):
137139
print_explicit(src_prefix)
138140

@@ -147,6 +149,12 @@ def execute(args: argparse.Namespace) -> int:
147149
print("Resetting 'base' environment...")
148150
reset(uninstallable_packages=uninstallable_packages)
149151

152+
# Save the state after migration to allow users to reset to post-migration state
153+
migrate_state = Path(src_prefix, "conda-meta", RESET_FILE_MIGRATE)
154+
with migrate_state.open(mode="w") as f:
155+
with redirect_stdout(f):
156+
print_explicit(src_prefix)
157+
150158
# protect the base environment
151159
try:
152160
frozen_path = PrefixData(sys.prefix).prefix_path / PREFIX_FROZEN_FILE

conda_self/cli/main_reset.py

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
11
from __future__ import annotations
22

3+
import sys
4+
from pathlib import Path
35
from textwrap import dedent
46
from typing import TYPE_CHECKING
57

68
if TYPE_CHECKING:
79
import argparse
10+
from typing import TypedDict
11+
12+
class SnapshotData(TypedDict):
13+
file_path: Path
14+
snapshot_name: str
15+
816

917
HELP = "Reset 'base' environment to essential packages only."
18+
SNAPSHOT_HELP = dedent(
19+
"""
20+
Snapshot to reset the `base` environment to.
21+
`snapshot` removes all packages except for `conda`, its plugins,
22+
and their dependencies.
23+
`installer` resets the `base` environment to the snapshot provided
24+
by the installer.
25+
`migrate` resets the `base` environment to the snapshot after the last
26+
`conda migrate` command run.
27+
28+
If not set, `conda self` will try to reset to the post-migration snapshot first,
29+
then to the installer-provided, and finally to the current snapshot.
30+
"""
31+
).lstrip()
1032

1133
WHAT_TO_EXPECT = dedent(
1234
"""
@@ -20,38 +42,83 @@
2042
Reset the `base` environment to only the essential packages and plugins.
2143
"""
2244
).lstrip()
45+
SUCCESS_SNAPSHOT = dedent(
46+
"""
47+
SUCCESS!
48+
Reset the `base` environment to {snapshot_name} snapshot.
49+
"""
50+
).lstrip()
2351

2452

2553
def configure_parser(parser: argparse.ArgumentParser) -> None:
2654
from conda.cli.helpers import add_output_and_prompt_options
2755

2856
parser.description = HELP
2957
add_output_and_prompt_options(parser)
58+
parser.add_argument(
59+
"--snapshot",
60+
choices=("current", "installer", "migrate"),
61+
help=SNAPSHOT_HELP,
62+
)
3063
parser.set_defaults(func=execute)
3164

3265

3366
def execute(args: argparse.Namespace) -> int:
3467
from conda.base.context import context
3568
from conda.reporters import confirm_yn
3669

70+
from ..constants import RESET_FILE_INSTALLER, RESET_FILE_MIGRATE
3771
from ..query import permanent_dependencies
3872
from ..reset import reset
3973

4074
if not context.quiet:
4175
print(WHAT_TO_EXPECT)
4276

43-
confirm_yn(
44-
"Proceed with resetting your 'base' environment?[y/n]:\n",
45-
default="no",
46-
dry_run=context.dry_run,
47-
)
77+
reset_data: dict[str, SnapshotData] = {
78+
"installer": {
79+
"file_path": Path(sys.prefix, "conda-meta", RESET_FILE_INSTALLER),
80+
"snapshot_name": "installer-provided",
81+
},
82+
"migrate": {
83+
"file_path": Path(sys.prefix, "conda-meta", RESET_FILE_MIGRATE),
84+
"snapshot_name": "post-migration",
85+
},
86+
}
87+
88+
reset_file: Path | None = None
89+
snapshot_name = ""
90+
if not args.snapshot:
91+
for snapshot in ("migrate", "installer"):
92+
snapshot_data = reset_data[snapshot]
93+
if not snapshot_data["file_path"].exists():
94+
continue
95+
reset_file = snapshot_data["file_path"]
96+
snapshot_name = snapshot_data["snapshot_name"]
97+
break
98+
elif args.snapshot in reset_data:
99+
reset_file = reset_data[args.snapshot]["file_path"]
100+
snapshot_name = reset_data[args.snapshot]["snapshot_name"]
101+
102+
if reset_file and not reset_file.exists():
103+
raise FileNotFoundError(
104+
f"Failed to reset to `{args.snapshot}`.\n"
105+
f"Required file {reset_file} not found."
106+
)
107+
108+
prompt = "Proceed with resetting your 'base' environment"
109+
if snapshot_name:
110+
prompt += f" to the {snapshot_name} snapshot"
111+
confirm_yn(f"{prompt}?[y/n]:\n", default="no", dry_run=context.dry_run)
48112

49113
if not context.quiet:
50114
print("Resetting 'base' environment...")
51-
uninstallable_packages = permanent_dependencies()
52-
reset(uninstallable_packages=uninstallable_packages)
115+
uninstallable_packages = permanent_dependencies() if not reset_file else set()
116+
reset(uninstallable_packages=uninstallable_packages, snapshot=reset_file)
53117

54118
if not context.quiet:
55-
print(SUCCESS)
119+
if snapshot_name:
120+
print(SUCCESS_SNAPSHOT.format(snapshot_name=snapshot_name))
121+
else:
122+
print(SUCCESS)
56123

57124
return 0

conda_self/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
from typing import Final
22

33
PERMANENT_PACKAGES: Final = ("conda", "conda-self")
4+
5+
RESET_FILE_INSTALLER = "initial-state.explicit.txt"
6+
RESET_FILE_MIGRATE = "migrate-state.explicit.txt"

conda_self/reset.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,50 @@
1+
from __future__ import annotations
2+
13
import sys
4+
from typing import TYPE_CHECKING
25

6+
from boltons.setutils import IndexedSet
37
from conda.base.context import context
48
from conda.core.link import PrefixSetup, UnlinkLinkTransaction
59
from conda.core.prefix_data import PrefixData
10+
from conda.core.solve import diff_for_unlink_link_precs
11+
from conda.gateways.disk.read import yield_lines
12+
from conda.misc import get_package_records_from_explicit
13+
14+
if TYPE_CHECKING:
15+
from pathlib import Path
616

717

8-
def reset(prefix: str = sys.prefix, uninstallable_packages: set[str] = set()):
9-
installed = sorted(PrefixData(prefix).iter_records(), key=lambda x: x.name)
10-
packages_to_remove = [
11-
pkg for pkg in installed if pkg.name not in uninstallable_packages
12-
]
18+
def reset(
19+
prefix: str = sys.prefix,
20+
uninstallable_packages: set[str] = set(),
21+
snapshot: Path | None = None,
22+
):
23+
if snapshot:
24+
snapshot_content = list(yield_lines(snapshot))
25+
packages_in_reset_env = IndexedSet(
26+
get_package_records_from_explicit(snapshot_content)
27+
)
28+
packages_to_remove, packages_to_install = diff_for_unlink_link_precs(
29+
prefix, packages_in_reset_env
30+
)
31+
if not packages_to_remove and not packages_to_install:
32+
print(
33+
"Nothing to do. "
34+
"Packages in target environment match the selected snapshot."
35+
)
36+
return
37+
else:
38+
installed = sorted(PrefixData(prefix).iter_records(), key=lambda x: x.name)
39+
packages_to_remove = tuple(
40+
pkg for pkg in installed if pkg.name not in uninstallable_packages
41+
)
42+
packages_to_install = ()
1343

1444
stp = PrefixSetup(
1545
target_prefix=prefix,
1646
unlink_precs=packages_to_remove,
17-
link_precs=(),
47+
link_precs=packages_to_install,
1848
remove_specs=(),
1949
update_specs=(),
2050
neutered_specs=(),

news/83-add-reset

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
### Enhancements
2+
3+
* Add option to reset protected environment to installer-provided or post-migration state. (#54 via #83)
4+
* Change `conda self reset` to try to reset to the post-migration state or the installer-provided state by default. (#55 via #83)
5+
6+
### Bug fixes
7+
8+
* <news item>
9+
10+
### Deprecations
11+
12+
* <news item>
13+
14+
### Docs
15+
16+
* <news item>
17+
18+
### Other
19+
20+
* <news item>

0 commit comments

Comments
 (0)