From c6c4134e3917aa459ce95a7dee840c848a60e922 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Thu, 9 Oct 2025 14:44:07 +0100 Subject: [PATCH 1/9] Initial prototype as shown in demo --- .gitignore | 5 + constructor/briefcase.py | 150 +++++++++++++++++++++ constructor/briefcase/run_installation.bat | 9 ++ constructor/data/construct.schema.json | 1 + constructor/main.py | 6 +- constructor/osxpkg.py | 3 +- constructor/utils.py | 2 + dev/environment.yml | 1 + docs/source/construct-yaml.md | 8 +- docs/source/howto.md | 10 +- pyproject.toml | 3 +- recipe/meta.yaml | 1 + tests/test_briefcase.py | 101 ++++++++++++++ 13 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 constructor/briefcase.py create mode 100644 constructor/briefcase/run_installation.bat create mode 100644 tests/test_briefcase.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/constructor/briefcase.py b/constructor/briefcase.py new file mode 100644 index 000000000..9c2f7d9bd --- /dev/null +++ b/constructor/briefcase.py @@ -0,0 +1,150 @@ +""" +Logic to build installers using Briefcase. +""" + +import logging +import re +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): + name = info["name"] + if not 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 + + +# 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": ({"file": info["license_file"]} if "license_file" in info else {"text": ""}), + "app": { + app_name: { + "formal_name": f"{info['name']} {info['version']}", + "description": "", # Required, but not used in the installer. + "external_package_path": EXTERNAL_PACKAGE_PATH, + "external_package_executable_path": "", + "use_full_install_path": 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): + 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" + 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}") + shutil.copy(msi_paths[0], info["_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..f043b1c96 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -224,6 +224,7 @@ "enum": [ "all", "exe", + "msi", "pkg", "sh" ], 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..58f07cbaf 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/pyproject.toml b/pyproject.toml index 8f48009ac..97eb1235a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,8 @@ 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", ] [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) From 201a3edb9cc6ee68bde18040c99b85327eb63bfa Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 29 Oct 2025 14:57:31 +0000 Subject: [PATCH 2/9] Switch to install_launcher option --- constructor/briefcase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 9c2f7d9bd..c0abf4dd7 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -103,8 +103,8 @@ def write_pyproject_toml(tmp_dir, info): "formal_name": f"{info['name']} {info['version']}", "description": "", # Required, but not used in the installer. "external_package_path": EXTERNAL_PACKAGE_PATH, - "external_package_executable_path": "", "use_full_install_path": False, + "install_launcher": False, "post_install_script": str(BRIEFCASE_DIR / "run_installation.bat"), } }, From a12eb5936f304b0cd0ce23820cd9722a87cebe29 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 29 Oct 2025 15:14:15 +0000 Subject: [PATCH 3/9] Update schema properly --- CONSTRUCT.md | 8 ++++++-- constructor/_schema.py | 9 +++++++-- constructor/data/construct.schema.json | 4 ++-- docs/source/construct-yaml.md | 4 ++-- 4 files changed, 17 insertions(+), 8 deletions(-) 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/data/construct.schema.json b/constructor/data/construct.schema.json index f043b1c96..ba14269f7 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -825,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": { @@ -1105,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/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 58f07cbaf..fad0efef6 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -318,8 +318,8 @@ 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 MSI and PKG installers to handle future updates and others. If -not provided, it will default to: +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`. From 52b3d344d343046a3f46d413a027e9abd756ba4f Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 29 Oct 2025 16:04:38 +0000 Subject: [PATCH 4/9] Move MSI file rather than copying it --- constructor/briefcase.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index c0abf4dd7..eea2083d5 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -144,7 +144,10 @@ def create(info, verbose=False): msi_paths = list(dist_dir.glob("*.msi")) if len(msi_paths) != 1: raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}") - shutil.copy(msi_paths[0], info["_outpath"]) + + outpath = Path(info["_outpath"]) + outpath.unlink(missing_ok=True) + shutil.move(msi_paths[0], outpath) if not info.get("_debug"): shutil.rmtree(tmp_dir) From 0101c622a8c3663d5cc7d1095a880b82a3424ec6 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 9 Dec 2025 13:52:32 +0000 Subject: [PATCH 5/9] Add fallbacks for invalid versions and app names --- constructor/briefcase.py | 43 +++++++++++++++++++----------- tests/test_briefcase.py | 57 +++++++++++++++++++++++++++++++--------- 2 files changed, 73 insertions(+), 27 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index eea2083d5..3f2652971 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -18,13 +18,18 @@ BRIEFCASE_DIR = Path(__file__).parent / "briefcase" EXTERNAL_PACKAGE_PATH = "external" +# Default to a low version, so that if a valid version is provided in the future, it'll +# be treated as an upgrade. +DEFAULT_VERSION = "0.0.1" + logger = logging.getLogger(__name__) def get_name_version(info): - name = info["name"] - if not name: + if not (name := info.get("name")): raise ValueError("Name is empty") + if not (version := info.get("version")): + raise ValueError("Version 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 @@ -36,14 +41,16 @@ def get_name_version(info): matches = list( re.finditer( r"(\d+!)?\d+(\.\d+)*((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?", - info["version"].lower().replace("-", "."), + 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/" + logger.warning( + f"Version {version!r} contains no valid version numbers; " + f"defaulting to {DEFAULT_VERSION}" ) + return f"{name} {version}", DEFAULT_VERSION + match = matches[-1] version = match.group() @@ -57,32 +64,38 @@ def get_name_version(info): return name, version +# Takes an arbitrary string with at least one alphanumeric character, and makes it into +# a valid Python package name. +def make_app_name(name, source): + app_name = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + if not app_name: + raise ValueError(f"{source} contains no alphanumeric characters") + return app_name + + # 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 reverse_domain_identifier is provided, use it as-is, 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) + # Ensure that the last component is a valid Python package name, as Briefcase + # requires. 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/" + app_name = make_app_name( + app_name, f"Last component of reverse_domain_identifier {rdi!r}" ) # 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") + app_name = make_app_name(name, f"Name {name!r}") return bundle, app_name diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 36cb1ce82..a858b6ae4 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -43,32 +43,62 @@ def test_name_version(name_in, version_in, name_expected, version_expected): assert (name_actual, version_actual) == (name_expected, version_expected) -def test_name_empty(): +@pytest.mark.parametrize( + "info", + [ + {}, + {"name": ""}, + ], +) +def test_name_empty(info): with pytest.raises(ValueError, match="Name is empty"): - get_name_version({"name": ""}) + get_name_version(info) -@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( + "info", + [ + {"name": "Miniconda"}, + {"name": "Miniconda", "version": ""}, + ], +) +def test_version_empty(info): + with pytest.raises(ValueError, match="Version is empty"): + get_name_version(info) + + +@pytest.mark.parametrize("version_in", ["x", ".", " ", "hello"]) +def test_version_invalid(version_in, caplog): + name_actual, version_actual = get_name_version( + {"name": "Miniconda3", "version": version_in}, + ) + assert name_actual == f"Miniconda3 {version_in}" + assert version_actual == "0.0.1" + assert caplog.messages == [ + f"Version {version_in!r} contains no valid version numbers; defaulting to 0.0.1" + ] @pytest.mark.parametrize( "rdi, name, bundle_expected, app_name_expected", [ + # Valid rdi ("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"), + ("org.conda.1", "ignored", "org.conda", "1"), + # Invalid rdi + ("org.hello-", "Miniconda", "org", "hello"), + ("org.-hello", "Miniconda", "org", "hello"), + ("org.hello world", "Miniconda", "org", "hello-world"), + ("org.hello!world", "Miniconda", "org", "hello-world"), + # Missing rdi (None, "x", "io.continuum", "x"), (None, "X", "io.continuum", "x"), + (None, "1", "io.continuum", "1"), (None, "Miniconda", "io.continuum", "miniconda"), (None, "Miniconda3", "io.continuum", "miniconda3"), (None, "Miniconda3 py313", "io.continuum", "miniconda3-py313"), @@ -86,11 +116,14 @@ def test_rdi_no_dots(rdi): get_bundle_app_name({"reverse_domain_identifier": rdi}, "ignored") -@pytest.mark.parametrize("rdi", ["org.hello-", "org.-hello", "org.hello world", "org.hello!world"]) +@pytest.mark.parametrize("rdi", ["org.", "org.hello.", "org.hello.-"]) def test_rdi_invalid_package(rdi): with pytest.raises( ValueError, - match=f"reverse_domain_identifier '{rdi}' doesn't end with a valid package name", + match=( + f"Last component of reverse_domain_identifier '{rdi}' " + f"contains no alphanumeric characters" + ), ): get_bundle_app_name({"reverse_domain_identifier": rdi}, "ignored") From 8e79869a69a8e8e7c3ca441a1e387a085eac675d Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 9 Dec 2025 13:53:34 +0000 Subject: [PATCH 6/9] Use absolute paths in install script --- constructor/briefcase/run_installation.bat | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat index 7f9e6c486..190a6d9f7 100644 --- a/constructor/briefcase/run_installation.bat +++ b/constructor/briefcase/run_installation.bat @@ -1,9 +1,10 @@ -_conda constructor --prefix . --extract-conda-pkgs +set PREFIX=%cd% +_conda constructor --prefix %PREFIX% --extract-conda-pkgs set CONDA_PROTECT_FROZEN_ENVS=0 -set CONDA_ROOT_PREFIX=%cd% +set CONDA_ROOT_PREFIX=%PREFIX% set CONDA_SAFETY_CHECKS=disabled set CONDA_EXTRA_SAFETY_CHECKS=no -set CONDA_PKGS_DIRS=%cd%\pkgs +set CONDA_PKGS_DIRS=%PREFIX%\pkgs -_conda install --offline --file conda-meta\initial-state.explicit.txt -yp . +_conda install --offline --file %PREFIX%\conda-meta\initial-state.explicit.txt -yp %PREFIX% From 93ce7aa24e17f1ddcf85b54734b4fbe7205154c4 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 9 Dec 2025 13:57:00 +0000 Subject: [PATCH 7/9] Check that briefcase.exe exists --- constructor/briefcase.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 3f2652971..70569c794 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -146,6 +146,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.\nTried: {briefcase}" + ) + logger.info("Building installer") run( [briefcase, "package"] + (["-v"] if verbose else []), From 2a99674210898735d797ed9d7754e835d620bccb Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 9 Dec 2025 14:18:31 +0000 Subject: [PATCH 8/9] Add briefcase to dependencies, and make it and tomli-w Windows-only --- dev/environment.yml | 3 ++- pyproject.toml | 3 ++- recipe/meta.yaml | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/dev/environment.yml b/dev/environment.yml index 4cb0d22d4..bece22097 100644 --- a/dev/environment.yml +++ b/dev/environment.yml @@ -10,4 +10,5 @@ dependencies: - jinja2 - jsonschema >=4 - pydantic 2.11.* - - tomli-w >=1.2.0 + - briefcase >=0.3.26 # [win] + - tomli-w >=1.2.0 # [win] diff --git a/pyproject.toml b/pyproject.toml index 97eb1235a..fa8162ad4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,8 @@ dependencies = [ "pillow >=3.1 ; platform_system=='Windows' or platform_system=='Darwin'", "jinja2", "jsonschema >=4", - "tomli-w >=1.2.0", + "briefcase >=0.3.26 ; platform_system=='Windows'", + "tomli-w >=1.2.0 ; platform_system=='Windows'", ] [project.optional-dependencies] diff --git a/recipe/meta.yaml b/recipe/meta.yaml index a784d1567..80ae538ed 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -30,7 +30,8 @@ requirements: - jsonschema >=4 - pillow >=3.1 # [win or osx] - nsis >=3.08 # [win] - - tomli-w >=1.2.0 + - briefcase >=0.3.26 # [win] + - tomli-w >=1.2.0 # [win] run_constrained: # [unix] - nsis >=3.08 # [unix] - conda-libmamba-solver !=24.11.0 From 2cd9d19f950cd2a7f40f81899fd97d1a97fe64b8 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 17 Dec 2025 13:22:00 +0000 Subject: [PATCH 9/9] Move Windows-specific dependencies from environment.yml to extra-requirements-windows.txt --- dev/environment.yml | 2 -- dev/extra-requirements-windows.txt | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/environment.yml b/dev/environment.yml index bece22097..6d2e400ff 100644 --- a/dev/environment.yml +++ b/dev/environment.yml @@ -10,5 +10,3 @@ dependencies: - jinja2 - jsonschema >=4 - pydantic 2.11.* - - briefcase >=0.3.26 # [win] - - tomli-w >=1.2.0 # [win] diff --git a/dev/extra-requirements-windows.txt b/dev/extra-requirements-windows.txt index 1f405685d..d382e69cb 100644 --- a/dev/extra-requirements-windows.txt +++ b/dev/extra-requirements-windows.txt @@ -1 +1,3 @@ +conda-forge::briefcase>=0.3.26 conda-forge::nsis>=3.08=*_log_* +conda-forge::tomli-w>=1.2.0