Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
210 changes: 210 additions & 0 deletions constructor/briefcase.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"""
Logic to build installers using Briefcase.
"""

import logging
import re
import shutil
import sys
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)

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


def create_install_options_list(info: dict) -> list[dict]:
"""Returns a list of dicts with data formatted for the installation options page."""
options = []
register_python = info.get("register_python", True)
if register_python:
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
options.append(
{
"name": "register_python",
"title": f"Register {info['name']} as my default Python {python_version}.",
"description": "Allows other programs, such as VSCode, PyCharm, etc. to automatically "
f"detect {info['name']} as the primary Python {python_version} on the system.",
"default": info.get("register_python_default", False),
}
)
initialize_conda = info.get("initialize_conda", "classic")
if initialize_conda:
# TODO: How would we distinguish between condabin/classic in the UI?
if initialize_conda == "condabin":
description = "Adds condabin, which only contains the 'conda' executables, to PATH. "
"Does not require special shortcuts but activation needs "
"to be performed manually."
else:
description = "NOT recommended. This can lead to conflicts with other applications. "
"Instead, use the Commmand Prompt and Powershell menus added to the Windows Start Menu."
options.append(
{
"name": "initialize_conda",
"title": "Add installation to my PATH environment variable",
"description": description,
"default": info.get("initialize_by_default", False),
}
)

return options


# 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"),
"install_option": create_install_options_list(info),
}
},
}

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)
10 changes: 10 additions & 0 deletions constructor/briefcase/run_installation.bat
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%
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",
"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
2 changes: 2 additions & 0 deletions dev/extra-requirements-windows.txt
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
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
Loading