Skip to content

Commit 0528eaa

Browse files
MSI: Add support for extra_envs (#1186)
* add support for extra_envs, work in progress * Fix rebase issues * Add more examples to MSI testing * fix error: TypeError: unhashable type * Changed default value of shortcut option for consistency with NSIS * Add debug output * Always uninstall with conda-standalone * Add debug output * Improve debug output * Remove debug output, skip test * Remove more debug statements * Use pytest.xfail * Review fix * Add suggestion from review Co-authored-by: Marco Esters <mesters@anaconda.com> * Bump min conda-standalone --------- Co-authored-by: Marco Esters <mesters@anaconda.com>
1 parent bca30b6 commit 0528eaa

9 files changed

Lines changed: 226 additions & 68 deletions

File tree

constructor/briefcase.py

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
bat_env_var_esc,
2929
copy_conda_exe,
3030
filename_dist,
31+
get_final_channels,
3132
shortcuts_flags,
3233
)
3334

@@ -151,11 +152,50 @@ def _get_script_env_variables(info: dict) -> dict[str, str]:
151152
return escaped_vars
152153

153154

155+
def _setup_envs_commands(info: dict) -> list[dict]:
156+
"""Build environment setup data for base and extra_envs.
157+
158+
Returns a list of dicts, each containing the data needed to install
159+
one environment. Used by the run_installation.bat template.
160+
"""
161+
environments = []
162+
163+
# Base environment
164+
environments.append(
165+
{
166+
"name": "base",
167+
"prefix": "%BASE_PATH%",
168+
"lockfile": r"%BASE_PATH%\conda-meta\initial-state.explicit.txt",
169+
"channels": ",".join(get_final_channels(info)),
170+
"shortcuts": shortcuts_flags(info),
171+
}
172+
)
173+
174+
# Extra environments
175+
for env_name in info.get("_extra_envs_info", {}):
176+
env_config = info["extra_envs"][env_name]
177+
# Needed for shortcuts_flags function
178+
if "_conda_exe_type" not in env_config:
179+
env_config["_conda_exe_type"] = info.get("_conda_exe_type")
180+
channel_info = {
181+
"channels": env_config.get("channels", info.get("channels", ())),
182+
"channels_remap": env_config.get("channels_remap", info.get("channels_remap", ())),
183+
}
184+
environments.append(
185+
{
186+
"name": env_name,
187+
"prefix": rf"%BASE_PATH%\envs\{env_name}",
188+
"lockfile": rf"%BASE_PATH%\envs\{env_name}\conda-meta\initial-state.explicit.txt",
189+
"channels": ",".join(get_final_channels(channel_info)),
190+
"shortcuts": shortcuts_flags(env_config),
191+
}
192+
)
193+
194+
return environments
195+
196+
154197
def create_uninstall_options_list(info: dict) -> list[dict]:
155-
"""Returns a list of dicts with data formatted for the uninstallation options page.
156-
Options are currently only shown when uninstall_with_conda_exe is True."""
157-
if not bool(info.get("uninstall_with_conda_exe")):
158-
return []
198+
"""Returns a list of dicts with data formatted for the uninstallation options page."""
159199
return [
160200
{
161201
"name": "remove_user_data",
@@ -243,7 +283,7 @@ def create_install_options_list(info: dict) -> list[dict]:
243283
"name": "enable_shortcuts",
244284
"title": "Create shortcuts",
245285
"description": "Create shortcuts (supported packages only).",
246-
"default": False,
286+
"default": True,
247287
}
248288
)
249289

@@ -411,14 +451,8 @@ def render_templates(self) -> list[Path]:
411451
# In the .bat template this is used in the "shortcuts enabled" branch,
412452
# so passing an empty string here is correct when all shortcuts are wanted.
413453
"shortcuts": shortcuts_flags(self.info),
414-
# --- uninstall_with_conda_exe ---
415-
"uninstall_with_conda_exe": bool(self.info.get("uninstall_with_conda_exe")),
416-
# --- has_conda ---
417-
"has_conda": self.info.get("_has_conda", False),
418454
# --- setup_envs ---
419-
# Placeholder for extra_envs support. Currently only contains base env.
420-
# Will be expanded when extra_envs is implemented for MSI installers.
421-
"setup_envs": [{"name": "base", "prefix": "%BASE_PATH%"}],
455+
"setup_envs": _setup_envs_commands(self.info),
422456
# --- virtual_specs ---
423457
# virtual_specs: quoted for command-line use
424458
# virtual_specs_debug: unquoted for display
@@ -475,7 +509,11 @@ def write_pyproject_toml(self, root: Path, external: Path) -> None:
475509

476510
def _stage_dists(self, pkgs_dir: Path) -> None:
477511
download_dir = Path(self.info["_download_dir"])
478-
for dist in self.info["_dists"]:
512+
# Collect dists from base and extra_envs, de-duplicated
513+
dists = set(self.info["_dists"])
514+
for env_info in self.info.get("_extra_envs_info", {}).values():
515+
dists.update(env_info.get("_dists", []))
516+
for dist in sorted(dists):
479517
shutil.copy(download_dir / filename_dist(dist), pkgs_dir)
480518

481519
def _stage_conda(self, external_dir: Path) -> None:
@@ -489,6 +527,11 @@ def create(info, verbose=False):
489527
if not info.get("_conda_exe_supports_logging"):
490528
raise Exception("MSI installers require conda-standalone with logging support.")
491529

530+
# MSI installers always use conda-standalone for uninstallation.
531+
# This ensures proper cleanup of conda init, environments, and shortcuts
532+
# via the `conda constructor uninstall` command.
533+
info["uninstall_with_conda_exe"] = True
534+
492535
payload = Payload(info)
493536
payload.prepare()
494537

constructor/briefcase/pre_uninstall.bat

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,6 @@ exit /b 0
115115
:after_remove_python_registry
116116
{%- endif %}
117117

118-
{%- if uninstall_with_conda_exe %}
119118
rem Run constructor uninstall, conditionally passing optional flags
120119
set "UNINST_ARGS="
121120
if "%OPTION_REMOVE_USER_DATA%"=="1" (
@@ -136,31 +135,6 @@ if "%OPTION_REMOVE_CONFIG_FILES%"=="1" (
136135
{{ tee("Running constructor uninstall...") }}
137136
"%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%"!UNINST_ARGS! --log-file "%LOG%"
138137
if errorlevel 1 ( exit /b %errorlevel% )
139-
{%- else %}
140-
rem Remove menus for each environment.
141-
{%- for env in setup_envs %}
142-
{{ tee("Removing menus for " + env.name + "...") }}
143-
"%CONDA_EXE%" constructor --prefix "{{ env.prefix }}" --rm-menus --log-file "%LOG%"
144-
if errorlevel 1 ( exit /b %errorlevel% )
145-
{%- endfor %}
146-
147-
{%- if has_conda %}
148-
rem Reverse conda shell initialization
149-
if "%REG_HIVE%"=="HKCU" (
150-
set "CONDA_INIT_SCOPE=user"
151-
) else (
152-
set "CONDA_INIT_SCOPE=system"
153-
)
154-
{{ tee("Reversing conda shell initialization...") }}
155-
"%BASE_PATH%\condabin\conda.bat" init cmd.exe --reverse --!CONDA_INIT_SCOPE! --log-file "%LOG%"
156-
if errorlevel 1 ( exit /b %errorlevel% )
157-
{%- endif %}
158-
159-
rem Remove conda environments. INSTDIR itself is cleaned up by the MSI engine.
160-
{{ tee("Removing environments...") }}
161-
rmdir /s /q "%BASE_PATH%"
162-
if errorlevel 1 ( exit /b %errorlevel% )
163-
{%- endif %}
164138

165139
rem If we reached this far without any errors, remove any log files.
166140
if exist "%INSTDIR%\install.log" del "%INSTDIR%\install.log"

constructor/briefcase/run_installation.bat

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ echo {{ message }}
1515
>> "%LOG%" echo {{ message }}
1616
{%- endmacro %}
1717

18+
{% macro install_env(env) %}
19+
{{ tee("Setting up " ~ env.name ~ " environment...") }}
20+
set "CONDA_CHANNELS={{ env.channels }}"
21+
if "%OPTION_ENABLE_SHORTCUTS%"=="1" (
22+
"%CONDA_EXE%" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile }}" {{ env.shortcuts }} {{ no_rcs_arg }} --log-file "%LOG%"
23+
) else (
24+
"%CONDA_EXE%" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile }}" --no-shortcuts {{ no_rcs_arg }} --log-file "%LOG%"
25+
)
26+
set "INSTALL_ERRORLEVEL=%errorlevel%"
27+
if %INSTALL_ERRORLEVEL% neq 0 ( exit /b %INSTALL_ERRORLEVEL% )
28+
{% endmacro %}
29+
1830
rem Assign INSTDIR and normalize the path
1931
set "INSTDIR=%~dp0.."
2032
for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI"
@@ -98,24 +110,17 @@ set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs"
98110
"%CONDA_EXE%" constructor extract --prefix "%BASE_PATH%" --conda-pkgs --log-file "%LOG%"
99111
if errorlevel 1 ( exit /b %errorlevel% )
100112

101-
rem TODO: loop over extra_envs when extra_envs support is implemented for MSI.
102-
103113
rem Create .nonadmin marker file for user-scoped installs inside BASE_PATH.
104114
rem This is used by the uninstaller (and menuinst) to determine the install mode.
105115
if "%ALLUSERS%"=="0" (
106116
echo. > "%BASE_PATH%\.nonadmin"
107117
if errorlevel 1 ( exit /b %errorlevel% )
108118
)
109119

110-
rem Install packages, conditionally creating shortcuts
111-
if "%OPTION_ENABLE_SHORTCUTS%"=="1" (
112-
{{ tee("Installing packages with shortcuts...") }}
113-
"%CONDA_EXE%" install --offline -yp "%BASE_PATH%" --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" {{ shortcuts }} {{ no_rcs_arg }} --log-file "%LOG%"
114-
) else (
115-
{{ tee("Installing packages...") }}
116-
"%CONDA_EXE%" install --offline -yp "%BASE_PATH%" --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" --no-shortcuts {{ no_rcs_arg }} --log-file "%LOG%"
117-
)
118-
if errorlevel 1 ( exit /b %errorlevel% )
120+
rem Install packages for each environment
121+
{%- for env in setup_envs %}
122+
{{ install_env(env) }}
123+
{%- endfor %}
119124

120125
rem Delete the payload to save disk space.
121126
rem A truncated placeholder of 0 bytes is recreated during uninstall

constructor/main.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ def get_installer_type(info: dict):
4848
return os_allowed[osname][:1]
4949
elif itype == "all":
5050
return os_allowed[osname]
51+
elif isinstance(itype, (list, tuple)):
52+
# Handle list of installer types, e.g. [exe, msi]
53+
for t in itype:
54+
if t not in all_allowed:
55+
all_allowed_str = ", ".join(sorted(all_allowed))
56+
sys.exit("Error: invalid installer type '%s'; allowed: %s" % (t, all_allowed_str))
57+
if t not in os_allowed[osname]:
58+
os_allowed_str = ", ".join(sorted(os_allowed[osname]))
59+
sys.exit(
60+
"Error: invalid installer type '%s' for %s; allowed: %s"
61+
% (t, osname, os_allowed_str)
62+
)
63+
return tuple(itype)
5164
elif itype not in all_allowed:
5265
all_allowed = ", ".join(sorted(all_allowed))
5366
sys.exit("Error: invalid installer type '%s'; allowed: %s" % (itype, all_allowed))

examples/outputs/construct.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
name: Outputs
55
version: 1.0.0
66
installer_type: sh # [unix]
7-
installer_type: exe # [win]
7+
installer_type: [exe, msi] # [win]
88
channels:
99
- https://conda.anaconda.org/conda-forge
1010
specs:

examples/shortcuts/construct.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
name: MinicondaWithShortcuts
55
version: X
6-
installer_type: {{ "exe" if os.name == "nt" else "all" }}
6+
installer_type: all
77

88
channels:
99
- conda-test/label/menuinst-tests

recipe/meta.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ requirements:
2525
- conda >=4.6
2626
- python # >=3.10
2727
- ruamel.yaml >=0.11.14,<0.19
28-
- conda-standalone >=24.1.2
28+
- conda-standalone >=24.11.0
2929
- jinja2
3030
- jsonschema >=4
3131
- pillow >=3.1 # [win or osx]

0 commit comments

Comments
 (0)