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/.gitignore b/.gitignore index 22609e17e..9733f24b1 100644 --- a/.gitignore +++ b/.gitignore @@ -150,8 +150,13 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# VS Code .vscode/ +# macOS +.DS_Store + # Rever rever/ diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 77155d3e9..fad0efef6 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -235,6 +235,7 @@ The type of the installer being created. Possible values are: - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS +- `msi`: Windows GUI installer built with Briefcase and WiX The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -317,8 +318,11 @@ Name of the company/entity responsible for the installer. ### `reverse_domain_identifier` Unique identifier for this package, formatted with reverse domain notation. This is -used internally in the PKG installers to handle future updates and others. If not -provided, it will default to `io.continuum`. (MacOS only) +used internally in the MSI and PKG installers to handle future updates and others. +If not provided, it will default to: + +* In MSI installers: `io.continuum` followed by an ID derived from the `name`. +* In PKG installers: `io.continuum`. ### `uninstall_name` diff --git a/constructor/_schema.py b/constructor/_schema.py index c77a00cf2..03f83b879 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -40,6 +40,7 @@ class WinSignTools(StrEnum): class InstallerTypes(StrEnum): ALL = "all" EXE = "exe" + MSI = "msi" PKG = "pkg" SH = "sh" @@ -401,6 +402,7 @@ class ConstructorConfiguration(BaseModel): - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS + - `msi`: Windows GUI installer built with Briefcase and WiX The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -484,8 +486,11 @@ class ConstructorConfiguration(BaseModel): reverse_domain_identifier: NonEmptyStr | None = None """ Unique identifier for this package, formatted with reverse domain notation. This is - used internally in the PKG installers to handle future updates and others. If not - provided, it will default to `io.continuum`. (MacOS only) + used internally in the MSI and PKG installers to handle future updates and others. + If not provided, it will default to: + + * In MSI installers: `io.continuum` followed by an ID derived from the `name`. + * In PKG installers: `io.continuum`. """ uninstall_name: NonEmptyStr | None = None """ diff --git a/constructor/briefcase.py b/constructor/briefcase.py new file mode 100644 index 000000000..32b5679aa --- /dev/null +++ b/constructor/briefcase.py @@ -0,0 +1,168 @@ +""" +Logic to build installers using Briefcase. +""" + +import logging +import re +import sys +import shutil +import sysconfig +import tempfile +from pathlib import Path +from subprocess import run + +import tomli_w + +from . import preconda +from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist + +BRIEFCASE_DIR = Path(__file__).parent / "briefcase" +EXTERNAL_PACKAGE_PATH = "external" + +logger = logging.getLogger(__name__) + + +def get_name_version(info): + if not (name := info.get("name")): + raise ValueError("Name is empty") + + # Briefcase requires version numbers to be in the canonical Python format, and some + # installer types use the version to distinguish between upgrades, downgrades and + # reinstalls. So try to produce a consistent ordering by extracting the last valid + # version from the Constructor version string. + # + # Hyphens aren't allowed in this format, but for compatibility with Miniconda's + # version format, we treat them as dots. + matches = list( + re.finditer( + r"(\d+!)?\d+(\.\d+)*((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?", + info["version"].lower().replace("-", "."), + ) + ) + if not matches: + raise ValueError( + f"Version {info['version']!r} contains no valid version numbers: see " + f"https://packaging.python.org/en/latest/specifications/version-specifiers/" + ) + match = matches[-1] + version = match.group() + + # Treat anything else in the version string as part of the name. + start, end = match.span() + strip_chars = " .-_" + before = info["version"][:start].strip(strip_chars) + after = info["version"][end:].strip(strip_chars) + name = " ".join(s for s in [name, before, after] if s) + + return name, version + + +# Some installer types use the reverse domain ID to detect when the product is already +# installed, so it should be both unique between different products, and stable between +# different versions of a product. +def get_bundle_app_name(info, name): + # If reverse_domain_identifier is provided, use it as-is, but verify that the last + # component is a valid Python package name, as Briefcase requires. + if (rdi := info.get("reverse_domain_identifier")) is not None: + if "." not in rdi: + raise ValueError(f"reverse_domain_identifier {rdi!r} contains no dots") + bundle, app_name = rdi.rsplit(".", 1) + + if not re.fullmatch( + r"[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9]", app_name, flags=re.IGNORECASE + ): + raise ValueError( + f"reverse_domain_identifier {rdi!r} doesn't end with a valid package " + f"name: see " + f"https://packaging.python.org/en/latest/specifications/name-normalization/" + ) + + # If reverse_domain_identifier isn't provided, generate it from the name. + else: + bundle = DEFAULT_REVERSE_DOMAIN_ID + app_name = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + if not app_name: + raise ValueError(f"Name {name!r} contains no alphanumeric characters") + + 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. +def write_pyproject_toml(tmp_dir, info): + name, version = get_name_version(info) + bundle, app_name = get_bundle_app_name(info, name) + + config = { + "project_name": name, + "bundle": bundle, + "version": version, + "license": get_license(info), + "app": { + app_name: { + "formal_name": f"{info['name']} {info['version']}", + "description": "", # Required, but not used in the installer. + "external_package_path": EXTERNAL_PACKAGE_PATH, + "use_full_install_path": False, + "install_launcher": False, + "post_install_script": str(BRIEFCASE_DIR / "run_installation.bat"), + } + }, + } + + if "company" in info: + config["author"] = info["company"] + + (tmp_dir / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}})) + + +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) + + external_dir = tmp_dir / EXTERNAL_PACKAGE_PATH + external_dir.mkdir() + preconda.write_files(info, external_dir) + preconda.copy_extra_files(info.get("extra_files", []), external_dir) + + download_dir = Path(info["_download_dir"]) + pkgs_dir = external_dir / "pkgs" + for dist in info["_dists"]: + shutil.copy(download_dir / filename_dist(dist), pkgs_dir) + + 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 []), + cwd=tmp_dir, + check=True, + ) + + dist_dir = tmp_dir / "dist" + msi_paths = list(dist_dir.glob("*.msi")) + if len(msi_paths) != 1: + raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}") + + outpath = Path(info["_outpath"]) + outpath.unlink(missing_ok=True) + shutil.move(msi_paths[0], outpath) + + if not info.get("_debug"): + shutil.rmtree(tmp_dir) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat new file mode 100644 index 000000000..7f9e6c486 --- /dev/null +++ b/constructor/briefcase/run_installation.bat @@ -0,0 +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 install --offline --file conda-meta\initial-state.explicit.txt -yp . diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index 5914be40e..ba14269f7 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -224,6 +224,7 @@ "enum": [ "all", "exe", + "msi", "pkg", "sh" ], @@ -824,7 +825,7 @@ } ], "default": null, - "description": "The type of the installer being created. Possible values are:\n- `sh`: shell-based installer for Linux or macOS\n- `pkg`: macOS GUI installer built with Apple's `pkgbuild`\n- `exe`: Windows GUI installer built with NSIS\nThe default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well as `sh` on Linux and `exe` on Windows.", + "description": "The type of the installer being created. Possible values are:\n- `sh`: shell-based installer for Linux or macOS\n- `pkg`: macOS GUI installer built with Apple's `pkgbuild`\n- `exe`: Windows GUI installer built with NSIS\n- `msi`: Windows GUI installer built with Briefcase and WiX\nThe default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well as `sh` on Linux and `exe` on Windows.", "title": "Installer Type" }, "keep_pkgs": { @@ -1104,7 +1105,7 @@ } ], "default": null, - "description": "Unique identifier for this package, formatted with reverse domain notation. This is used internally in the PKG installers to handle future updates and others. If not provided, it will default to `io.continuum`. (MacOS only)", + "description": "Unique identifier for this package, formatted with reverse domain notation. This is used internally in the MSI and PKG installers to handle future updates and others. If not provided, it will default to:\n* In MSI installers: `io.continuum` followed by an ID derived from the `name`. * In PKG installers: `io.continuum`.", "title": "Reverse Domain Identifier" }, "script_env_variables": { diff --git a/constructor/main.py b/constructor/main.py index d7b02e7ce..6dc6f0656 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -38,7 +38,7 @@ def get_installer_type(info): osname, unused_arch = info["_platform"].split("-") - os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe",)} + os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe", "msi")} all_allowed = set(sum(os_allowed.values(), ("all",))) itype = info.get("installer_type") @@ -317,6 +317,10 @@ def is_conda_meta_frozen(path_str: str) -> bool: from .winexe import create as winexe_create create = winexe_create + elif itype == "msi": + from .briefcase import create as briefcase_create + + create = briefcase_create info["installer_type"] = itype info["_outpath"] = abspath(join(output_dir, get_output_filename(info))) create(info, verbose=verbose) diff --git a/constructor/osxpkg.py b/constructor/osxpkg.py index 3785ac617..eb410101b 100644 --- a/constructor/osxpkg.py +++ b/constructor/osxpkg.py @@ -21,6 +21,7 @@ from .jinja import render_template from .signing import CodeSign from .utils import ( + DEFAULT_REVERSE_DOMAIN_ID, add_condarc, approx_size_kb, copy_conda_exe, @@ -390,7 +391,7 @@ def fresh_dir(dir_path): def pkgbuild(name, identifier=None, version=None, install_location=None): "see `man pkgbuild` for the meaning of optional arguments" if identifier is None: - identifier = "io.continuum" + identifier = DEFAULT_REVERSE_DOMAIN_ID args = [ "pkgbuild", "--root", diff --git a/constructor/utils.py b/constructor/utils.py index 705a42bd7..656937391 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -26,6 +26,8 @@ from conda.models.version import VersionOrder from ruamel.yaml import YAML +DEFAULT_REVERSE_DOMAIN_ID = "io.continuum" + logger = logging.getLogger(__name__) yaml = YAML(typ="rt") yaml.default_flow_style = False diff --git a/dev/environment.yml b/dev/environment.yml index 6d2e400ff..4cb0d22d4 100644 --- a/dev/environment.yml +++ b/dev/environment.yml @@ -10,3 +10,4 @@ dependencies: - jinja2 - jsonschema >=4 - pydantic 2.11.* + - tomli-w >=1.2.0 diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 77155d3e9..fad0efef6 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -235,6 +235,7 @@ The type of the installer being created. Possible values are: - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS +- `msi`: Windows GUI installer built with Briefcase and WiX The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -317,8 +318,11 @@ Name of the company/entity responsible for the installer. ### `reverse_domain_identifier` Unique identifier for this package, formatted with reverse domain notation. This is -used internally in the PKG installers to handle future updates and others. If not -provided, it will default to `io.continuum`. (MacOS only) +used internally in the MSI and PKG installers to handle future updates and others. +If not provided, it will default to: + +* In MSI installers: `io.continuum` followed by an ID derived from the `name`. +* In PKG installers: `io.continuum`. ### `uninstall_name` diff --git a/docs/source/howto.md b/docs/source/howto.md index 8087a2803..c255b6e84 100644 --- a/docs/source/howto.md +++ b/docs/source/howto.md @@ -7,10 +7,12 @@ which it is running. In other words, if you run constructor on a Windows computer, you can only generate Windows installers. This is largely because OS-native tools are needed to generate the Windows `.exe` files and macOS `.pkg` files. There is a key in `construct.yaml`, `installer_type`, which dictates -the type of installer that gets generated. This is primarily only useful for -macOS, where you can generate either `.pkg` or `.sh` installers. When not set in -`construct.yaml`, this value defaults to `.sh` on Unix platforms, and `.exe` on -Windows. Using this key is generally done with selectors. For example, to +the type of installer that gets generated. This is useful for macOS, where you can +generate either `.pkg` or `.sh` installers, and Windows, where you can generate +either `.exe` or `.msi` installers. + +When not set in`construct.yaml`, this value defaults to `.sh` on Unix platforms, and +`.exe` on Windows. Using this key is generally done with selectors. For example, to build a `.pkg` installer on MacOS, but fall back to default behavior on other platforms: 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 8f48009ac..b3159dd45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,9 @@ dependencies = [ "ruamel.yaml >=0.11.14,<0.19", "pillow >=3.1 ; platform_system=='Windows' or platform_system=='Darwin'", "jinja2", - "jsonschema >=4" + "jsonschema >=4", + "tomli-w >=1.2.0", + "briefcase" ] [project.optional-dependencies] diff --git a/recipe/meta.yaml b/recipe/meta.yaml index c5876d0cd..a784d1567 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -30,6 +30,7 @@ requirements: - jsonschema >=4 - pillow >=3.1 # [win or osx] - nsis >=3.08 # [win] + - tomli-w >=1.2.0 run_constrained: # [unix] - nsis >=3.08 # [unix] - conda-libmamba-solver !=24.11.0 diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py new file mode 100644 index 000000000..36cb1ce82 --- /dev/null +++ b/tests/test_briefcase.py @@ -0,0 +1,101 @@ +import pytest + +from constructor.briefcase import get_bundle_app_name, get_name_version + + +@pytest.mark.parametrize( + "name_in, version_in, name_expected, version_expected", + [ + # Valid versions + ("Miniconda", "1", "Miniconda", "1"), + ("Miniconda", "1.2", "Miniconda", "1.2"), + ("Miniconda", "1.2.3", "Miniconda", "1.2.3"), + ("Miniconda", "1.2a1", "Miniconda", "1.2a1"), + ("Miniconda", "1.2b2", "Miniconda", "1.2b2"), + ("Miniconda", "1.2rc3", "Miniconda", "1.2rc3"), + ("Miniconda", "1.2.post4", "Miniconda", "1.2.post4"), + ("Miniconda", "1.2.dev5", "Miniconda", "1.2.dev5"), + ("Miniconda", "1.2rc3.post4.dev5", "Miniconda", "1.2rc3.post4.dev5"), + # Hyphens are treated as dots + ("Miniconda", "1.2-3", "Miniconda", "1.2.3"), + ("Miniconda", "1.2-3.4-5.6", "Miniconda", "1.2.3.4.5.6"), + # Additional text before and after the last valid version should be treated as + # part of the name. + ("Miniconda", "1.2 3.4 5.6", "Miniconda 1.2 3.4", "5.6"), + ("Miniconda", "1.2_3.4_5.6", "Miniconda 1.2_3.4", "5.6"), + ("Miniconda", "1.2c3", "Miniconda 1.2c", "3"), + ("Miniconda", "1.2rc3.dev5.post4", "Miniconda 1.2rc3.dev5.post", "4"), + ("Miniconda", "py313", "Miniconda py", "313"), + ("Miniconda", "py.313", "Miniconda py", "313"), + ("Miniconda", "py3.13", "Miniconda py", "3.13"), + ("Miniconda", "py313_1.2", "Miniconda py313", "1.2"), + ("Miniconda", "1.2 and more", "Miniconda and more", "1.2"), + ("Miniconda", "1.2! and more", "Miniconda ! and more", "1.2"), + ("Miniconda", "py313 1.2 and more", "Miniconda py313 and more", "1.2"), + # Numbers in the name are not added to the version. + ("Miniconda3", "1", "Miniconda3", "1"), + ], +) +def test_name_version(name_in, version_in, name_expected, version_expected): + name_actual, version_actual = get_name_version( + {"name": name_in, "version": version_in}, + ) + assert (name_actual, version_actual) == (name_expected, version_expected) + + +def test_name_empty(): + with pytest.raises(ValueError, match="Name is empty"): + get_name_version({"name": ""}) + + +@pytest.mark.parametrize("version_in", ["", ".", "hello"]) +def test_version_invalid(version_in): + with pytest.raises( + ValueError, match=f"Version {version_in!r} contains no valid version numbers" + ): + get_name_version( + {"name": "Miniconda3", "version": version_in}, + ) + + +@pytest.mark.parametrize( + "rdi, name, bundle_expected, app_name_expected", + [ + ("org.conda", "ignored", "org", "conda"), + ("org.Conda", "ignored", "org", "Conda"), + ("org.conda-miniconda", "ignored", "org", "conda-miniconda"), + ("org.conda_miniconda", "ignored", "org", "conda_miniconda"), + ("org-conda.miniconda", "ignored", "org-conda", "miniconda"), + ("org.conda.miniconda", "ignored", "org.conda", "miniconda"), + (None, "x", "io.continuum", "x"), + (None, "X", "io.continuum", "x"), + (None, "Miniconda", "io.continuum", "miniconda"), + (None, "Miniconda3", "io.continuum", "miniconda3"), + (None, "Miniconda3 py313", "io.continuum", "miniconda3-py313"), + (None, "Hello, world!", "io.continuum", "hello-world"), + ], +) +def test_bundle_app_name(rdi, name, bundle_expected, app_name_expected): + bundle_actual, app_name_actual = get_bundle_app_name({"reverse_domain_identifier": rdi}, name) + assert (bundle_actual, app_name_actual) == (bundle_expected, app_name_expected) + + +@pytest.mark.parametrize("rdi", ["", "org"]) +def test_rdi_no_dots(rdi): + with pytest.raises(ValueError, match=f"reverse_domain_identifier '{rdi}' contains no dots"): + get_bundle_app_name({"reverse_domain_identifier": rdi}, "ignored") + + +@pytest.mark.parametrize("rdi", ["org.hello-", "org.-hello", "org.hello world", "org.hello!world"]) +def test_rdi_invalid_package(rdi): + with pytest.raises( + ValueError, + match=f"reverse_domain_identifier '{rdi}' doesn't end with a valid package name", + ): + get_bundle_app_name({"reverse_domain_identifier": rdi}, "ignored") + + +@pytest.mark.parametrize("name", ["", " ", "!", "-", "---"]) +def test_name_no_alphanumeric(name): + with pytest.raises(ValueError, match=f"Name '{name}' contains no alphanumeric characters"): + get_bundle_app_name({}, name) 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/