Skip to content

Commit d65daea

Browse files
committed
MSI: Add support for user supplied scripts (conda#1189)
* Add support for user supplied scripts * Enable some new examples * Fix type error for test * Change copying of extra_files to base environment * Fix issue with license file not being copied * Add debug output * Fix order of user script, fixes last error
1 parent 104556e commit d65daea

11 files changed

Lines changed: 242 additions & 12 deletions

File tree

CONSTRUCT.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,9 @@ Path to a post-install script. Some notes:
388388
the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`,
389389
`%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`.
390390
`%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`/S`), `"0"` otherwise.
391+
- For Windows `.msi` installers, the script must be a `.bat` file.
392+
The same variables as `.exe` installers are available, except
393+
`%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available.
391394

392395
If necessary, you can activate the installed `base` environment like this:
393396

@@ -406,11 +409,11 @@ This option has no effect on `SH` installers.
406409

407410
### `pre_uninstall`
408411

409-
Path to a pre uninstall script. This is only supported on Windows,
412+
Path to a pre uninstall script. This is only supported on Windows (EXE and MSI),
410413
and must be a `.bat` file. Installation path is available as `%PREFIX%`.
411414
Metadata about the installer can be found in the `%INSTALLER_NAME%`,
412415
`%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables.
413-
`%INSTALLER_TYPE%` is set to `EXE`.
416+
`%INSTALLER_TYPE%` is set to `EXE` or `MSI`.
414417

415418
If the uninstallation is performed with `conda-standalone`, the following
416419
environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to

constructor/_schema.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,9 @@ class ConstructorConfiguration(BaseModel):
558558
the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`,
559559
`%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`.
560560
`%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`/S`), `"0"` otherwise.
561+
- For Windows `.msi` installers, the script must be a `.bat` file.
562+
The same variables as `.exe` installers are available, except
563+
`%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available.
561564
562565
If necessary, you can activate the installed `base` environment like this:
563566
@@ -576,11 +579,11 @@ class ConstructorConfiguration(BaseModel):
576579
"""
577580
pre_uninstall: NonEmptyStr | None = None
578581
"""
579-
Path to a pre uninstall script. This is only supported on Windows,
582+
Path to a pre uninstall script. This is only supported on Windows (EXE and MSI),
580583
and must be a `.bat` file. Installation path is available as `%PREFIX%`.
581584
Metadata about the installer can be found in the `%INSTALLER_NAME%`,
582585
`%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables.
583-
`%INSTALLER_TYPE%` is set to `EXE`.
586+
`%INSTALLER_TYPE%` is set to `EXE` or `MSI`.
584587
585588
If the uninstallation is performed with `conda-standalone`, the following
586589
environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to

constructor/briefcase.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,8 +386,12 @@ def prepare(self) -> None:
386386
self.write_pyproject_toml(self.root, external_dir)
387387

388388
preconda.write_files(self.info, base_dir)
389-
preconda.copy_extra_files(self.info.get("extra_files", []), external_dir)
389+
preconda.copy_extra_files(self.info.get("extra_files", []), base_dir)
390+
# Copy license file to PREFIX, matching behavior of other installer types
391+
if license_file := self.info.get("license_file"):
392+
preconda.copy_extra_files([license_file], base_dir)
390393
self._stage_dists(pkgs_dir)
394+
self._stage_user_scripts(pkgs_dir)
391395
self._stage_conda(external_dir)
392396

393397
archive_path = self.make_archive(base_dir, external_dir)
@@ -463,6 +467,19 @@ def render_templates(self) -> list[Path]:
463467
# --- script_env_variables ---
464468
# User-defined environment variables for pre/post install scripts
465469
"script_env_variables": _get_script_env_variables(self.info),
470+
# --- user scripts ---
471+
# Flags indicating whether user-supplied scripts are present
472+
"has_pre_install": bool(self.info.get("pre_install")),
473+
"has_post_install": bool(self.info.get("post_install")),
474+
"has_pre_uninstall": bool(self.info.get("pre_uninstall")),
475+
# Flags indicating whether scripts are optional (have a description)
476+
"has_pre_install_desc": bool(self.info.get("pre_install_desc")),
477+
"has_post_install_desc": bool(self.info.get("post_install_desc")),
478+
# --- installer metadata ---
479+
# Used by user scripts to identify the installer
480+
"installer_name": self.info.get("name", ""),
481+
"installer_version": self.info.get("version", ""),
482+
"installer_platform": self.info.get("_platform", ""),
466483
}
467484

468485
# Render the templates now using jinja and the defined context
@@ -516,6 +533,22 @@ def _stage_dists(self, pkgs_dir: Path) -> None:
516533
for dist in sorted(dists):
517534
shutil.copy(download_dir / filename_dist(dist), pkgs_dir)
518535

536+
def _stage_user_scripts(self, pkgs_dir: Path) -> None:
537+
"""Copy user-supplied pre/post install scripts to the pkgs directory."""
538+
script_mappings = [
539+
("pre_install", "user_pre_install.bat"),
540+
("post_install", "user_post_install.bat"),
541+
("pre_uninstall", "user_pre_uninstall.bat"),
542+
]
543+
for key, dest_name in script_mappings:
544+
if script_path := self.info.get(key):
545+
script_path = Path(script_path)
546+
if not is_bat_file(script_path):
547+
raise ValueError(
548+
f"Specified {key} script '{script_path}' must be an existing '.bat' file."
549+
)
550+
shutil.copy(script_path, pkgs_dir / dest_name)
551+
519552
def _stage_conda(self, external_dir: Path) -> None:
520553
copy_conda_exe(external_dir, self.conda_exe_name, self.info["_conda_exe"])
521554

constructor/briefcase/pre_uninstall.bat

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ set "{{ key }}={{ val }}"
3737
{%- endfor %}
3838
{%- endif %}
3939

40+
rem Installer metadata for pre-uninstall script
41+
set "INSTALLER_NAME={{ installer_name }}"
42+
set "INSTALLER_VER={{ installer_version }}"
43+
set "INSTALLER_PLAT={{ installer_platform }}"
44+
set "INSTALLER_TYPE=MSI"
45+
rem INSTALLER_UNATTENDED is not available for MSI installers.
46+
rem Detecting silent mode requires UILevel from WiX, which would need
47+
rem changes to the briefcase-windows-app-template to pass to this script.
48+
4049
rem Determine install mode from .nonadmin marker file written at install time
4150
if exist "%BASE_PATH%\.nonadmin" (
4251
set "REG_HIVE=HKCU"
@@ -78,6 +87,13 @@ if errorlevel 1 (
7887
{{ error_block('Failed to create "%PAYLOAD_TAR%"', '%errorlevel%') }}
7988
)
8089

90+
{%- if has_pre_uninstall %}
91+
rem Run user-supplied pre-uninstall script
92+
{{ tee("Running pre-uninstall script...") }}
93+
call "%BASE_PATH%\pkgs\user_pre_uninstall.bat"
94+
if errorlevel 1 ( exit /b %errorlevel% )
95+
{%- endif %}
96+
8197
rem Remove PATH entries only for user-scoped installs (mirrors NSIS .nonadmin check)
8298
{%- set pathflag = "--condabin" if initialize_conda == "condabin" else "--classic" %}
8399
if exist "%BASE_PATH%\.nonadmin" (

constructor/briefcase/run_installation.bat

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ set "{{ key }}={{ val }}"
5353
{%- endfor %}
5454
{%- endif %}
5555

56+
rem Installer metadata for pre/post install scripts
57+
set "INSTALLER_NAME={{ installer_name }}"
58+
set "INSTALLER_VER={{ installer_version }}"
59+
set "INSTALLER_PLAT={{ installer_platform }}"
60+
set "INSTALLER_TYPE=MSI"
61+
rem INSTALLER_UNATTENDED is not available for MSI installers.
62+
rem Detecting silent mode requires UILevel from WiX, which would need
63+
rem changes to the briefcase-windows-app-template to pass to this script.
64+
5665
{%- if add_debug %}
5766
>> "%LOG%" echo ==== run_installation start ====
5867
>> "%LOG%" echo SCRIPT=%~f0
@@ -117,6 +126,19 @@ if "%ALLUSERS%"=="0" (
117126
if errorlevel 1 ( exit /b %errorlevel% )
118127
)
119128

129+
{%- if has_pre_install %}
130+
rem Run user-supplied pre-install script
131+
{%- if has_pre_install_desc %}
132+
if "%OPTION_PRE_INSTALL_SCRIPT%"=="1" (
133+
{%- endif %}
134+
{{ tee("Running pre-install script...") }}
135+
call "%BASE_PATH%\pkgs\user_pre_install.bat"
136+
if errorlevel 1 ( exit /b %errorlevel% )
137+
{%- if has_pre_install_desc %}
138+
)
139+
{%- endif %}
140+
{%- endif %}
141+
120142
rem Install packages for each environment
121143
{%- for env in setup_envs %}
122144
{{ install_env(env) }}
@@ -166,6 +188,19 @@ if "%OPTION_REGISTER_PYTHON%"=="1" (
166188
)
167189
{%- endif %}
168190

191+
{%- if has_post_install %}
192+
rem Run user-supplied post-install script
193+
{%- if has_post_install_desc %}
194+
if "%OPTION_POST_INSTALL_SCRIPT%"=="1" (
195+
{%- endif %}
196+
{{ tee("Running post-install script...") }}
197+
call "%BASE_PATH%\pkgs\user_post_install.bat"
198+
if errorlevel 1 ( exit /b %errorlevel% )
199+
{%- if has_post_install_desc %}
200+
)
201+
{%- endif %}
202+
{%- endif %}
203+
169204
rem Clear the package cache if the option was selected
170205
if "%OPTION_CLEAR_PACKAGE_CACHE%"=="1" (
171206
{{ tee("Clearing package cache...") }}

constructor/data/construct.schema.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -997,7 +997,7 @@
997997
}
998998
],
999999
"default": null,
1000-
"description": "Path to a post-install script. Some notes:\n- For Unix `.sh` installers, the shebang line is respected if present; otherwise, the script is run by the POSIX shell `sh`. Note that the use of a shebang can reduce the portability of the installer. The installation path is available as `${PREFIX}`. Installer metadata is available in the `${INSTALLER_NAME}`, `${INSTALLER_VER}`, `${INSTALLER_PLAT}` environment variables. `${INSTALLER_TYPE}` is set to `SH`. `${INSTALLER_UNATTENDED}` will be `\"1\"` in batch mode (`-b`), `\"0\"` otherwise.\n- For PKG installers, the shebang line is respected if present; otherwise, `bash` is used. The same variables mentioned for `sh` installers are available here. `${INSTALLER_TYPE}` is set to `PKG`. `${INSTALLER_UNATTENDED}` will be `\"1\"` for command line installs, `\"0\"` otherwise.\n- For Windows `.exe` installers, the script must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `\"1\"` in silent mode (`/S`), `\"0\"` otherwise.\nIf necessary, you can activate the installed `base` environment like this:\n- Unix: `. \"$PREFIX/etc/profile.d/conda.sh\" && conda activate \"$PREFIX\"`\n- Windows: `call \"%PREFIX%\\Scripts\\activate.bat\"`",
1000+
"description": "Path to a post-install script. Some notes:\n- For Unix `.sh` installers, the shebang line is respected if present; otherwise, the script is run by the POSIX shell `sh`. Note that the use of a shebang can reduce the portability of the installer. The installation path is available as `${PREFIX}`. Installer metadata is available in the `${INSTALLER_NAME}`, `${INSTALLER_VER}`, `${INSTALLER_PLAT}` environment variables. `${INSTALLER_TYPE}` is set to `SH`. `${INSTALLER_UNATTENDED}` will be `\"1\"` in batch mode (`-b`), `\"0\"` otherwise.\n- For PKG installers, the shebang line is respected if present; otherwise, `bash` is used. The same variables mentioned for `sh` installers are available here. `${INSTALLER_TYPE}` is set to `PKG`. `${INSTALLER_UNATTENDED}` will be `\"1\"` for command line installs, `\"0\"` otherwise.\n- For Windows `.exe` installers, the script must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `\"1\"` in silent mode (`/S`), `\"0\"` otherwise.\n- For Windows `.msi` installers, the script must be a `.bat` file. The same variables as `.exe` installers are available, except `%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available.\nIf necessary, you can activate the installed `base` environment like this:\n- Unix: `. \"$PREFIX/etc/profile.d/conda.sh\" && conda activate \"$PREFIX\"`\n- Windows: `call \"%PREFIX%\\Scripts\\activate.bat\"`",
10011001
"title": "Post Install"
10021002
},
10031003
"post_install_desc": {
@@ -1074,7 +1074,7 @@
10741074
}
10751075
],
10761076
"default": null,
1077-
"description": "Path to a pre uninstall script. This is only supported on Windows, and must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`.\nIf the uninstallation is performed with `conda-standalone`, the following environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to `system`, `user`, or `all` if selected), `%UNINSTALLER_REMOVE_USER_DATA%` (set to `1` if set), and `%UNINSTALLER_REMOVE_CACHES%` (set to `1` if set).",
1077+
"description": "Path to a pre uninstall script. This is only supported on Windows (EXE and MSI), and must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE` or `MSI`.\nIf the uninstallation is performed with `conda-standalone`, the following environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to `system`, `user`, or `all` if selected), `%UNINSTALLER_REMOVE_USER_DATA%` (set to `1` if set), and `%UNINSTALLER_REMOVE_CACHES%` (set to `1` if set).",
10781078
"title": "Pre Uninstall"
10791079
},
10801080
"progress_notifications": {

docs/source/construct-yaml.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,9 @@ Path to a post-install script. Some notes:
388388
the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`,
389389
`%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`.
390390
`%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`/S`), `"0"` otherwise.
391+
- For Windows `.msi` installers, the script must be a `.bat` file.
392+
The same variables as `.exe` installers are available, except
393+
`%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available.
391394

392395
If necessary, you can activate the installed `base` environment like this:
393396

@@ -406,11 +409,11 @@ This option has no effect on `SH` installers.
406409

407410
### `pre_uninstall`
408411

409-
Path to a pre uninstall script. This is only supported on Windows,
412+
Path to a pre uninstall script. This is only supported on Windows (EXE and MSI),
410413
and must be a `.bat` file. Installation path is available as `%PREFIX%`.
411414
Metadata about the installer can be found in the `%INSTALLER_NAME%`,
412415
`%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables.
413-
`%INSTALLER_TYPE%` is set to `EXE`.
416+
`%INSTALLER_TYPE%` is set to `EXE` or `MSI`.
414417

415418
If the uninstallation is performed with `conda-standalone`, the following
416419
environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to

examples/customize_controls/construct.yaml

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

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

88
channels:
99
- https://repo.anaconda.com/pkgs/main/

examples/from_env_yaml/construct.yaml

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

44
name: EnvironmentYAML
55
version: 1.0.0
6-
installer_type: {{ "exe" if os.name == "nt" else "all" }}
6+
installer_type: all
77
environment_file: env.yaml
88
initialize_by_default: false
99
register_python: False

tests/test_briefcase.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,3 +849,140 @@ def test_render_templates_with_extra_envs():
849849
assert "Setting up base environment" in text
850850
assert "Setting up py311 environment" in text
851851
assert r"%BASE_PATH%\envs\py311" in text
852+
853+
854+
@pytest.mark.parametrize("template_name", ["run_installation.bat", "pre_uninstall.bat"])
855+
def test_render_templates_installer_metadata(template_name):
856+
"""Test that installer metadata env vars are rendered in both templates."""
857+
info = mock_info.copy()
858+
payload = Payload(info)
859+
rendered_templates = payload.render_templates()
860+
861+
template = next(f for f in rendered_templates if f.name == template_name)
862+
text = template.read_text(encoding="utf-8")
863+
864+
assert 'set "INSTALLER_NAME=MockInfo"' in text
865+
assert 'set "INSTALLER_VER=1.0.0"' in text
866+
assert f'set "INSTALLER_PLAT={cc_platform}"' in text
867+
assert 'set "INSTALLER_TYPE=MSI"' in text
868+
assert "INSTALLER_UNATTENDED is not available" in text
869+
870+
871+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only")
872+
@pytest.mark.parametrize(
873+
"script_type,has_desc",
874+
[
875+
("pre_install", False),
876+
("pre_install", True),
877+
("post_install", False),
878+
("post_install", True),
879+
],
880+
)
881+
def test_render_templates_user_install_scripts(tmp_path, script_type, has_desc):
882+
"""Test that user install scripts are rendered correctly.
883+
884+
- Mandatory scripts (no desc) are always called
885+
- Optional scripts (with desc) are gated by OPTION flag
886+
"""
887+
script = tmp_path / f"{script_type}.bat"
888+
script.write_text(f"@echo {script_type}")
889+
890+
info = mock_info.copy()
891+
info[script_type] = str(script)
892+
if has_desc:
893+
info[f"{script_type}_desc"] = "Custom script description"
894+
895+
payload = Payload(info)
896+
rendered_templates = payload.render_templates()
897+
898+
run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat")
899+
text = run_installation.read_text(encoding="utf-8")
900+
901+
# Script call should always be present
902+
label = script_type.replace("_", "-") # pre_install -> pre-install
903+
assert f"Running {label} script" in text
904+
assert f"user_{script_type}.bat" in text
905+
906+
# OPTION check only present for optional scripts
907+
option_var = f"OPTION_{script_type.upper()}_SCRIPT"
908+
if has_desc:
909+
assert option_var in text
910+
else:
911+
assert option_var not in text
912+
913+
914+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only")
915+
def test_render_templates_with_pre_uninstall(tmp_path):
916+
"""Test that pre_uninstall script call is rendered."""
917+
script = tmp_path / "pre_uninstall.bat"
918+
script.write_text("@echo pre_uninstall")
919+
920+
info = mock_info.copy()
921+
info["pre_uninstall"] = str(script)
922+
payload = Payload(info)
923+
rendered_templates = payload.render_templates()
924+
925+
pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat")
926+
text = pre_uninstall.read_text(encoding="utf-8")
927+
928+
assert "Running pre-uninstall script" in text
929+
assert "user_pre_uninstall.bat" in text
930+
931+
932+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only")
933+
def test_render_templates_without_user_scripts():
934+
"""Test that no user script blocks are rendered when scripts are not provided."""
935+
info = mock_info.copy()
936+
payload = Payload(info)
937+
rendered_templates = payload.render_templates()
938+
939+
run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat")
940+
pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat")
941+
942+
assert "user_pre_install.bat" not in run_installation.read_text(encoding="utf-8")
943+
assert "user_post_install.bat" not in run_installation.read_text(encoding="utf-8")
944+
assert "user_pre_uninstall.bat" not in pre_uninstall.read_text(encoding="utf-8")
945+
946+
947+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only")
948+
@pytest.mark.parametrize(
949+
"script_key,dest_name",
950+
[
951+
("pre_install", "user_pre_install.bat"),
952+
("post_install", "user_post_install.bat"),
953+
("pre_uninstall", "user_pre_uninstall.bat"),
954+
],
955+
)
956+
def test_stage_user_scripts(tmp_path, script_key, dest_name):
957+
"""Test that user scripts are staged to the correct location."""
958+
script = tmp_path / f"{script_key}.bat"
959+
script.write_text(f"@echo {script_key}")
960+
961+
info = mock_info.copy()
962+
info[script_key] = str(script)
963+
payload = Payload(info)
964+
965+
pkgs_dir = tmp_path / "pkgs"
966+
pkgs_dir.mkdir()
967+
payload._stage_user_scripts(pkgs_dir)
968+
969+
staged_script = pkgs_dir / dest_name
970+
assert staged_script.is_file()
971+
assert staged_script.read_text() == f"@echo {script_key}"
972+
973+
974+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only")
975+
def test_stage_user_scripts_validates_bat_extension(tmp_path):
976+
"""Test that non-.bat files are rejected."""
977+
script = tmp_path / "pre_install.sh"
978+
script.write_text("foo")
979+
980+
info = mock_info.copy()
981+
info["pre_install"] = str(script)
982+
payload = Payload(info)
983+
984+
pkgs_dir = tmp_path / "pkgs"
985+
pkgs_dir.mkdir()
986+
987+
with pytest.raises(ValueError, match="must be an existing '.bat' file"):
988+
payload._stage_user_scripts(pkgs_dir)

0 commit comments

Comments
 (0)