diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e053e5c40..2bb7c8abf 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" @@ -153,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/constructor/briefcase.py b/constructor/briefcase.py index eea2083d5..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 @@ -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(info), "app": { app_name: { "formal_name": f"{info['name']} {info['version']}", @@ -117,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) @@ -133,6 +143,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 to be installed.\n" + f"Tried: {briefcase}" + ) logger.info("Building installer") run( [briefcase, "package"] + (["-v"] if verbose else []), 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/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 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/pyproject.toml b/pyproject.toml index 97eb1235a..b3159dd45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "jinja2", "jsonschema >=4", "tomli-w >=1.2.0", + "briefcase" ] [project.optional-dependencies] diff --git a/tests/test_examples.py b/tests/test_examples.py index 7d8913e2f..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 @@ -44,6 +45,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.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) @@ -286,6 +288,75 @@ def _sentinel_file_checks(example_path, install_dir): ) +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, +): + """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 [] + 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("A check 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, installer: Path, @@ -331,12 +402,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 @@ -356,16 +445,19 @@ def create_installer( output_dir = workspace / "installer" output_dir.mkdir(parents=True, exist_ok=True) - cmd = [ - *COV_CMD, - "constructor", - "-v", + 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: + cmd.append("-v") + cmd += [ str(input_dir), "--output-dir", str(output_dir), "--config-filename", config_filename, ] + if conda_exe: cmd.extend(["--conda-exe", conda_exe]) if debug: @@ -379,18 +471,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: @@ -478,13 +573,27 @@ 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": + 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) 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): @@ -558,6 +667,14 @@ 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 + 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") def test_example_noconda(tmp_path, request): @@ -720,7 +837,14 @@ def test_example_shortcuts(tmp_path, request): break else: raise AssertionError("No shortcuts found!") - _run_uninstaller_exe(install_dir) + 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) assert not (package_1 / "A.lnk").is_file() assert not (package_1 / "B.lnk").is_file() elif sys.platform == "darwin": @@ -862,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 @@ -924,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") @@ -939,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( @@ -954,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 @@ -1016,6 +1147,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( @@ -1051,6 +1184,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" @@ -1304,7 +1439,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. 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/