-
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
Changes from all commits
c6c4134
f719293
201a3ed
a12eb59
52b3d34
0101c62
8e79869
93ce7aa
2a99674
2cd9d19
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,171 @@ | ||
| """ | ||
| 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" | ||
|
|
||
| # 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): | ||
| 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 | ||
| # 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+)?", | ||
| version.lower().replace("-", "."), | ||
| ) | ||
| ) | ||
| if not matches: | ||
| 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() | ||
|
|
||
| # 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
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. 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 |
||
|
|
||
| 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, | ||
| 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 | ||
| ): | ||
| 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 = make_app_name(name, f"Name {name!r}") | ||
|
|
||
| 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" | ||
| 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 []), | ||
| 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,10 @@ | ||
| set PREFIX=%cd% | ||
| _conda constructor --prefix %PREFIX% --extract-conda-pkgs | ||
|
|
||
| set CONDA_PROTECT_FROZEN_ENVS=0 | ||
| set CONDA_ROOT_PREFIX=%PREFIX% | ||
| set CONDA_SAFETY_CHECKS=disabled | ||
| set CONDA_EXTRA_SAFETY_CHECKS=no | ||
| set CONDA_PKGS_DIRS=%PREFIX%\pkgs | ||
|
|
||
| _conda install --offline --file %PREFIX%\conda-meta\initial-state.explicit.txt -yp %PREFIX% |
| 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 |
|---|---|---|
| @@ -1 +1,3 @@ | ||
| conda-forge::briefcase>=0.3.26 | ||
| conda-forge::nsis>=3.08=*_log_* | ||
| conda-forge::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
Uh oh!
There was an error while loading. Please reload this page.
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.
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
versiongets moved into thenamefor 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
versionto something likepy313_25.1.2-3. It's better to transform this into25.1.2.3rather than313.25.1.2.3.Uh oh!
There was an error while loading. Please reload this page.
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.
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.
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.
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.