Skip to content

Commit 29da2ae

Browse files
committed
Add support for user supplied scripts
1 parent 40cf913 commit 29da2ae

8 files changed

Lines changed: 235 additions & 8 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
### `default_prefix`
416419

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
default_prefix: NonEmptyStr | None = None
586589
"""

constructor/briefcase.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ def prepare(self) -> tuple:
387387
preconda.write_files(self.info, base_dir)
388388
preconda.copy_extra_files(self.info.get("extra_files", []), external_dir)
389389
self._stage_dists(pkgs_dir)
390+
self._stage_user_scripts(pkgs_dir)
390391
self._stage_conda(external_dir)
391392

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

468482
# Render the templates now using jinja and the defined context
@@ -516,6 +530,22 @@ def _stage_dists(self, pkgs_dir: Path) -> None:
516530
for dist in sorted(dists):
517531
shutil.copy(download_dir / filename_dist(dist), pkgs_dir)
518532

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

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) }}
@@ -173,4 +195,17 @@ if "%OPTION_CLEAR_PACKAGE_CACHE%"=="1" (
173195
if errorlevel 1 ( exit /b %errorlevel% )
174196
)
175197

198+
{%- if has_post_install %}
199+
rem Run user-supplied post-install script
200+
{%- if has_post_install_desc %}
201+
if "%OPTION_POST_INSTALL_SCRIPT%"=="1" (
202+
{%- endif %}
203+
{{ tee("Running post-install script...") }}
204+
call "%BASE_PATH%\pkgs\user_post_install.bat"
205+
if errorlevel 1 ( exit /b %errorlevel% )
206+
{%- if has_post_install_desc %}
207+
)
208+
{%- endif %}
209+
{%- endif %}
210+
176211
exit /b 0

constructor/data/construct.schema.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -996,7 +996,7 @@
996996
}
997997
],
998998
"default": null,
999-
"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\"`",
999+
"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\"`",
10001000
"title": "Post Install"
10011001
},
10021002
"post_install_desc": {
@@ -1073,7 +1073,7 @@
10731073
}
10741074
],
10751075
"default": null,
1076-
"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`.",
1076+
"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`.",
10771077
"title": "Pre Uninstall"
10781078
},
10791079
"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
### `default_prefix`
416419

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)