diff --git a/CONSTRUCT.md b/CONSTRUCT.md index ed5e3ad66..401a3c2d4 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -869,6 +869,14 @@ Allowed keys are: license text. Only relevant if include_text is True. Any str accepted by open()'s 'errors' argument is valid. See https://docs.python.org/3/library/functions.html#open. +### `uninstall_with_conda_exe` + +_required:_ no
+_type:_ boolean
+ +Use the standalone binary to perform the uninstallation. +Requires conda-standalone 24.11.0 or newer. + ## Available selectors - `aarch64` diff --git a/constructor/construct.py b/constructor/construct.py index 2f80974cb..795284f5b 100644 --- a/constructor/construct.py +++ b/constructor/construct.py @@ -643,6 +643,11 @@ - `text_errors` (optional str, default=`None`): How to handle decoding errors when reading the license text. Only relevant if include_text is True. Any str accepted by open()'s 'errors' argument is valid. See https://docs.python.org/3/library/functions.html#open. +'''), + + ('uninstall_with_conda_exe', False, bool, ''' +Use the standalone binary to perform the uninstallation. +Requires conda-standalone 24.11.0 or newer. '''), ] diff --git a/constructor/main.py b/constructor/main.py index 79fa8c3ef..017670bd8 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -102,6 +102,14 @@ def main_build(dir_path, output_dir='.', platform=cc_platform, # TODO: Investigate errors on Windows and re-enable sys.exit("Error: micromamba is not supported on Windows installers.") + if ( + info.get("uninstall_with_conda_exe") + and not ( + exe_type == StandaloneExe.CONDA and exe_version and exe_version >= Version("24.11.0") + ) + ): + sys.exit("Error: uninstalling with conda.exe requires conda-standalone 24.11.0 or newer.") + logger.debug('conda packages download: %s', info['_download_dir']) for key in ('welcome_image_text', 'header_image_text'): @@ -184,7 +192,7 @@ def main_build(dir_path, output_dir='.', platform=cc_platform, "Will assume it is compatible with shortcuts." ) elif sys.platform != "win32" and ( - exe_type != StandaloneExe.CONDA or exe_version < Version("23.11.0") + exe_type != StandaloneExe.CONDA or (exe_version and exe_version < Version("23.11.0")) ): logger.warning("conda-standalone 23.11.0 or above is required for shortcuts on Unix.") info['_enable_shortcuts'] = "incompatible" diff --git a/constructor/nsis/StandaloneUninstallerOptions.nsh b/constructor/nsis/StandaloneUninstallerOptions.nsh new file mode 100644 index 000000000..4da385ae5 --- /dev/null +++ b/constructor/nsis/StandaloneUninstallerOptions.nsh @@ -0,0 +1,108 @@ +var UninstCustomOptions +var UninstCustomOptions.RemoveConfigFiles_User +var UninstCustomOptions.RemoveConfigFiles_System +var UninstCustomOptions.RemoveUserData +var UninstCustomOptions.RemoveCaches + +# These are the checkbox states, to be used by the uninstaller +var UninstRemoveConfigFiles_User_State +var UninstRemoveConfigFiles_System_State +var UninstRemoveUserData_State +var UninstRemoveCaches_State + +Function un.UninstCustomOptions_InitDefaults + StrCpy $UninstRemoveConfigFiles_User_State ${BST_UNCHECKED} + StrCpy $UninstRemoveConfigFiles_System_State ${BST_UNCHECKED} + StrCpy $UninstRemoveUserData_State ${BST_UNCHECKED} + StrCpy $UninstRemoveCaches_State ${BST_UNCHECKED} +FunctionEnd + +Function un.UninstCustomOptions_Show + ${If} $UninstRemoveCaches_State == "" + Abort + ${EndIf} + # Create dialog + nsDialogs::Create 1018 + Pop $UninstCustomOptions + ${If} $UninstCustomOptions == error + Abort + ${EndIf} + + !insertmacro MUI_HEADER_TEXT \ + "Advanced uninstallation options" \ + "Remove configuration, data, and cache files" + + # We will use $5 as the y axis accumulator, starting at 0 + # We sum the the number of 'u' units added by 'NSD_Create*' functions + IntOp $5 0 + 0 + + # Option to remove configuration files + ${NSD_CreateCheckbox} 0 "$5u" 100% 11u "Remove user configuration files." + IntOp $5 $5 + 11 + Pop $UninstCustomOptions.RemoveConfigFiles_User + ${NSD_SetState} $UninstCustomOptions.RemoveConfigFiles_User $UninstRemoveConfigFiles_User_State + ${NSD_OnClick} $UninstCustomOptions.RemoveConfigFiles_User un.UninstRemoveConfigFiles_User_Onclick + ${NSD_CreateLabel} 5% "$5u" 90% 10u \ + "This removes configuration files such as .condarc files in the Users directory." + IntOp $5 $5 + 10 + + ${If} ${UAC_IsAdmin} + ${NSD_CreateCheckbox} 0 "$5u" 100% 11u "Remove system-wide configuration files." + IntOp $5 $5 + 11 + Pop $UninstCustomOptions.RemoveConfigFiles_System + ${NSD_SetState} $UninstCustomOptions.RemoveConfigFiles_System $UninstRemoveConfigFiles_System_State + ${NSD_OnClick} $UninstCustomOptions.RemoveConfigFiles_System un.UninstRemoveConfigFiles_System_Onclick + ${NSD_CreateLabel} 5% "$5u" 90% 10u \ + "This removes configuration files such as .condarc files in the ProgramData directory." + IntOp $5 $5 + 10 + ${EndIf} + + # Option to remove user data files + ${NSD_CreateCheckbox} 0 "$5u" 100% 11u "Remove user data." + IntOp $5 $5 + 11 + Pop $UninstCustomOptions.RemoveUserData + ${NSD_SetState} $UninstCustomOptions.RemoveUserData $UninstRemoveUserData_State + ${NSD_OnClick} $UninstCustomOptions.RemoveUserData un.UninstRemoveUserData_Onclick + ${NSD_CreateLabel} 5% "$5u" 90% 10u \ + "This removes user data files such as the .conda directory inside the Users folder." + IntOp $5 $5 + 10 + + # Option to remove caches + ${NSD_CreateCheckbox} 0 "$5u" 100% 11u "Remove caches." + IntOp $5 $5 + 11 + Pop $UninstCustomOptions.RemoveCaches + ${NSD_SetState} $UninstCustomOptions.RemoveCaches $UninstRemoveCaches_State + ${NSD_OnClick} $UninstCustomOptions.RemoveCaches un.UninstRemoveCaches_Onclick + ${NSD_CreateLabel} 5% "$5u" 90% 10u \ + "This removes cache directories such as package caches and notices." + IntOp $5 $5 + 20 + + IntOp $5 $5 + 5 + ${NSD_CreateLabel} 0 "$5u" 100% 10u \ + "These options are not recommended if multiple conda installations exist on the same system." + IntOp $5 $5 + 10 + Pop $R0 + SetCtlColors $R0 ff0000 transparent + + nsDialogs::Show +FunctionEnd + +Function un.UninstRemoveConfigFiles_User_OnClick + Pop $0 + ${NSD_GetState} $0 $UninstRemoveConfigFiles_User_State +FunctionEnd + +Function un.UninstRemoveConfigFiles_System_OnClick + Pop $0 + ${NSD_GetState} $0 $UninstRemoveConfigFiles_System_State +FunctionEnd + +Function un.UninstRemoveUserData_OnClick + Pop $0 + ${NSD_GetState} $0 $UninstRemoveUserData_State +FunctionEnd + +Function un.UninstRemoveCaches_OnClick + Pop $0 + ${NSD_GetState} $0 $UninstRemoveCaches_State +FunctionEnd diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index 4e2722194..d38fb93b2 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -67,6 +67,10 @@ var /global StdOutHandleSet !include "Utils.nsh" +{%- if uninstall_with_conda_exe %} +!include "StandaloneUninstallerOptions.nsh" +{%- endif %} + !define NAME {{ installer_name }} !define VERSION {{ installer_version }} !define COMPANY {{ company }} @@ -110,6 +114,11 @@ var /global ARGV_NoScripts var /global ARGV_NoShortcuts var /global ARGV_CheckPathLength var /global ARGV_QuietMode +{%- if uninstall_with_conda_exe %} +var /global ARGV_Uninst_RemoveConfigFiles +var /global ARGV_Uninst_RemoveUserData +var /global ARGV_Uninst_RemoveCaches +{%- endif %} var /global IsDomainUser var /global CheckPathLength @@ -195,7 +204,7 @@ Page Custom mui_AnaCustomOptions_Show {%- if custom_conclusion %} # Custom conclusion file(s) {{ CUSTOM_CONCLUSION_FILE }} -#else +{%- else %} !insertmacro MUI_PAGE_FINISH {%- endif %} @@ -203,6 +212,9 @@ Page Custom mui_AnaCustomOptions_Show !insertmacro MUI_UNPAGE_WELCOME !define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.OnDirectoryLeave !insertmacro MUI_UNPAGE_CONFIRM +{%- if uninstall_with_conda_exe %} +UninstPage Custom un.UninstCustomOptions_Show +{%- endif %} !insertmacro MUI_UNPAGE_INSTFILES !insertmacro MUI_UNPAGE_FINISH @@ -716,7 +728,7 @@ Function .onInit Pop $0 FunctionEnd -Function un.onInit +!macro un.ParseCommandLineArgs ClearErrors ${GetParameters} $ARGV ${GetOptions} $ARGV "/?" $ARGV_Help @@ -736,6 +748,11 @@ Function un.onInit /? (show this help message)$\n\ /S (run in CLI/headless mode)$\n\ /Q (quiet mode, do not print output to console)$\n\ +{%- if uninstall_with_conda_exe %} + /RemoveCaches=[0|1] [default: 0]$\n\ + /RemoveConfigFiles=[none|users|system|all] [default: none]$\n\ + /RemoveUserData=[0|1] [default: 0]$\n\ +{%- endif %} /_?=[installation directory] (must be last parameter)$\n\ $\n\ EXAMPLES$\n\ @@ -747,8 +764,7 @@ Function un.onInit Closing in 10s..." # Give it some time so users can read it the pop-up console # The pop-up console happens because the uninstaller copies itself to - # a temporary location because actually running, so we can't get the parent - # console handle + # a temporary location, so we can't get the parent console handle Sleep 10000 Abort ${EndIf} @@ -758,6 +774,59 @@ Function un.onInit ${IfNot} ${Errors} StrCpy $QuietMode "1" ${EndIf} +{%- if uninstall_with_conda_exe %} + ClearErrors + ${GetOptions} $ARGV "/RemoveConfigFiles=" $ARGV_Uninst_RemoveConfigFiles + ${IfNot} ${Errors} + ${IfNot} ${UAC_IsAdmin} + ${If} $ARGV_Uninst_RemoveConfigFiles == "all" + ${OrIf} $ARGV_Uninst_RemoveConfigFiles == "system" + MessageBox MB_ICONSTOP "Removing system .condarc files requires an elevated prompt." + Abort + ${EndIf} + ${EndIf} + ${If} $ARGV_Uninst_RemoveConfigFiles == "user" + StrCpy $UninstRemoveConfigFiles_User_State ${BST_CHECKED} + StrCpy $UninstRemoveConfigFiles_System_State ${BST_UNCHECKED} + ${ElseIf} $ARGV_Uninst_RemoveConfigFiles == "system" + StrCpy $UninstRemoveConfigFiles_User_State ${BST_UNCHECKED} + StrCpy $UninstRemoveConfigFiles_System_State ${BST_CHECKED} + ${ElseIf} $ARGV_Uninst_RemoveConfigFiles == "all" + StrCpy $UninstRemoveConfigFiles_User_State ${BST_CHECKED} + StrCpy $UninstRemoveConfigFiles_System_State ${BST_CHECKED} + ${Else} + StrCpy $UninstRemoveConfigFiles_User_State ${BST_UNCHECKED} + StrCpy $UninstRemoveConfigFiles_System_State ${BST_UNCHECKED} + ${EndIf} + ${EndIf} + + ClearErrors + ${GetOptions} $ARGV "/RemoveUserData=" $ARGV_Uninst_RemoveUserData + ${IfNot} ${Errors} + ${If} $ARGV_Uninst_RemoveUserData = "1" + StrCpy $UninstRemoveUserData_State ${BST_CHECKED} + ${ElseIf} $ARGV_Uninst_RemoveUserData = "0" + StrCpy $UninstRemoveUserData_State ${BST_UNCHECKED} + ${EndIf} + ${EndIf} + + ClearErrors + ${GetOptions} $ARGV "/RemoveCaches=" $ARGV_Uninst_RemoveCaches + ${IfNot} ${Errors} + ${If} $ARGV_Uninst_RemoveCaches = "1" + StrCpy $UninstRemoveCaches_State ${BST_CHECKED} + ${ElseIf} $ARGV_Uninst_RemoveCaches = "0" + StrCpy $UninstRemoveCaches_State ${BST_UNCHECKED} + ${EndIf} + ${EndIf} +{%- endif %} +!macroend + +Function un.onInit + +{%- if uninstall_with_conda_exe %} + Call un.UninstCustomOptions_InitDefaults +{%- endif %} Push $0 Push $1 @@ -1384,10 +1453,11 @@ SectionEnd Section "Uninstall" ${LogSet} on + ${If} ${Silent} + !insertmacro un.ParseCommandLineArgs + ${EndIf} - # Remove menu items, path entries System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_ROOT_PREFIX", "$INSTDIR")".r0' - {{ UNINSTALL_MENUS }} # ensure that MSVC runtime DLLs are on PATH during uninstallation ReadEnvStr $0 PATH @@ -1437,15 +1507,7 @@ Section "Uninstall" System::Call 'kernel32::SetEnvironmentVariable(t,t)i("INSTALLER_UNATTENDED", "0").r0' ${EndIf} - !insertmacro AbortRetryNSExecWaitLibNsisCmd "pre_uninstall" - !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmpath" - !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmreg" - - ${Print} "Removing files and folders..." - nsExec::Exec 'cmd.exe /D /C RMDIR /Q /S "$INSTDIR"' - - # In case the last command fails, run the slow method to remove leftover - RMDir /r /REBOOTOK "$INSTDIR" + {{ UNINSTALL_COMMANDS }} ${If} $INSTALLER_NAME_FULL != "" DeleteRegKey SHCTX "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$INSTALLER_NAME_FULL" diff --git a/constructor/winexe.py b/constructor/winexe.py index 74cf2e4da..ef269d922 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -12,6 +12,7 @@ from os.path import abspath, basename, dirname, isfile, join from pathlib import Path from subprocess import check_output, run +from textwrap import dedent from typing import List, Union from .construct import ns_platform @@ -196,7 +197,7 @@ def setup_envs_commands(info, dir_path): return [line.strip() for line in lines] -def uninstall_menus_commands(info): +def uninstall_menus_commands(info: dict) -> List[str]: tmpl = r""" SetDetailsPrint both ${{Print}} "Deleting {name} menus in {env_name}..." @@ -214,6 +215,69 @@ def uninstall_menus_commands(info): return [line.strip() for line in lines] +def uninstall_commands_default(info: dict) -> List[str]: + return uninstall_menus_commands(info) + dedent(""" + !insertmacro AbortRetryNSExecWaitLibNsisCmd "pre_uninstall" + !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmpath" + !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmreg" + + ${Print} "Removing files and folders..." + nsExec::Exec 'cmd.exe /D /C RMDIR /Q /S "$INSTDIR"' + + # In case the last command fails, run the slow method to remove leftover + RMDir /r /REBOOTOK "$INSTDIR" + """).splitlines() + + +def uninstall_commands_conda_standalone() -> List[str]: + return dedent(r""" + !insertmacro AbortRetryNSExecWaitLibNsisCmd "pre_uninstall" + !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmpath" + !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmreg" + + # Parse arguments + StrCpy $R0 "" + + ${If} $UninstRemoveConfigFiles_User_State == ${BST_CHECKED} + ${If} $UninstRemoveConfigFiles_System_State == ${BST_CHECKED} + StrCpy $R0 "$R0 --remove-config-files=all" + ${Else} + StrCpy $R0 "$R0 --remove-config-files=user" + ${EndIf} + ${ElseIf} $UninstRemoveConfigFiles_System_State == ${BST_CHECKED} + StrCpy $R0 "$R0 --remove-config-files=system" + ${EndIf} + + ${If} $UninstRemoveUserData_State == ${BST_CHECKED} + StrCpy $R0 "$R0 --remove-user-data" + ${EndIf} + + ${If} $UninstRemoveCaches_State == ${BST_CHECKED} + StrCpy $R0 "$R0 --remove-caches" + ${EndIf} + + ${Print} "Removing files and folders..." + push '"$INSTDIR\_conda.exe" constructor uninstall $R0 --prefix "$INSTDIR"' + push 'Failed to remove files and folders. Please see the log for more information.' + push 'WithLog' + SetDetailsPrint listonly + call un.AbortRetryNSExecWait + SetDetailsPrint both + + # The uninstallation may leave the install.log, the uninstaller, + # and .conda_trash files behind, so remove those manually. + ${If} ${FileExists} "$INSTDIR" + RMDir /r /REBOOTOK "$INSTDIR" + ${EndIf} + """).splitlines() + + +def uninstall_commands(info: dict) -> List[str]: + if info.get("uninstall_with_conda_exe"): + return uninstall_commands_conda_standalone() + return uninstall_commands_default(info) + + def make_nsi( info: dict, dir_path: str, @@ -336,6 +400,7 @@ def make_nsi( variables["custom_conclusion"] = info.get("conclusion_file", "").endswith(".nsi") variables["has_license"] = bool(info.get("license_file")) variables["post_install_pages"] = bool(info.get("post_install_pages")) + variables["uninstall_with_conda_exe"] = bool(info.get("uninstall_with_conda_exe")) approx_pkgs_size_kb = approx_size_kb(info, "pkgs") @@ -352,7 +417,7 @@ def make_nsi( 'uninstall_name', '${NAME} ${VERSION} (Python ${PYVERSION} ${ARCH})' ) - variables['UNINSTALL_MENUS'] = '\n '.join(uninstall_menus_commands(info)) + variables['UNINSTALL_COMMANDS'] = '\n '.join(uninstall_commands(info)) variables['EXTRA_FILES'] = '\n '.join(extra_files_commands(extra_files, dir_path)) variables['SCRIPT_ENV_VARIABLES'] = '\n '.join(setup_script_env_variables(info)) variables['CUSTOM_WELCOME_FILE'] = ( diff --git a/docs/source/cli-options.md b/docs/source/cli-options.md index a67abc936..b9fa759fe 100644 --- a/docs/source/cli-options.md +++ b/docs/source/cli-options.md @@ -54,6 +54,11 @@ $ CONDA_VERBOSITY=3 bash -x my_installer.sh Windows installers have the following CLI options available: +- `/NCRC`: disables the CRC check. +- `/S` (silent): runs the installer in headless mode. Installers created with `constructor 3.10` or + later will report information to the active console. See below for different invocation examples. +- `/Q` (quiet): do not report to the console in headless mode. Only relevant when used with `/S` + (see above). - `/InstallationType=[JustMe|AllUsers]`: This flag sets the installation type. The default is `JustMe`. `AllUsers` might require elevated privileges. - `/AddToPath=[0|1]`: Whether to add the installation directory to the `PATH` environment @@ -68,19 +73,37 @@ Windows installers have the following CLI options available: `0`. - `/RegisterPython=[0|1]`: Whether to register Python as default in the Windows registry. Defaults to `1`. This is preferred to `AddToPath`. -- `/Q` (quiet): do not report to the console in headless mode. Only relevant when used with `/S` - (see below). Also works for the uninstallers. +- `/D` (directory): sets the default installation directory. Note that even if the path contains + spaces, it must be the last parameter used in the command line and must not contain any quotes. + Only absolute paths are supported. -You can also supply [standard NSIS flags](https://nsis.sourceforge.io/Docs/Chapter3.html#installerusage), but only _after_ the ones mentioned above: +Some of these flags are [standard NSIS flags](https://nsis.sourceforge.io/Docs/Chapter3.html#installerusage). -- `/NCRC`: disables the CRC check. -- `/S` (silent): runs the installer or uninstaller in headless mode. Installers created with - `constructor 3.10` or later will report information to the active console. Note that while the - installer output will be reported in the active console, the uninstaller output will happen in - a new console. See below for different invocation examples. +Windows uninstallers have the following CLI options: + +- `/S` (silent): uninstaller in headless mode. Uninstallers created with `constructor 3.10` or + later will report information to the console in a new window. + See below for different invocation examples. +- `/Q` (quiet): do not report to the console in headless mode. Only relevant when used with `/S` + (see above). Also works for the uninstallers. +- `/RemoveCaches=[0|1]` (only if built with `uninstall_with_conda_exe`): + Removes caches such package caches. + For details, see the `constructor uninstall` subcommand of the `conda.exe` file. +- `/RemoveConfigFiles=[none|users|system|all]` (only if built with `uninstall_with_conda_exe`): + Removes configuration files such as `.condarc` files. `user` removes the files inside the + current user's home directory and `system` removes all files outside of that directory. + For details, see the `constructor uninstall` subcommand of the `conda.exe` file. +- `/RemoveUserData=[0|1]` (only if built with `uninstall_with_conda_exe`): + removes user data such as the `~/.conda` directory. + For details, see the `constructor uninstall` subcommand of the `conda.exe` file. +- `/Q` (quiet): do not report to the console in headless mode. Only relevant when used with `/S` - `/D` (directory): sets the default installation directory. Note that even if the path contains spaces, it must be the last parameter used in the command line and must not contain any quotes. - Only absolute paths are supported. The uninstaller uses `_?` instead of `/D`. + Only absolute paths are supported. +- `_?`: like `/D` but prevents the uninstaller from copying itself to the temporary directory. + +> [!IMPORTANT] +> Flags without arguments should precede flags with arguments to avoid parsing errors. ### Examples @@ -105,12 +128,12 @@ Run the installer in headless mode, for all users, adding to PATH and installing `````{tab-set} ````{tab-item} CMD ```pwsh -cmd.exe /C start /wait my_installer.exe /InstallationType=AllUsers /AddToPath=1 /S /D=C:\Program Files\my_app +cmd.exe /C start /wait my_installer.exe /S /InstallationType=AllUsers /AddToPath=1 /D=C:\Program Files\my_app ``` ```` ````{tab-item} PowerShell ```pwsh -Start-Process -FilePath .\my_installer.exe -ArgumentList "/InstallationType=AllUsers /AddToPath=1 /S /D=C:\Program Files\my_app" -NoNewWindow -Wait +Start-Process -FilePath .\my_installer.exe -ArgumentList "/S /InstallationType=AllUsers /AddToPath=1 /D=C:\Program Files\my_app" -NoNewWindow -Wait ``` ```` ````` diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index ed5e3ad66..401a3c2d4 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -869,6 +869,14 @@ Allowed keys are: license text. Only relevant if include_text is True. Any str accepted by open()'s 'errors' argument is valid. See https://docs.python.org/3/library/functions.html#open. +### `uninstall_with_conda_exe` + +_required:_ no
+_type:_ boolean
+ +Use the standalone binary to perform the uninstallation. +Requires conda-standalone 24.11.0 or newer. + ## Available selectors - `aarch64` diff --git a/docs/source/howto.md b/docs/source/howto.md index c6fca5056..970cc17fd 100644 --- a/docs/source/howto.md +++ b/docs/source/howto.md @@ -160,4 +160,44 @@ If you want to perform the uninstallation steps manually, you can: ### macOS and Linux -Remove the installation directory. Usually this is a directory in your user's home directory (user installs), or under `/opt` for system-wide installations. +:::{note} +The following sections requires installers to be built using the `uninstall_with_conda_exe` option. +This is currently only implemented for `conda-standalone` 24.11.0 and higher. + +For other installers, all files need to be removed manually. +::: + +Unlike Windows, macOS and Linux installers do not ship an uninstaller executable. +However, some standalone applications (like `conda-standalone`) provide an uninstaller subcommand. +The following can be used to uninstall an existing installation: + +```bash +$ $INSTDIR/_conda constructor uninstall --prefix $INSTDIR +``` + +where `$INSTDIR` is the installation directory. This command recursively removes all environments +and removes shell initializers that point to `$INSTDIR`. + +The command supports additional options to delete files outside the installation directory: + +- `--remove-caches`: + Removes cache directories such as package caches and notices. + Not recommended with multiple conda installations when softlinks are enabled. +- `--remove-config-files {user,system,all}`: + Removes all configuration files such as `.condarc` files outside the installation directory. + `user` removes the files inside the current user's home directory + and `system` removes all files outside of that directory. +- `--remove-user-data`: + This removes user data files such as the `~/.conda` directory. + +These options are not recommended if multiple conda installations are on the same system because +they delete commonly used files. + +:::{note} +If removing these files requires superuser privileges, use `sudo -E` instead of `sudo` since +finding these files may rely on environment variables, especially `$HOME`. +::: + +For more detailed implementation notes, see the documentation of the standalone application: + +* [conda-standalone](https://github.com/conda/conda-standalone) diff --git a/news/897-standalone-uninstallation b/news/897-standalone-uninstallation new file mode 100644 index 000000000..48d6b9c5c --- /dev/null +++ b/news/897-standalone-uninstallation @@ -0,0 +1,19 @@ +### Enhancements + +* Implement feature to run uninstallation via conda-standalone (see https://github.com/conda/conda-standalone/pull/112). (#897) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* diff --git a/tests/test_examples.py b/tests/test_examples.py index c0a744783..0cabf2505 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -11,7 +11,7 @@ from functools import lru_cache from pathlib import Path from plistlib import load as plist_load -from typing import Generator, Iterable, Optional, Tuple +from typing import Generator, Iterable, Optional, Tuple, Union import pytest from conda.base.context import context @@ -33,7 +33,7 @@ pytestmark = pytest.mark.examples REPO_DIR = Path(__file__).parent.parent -ON_CI = os.environ.get("CI") +ON_CI = bool(os.environ.get("CI")) and os.environ.get("CI") != "0" CONSTRUCTOR_CONDA_EXE = os.environ.get("CONSTRUCTOR_CONDA_EXE") CONDA_EXE, CONDA_EXE_VERSION = identify_conda_exe(CONSTRUCTOR_CONDA_EXE) if CONDA_EXE_VERSION is not None: @@ -141,7 +141,12 @@ def _run_installer_exe(installer, install_dir, installer_input=None, timeout=420 return process -def _run_uninstaller_exe(install_dir, timeout=420, check=True): +def _run_uninstaller_exe( + install_dir: Path, + timeout: int = 420, + check: bool = True, + options: Union[list, None] = None, +) -> Union[subprocess.CompletedProcess, None]: # Now test the uninstallers if " " in str(install_dir): # TODO: We can't seem to run the uninstaller when there are spaces in the PATH @@ -150,6 +155,7 @@ def _run_uninstaller_exe(install_dir, timeout=420, check=True): "This is a known issue with our setup, to be fixed." ) return + options = options or [] # Rename install.log install_log = install_dir / "install.log" if install_log.exists(): @@ -164,11 +170,13 @@ def _run_uninstaller_exe(install_dir, timeout=420, check=True): "start", "/wait", str(uninstaller), + "/S", + *options, # We need silent mode + "uninstaller location" (_?=...) so the command can # be waited; otherwise, since the uninstaller copies itself to a different # location so it can be auto-deleted, it returns immediately and it gives # us problems with the tempdir cleanup later - f"/S _?={install_dir}", + f"_?={install_dir}", ] process = _execute(cmd, timeout=timeout, check=check) if check: @@ -913,6 +921,84 @@ def test_ignore_condarc_files(tmp_path, monkeypatch, request): assert proc.stderr.count("Bad conversion of configurable") == 4 +@pytest.mark.skipif( + CONDA_EXE == StandaloneExe.CONDA and CONDA_EXE_VERSION < Version("24.11.0"), + reason="Requires conda-standalone 24.11.x or newer", +) +@pytest.mark.skipif(not sys.platform == "win32", reason="Windows only") +@pytest.mark.skipif(not ON_CI, reason="CI only - Interacts with system files") +@pytest.mark.parametrize( + "remove_user_data,remove_caches,remove_config_files", + ( + pytest.param(False, False, None, id="keep files"), + pytest.param(True, True, "all", id="remove all files"), + pytest.param(False, False, "system", id="remove system .condarc files"), + pytest.param(False, False, "user", id="remove user .condarc files"), + ) +) +def test_uninstallation_standalone( + monkeypatch, + remove_user_data: bool, + remove_caches: bool, + remove_config_files: Union[str, None], + tmp_path: Path, +): + recipe_path = _example_path("customize_controls") + input_path = tmp_path / "input" + shutil.copytree(str(recipe_path), str(input_path)) + with open(input_path / "construct.yaml", "a") as construct: + construct.write("uninstall_with_conda_exe: true\n") + installer, install_dir = next(create_installer(input_path, tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + _run_installer( + input_path, + installer, + install_dir, + check_subprocess=True, + uninstall=False, + ) + + # Set up files for removal. + # Since conda-standalone is extensively tested upstream, + # only set up a minimum set of files. + dot_conda_dir = tmp_path / ".conda" + assert dot_conda_dir.exists() + + # Minimum set of files needed for an index cache + pkg_cache = tmp_path / "pkgs" + (pkg_cache / "cache").mkdir(parents=True) + (pkg_cache / "urls.txt").touch() + + user_rc = tmp_path / ".condarc" + system_rc = Path("C:/ProgramData/conda/.condarc") + system_rc.parent.mkdir(parents=True) + condarc = f"pkgs_dirs:\n - {pkg_cache}\n" + user_rc.write_text(condarc) + system_rc.write_text(condarc) + + uninstall_options = [] + remove_system_rcs = False + remove_user_rcs = False + if remove_config_files is not None: + uninstall_options.append(f"/RemoveConfigFiles={remove_config_files}") + remove_system_rcs = remove_config_files != "user" + remove_user_rcs = remove_config_files != "system" + if remove_user_data: + uninstall_options.append("/RemoveUserData=1") + if remove_caches: + uninstall_options.append("/RemoveCaches=1") + + try: + _run_uninstaller_exe(install_dir, check=True, options=uninstall_options) + assert dot_conda_dir.exists() != remove_user_data + assert pkg_cache.exists() != remove_caches + assert system_rc.exists() != remove_system_rcs + assert user_rc.exists() != remove_user_rcs + finally: + if system_rc.parent.exists(): + shutil.rmtree(system_rc.parent) + + def test_regressions(tmp_path, request): input_path = _example_path("regressions") for installer, install_dir in create_installer(input_path, tmp_path):