diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 18facf0c2..eba3db1d7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -79,11 +79,8 @@ jobs: conda-standalone: conda-standalone - os: windows python-version: "3.11" + # conda-standalone: micromamba conda-standalone: conda-standalone-nightly - # Micromamba doesn't support Windows yet (menuinst features missing) - # - os: windows - # python-version: 3.11 - # conda-standalone: micromamba env: PYTHONUNBUFFERED: "1" @@ -133,7 +130,6 @@ jobs: - name: Run examples env: CONSTRUCTOR_EXAMPLES_KEEP_ARTIFACTS: "${{ runner.temp }}/examples_artifacts" - # signtool only exists on Windows, but doesn't cause errors on unix when absent CONSTRUCTOR_SIGNTOOL_PATH: "C:/Program Files (x86)/Windows Kits/10/bin/10.0.17763.0/x86/signtool.exe" run: | rm -rf coverage.json @@ -144,10 +140,6 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} flags: integration - - name: Test with conda-libmamba-solver - run: | - conda install -yq conda-libmamba-solver - CONDA_SOLVER=libmamba CONDA_VERBOSITY=1 pytest -vv tests/test_examples.py -k noconda - name: Check docs are up-to-date if: matrix.check-docs run: | diff --git a/CONSTRUCT.md b/CONSTRUCT.md index f737677a3..ccb36a91e 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -128,9 +128,16 @@ _required:_ no
_type:_ list
A list of packages with menu items to be installed. The packages must have -necessary metadata in `Menu/.json`). Menu items are currently -only supported on Windows. By default, all menu items will be installed; -supplying this list allows a subset to be selected instead. +necessary metadata in `Menu/.json`). By default, all menu items +found in the installation will be created; supplying this list allows a +subset to be selected instead. If an empty list is supplied, no shortcuts will +be created. + +If all environments (`extra_envs` included) set `menu_packages` to an empty list, +no UI options about shortcuts will be offered to the user. + +Note: This option is not fully implemented when `micromamba` is used as +the `--conda-exe` binary. The only accepted value is an empty list (`[]`). ### `ignore_duplicate_files` @@ -220,12 +227,12 @@ name) to a dictionary of options: an empty list. - `user_requested_specs` (list of str): same as the global option, but for this env; if not provided, global value is _not_ used +- `menu_packages` (list of str): same as the global option, for this env; + if not provided, the global value is _not_ used. Notes: - `ignore_duplicate_files` will always be considered `True` if `extra_envs` is in use. - `conda` needs to be present in the `base` environment (via `specs`) -- support for `menu_packages` is planned, but not possible right now. For now, all packages - in an `extra_envs` config will be allowed to create their shortcuts. - If a global `exclude` option is used, it will have an effect on the environments created by `extra_envs` too. For example, if the global environment excludes `tk`, none of the extra environments will have it either. Unlike the global option, an error will not be diff --git a/constructor/construct.py b/constructor/construct.py index 55d19f861..1de1dd1ef 100644 --- a/constructor/construct.py +++ b/constructor/construct.py @@ -83,11 +83,18 @@ is contained as a result of resolving the specs for `python 2.7`. '''), - ('menu_packages', False, list, ''' + ('menu_packages', False, list, ''' A list of packages with menu items to be installed. The packages must have -necessary metadata in `Menu/.json`). Menu items are currently -only supported on Windows. By default, all menu items will be installed; -supplying this list allows a subset to be selected instead. +necessary metadata in `Menu/.json`). By default, all menu items +found in the installation will be created; supplying this list allows a +subset to be selected instead. If an empty list is supplied, no shortcuts will +be created. + +If all environments (`extra_envs` included) set `menu_packages` to an empty list, +no UI options about shortcuts will be offered to the user. + +Note: This option is not fully implemented when `micromamba` is used as +the `--conda-exe` binary. The only accepted value is an empty list (`[]`). '''), ('ignore_duplicate_files', False, bool, ''' @@ -152,12 +159,12 @@ an empty list. - `user_requested_specs` (list of str): same as the global option, but for this env; if not provided, global value is _not_ used +- `menu_packages` (list of str): same as the global option, for this env; + if not provided, the global value is _not_ used. Notes: - `ignore_duplicate_files` will always be considered `True` if `extra_envs` is in use. - `conda` needs to be present in the `base` environment (via `specs`) -- support for `menu_packages` is planned, but not possible right now. For now, all packages - in an `extra_envs` config will be allowed to create their shortcuts. - If a global `exclude` option is used, it will have an effect on the environments created by `extra_envs` too. For example, if the global environment excludes `tk`, none of the extra environments will have it either. Unlike the global option, an error will not be @@ -599,9 +606,7 @@ "channels_remap": (list, tuple), "user_requested_specs": (list, tuple), "exclude": (list, tuple), - # TODO: we can't support menu_packages for extra envs yet - # will implement when the PR for new menuinst lands - # "menu_packages": (list, tuple), + "menu_packages": (list, tuple), } logger = logging.getLogger(__name__) @@ -766,7 +771,7 @@ def verify(info): sys.exit( f"Environment names (keys in 'extra_envs') cannot contain any of {disallowed}. " f"You tried to use: {env_name}" - ) + ) for key, value in env_data.items(): if key not in _EXTRA_ENVS_SCHEMA: sys.exit(f"Key '{key}' not supported in 'extra_envs'.") diff --git a/constructor/fcp.py b/constructor/fcp.py index bb888db91..74d36dc3d 100644 --- a/constructor/fcp.py +++ b/constructor/fcp.py @@ -56,6 +56,8 @@ def getsize(filename): def warn_menu_packages_missing(precs, menu_packages): + if not menu_packages: + return all_names = {prec.name for prec in precs} for name in menu_packages: if name not in all_names: @@ -236,7 +238,7 @@ def _precs_from_environment(environment, input_dir): def _solve_precs(name, version, download_dir, platform, channel_urls=(), channels_remap=(), - specs=(), exclude=(), menu_packages=(), environment=None, environment_file=None, + specs=(), exclude=(), menu_packages=None, environment=None, environment_file=None, verbose=True, conda_exe="conda.exe", extra_env=False, input_dir=""): # Add python to specs, since all installers need a python interpreter. In the future we'll # probably want to add conda too. @@ -376,7 +378,7 @@ def _fetch_precs(precs, download_dir, transmute_file_type=''): def _main(name, version, download_dir, platform, channel_urls=(), channels_remap=(), specs=(), - exclude=(), menu_packages=(), ignore_duplicate_files=True, environment=None, + exclude=(), menu_packages=None, ignore_duplicate_files=True, environment=None, environment_file=None, verbose=True, dry_run=False, conda_exe="conda.exe", transmute_file_type='', extra_envs=None, check_path_spaces=True, input_dir=""): precs = _solve_precs( @@ -408,7 +410,7 @@ def _main(name, version, download_dir, platform, channel_urls=(), channels_remap channels_remap=env_config.get("channels_remap", channels_remap), specs=env_config.get("specs", ()), exclude=env_config.get("exclude", exclude), - menu_packages=env_config.get("menu_packages", ()), + menu_packages=env_config.get("menu_packages"), environment=env_config.get("environment"), environment_file=env_config.get("environment_file"), verbose=verbose, @@ -462,7 +464,7 @@ def main(info, verbose=True, dry_run=False, conda_exe="conda.exe"): channels_remap = info.get('channels_remap', ()) specs = info.get("specs", ()) exclude = info.get("exclude", ()) - menu_packages = info.get("menu_packages", ()) + menu_packages = info.get("menu_packages") ignore_duplicate_files = info.get("ignore_duplicate_files", True) environment = info.get("environment", None) environment_file = info.get("environment_file", None) diff --git a/constructor/header.sh b/constructor/header.sh index 48db69a8f..d35bab72b 100644 --- a/constructor/header.sh +++ b/constructor/header.sh @@ -46,6 +46,9 @@ KEEP_PKGS=1 KEEP_PKGS=0 #endif SKIP_SCRIPTS=0 +#if enable_shortcuts == "true" +SKIP_SHORTCUTS=0 +#endif TEST=0 REINSTALL=0 USAGE=" @@ -70,6 +73,9 @@ Installs ${INSTALLER_NAME} ${INSTALLER_VER} -p PREFIX install prefix, defaults to $PREFIX #endif -s skip running pre/post-link/install scripts +#if enable_shortcuts == 'true' +-m disable the creation of menu items / shortcuts +#endif -u update an existing installation #if has_conda -t run package tests after installation (may install conda-build) @@ -80,7 +86,11 @@ Installs ${INSTALLER_NAME} ${INSTALLER_VER} # However getopt is not standardized and the version on Mac has different # behaviour. getopts is good enough for what we need :) # More info: https://unix.stackexchange.com/questions/62950/ +#if enable_shortcuts == "true" +while getopts "bifhkp:smut" x; do +#else while getopts "bifhkp:sut" x; do +#endif case "$x" in h) printf "%s\\n" "$USAGE" @@ -104,6 +114,11 @@ while getopts "bifhkp:sut" x; do s) SKIP_SCRIPTS=1 ;; +#if enable_shortcuts == "true" + m) + SKIP_SHORTCUTS=1 + ;; +#endif u) FORCE=1 ;; @@ -391,7 +406,7 @@ cd "$PREFIX" unset PYTHON_SYSCONFIGDATA_NAME _CONDA_PYTHON_SYSCONFIGDATA_NAME # the first binary payload: the standalone conda executable -CONDA_EXEC="$PREFIX/conda.exe" +CONDA_EXEC="$PREFIX/_conda" extract_range "${boundary0}" "${boundary1}" > "$CONDA_EXEC" chmod +x "$CONDA_EXEC" @@ -399,6 +414,11 @@ export TMP_BACKUP="${TMP:-}" export TMP="$PREFIX/install_tmp" mkdir -p "$TMP" +# Create $PREFIX/.nonadmin if the installation didn't require superuser permissions +if [ "$(id -u)" -ne 0 ]; then + touch "$PREFIX/.nonadmin" +fi + # the second binary payload: the tarball of packages printf "Unpacking payload ...\n" extract_range $boundary1 $boundary2 | \ @@ -445,14 +465,31 @@ test -d ~/.conda || mkdir -p ~/.conda >/dev/null 2>/dev/null || test -d ~/.conda printf "\nInstalling base environment...\n\n" +#if enable_shortcuts == "true" +if [ "$SKIP_SHORTCUTS" = "1" ]; then + shortcuts="--no-shortcuts" +else + shortcuts="__SHORTCUTS__" +fi +#endif +#if enable_shortcuts == "false" +shortcuts="--no-shortcuts" +#endif +#if enable_shortcuts == "incompatible" +shortcuts="" +#endif + +# shellcheck disable=SC2086 +CONDA_ROOT_PREFIX="$PREFIX" \ CONDA_REGISTER_ENVS="__REGISTER_ENVS__" \ CONDA_SAFETY_CHECKS=disabled \ CONDA_EXTRA_SAFETY_CHECKS=no \ CONDA_CHANNELS="__CHANNELS__" \ CONDA_PKGS_DIRS="$PREFIX/pkgs" \ -"$CONDA_EXEC" install --offline --file "$PREFIX/pkgs/env.txt" -yp "$PREFIX" || exit 1 +"$CONDA_EXEC" install --offline --file "$PREFIX/pkgs/env.txt" -yp "$PREFIX" $shortcuts || exit 1 rm -f "$PREFIX/pkgs/env.txt" +#The templating doesn't support nested if statements #if has_conda mkdir -p "$PREFIX/envs" for env_pkgs in "${PREFIX}"/pkgs/envs/*/; do @@ -469,14 +506,31 @@ for env_pkgs in "${PREFIX}"/pkgs/envs/*/; do else env_channels="__CHANNELS__" fi - - # TODO: custom shortcuts per env? +#endif +#if has_conda and enable_shortcuts == "true" + if [ "$SKIP_SHORTCUTS" = "1" ]; then + env_shortcuts="--no-shortcuts" + else + # This file is guaranteed to exist, even if empty + env_shortcuts=$(cat "${env_pkgs}shortcuts.txt") + rm -f "${env_pkgs}shortcuts.txt" + fi +#endif +#if has_conda and enable_shortcuts == "false" + env_shortcuts="--no-shortcuts" +#endif +#if has_conda and enable_shortcuts == "incompatible" + env_shortcuts="" +#endif +#if has_conda + # shellcheck disable=SC2086 + CONDA_ROOT_PREFIX="$PREFIX" \ CONDA_REGISTER_ENVS="__REGISTER_ENVS__" \ CONDA_SAFETY_CHECKS=disabled \ CONDA_EXTRA_SAFETY_CHECKS=no \ CONDA_CHANNELS="$env_channels" \ CONDA_PKGS_DIRS="$PREFIX/pkgs" \ - "$CONDA_EXEC" install --offline --file "${env_pkgs}env.txt" -yp "$PREFIX/envs/$env_name" || exit 1 + "$CONDA_EXEC" install --offline --file "${env_pkgs}env.txt" -yp "$PREFIX/envs/$env_name" $env_shortcuts || exit 1 rm -f "${env_pkgs}env.txt" done #endif @@ -486,9 +540,6 @@ __INSTALL_COMMANDS__ POSTCONDA="$PREFIX/postconda.tar.bz2" "$CONDA_EXEC" constructor --prefix "$PREFIX" --extract-tarball < "$POSTCONDA" || exit 1 rm -f "$POSTCONDA" - -rm -f "$CONDA_EXEC" - rm -rf "$PREFIX/install_tmp" export TMP="$TMP_BACKUP" @@ -630,4 +681,5 @@ fi #endif exit 0 +# shellcheck disable=SC2317 @@END_HEADER@@ diff --git a/constructor/main.py b/constructor/main.py index 130198487..6db8071b0 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -14,12 +14,14 @@ from . import __version__ from .build_outputs import process_build_outputs -from .conda_interface import SUPPORTED_PLATFORMS, cc_platform +from .conda_interface import SUPPORTED_PLATFORMS +from .conda_interface import VersionOrder as Version +from .conda_interface import cc_platform from .construct import generate_key_info_list, ns_platform from .construct import parse as construct_parse from .construct import verify as construct_verify from .fcp import main as fcp_main -from .utils import normalize_path, yield_lines +from .utils import identify_conda_exe, normalize_path, yield_lines DEFAULT_CACHE_DIR = os.getenv('CONSTRUCTOR_CACHE', '~/.conda/constructor') @@ -90,7 +92,7 @@ def main_build(dir_path, output_dir='.', platform=cc_platform, if platform != cc_platform and 'pkg' in itypes and not cc_platform.startswith('osx-'): sys.exit("Error: cannot construct a macOS 'pkg' installer on '%s'" % cc_platform) if osname == "win" and "micromamba" in os.path.basename(info['_conda_exe']): - # TODO: Remove when shortcut creation is implemented on micromamba + # TODO: Investigate errors on Windows and re-enable sys.exit("Error: micromamba is not supported on Windows installers.") logger.debug('conda packages download: %s', info['_download_dir']) @@ -145,6 +147,23 @@ def main_build(dir_path, output_dir='.', platform=cc_platform, if config_key == "environment_file": env_config[config_key] = abspath(join(dir_path, value)) + exe_name, exe_version = identify_conda_exe(info.get("_conda_exe")) + if sys.platform != "win32" and ( + exe_name == "micromamba" or Version(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" + else: + # Installers will provide shortcut options and features only if the user + # didn't opt-out by setting every `menu_packages` item to an empty list + info['_enable_shortcuts'] = bool( + info.get("menu_packages", True) + or any( + env.get("menu_packages", True) + for env in info.get("extra_envs", {}).values() + ) + ) + info['installer_type'] = itypes[0] fcp_main(info, verbose=verbose, dry_run=dry_run, conda_exe=conda_exe) if dry_run: diff --git a/constructor/nsis/OptionsDialog.nsh b/constructor/nsis/OptionsDialog.nsh index b1f975eeb..ec67295a0 100644 --- a/constructor/nsis/OptionsDialog.nsh +++ b/constructor/nsis/OptionsDialog.nsh @@ -43,7 +43,11 @@ Function mui_AnaCustomOptions_InitDefaults StrCpy $Ana_RegisterSystemPython_State ${BST_CHECKED} ${EndIf} ${If} $Ana_CreateShortcuts_State == "" - StrCpy $Ana_CreateShortcuts_State ${BST_CHECKED} + ${If} "${ENABLE_SHORTCUTS}" == "yes" + StrCpy $Ana_CreateShortcuts_State ${BST_CHECKED} + ${Else} + StrCpy $Ana_CreateShortcuts_State ${BST_UNCHECKED} + ${EndIf} ${EndIf} ${EndIf} FunctionEnd @@ -70,12 +74,15 @@ Function mui_AnaCustomOptions_Show # 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 - ${NSD_CreateCheckbox} 0 0u 100% 11u "Create start menu shortcuts (supported packages only)." - IntOp $5 0 + 11 - Pop $mui_AnaCustomOptions.CreateShortcuts - ${NSD_SetState} $mui_AnaCustomOptions.CreateShortcuts $Ana_CreateShortcuts_State - ${NSD_OnClick} $mui_AnaCustomOptions.CreateShortcuts CreateShortcuts_OnClick + ${If} "${ENABLE_SHORTCUTS}" == "yes" + ${NSD_CreateCheckbox} 0 0u 100% 11u "Create start menu shortcuts (supported packages only)." + IntOp $5 $5 + 11 + Pop $mui_AnaCustomOptions.CreateShortcuts + ${NSD_SetState} $mui_AnaCustomOptions.CreateShortcuts $Ana_CreateShortcuts_State + ${NSD_OnClick} $mui_AnaCustomOptions.CreateShortcuts CreateShortcuts_OnClick + ${EndIf} ${If} "${SHOW_ADD_TO_PATH}" == "yes" # AddToPath is only an option for JustMe installations; it is disabled for AllUsers diff --git a/constructor/nsis/_nsis.py b/constructor/nsis/_nsis.py index 314868f80..01905b5ae 100644 --- a/constructor/nsis/_nsis.py +++ b/constructor/nsis/_nsis.py @@ -105,6 +105,10 @@ def get(self, name): def mk_menus(remove=False, prefix=None, pkg_names=None, root_prefix=None): + err( + "Deprecation warning: mk_menus is deprecated and will be removed in the future." + " Please use menuinst v2 directly or via conda-standalone 23.XXXX+ instead.\n" + ) try: import menuinst except (ImportError, OSError): @@ -157,6 +161,10 @@ def get_conda_envs_from_python_api(): def rm_menus(prefix=None, root_prefix=None): + err( + "Deprecation warning: rm_menus is deprecated and will be removed in the future." + " Please use menuinst v2 directly or via conda-standalone 23.XXXX+ instead.\n" + ) try: import menuinst # noqa from conda.base.context import context diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index a2ae2fb28..664e79bdc 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -57,7 +57,7 @@ Unicode "true" !define DEFAULT_PREFIX_ALL_USERS __DEFAULT_PREFIX_ALL_USERS__ !define PRE_INSTALL_DESC __PRE_INSTALL_DESC__ !define POST_INSTALL_DESC __POST_INSTALL_DESC__ -!define MENU_PKGS "@MENU_PKGS@" +!define ENABLE_SHORTCUTS __ENABLE_SHORTCUTS__ !define SHOW_REGISTER_PYTHON __SHOW_REGISTER_PYTHON__ !define SHOW_ADD_TO_PATH __SHOW_ADD_TO_PATH__ !define PRODUCT_NAME "${NAME} ${VERSION} (${ARCH})" @@ -296,14 +296,18 @@ FunctionEnd ${EndIf} ${EndIf} - ClearErrors - ${GetOptions} $ARGV "/NoShortcuts=" $ARGV_NoShortcuts - ${IfNot} ${Errors} - ${If} $ARGV_NoShortcuts = "1" - StrCpy $Ana_CreateShortcuts_State ${BST_UNCHECKED} - ${ElseIf} $ARGV_NoShortcuts = "0" - StrCpy $Ana_CreateShortcuts_State ${BST_CHECKED} + ${If} "${ENABLE_SHORTCUTS}" == "yes" + ClearErrors + ${GetOptions} $ARGV "/NoShortcuts=" $ARGV_NoShortcuts + ${IfNot} ${Errors} + ${If} $ARGV_NoShortcuts = "1" + StrCpy $Ana_CreateShortcuts_State ${BST_UNCHECKED} + ${ElseIf} $ARGV_NoShortcuts = "0" + StrCpy $Ana_CreateShortcuts_State ${BST_CHECKED} + ${EndIf} ${EndIf} + ${Else} + StrCpy $Ana_CreateShortcuts_State ${BST_UNCHECKED} ${EndIf} ClearErrors @@ -1116,6 +1120,7 @@ Section "Install" @SCRIPT_ENV_VARIABLES@ System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_SAFETY_CHECKS", "disabled").r0' System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_EXTRA_SAFETY_CHECKS", "no").r0' + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_ROOT_PREFIX", "$INSTDIR")".r0' System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_PKGS_DIRS", "$INSTDIR\pkgs")".r0' # Extra info for pre and post install scripts # NOTE: If more vars are added, make sure to update the examples/scripts tests too @@ -1163,14 +1168,6 @@ Section "Install" AddSize @SIZE@ - ${If} $Ana_CreateShortcuts_State = ${BST_CHECKED} - DetailPrint "Creating @NAME@ menus..." - push '"$INSTDIR\_conda.exe" constructor --prefix "$INSTDIR" --make-menus @MENU_PKGS@' - push 'Failed to create menus' - push 'WithLog' - call AbortRetryNSExecWait - ${EndIf} - #if has_conda is True DetailPrint "Initializing conda directories..." push '"$INSTDIR\pythonw.exe" -E -s "$INSTDIR\Lib\_nsis.py" mkdirs' @@ -1272,10 +1269,11 @@ SectionEnd !macroend Section "Uninstall" - # Remove menu items, path entries + ${LogSet} on - DetailPrint "Deleting @NAME@ menus..." - nsExec::ExecToLog '"$INSTDIR\_conda.exe" constructor --prefix "$INSTDIR" --rm-menus' + # 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 diff --git a/constructor/osx/check_shortcuts.sh b/constructor/osx/check_shortcuts.sh new file mode 100644 index 000000000..b592b2443 --- /dev/null +++ b/constructor/osx/check_shortcuts.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +set -eux + +# $2 is the install location, which is ~ by default +# but which the user can change. +PREFIX="$2/__NAME_LOWER__" +PREFIX=$(cd "$PREFIX"; pwd) + +# If the UI selected the "Create shortcuts" option +# we create a sentinel file that will be checked for existence +# during run_installation.sh +# If it doesn't exist, it means that this script never ran +# due to (A) the user deselected the option, or (B) the installer +# was created with menu_packages=[], which disables shortcuts altogether, +# or (C) the installer was created with an incompatible --conda-exe. +touch "$PREFIX/pkgs/user_wants_shortcuts" diff --git a/constructor/osx/prepare_installation.sh b/constructor/osx/prepare_installation.sh index 57d6c4a62..93e34fcea 100644 --- a/constructor/osx/prepare_installation.sh +++ b/constructor/osx/prepare_installation.sh @@ -22,7 +22,7 @@ PREFIX="$2/__NAME_LOWER__" PREFIX=$(cd "$PREFIX"; pwd) export PREFIX echo "PREFIX=$PREFIX" -CONDA_EXEC="$PREFIX/conda.exe" +CONDA_EXEC="$PREFIX/_conda" # /COMMON UTILS chmod +x "$CONDA_EXEC" @@ -31,6 +31,11 @@ chmod +x "$CONDA_EXEC" mkdir -p "$PREFIX/conda-meta" touch "$PREFIX/conda-meta/history" +# Create $PREFIX/.nonadmin if the installation didn't require superuser permissions +if [ "$(id -u)" -ne 0 ]; then + touch "$PREFIX/.nonadmin" +fi + # Extract the conda packages but avoiding the overwriting of the # custom metadata we have already put in place notify "Preparing packages..." diff --git a/constructor/osx/run_installation.sh b/constructor/osx/run_installation.sh index b1f562884..acea8d943 100644 --- a/constructor/osx/run_installation.sh +++ b/constructor/osx/run_installation.sh @@ -24,17 +24,31 @@ PREFIX="$2/__NAME_LOWER__" PREFIX=$(cd "$PREFIX"; pwd) export PREFIX echo "PREFIX=$PREFIX" -CONDA_EXEC="$PREFIX/conda.exe" +CONDA_EXEC="$PREFIX/_conda" # /COMMON UTILS +# Check whether the user wants shortcuts or not +# See check_shortcuts.sh script for details +ENABLE_SHORTCUTS="__ENABLE_SHORTCUTS__" +if [[ -f "$PREFIX/pkgs/user_wants_shortcuts" ]]; then # this implies ENABLE_SHORTCUTS==true + shortcuts="__SHORTCUTS__" +elif [[ "$ENABLE_SHORTCUTS" == "incompatible" ]]; then + shortcuts="" +else + shortcuts="--no-shortcuts" +fi + # Perform the conda install notify "Installing packages. This might take a few minutes." -if ! CONDA_REGISTER_ENVS="__REGISTER_ENVS__" \ +# shellcheck disable=SC2086 +if ! \ +CONDA_REGISTER_ENVS="__REGISTER_ENVS__" \ +CONDA_ROOT_PREFIX="$PREFIX" \ CONDA_SAFETY_CHECKS=disabled \ CONDA_EXTRA_SAFETY_CHECKS=no \ CONDA_CHANNELS=__CHANNELS__ \ CONDA_PKGS_DIRS="$PREFIX/pkgs" \ -"$CONDA_EXEC" install --offline --file "$PREFIX/pkgs/env.txt" -yp "$PREFIX"; then +"$CONDA_EXEC" install --offline --file "$PREFIX/pkgs/env.txt" -yp "$PREFIX" $shortcuts; then echo "ERROR: could not complete the conda install" exit 1 fi @@ -63,21 +77,30 @@ for env_pkgs in "${PREFIX}"/pkgs/envs/*/; do else env_channels="__CHANNELS__" fi - # TODO: custom channels per env? - # TODO: custom shortcuts per env? + if [[ -f "$PREFIX/pkgs/user_wants_shortcuts" ]]; then # this implies ENABLE_SHORTCUTS==true + # This file is guaranteed to exist, even if empty + env_shortcuts=$(cat "${env_pkgs}shortcuts.txt") + rm -f "${env_pkgs}shortcuts.txt" + elif [[ "$ENABLE_SHORTCUTS" == "incompatible" ]]; then + env_shortcuts="" + else + env_shortcuts="--no-shortcuts" + fi + + # shellcheck disable=SC2086 + CONDA_ROOT_PREFIX="$PREFIX" \ CONDA_REGISTER_ENVS="__REGISTER_ENVS__" \ CONDA_SAFETY_CHECKS=disabled \ CONDA_EXTRA_SAFETY_CHECKS=no \ CONDA_CHANNELS="$env_channels" \ CONDA_PKGS_DIRS="$PREFIX/pkgs" \ - "$CONDA_EXEC" install --offline --file "${env_pkgs}env.txt" -yp "$PREFIX/envs/$env_name" || exit 1 + "$CONDA_EXEC" install --offline --file "${env_pkgs}env.txt" -yp "$PREFIX/envs/$env_name" $env_shortcuts || exit 1 # Move the prepackaged history file into place mv "${env_pkgs}/conda-meta/history" "$PREFIX/envs/$env_name/conda-meta/history" rm -f "${env_pkgs}env.txt" done # Cleanup! -rm -f "$CONDA_EXEC" find "$PREFIX/pkgs" -type d -empty -exec rmdir {} \; 2>/dev/null || : __WRITE_CONDARC__ diff --git a/constructor/osx/run_user_script.sh b/constructor/osx/run_user_script.sh index 9f6291518..61816160f 100644 --- a/constructor/osx/run_user_script.sh +++ b/constructor/osx/run_user_script.sh @@ -22,7 +22,7 @@ PREFIX="$2/__NAME_LOWER__" PREFIX=$(cd "$PREFIX"; pwd) export PREFIX echo "PREFIX=$PREFIX" -CONDA_EXEC="$PREFIX/conda.exe" +CONDA_EXEC="$PREFIX/_conda" # /COMMON UTILS # Expose these to user scripts as well diff --git a/constructor/osxpkg.py b/constructor/osxpkg.py index 8c72638c7..32ebf0d7b 100644 --- a/constructor/osxpkg.py +++ b/constructor/osxpkg.py @@ -20,6 +20,7 @@ get_final_channels, preprocess, rm_rf, + shortcuts_flags, ) OSX_DIR = join(dirname(__file__), "osx") @@ -216,6 +217,22 @@ def modify_xml(xml_path, info): path_choice.set('visible', 'false') path_choice.set('title', 'Apply {}'.format(info['name'])) path_choice.set('enabled', 'false') + elif ident.endswith('shortcuts'): + # Show this option if menu_packages was set to a non-empty value + # or if the option was not set at all. We don't show the option + # menu_packages was set to an empty list! + path_choice.set('visible', 'true') + path_choice.set('title', "Create shortcuts") + path_choice.set('enabled', 'true') + descr = "Create shortcuts for compatible packages" + menu_packages = info.get("menu_packages") + if menu_packages is None: + menu_packages = [] + for extra_env in info.get("extra_envs", {}).values(): + menu_packages += extra_env.get("menu_packages", []) + if menu_packages: + descr += f" ({', '.join(menu_packages)})" + path_choice.set('description', descr) elif ident.endswith('user_pre_install') and info.get('pre_install_desc'): path_choice.set('visible', 'true') path_choice.set('title', "Run the pre-install script") @@ -306,6 +323,8 @@ def move_script(src, dst, info, ensure_shebang=False, user_script_type=None): 'PROGRESS_NOTIFICATIONS': str(info.get('progress_notifications', False)), 'PRE_OR_POST': user_script_type or '__PRE_OR_POST__', 'CONSTRUCTOR_VERSION': info['CONSTRUCTOR_VERSION'], + 'SHORTCUTS': shortcuts_flags(info), + 'ENABLE_SHORTCUTS': str(info['_enable_shortcuts']).lower(), 'REGISTER_ENVS': str(info.get("register_envs", True)).lower(), } data = preprocess(data, ppd) @@ -428,7 +447,7 @@ def create(info, verbose=False): # 1. Prepare installation # The 'prepare_installation' package contains the prepopulated package cache, the modified - # conda-meta metadata staged into pkgs/conda-meta, conda.exe, + # conda-meta metadata staged into pkgs/conda-meta, _conda (conda-standalone), # Optionally, extra files and the user-provided scripts. # We first populate PACKAGE_ROOT with everything needed, and then run pkg build on that dir fresh_dir(PACKAGE_ROOT) @@ -461,7 +480,7 @@ def create(info, verbose=False): for dist in all_dists: os.link(join(CACHE_DIR, dist), join(pkgs_dir, dist)) - shutil.copyfile(info['_conda_exe'], join(prefix, "conda.exe")) + shutil.copyfile(info['_conda_exe'], join(prefix, "_conda")) # Sign conda-standalone so it can pass notarization notarization_identity_name = info.get('notarization_identity_name') @@ -485,7 +504,7 @@ def create(info, verbose=False): "--options", "runtime", "--force", "--entitlements", f.name, - join(prefix, "conda.exe"), + join(prefix, "_conda"), ] ) os.unlink(f.name) @@ -506,6 +525,11 @@ def create(info, verbose=False): ) names.append('user_pre_install') + # pre-3. Enable or disable shortcuts creation + if info['_enable_shortcuts'] is True: + pkgbuild_script('shortcuts', info, 'check_shortcuts.sh') + names.append('shortcuts') + # 3. Run the installation # This script-only package will run conda to link and install the packages pkgbuild_script('run_installation', info, 'run_installation.sh') diff --git a/constructor/preconda.py b/constructor/preconda.py index 44350f81f..35fef41d6 100644 --- a/constructor/preconda.py +++ b/constructor/preconda.py @@ -26,7 +26,13 @@ ) from .conda_interface import distro as conda_distro from .conda_interface import get_repodata, write_repodata -from .utils import ensure_transmuted_ext, filename_dist, get_final_channels, get_final_url +from .utils import ( + ensure_transmuted_ext, + filename_dist, + get_final_channels, + get_final_url, + shortcuts_flags, +) try: import json @@ -173,6 +179,8 @@ def write_files(info, dst_dir): write_env_txt(info, env_dst_dir, env_urls_md5) # channels write_channels_txt(info, env_dst_dir, env_config) + # shortcuts + write_shortcuts_txt(info, env_dst_dir, env_config) def write_conda_meta(info, dst_dir, final_urls_md5s, user_requested_specs=None): @@ -261,6 +269,15 @@ def write_channels_txt(info, dst_dir, env_config): f.write(",".join(get_final_channels(env_config))) +def write_shortcuts_txt(info, dst_dir, env_config): + if "menu_packages" in env_config: + contents = shortcuts_flags(env_config) + else: + contents = shortcuts_flags(info) + with open(join(dst_dir, "shortcuts.txt"), "w") as f: + f.write(contents) + + def copy_extra_files( extra_files: List[Union[os.PathLike, Mapping]], workdir: os.PathLike ) -> List[os.PathLike]: diff --git a/constructor/shar.py b/constructor/shar.py index 513c6bbf4..299f3ab9f 100644 --- a/constructor/shar.py +++ b/constructor/shar.py @@ -24,6 +24,7 @@ hash_files, preprocess, read_ascii_only, + shortcuts_flags, ) THIS_DIR = dirname(__file__) @@ -68,6 +69,7 @@ def get_header(conda_exec, tarball, info): ppd['initialize_conda'] = info.get('initialize_conda', True) ppd['initialize_by_default'] = info.get('initialize_by_default', False) ppd['has_conda'] = info['_has_conda'] + ppd['enable_shortcuts'] = str(info['_enable_shortcuts']).lower() ppd['check_path_spaces'] = info.get("check_path_spaces", True) install_lines = list(add_condarc(info)) # Needs to happen first -- can be templated @@ -86,6 +88,7 @@ def get_header(conda_exec, tarball, info): 'CHANNELS': ','.join(get_final_channels(info)), 'CONCLUSION_TEXT': info.get("conclusion_text", "installation finished."), 'pycache': '__pycache__', + 'SHORTCUTS': shortcuts_flags(info), 'REGISTER_ENVS': str(info.get("register_envs", True)).lower(), } if has_license: @@ -121,6 +124,8 @@ def create(info, verbose=False): for env_name in info.get("_extra_envs_info", ()): pre_t.add(join(tmp_dir, "envs", env_name, "env.txt"), f"pkgs/envs/{env_name}/env.txt") + pre_t.add(join(tmp_dir, "envs", env_name, "shortcuts.txt"), + f"pkgs/envs/{env_name}/shortcuts.txt") for key in 'pre_install', 'post_install': if key in info: diff --git a/constructor/utils.py b/constructor/utils.py index b763fab9e..09a96f2eb 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -10,9 +10,9 @@ import re import sys from os import sep, unlink -from os.path import basename, isdir, isfile, islink, normpath +from os.path import basename, isdir, isfile, islink, join, normpath from shutil import rmtree -from subprocess import check_call +from subprocess import check_call, check_output from ruamel import yaml @@ -205,6 +205,27 @@ def yield_lines(path): yield line +def shortcuts_flags(info, conda_exe=None): + menu_packages = info.get("menu_packages") + conda_exe = conda_exe or info.get("_conda_exe", "") + is_micromamba = "micromamba" in basename(conda_exe).lower() + if menu_packages is None: + # not set: we create all shortcuts (default behaviour) + return "" + if menu_packages: + if is_micromamba: + logger.warning( + "Micromamba does not support '--shortcuts-only'. " + "Will install all shortcuts." + ) + return "" + # set and populated: we only create shortcuts for some + # NOTE: This syntax requires conda 23.11 or above + return " ".join([f"--shortcuts-only={pkg.strip()}" for pkg in menu_packages]) + # set but empty: disable all shortcuts + return "--no-shortcuts" + + def approx_size_kb(info, which="pkgs"): valid = ("pkgs", "tarballs", "total") assert which in valid, f"'which' must be one of {valid}" @@ -219,3 +240,18 @@ def approx_size_kb(info, which="pkgs"): # division by 10^3 instead of 2^10 is deliberate here. gives us more room return int(math.ceil(size_bytes/1000)) + + +def identify_conda_exe(conda_exe=None): + if conda_exe is None: + conda_exe = normalize_path(join(sys.prefix, "standalone_conda", "conda.exe")) + output = check_output([conda_exe, "--version"], text=True) + output = output.strip() + fields = output.split() + if "conda" in fields: + name = "conda-standalone" + version = fields[1] + else: + name = "micromamba" + version = output.strip() + return name, version diff --git a/constructor/winexe.py b/constructor/winexe.py index 3ea7c47b8..93a6d6a1b 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -26,6 +26,7 @@ get_final_channels, make_VIProductVersion, preprocess, + shortcuts_flags, ) NSIS_DIR = join(abspath(dirname(__file__)), 'nsis') @@ -121,32 +122,41 @@ def custom_nsi_insert_from_file(filepath: os.PathLike) -> str: def setup_envs_commands(info, dir_path): template = r""" # Set up {name} env - SetDetailsPrint TextOnly - DetailPrint "Setting up the {name} environment ..." SetDetailsPrint both + DetailPrint "Setting up the {name} environment ..." + SetDetailsPrint listonly + # List of packages to install SetOutPath "{env_txt_dir}" File "{env_txt_abspath}" + # A conda-meta\history file is required for a valid conda prefix SetOutPath "{conda_meta}" File "{history_abspath}" + # Set channels System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_CHANNELS", "{channels}").r0' # Set register_envs System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_REGISTER_ENVS", "{register_envs}").r0' - # Run conda - SetDetailsPrint TextOnly - nsExec::ExecToLog '"$INSTDIR\_conda.exe" install --offline -yp "{prefix}" --file "{env_txt}" {shortcuts}' - Pop $0 - ${{If}} $0 != "0" - DetailPrint "::error:: Failed to link extracted packages to {prefix}!" - MessageBox MB_OK|MB_ICONSTOP "Failed to link extracted packages to {prefix}. Please check logs." /SD IDOK - Abort + + # Run conda install + ${{If}} $Ana_CreateShortcuts_State = ${{BST_CHECKED}} + DetailPrint "Installing packages for {name}, creating shortcuts if necessary..." + push '"$INSTDIR\_conda.exe" install --offline -yp "{prefix}" --file "{env_txt}" {shortcuts}' + ${{Else}} + DetailPrint "Installing packages for {name}..." + push '"$INSTDIR\_conda.exe" install --offline -yp "{prefix}" --file "{env_txt}" --no-shortcuts' ${{EndIf}} + push 'Failed to link extracted packages to {prefix}!' + push 'WithLog' + SetDetailsPrint listonly + call AbortRetryNSExecWait SetDetailsPrint both + # Cleanup {name} env.txt SetOutPath "$INSTDIR" Delete "{env_txt}" + # Restore shipped conda-meta\history for remapped # channels and retain only the first transaction SetOutPath "{conda_meta}" @@ -162,7 +172,7 @@ def setup_envs_commands(info, dir_path): conda_meta=r"$INSTDIR\conda-meta", history_abspath=join(dir_path, "conda-meta", "history"), channels=','.join(get_final_channels(info)), - shortcuts="--no-shortcuts", + shortcuts=shortcuts_flags(info), register_envs=str(info.get("register_envs", True)).lower(), ).splitlines() # now we generate one more block per extra env, if present @@ -182,13 +192,31 @@ def setup_envs_commands(info, dir_path): conda_meta=join("$INSTDIR", "envs", env_name, "conda-meta"), history_abspath=join(dir_path, "envs", env_name, "conda-meta", "history"), channels=",".join(get_final_channels(channel_info)), - shortcuts="", + shortcuts=shortcuts_flags(env_info, conda_exe=info.get("_conda_exe")), register_envs=str(info.get("register_envs", True)).lower(), ).splitlines() return [line.strip() for line in lines] +def uninstall_menus_commands(info): + tmpl = r""" + SetDetailsPrint both + DetailPrint "Deleting {name} menus in {env_name}..." + SetDetailsPrint listonly + push '"$INSTDIR\_conda.exe" constructor --prefix "{path}" --rm-menus' + push 'Failed to delete menus in {env_name}' + push 'WithLog' + call un.AbortRetryNSExecWait + SetDetailsPrint both + """ + lines = tmpl.format(name=info["name"], env_name="base", path="$INSTDIR").splitlines() + for env_name in info.get("_extra_envs_info", {}): + path = join("$INSTDIR", "envs", env_name) + lines += tmpl.format(name=info["name"], env_name=env_name, path=path).splitlines() + return [line.strip() for line in lines] + + def signtool_command(info): "Generates a signtool command to be used in the NSIS template" pfx_certificate = info.get("signing_certificate") @@ -230,7 +258,6 @@ def make_nsi(info, dir_path, extra_files=None, temp_extra_files=None): info['pre_install_desc'] = info.get('pre_install_desc', "") info['post_install_desc'] = info.get('post_install_desc', "") - # these appear as ____ in the template, and get escaped replace = { 'NAME': name, 'VERSION': info['version'], @@ -248,6 +275,7 @@ def make_nsi(info, dir_path, extra_files=None, temp_extra_files=None): join('%ALLUSERSPROFILE%', name.lower())), 'PRE_INSTALL_DESC': info['pre_install_desc'], 'POST_INSTALL_DESC': info['post_install_desc'], + 'ENABLE_SHORTCUTS': "yes" if info['_enable_shortcuts'] is True else "no", 'SHOW_REGISTER_PYTHON': "yes" if info.get("register_python", True) else "no", 'SHOW_ADD_TO_PATH': "yes" if info.get("initialize_conda", True) else "no", 'OUTFILE': info['_outpath'], @@ -347,11 +375,11 @@ def make_nsi(info, dir_path, extra_files=None, temp_extra_files=None): ('@SIGNTOOL_COMMAND@', signtool_command(info)), ('@SETUP_ENVS@', '\n '.join(setup_envs_commands(info, dir_path))), ('@WRITE_CONDARC@', '\n '.join(add_condarc(info))), - ('@MENU_PKGS@', ' '.join(info.get('menu_packages', []))), ('@SIZE@', str(approx_pkgs_size_kb)), ('@UNINSTALL_NAME@', info.get('uninstall_name', '${NAME} ${VERSION} (Python ${PYVERSION} ${ARCH})' )), + ('@UNINSTALL_MENUS@', '\n '.join(uninstall_menus_commands(info))), ('@EXTRA_FILES@', '\n '.join(extra_files_commands(extra_files, dir_path))), ('@SCRIPT_ENV_VARIABLES@', '\n '.join(setup_script_env_variables(info))), ( diff --git a/dev/environment.yml b/dev/environment.yml index 5d5e3fde8..ba74a8fd6 100644 --- a/dev/environment.yml +++ b/dev/environment.yml @@ -5,5 +5,5 @@ dependencies: - python - conda >=4.6 - ruamel.yaml >=0.11.14,<0.18 - - conda-standalone + - conda-standalone # >=23.11.0 - pillow >=3.1 # [osx or win] diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index f737677a3..ccb36a91e 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -128,9 +128,16 @@ _required:_ no
_type:_ list
A list of packages with menu items to be installed. The packages must have -necessary metadata in `Menu/.json`). Menu items are currently -only supported on Windows. By default, all menu items will be installed; -supplying this list allows a subset to be selected instead. +necessary metadata in `Menu/.json`). By default, all menu items +found in the installation will be created; supplying this list allows a +subset to be selected instead. If an empty list is supplied, no shortcuts will +be created. + +If all environments (`extra_envs` included) set `menu_packages` to an empty list, +no UI options about shortcuts will be offered to the user. + +Note: This option is not fully implemented when `micromamba` is used as +the `--conda-exe` binary. The only accepted value is an empty list (`[]`). ### `ignore_duplicate_files` @@ -220,12 +227,12 @@ name) to a dictionary of options: an empty list. - `user_requested_specs` (list of str): same as the global option, but for this env; if not provided, global value is _not_ used +- `menu_packages` (list of str): same as the global option, for this env; + if not provided, the global value is _not_ used. Notes: - `ignore_duplicate_files` will always be considered `True` if `extra_envs` is in use. - `conda` needs to be present in the `base` environment (via `specs`) -- support for `menu_packages` is planned, but not possible right now. For now, all packages - in an `extra_envs` config will be allowed to create their shortcuts. - If a global `exclude` option is used, it will have an effect on the environments created by `extra_envs` too. For example, if the global environment excludes `tk`, none of the extra environments will have it either. Unlike the global option, an error will not be diff --git a/docs/source/howto.md b/docs/source/howto.md index 95fac9315..2481439a0 100644 --- a/docs/source/howto.md +++ b/docs/source/howto.md @@ -60,6 +60,11 @@ under `$PREFIX/Menu`, `conda` will process it to create the specified menu items This happens by default for _all packages_. If you only want this to happen for certain packages, use the [`menu_packages`](construct-yaml.md#menu_packages) key. +Starting with `conda` 23.11, `menuinst 2.x` is supported, which means you can create shortcuts in all platforms (Linux, macOS and Windows). +The JSON document format is slightly different, so make sure to check the [menuinst documentation](https://conda.github.io/menuinst/). +Your installer will need to be created with `conda-standalone 23.11` or above. +`micromamba` does not currently support `menuinst 2.x` style shortcuts (only `1.x` on Windows). + To learn more about `menuinst`, visit [`conda/menuinst`](https://github.com/conda/menuinst). ## Find out the used constructor version diff --git a/examples/osxpkg/construct.yaml b/examples/osxpkg/construct.yaml index d10fb6ed4..b749fa6ec 100644 --- a/examples/osxpkg/construct.yaml +++ b/examples/osxpkg/construct.yaml @@ -10,7 +10,6 @@ channels: - http://repo.anaconda.com/pkgs/main/ attempt_hardlinks: True -initialize_by_default: false specs: - python @@ -70,3 +69,6 @@ conclusion_text: | install_path_exists_error_text: > {CHOSEN_PATH} exists! Please update using our in-app mechanisms or relaunch the installer and choose a different location. + +initialize_by_default: false +register_python: False diff --git a/examples/shortcuts/construct.yaml b/examples/shortcuts/construct.yaml index d7de2d7bb..e22d46d97 100644 --- a/examples/shortcuts/construct.yaml +++ b/examples/shortcuts/construct.yaml @@ -3,12 +3,25 @@ version: X installer_type: all channels: + - conda-test/label/menuinst-tests - http://repo.anaconda.com/pkgs/main/ specs: - python - conda - - console_shortcut # [win] + - console_shortcut # [win] + - package_1 + +menu_packages: + - package_1 + +extra_envs: + another_env: + specs: + - package_1 + - console_shortcut # [win] + menu_packages: # [win] + - console_shortcut # [win] initialize_by_default: false register_python: False diff --git a/examples/use_channel_remap/construct.yaml b/examples/use_channel_remap/construct.yaml index fbd3fe588..2fd86f0ff 100644 --- a/examples/use_channel_remap/construct.yaml +++ b/examples/use_channel_remap/construct.yaml @@ -19,3 +19,4 @@ specs: license_file: eula.txt initialize_by_default: false +register_python: false diff --git a/news/474-menuinst-v2 b/news/474-menuinst-v2 new file mode 100644 index 000000000..585f6c2aa --- /dev/null +++ b/news/474-menuinst-v2 @@ -0,0 +1,20 @@ +### Enhancements + +* Add support for `menuinst` v2, which extends shortcut (menu items) creation from Windows to Linux and macOS. See [`menuinst` documentation](https://conda.github.io/menuinst/) for more information. Note that this feature requires `conda-standalone 23.11.0` or later. `micromamba` doesn't support v2-style menu items yet. (#474) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* Unix installers now bundle conda-standalone (or micromamba) as `_conda`, instead of `conda.exe`. + (#741 via #474) diff --git a/pyproject.toml b/pyproject.toml index b9a12345b..4e2d0a129 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,9 @@ target-version = ['py37', 'py38', 'py39', 'py310'] profile = "black" line_length = 100 +[tool.ruff] +line-length = 100 + [tool.pytest.ini_options] markers = [ "examples", diff --git a/tests/test_examples.py b/tests/test_examples.py index 55f3b27d2..1c3609403 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -12,6 +12,9 @@ import pytest from conda.base.context import context from conda.core.prefix_data import PrefixData +from conda.models.version import VersionOrder as Version + +from constructor.utils import identify_conda_exe if sys.platform == "darwin": from constructor.osxpkg import calculate_install_dir @@ -28,6 +31,7 @@ REPO_DIR = Path(__file__).parent.parent ON_CI = os.environ.get("CI") CONSTRUCTOR_CONDA_EXE = os.environ.get("CONSTRUCTOR_CONDA_EXE") +CONDA_EXE, CONDA_EXE_VERSION = identify_conda_exe(CONSTRUCTOR_CONDA_EXE) CONSTRUCTOR_DEBUG = bool(os.environ.get("CONSTRUCTOR_DEBUG")) if artifacts_path := os.environ.get("CONSTRUCTOR_EXAMPLES_KEEP_ARTIFACTS"): KEEP_ARTIFACTS_PATH = Path(artifacts_path) @@ -72,6 +76,31 @@ def _execute( print("Took", timedelta(seconds=time.time() - t0)) +def _check_installer_log(install_dir): + # Windows installers won't raise exit codes so we need to check the log file + error_lines = [] + try: + log_is_empty = True + with open(os.path.join(install_dir, "install.log"), encoding="utf-16-le") as f: + print("Installer log:", file=sys.stderr) + for line in f: + log_is_empty = False + print(line, end="", file=sys.stderr) + if ":error:" in line.lower(): + error_lines.append(line) + if log_is_empty: + error_lines.append("Logfile was unexpectedly empty!") + except Exception as exc: + error_lines.append( + f"Could not read logs! {exc.__class__.__name__}: {exc}\n" + "This usually means that the destination folder could not be created.\n" + "Possible causes: permissions, non-supported characters, long paths...\n" + "Consider setting 'check_path_spaces' and 'check_path_length' to 'False'." + ) + if error_lines: + raise AssertionError("\n".join(error_lines)) + + def _run_installer_exe(installer, install_dir, installer_input=None, timeout=420): """ NSIS manual: @@ -96,27 +125,7 @@ def _run_installer_exe(installer, install_dir, installer_input=None, timeout=420 ) cmd = ["cmd.exe", "/c", "start", "/wait", installer, "/S", *f"/D={install_dir}".split()] _execute(cmd, installer_input=installer_input, timeout=timeout) - - # Windows installers won't raise exit codes so we need to check the log file - error_lines = [] - try: - log_is_empty = True - with open(os.path.join(install_dir, "install.log"), encoding="utf-16-le") as f: - for line in f: - log_is_empty = False - if ":error:" in line.lower(): - error_lines.append(line) - if log_is_empty: - error_lines.append("Logfile was unexpectedly empty!") - except Exception as exc: - error_lines.append( - f"Could not read logs! {exc.__class__.__name__}: {exc}\n" - "This usually means that the destination folder could not be created.\n" - "Possible causes: permissions, non-supported characters, long paths...\n" - "Consider setting 'check_path_spaces' and 'check_path_length' to 'False'." - ) - if error_lines: - raise AssertionError("\n".join(error_lines)) + _check_installer_log(install_dir) def _run_uninstaller_exe(install_dir, timeout=420): @@ -128,6 +137,10 @@ def _run_uninstaller_exe(install_dir, timeout=420): "This is a known issue with our setup, to be fixed." ) return + # Rename install.log + install_log = install_dir / "install.log" + if install_log.exists(): + install_log.rename(install_dir / "install.log.bak") uninstaller = next(install_dir.glob("Uninstall-*.exe"), None) if not uninstaller: @@ -145,8 +158,9 @@ def _run_uninstaller_exe(install_dir, timeout=420): f"/S _?={install_dir}", ] _execute(cmd, timeout=timeout) + _check_installer_log(install_dir) remaining_files = list(install_dir.iterdir()) - if len(remaining_files) > 2: + if len(remaining_files) > 3: # The debug installer writes to install.log too, which will only # be deleted _after_ a reboot. Finding some files is ok, but more # than two usually means a problem with the uninstaller. @@ -209,6 +223,7 @@ def _run_installer( installer_input: Optional[str] = None, check_sentinels=True, request=None, + uninstall=True, timeout=420, ): if installer.suffix == ".exe": @@ -223,7 +238,7 @@ def _run_installer( raise ValueError(f"Unknown installer type: {installer.suffix}") if check_sentinels: _sentinel_file_checks(example_path, install_dir) - if installer.suffix == ".exe": + if uninstall and installer.suffix == ".exe": _run_uninstaller_exe(install_dir, timeout=timeout) @@ -237,7 +252,7 @@ def create_installer( **env_vars, ) -> Tuple[Path, Path]: if sys.platform.startswith("win") and conda_exe and _is_micromamba(conda_exe): - pytest.skip("Micromamba is not supported on Windows yet (shortcut creation).") + pytest.skip("Micromamba is not supported on Windows yet.") output_dir = workspace / "installer" output_dir.mkdir(parents=True, exist_ok=True) @@ -320,6 +335,10 @@ def test_example_extra_files(tmp_path, request): _run_installer(input_path, installer, install_dir, request=request) +@pytest.mark.xfail( + CONDA_EXE == "conda-standalone" and Version(CONDA_EXE_VERSION) < Version("23.11.0a0"), + reason="Known issue with conda-standalone<=23.10: shortcuts are created but not removed.", +) def test_example_miniforge(tmp_path, request): input_path = _example_path("miniforge") for installer, install_dir in create_installer(input_path, tmp_path): @@ -339,9 +358,23 @@ def test_example_miniforge(tmp_path, request): # PKG installers use their own install path, so we can't check sentinels # via `install_dir` check_sentinels=installer.suffix != ".pkg", + uninstall=False, ) if installer.suffix == ".pkg" and ON_CI: _sentinel_file_checks(input_path, Path(os.environ["HOME"]) / "Miniforge3") + if installer.suffix == ".exe": + for key in ("ProgramData", "AppData"): + start_menu_dir = Path( + os.environ[key], + "Microsoft/Windows/Start Menu/Programs/Miniforge3", + ) + if start_menu_dir.is_dir(): + assert list(start_menu_dir.glob("Miniforge*.lnk")) + break + else: + raise AssertionError("Could not find Start Menu folder for miniforge") + _run_uninstaller_exe(install_dir) + assert not list(start_menu_dir.glob("Miniforge*.lnk")) def test_example_noconda(tmp_path, request): @@ -363,10 +396,44 @@ def test_example_scripts(tmp_path, request): _run_installer(input_path, installer, install_dir, request=request) +@pytest.mark.skipif( + CONDA_EXE == "micromamba" or Version(CONDA_EXE_VERSION) < Version("23.11.0a0"), + reason="menuinst v2 requires conda-standalone>=23.11.0; micromamba is not supported yet", +) def test_example_shortcuts(tmp_path, request): input_path = _example_path("shortcuts") for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) + _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + # check that the shortcuts are created + if sys.platform == "win32": + for key in ("ProgramData", "AppData"): + start_menu = Path(os.environ[key]) / "Microsoft/Windows/Start Menu/Programs" + package_1 = start_menu / "Package 1" + anaconda = start_menu / "Anaconda3 (64-bit)" + if package_1.is_dir() and anaconda.is_dir(): + assert (package_1 / "A.lnk").is_file() + assert (package_1 / "B.lnk").is_file() + # The shortcut created from the 'base' env + # should not exist because we filtered it out in the YAML + # We do expect one shortcut from 'another_env' + assert not (anaconda / "Anaconda Prompt.lnk").is_file() + assert (anaconda / "Anaconda Prompt (another_env).lnk").is_file() + break + else: + raise AssertionError("No shortcuts found!") + _run_uninstaller_exe(install_dir) + assert not (package_1 / "A.lnk").is_file() + assert not (package_1 / "B.lnk").is_file() + elif sys.platform == "darwin": + applications = Path("~/Applications").expanduser() + print("Shortcuts found:", sorted(applications.glob("**/*.app"))) + assert (applications / "A.app").exists() + assert (applications / "B.app").exists() + elif sys.platform == "linux": + applications = Path("~/.local/share/applications").expanduser() + print("Shortcuts found:", sorted(applications.glob("**/*.desktop"))) + assert (applications / "package-1_a.desktop").exists() + assert (applications / "package-1_b.desktop").exists() @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") diff --git a/tests/test_header.py b/tests/test_header.py index 725fe6077..ab8c5daf9 100644 --- a/tests/test_header.py +++ b/tests/test_header.py @@ -54,6 +54,7 @@ def test_linux_template_processing(): initialize_by_default, has_post_install, has_pre_install, + enable_shortcuts, check_path_spaces, arch, ) in itertools.product( @@ -69,6 +70,7 @@ def test_linux_template_processing(): [False, True], [False, True], [False, True], + [False, True], ["x86", "x86_64", " ppc64le", "s390x", "aarch64"], ): params = { @@ -89,13 +91,15 @@ def test_linux_template_processing(): "direct_execute_post_install": direct_execute_post_install, "initialize_conda": initialize_conda, "initialize_by_default": initialize_by_default, + "enable_shortcuts": enable_shortcuts, "check_path_spaces": check_path_spaces, } processed = preprocess(template, params) for template_string in ["#if", "#else", "#endif"]: if template_string in processed: - errors.append(f"Found '{template_string}' after " - f"processing header.sh with '{params}'.") + errors.append( + f"Found '{template_string}' after " f"processing header.sh with '{params}'." + ) assert not errors @@ -117,13 +121,22 @@ def test_osxpkg_scripts_template_processing(arch, check_path_spaces, script): @pytest.mark.skipif(available_command("shellcheck") is False, reason="requires shellcheck") @pytest.mark.parametrize("arch", ["x86_64", "arm64"]) @pytest.mark.parametrize("check_path_spaces", [False, True]) -@pytest.mark.parametrize("script", sorted(Path(OSX_DIR).glob("*.sh"))) +@pytest.mark.parametrize( + "script", [pytest.param(path, id=str(path)) for path in sorted(Path(OSX_DIR).glob("*.sh"))] +) def test_osxpkg_scripts_shellcheck(arch, check_path_spaces, script): with script.open() as f: data = f.read() - processed = preprocess(data, {"arch": arch, "check_path_spaces": check_path_spaces}) + processed = preprocess( + data, + { + "arch": arch, + "check_path_spaces": check_path_spaces, + }, + ) findings, returncode = run_shellcheck(processed) + print(*findings, sep="\n") assert findings == [] assert returncode == 0 @@ -142,6 +155,7 @@ def test_osxpkg_scripts_shellcheck(arch, check_path_spaces, script): @pytest.mark.parametrize("has_pre_install", [False]) @pytest.mark.parametrize("arch", ["x86_64", "aarch64"]) @pytest.mark.parametrize("check_path_spaces", [True]) +@pytest.mark.parametrize("enable_shortcuts", ["true"]) def test_template_shellcheck( osx, arch, @@ -156,6 +170,7 @@ def test_template_shellcheck( direct_execute_pre_install, direct_execute_post_install, check_path_spaces, + enable_shortcuts, ): template = read_header_template() processed = preprocess( @@ -179,6 +194,7 @@ def test_template_shellcheck( "initialize_conda": initialize_conda, "initialize_by_default": initialize_by_default, "check_path_spaces": check_path_spaces, + "enable_shortcuts": enable_shortcuts, }, )