Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
8 changes: 6 additions & 2 deletions CONSTRUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down
9 changes: 7 additions & 2 deletions constructor/_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class WinSignTools(StrEnum):
class InstallerTypes(StrEnum):
ALL = "all"
EXE = "exe"
MSI = "msi"
PKG = "pkg"
SH = "sh"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
"""
Expand Down
153 changes: 153 additions & 0 deletions constructor/briefcase.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""
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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
name = info["name"]
if not name:
raise ValueError("Name is empty")
if not (name := info.get("name")):
raise ValueError("Name is empty")

If we are concerned about empty values, we should use get, too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed it in mhsmith#1

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've copied that fix to this PR.


# Briefcase requires version numbers to be in the canonical Python format, and some
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Python is not fully compatible with SemVer, so that could be a pretty significant limitation.

It will at least require a few version changes in our integration test examples:

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed temporarily in mhsmith#1

Copy link
Author

@mhsmith mhsmith Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as MSI is concerned, the version number is only used for 2 purposes:

  • Display in the installed apps list. This is just for the user's information, so I think it's acceptable if some of the construct.yaml version gets moved into the name for display purposes, as long as all the information is still present.

  • Blocking the installer of an old version if a new version is already installed. Since constructor itself doesn't define any version ordering rules, it's impossible to do this perfectly for all version schemes. The current code covers a reasonably large number of cases, but it could be extended if necessary.

Notice the current code only uses the last valid Python package version number it finds. This is to accommodate the Miniconda construct.yaml file, which sets version to something like py313_25.1.2-3. It's better to transform this into 25.1.2.3 rather than 313.25.1.2.3.

Copy link
Author

@mhsmith mhsmith Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For versions with no numbers in them at all, we could either reject them as the PR currently does, or display a warning and fall back to a default like 0.0.1 or 1.0.0. That's probably a good idea, since it would allow all existing construct.yaml files to at least build, and the integration tests wouldn't need to change so much.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made it use a default of 0.0.1, so that if a valid version is added in the future, it'll be treated as an upgrade.

# 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, for something like Miniconda3-py310_25.9.1-3-Windows-x86_64.msi, we have the following?

name = Miniconda3 py310
version = 25.9.1.3

Copy link
Author

@mhsmith mhsmith Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, those would be the name and version from Briefcase's point of view, and that's how they'd be displayed in the Windows apps list.

The current code can generate these values from a construct.yaml file where py310 is part of the version. There's an example of that in test_name_version in test_briefcase.py.


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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since constructor doesn't require Python-based packages, will we run into issues with non-Python namespaces?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As with the version, I guess we could make a best effort to transform it into something valid. In the case of MSI, this value is only used internally by Briefcase and is never visible to the user. So it doesn't matter if it's slightly different to the construct.yaml value, as long as it's consistent.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

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,
"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):
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,
)
Comment on lines 148 to 159
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have two questions here:

  1. Is it possible to use briefcase as an API rather than a CLI?
  2. This looks like Windows - do we need any guards here?

Either way, we should make sure that briefcase.exe exists before calling it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added 2) in mhsmith#1, the check for briefcase.exe is also there

Copy link
Author

@mhsmith mhsmith Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. No, the CLI is the only stable interface.
  2. The check in mhsmith#1 looks good to me. The fact that we're running on WIndows is already guarded by os_allowed in main.py.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've copied the briefcase.exe check to this PR.


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)
9 changes: 9 additions & 0 deletions constructor/briefcase/run_installation.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
_conda constructor --prefix . --extract-conda-pkgs
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use absolute paths instead of relative paths.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


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 .
5 changes: 3 additions & 2 deletions constructor/data/construct.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@
"enum": [
"all",
"exe",
"msi",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change should be applied in _schema.py and then propagated.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

"pkg",
"sh"
],
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
6 changes: 5 additions & 1 deletion constructor/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion constructor/osxpkg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions constructor/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions dev/environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ dependencies:
- jinja2
- jsonschema >=4
- pydantic 2.11.*
- tomli-w >=1.2.0
8 changes: 6 additions & 2 deletions docs/source/construct-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down
10 changes: 6 additions & 4 deletions docs/source/howto.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is only needed for MSI installers, this should probably only be added for Windows - same in the recipe.

This is also missing briefcase as a dependency.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated pyproject.toml, meta.yaml and environment.yml. A new enough version of Briefcase is now on conda-forge, so it no longer needs to be installed manually.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Briefcase 0.3.26 also has a couple of new MSI features:

  • Interactive selection of the install directory.
  • Uninstaller options – though as it says here, these currently only appear when you choose "Modify" from the app list rather than "Uninstall", so we'll need to revisit this in the future.

]

[project.optional-dependencies]
Expand Down
1 change: 1 addition & 0 deletions recipe/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading