Skip to content

Commit a2caeec

Browse files
mhsmithmarcoesters
andcommitted
Initial MSI implementation, based on Briefcase (#1084)
* Initial prototype as shown in demo * Switch to install_launcher option * Update schema properly * Move MSI file rather than copying it * Add fallbacks for invalid versions and app names * Use absolute paths in install script * Check that briefcase.exe exists * Add briefcase to dependencies, and make it and tomli-w Windows-only * Move Windows-specific dependencies from environment.yml to extra-requirements-windows.txt --------- Co-authored-by: Marco Esters <mesters@anaconda.com>
1 parent 0c45f57 commit a2caeec

File tree

15 files changed

+364
-15
lines changed

15 files changed

+364
-15
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,13 @@ cython_debug/
150150
# and can be added to the global gitignore or merged into this file. For a more nuclear
151151
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
152152
#.idea/
153+
154+
# VS Code
153155
.vscode/
154156

157+
# macOS
158+
.DS_Store
159+
155160
# Rever
156161
rever/
157162

CONSTRUCT.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ The type of the installer being created. Possible values are:
235235
- `sh`: shell-based installer for Linux or macOS
236236
- `pkg`: macOS GUI installer built with Apple's `pkgbuild`
237237
- `exe`: Windows GUI installer built with NSIS
238+
- `msi`: Windows GUI installer built with Briefcase and WiX
238239

239240
The default type is `sh` on Linux and macOS, and `exe` on Windows. A special
240241
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.
317318
### `reverse_domain_identifier`
318319

319320
Unique identifier for this package, formatted with reverse domain notation. This is
320-
used internally in the PKG installers to handle future updates and others. If not
321-
provided, it will default to `io.continuum`. (MacOS only)
321+
used internally in the MSI and PKG installers to handle future updates and others.
322+
If not provided, it will default to:
323+
324+
* In MSI installers: `io.continuum` followed by an ID derived from the `name`.
325+
* In PKG installers: `io.continuum`.
322326

323327
### `uninstall_name`
324328

constructor/_schema.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class WinSignTools(StrEnum):
4040
class InstallerTypes(StrEnum):
4141
ALL = "all"
4242
EXE = "exe"
43+
MSI = "msi"
4344
PKG = "pkg"
4445
SH = "sh"
4546

@@ -401,6 +402,7 @@ class ConstructorConfiguration(BaseModel):
401402
- `sh`: shell-based installer for Linux or macOS
402403
- `pkg`: macOS GUI installer built with Apple's `pkgbuild`
403404
- `exe`: Windows GUI installer built with NSIS
405+
- `msi`: Windows GUI installer built with Briefcase and WiX
404406
405407
The default type is `sh` on Linux and macOS, and `exe` on Windows. A special
406408
value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well
@@ -484,8 +486,11 @@ class ConstructorConfiguration(BaseModel):
484486
reverse_domain_identifier: NonEmptyStr | None = None
485487
"""
486488
Unique identifier for this package, formatted with reverse domain notation. This is
487-
used internally in the PKG installers to handle future updates and others. If not
488-
provided, it will default to `io.continuum`. (MacOS only)
489+
used internally in the MSI and PKG installers to handle future updates and others.
490+
If not provided, it will default to:
491+
492+
* In MSI installers: `io.continuum` followed by an ID derived from the `name`.
493+
* In PKG installers: `io.continuum`.
489494
"""
490495
uninstall_name: NonEmptyStr | None = None
491496
"""

constructor/briefcase.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""
2+
Logic to build installers using Briefcase.
3+
"""
4+
5+
import logging
6+
import re
7+
import shutil
8+
import sysconfig
9+
import tempfile
10+
from pathlib import Path
11+
from subprocess import run
12+
13+
import tomli_w
14+
15+
from . import preconda
16+
from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist
17+
18+
BRIEFCASE_DIR = Path(__file__).parent / "briefcase"
19+
EXTERNAL_PACKAGE_PATH = "external"
20+
21+
# Default to a low version, so that if a valid version is provided in the future, it'll
22+
# be treated as an upgrade.
23+
DEFAULT_VERSION = "0.0.1"
24+
25+
logger = logging.getLogger(__name__)
26+
27+
28+
def get_name_version(info):
29+
if not (name := info.get("name")):
30+
raise ValueError("Name is empty")
31+
if not (version := info.get("version")):
32+
raise ValueError("Version is empty")
33+
34+
# Briefcase requires version numbers to be in the canonical Python format, and some
35+
# installer types use the version to distinguish between upgrades, downgrades and
36+
# reinstalls. So try to produce a consistent ordering by extracting the last valid
37+
# version from the Constructor version string.
38+
#
39+
# Hyphens aren't allowed in this format, but for compatibility with Miniconda's
40+
# version format, we treat them as dots.
41+
matches = list(
42+
re.finditer(
43+
r"(\d+!)?\d+(\.\d+)*((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?",
44+
version.lower().replace("-", "."),
45+
)
46+
)
47+
if not matches:
48+
logger.warning(
49+
f"Version {version!r} contains no valid version numbers; "
50+
f"defaulting to {DEFAULT_VERSION}"
51+
)
52+
return f"{name} {version}", DEFAULT_VERSION
53+
54+
match = matches[-1]
55+
version = match.group()
56+
57+
# Treat anything else in the version string as part of the name.
58+
start, end = match.span()
59+
strip_chars = " .-_"
60+
before = info["version"][:start].strip(strip_chars)
61+
after = info["version"][end:].strip(strip_chars)
62+
name = " ".join(s for s in [name, before, after] if s)
63+
64+
return name, version
65+
66+
67+
# Takes an arbitrary string with at least one alphanumeric character, and makes it into
68+
# a valid Python package name.
69+
def make_app_name(name, source):
70+
app_name = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
71+
if not app_name:
72+
raise ValueError(f"{source} contains no alphanumeric characters")
73+
return app_name
74+
75+
76+
# Some installer types use the reverse domain ID to detect when the product is already
77+
# installed, so it should be both unique between different products, and stable between
78+
# different versions of a product.
79+
def get_bundle_app_name(info, name):
80+
# If reverse_domain_identifier is provided, use it as-is,
81+
if (rdi := info.get("reverse_domain_identifier")) is not None:
82+
if "." not in rdi:
83+
raise ValueError(f"reverse_domain_identifier {rdi!r} contains no dots")
84+
bundle, app_name = rdi.rsplit(".", 1)
85+
86+
# Ensure that the last component is a valid Python package name, as Briefcase
87+
# requires.
88+
if not re.fullmatch(
89+
r"[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9]", app_name, flags=re.IGNORECASE
90+
):
91+
app_name = make_app_name(
92+
app_name, f"Last component of reverse_domain_identifier {rdi!r}"
93+
)
94+
95+
# If reverse_domain_identifier isn't provided, generate it from the name.
96+
else:
97+
bundle = DEFAULT_REVERSE_DOMAIN_ID
98+
app_name = make_app_name(name, f"Name {name!r}")
99+
100+
return bundle, app_name
101+
102+
103+
# Create a Briefcase configuration file. Using a full TOML writer rather than a Jinja
104+
# template allows us to avoid escaping strings everywhere.
105+
def write_pyproject_toml(tmp_dir, info):
106+
name, version = get_name_version(info)
107+
bundle, app_name = get_bundle_app_name(info, name)
108+
109+
config = {
110+
"project_name": name,
111+
"bundle": bundle,
112+
"version": version,
113+
"license": ({"file": info["license_file"]} if "license_file" in info else {"text": ""}),
114+
"app": {
115+
app_name: {
116+
"formal_name": f"{info['name']} {info['version']}",
117+
"description": "", # Required, but not used in the installer.
118+
"external_package_path": EXTERNAL_PACKAGE_PATH,
119+
"use_full_install_path": False,
120+
"install_launcher": False,
121+
"post_install_script": str(BRIEFCASE_DIR / "run_installation.bat"),
122+
}
123+
},
124+
}
125+
126+
if "company" in info:
127+
config["author"] = info["company"]
128+
129+
(tmp_dir / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}}))
130+
131+
132+
def create(info, verbose=False):
133+
tmp_dir = Path(tempfile.mkdtemp())
134+
write_pyproject_toml(tmp_dir, info)
135+
136+
external_dir = tmp_dir / EXTERNAL_PACKAGE_PATH
137+
external_dir.mkdir()
138+
preconda.write_files(info, external_dir)
139+
preconda.copy_extra_files(info.get("extra_files", []), external_dir)
140+
141+
download_dir = Path(info["_download_dir"])
142+
pkgs_dir = external_dir / "pkgs"
143+
for dist in info["_dists"]:
144+
shutil.copy(download_dir / filename_dist(dist), pkgs_dir)
145+
146+
copy_conda_exe(external_dir, "_conda.exe", info["_conda_exe"])
147+
148+
briefcase = Path(sysconfig.get_path("scripts")) / "briefcase.exe"
149+
if not briefcase.exists():
150+
raise FileNotFoundError(
151+
f"Dependency 'briefcase' does not seem to be installed.\nTried: {briefcase}"
152+
)
153+
154+
logger.info("Building installer")
155+
run(
156+
[briefcase, "package"] + (["-v"] if verbose else []),
157+
cwd=tmp_dir,
158+
check=True,
159+
)
160+
161+
dist_dir = tmp_dir / "dist"
162+
msi_paths = list(dist_dir.glob("*.msi"))
163+
if len(msi_paths) != 1:
164+
raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}")
165+
166+
outpath = Path(info["_outpath"])
167+
outpath.unlink(missing_ok=True)
168+
shutil.move(msi_paths[0], outpath)
169+
170+
if not info.get("_debug"):
171+
shutil.rmtree(tmp_dir)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
set PREFIX=%cd%
2+
_conda constructor --prefix %PREFIX% --extract-conda-pkgs
3+
4+
set CONDA_PROTECT_FROZEN_ENVS=0
5+
set CONDA_ROOT_PREFIX=%PREFIX%
6+
set CONDA_SAFETY_CHECKS=disabled
7+
set CONDA_EXTRA_SAFETY_CHECKS=no
8+
set CONDA_PKGS_DIRS=%PREFIX%\pkgs
9+
10+
_conda install --offline --file %PREFIX%\conda-meta\initial-state.explicit.txt -yp %PREFIX%

constructor/data/construct.schema.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@
224224
"enum": [
225225
"all",
226226
"exe",
227+
"msi",
227228
"pkg",
228229
"sh"
229230
],
@@ -824,7 +825,7 @@
824825
}
825826
],
826827
"default": null,
827-
"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.",
828+
"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.",
828829
"title": "Installer Type"
829830
},
830831
"keep_pkgs": {
@@ -1104,7 +1105,7 @@
11041105
}
11051106
],
11061107
"default": null,
1107-
"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)",
1108+
"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`.",
11081109
"title": "Reverse Domain Identifier"
11091110
},
11101111
"script_env_variables": {

constructor/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
def get_installer_type(info):
4141
osname, unused_arch = info["_platform"].split("-")
4242

43-
os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe",)}
43+
os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe", "msi")}
4444
all_allowed = set(sum(os_allowed.values(), ("all",)))
4545

4646
itype = info.get("installer_type")
@@ -357,6 +357,10 @@ def is_conda_meta_frozen(path_str: str) -> bool:
357357
from .winexe import create as winexe_create
358358

359359
create = winexe_create
360+
elif itype == "msi":
361+
from .briefcase import create as briefcase_create
362+
363+
create = briefcase_create
360364
info["installer_type"] = itype
361365
info["_outpath"] = abspath(join(output_dir, get_output_filename(info)))
362366
create(info, verbose=verbose)

constructor/osxpkg.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from .jinja import render_template
2222
from .signing import CodeSign
2323
from .utils import (
24+
DEFAULT_REVERSE_DOMAIN_ID,
2425
add_condarc,
2526
approx_size_kb,
2627
copy_conda_exe,
@@ -392,7 +393,7 @@ def fresh_dir(dir_path):
392393
def pkgbuild(name, identifier=None, version=None, install_location=None):
393394
"see `man pkgbuild` for the meaning of optional arguments"
394395
if identifier is None:
395-
identifier = "io.continuum"
396+
identifier = DEFAULT_REVERSE_DOMAIN_ID
396397
args = [
397398
"pkgbuild",
398399
"--root",

constructor/utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from conda.models.version import VersionOrder
2727
from ruamel.yaml import YAML
2828

29+
DEFAULT_REVERSE_DOMAIN_ID = "io.continuum"
30+
2931
logger = logging.getLogger(__name__)
3032
yaml = YAML(typ="rt")
3133
yaml.default_flow_style = False

dev/extra-requirements-windows.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
conda-forge::briefcase>=0.3.26
12
conda-forge::nsis>=3.08=*_log_*
3+
conda-forge::tomli-w>=1.2.0

0 commit comments

Comments
 (0)