From 9286bb41d3d93666cd88885d3c84ff1ebf6417f4 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 12 Nov 2025 12:47:35 -0500 Subject: [PATCH 01/24] Dummy commit --- tests/test_examples.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_examples.py b/tests/test_examples.py index 7d8913e2f..7108b95f6 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -166,6 +166,8 @@ def _run_installer_exe( _check_installer_log(install_dir) return process +def _run_uninstaller_windows(): + print("TODO") def _run_uninstaller_exe( install_dir: Path, From 92735f06ee7618bf9115ddb9eaf6e0dc949ff0e8 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 12 Nov 2025 13:54:35 -0500 Subject: [PATCH 02/24] Temporarily disable verbosity --- tests/test_examples.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 7108b95f6..cf21ec04b 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -361,13 +361,16 @@ def create_installer( cmd = [ *COV_CMD, "constructor", - "-v", str(input_dir), "--output-dir", str(output_dir), "--config-filename", config_filename, ] + # This flag will (if enabled) create a lot of output upon test failures for .exe-installers. + # If debugging generated NSIS templates, it can be worth to enable. + if os.getenv("CONSTRUCTOR_VERBOSE"): + cmd.insert(2, "-v") if conda_exe: cmd.extend(["--conda-exe", conda_exe]) if debug: From efe3ef30076441f46c73a2776d23144e3ce803b2 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 12 Nov 2025 14:13:10 -0500 Subject: [PATCH 03/24] Set appropriate version, raise error on missing executable --- constructor/briefcase.py | 5 +++++ examples/extra_files/construct.yaml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index eea2083d5..cb0fa0b16 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -133,6 +133,11 @@ def create(info, verbose=False): copy_conda_exe(external_dir, "_conda.exe", info["_conda_exe"]) briefcase = Path(sysconfig.get_path("scripts")) / "briefcase.exe" + if not briefcase.exists(): + raise FileNotFoundError( + f"Dependency 'briefcase' does not seem installed." + f"Tried: {briefcase}" + ) logger.info("Building installer") run( [briefcase, "package"] + (["-v"] if verbose else []), diff --git a/examples/extra_files/construct.yaml b/examples/extra_files/construct.yaml index 0bcbd2b6d..de294d5ce 100644 --- a/examples/extra_files/construct.yaml +++ b/examples/extra_files/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: ExtraFiles -version: X +version: 1.0.0 installer_type: all check_path_spaces: False check_path_length: False From 0d0063b5f560bda3eb95da62eb3d8acadc1390ea Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 12 Nov 2025 15:35:48 -0500 Subject: [PATCH 04/24] Update github workflow and briefcase test integration --- .github/workflows/main.yml | 12 ++++++++++++ constructor/briefcase.py | 11 +++++++++-- tests/test_examples.py | 12 ++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e053e5c40..5287a9359 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -135,6 +135,18 @@ jobs: run: conda list - name: conda config run: conda config --show-sources + + - name: Checkout Briefcase (pinned) + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + repository: beeware/briefcase + ref: efb0e08c2a9ce72dcebd0a62c12f5662fd5eb0e0 # this is a commit with latest necessary changes on the main branch + path: briefcase + fetch-depth: 1 + + - name: Install Briefcase (editable) + run: | + pip install -e briefcase - name: Run unit tests run: | pytest -vv --cov=constructor --cov-branch tests/ -m "not examples" diff --git a/constructor/briefcase.py b/constructor/briefcase.py index cb0fa0b16..179c5b01e 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -86,6 +86,13 @@ def get_bundle_app_name(info, name): return bundle, app_name +def get_license(info): + """ Retrieve the specified license as a dict or return a placeholder if not set. """ + + if "license_file" in info: + return {"file": info["license_file"]} + # We cannot return an empty string because that results in an exception on the briefcase side. + return {"text": "TODO"} # Create a Briefcase configuration file. Using a full TOML writer rather than a Jinja # template allows us to avoid escaping strings everywhere. @@ -97,7 +104,7 @@ def write_pyproject_toml(tmp_dir, info): "project_name": name, "bundle": bundle, "version": version, - "license": ({"file": info["license_file"]} if "license_file" in info else {"text": ""}), + "license": get_license(), "app": { app_name: { "formal_name": f"{info['name']} {info['version']}", @@ -135,7 +142,7 @@ def create(info, verbose=False): briefcase = Path(sysconfig.get_path("scripts")) / "briefcase.exe" if not briefcase.exists(): raise FileNotFoundError( - f"Dependency 'briefcase' does not seem installed." + f"Dependency 'briefcase' does not seem to be installed.\n" f"Tried: {briefcase}" ) logger.info("Building installer") diff --git a/tests/test_examples.py b/tests/test_examples.py index cf21ec04b..b1bbf4fed 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -44,6 +44,7 @@ REPO_DIR = Path(__file__).parent.parent ON_CI = bool(os.environ.get("CI")) and os.environ.get("CI") != "0" CONSTRUCTOR_CONDA_EXE = os.environ.get("CONSTRUCTOR_CONDA_EXE") +CONSTRUCTOR_VERBOSE = os.getenv.get("CONSTRUCTOR_VERBOSE") CONDA_EXE, CONDA_EXE_VERSION = identify_conda_exe(CONSTRUCTOR_CONDA_EXE) if CONDA_EXE_VERSION is not None: CONDA_EXE_VERSION = Version(CONDA_EXE_VERSION) @@ -369,7 +370,7 @@ def create_installer( ] # This flag will (if enabled) create a lot of output upon test failures for .exe-installers. # If debugging generated NSIS templates, it can be worth to enable. - if os.getenv("CONSTRUCTOR_VERBOSE"): + if CONSTRUCTOR_VERBOSE: cmd.insert(2, "-v") if conda_exe: cmd.extend(["--conda-exe", conda_exe]) @@ -489,7 +490,14 @@ def test_example_extra_envs(tmp_path, request): def test_example_extra_files(tmp_path, request): input_path = _example_path("extra_files") for installer, install_dir in create_installer(input_path, tmp_path, with_spaces=True): - _run_installer(input_path, installer, install_dir, request=request) + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_sentinels=CONSTRUCTOR_VERBOSE, + check_subprocess=CONSTRUCTOR_VERBOSE + ) def test_example_mirrored_channels(tmp_path, request): From a32f560358778eb7c5d41aa88aa14ca6ed179b9c Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 12 Nov 2025 15:37:04 -0500 Subject: [PATCH 05/24] Fixed typo in os-function --- tests/test_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index b1bbf4fed..4e0f84b00 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -44,7 +44,7 @@ REPO_DIR = Path(__file__).parent.parent ON_CI = bool(os.environ.get("CI")) and os.environ.get("CI") != "0" CONSTRUCTOR_CONDA_EXE = os.environ.get("CONSTRUCTOR_CONDA_EXE") -CONSTRUCTOR_VERBOSE = os.getenv.get("CONSTRUCTOR_VERBOSE") +CONSTRUCTOR_VERBOSE = os.environ.get("CONSTRUCTOR_VERBOSE") CONDA_EXE, CONDA_EXE_VERSION = identify_conda_exe(CONSTRUCTOR_CONDA_EXE) if CONDA_EXE_VERSION is not None: CONDA_EXE_VERSION = Version(CONDA_EXE_VERSION) From 6a23b557a9d621d93143d739b9ea4d13b7c6ffb0 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 12 Nov 2025 15:38:35 -0500 Subject: [PATCH 06/24] Add missing argument --- constructor/briefcase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 179c5b01e..af5690686 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -104,7 +104,7 @@ def write_pyproject_toml(tmp_dir, info): "project_name": name, "bundle": bundle, "version": version, - "license": get_license(), + "license": get_license(info), "app": { app_name: { "formal_name": f"{info['name']} {info['version']}", From 3d11d7fa423702239fd63c0c4610e37308194a7f Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 13 Nov 2025 13:26:10 -0500 Subject: [PATCH 07/24] Change version field to 1.0.0 instead of X --- examples/azure_signtool/construct.yaml | 2 +- examples/custom_nsis_template/construct.yaml | 2 +- examples/customize_controls/construct.yaml | 2 +- examples/customized_welcome_conclusion/construct.yaml | 2 +- examples/exe_extra_pages/construct.yaml | 2 +- examples/extra_envs/construct.yaml | 2 +- examples/from_env_txt/construct.yaml | 2 +- examples/from_env_yaml/construct.yaml | 2 +- examples/from_existing_env/construct.yaml | 2 +- examples/from_explicit/construct.yaml | 2 +- examples/miniconda/construct.yaml | 2 +- examples/mirrored_channels/construct.yaml | 2 +- examples/noconda/constructor_input.yaml | 2 +- examples/outputs/construct.yaml | 2 +- examples/protected_base/construct.yaml | 2 +- examples/register_envs/construct.yaml | 2 +- examples/scripts/construct.yaml | 2 +- examples/shortcuts/construct.yaml | 2 +- examples/signing/construct.yaml | 2 +- tests/test_main.py | 2 +- 20 files changed, 20 insertions(+), 20 deletions(-) diff --git a/examples/azure_signtool/construct.yaml b/examples/azure_signtool/construct.yaml index f40c2efa3..e0c4883f9 100644 --- a/examples/azure_signtool/construct.yaml +++ b/examples/azure_signtool/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Signed_AzureSignTool -version: X +version: 1.0.0 installer_type: exe channels: - http://repo.anaconda.com/pkgs/main/ diff --git a/examples/custom_nsis_template/construct.yaml b/examples/custom_nsis_template/construct.yaml index 4b8eab0b4..4a59f423a 100644 --- a/examples/custom_nsis_template/construct.yaml +++ b/examples/custom_nsis_template/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: custom -version: X +version: 1.0.0 ignore_duplicate_files: True installer_filename: {{ name }}-installer.exe installer_type: exe diff --git a/examples/customize_controls/construct.yaml b/examples/customize_controls/construct.yaml index 074c6e8de..3203a8caf 100644 --- a/examples/customize_controls/construct.yaml +++ b/examples/customize_controls/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: NoCondaOptions -version: X +version: 1.0.0 installer_type: all channels: diff --git a/examples/customized_welcome_conclusion/construct.yaml b/examples/customized_welcome_conclusion/construct.yaml index 79f55f943..1cb22b701 100644 --- a/examples/customized_welcome_conclusion/construct.yaml +++ b/examples/customized_welcome_conclusion/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: CustomizedWelcomeConclusion -version: X +version: 1.0.0 installer_type: all channels: - http://repo.anaconda.com/pkgs/main/ diff --git a/examples/exe_extra_pages/construct.yaml b/examples/exe_extra_pages/construct.yaml index 862cb1d9b..78569621c 100644 --- a/examples/exe_extra_pages/construct.yaml +++ b/examples/exe_extra_pages/construct.yaml @@ -7,7 +7,7 @@ {% set name = "extraPageSingle" %} {% endif %} name: {{ name }} -version: X +version: 1.0.0 installer_type: all channels: - http://repo.anaconda.com/pkgs/main/ diff --git a/examples/extra_envs/construct.yaml b/examples/extra_envs/construct.yaml index aedaf28ff..aad79aac0 100644 --- a/examples/extra_envs/construct.yaml +++ b/examples/extra_envs/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: ExtraEnvs -version: X +version: 1.0.0 installer_type: all channels: - https://conda.anaconda.org/conda-forge diff --git a/examples/from_env_txt/construct.yaml b/examples/from_env_txt/construct.yaml index ee8412dc7..5cb7ae774 100644 --- a/examples/from_env_txt/construct.yaml +++ b/examples/from_env_txt/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: EnvironmentTXT -version: X +version: 1.0.0 installer_type: all environment_file: env.txt initialize_by_default: false diff --git a/examples/from_env_yaml/construct.yaml b/examples/from_env_yaml/construct.yaml index d86bdeafb..caa5922b8 100644 --- a/examples/from_env_yaml/construct.yaml +++ b/examples/from_env_yaml/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: EnvironmentYAML -version: X +version: 1.0.0 installer_type: all environment_file: env.yaml initialize_by_default: false diff --git a/examples/from_existing_env/construct.yaml b/examples/from_existing_env/construct.yaml index e60d00945..42a95ed05 100644 --- a/examples/from_existing_env/construct.yaml +++ b/examples/from_existing_env/construct.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=../../constructor/data/construct.schema.json "$schema": "../../constructor/data/construct.schema.json" name: Existing -version: X +version: 1.0.0 installer_type: all environment: {{ os.environ.get("CONSTRUCTOR_TEST_EXISTING_ENV", os.environ["CONDA_PREFIX"]) }} channels: diff --git a/examples/from_explicit/construct.yaml b/examples/from_explicit/construct.yaml index 9137fa8f7..6e07790cd 100644 --- a/examples/from_explicit/construct.yaml +++ b/examples/from_explicit/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Explicit -version: X +version: 1.0.0 installer_type: all environment_file: explicit_linux-64.txt initialize_by_default: false diff --git a/examples/miniconda/construct.yaml b/examples/miniconda/construct.yaml index 7c5dad48b..a8b8308ba 100644 --- a/examples/miniconda/construct.yaml +++ b/examples/miniconda/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: MinicondaX -version: X +version: 1.0.0 installer_type: all channels: diff --git a/examples/mirrored_channels/construct.yaml b/examples/mirrored_channels/construct.yaml index f105c6d0c..6e7ab9d81 100644 --- a/examples/mirrored_channels/construct.yaml +++ b/examples/mirrored_channels/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Mirrors -version: X +version: 1.0.0 channels: - conda-forge diff --git a/examples/noconda/constructor_input.yaml b/examples/noconda/constructor_input.yaml index 5e3fa6fd3..d2d96575a 100644 --- a/examples/noconda/constructor_input.yaml +++ b/examples/noconda/constructor_input.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: NoConda -version: X +version: 1.0.0 installer_type: all channels: - http://repo.anaconda.com/pkgs/main/ diff --git a/examples/outputs/construct.yaml b/examples/outputs/construct.yaml index 01aa24a0a..9080dc36d 100644 --- a/examples/outputs/construct.yaml +++ b/examples/outputs/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Outputs -version: X +version: 1.0.0 installer_type: sh # [unix] installer_type: exe # [win] channels: diff --git a/examples/protected_base/construct.yaml b/examples/protected_base/construct.yaml index c43044761..56581b6c6 100644 --- a/examples/protected_base/construct.yaml +++ b/examples/protected_base/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: ProtectedBaseEnv -version: X +version: 1.0.0 installer_type: all channels: diff --git a/examples/register_envs/construct.yaml b/examples/register_envs/construct.yaml index b55eae9ea..1a75593b2 100644 --- a/examples/register_envs/construct.yaml +++ b/examples/register_envs/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: RegisterEnvs -version: X +version: 1.0.0 installer_type: all channels: - http://repo.anaconda.com/pkgs/main/ diff --git a/examples/scripts/construct.yaml b/examples/scripts/construct.yaml index 935b1f40b..48e37248d 100644 --- a/examples/scripts/construct.yaml +++ b/examples/scripts/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Scripts -version: X +version: 1.0.0 installer_type: all channels: - http://repo.anaconda.com/pkgs/main/ diff --git a/examples/shortcuts/construct.yaml b/examples/shortcuts/construct.yaml index b237e83c2..c2d50c94d 100644 --- a/examples/shortcuts/construct.yaml +++ b/examples/shortcuts/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: MinicondaWithShortcuts -version: X +version: 1.0.0 installer_type: all channels: diff --git a/examples/signing/construct.yaml b/examples/signing/construct.yaml index 06ce44d00..87d102e2d 100644 --- a/examples/signing/construct.yaml +++ b/examples/signing/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Signed -version: X +version: 1.0.0 installer_type: all channels: - http://repo.anaconda.com/pkgs/main/ diff --git a/tests/test_main.py b/tests/test_main.py index 71aa79fdb..003d9132d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -7,7 +7,7 @@ def test_dry_run(tmp_path): inputfile = dedent( """ name: test_schema_validation - version: X + version: 1.0.0 installer_type: all channels: - http://repo.anaconda.com/pkgs/main/ From fc95186f52c2c3e7774edccc76afcac34aef8a60 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 13 Nov 2025 14:45:36 -0500 Subject: [PATCH 08/24] Add initial test changes --- tests/test_examples.py | 135 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 119 insertions(+), 16 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 4e0f84b00..0690cbfcd 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -14,6 +14,7 @@ from pathlib import Path from plistlib import load as plist_load from typing import TYPE_CHECKING +import ctypes import pytest from conda.base.context import context @@ -167,9 +168,6 @@ def _run_installer_exe( _check_installer_log(install_dir) return process -def _run_uninstaller_windows(): - print("TODO") - def _run_uninstaller_exe( install_dir: Path, timeout: int = 420, @@ -288,6 +286,73 @@ def _sentinel_file_checks(example_path, install_dir): f"{install_dir} contents:\n" + "\n".join(sorted(map(str, install_dir.iterdir()))) ) +def is_admin() -> bool: + try: + return ctypes.windll.shell32.IsUserAnAdmin() + except Exception: + return False + +def calculate_msi_install_path(installer: Path) -> Path: + """ This is a temporary solution for now since we cannot choose the install location ourselves. + Installers are named --Windows-x86_64.msi. + """ + dir_name = installer.name.replace('-Windows-x86_64.msi', '').replace('-', ' ') + if is_admin(): + root_dir = Path(os.environ.get('PROGRAMFILES') or r"C:\Program Files") + else: + local_dir = os.environ.get('LOCALAPPDATA') or str(Path.home() / r"AppData\Local") + root_dir = Path(local_dir) / 'Programs' + return Path(root_dir) / dir_name + + +def _run_installer_msi( + installer: Path, + install_dir: Path, + installer_input=None, + timeout=420, + check=True, + options: list | None = None, +): + """ + TODO + """ + if not sys.platform.startswith("win"): + raise ValueError("Can only run .msi installers on Windows") + options = options or [] + cmd = [ + "msiexec.exe", + "/i", + str(installer), + "ALLUSERS=1" if is_admin() else "MSIINSTALLPERUSER=1", + "/qn", + ] + + log_path = Path(os.environ.get('TEMP')) / (install_dir.name + ".log") + cmd.extend(["/L*V", str(log_path)]) + process = _execute(cmd, installer_input=installer_input, timeout=timeout, check=check) + if check: + print("post install checks for MSI Installers not yet implemented") + return process + +def _run_uninstaller_msi(installer: Path, + install_dir: Path, + timeout: int = 420, + check: bool = True, +) -> subprocess.CompletedProcess | None: + + cmd = [ + "msiexec.exe", + "/x", + str(installer), + "/qn", + ] + process = _execute(cmd, timeout=timeout, check=check) + if check: + # TODO: + # Check log and if there are remaining files, similar to the exe installers + pass + + return process def _run_installer( example_path: Path, @@ -334,12 +399,30 @@ def _run_installer( timeout=timeout, check=check_subprocess, ) + elif installer.suffix == ".msi": + process = _run_installer_msi( + installer, + install_dir, + installer_input=installer_input, + timeout=timeout, + check=check_subprocess, + options=options, + ) else: raise ValueError(f"Unknown installer type: {installer.suffix}") - if check_sentinels and not (installer.suffix == ".pkg" and ON_CI): + + if installer.suffix == ".msi": + print("sentinel_file_checks for MSI installers not yet implemented") + elif check_sentinels and not (installer.suffix == ".pkg" and ON_CI): _sentinel_file_checks(example_path, install_dir) - if uninstall and installer.suffix == ".exe": - _run_uninstaller_exe(install_dir, timeout=timeout, check=check_subprocess) + if uninstall: + if installer.suffix == ".msi": + if request: # and ON_CI + # We always need to do this currently since uninstall doesnt work fully + request.addfinalizer(lambda: shutil.rmtree(str(install_dir), ignore_errors=True)) + _run_uninstaller_msi(installer, install_dir, timeout=timeout, check=check_subprocess) + elif installer.suffix == ".exe": + _run_uninstaller_exe(install_dir, timeout=timeout, check=check_subprocess) return process @@ -361,17 +444,19 @@ def create_installer( output_dir.mkdir(parents=True, exist_ok=True) cmd = [ *COV_CMD, - "constructor", + "constructor"] + # This flag will (if enabled) create a lot of output upon test failures for .exe-installers. + # If debugging generated NSIS templates, it can be worth to enable. + if CONSTRUCTOR_VERBOSE: + cmd.append("-v") + cmd += [ str(input_dir), "--output-dir", str(output_dir), "--config-filename", config_filename, ] - # This flag will (if enabled) create a lot of output upon test failures for .exe-installers. - # If debugging generated NSIS templates, it can be worth to enable. - if CONSTRUCTOR_VERBOSE: - cmd.insert(2, "-v") + if conda_exe: cmd.extend(["--conda-exe", conda_exe]) if debug: @@ -385,18 +470,21 @@ def create_installer( def _sort_by_extension(path): "Return shell installers first so they are run before the GUI ones" - return {"sh": 1, "pkg": 2, "exe": 3}[path.suffix[1:]], path + return {"sh": 1, "pkg": 2, "exe": 3, "msi": 4}[path.suffix[1:]], path - installers = (p for p in output_dir.iterdir() if p.suffix in (".exe", ".sh", ".pkg")) + installers = (p for p in output_dir.iterdir() if p.suffix in (".exe", ".msi", ".sh", ".pkg")) for installer in sorted(installers, key=_sort_by_extension): if installer.suffix == ".pkg" and ON_CI: install_dir = Path("~").expanduser() / calculate_install_dir( input_dir / config_filename ) + elif installer.suffix == '.msi': + install_dir = calculate_msi_install_path(installer) else: install_dir = ( workspace / f"{install_dir_prefix}-{installer.stem}-{installer.suffix[1:]}" ) + yield installer, install_dir if KEEP_ARTIFACTS_PATH: try: @@ -484,7 +572,10 @@ def test_example_extra_envs(tmp_path, request): assert "@EXPLICIT" in envtxt.read_text() if sys.platform.startswith("win"): - _run_uninstaller_exe(install_dir=install_dir) + if installer.suffix == ".msi": + _run_uninstaller_msi(installer, install_dir, timeout=timeout, check=check_subprocess) + else: + _run_uninstaller_exe(install_dir=install_dir) def test_example_extra_files(tmp_path, request): @@ -571,6 +662,10 @@ def test_example_miniforge(tmp_path, request, example): raise AssertionError("Could not find Start Menu folder for miniforge") _run_uninstaller_exe(install_dir) assert not list(start_menu_dir.glob("Miniforge*.lnk")) + elif installer.suffix == '.msi': + # TODO: Start menus + _run_uninstaller_msi(installer, install_dir) + raise Exception("Test needs to be implemented") def test_example_noconda(tmp_path, request): @@ -733,7 +828,10 @@ def test_example_shortcuts(tmp_path, request): break else: raise AssertionError("No shortcuts found!") - _run_uninstaller_exe(install_dir) + if installer.suffix == '.msi': + _run_uninstaller_msi(installer, install_dir) + else: + _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": @@ -1029,6 +1127,8 @@ def test_initialization(tmp_path, request, monkeypatch, method): # GHA runs on an admin user account, but AllUsers (admin) installs # do not add to PATH due to CVE-2022-26526, so force single user install options = ["/AddToPath=1", "/InstallationType=JustMe"] + elif installer.suffix == ".msi": + raise Exception("Test needs to be implemented") else: options = [] _run_installer( @@ -1064,6 +1164,8 @@ def test_initialization(tmp_path, request, monkeypatch, method): finally: _run_uninstaller_exe(install_dir, check=True) + elif installer.suffix == ".msi": + raise Exception("Test needs to be implemented") else: # GHA's Ubuntu needs interactive, but macOS wants login :shrug: login_flag = "-i" if sys.platform.startswith("linux") else "-l" @@ -1317,7 +1419,8 @@ def test_uninstallation_standalone( check_subprocess=True, uninstall=False, ) - + if installer.suffix == ".msi": + raise Exception("Test needs to be implemented") # Set up files for removal. # Since conda-standalone is extensively tested upstream, # only set up a minimum set of files. From 2aeddae4276833348df70b01baf1f4836e5e313f Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 13 Nov 2025 15:41:17 -0500 Subject: [PATCH 09/24] More fixes --- tests/test_examples.py | 66 +++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 0690cbfcd..8e94caea3 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ctypes import getpass import json import os @@ -14,7 +15,6 @@ from pathlib import Path from plistlib import load as plist_load from typing import TYPE_CHECKING -import ctypes import pytest from conda.base.context import context @@ -168,6 +168,7 @@ def _run_installer_exe( _check_installer_log(install_dir) return process + def _run_uninstaller_exe( install_dir: Path, timeout: int = 420, @@ -286,22 +287,24 @@ def _sentinel_file_checks(example_path, install_dir): f"{install_dir} contents:\n" + "\n".join(sorted(map(str, install_dir.iterdir()))) ) + def is_admin() -> bool: try: return ctypes.windll.shell32.IsUserAnAdmin() except Exception: return False + def calculate_msi_install_path(installer: Path) -> Path: - """ This is a temporary solution for now since we cannot choose the install location ourselves. - Installers are named --Windows-x86_64.msi. + """This is a temporary solution for now since we cannot choose the install location ourselves. + Installers are named --Windows-x86_64.msi. """ - dir_name = installer.name.replace('-Windows-x86_64.msi', '').replace('-', ' ') + dir_name = installer.name.replace("-Windows-x86_64.msi", "").replace("-", " ") if is_admin(): - root_dir = Path(os.environ.get('PROGRAMFILES') or r"C:\Program Files") + root_dir = Path(os.environ.get("PROGRAMFILES") or r"C:\Program Files") else: - local_dir = os.environ.get('LOCALAPPDATA') or str(Path.home() / r"AppData\Local") - root_dir = Path(local_dir) / 'Programs' + local_dir = os.environ.get("LOCALAPPDATA") or str(Path.home() / r"AppData\Local") + root_dir = Path(local_dir) / "Programs" return Path(root_dir) / dir_name @@ -313,9 +316,7 @@ def _run_installer_msi( check=True, options: list | None = None, ): - """ - TODO - """ + """Runs specified MSI Installer via command line in silent mode. This is work in progress.""" if not sys.platform.startswith("win"): raise ValueError("Can only run .msi installers on Windows") options = options or [] @@ -327,19 +328,20 @@ def _run_installer_msi( "/qn", ] - log_path = Path(os.environ.get('TEMP')) / (install_dir.name + ".log") + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + ".log") cmd.extend(["/L*V", str(log_path)]) process = _execute(cmd, installer_input=installer_input, timeout=timeout, check=check) if check: - print("post install checks for MSI Installers not yet implemented") + print("A check for MSI Installers not yet implemented") return process -def _run_uninstaller_msi(installer: Path, + +def _run_uninstaller_msi( + installer: Path, install_dir: Path, timeout: int = 420, check: bool = True, ) -> subprocess.CompletedProcess | None: - cmd = [ "msiexec.exe", "/x", @@ -354,6 +356,7 @@ def _run_uninstaller_msi(installer: Path, return process + def _run_installer( example_path: Path, installer: Path, @@ -417,7 +420,7 @@ def _run_installer( _sentinel_file_checks(example_path, install_dir) if uninstall: if installer.suffix == ".msi": - if request: # and ON_CI + if request: # and ON_CI # We always need to do this currently since uninstall doesnt work fully request.addfinalizer(lambda: shutil.rmtree(str(install_dir), ignore_errors=True)) _run_uninstaller_msi(installer, install_dir, timeout=timeout, check=check_subprocess) @@ -442,9 +445,7 @@ def create_installer( output_dir = workspace / "installer" output_dir.mkdir(parents=True, exist_ok=True) - cmd = [ - *COV_CMD, - "constructor"] + cmd = [*COV_CMD, "constructor"] # This flag will (if enabled) create a lot of output upon test failures for .exe-installers. # If debugging generated NSIS templates, it can be worth to enable. if CONSTRUCTOR_VERBOSE: @@ -478,7 +479,7 @@ def _sort_by_extension(path): install_dir = Path("~").expanduser() / calculate_install_dir( input_dir / config_filename ) - elif installer.suffix == '.msi': + elif installer.suffix == ".msi": install_dir = calculate_msi_install_path(installer) else: install_dir = ( @@ -573,7 +574,11 @@ def test_example_extra_envs(tmp_path, request): if sys.platform.startswith("win"): if installer.suffix == ".msi": - _run_uninstaller_msi(installer, install_dir, timeout=timeout, check=check_subprocess) + if request: + request.addfinalizer( + lambda: shutil.rmtree(str(install_dir), ignore_errors=True) + ) + _run_uninstaller_msi(installer, install_dir) else: _run_uninstaller_exe(install_dir=install_dir) @@ -587,7 +592,7 @@ def test_example_extra_files(tmp_path, request): install_dir, request=request, check_sentinels=CONSTRUCTOR_VERBOSE, - check_subprocess=CONSTRUCTOR_VERBOSE + check_subprocess=CONSTRUCTOR_VERBOSE, ) @@ -662,8 +667,12 @@ def test_example_miniforge(tmp_path, request, example): raise AssertionError("Could not find Start Menu folder for miniforge") _run_uninstaller_exe(install_dir) assert not list(start_menu_dir.glob("Miniforge*.lnk")) - elif installer.suffix == '.msi': + elif installer.suffix == ".msi": # TODO: Start menus + if request: + request.addfinalizer( + lambda: shutil.rmtree(str(install_dir), ignore_errors=True) + ) _run_uninstaller_msi(installer, install_dir) raise Exception("Test needs to be implemented") @@ -828,7 +837,11 @@ def test_example_shortcuts(tmp_path, request): break else: raise AssertionError("No shortcuts found!") - if installer.suffix == '.msi': + if installer.suffix == ".msi": + if request: + request.addfinalizer( + lambda: shutil.rmtree(str(install_dir), ignore_errors=True) + ) _run_uninstaller_msi(installer, install_dir) else: _run_uninstaller_exe(install_dir) @@ -973,8 +986,11 @@ def test_example_from_explicit(tmp_path, request): def test_register_envs(tmp_path, request): + """Verify that 'register_envs: False' results in the environment not being registered.""" input_path = _example_path("register_envs") for installer, install_dir in create_installer(input_path, tmp_path): + if installer.suffix == ".msi": + raise Exception("Test for 'register_envs' not yet implemented for MSI") _run_installer(input_path, installer, install_dir, request=request) environments_txt = Path("~/.conda/environments.txt").expanduser().read_text() assert str(install_dir) not in environments_txt @@ -1035,6 +1051,7 @@ def test_cross_osx_building(tmp_path): ) +@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix only") def test_cross_build_example(tmp_path, platform_conda_exe): platform, conda_exe = platform_conda_exe input_path = _example_path("virtual_specs_ok") @@ -1050,6 +1067,7 @@ def test_cross_build_example(tmp_path, platform_conda_exe): def test_virtual_specs_failed(tmp_path, request): + """Verify that virtual packages listed via 'virtual_specs' are satisfied.""" input_path = _example_path("virtual_specs_failed") for installer, install_dir in create_installer(input_path, tmp_path): process = _run_installer( @@ -1065,6 +1083,8 @@ def test_virtual_specs_failed(tmp_path, request): with pytest.raises(AssertionError, match="Failed to check virtual specs"): _check_installer_log(install_dir) continue + elif installer.suffix == ".msi": + raise Exception("Test for 'virtual_specs' not yet implemented for MSI") elif installer.suffix == ".pkg": if not ON_CI: continue From 946f89adef530a98131de09f8104638c64deb0ad Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 14 Nov 2025 13:26:27 -0500 Subject: [PATCH 10/24] Add dependency and env variable for github workflow --- .github/workflows/main.yml | 1 + pyproject.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5287a9359..2bb7c8abf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -165,6 +165,7 @@ jobs: AZURE_SIGNTOOL_KEY_VAULT_URL: ${{ secrets.AZURE_SIGNTOOL_KEY_VAULT_URL }} CONSTRUCTOR_EXAMPLES_KEEP_ARTIFACTS: "${{ runner.temp }}/examples_artifacts" CONSTRUCTOR_SIGNTOOL_PATH: "C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x86/signtool.exe" + CONSTRUCTOR_VERBOSE: 1 run: | rm -rf coverage.json pytest -vv --cov=constructor --cov-branch tests/test_examples.py diff --git a/pyproject.toml b/pyproject.toml index 97eb1235a..b3c48c758 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "jinja2", "jsonschema >=4", "tomli-w >=1.2.0", + "breifcase" ] [project.optional-dependencies] From 9be1852ffbb06eb906cfe448fe1f188c635baf2d Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 14 Nov 2025 14:16:52 -0500 Subject: [PATCH 11/24] Some more fixes --- constructor/briefcase.py | 7 +++++-- constructor/briefcase/run_installation.bat | 7 ++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index af5690686..32b5679aa 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -4,6 +4,7 @@ import logging import re +import sys import shutil import sysconfig import tempfile @@ -22,8 +23,7 @@ def get_name_version(info): - name = info["name"] - if not name: + if not (name := info.get("name")): raise ValueError("Name is empty") # Briefcase requires version numbers to be in the canonical Python format, and some @@ -124,6 +124,9 @@ def write_pyproject_toml(tmp_dir, info): def create(info, verbose=False): + if sys.platform != 'win32': + raise Exception(f"Invalid platform '{sys.platform}'. Only Windows is supported.") + tmp_dir = Path(tempfile.mkdtemp()) write_pyproject_toml(tmp_dir, info) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 7f9e6c486..71c7b50f9 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,9 +1,10 @@ -_conda constructor --prefix . --extract-conda-pkgs - set CONDA_PROTECT_FROZEN_ENVS=0 set CONDA_ROOT_PREFIX=%cd% set CONDA_SAFETY_CHECKS=disabled set CONDA_EXTRA_SAFETY_CHECKS=no set CONDA_PKGS_DIRS=%cd%\pkgs -_conda install --offline --file conda-meta\initial-state.explicit.txt -yp . +%CONDA_ROOT_PREFIX%\_conda constructor --prefix . --extract-conda-pkgs + + +%CONDA_ROOT_PREFIX%\_conda install --offline --file conda-meta\initial-state.explicit.txt -yp . From 6b940258ab5fcb0c3173ee843fb26440976cf04c Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 14 Nov 2025 14:32:42 -0500 Subject: [PATCH 12/24] Fix spelling error --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b3c48c758..b3159dd45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "jinja2", "jsonschema >=4", "tomli-w >=1.2.0", - "breifcase" + "briefcase" ] [project.optional-dependencies] From 5d3c3cdf2eb15ca63bc4b20557c045bdcebb6e1e Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 14 Nov 2025 15:00:13 -0500 Subject: [PATCH 13/24] Undo earlier change sine it wasn't the correct way --- constructor/briefcase/run_installation.bat | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 71c7b50f9..7f9e6c486 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,10 +1,9 @@ +_conda constructor --prefix . --extract-conda-pkgs + set CONDA_PROTECT_FROZEN_ENVS=0 set CONDA_ROOT_PREFIX=%cd% set CONDA_SAFETY_CHECKS=disabled set CONDA_EXTRA_SAFETY_CHECKS=no set CONDA_PKGS_DIRS=%cd%\pkgs -%CONDA_ROOT_PREFIX%\_conda constructor --prefix . --extract-conda-pkgs - - -%CONDA_ROOT_PREFIX%\_conda install --offline --file conda-meta\initial-state.explicit.txt -yp . +_conda install --offline --file conda-meta\initial-state.explicit.txt -yp . From 50a47b91047f03b27a938f8336f02e6b034fc5be Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 19 Nov 2025 16:10:51 -0500 Subject: [PATCH 14/24] Initial work for pre_uninstall --- constructor/briefcase.py | 112 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 105 insertions(+), 7 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 32b5679aa..baee3845a 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -2,12 +2,15 @@ Logic to build installers using Briefcase. """ +from __future__ import annotations + import logging import re -import sys import shutil +import sys import sysconfig import tempfile +from functools import cached_property from pathlib import Path from subprocess import run @@ -86,17 +89,108 @@ def get_bundle_app_name(info, name): return bundle, app_name + def get_license(info): - """ Retrieve the specified license as a dict or return a placeholder if not set. """ + """Retrieve the specified license as a dict or return a placeholder if not set.""" if "license_file" in info: return {"file": info["license_file"]} # We cannot return an empty string because that results in an exception on the briefcase side. return {"text": "TODO"} + +class UninstallBat: + """Represents a pre-uninstall batch script handler for the MSI installers. + This is intended to handle both the user specified 'pre_uninstall' bat script + and also the 'pre_uninstall_script' passed to briefcase by merging them into one. + """ + + def __init__(self, dst: Path, user_script: str | None): + """ + Parameters + ---------- + dst : Path + Destination directory where the generated `pre_uninstall.bat` file + will be written. + user_script : str | None + Optional path (string) to a user-provided `.bat` file configured + via the `pre_uninstall` setting in the installer configuration. + If provided, the file must adhere to the schema. + """ + self._dst = dst + + self.user_script = None + if user_script: + user_script_path = Path(user_script) + if not self.is_bat_file(user_script_path): + raise ValueError( + f"The entry '{user_script}' configured via 'pre_uninstall' " + "must be a path to an existing .bat file." + ) + self.user_script = user_script_path + self._encoding = "utf-8" # TODO: Do we want to use utf-8-sig? + + def is_bat_file(self, file_path: Path) -> bool: + return file_path.is_file() and file_path.suffix.lower() == ".bat" + + def user_script_as_list(self) -> list[str]: + """Read user script.""" + if not self.user_script: + return [] + with open(self.user_script, encoding=self._encoding, newline=None) as f: + return f.read().splitlines() + + def sanitize_input(self, input_list: list[str]) -> list[str]: + """Sanitizes the input, adds a safe exit if necessary. + Assumes the contents of the input represents the contents of a .bat-file. + """ + return ["exit /b" if line.strip().lower() == "exit" else line for line in input_list] + + def create(self) -> None: + """Create the pre uninstall script. This merges includes the 'pre_uninstall' that may + may have been specified at installer creation. + """ + header = [ + "@echo off", + "setlocal enableextensions enabledelayedexpansion", + 'set "_SELF=%~f0"', + 'set "_HERE=%~dp0"', + "", + "rem === Pre-uninstall script ===", + ] + + # TODO: Create unique labels using uuid to avoid collisions + + user_bat: list[str] = [] + + if self.user_script: + # user_script: list = self.sanitize(self.user_script_as_list()) + # TODO: Embed user script and run it as a subroutine. + # Add error handling using unique labels with 'goto' + user_bat += [ + "rem User supplied with a script", + ] + + # The main part of the bat-script here + tail = [ + 'echo "hello from the script"', + "pause", + ] + final_lines = header + [""] + user_bat + [""] + tail + + with open(self.file_path, "w", encoding=self._encoding, newline="\r\n") as f: + # Python will write \n as \r\n since we have set the 'newline' argument above. + f.writelines(line + "\n" for line in final_lines) + + @cached_property + def file_path(self) -> Path: + """The absolute path to the generated `pre_uninstall.bat` file.""" + return self._dst / "pre_uninstall.bat" + + # Create a Briefcase configuration file. Using a full TOML writer rather than a Jinja # template allows us to avoid escaping strings everywhere. -def write_pyproject_toml(tmp_dir, info): +def write_pyproject_toml(tmp_dir, info, uninstall_bat): name, version = get_name_version(info) bundle, app_name = get_bundle_app_name(info, name) @@ -113,6 +207,7 @@ def write_pyproject_toml(tmp_dir, info): "use_full_install_path": False, "install_launcher": False, "post_install_script": str(BRIEFCASE_DIR / "run_installation.bat"), + "pre_uninstall_script": uninstall_bat.file_path, } }, } @@ -124,11 +219,15 @@ def write_pyproject_toml(tmp_dir, info): def create(info, verbose=False): - if sys.platform != 'win32': + if sys.platform != "win32": raise Exception(f"Invalid platform '{sys.platform}'. Only Windows is supported.") tmp_dir = Path(tempfile.mkdtemp()) - write_pyproject_toml(tmp_dir, info) + + uninstall_bat = UninstallBat(info.get("pre_uninstall", None), tmp_dir) + uninstall_bat.create() + + write_pyproject_toml(tmp_dir, info, uninstall_bat) external_dir = tmp_dir / EXTERNAL_PACKAGE_PATH external_dir.mkdir() @@ -145,8 +244,7 @@ def create(info, verbose=False): briefcase = Path(sysconfig.get_path("scripts")) / "briefcase.exe" if not briefcase.exists(): raise FileNotFoundError( - f"Dependency 'briefcase' does not seem to be installed.\n" - f"Tried: {briefcase}" + f"Dependency 'briefcase' does not seem to be installed.\nTried: {briefcase}" ) logger.info("Building installer") run( From 9663143081eeedece066244459b1eff9a0c6b7d5 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 20 Nov 2025 10:02:58 -0500 Subject: [PATCH 15/24] Add tests for initial setup --- constructor/briefcase.py | 12 ++++-- tests/test_briefcase.py | 84 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index baee3845a..8bb05b760 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -147,9 +147,13 @@ def sanitize_input(self, input_list: list[str]) -> list[str]: return ["exit /b" if line.strip().lower() == "exit" else line for line in input_list] def create(self) -> None: - """Create the pre uninstall script. This merges includes the 'pre_uninstall' that may - may have been specified at installer creation. + """Create the bat script for uninstallation. The script will also include the contents from the file the user + may have specified in the yaml-file via 'pre_uninstall'. + When this function is called, the directory 'dst' specified at class instantiation must exist. """ + if not self._dst.exists(): + raise FileNotFoundError(f"The directory {self._dst} must exist in order to create the file.") + header = [ "@echo off", "setlocal enableextensions enabledelayedexpansion", @@ -172,11 +176,11 @@ def create(self) -> None: ] # The main part of the bat-script here - tail = [ + main_bat = [ 'echo "hello from the script"', "pause", ] - final_lines = header + [""] + user_bat + [""] + tail + final_lines = header + [""] + user_bat + [""] + main_bat with open(self.file_path, "w", encoding=self._encoding, newline="\r\n") as f: # Python will write \n as \r\n since we have set the 'newline' argument above. diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 36cb1ce82..2170366b8 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -1,6 +1,9 @@ import pytest +import re +from pathlib import Path +from constructor.briefcase import get_bundle_app_name, get_name_version, UninstallBat -from constructor.briefcase import get_bundle_app_name, get_name_version +THIS_DIR = Path(__file__).parent @pytest.mark.parametrize( @@ -99,3 +102,82 @@ def test_rdi_invalid_package(rdi): def test_name_no_alphanumeric(name): with pytest.raises(ValueError, match=f"Name '{name}' contains no alphanumeric characters"): get_bundle_app_name({}, name) + +@pytest.mark.parametrize( + "test_path", + [ + Path("foo"), # relative path + THIS_DIR, # absolute path of current test file + THIS_DIR / "subdir", # absolute path to subdirectory + Path.cwd() / "foo", # absolute path relative to working dir + ], +) +def test_uninstall_bat_file_path(test_path): + """Test that various directory inputs work as expected.""" + uninstall_bat = UninstallBat(test_path, user_script=None) + assert uninstall_bat.file_path == test_path / 'pre_uninstall.bat' + +@pytest.mark.parametrize("bat_file_name", ['foo.bat', 'bar.BAT']) +def test_bat_file_works(tmp_path, bat_file_name): + """Test that both .bat and .BAT works and is considered a bat file.""" + uninstall_bat = UninstallBat(tmp_path, user_script=None) + with open(uninstall_bat.file_path, 'w') as f: + f.write("Hello") + uninstall_bat.is_bat_file(uninstall_bat.file_path) + +@pytest.mark.parametrize("bat_file_name", ['foo.bat', 'bar.BAT', 'foo.txt', 'bar']) +def test_invalid_user_script(tmp_path, bat_file_name): + """Verify we get an exception if the user specifies an invalid type of pre_uninstall script.""" + expected = f"The entry '{bat_file_name}' configured via 'pre_uninstall' must be a path to an existing .bat file." + with pytest.raises(ValueError, match=expected): + UninstallBat(tmp_path, user_script = bat_file_name) + +def test_sanitize_input_simple(): + """Test sanitize simple list.""" + items = ['foo', 'txt', 'exit'] + ubat = UninstallBat(Path('foo'), user_script=None) + assert ubat.sanitize_input(items) == ['foo', 'txt', 'exit /b'] + +def test_sanitize_input_from_file(tmp_path): + """Test sanitize input, also add a mix of newlines.""" + bat_file = tmp_path / 'test.bat' + with open(bat_file, 'w') as f: + f.writelines(['echo 1\n', 'exit\r\n', 'echo 2\n\n']) + ubat = UninstallBat(tmp_path, user_script=bat_file) + user_script = ubat.user_script_as_list() + sanitized = ubat.sanitize_input(user_script) + assert sanitized == ['echo 1', 'exit /b', '', 'echo 2', ''] + +def test_create_without_dir(tmp_path): + """Verify we get an exception if the target directory does not exist""" + dir_that_doesnt_exist = tmp_path / 'foo' + ubat = UninstallBat(dir_that_doesnt_exist, user_script = None) + expected = f"The directory {dir_that_doesnt_exist} must exist in order to create the file." + with pytest.raises(FileNotFoundError, match=re.escape(expected)): + ubat.create() + +def test_create(tmp_path): + """Verify the contents of the uninstall script looks as expected.""" + # TODO: Since we don't merge the user script right now, we need to account for this + # when it's been added. + + bat_file = tmp_path / 'test.bat' + with open(bat_file, 'w') as f: + f.writelines(['echo 1\n', 'exit\r\n', 'echo 2\n\n']) + ubat = UninstallBat(tmp_path, user_script=bat_file) + ubat.create() + with open(ubat.file_path) as f: + contents = f.readlines() + expected = [ + '@echo off\n', + 'setlocal enableextensions enabledelayedexpansion\n', + 'set "_SELF=%~f0"\n', + 'set "_HERE=%~dp0"\n', + '\n', + 'rem === Pre-uninstall script ===\n', + '\n', 'rem User supplied with a script\n', + '\n', + 'echo "hello from the script"\n', + 'pause\n' + ] + assert contents == expected From aad10acb5258d5fb312ace6e7c06da9724b77bc2 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 20 Nov 2025 13:32:53 -0500 Subject: [PATCH 16/24] A few fixes --- constructor/briefcase.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 8bb05b760..586e36bfe 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -159,6 +159,7 @@ def create(self) -> None: "setlocal enableextensions enabledelayedexpansion", 'set "_SELF=%~f0"', 'set "_HERE=%~dp0"', + r'set "PREFIX=%_HERE%\..\"', "", "rem === Pre-uninstall script ===", ] @@ -178,6 +179,8 @@ def create(self) -> None: # The main part of the bat-script here main_bat = [ 'echo "hello from the script"', + 'echo %_SELF%', + 'echo %_HERE%', "pause", ] final_lines = header + [""] + user_bat + [""] + main_bat @@ -211,7 +214,7 @@ def write_pyproject_toml(tmp_dir, info, uninstall_bat): "use_full_install_path": False, "install_launcher": False, "post_install_script": str(BRIEFCASE_DIR / "run_installation.bat"), - "pre_uninstall_script": uninstall_bat.file_path, + "pre_uninstall_script": str(uninstall_bat.file_path), } }, } @@ -228,7 +231,7 @@ def create(info, verbose=False): tmp_dir = Path(tempfile.mkdtemp()) - uninstall_bat = UninstallBat(info.get("pre_uninstall", None), tmp_dir) + uninstall_bat = UninstallBat(tmp_dir, info.get("pre_uninstall", None)) uninstall_bat.create() write_pyproject_toml(tmp_dir, info, uninstall_bat) From 13e2cbebdd9d33b137b7ae64d16d3c85a3c197a1 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 20 Nov 2025 13:36:41 -0500 Subject: [PATCH 17/24] Add batfile as a docstring right now --- constructor/briefcase.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 586e36bfe..2d348ff7e 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -176,6 +176,26 @@ def create(self) -> None: "rem User supplied with a script", ] + + # TODO, this works (almost) + """ + echo "Preparing uninstallation..." + echo %_SELF% + echo %_HERE% + set "INSTDIR=%_HERE%\.." + set "CONDA_FLAGS=--remove-config-files=user" + set "CONDA_EXE=_conda.exe" + "%INSTDIR%\%CONDA_EXE%" constructor uninstall %CONDA_FLAGS% --prefix "%INSTDIR%" + if errorlevel 1 ( + echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%. + pause + exit /b %errorlevel% + ) + RMDIR /Q /S "%INSTDIR%" + echo [INFO] %CONDA_EXE% completed successfully. + pause + + """ # The main part of the bat-script here main_bat = [ 'echo "hello from the script"', From b7898b5f5ce96b4550d12f057c175605d46608d9 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 20 Nov 2025 13:50:12 -0500 Subject: [PATCH 18/24] Pre-commit and add script --- constructor/briefcase.py | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 2d348ff7e..b44da1931 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -152,7 +152,9 @@ def create(self) -> None: When this function is called, the directory 'dst' specified at class instantiation must exist. """ if not self._dst.exists(): - raise FileNotFoundError(f"The directory {self._dst} must exist in order to create the file.") + raise FileNotFoundError( + f"The directory {self._dst} must exist in order to create the file." + ) header = [ "@echo off", @@ -176,31 +178,23 @@ def create(self) -> None: "rem User supplied with a script", ] - # TODO, this works (almost) - """ - echo "Preparing uninstallation..." - echo %_SELF% - echo %_HERE% - set "INSTDIR=%_HERE%\.." - set "CONDA_FLAGS=--remove-config-files=user" - set "CONDA_EXE=_conda.exe" - "%INSTDIR%\%CONDA_EXE%" constructor uninstall %CONDA_FLAGS% --prefix "%INSTDIR%" - if errorlevel 1 ( - echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%. - pause - exit /b %errorlevel% - ) - RMDIR /Q /S "%INSTDIR%" - echo [INFO] %CONDA_EXE% completed successfully. - pause - - """ # The main part of the bat-script here main_bat = [ - 'echo "hello from the script"', - 'echo %_SELF%', - 'echo %_HERE%', + 'echo "Preparing uninstallation..."', + "echo %_SELF%", + "echo %_HERE%", + r'set "INSTDIR=%_HERE%\.."', + 'set "CONDA_FLAGS=--remove-config-files=user"', + 'set "CONDA_EXE=_conda.exe"', + r'"%INSTDIR%\%CONDA_EXE%" constructor uninstall %CONDA_FLAGS% --prefix "%INSTDIR%"', + "if errorlevel 1 (", + " echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%.", + " pause", + " exit /b %errorlevel%", + ")", + 'rem RMDIR /Q /S "%INSTDIR%"', + "echo [INFO] %CONDA_EXE% completed successfully.", "pause", ] final_lines = header + [""] + user_bat + [""] + main_bat From 9a6923146b1364b14796e31d85f5fbe0e7fb543e Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 21 Nov 2025 15:25:29 -0500 Subject: [PATCH 19/24] Add working uninstallation --- constructor/briefcase.py | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index b44da1931..39ce2c1a8 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -159,15 +159,11 @@ def create(self) -> None: header = [ "@echo off", "setlocal enableextensions enabledelayedexpansion", - 'set "_SELF=%~f0"', 'set "_HERE=%~dp0"', - r'set "PREFIX=%_HERE%\..\"', "", "rem === Pre-uninstall script ===", ] - # TODO: Create unique labels using uuid to avoid collisions - user_bat: list[str] = [] if self.user_script: @@ -175,27 +171,39 @@ def create(self) -> None: # TODO: Embed user script and run it as a subroutine. # Add error handling using unique labels with 'goto' user_bat += [ - "rem User supplied with a script", + "rem User supplied a script", ] - # TODO, this works (almost) - # The main part of the bat-script here + """ + The goal is to remove most of the files except for the directory '_installer' where + the bat-files are located. This is because the MSI Installer needs to call these bat-files + after 'pre_uninstall_script' is finished, in order to finish with the uninstallation. + """ main_bat = [ 'echo "Preparing uninstallation..."', - "echo %_SELF%", - "echo %_HERE%", r'set "INSTDIR=%_HERE%\.."', - 'set "CONDA_FLAGS=--remove-config-files=user"', 'set "CONDA_EXE=_conda.exe"', - r'"%INSTDIR%\%CONDA_EXE%" constructor uninstall %CONDA_FLAGS% --prefix "%INSTDIR%"', + r'"%INSTDIR%\%CONDA_EXE%" menuinst --prefix "%INSTDIR%" --remove' + r'"%INSTDIR%\%CONDA_EXE%" remove -p "%INSTDIR%" --keep-env --all -y', "if errorlevel 1 (", " echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%.", - " pause", " exit /b %errorlevel%", ")", - 'rem RMDIR /Q /S "%INSTDIR%"', + "", "echo [INFO] %CONDA_EXE% completed successfully.", - "pause", + r'set "PKGS=%INSTDIR%\pkgs"', + 'if exist "%PKGS%" (', + ' echo [INFO] Removing "%PKGS%" ...', + ' rmdir /s /q "%PKGS%"', + " echo [INFO] Done.", + ")", + "", + r'set "NONADMIN=%INSTDIR%\.nonadmin"', + 'if exist "%NONADMIN%" (', + ' echo [INFO] Removing file "%NONADMIN%" ...', + ' del /f /q "%NONADMIN%"', + ")", + "", ] final_lines = header + [""] + user_bat + [""] + main_bat From 3dc6e480dd0ad7b0651107280f91e07b4737fc7a Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 21 Nov 2025 15:38:03 -0500 Subject: [PATCH 20/24] Update test and add missing comma-sign --- constructor/briefcase.py | 2 +- tests/test_briefcase.py | 30 ++++++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 39ce2c1a8..08debe966 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -183,7 +183,7 @@ def create(self) -> None: 'echo "Preparing uninstallation..."', r'set "INSTDIR=%_HERE%\.."', 'set "CONDA_EXE=_conda.exe"', - r'"%INSTDIR%\%CONDA_EXE%" menuinst --prefix "%INSTDIR%" --remove' + r'"%INSTDIR%\%CONDA_EXE%" menuinst --prefix "%INSTDIR%" --remove', r'"%INSTDIR%\%CONDA_EXE%" remove -p "%INSTDIR%" --keep-env --all -y', "if errorlevel 1 (", " echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%.", diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 2170366b8..eec158cc3 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -171,13 +171,35 @@ def test_create(tmp_path): expected = [ '@echo off\n', 'setlocal enableextensions enabledelayedexpansion\n', - 'set "_SELF=%~f0"\n', 'set "_HERE=%~dp0"\n', '\n', 'rem === Pre-uninstall script ===\n', - '\n', 'rem User supplied with a script\n', '\n', - 'echo "hello from the script"\n', - 'pause\n' + 'rem User supplied a script\n', + '\n', + 'echo "Preparing uninstallation..."\n', + 'set "INSTDIR=%_HERE%\\.."\n', + 'set "CONDA_EXE=_conda.exe"\n', + '"%INSTDIR%\\%CONDA_EXE%" menuinst --prefix "%INSTDIR%" --remove\n', + '"%INSTDIR%\\%CONDA_EXE%" remove -p "%INSTDIR%" --keep-env --all -y\n', + 'if errorlevel 1 (\n', + ' echo [ERROR] %CONDA_EXE% failed with exit code %errorlevel%.\n', + ' exit /b %errorlevel%\n', + ')\n', + '\n', + 'echo [INFO] %CONDA_EXE% completed successfully.\n', + 'set "PKGS=%INSTDIR%\\pkgs"\n', + 'if exist "%PKGS%" (\n', + ' echo [INFO] Removing "%PKGS%" ...\n', + ' rmdir /s /q "%PKGS%"\n', + ' echo [INFO] Done.\n', + ')\n', + '\n', + 'set "NONADMIN=%INSTDIR%\\.nonadmin"\n', + 'if exist "%NONADMIN%" (\n', + ' echo [INFO] Removing file "%NONADMIN%" ...\n', + ' del /f /q "%NONADMIN%"\n', + ')\n', + '\n', ] assert contents == expected From 4e6606a0ec7f1f2ca8a36258a30bc2a8ba807131 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 24 Nov 2025 09:39:59 -0500 Subject: [PATCH 21/24] Improve documentation and fixing language --- constructor/briefcase.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 08debe966..7ec3de411 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -100,9 +100,10 @@ def get_license(info): class UninstallBat: - """Represents a pre-uninstall batch script handler for the MSI installers. - This is intended to handle both the user specified 'pre_uninstall' bat script - and also the 'pre_uninstall_script' passed to briefcase by merging them into one. + """Represents a pre-uninstall batch file handler for the MSI installers. + This class handles both an optional user script together with a default uininstallation, + by creating one, merged batch file. + The created file is designed for the briefcase specific config entry 'pre_uninstall_script'. """ def __init__(self, dst: Path, user_script: str | None): @@ -110,12 +111,11 @@ def __init__(self, dst: Path, user_script: str | None): Parameters ---------- dst : Path - Destination directory where the generated `pre_uninstall.bat` file - will be written. + Destination directory where the file `pre_uninstall.bat` will be written. user_script : str | None - Optional path (string) to a user-provided `.bat` file configured - via the `pre_uninstall` setting in the installer configuration. - If provided, the file must adhere to the schema. + Optional path (string) to an existing user-provided batch file. + If provided, the file must adhere to the schema, in particular, + as 'pre_uninstall' is defined. """ self._dst = dst @@ -134,7 +134,7 @@ def is_bat_file(self, file_path: Path) -> bool: return file_path.is_file() and file_path.suffix.lower() == ".bat" def user_script_as_list(self) -> list[str]: - """Read user script.""" + """Read user script into a list.""" if not self.user_script: return [] with open(self.user_script, encoding=self._encoding, newline=None) as f: @@ -142,12 +142,12 @@ def user_script_as_list(self) -> list[str]: def sanitize_input(self, input_list: list[str]) -> list[str]: """Sanitizes the input, adds a safe exit if necessary. - Assumes the contents of the input represents the contents of a .bat-file. + Assumes the contents of the input represents the contents of a batch file. """ return ["exit /b" if line.strip().lower() == "exit" else line for line in input_list] def create(self) -> None: - """Create the bat script for uninstallation. The script will also include the contents from the file the user + """Create the batch file for uninstallation. The script will also include the contents from the file the user may have specified in the yaml-file via 'pre_uninstall'. When this function is called, the directory 'dst' specified at class instantiation must exist. """ @@ -176,8 +176,8 @@ def create(self) -> None: """ The goal is to remove most of the files except for the directory '_installer' where - the bat-files are located. This is because the MSI Installer needs to call these bat-files - after 'pre_uninstall_script' is finished, in order to finish with the uninstallation. + the batch files are located. This is because the MSI Installer needs these batch files + to exist after 'pre_uninstall_script' is finished, in order to finish with the uninstallation. """ main_bat = [ 'echo "Preparing uninstallation..."', From 702f8ef42800706630a97824f9871befbb3c9726 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 24 Nov 2025 11:08:47 -0500 Subject: [PATCH 22/24] Fix code clarity and removal of 'envs' dir --- constructor/briefcase.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 7ec3de411..79914e1f8 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -147,8 +147,8 @@ def sanitize_input(self, input_list: list[str]) -> list[str]: return ["exit /b" if line.strip().lower() == "exit" else line for line in input_list] def create(self) -> None: - """Create the batch file for uninstallation. The script will also include the contents from the file the user - may have specified in the yaml-file via 'pre_uninstall'. + """Create the batch file. If a `user_script` was defined at class instantiation, the batch file + will also include the contents from that file. When this function is called, the directory 'dst' specified at class instantiation must exist. """ if not self._dst.exists(): @@ -175,14 +175,24 @@ def create(self) -> None: ] """ - The goal is to remove most of the files except for the directory '_installer' where - the batch files are located. This is because the MSI Installer needs these batch files - to exist after 'pre_uninstall_script' is finished, in order to finish with the uninstallation. + The goal is to remove most of the files except for files such as the + directory named '_installer' which the MSI installer expects to exist when + it performs the uninstallation. """ - main_bat = [ - 'echo "Preparing uninstallation..."', + main_bat = [ # Prep + "echo Preparing uninstallation...", r'set "INSTDIR=%_HERE%\.."', 'set "CONDA_EXE=_conda.exe"', + ] + main_bat += [ # Removal of 'envs' directory + r'set "ENVS_DIR=%INSTDIR%\envs"', + 'if exist "%ENVS_DIR%" (', + ' echo [INFO] Removing "%ENVS_DIR%" ...', + r' "%INSTDIR%\%CONDA_EXE%" constructor uninstall --prefix "%INSTDIR%\envs"', + " echo [INFO] Done.", + ")", + ] + main_bat += [ # Removal of Start Menus and base env r'"%INSTDIR%\%CONDA_EXE%" menuinst --prefix "%INSTDIR%" --remove', r'"%INSTDIR%\%CONDA_EXE%" remove -p "%INSTDIR%" --keep-env --all -y', "if errorlevel 1 (", @@ -190,6 +200,8 @@ def create(self) -> None: " exit /b %errorlevel%", ")", "", + ] + main_bat += [ # Removal of 'pkgs' directory "echo [INFO] %CONDA_EXE% completed successfully.", r'set "PKGS=%INSTDIR%\pkgs"', 'if exist "%PKGS%" (', @@ -198,6 +210,8 @@ def create(self) -> None: " echo [INFO] Done.", ")", "", + ] + main_bat += [ # Removal of .nonadmin r'set "NONADMIN=%INSTDIR%\.nonadmin"', 'if exist "%NONADMIN%" (', ' echo [INFO] Removing file "%NONADMIN%" ...', @@ -205,8 +219,8 @@ def create(self) -> None: ")", "", ] - final_lines = header + [""] + user_bat + [""] + main_bat + final_lines = header + [""] + user_bat + [""] + main_bat with open(self.file_path, "w", encoding=self._encoding, newline="\r\n") as f: # Python will write \n as \r\n since we have set the 'newline' argument above. f.writelines(line + "\n" for line in final_lines) From 80851ebb278138d9a760cd273203281da056d48a Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 25 Nov 2025 13:17:30 -0500 Subject: [PATCH 23/24] Add separate clean up of pkgs --- constructor/briefcase.py | 67 ++++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 79914e1f8..35addcc07 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -146,6 +146,60 @@ def sanitize_input(self, input_list: list[str]) -> list[str]: """ return ["exit /b" if line.strip().lower() == "exit" else line for line in input_list] + def _clean_up_pkgs_dir(self) -> list[str]: + """This method returns a list of strings that represents the code + necessary to clean up the 'pkgs' directory. Unfortunately we have to follow + a very strict set of instructions in order to ensure that we clean up the directory + such that the MSI installer can continue by removing related Windows Registry Entries. + The current behavior is that the MSI installer registers each directory from `pkgs` + if it contains the subdirectory 'info'. Therefore we need to clean up the 'pkgs' directory + to match that. + """ + return [ + r'set "PKGS=%INSTDIR%\pkgs"', + # Sanity check + 'if not exist "%PKGS%\" (', + ' echo [WARNING] "%PKGS%" does not exist.', + ' exit /b 0', + ')', + '', + # Delete plain files directly under pkgs + 'pushd "%PKGS%" || (echo [ERROR] cannot enter "%PKGS%" & exit /b 1)', + 'del /f /q * >nul 2>&1', + '', + # For each subdirectory, delete everything except "info" + 'for /d %%D in (*) do (', + # Try enter the child directory; if it fails, skip it + ' pushd "%%~fD" >nul 2>&1', + ' if errorlevel 1 (', + ' echo [WARNING] Could not enter "%%~fD"', + ' ) else (', + # Delete files in this child dir + ' del /f /q * >nul 2>&1', + # Delete all directories except "info" + # If we encounter "info", delete its contents + ' for /d %%S in (*) do (', + ' if /i "%%S"=="info" (', + ' pushd "%%~fS" >nul 2>&1', + ' if not errorlevel 1 (', + ' del /f /q * >nul 2>&1', + ' for /d %%I in (*) do rmdir /s /q "%%I"', + ' popd', + ' ) else (', + ' echo [WARNING] Could not enter "%%~fS"', + ' )', + ' ) else (', + ' rmdir /s /q "%%S"', + ' )', + ' )', + ' popd', + ' )', + ')', + '', + 'popd', + '', + ] + def create(self) -> None: """Create the batch file. If a `user_script` was defined at class instantiation, the batch file will also include the contents from that file. @@ -201,16 +255,9 @@ def create(self) -> None: ")", "", ] - main_bat += [ # Removal of 'pkgs' directory - "echo [INFO] %CONDA_EXE% completed successfully.", - r'set "PKGS=%INSTDIR%\pkgs"', - 'if exist "%PKGS%" (', - ' echo [INFO] Removing "%PKGS%" ...', - ' rmdir /s /q "%PKGS%"', - " echo [INFO] Done.", - ")", - "", - ] + # Removal of pkgs + main_bat += self._clean_up_pkgs_dir() + main_bat += [ # Removal of .nonadmin r'set "NONADMIN=%INSTDIR%\.nonadmin"', 'if exist "%NONADMIN%" (', From ef398ec204daf56f2244a57b55bb9d06eb1270ba Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 25 Nov 2025 13:18:36 -0500 Subject: [PATCH 24/24] Fix formatting --- constructor/briefcase.py | 50 ++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 35addcc07..caaf74332 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -158,46 +158,46 @@ def _clean_up_pkgs_dir(self) -> list[str]: return [ r'set "PKGS=%INSTDIR%\pkgs"', # Sanity check - 'if not exist "%PKGS%\" (', + 'if not exist "%PKGS%" (', ' echo [WARNING] "%PKGS%" does not exist.', - ' exit /b 0', - ')', - '', + " exit /b 0", + ")", + "", # Delete plain files directly under pkgs 'pushd "%PKGS%" || (echo [ERROR] cannot enter "%PKGS%" & exit /b 1)', - 'del /f /q * >nul 2>&1', - '', + "del /f /q * >nul 2>&1", + "", # For each subdirectory, delete everything except "info" - 'for /d %%D in (*) do (', + "for /d %%D in (*) do (", # Try enter the child directory; if it fails, skip it ' pushd "%%~fD" >nul 2>&1', - ' if errorlevel 1 (', + " if errorlevel 1 (", ' echo [WARNING] Could not enter "%%~fD"', - ' ) else (', + " ) else (", # Delete files in this child dir - ' del /f /q * >nul 2>&1', + " del /f /q * >nul 2>&1", # Delete all directories except "info" # If we encounter "info", delete its contents - ' for /d %%S in (*) do (', + " for /d %%S in (*) do (", ' if /i "%%S"=="info" (', ' pushd "%%~fS" >nul 2>&1', - ' if not errorlevel 1 (', - ' del /f /q * >nul 2>&1', + " if not errorlevel 1 (", + " del /f /q * >nul 2>&1", ' for /d %%I in (*) do rmdir /s /q "%%I"', - ' popd', - ' ) else (', + " popd", + " ) else (", ' echo [WARNING] Could not enter "%%~fS"', - ' )', - ' ) else (', + " )", + " ) else (", ' rmdir /s /q "%%S"', - ' )', - ' )', - ' popd', - ' )', - ')', - '', - 'popd', - '', + " )", + " )", + " popd", + " )", + ")", + "", + "popd", + "", ] def create(self) -> None: