diff --git a/conda_self/cli/main_reset.py b/conda_self/cli/main_reset.py index 6629680..f87d024 100644 --- a/conda_self/cli/main_reset.py +++ b/conda_self/cli/main_reset.py @@ -22,6 +22,7 @@ class Snapshot(Enum): """ CURRENT = "current" + INSTALLER = "installer" # Accepted only to explain how to migrate. INSTALLER_EXACT = "installer-exact" INSTALLER_UPDATED = "installer-updated" BASE_PROTECTION = "base-protection" @@ -34,6 +35,8 @@ def display_name(self) -> str: match self: case Snapshot.CURRENT: return "current" + case Snapshot.INSTALLER: + return "installer" case Snapshot.INSTALLER_EXACT: return "installer-provided (exact)" case Snapshot.INSTALLER_UPDATED: @@ -49,7 +52,7 @@ def file_path(self) -> Path | None: 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: + case Snapshot.CURRENT | Snapshot.INSTALLER: return None @@ -73,6 +76,8 @@ def file_path(self) -> Path | None: currently installed versions (no downgrade). `base-protection` restores the `base` environment to the snapshot saved by `conda doctor --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 @@ -112,12 +117,23 @@ def configure_parser(parser: argparse.ArgumentParser) -> None: def execute(args: argparse.Namespace) -> int: from conda.base.context import context + from conda.cli.common import stdout_json_success + from conda.exceptions import CondaValueError from conda.reporters import confirm_yn from ..query import permanent_dependencies from ..reset import names_from_explicit, reset snapshot: Snapshot | None = args.snapshot + if snapshot is Snapshot.INSTALLER: + raise CondaValueError( + "The '--snapshot installer' mode is no longer supported. " + "Use '--snapshot installer-exact' to restore the exact conda packages " + "recorded by the installer, or '--snapshot installer-updated' to keep " + "currently installed installer packages, conda plugins, and permanent " + "packages with their dependencies. 'installer-updated' does not update " + "packages or install missing packages. No reset was performed." + ) reset_file: Path | None = None if snapshot is not None: @@ -135,7 +151,7 @@ def execute(args: argparse.Namespace) -> int: f"Failed to reset to '{snapshot}'.\nRequired file {reset_file} not found." ) - if not context.quiet: + if not context.json and not context.quiet: if snapshot is not None: print(WHAT_TO_EXPECT_SNAPSHOT.format(snapshot_name=snapshot.display_name)) else: @@ -146,7 +162,7 @@ def execute(args: argparse.Namespace) -> int: 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: + if not context.json and not context.quiet: print("Resetting 'base' environment...") match snapshot: @@ -160,7 +176,9 @@ def execute(args: argparse.Namespace) -> int: case _: reset(uninstallable_packages=permanent_dependencies(add_plugins=True)) - if not context.quiet: + if context.json: + stdout_json_success() + elif not context.quiet: if snapshot is not None: print(SUCCESS_SNAPSHOT.format(snapshot_name=snapshot.display_name)) else: diff --git a/conda_self/reset.py b/conda_self/reset.py index 9069381..fe82585 100644 --- a/conda_self/reset.py +++ b/conda_self/reset.py @@ -1,29 +1,166 @@ from __future__ import annotations +import re import sys +from os.path import dirname, isfile from typing import TYPE_CHECKING from boltons.setutils import IndexedSet +from conda import CondaError, CondaExitZero, CondaMultiError from conda.base.constants import EXPLICIT_MARKER from conda.base.context import context +from conda.common.io import dashlist +from conda.common.path import get_major_minor_version from conda.core.link import PrefixSetup, UnlinkLinkTransaction +from conda.core.package_cache_data import PackageCacheData 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.exceptions import ChecksumMismatchError, CondaSignalInterrupt, ParseError +from conda.gateways.disk.read import compute_sum, yield_lines +from conda.misc import _match_specs_from_explicit, get_package_records_from_explicit +from conda.models.enums import NoarchType from conda.models.match_spec import MatchSpec +from conda_package_handling.exceptions import InvalidArchiveError if TYPE_CHECKING: from pathlib import Path + from conda.models.records import PackageRecord + + +def records_from_snapshot( + prefix: str, snapshot_content: list[str] +) -> tuple[IndexedSet, tuple[MatchSpec, ...]]: + """Return package records for the snapshot and MatchSpecs to install or relink.""" + entries = tuple(line for line in snapshot_content if line != EXPLICIT_MARKER) + try: + specs = tuple(_match_specs_from_explicit(entries)) + except (ParseError, ValueError, IndexError, re.error): + raise ParseError("Could not parse a package URL in the snapshot.") from None + prefix_data = PrefixData(prefix) + installed_python = prefix_data.get("python", None) + snapshot_python = next((spec for spec in specs if spec.name == "python"), None) + snapshot_python_version = ( + snapshot_python.get_exact_value("version") + if snapshot_python is not None + else None + ) + relink_noarch_python = bool( + installed_python is not None + and snapshot_python_version + and get_major_minor_version(installed_python.version) + != get_major_minor_version(snapshot_python_version) + ) + + records: list[PackageRecord | None] = [None] * len(entries) + unresolved_entries: list[str] = [] + unresolved_specs: list[MatchSpec] = [] + unresolved_indices: list[int] = [] + + for index, (entry, spec) in enumerate(zip(entries, specs, strict=True)): + installed_record = next(iter(prefix_data.query(spec)), None) + needs_python_relink = bool( + installed_record is not None + and relink_noarch_python + and installed_record.noarch == NoarchType.python + ) + if installed_record is not None and not needs_python_relink: + records[index] = installed_record + continue + + unresolved_entries.append(entry) + unresolved_specs.append(spec) + unresolved_indices.append(index) + + if unresolved_entries: + try: + fetched_records = tuple( + get_package_records_from_explicit(unresolved_entries) + ) + checksum_errors = [] + for spec, record in zip(unresolved_specs, fetched_records, strict=True): + url = spec.get_exact_value("url") + if not url or not url.startswith("file:"): + continue + archive = record.package_tarball_full_path + if not isfile(archive): + continue + # Conda trusts the requested checksum when copying a local package. + for checksum_type in ("md5", "sha256"): + expected_checksum = spec.get_exact_value(checksum_type) + if expected_checksum is None: + continue + actual_checksum = compute_sum(archive, checksum_type) + if actual_checksum != expected_checksum: + # Keep the extracted cache usable only for its actual bytes. + setattr(record, checksum_type, actual_checksum) + PackageCacheData(dirname(archive)).insert(record) + checksum_errors.append( + ChecksumMismatchError( + url, + archive, + checksum_type, + expected_checksum, + actual_checksum, + ) + ) + if checksum_errors: + raise CondaMultiError(checksum_errors) + except (CondaError, InvalidArchiveError) as error: + pending_errors = [error] + found_error = False + while pending_errors: + nested_error = pending_errors.pop() + if isinstance(nested_error, CondaMultiError): + pending_errors.extend(nested_error.errors) + elif type(nested_error) is RuntimeError and str( + nested_error + ).startswith( + f"{InvalidArchiveError.__module__}." + f"{InvalidArchiveError.__qualname__}: " + ): + # Conda 26.7 extraction workers wrap unpicklable errors in + # RuntimeError, retaining only the qualified type and message. + found_error = True + elif not isinstance( + nested_error, (CondaError, InvalidArchiveError) + ) or isinstance(nested_error, (CondaExitZero, CondaSignalInterrupt)): + raise + else: + found_error = True + if not found_error: + raise + + packages = dashlist( + spec.get_exact_value("fn") or spec.name for spec in unresolved_specs + ) + raise CondaError( + "Could not make all conda packages required by the selected " + "snapshot available in a package cache.\n" + "Required conda packages:%(packages)s\n" + "The target environment was not changed. Some conda packages " + "may have been downloaded and extracted into a package cache. " + "Ensure each listed package is available in a package cache or " + "can be downloaded from the URL in the snapshot, verified using " + "its recorded checksum when present, and extracted, then retry.", + packages=packages, + ) from None + + for index, record in zip(unresolved_indices, fetched_records, strict=True): + records[index] = record + + return IndexedSet(record for record in records if record is not None), tuple( + unresolved_specs + ) + def names_from_explicit(path: Path) -> set[str]: - """Extract package names from a CEP-23 ``@EXPLICIT`` file without fetching. + """Extract package names from a CEP 23 explicit spec file without downloading. 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`. + which reads ``name``/``version``/``build`` from the package filename and + strips any 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 @@ -37,17 +174,22 @@ def reset( ): if snapshot: snapshot_content = list(yield_lines(snapshot)) - packages_in_reset_env = IndexedSet( - get_package_records_from_explicit(snapshot_content) + packages_in_reset_env, unresolved_specs = records_from_snapshot( + prefix, snapshot_content ) packages_to_remove, packages_to_install = diff_for_unlink_link_precs( - prefix, packages_in_reset_env + prefix, + packages_in_reset_env, + specs_to_add=unresolved_specs, + force_reinstall=True, ) if not packages_to_remove and not packages_to_install: - print( - "Nothing to do. " - "Packages in target environment match the selected snapshot." - ) + if not context.json and not context.quiet: + print( + "Nothing to do. " + "The conda packages in the target environment match the " + "selected snapshot." + ) return else: installed = sorted(PrefixData(prefix).iter_records(), key=lambda x: x.name) diff --git a/docs/configuration.md b/docs/configuration.md index f15a207..d488f37 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,7 +30,7 @@ use conda's `@EXPLICIT` format (a list of exact package URLs). | File | Created by | Purpose | |------|-----------|---------| | `base-protection-state.explicit.txt` | `conda doctor base-protection --fix` | Pre-protection state of base | -| `installer-state.explicit.txt` | Installer (e.g. Miniforge) | Original installer state | +| `initial-state.explicit.txt` | Installer (e.g. Miniforge) | Original installer state | These files are used by `conda self reset --snapshot ` to restore base to a known state without running the solver. @@ -41,7 +41,7 @@ restore base to a known state without running the solver. |----------|-------|-------------| | `DEFAULT_ENV_NAME` | `"default"` | Name of the environment created when cloning base | | `SNAPSHOT_FILE_BASE_PROTECTION` | `"base-protection-state.explicit.txt"` | Snapshot filename for base protection | -| `RESET_FILE_INSTALLER` | `"installer-state.explicit.txt"` | Snapshot filename from installer | +| `RESET_FILE_INSTALLER` | `"initial-state.explicit.txt"` | Snapshot filename from installer | | `SELF_PERMANENT_PACKAGES_SETTING` | `"self_permanent_packages"` | Name of the condarc setting | ## Environment variables diff --git a/docs/features.md b/docs/features.md index 3573855..4b4a300 100644 --- a/docs/features.md +++ b/docs/features.md @@ -93,15 +93,22 @@ pre-protection state. This snapshot can be used to restore base: ```bash conda self reset # auto-detect best snapshot -conda self reset --snapshot installer # reset to installer state +conda self reset --snapshot installer-exact # reset to installer state conda self reset --snapshot base-protection # reset to protection snapshot conda self reset --snapshot current # strip to essentials only ``` +The old `--snapshot installer` option reports a migration error. Use +`installer-exact` to restore the exact snapshot, or `installer-updated` to +retain installed versions of installer packages alongside conda, conda-self, +installed conda plugins, configured permanent packages, and their dependencies. +See {doc}`guides/resetting-base` for migration guidance and exact-reset +requirements. + Snapshots are stored as `@EXPLICIT` files in `conda-meta/`: - `base-protection-state.explicit.txt` -- saved by `conda doctor --fix` -- `installer-state.explicit.txt` -- saved by the installer (if available) +- `initial-state.explicit.txt` -- saved by the installer (if available) ## Health check integration diff --git a/docs/guides/resetting-base.md b/docs/guides/resetting-base.md index 35c6b6e..83c72f2 100644 --- a/docs/guides/resetting-base.md +++ b/docs/guides/resetting-base.md @@ -12,7 +12,7 @@ conda self reset conda-self tries snapshots in this order: 1. `base-protection` -- the snapshot saved by [conda doctor base-protection --fix](inv:conda:std:doc#commands/doctor) -2. `installer` -- the snapshot saved by the installer +2. `installer-updated` -- retain installed versions of packages named in the installer snapshot 3. `current` -- strip to essentials without a snapshot ## Reset to a specific snapshot @@ -32,11 +32,26 @@ This uses `conda-meta/base-protection-state.explicit.txt`. Restore to the original state from the installer (e.g. Miniforge): ```bash -conda self reset --snapshot installer +conda self reset --snapshot installer-exact ``` -This uses `conda-meta/installer-state.explicit.txt`. Not all -installers provide this file. +This uses `conda-meta/initial-state.explicit.txt`. Not all +installers provide this file. Restoring the exact snapshot may downgrade +packages that have since been updated. + +### Installer snapshot with updated packages + +Retain currently installed packages whose names appear in the installer +snapshot: + +```bash +conda self reset --snapshot installer-updated +``` + +This also keeps conda, conda-self, installed conda plugins, configured +permanent packages, and their dependencies. It does not update packages or +install missing packages. Use `installer-exact` or a suitable +`base-protection` snapshot to remove a plugin outside the snapshot. ### Current essentials @@ -47,15 +62,41 @@ without using any snapshot file: conda self reset --snapshot current ``` +## Migrate commands that use `installer` + +`conda self reset --snapshot installer` reports a migration error before +asking for confirmation or changing the environment. Replace `installer` +with `installer-exact` to restore the exact recorded packages, or +`installer-updated` to retain their currently installed versions alongside +conda, conda-self, installed conda plugins, configured permanent packages, +and their dependencies. `installer-updated` does not update packages or +install missing packages. + ## Dry run Preview what a reset would do: ```bash conda self reset --dry-run -conda self reset --snapshot installer --dry-run +conda self reset --snapshot installer-exact --dry-run ``` +## Packages required for an exact reset + +For `installer-exact` and `base-protection`, conda-self reuses a package +already installed in base when its package URL and any recorded checksum +match the snapshot and it does not need to be reinstalled. An unavailable +snapshot URL therefore does not prevent an exact reset when that package can +be reused. + +Each package that must be installed or reinstalled must be present in a +package cache or downloadable from its URL in the snapshot. Conda downloads, +verifies, and extracts these packages as needed. This includes noarch Python +packages that must be relinked after a Python major or minor version change. +If a required package cannot be prepared, the exact reset stops before the +target environment is changed. Packages downloaded and extracted before the +failure may remain in a package cache. + ## After a reset After resetting, your base environment contains only essentials. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 17d6b94..3dea71a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -129,23 +129,37 @@ conda self reset [--snapshot ] [--dry-run] [--yes] [--json] [--quiet] : Remove all packages except conda, its plugins, and their dependencies. - `installer` - : Reset to the snapshot saved by the installer - (`conda-meta/installer-state.explicit.txt`). + `installer-exact` + : Reset to the exact snapshot saved by the installer + (`conda-meta/initial-state.explicit.txt`). This may downgrade packages. + + `installer-updated` + : Retain currently installed packages whose names appear in the installer + snapshot, alongside conda, conda-self, installed conda plugins, configured + permanent packages, and their dependencies. This does not update packages + or install missing packages. `base-protection` : Reset to the snapshot saved by `conda doctor --fix` (`conda-meta/base-protection-state.explicit.txt`). If not specified, conda-self tries `base-protection` first, then - `installer`, and falls back to `current`. + `installer-updated`, and falls back to `current`. + + `--snapshot installer` reports a migration error before confirmation or + environment changes. Replace it with `installer-exact` to restore the exact + recorded packages, or `installer-updated` to retain their currently installed + versions alongside the packages described above. ```bash # Auto-detect best snapshot conda self reset # Reset to installer state -conda self reset --snapshot installer +conda self reset --snapshot installer-exact + +# Retain installed versions of installer packages +conda self reset --snapshot installer-updated # Reset to base-protection snapshot conda self reset --snapshot base-protection @@ -154,6 +168,13 @@ conda self reset --snapshot base-protection conda self reset --snapshot current ``` +Exact resets reuse an installed package when its package URL and any recorded +checksum match the snapshot and it does not need to be reinstalled. Conda +prepares any remaining packages in a package cache before changing the target +environment. This includes noarch Python packages that must be relinked after +a Python major or minor version change. If preparation fails, the target +environment is unchanged. See {doc}`../guides/resetting-base` for details. + --- ## conda doctor base-protection diff --git a/news/150-snapshot-packages b/news/150-snapshot-packages new file mode 100644 index 0000000..0387a82 --- /dev/null +++ b/news/150-snapshot-packages @@ -0,0 +1,31 @@ +### Enhancements + +* + +### Bug fixes + +* Reuse an installed conda package when its package URL and any recorded + checksum match the snapshot and the package does not need to be reinstalled, + so an unavailable URL does not block the exact reset. (#150) +* Validate recorded checksums for local snapshot archives and report download, + extraction, and package-cache lookup failures before changing the target + environment. (#150) +* Respect `--json` and `--quiet` when reporting reset progress and completion. + (#150) +* Reject the ambiguous `--snapshot installer` option before confirmation or + environment changes. Explain how to migrate to `installer-exact` to restore + the recorded conda packages or `installer-updated` to retain their currently + installed versions without updating or installing missing packages. (#150) + +### Deprecations + +* + +### Docs + +* Correct the documented reset modes, automatic fallback order, installer + snapshot filename, and exact-reset limitations. (#150) + +### Other + +* diff --git a/tests/test_cli_reset.py b/tests/test_cli_reset.py index 2b21c0a..811d01c 100644 --- a/tests/test_cli_reset.py +++ b/tests/test_cli_reset.py @@ -1,12 +1,22 @@ from __future__ import annotations +import json +import signal import sys +import traceback from contextlib import redirect_stdout +from types import SimpleNamespace from typing import TYPE_CHECKING import pytest +from conda import CondaError, CondaExitZero, CondaMultiError from conda.base.constants import PREFIX_FROZEN_FILE +from conda.base.context import context as conda_context from conda.cli.main_list import print_explicit +from conda.exceptions import CondaHTTPError, CondaSignalInterrupt, ParseError +from conda.models.channel import Channel +from conda.models.enums import NoarchType +from conda.models.records import PackageRecord from conda_self.cli.main_reset import Snapshot from conda_self.constants import ( @@ -22,13 +32,15 @@ from pytest import MonkeyPatch +MD5 = "0" * 32 +SHA256 = "a" * 64 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" + f"mamba-1.5.3-py311h3072747_1.conda#{MD5}\n" "https://conda.anaconda.org/conda-forge/linux-64/" - "pip-24.0-pyhd8ed1ab_0.conda#def\n" + f"pip-24.0-pyhd8ed1ab_0.conda#{MD5}\n" ) @@ -37,6 +49,41 @@ def __init__(self, name: str): self.name = name +def make_package_record( + name: str, + *, + version: str = "1.0", + build: str = "0", + channel: str = "https://packages.example.test/conda-forge", + url: str | None = None, + subdir: str = "linux-64", + md5: str = MD5, + sha256: str = SHA256, + noarch: NoarchType | None = None, +) -> PackageRecord: + filename = f"{name}-{version}-{build}.conda" + return PackageRecord( + name=name, + version=version, + build=build, + build_number=0, + channel=Channel(channel), + subdir=subdir, + fn=filename, + url=url or f"{channel}/{subdir}/{filename}", + md5=md5, + sha256=sha256, + depends=(), + noarch=noarch, + ) + + +def explicit_entry(record: PackageRecord, checksum: str = "sha256") -> str: + digest = getattr(record, checksum) + prefix = "sha256:" if checksum == "sha256" else "" + return f"{record.url}#{prefix}{digest}" + + @pytest.fixture def reset_calls(): return [] @@ -65,12 +112,28 @@ 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 fake_reset_output_env( + tmp_path: Path, + monkeypatch: MonkeyPatch, +): + conda_meta = tmp_path / "conda-meta" + conda_meta.mkdir() + monkeypatch.setattr(sys, "prefix", str(tmp_path)) + monkeypatch.setattr("conda_self.reset.reset", lambda **kwargs: None) + monkeypatch.setattr( + "conda_self.query.permanent_dependencies", lambda **kwargs: {"conda"} + ) + (conda_meta / RESET_FILE_INSTALLER).write_text(INSTALLER_SNAPSHOT_CONTENT) + (conda_meta / RESET_FILE_BASE_PROTECTION).write_text(INSTALLER_SNAPSHOT_CONTENT) + return tmp_path + + @pytest.fixture def stub_transaction(monkeypatch: MonkeyPatch): """Stub ``conda_self.reset.reset``'s disk dependencies. @@ -109,11 +172,486 @@ def execute(self): return captured +@pytest.fixture +def snapshot_reset(monkeypatch: MonkeyPatch): + captured: dict = {"installed": [], "fetched": []} + + class StubPrefixData: + def __init__(self, *args, **kwargs): + pass + + def get(self, name, default=None): + return next( + (record for record in captured["installed"] if record.name == name), + default, + ) + + def query(self, spec): + return (record for record in captured["installed"] if spec.match(record)) + + def stub_fetch(entries): + captured["fetch_entries"] = tuple(entries) + if error := captured.get("fetch_error"): + raise error + return captured["fetched"] + + def stub_diff( + prefix, + final_precs, + specs_to_add=(), + force_reinstall=False, + ): + captured["diff"] = { + "prefix": prefix, + "final_precs": tuple(final_precs), + "specs_to_add": tuple(specs_to_add), + "force_reinstall": force_reinstall, + } + return (), () + + monkeypatch.setattr("conda_self.reset.PrefixData", StubPrefixData) + monkeypatch.setattr( + "conda_self.reset.get_package_records_from_explicit", stub_fetch + ) + monkeypatch.setattr("conda_self.reset.diff_for_unlink_link_precs", stub_diff) + return captured + + +@pytest.mark.parametrize( + "entry", + [ + ( + "https://user:password@packages.example.test/t/tk-secret/linux-64/" + "demo-1.0-0.conda?X-Amz-Credential=signed-secret#invalid" + ), + ( + "https://user:password@packages.example.test/t/tk-secret/linux-64/" + f"demo-^signedsecret-0.conda#{SHA256}" + ), + ], + ids=["checksum", "filename"], +) +def test_reset_snapshot_hides_invalid_explicit_entry(entry: str, tmp_path: Path): + from conda_self.reset import reset + + snapshot = tmp_path / "snapshot.explicit.txt" + snapshot.write_text(f"@EXPLICIT\n{entry}\n") + + with pytest.raises(ParseError) as exc_info: + reset(prefix="/target", snapshot=snapshot) + + outputs = ( + str(exc_info.value), + "".join(traceback.format_exception(exc_info.value)), + ) + assert outputs[0] == "Could not parse a package URL in the snapshot." + for secret in ( + "user:password", + "tk-secret", + "X-Amz-Credential", + "signed-secret", + "signedsecret", + SHA256, + ): + assert all(secret not in output for output in outputs) + + +def test_reset_snapshot_reuses_installed_record_without_fetching( + monkeypatch: MonkeyPatch, + tmp_path: Path, +): + from conda_self.reset import reset + + prefix = tmp_path / "prefix" + conda_meta = prefix / "conda-meta" + conda_meta.mkdir(parents=True) + installed = make_package_record( + "removed-upstream", + channel="https://dead.example.test/conda-forge", + ) + extra = make_package_record("extra") + for record in (installed, extra): + record_path = conda_meta / f"{record.name}-{record.version}-0.json" + record_path.write_text(json.dumps(record.dump())) + snapshot = tmp_path / "snapshot.explicit.txt" + snapshot.write_text(f"@EXPLICIT\n{explicit_entry(installed)}\n") + captured = {} + + def fail_fetch(entries): + pytest.fail(f"unexpected fetch: {entries}") + + monkeypatch.setattr( + "conda_self.reset.get_package_records_from_explicit", fail_fetch + ) + + 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.PrefixSetup", stub_prefix_setup) + monkeypatch.setattr("conda_self.reset.UnlinkLinkTransaction", StubTxn) + + reset(prefix=str(prefix), snapshot=snapshot) + + assert tuple(record.name for record in captured["unlink_precs"]) == ("extra",) + assert captured["link_precs"] == () + + +def test_reset_snapshot_fetches_only_missing_records_and_preserves_order( + snapshot_reset: dict, + tmp_path: Path, +): + from conda_self.reset import reset + + missing_first = make_package_record("missing-first") + installed = make_package_record("already-installed") + missing_last = make_package_record("missing-last") + entries = tuple( + explicit_entry(record) for record in (missing_first, installed, missing_last) + ) + snapshot = tmp_path / "snapshot.explicit.txt" + snapshot.write_text("@EXPLICIT\n" + "\n".join(entries) + "\n") + snapshot_reset["installed"] = [installed] + snapshot_reset["fetched"] = [missing_first, missing_last] + + reset(prefix="/target", snapshot=snapshot) + + assert snapshot_reset["fetch_entries"] == (entries[0], entries[2]) + assert snapshot_reset["diff"]["final_precs"] == ( + missing_first, + installed, + missing_last, + ) + assert tuple(spec.name for spec in snapshot_reset["diff"]["specs_to_add"]) == ( + "missing-first", + "missing-last", + ) + + +@pytest.mark.parametrize("mismatch", ["checksum", "url"]) +def test_reset_snapshot_reinstalls_same_package_for_url_or_checksum_mismatch( + mismatch: str, + snapshot_reset: dict, + tmp_path: Path, +): + from conda_self.reset import reset + + target = make_package_record("demo") + if mismatch == "checksum": + installed = make_package_record("demo", sha256="b" * 64) + else: + installed = make_package_record( + "demo", + url="https://mirror.example.test/linux-64/demo-1.0-0.conda", + ) + assert installed == target + + snapshot = tmp_path / "snapshot.explicit.txt" + snapshot.write_text(f"@EXPLICIT\n{explicit_entry(target)}\n") + snapshot_reset["installed"] = [installed] + snapshot_reset["fetched"] = [target] + + reset(prefix="/target", snapshot=snapshot) + + force_specs = snapshot_reset["diff"]["specs_to_add"] + assert len(force_specs) == 1 + assert force_specs[0].match(target) + assert not force_specs[0].match(installed) + assert snapshot_reset["diff"]["force_reinstall"] is True + assert snapshot_reset["diff"]["final_precs"] == (target,) + + +@pytest.mark.parametrize("worker_error", [False, True], ids=["download", "worker"]) +def test_reset_snapshot_package_error_reports_safe_context( + worker_error: bool, + snapshot_reset: dict, + tmp_path: Path, +): + from conda_self.reset import reset + + unavailable = make_package_record( + "unavailable", + url=( + "https://user:password@bad%q.invalid/t/tk-secret/linux-64/" + "unavailable-1.0-0.conda" + ), + ) + snapshot = tmp_path / "snapshot.explicit.txt" + snapshot.write_text(f"@EXPLICIT\n{explicit_entry(unavailable)}\n") + error: Exception = CondaHTTPError( + "server%body-secret", + f"{unavailable.url}?X-Amz-Credential=signed-secret", + 404, + "reason%secret", + "-", + ) + if worker_error: + error = RuntimeError( + "conda_package_handling.exceptions.InvalidArchiveError: " + f"archive from {unavailable.url}?X-Amz-Credential=signed-secret " + "server%body-secret reason%secret" + ) + snapshot_reset["fetch_error"] = CondaMultiError((error,)) + + with pytest.raises(CondaError) as exc_info: + reset(prefix="/target", snapshot=snapshot) + + outputs = ( + str(exc_info.value), + repr(exc_info.value.dump_map()), + "".join(traceback.format_exception(exc_info.value)), + ) + assert outputs[0] == ( + "Could not make all conda packages required by the selected snapshot " + "available in a package cache.\n" + "Required conda packages:\n" + " - unavailable-1.0-0.conda\n" + "The target environment was not changed. Some conda packages may have " + "been downloaded and extracted into a package cache. Ensure each listed " + "package is available in a package cache or can be downloaded from the " + "URL in the snapshot, verified using its recorded checksum when present, " + "and extracted, then retry." + ) + assert all("unavailable-1.0-0.conda" in output for output in outputs) + for secret in ( + "user:password", + "tk-secret", + "X-Amz-Credential", + "signed-secret", + "server%body-secret", + "reason%secret", + ): + assert all(secret not in output for output in outputs) + assert "diff" not in snapshot_reset + + +@pytest.mark.parametrize( + "error", + [ + CondaExitZero("requested exit"), + CondaSignalInterrupt(signal.SIGINT), + RuntimeError("unexpected failure"), + CondaMultiError(()), + CondaMultiError((CondaMultiError((CondaExitZero("requested exit"),)),)), + CondaMultiError((CondaMultiError((RuntimeError("unexpected failure"),)),)), + CondaMultiError((CondaMultiError((CondaSignalInterrupt(signal.SIGINT),)),)), + CondaMultiError( + ( + RuntimeError("unexpected failure"), + RuntimeError( + "conda_package_handling.exceptions.InvalidArchiveError: " + "corrupt archive" + ), + ) + ), + ], + ids=[ + "exit", + "interrupt", + "unexpected", + "empty", + "nested-exit", + "nested-unexpected", + "nested-interrupt", + "mixed-worker-and-unexpected", + ], +) +def test_reset_snapshot_preserves_interrupt_or_unexpected_error( + error: Exception, + snapshot_reset: dict, + tmp_path: Path, +): + from conda_self.reset import reset + + package = make_package_record("missing") + snapshot = tmp_path / "snapshot.explicit.txt" + snapshot.write_text(f"@EXPLICIT\n{explicit_entry(package)}\n") + snapshot_reset["fetch_error"] = error + + with pytest.raises(type(error)) as exc_info: + reset(prefix="/target", snapshot=snapshot) + + assert exc_info.value is error + assert "diff" not in snapshot_reset + + +@pytest.mark.parametrize( + "json_output, quiet", + [(True, False), (False, True)], + ids=["json", "quiet"], +) +def test_reset_snapshot_noop_suppresses_human_output( + json_output: bool, + quiet: bool, + snapshot_reset: dict, + monkeypatch: MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +): + from conda_self.reset import reset + + package = make_package_record("installed") + snapshot = tmp_path / "snapshot.explicit.txt" + snapshot.write_text(f"@EXPLICIT\n{explicit_entry(package)}\n") + snapshot_reset["installed"] = [package] + monkeypatch.setattr( + "conda_self.reset.context", + SimpleNamespace( + json=json_output, + quiet=quiet, + plugin_manager=conda_context.plugin_manager, + ), + ) + + reset(prefix="/target", snapshot=snapshot) + + assert capsys.readouterr().out == "" + + +def test_reset_snapshot_fetches_noarch_python_package_for_python_relink( + snapshot_reset: dict, + tmp_path: Path, +): + from conda_self.reset import reset + + installed_python = make_package_record("python", version="3.12.9") + target_python = make_package_record("python", version="3.13.2") + installed_noarch = make_package_record( + "pip", + version="25.0", + subdir="noarch", + noarch=NoarchType.python, + ) + fetched_noarch = make_package_record( + "pip", + version="25.0", + subdir="noarch", + noarch=NoarchType.python, + ) + installed_native = make_package_record("zlib") + records = (installed_noarch, installed_native, target_python) + entries = tuple(explicit_entry(record) for record in records) + snapshot = tmp_path / "snapshot.explicit.txt" + snapshot.write_text("@EXPLICIT\n" + "\n".join(entries) + "\n") + snapshot_reset["installed"] = [ + installed_python, + installed_noarch, + installed_native, + ] + snapshot_reset["fetched"] = [fetched_noarch, target_python] + + reset(prefix="/target", snapshot=snapshot) + + assert snapshot_reset["fetch_entries"] == (entries[0], entries[2]) + assert snapshot_reset["diff"]["final_precs"] == ( + fetched_noarch, + installed_native, + target_python, + ) + assert tuple(spec.name for spec in snapshot_reset["diff"]["specs_to_add"]) == ( + "pip", + "python", + ) + + def test_help(conda_cli: CondaCLIFixture): out, err, exc = conda_cli("self", "reset", "--help", raises=SystemExit) assert exc.value.code == 0 +@pytest.mark.parametrize("snapshot_present", [False, True]) +@pytest.mark.parametrize("yes", [False, True]) +@pytest.mark.parametrize("output_option", [None, "--json", "--quiet"]) +def test_installer_requires_explicit_migration_before_reset( + snapshot_present: bool, + yes: bool, + output_option: str | None, + fake_reset_env: Path, + reset_calls: list, + perm_deps_calls: list, + monkeypatch: MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + from conda.base.context import reset_context + from conda.cli.main import main + + snapshot = fake_reset_env / "conda-meta" / RESET_FILE_INSTALLER + if snapshot_present: + snapshot.write_text(INSTALLER_SNAPSHOT_CONTENT) + + def fail_confirmation(*args, **kwargs): + pytest.fail("migration must be explained before confirmation") + + monkeypatch.setattr("conda.reporters.confirm_yn", fail_confirmation) + args = ["self", "reset", "--snapshot", "installer"] + if yes: + args.append("--yes") + if output_option: + args.append(output_option) + + try: + code = main(*args) + finally: + reset_context() + assert code == 1 + + out, err = capsys.readouterr() + if output_option == "--json": + result = json.loads(out) + assert result["exception_name"] == "CondaValueError" + assert result.get("success") is not True + message = result["message"] + assert err == "" + else: + assert out == "" + message = err + assert "--snapshot installer-exact" in message + assert "--snapshot installer-updated" in message + assert "does not update packages or install missing packages" in message + assert "No reset was performed." in message + assert reset_calls == [] + assert perm_deps_calls == [] + assert snapshot.exists() is snapshot_present + if snapshot_present: + assert snapshot.read_text() == INSTALLER_SNAPSHOT_CONTENT + + +@pytest.mark.parametrize( + "snapshot", [s.value for s in Snapshot if s is not Snapshot.INSTALLER] +) +def test_reset_json_output( + snapshot: str, + conda_cli: CondaCLIFixture, + fake_reset_output_env: Path, +): + out, _err, _exc = conda_cli( + "self", "reset", "--yes", "--json", "--snapshot", snapshot + ) + + assert json.loads(out) == {"success": True} + + +def test_reset_quiet_output( + conda_cli: CondaCLIFixture, + fake_reset_output_env: Path, +): + out, _err, _exc = conda_cli( + "self", "reset", "--yes", "--quiet", "--snapshot", "current" + ) + + assert out == "" + + @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) @@ -122,8 +660,8 @@ def test_help_shows_snapshot_choices(conda_cli: CondaCLIFixture, choice: str): @pytest.mark.parametrize( "bad_value", - ["installer", "totally-bogus", ""], - ids=["bare-installer", "bogus", "empty"], + ["totally-bogus", ""], + ids=["bogus", "empty"], ) def test_invalid_snapshot_value_rejected(conda_cli: CondaCLIFixture, bad_value: str): out, err, exc = conda_cli( @@ -166,15 +704,18 @@ def test_snapshot_dispatch( assert expected_names <= call["uninstallable_packages"] -def test_installer_exact_missing_file_raises( - conda_cli: CondaCLIFixture, fake_reset_env: Path +@pytest.mark.parametrize("snapshot", ["installer-exact", "installer-updated"]) +def test_installer_snapshot_missing_file_raises( + snapshot: str, + conda_cli: CondaCLIFixture, + fake_reset_env: Path, ): conda_cli( "self", "reset", "--yes", "--snapshot", - "installer-exact", + snapshot, raises=FileNotFoundError, ) @@ -233,24 +774,11 @@ def test_fallback_ordering( 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, None), (Snapshot.INSTALLER_EXACT, RESET_FILE_INSTALLER), (Snapshot.INSTALLER_UPDATED, RESET_FILE_INSTALLER), (Snapshot.BASE_PROTECTION, RESET_FILE_BASE_PROTECTION), diff --git a/tests/test_reset_snapshot_planning.py b/tests/test_reset_snapshot_planning.py new file mode 100644 index 0000000..848a0ce --- /dev/null +++ b/tests/test_reset_snapshot_planning.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import hashlib +import io +import json +import tarfile +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from threading import Thread +from types import SimpleNamespace +from typing import TYPE_CHECKING + +import pytest +from conda import CondaError +from conda.core.link import UnlinkLinkTransaction +from conda.core.package_cache_data import PackageCacheData +from conda.core.prefix_data import PrefixData +from conda.misc import get_package_records_from_explicit +from conda.models.channel import Channel +from conda.models.enums import NoarchType +from conda.models.records import PackageRecord, PrefixRecord + +from conda_self.reset import reset + +if TYPE_CHECKING: + from conda.core.link import PrefixSetup + from pytest import MonkeyPatch + + +@pytest.fixture(autouse=True) +def isolated_transactions(tmp_path: Path, tmp_pkgs_dir: Path, monkeypatch: MonkeyPatch): + monkeypatch.setattr( + "conda_self.reset.context", SimpleNamespace(quiet=True, json=False) + ) + monkeypatch.setattr( + "conda.core.envs_manager.get_user_environments_txt_file", + lambda *args: str(tmp_path / "environments.txt"), + ) + + +@pytest.fixture +def package_server(tmp_path: Path): + class Handler(SimpleHTTPRequestHandler): + def log_message(self, format: str, *args) -> None: + pass + + server = ThreadingHTTPServer( + ("127.0.0.1", 0), partial(Handler, directory=str(tmp_path)) + ) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + server.server_close() + thread.join() + + +def make_package( + root: Path, + name: str, + *, + version: str = "1.0", + noarch: NoarchType | None = None, + depends: tuple[str, ...] = (), + index_name: str | None = None, +) -> tuple[Path, PackageRecord]: + """Create a small real archive that Conda can extract and link.""" + package_dir = root / "channel" / "noarch" + package_dir.mkdir(parents=True, exist_ok=True) + archive = package_dir / f"{name}-{version}-0.tar.bz2" + index = { + "name": name, + "version": version, + "build": "0", + "build_number": 0, + "subdir": "noarch", + "depends": depends, + } + if noarch is not None: + index["noarch"] = noarch.value + payload = f"share/{name}.txt" + members = { + "info/index.json": json.dumps({**index, "name": index_name or name}).encode(), + "info/files": f"{payload}\n".encode(), + payload: b"from snapshot\n", + } + with tarfile.open(archive, "w:bz2") as output: + for name, content in members.items(): + member = tarfile.TarInfo(name) + member.size = len(content) + output.addfile(member, io.BytesIO(content)) + data = archive.read_bytes() + return archive, PackageRecord( + **index, + channel=Channel(package_dir.parent.as_uri()), + fn=archive.name, + url=archive.as_uri(), + md5=hashlib.md5(data).hexdigest(), + sha256=hashlib.sha256(data).hexdigest(), + ) + + +def seed_prefix(prefix: Path, *records: PackageRecord) -> None: + conda_meta = prefix / "conda-meta" + conda_meta.mkdir(parents=True) + (conda_meta / "history").touch() + for record in records: + payload = f"share/{record.name}.txt" + path = prefix / payload + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("already installed\n") + installed = PrefixRecord.from_objects(record, files=(payload,)) + metadata = conda_meta / f"{record.name}-{record.version}-{record.build}.json" + metadata.write_text(json.dumps(installed.dump())) + + +def write_snapshot( + path: Path, *records: PackageRecord, checksum: str = "sha256" +) -> Path: + checksum_prefix = "sha256:" if checksum == "sha256" else "" + entries = ( + f"{record.url}#{checksum_prefix}{getattr(record, checksum)}" + for record in records + ) + path.write_text("@EXPLICIT\n" + "\n".join(entries) + "\n") + return path + + +def capture_transactions(monkeypatch: MonkeyPatch) -> list[PrefixSetup]: + setups = [] + + def transaction(setup): + setups.append(setup) + return UnlinkLinkTransaction(setup) + + monkeypatch.setattr("conda_self.reset.UnlinkLinkTransaction", transaction) + return setups + + +def test_unavailable_installed_package_survives_extra_package_removal( + tmp_path: Path, monkeypatch: MonkeyPatch, tmp_pkgs_dir: Path +): + archive, retained = make_package(tmp_path, "removed-upstream") + _, extra = make_package(tmp_path, "extra") + prefix = tmp_path / "prefix" + seed_prefix(prefix, retained, extra) + snapshot = write_snapshot(tmp_path / "snapshot.txt", retained) + retained_metadata = prefix / "conda-meta" / "removed-upstream-1.0-0.json" + original_metadata = retained_metadata.read_bytes() + archive.unlink() + assert not (tmp_pkgs_dir / retained.fn).exists() + setups = capture_transactions(monkeypatch) + + def fail_fetch(entries): + pytest.fail("An unchanged installed package must not be fetched") + + monkeypatch.setattr( + "conda_self.reset.get_package_records_from_explicit", fail_fetch + ) + + reset(prefix=str(prefix), snapshot=snapshot) + + assert len(setups) == 1 + assert tuple(record.name for record in setups[0].unlink_precs) == ("extra",) + assert setups[0].link_precs == () + assert retained_metadata.read_bytes() == original_metadata + assert (prefix / "share" / "removed-upstream.txt").read_text() == ( + "already installed\n" + ) + assert not (prefix / "share" / "extra.txt").exists() + assert tuple(record.name for record in PrefixData(prefix).iter_records()) == ( + "removed-upstream", + ) + + +@pytest.mark.parametrize("mismatch", ["url", "md5", "sha256"]) +def test_equal_package_identity_is_reinstalled_when_snapshot_does_not_match( + mismatch: str, tmp_path: Path, monkeypatch: MonkeyPatch +): + _, target = make_package(tmp_path, "demo") + different_value = ( + "https://removed.example.test/noarch/demo-1.0-0.tar.bz2" + if mismatch == "url" + else "0" * (32 if mismatch == "md5" else 64) + ) + installed = PackageRecord.from_objects(target, **{mismatch: different_value}) + assert installed == target + prefix = tmp_path / "prefix" + seed_prefix(prefix, installed) + checksum = "md5" if mismatch == "md5" else "sha256" + snapshot = write_snapshot(tmp_path / "snapshot.txt", target, checksum=checksum) + setups = capture_transactions(monkeypatch) + + reset(prefix=str(prefix), snapshot=snapshot) + + assert len(setups) == 1 + assert tuple(record.name for record in setups[0].unlink_precs) == ("demo",) + assert tuple(record.name for record in setups[0].link_precs) == ("demo",) + assert (prefix / "share" / "demo.txt").read_text() == "from snapshot\n" + actual = PrefixData(prefix).get("demo") + assert actual.url == target.url + assert getattr(actual, checksum) == getattr(target, checksum) + + +@pytest.mark.parametrize( + "target_version, relink", [("3.13.2", True), ("3.12.10", False)] +) +def test_python_version_change_prepares_only_packages_that_need_linking( + target_version: str, + relink: bool, + tmp_path: Path, + monkeypatch: MonkeyPatch, +): + _, installed_python = make_package(tmp_path, "python", version="3.12.9") + _, target_python = make_package(tmp_path, "python", version=target_version) + _, noarch_python = make_package( + tmp_path, "python-tool", noarch=NoarchType.python, depends=("python",) + ) + _, retained = make_package(tmp_path, "retained") + prefix = tmp_path / "prefix" + seed_prefix(prefix, installed_python, noarch_python, retained) + snapshot = write_snapshot( + tmp_path / "snapshot.txt", target_python, noarch_python, retained + ) + setups = [] + + class PlannedTransaction: + def __init__(self, setup): + setups.append(setup) + + def execute(self): + pass + + monkeypatch.setattr("conda_self.reset.UnlinkLinkTransaction", PlannedTransaction) + + reset(prefix=str(prefix), snapshot=snapshot) + + assert len(setups) == 1 + expected = {"python", "python-tool"} if relink else {"python"} + assert {record.name for record in setups[0].unlink_precs} == expected + assert {record.name for record in setups[0].link_precs} == expected + for record in setups[0].link_precs: + assert (Path(record.extracted_package_dir) / "info" / "index.json").is_file() + + +@pytest.mark.parametrize( + "transport, failure", + [ + ("http", "missing"), + ("http", "md5"), + ("http", "sha256"), + ("file", "md5"), + ("file", "sha256"), + ("http", "corrupt"), + ("http", "index"), + ], +) +def test_package_preparation_failure_does_not_start_a_transaction( + transport: str, + failure: str, + tmp_path: Path, + monkeypatch: MonkeyPatch, + package_server: str, +): + archive, required = make_package( + tmp_path, "required", index_name="different" if failure == "index" else None + ) + if transport == "http": + required = PackageRecord.from_objects( + required, + url=f"{package_server}/channel/noarch/{archive.name}", + channel=Channel(f"{package_server}/channel"), + ) + _, extra = make_package(tmp_path, "extra") + prefix = tmp_path / "prefix" + seed_prefix(prefix, extra) + if failure == "missing": + archive.unlink() + elif failure in ("md5", "sha256"): + required = PackageRecord.from_objects( + required, **{failure: "0" * (32 if failure == "md5" else 64)} + ) + elif failure == "corrupt": + archive.write_bytes(b"not a package archive") + required = PackageRecord.from_objects( + required, sha256=hashlib.sha256(archive.read_bytes()).hexdigest() + ) + snapshot = write_snapshot( + tmp_path / "snapshot.txt", + required, + checksum="md5" if failure == "md5" else "sha256", + ) + before = { + path.relative_to(prefix): path.read_bytes() + for path in prefix.rglob("*") + if path.is_file() + } + + def fail_transaction(setup): + pytest.fail("Package preparation failed before transaction creation") + + monkeypatch.setattr("conda_self.reset.UnlinkLinkTransaction", fail_transaction) + + with pytest.raises(CondaError, match="target environment was not changed"): + reset(prefix=str(prefix), snapshot=snapshot) + + assert { + path.relative_to(prefix): path.read_bytes() + for path in prefix.rglob("*") + if path.is_file() + } == before + + +def test_extracted_package_cache_can_supply_an_unavailable_archive(tmp_path: Path): + archive, required = make_package(tmp_path, "required") + _, extra = make_package(tmp_path, "extra") + prefix = tmp_path / "prefix" + seed_prefix(prefix, extra) + snapshot = write_snapshot(tmp_path / "snapshot.txt", required) + (cached,) = get_package_records_from_explicit(snapshot.read_text().splitlines()) + archive.unlink() + Path(cached.package_tarball_full_path).unlink() + + reset(prefix=str(prefix), snapshot=snapshot) + + assert (prefix / "share" / "required.txt").read_text() == "from snapshot\n" + assert not (prefix / "share" / "extra.txt").exists() + assert tuple(record.name for record in PrefixData(prefix).iter_records()) == ( + "required", + ) + + +@pytest.mark.parametrize("checksum", ["md5", "sha256"]) +def test_rejected_local_checksums_remain_rejected_after_archive_removal( + checksum: str, + tmp_path: Path, + tmp_pkgs_dir: Path, + monkeypatch: MonkeyPatch, +): + packages = [make_package(tmp_path, name) for name in ("first", "second")] + _, extra = make_package(tmp_path, "extra") + prefix = tmp_path / "prefix" + seed_prefix(prefix, extra) + incorrect_records = [ + PackageRecord.from_objects( + record, **{checksum: "0" * (32 if checksum == "md5" else 64)} + ) + for _, record in packages + ] + snapshot = write_snapshot( + tmp_path / "snapshot.txt", *incorrect_records, checksum=checksum + ) + before = { + path.relative_to(prefix): path.read_bytes() + for path in prefix.rglob("*") + if path.is_file() + } + setups = capture_transactions(monkeypatch) + + with pytest.raises(CondaError, match="target environment was not changed"): + reset(prefix=str(prefix), snapshot=snapshot) + + cached_records = { + record.name: record for record in PackageCacheData(tmp_pkgs_dir).iter_records() + } + for archive, record in packages: + cached = cached_records[record.name] + assert getattr(cached, checksum) == getattr(record, checksum) + metadata = Path(cached.extracted_package_dir) / "info" / "repodata_record.json" + assert json.loads(metadata.read_text())[checksum] == getattr(record, checksum) + archive.unlink() + Path(cached.package_tarball_full_path).unlink() + PackageCacheData._cache_.pop(str(tmp_pkgs_dir), None) + + with pytest.raises(CondaError, match="target environment was not changed"): + reset(prefix=str(prefix), snapshot=snapshot) + + assert setups == [] + assert { + path.relative_to(prefix): path.read_bytes() + for path in prefix.rglob("*") + if path.is_file() + } == before + + write_snapshot(snapshot, *(record for _, record in packages), checksum=checksum) + reset(prefix=str(prefix), snapshot=snapshot) + + assert len(setups) == 1 + assert {record.name for record in PrefixData(prefix).iter_records()} == { + "first", + "second", + } + for _, record in packages: + assert (prefix / "share" / f"{record.name}.txt").read_text() == ( + "from snapshot\n" + ) + assert not (prefix / "share" / "extra.txt").exists()