From dce4ddb9947d7c321243f3727ab623e613c7173f Mon Sep 17 00:00:00 2001 From: Marco Esters Date: Wed, 27 Nov 2024 08:27:21 -0800 Subject: [PATCH] Add uninstall subcommand (#112) * Add uninstall subcommand. * Add support for removing environment directories * Create _is_subdir function * Specify which .condarc files to remove * Clean parent directories of config files * Rename ambiguous --clean to --conda-clean * Add doc strings * Clarify use of user and home in --remove-condarcs * Use target path to detect Windows reg keys * Improve HKEY handling * Do not support environment directories (menuinst may cause problems * Run menuinst separately from conda remove * Add support for environments directories * Determine base prefix for menuinst * Use CONDA_ROOT_PREFIX instead of MENUINST_BASE_PREFIX * Change homedir to Path.home() * Replace deprecated native_path_to_unix with win_path_to_unix * Remove empty parent directories of pkgs_dirs and cache directories * Consolidate parent removal functions * Add tests * Fix sentinel string for csh and tcsh * Remove pkgs directory when only urls files are present * Check that parent directories are removed for init reverse tests * Pre-create directories for menuinst on Windows * Force mocking user cache directory * Skip LongPathsEnabled registry key * Add conda to recipe test requirements * Update documentation * Replace deprecated List type * Add news file * Interpret conda.exe -m as conda.exe python -m to call run_plan_elevated * Use sudo where needed for test_uninstallation_remove_condarcs * Add a note that some files may be left behind with sudo * Add missing XDG_DATA_HOME location for Linux shortcuts * Convert path into string for init reverse plans * Use different condarc locations for sudo and non-sudo runs * Do not write directly into /root * Always write parents on mkdir * Catch PermissionError on exists for /root/.condarc * Use sudo -E * Instruct to use sudo -E in README * Create wrapper function to run uninstaller in tests * Make uninstall a subcommand of constructor * Use removeprefix * Check for directory before checking for file/symlink * Ensure that ON_CI=False for CI="0" * Patch HOMEDRIVE and HOMEPATH * Use conda environment fixtures * Add test_uninstallation_keep_config_dir * Set MENUINST_BASE_PREFIX for menuinst test * Simplify _is_subdir function * Warn about limited softlink support * Do not resolve paths in _remove_config_file_and_parents * Do not use conda fixtures for menuinst tests * Elaborate on lack of symlink support. * Rename --remove-caches to --remove-conda-caches * Expand variables for args.prefix * Ensure that mutually exclusive group is required without a subcommand * Clarify how --prefix is enforced * Remove empty parents only when created by conda * Return earlier when removing parents of config files * Do not resolve uninstall_prefix * Remove notices cache with package caches * Use XDG-style conventions for constructor uninstall CLI arguments * Fix typo in README * Apply suggestions from code review Co-authored-by: jaimergp * Update src/entry_point.py Co-authored-by: jaimergp --------- Co-authored-by: jaimergp --- README.md | 52 +++- news/112-add-uninstall-subcommand | 20 ++ recipe/meta.yaml | 3 +- src/entry_point.py | 382 +++++++++++++++++++++++++- tests/conftest.py | 76 ++++++ tests/requirements.txt | 3 +- tests/test_main.py | 69 ++--- tests/test_uninstall.py | 432 ++++++++++++++++++++++++++++++ 8 files changed, 972 insertions(+), 65 deletions(-) create mode 100644 news/112-add-uninstall-subcommand create mode 100644 tests/conftest.py create mode 100644 tests/test_uninstall.py diff --git a/README.md b/README.md index 6113ae7..6ca9c9e 100644 --- a/README.md +++ b/README.md @@ -50,21 +50,65 @@ It also adds new subcommands not available on the regular `conda`: This subcommand is mainly used by the installers generated with `constructor`. ```bash -$ conda.exe constructor --help -usage: conda.exe constructor [-h] --prefix PREFIX [--extract-conda-pkgs] [--extract-tarball] [--make-menus [PKG_NAME ...]] [--rm-menus] +usage: conda.exe constructor [-h] [--prefix PREFIX] [--num-processors N] + [--extract-conda-pkgs | --extract-tarball | --make-menus [PKG_NAME ...] | --rm-menus] + {uninstall} ... constructor helper subcommand -optional arguments: +positional arguments: + {uninstall} + +options: -h, --help show this help message and exit --prefix PREFIX path to the conda environment to operate on + --num-processors N Number of processors to use with --extract-conda-pkgs. Value must be int between 0 (auto) and the + number of processors. Defaults to 3. --extract-conda-pkgs extract conda packages found in prefix/pkgs --extract-tarball extract tarball from stdin --make-menus [PKG_NAME ...] - create menu items for the given packages; if none are given, create menu items for all packages in the environment specified by --prefix + create menu items for the given packages; if none are given, create menu items for all packages + in the environment specified by --prefix --rm-menus remove menu items for all packages in the environment specified by --prefix ``` +### `conda.exe constructor uninstall` + +This subcommand can be used to uninstall a base environment and all sub-environments, including +entire Miniconda/Miniforge installations. +It is also possible to remove environments directories created by `conda create`. This feature is +useful if `envs_dirs` is set inside `.condarc` file. + +There are several options to remove configuration and cache files: + +```bash +$ conda.exe constructor uninstall [-h] --prefix PREFIX [--conda-clean] [--remove-config-files {user,system,all}] + [--remove-conda-caches] +``` + +- `--prefix` (required): Path to the conda directory to uninstall. +- `--remove-caches`: + Removes the notices cache and runs `conda --clean --all` to clean package caches outside the + installation directory. This is especially useful when `pkgs_dirs` is set in a `.condarc` file. + Not recommended with multiple conda installations when softlinks are enabled. +- `--remove-config-files {user,system,all}`: + Removes all `.condarc` files. `user` removes the files inside the current user's home directory + and `system` removes all files outside of that directory. Not recommended when multiple conda + installations are on the system or when running on an environments directory. +- `--remove-user-data`: + Removes the `~/.conda` directory. Not recommended when multiple conda installations are installed + on the system or when running on an environments directory. + +> [!IMPORTANT] +> Use `sudo -E` if removing system-level configuration files requires superuser privileges. +> `conda` relies on environment variables like `HOME` and `XDG_CONFIG_HOME` when detecting +> configuration files, which may be overwritten with just `sudo`. +> This can cause files to be left behind. + +> [!WARNING] +> Support for softlinks is still untested. +> The uninstaller will only perform unlink operations and not delete any files the links point to. + ### `conda.exe python` This subcommand provides access to the Python interpreter bundled in the conda-standalone diff --git a/news/112-add-uninstall-subcommand b/news/112-add-uninstall-subcommand new file mode 100644 index 0000000..f6162db --- /dev/null +++ b/news/112-add-uninstall-subcommand @@ -0,0 +1,20 @@ +### Enhancements + +* Add `uninstall` subcommand, which uninstalls all environments inside a prefix and reverses + `conda init` commands. It also for deleting cache directories and configuration files. (#112) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* diff --git a/recipe/meta.yaml b/recipe/meta.yaml index eda4426..632ea6e 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -49,8 +49,9 @@ requirements: test: requires: - - pytest + - conda - menuinst >={{ menuinst_lower_bound }} + - pytest - ruamel.yaml source_files: - tests diff --git a/src/entry_point.py b/src/entry_point.py index 6265a89..b678d87 100755 --- a/src/entry_point.py +++ b/src/entry_point.py @@ -85,11 +85,19 @@ def __call__(self, parser, namespace, values, option_string=None): num = None # let the multiprocessing module decide setattr(namespace, self.dest, num) - p = argparse.ArgumentParser(description="constructor helper subcommand") + # Remove "constructor" so that it does not clash with the uninstall subcommand + del sys.argv[1] + p = argparse.ArgumentParser( + prog="conda.exe constructor", description="constructor helper subcommand" + ) + # Cannot use argparse to make this a required argument + # or `conda.exe constructor uninstall --prefix` + # will not work (would have to be `conda constructor --prefix uninstall`). + # Requiring `--prefix` will be enforced manually later. p.add_argument( "--prefix", action="store", - required=True, + required=False, help="path to the conda environment to operate on", ) # We can't add this option yet because micromamba doesn't support it @@ -110,7 +118,7 @@ def __call__(self, parser, namespace, values, option_string=None): f"Defaults to {DEFAULT_NUM_PROCESSORS}.", ) - g = p.add_mutually_exclusive_group(required=True) + g = p.add_mutually_exclusive_group() g.add_argument( "--extract-conda-pkgs", action="store_true", @@ -136,9 +144,66 @@ def __call__(self, parser, namespace, values, option_string=None): "in the environment specified by --prefix", ) + subcommands = p.add_subparsers(dest="command") + uninstall_subcommand = subcommands.add_parser( + "uninstall", + description="Uninstalls a conda directory and all environments inside the directory.", + ) + uninstall_subcommand.add_argument( + "--prefix", + action="store", + required=True, + help="Path to the conda directory to uninstall.", + ) + uninstall_subcommand.add_argument( + "--remove-caches", + action="store_true", + required=False, + help=( + "Removes the notices cache and runs conda --clean --all to clean package caches" + " outside the installation directory." + " This is especially useful when pkgs_dirs is set in a .condarc file." + " Not recommended with multiple conda installations when softlinks are enabled." + ), + ) + uninstall_subcommand.add_argument( + "--remove-config-files", + choices=["user", "system", "all"], + default=None, + required=False, + help=( + "Removes all .condarc files." + " `user` removes the files inside the current user's" + " home directory and `system` removes all files outside of that directory." + " Not recommended when multiple conda installations are on the system" + " or when running on an environments directory." + ), + ) + uninstall_subcommand.add_argument( + "--remove-user-data", + action="store_true", + required=False, + help=( + "Removes the ~/.conda directory." + " Not recommended when multiple conda installations are on the system" + " or when running on an environments directory." + ), + ) + args, args_unknown = p.parse_known_args() - args.prefix = os.path.abspath(args.prefix) + if args.command != "uninstall": + group_args = getattr(g, "_group_actions") + if all(getattr(args, arg.dest, None) is None for arg in group_args): + required_args = [arg.option_strings[0] for arg in group_args] + raise argparse.ArgumentError( + f"one of the following arguments are required: {'/'.join(required_args)}" + ) + + if args.prefix is None: + raise argparse.ArgumentError("the following arguments are required: --prefix") + + args.prefix = os.path.abspath(os.path.expanduser(os.path.expandvars(args.prefix))) args.root_prefix = os.path.abspath(os.environ.get("CONDA_ROOT_PREFIX", args.prefix)) if "--num-processors" in sys.argv and not args.extract_conda_pkgs: @@ -201,6 +266,297 @@ def _constructor_menuinst(prefix, pkg_names=None, root_prefix=None, remove=False install(str(json_path), remove=remove, prefix=prefix, root_prefix=root_prefix) +def _is_subdir(directory: Path, root: Path) -> bool: + """ + Helper function to detect whether a directory is a subdirectory. + + Rely on Path objects rather than string comparison to be portable. + """ + return directory == root or root in directory.parents + + +def _get_init_reverse_plan( + uninstall_prefix, + prefixes: list[Path], + for_user: bool, + for_system: bool, + anaconda_prompt: bool, +) -> list[dict]: + """ + Prepare conda init --reverse runs for the uninstallation. + + Only grab the shells that were initialized by the prefix that + is to be uninstalled since the shells within the prefix are + removed later. + """ + import re + + from conda.base.constants import COMPATIBLE_SHELLS + from conda.common.compat import on_win + from conda.common.path import win_path_to_unix + from conda.core.initialize import ( + CONDA_INITIALIZE_PS_RE_BLOCK, + CONDA_INITIALIZE_RE_BLOCK, + _read_windows_registry, + make_initialize_plan, + ) + + BIN_DIRECTORY = "Scripts" if on_win else "bin" + reverse_plan = [] + for shell in COMPATIBLE_SHELLS: + # Make plan for each shell individually because + # not every plan includes the shell name + plan = make_initialize_plan( + str(uninstall_prefix), + [shell], + for_user, + for_system, + anaconda_prompt, + reverse=True, + ) + for initializer in plan: + target_path = initializer["kwargs"]["target_path"] + if target_path.startswith("HKEY"): + # target_path for cmd.exe is a registry path + reg_entry, _ = _read_windows_registry(target_path) + if not isinstance(reg_entry, str): + continue + autorun_parts = reg_entry.split("&") + for prefix in prefixes: + hook = str(prefix / "condabin" / "conda_hook.bat") + if any(hook in part for part in autorun_parts): + reverse_plan.append(initializer) + break + else: + target_path = Path(target_path) + # Only reverse for paths that are outside the uninstall prefix + # since paths inside the uninstall prefix will be deleted anyway + if not target_path.exists() or _is_subdir( + target_path, uninstall_prefix + ): + continue + rc_content = target_path.read_text() + if shell == "powershell": + pattern = CONDA_INITIALIZE_PS_RE_BLOCK + else: + pattern = CONDA_INITIALIZE_RE_BLOCK + flags = re.MULTILINE + matches = re.findall(pattern, rc_content, flags=flags) + if not matches: + continue + for prefix in prefixes: + # Ignore .exe suffix to make the logic simpler + if shell in ("csh", "tcsh") and sys.platform != "win32": + sentinel_str = str(prefix / "etc" / "profile.d" / "conda.csh") + else: + sentinel_str = str(prefix / BIN_DIRECTORY / "conda") + if sys.platform == "win32" and shell != "powershell": + # Remove /cygdrive to make the path shell-independent + sentinel_str = win_path_to_unix(sentinel_str).removeprefix( + "/cygdrive" + ) + if any(sentinel_str in match for match in matches): + reverse_plan.append(initializer) + break + return reverse_plan + + +def _constructor_uninstall_subcommand( + uninstall_dir: str, + remove_caches: bool = False, + remove_config_files: str | None = None, + remove_user_data: bool = False, +): + """ + Remove a conda prefix or a directory containing conda environments. + + This command also provides options to remove various cache and configuration + files to fully remove a conda installation. + """ + from conda.base.constants import PREFIX_MAGIC_FILE + + # See: https://github.com/conda/conda/blob/475e6acbdc98122fcbef4733eb8cb8689324c1c8/conda/gateways/disk/create.py#L482-L488 # noqa + ENVS_DIR_MAGIC_FILE = ".conda_envs_dir_test" + + uninstall_prefix = Path(uninstall_dir) + if ( + not (uninstall_prefix / PREFIX_MAGIC_FILE).exists() + and not (uninstall_prefix / ENVS_DIR_MAGIC_FILE).exists() + ): + raise OSError( + f"{uninstall_prefix} is not a valid conda environment or environments directory." + ) + + from shutil import rmtree + + from conda.base.context import context, reset_context + from conda.cli.main import main as conda_main + from conda.core.initialize import print_plan_results, run_plan, run_plan_elevated + + def _remove_file_directory(file: Path): + """ + Try to remove a file or directory. + + If the file is a link, just unlink, do not remove the target. + """ + try: + if not file.exists(): + return + if file.is_dir(): + rmtree(file) + elif file.is_symlink() or file.is_file(): + file.unlink() + except PermissionError: + pass + + def _remove_config_file_and_parents(file: Path): + """ + Remove a configuration file and empty parent directories. + + Only remove the configuration files created by conda. + For that reason, search only for specific subdirectories + and search backwards to be conservative about what is deleted. + """ + rootdir = None + _remove_file_directory(file) + # Directories that may have been created by conda that are okay + # to be removed if they are empty. + if file.parent.parts[-1] in (".conda", "conda", "xonsh", "fish"): + rootdir = file.parent + # rootdir may be $HOME/%USERPROFILE% if the username is conda, etc. + if not rootdir or rootdir == Path.home(): + return + # Covers directories like ~/.config/conda/ + if rootdir.parts[-1] in (".config", "conda"): + rootdir = rootdir.parent + if rootdir == Path.home(): + return + parent = file.parent + while parent != rootdir.parent and not next(parent.iterdir(), None): + _remove_file_directory(parent) + parent = parent.parent + + print(f"Uninstalling conda installation in {uninstall_prefix}...") + prefixes = [ + file.parent.parent.resolve() + for file in uninstall_prefix.glob(f"**/{PREFIX_MAGIC_FILE}") + ] + # Sort by path depth. This will place the root prefix first + # Since it is more likely that profiles contain the root prefix, + # this makes loops more efficient. + prefixes.sort(key=lambda x: len(x.parts)) + + # Run conda --init reverse for the shells + # that contain a prefix that is being uninstalled + anaconda_prompt = False + print("Running conda init --reverse...") + for for_user in (True, False): + # Run user and system reversal separately because user + # and system files may contain separate paths. + for_system = not for_user + anaconda_prompt = False + plan = _get_init_reverse_plan( + uninstall_prefix, prefixes, for_user, for_system, anaconda_prompt + ) + # Do not call conda.core.initialize() because it will always run make_install_plan. + # That function will search for activation scripts in sys.prefix which do no exist + # in the extraction directory of conda-standalone. + run_plan(plan) + run_plan_elevated(plan) + print_plan_results(plan) + for initializer in plan: + target_path = initializer["kwargs"]["target_path"] + if target_path.startswith("HKEY"): + continue + target_path = Path(target_path) + if target_path.exists() and not target_path.read_text().strip(): + _remove_config_file_and_parents(target_path) + + # menuinst must be run separately because conda remove --all does not remove all shortcuts. + # This is because some placeholders depend on conda's context.root_prefix, which is set to + # the extraction directory of conda-standalone. The base prefix must be determined separately + # since the uninstallation may be pointed to an environments directory or an extra environment + # outside of the uninstall prefix. + menuinst_base_prefix = None + if conda_root_prefix := os.environ.get("CONDA_ROOT_PREFIX"): + conda_root_prefix = Path(conda_root_prefix).resolve() + menuinst_base_prefix = Path(conda_root_prefix) + # If not set by the user, assume that conda-standalone is in the base environment. + if not menuinst_base_prefix: + standalone_path = Path(sys.executable).parent + if (standalone_path / PREFIX_MAGIC_FILE).exists(): + menuinst_base_prefix = standalone_path + # Fallback: use the uninstallation directory as root_prefix + if not menuinst_base_prefix: + menuinst_base_prefix = uninstall_prefix + menuinst_base_prefix = str(menuinst_base_prefix) + + print("Removing environments...") + # Uninstalling environments must be performed with the deepest environment first. + # Otherwise, parent environments will delete the environment directory and + # uninstallation logic (removing shortcuts, pre-unlink scripts, etc.) cannot be run. + for prefix in reversed(prefixes): + prefix_str = str(prefix) + _constructor_menuinst(prefix_str, root_prefix=menuinst_base_prefix, remove=True) + # If conda_root_prefix is the same as prefix, conda remove will not be able + # to remove that environment, so temporarily unset it. + if conda_root_prefix and conda_root_prefix == prefix: + del os.environ["CONDA_ROOT_PREFIX"] + reset_context() + conda_main("remove", "-y", "-p", prefix_str, "--all") + if conda_root_prefix and conda_root_prefix == prefix: + os.environ["CONDA_ROOT_PREFIX"] = str(conda_root_prefix) + reset_context() + + if uninstall_prefix.exists(): + # If the uninstall prefix is an environments directory, + # it should only contain the magic file. + # On Windows, the directory might still exist if conda-standalone + # tries to delete itself (it gets renamed to a .conda_trash file). + # In that case, the directory cannot be deleted - this needs to be + # done by the uninstaller. + delete_uninstall_prefix = True + for file in uninstall_prefix.iterdir(): + if not file.name == ENVS_DIR_MAGIC_FILE: + delete_uninstall_prefix = False + break + if delete_uninstall_prefix: + _remove_file_directory(uninstall_prefix) + + if remove_caches: + print("Cleaning cache directories.") + from conda.notices.cache import get_notices_cache_dir + conda_main("clean", "--all", "-y") + # Delete empty package cache directories + for directory in context.pkgs_dirs: + pkgs_dir = Path(directory) + if not pkgs_dir.exists(): + continue + expected_files = [pkgs_dir / "urls", pkgs_dir / "urls.txt"] + if all(file in expected_files for file in pkgs_dir.iterdir()): + _remove_file_directory(pkgs_dir) + + notices_dir = Path(get_notices_cache_dir()).expanduser() + _remove_config_file_and_parents(notices_dir) + + if remove_config_files: + print("Removing .condarc files...") + for config_file in context.config_files: + if remove_config_files == "user" and not _is_subdir( + config_file.parent, Path.home() + ): + continue + elif remove_config_files == "system" and _is_subdir( + config_file.parent, Path.home() + ): + continue + _remove_config_file_and_parents(config_file) + + if remove_user_data: + print("Removing user data...") + _remove_file_directory(Path("~/.conda").expanduser()) + + def _constructor_subcommand(): r""" This is the entry point for the `conda constructor` subcommand. This subcommand @@ -211,8 +567,17 @@ def _constructor_subcommand(): - invoke menuinst to create and remove menu items on Windows """ args, _ = _constructor_parse_cli() - os.chdir(args.prefix) + if args.command == "uninstall": + _constructor_uninstall_subcommand( + args.prefix, + remove_caches=args.remove_caches, + remove_config_files=args.remove_config_files, + remove_user_data=args.remove_user_data, + ) + # os.chdir will break conda --clean, so return early + return + os.chdir(args.prefix) if args.extract_conda_pkgs: _constructor_extract_conda_pkgs(args.prefix, max_workers=args.num_processors) @@ -248,7 +613,8 @@ def _python_subcommand(): - stdin: run the passed input as if it was '-c' """ - del sys.argv[1] # remove the 'python' argument + if sys.argv[1] == "python": + del sys.argv[1] first_arg = sys.argv[1] if len(sys.argv) > 1 else None if first_arg is None: @@ -313,7 +679,9 @@ def main(): if len(sys.argv) > 1: if sys.argv[1] == "constructor": return _constructor_subcommand() - elif sys.argv[1] == "python": + # Some parts of conda call `sys.executable -m`, so conda-standalone needs to + # interpret `conda.exe -m` as `conda.exe python -m`. + elif sys.argv[1] == "python" or sys.argv[1] == "-m": return _python_subcommand() return _conda_main() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4dc3a91 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,76 @@ +import os +import subprocess +import sys +from pathlib import Path + +# TIP: You can debug the tests with this setup: +# CONDA_STANDALONE=src/entry_point.py pytest ... +CONDA_EXE = os.environ.get( + "CONDA_STANDALONE", + os.path.join(sys.prefix, "standalone_conda", "conda.exe"), +) + + +menuinst_pkg_specs = [ + ( + "conda-test/label/menuinst-tests::package_1", + { + "win32": "Package 1/A.lnk", + "darwin": "A.app/Contents/MacOS/a", + "linux": "package-1_a.desktop", + }, + ), +] +if os.name == "nt": + menuinst_pkg_specs.append( + ( + "conda-forge::miniforge_console_shortcut", + {"win32": "{base}/{base} Prompt ({name}).lnk"}, + ), + ) + + +def run_conda(*args, **kwargs) -> subprocess.CompletedProcess: + check = kwargs.pop("check", False) + sudo = None + if "needs_sudo" in kwargs: + if kwargs["needs_sudo"]: + if sys.platform == "win32": + raise NotImplementedError( + "Calling run_conda with elevated privileged is not available on Windows" + ) + sudo = ["sudo", "-E"] + del kwargs["needs_sudo"] + cmd = [*sudo, CONDA_EXE] if sudo else [CONDA_EXE] + + process = subprocess.run([*cmd, *args], **kwargs) + if check: + if kwargs.get("capture_output"): + print(process.stdout) + print(process.stderr, file=sys.stderr) + process.check_returncode() + return process + + +def _get_shortcut_dirs() -> list[Path]: + if sys.platform == "win32": + from menuinst.platforms.win_utils.knownfolders import dirs_src as win_locations + + return [ + Path(win_locations["user"]["start"][0]), + Path(win_locations["system"]["start"][0]), + ] + if sys.platform == "darwin": + return [ + Path(os.environ["HOME"], "Applications"), + Path("/Applications"), + ] + if sys.platform == "linux": + paths = [ + Path(os.environ["HOME"], ".local", "share", "applications"), + Path("/usr/share/applications"), + ] + if xdg_data_home := os.environ.get("XDG_DATA_HOME"): + paths.append(Path(xdg_data_home, "applications")) + return paths + raise NotImplementedError(sys.platform) diff --git a/tests/requirements.txt b/tests/requirements.txt index ad6d668..c5a151b 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,3 +1,4 @@ -pytest +conda menuinst>=2 +pytest ruamel.yaml diff --git a/tests/test_main.py b/tests/test_main.py index 453f793..fb658a0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -9,44 +9,12 @@ from pathlib import Path import pytest +from conftest import CONDA_EXE, _get_shortcut_dirs, menuinst_pkg_specs, run_conda from ruamel.yaml import YAML -# TIP: You can debug the tests with this setup: -# CONDA_STANDALONE=src/entry_point.py pytest ... -CONDA_EXE = os.environ.get( - "CONDA_STANDALONE", - os.path.join(sys.prefix, "standalone_conda", "conda.exe"), -) HERE = Path(__file__).parent -def run_conda(*args, **kwargs) -> subprocess.CompletedProcess: - check = kwargs.pop("check", False) - process = subprocess.run([CONDA_EXE, *args], **kwargs) - if check: - if kwargs.get("capture_output") and process.returncode: - print(process.stdout) - print(process.stderr, file=sys.stderr) - process.check_returncode() - return process - - -def _get_shortcut_dirs(): - if sys.platform == "win32": - from menuinst.platforms.win_utils.knownfolders import dirs_src as win_locations - - return Path(win_locations["user"]["start"][0]), Path( - win_locations["system"]["start"][0] - ) - if sys.platform == "darwin": - return Path(os.environ["HOME"], "Applications"), Path("/Applications") - if sys.platform == "linux": - return Path(os.environ["HOME"], ".local", "share", "applications"), Path( - "/usr/share/applications" - ) - raise NotImplementedError(sys.platform) - - @pytest.mark.parametrize("solver", ["classic", "libmamba"]) def test_new_environment(tmp_path, solver): env = os.environ.copy() @@ -69,6 +37,18 @@ def test_constructor(): run_conda("constructor", "--help", check=True) +@pytest.mark.parametrize( + "args", + ( + pytest.param(["--prefix", "path"], id="missing command"), + pytest.param(["--extract-conda-pkgs"], id="missing prefix"), + ), +) +def test_constructor_missing_arguments(args: list[str]): + with pytest.raises(subprocess.CalledProcessError): + run_conda("constructor", *args, check=True) + + @pytest.mark.parametrize("search_paths", ("all_rcs", "--no-rc", "env_var")) def test_conda_standalone_config(search_paths, tmp_path, monkeypatch): expected_configs = {} @@ -189,28 +169,13 @@ def test_extract_conda_pkgs_num_processors(tmp_path: Path): ) -_pkg_specs = [ - ( - "conda-test/label/menuinst-tests::package_1", - { - "win32": "Package 1/A.lnk", - "darwin": "A.app/Contents/MacOS/a", - "linux": "package-1_a.desktop", - }, - ), -] -if os.name == "nt": - _pkg_specs.append( - ( - "conda-forge::miniforge_console_shortcut", - {"win32": "{base}/{base} Prompt ({name}).lnk"}, - ), - ) -_pkg_specs_params = pytest.mark.parametrize("pkg_spec, shortcut_path", _pkg_specs) +_pkg_specs_params = pytest.mark.parametrize( + "pkg_spec, shortcut_path", menuinst_pkg_specs +) @_pkg_specs_params -def test_menuinst_conda(tmp_path: Path, pkg_spec: str, shortcut_path: str): +def test_menuinst_conda(tmp_path: Path, pkg_spec: str, shortcut_path: dict[str, str]): "Check 'regular' conda can process menuinst JSONs" env = os.environ.copy() env["CONDA_ROOT_PREFIX"] = sys.prefix diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py new file mode 100644 index 0000000..9294251 --- /dev/null +++ b/tests/test_uninstall.py @@ -0,0 +1,432 @@ +from __future__ import annotations + +import os +import sys +from contextlib import nullcontext +from pathlib import Path +from shutil import rmtree +from subprocess import SubprocessError +from typing import TYPE_CHECKING + +import pytest +from conda.base.constants import COMPATIBLE_SHELLS +from conda.common.path import win_path_to_unix +from conda.core.initialize import ( + _read_windows_registry, + make_initialize_plan, + run_plan, + run_plan_elevated, +) +from conftest import _get_shortcut_dirs, menuinst_pkg_specs, run_conda +from ruamel.yaml import YAML + +if TYPE_CHECKING: + from conda.testing.fixtures import CondaCLIFixture, TmpEnvFixture + from pytest import MonkeyPatch + +ON_WIN = sys.platform == "win32" +ON_MAC = sys.platform == "darwin" +ON_LINUX = not (ON_WIN or ON_MAC) +ON_CI = bool(os.environ.get("CI")) and os.environ.get("CI") != "0" +CONDA_CHANNEL = os.environ.get("CONDA_STANDALONE_TEST_CHANNEL", "conda-forge") + +pytest_plugins = ["conda.testing.fixtures"] + + +@pytest.fixture(scope="function") +def mock_system_paths( + monkeypatch: MonkeyPatch, + tmp_path: Path, +) -> dict[str, Path]: + paths = {} + if ON_WIN: + homedir = tmp_path / "Users" / "conda" + monkeypatch.setenv("USERPROFILE", str(homedir)) + monkeypatch.setenv("HOMEDRIVE", homedir.anchor[:-1]) + monkeypatch.setenv("HOMEPATH", f"\\{homedir.relative_to(homedir.anchor)}") + # Monkeypatching LOCALAPPDATA will not help because user_cache_dir + # typically does not use environment variables + cachehome = homedir / "AppData" / "Local" + elif ON_MAC: + homedir = tmp_path / "Users" / "conda" + cachehome = homedir / "Library" / "Caches" + monkeypatch.setenv("HOME", str(homedir)) + else: + homedir = tmp_path / "home" / "conda" + monkeypatch.setenv("HOME", str(homedir)) + cachehome = homedir / "cache" + + paths = { + "home": homedir, + "cachehome": cachehome, + "confighome": homedir / "config", + "datahome": homedir / "data", + } + for mockdir in paths.values(): + mockdir.mkdir(parents=True, exist_ok=True) + + monkeypatch.setenv("XDG_CONFIG_HOME", str(paths["confighome"])) + monkeypatch.setenv("XDG_CACHE_HOME", str(paths["cachehome"])) + monkeypatch.setenv("XDG_DATA_HOME", str(paths["datahome"])) + return paths + + +def run_uninstaller( + prefix: Path, + remove_caches: bool = False, + remove_config_files: str | None = None, + remove_user_data: bool = False, + needs_sudo: bool = False, +): + args = ["--prefix", str(prefix)] + if remove_caches: + args.append("--remove-caches") + if remove_config_files: + args.extend(["--remove-config-files", remove_config_files]) + if remove_user_data: + args.append("--remove-user-data") + run_conda("constructor", "uninstall", *args, needs_sudo=needs_sudo, check=True) + + +def test_uninstallation( + mock_system_paths: dict[str, Path], + tmp_env: TmpEnvFixture, +): + environments_txt = mock_system_paths["home"] / ".conda" / "environments.txt" + with tmp_env() as base_env, tmp_env() as second_env: + assert environments_txt.exists() + environments = environments_txt.read_text().splitlines() + assert str(base_env) in environments and str(second_env) in environments + run_uninstaller(base_env) + assert not base_env.exists() + environments = environments_txt.read_text().splitlines() + assert str(base_env) not in environments and str(second_env) in environments + + +@pytest.mark.parametrize( + "remove", (True, False), ids=("remove directory", "keep directory") +) +def test_uninstallation_envs_dirs( + mock_system_paths: dict[str, Path], + conda_cli: CondaCLIFixture, + remove: bool, +): + yaml = YAML() + envs_dir = mock_system_paths["home"] / "envs" + testenv_name = "testenv" + testenv_dir = envs_dir / testenv_name + condarc = { + "envs_dirs": [str(envs_dir)], + } + with open(mock_system_paths["home"] / ".condarc", "w") as crc: + yaml.dump(condarc, crc) + conda_cli("create", "-n", testenv_name, "-y") + assert envs_dir.exists() + assert envs_dir / ".conda_envs_dir_test" + assert testenv_dir.exists() + # conda-standalone should not remove the environments directory + # if it finds other files than the magic file. + if not remove: + (envs_dir / "some_other_file").touch() + run_uninstaller(envs_dir) + assert envs_dir.exists() != remove + + +@pytest.mark.skipif( + ON_WIN and not ON_CI, + reason="CI only - interacts with user files and the registry", +) +@pytest.mark.parametrize( + "for_user,reverse", + ( + pytest.param(True, True, id="user, reverse"), + pytest.param(False, True, id="system, reverse"), + pytest.param(True, False, id="user, no reverse"), + pytest.param(False, False, id="system, no reverse"), + ), +) +def test_uninstallation_init_reverse( + mock_system_paths: dict[str, Path], + monkeypatch: MonkeyPatch, + tmp_env: TmpEnvFixture, + for_user: bool, + reverse: bool, +): + def _find_in_config(directory: str, target_path: str) -> bool: + if target_path.startswith("HKEY"): + reg_entry, _ = _read_windows_registry(target_path) + if not isinstance(reg_entry, str): + return False + return directory in reg_entry + else: + config_file = Path(target_path) + if not config_file.exists(): + return False + content = config_file.read_text() + if sys.platform == "win32" and not target_path.endswith(".ps1"): + directory = win_path_to_unix(directory).removeprefix("/cygdrive") + return directory in content + + # Patch out make_install_plan since it won't be used for uninstallation + # and breaks for conda-standalone + monkeypatch.setattr("conda.core.initialize.make_install_plan", lambda _: []) + if not for_user and not ON_CI: + pytest.skip("CI only - interacts with system files") + with tmp_env() as base_env: + anaconda_prompt = False + if reverse: + init_env = str(base_env) + else: + init_env = str(mock_system_paths["home"] / "testenv") + initialize_plan = make_initialize_plan( + init_env, + COMPATIBLE_SHELLS, + for_user, + not for_user, + anaconda_prompt, + reverse=False, + ) + # Filter out the LongPathsEnabled target since conda init --reverse does not remove it + initialize_plan = [ + plan + for plan in initialize_plan + if not plan["kwargs"]["target_path"].endswith("LongPathsEnabled") + ] + run_plan(initialize_plan) + run_plan_elevated(initialize_plan) + for plan in initialize_plan: + assert _find_in_config(init_env, plan["kwargs"]["target_path"]) + run_uninstaller(base_env) + for plan in initialize_plan: + target_path = plan["kwargs"]["target_path"] + assert _find_in_config(init_env, target_path) != reverse + if not reverse: + continue + parent = Path(target_path).parent + if parent == mock_system_paths["home"]: + assert parent.exists() + elif parent.name in ("fish", ".conda", "conda", "xonsh"): + assert not parent.exists() + + +def test_uninstallation_keep_config_dir( + mock_system_paths: dict[str, Path], + monkeypatch: MonkeyPatch, + tmp_env: TmpEnvFixture, +): + config_dir = mock_system_paths["home"] / ".config" + fish_config_dir = config_dir / "fish" + other_config_dir = config_dir / "other" + other_config_dir.mkdir(parents=True) + # Patch out make_install_plan since it won't be used for uninstallation + # and breaks for conda-standalone + monkeypatch.setattr("conda.core.initialize.make_install_plan", lambda _: []) + for_user = True + for_system = False + anaconda_prompt = False + shells = ["bash", "fish"] + with tmp_env() as base_env: + initialize_plan = make_initialize_plan( + base_env, + shells, + for_user, + for_system, + anaconda_prompt, + reverse=False, + ) + run_plan(initialize_plan) + assert fish_config_dir.exists() + run_uninstaller(base_env) + assert mock_system_paths["home"].exists() + assert not fish_config_dir.exists() + assert other_config_dir.exists() + + +def test_uninstallation_menuinst( + mock_system_paths: dict[str, Path], + monkeypatch: MonkeyPatch, +): + def _shortcuts_found(shortcut_env: Path) -> list: + variables = { + "base": base_env.name, + "name": shortcut_env.name, + } + shortcut_dirs = _get_shortcut_dirs() + if ON_WIN: + # For Windows, menuinst installed via conda does not pick up on the monkeypatched + # environment variables, so add the hard-coded patched directory. + # They do get patched for conda-standalone though. + programs = "AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs" + shortcut_dirs.append(mock_system_paths["home"] / programs) + + return [ + package[0] + for package in menuinst_pkg_specs + if any( + (folder / package[1][sys.platform].format(**variables)).is_file() + for folder in shortcut_dirs + ) + ] + + if ON_WIN: + # menuinst will error out if the directories it installs into do not exist. + for subdir in ( + "Desktop", + "Documents", + "AppData\\Local", + "AppData\\Roaming\\Microsoft\\Internet Explorer\\Quick Launch", + "AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs", + ): + (mock_system_paths["home"] / subdir).mkdir(parents=True, exist_ok=True) + base_env = mock_system_paths["home"] / "baseenv" + monkeypatch.setenv("CONDA_ROOT_PREFIX", str(base_env)) + # Conda test fixtures cannot be used here because menuinst + # will not use monkeypatched paths. + run_conda("create", "-y", "-p", str(base_env)) + (base_env / ".nonadmin").touch() + shortcuts = [package[0] for package in menuinst_pkg_specs] + shortcut_env = base_env / "envs" / "shortcutenv" + run_conda("create", "-y", "-p", str(shortcut_env), *shortcuts) + assert _shortcuts_found(shortcut_env) == shortcuts + run_uninstaller(base_env) + assert _shortcuts_found(shortcut_env) == [] + + +@pytest.mark.parametrize( + "shared_pkgs", + (True, False), + ids=("shared pkgs", "remove pkgs"), +) +def test_uninstallation_remove_caches( + mock_system_paths: dict[str, Path], + tmp_env: TmpEnvFixture, + shared_pkgs: bool, +): + # Set up notices + if ON_WIN: + try: + import ctypes + + if not hasattr(ctypes, "windll"): + pytest.skip("Test requires windll.ctypes for mocked locations to work.") + except ImportError: + pytest.skip("Test requires ctypes for mocked locations to work.") + notices_dir = Path( + mock_system_paths["cachehome"], "conda", "conda", "Cache", "notices" + ) + else: + notices_dir = Path(mock_system_paths["cachehome"], "conda", "notices") + notices_dir.mkdir(parents=True, exist_ok=True) + (notices_dir / "notices.cache").touch() + + yaml = YAML() + pkgs_dir = mock_system_paths["home"] / "pkgs" + condarc = { + "channels": [CONDA_CHANNEL], + "pkgs_dirs": [str(pkgs_dir)], + } + with open(mock_system_paths["home"] / ".condarc", "w") as crc: + yaml.dump(condarc, crc) + + other_env = tmp_env("python") if shared_pkgs else nullcontext() + with ( + tmp_env("constructor") as base_env, + other_env as _, + ): + assert pkgs_dir.exists() + assert list(pkgs_dir.glob("constructor*")) != [] + assert list(pkgs_dir.glob("python*")) != [] + run_uninstaller(base_env, remove_caches=True) + assert pkgs_dir.exists() == shared_pkgs + if shared_pkgs: + assert list(pkgs_dir.glob("constructor*")) == [] + assert list(pkgs_dir.glob("python*")) != [] + assert not notices_dir.exists() + + +def test_uninstallation_remove_user_data( + mock_system_paths: dict[str, Path], + tmp_env: TmpEnvFixture, +): + with tmp_env() as base_env: + dot_conda_dir = mock_system_paths["home"] / ".conda" + assert dot_conda_dir.exists() + run_uninstaller(base_env, remove_user_data=True) + assert not dot_conda_dir.exists() + + +@pytest.mark.parametrize("remove", ("user", "system", "all")) +@pytest.mark.skipif(not ON_CI, reason="CI only - Writes to system files") +def test_uninstallation_remove_config_files( + mock_system_paths: dict[str, Path], + tmp_env: TmpEnvFixture, + remove: str, +): + yaml = YAML() + remove_system = remove != "user" + remove_user = remove != "system" + condarc = { + "channels": [CONDA_CHANNEL], + } + needs_sudo = False + if ON_WIN: + system_condarc = Path("C:/ProgramData/conda/.condarc") + else: + system_condarc = Path("/etc/conda/.condarc") + try: + system_condarc.parent.mkdir(parents=True, exist_ok=True) + except PermissionError: + needs_sudo = True + user_condarc = mock_system_paths["confighome"] / "conda" / ".condarc" + for condarc_file in (system_condarc, user_condarc): + if needs_sudo and condarc_file == system_condarc: + from textwrap import dedent + + # Running shutil does not work using python -m, + # so create a temporary script and run as sudo. + # Since datahome is the temporary location since + # it is not used in this test. + tmp_condarc_dir = mock_system_paths["datahome"] / ".tmp_condarc" + tmp_condarc_dir.mkdir(parents=True, exist_ok=True) + tmp_condarc_file = tmp_condarc_dir / ".condarc" + with open(tmp_condarc_file, "w") as crc: + yaml.dump(condarc, crc) + script = dedent( + f""" + from pathlib import Path + from shutil import copyfile + + condarc_file = Path("{condarc_file}") + condarc_file.parent.mkdir(parents=True, exist_ok=True) + copyfile("{tmp_condarc_file}", condarc_file) + """ + ) + script_file = tmp_condarc_dir / "copy_condarc.py" + script_file.write_text(script) + run_conda("python", script_file, needs_sudo=True) + rmtree(tmp_condarc_dir) + else: + condarc_file.parent.mkdir(parents=True, exist_ok=True) + with open(condarc_file, "w") as crc: + yaml.dump(condarc, crc) + with tmp_env() as base_env: + try: + run_uninstaller(base_env, remove_config_files=remove, needs_sudo=needs_sudo) + assert user_condarc.exists() != remove_user + assert system_condarc.exists() != remove_system + finally: + if system_condarc.parent.exists(): + try: + rmtree(system_condarc.parent) + except PermissionError: + run_conda( + "python", + "-c", + f"from shutil import rmtree; rmtree('{system_condarc.parent}')", + needs_sudo=True, + ) + + +def test_uninstallation_invalid_directory(tmp_path: Path): + with pytest.raises(SubprocessError): + run_uninstaller(tmp_path)