-
Notifications
You must be signed in to change notification settings - Fork 176
Initial MSI implementation, based on Briefcase #1084
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: briefcase-integration
Are you sure you want to change the base?
Changes from all commits
c6c4134
f719293
201a3ed
a12eb59
52b3d34
9286bb4
92735f0
efe3ef3
0d0063b
a32f560
6a23b55
3d11d7f
fc95186
2aeddae
946f89a
9be1852
6b94025
5d3c3cd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So, for something like |
||
|
|
||
| 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since |
||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| _conda constructor --prefix . --extract-conda-pkgs | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's use absolute paths instead of relative paths. |
||
|
|
||
| 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 . | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -224,6 +224,7 @@ | |
| "enum": [ | ||
| "all", | ||
| "exe", | ||
| "msi", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This change should be applied in
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||
| "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": { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,3 +10,4 @@ dependencies: | |
| - jinja2 | ||
| - jsonschema >=4 | ||
| - pydantic 2.11.* | ||
| - tomli-w >=1.2.0 | ||
There was a problem hiding this comment.
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:
constructor/examples/scripts/construct.yaml
Line 5 in 170417a
There was a problem hiding this comment.
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