1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
|
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
"""Make a WiX Toolset .wxs file for the DataLab Windows installer.
History:
- Version 1.0.0 (2024-04-01)
Initial version.
"""
# TODO: [P4] Localization, eventually.
import argparse
import os
import os.path as osp
import re
import uuid
import xml.etree.ElementTree as ET
COUNT = 0
def generate_id() -> str:
"""Generate an ID for a WiX Toolset XML element."""
global COUNT
COUNT += 1
return f"ID_{COUNT:04d}"
def insert_text_after(text: str, containing: str, content: str) -> str:
"""Insert line of text after the line containing a specific text."""
if os.linesep in content:
linesep = os.linesep
elif "\r\n" in content:
linesep = "\r\n"
else:
linesep = "\n"
lines = content.splitlines()
for i_line, line in enumerate(lines):
if containing in line:
lines.insert(i_line + 1, text)
break
return linesep.join(lines)
def make_wxs(product_name: str, version: str) -> None:
"""Make a .wxs file for the DataLab Windows installer."""
# MSI Version does not support labels, at least not in a way compatible with
# Python packaging (e.g., "0.16.dev0" is not a valid MSI version).
#
# Here is the conversion we must do:
# - "0.16.post1" -> "0.16.1"
# - "0.16.post2" -> "0.16.1"
# - "0.16.dev0" -> "0.16.0"
# - "0.16.dev1" -> "0.16.0"
# - "0.16.alpha1" -> "0.16.0"
# - "0.16.alpha2" -> "0.16.0"
# - "0.16.beta1" -> "0.16.0"
# - "0.16.beta2" -> "0.16.0"
# - "0.16.rc1" -> "0.16.0"
# - "0.16.rc2" -> "0.16.0"
#
version_msi = re.sub(r"\.post\d+$", ".1", version)
version_msi = re.sub(r"\.dev\d+$", ".0", version_msi)
version_msi = re.sub(r"(rc|a|b)\d+$", ".0", version_msi)
# Generate version-based names for folders
# Extract major.minor version (e.g., "1.0" from "1.0.0")
major_minor = ".".join(version_msi.split(".")[:2])
# Extract major version for technical folder name (e.g., "1" from "1.0.0")
major_version = version_msi.split(".")[0]
# Technical folder name: "DataLab_v1"
install_folder_name = f"{product_name}_v{major_version}"
# User-friendly folder name: "DataLab 1.0"
display_folder_name = f"{product_name} {major_minor}"
# Generate a deterministic ProductCode based on MAJOR version only
# All versions with the same major version (e.g., 1.0.0, 1.0.1, 1.1.0) will have
# the same ProductCode, preventing installation of multiple v1.x versions.
# Different major versions (v1.x vs v2.x) will have different ProductCodes,
# allowing them to be installed side-by-side.
upgrade_code = uuid.UUID("e7a3f5c1-9d84-4b2a-a6f1-2c5d8e9b7a31")
product_code = str(uuid.uuid5(upgrade_code, f"v{major_version}"))
wix_dir = osp.abspath(osp.dirname(__file__))
proj_dir = osp.join(wix_dir, os.pardir)
dist_dir = osp.join(proj_dir, "dist", product_name)
wxs_path = osp.join(wix_dir, f"generic-{product_name}.wxs")
output_path = osp.join(wix_dir, f"{product_name}-{version}.wxs")
dir_ids: dict[str, str] = {}
file_ids: dict[str, str] = {}
files_dict: dict[str, list[str]] = {}
dir_str_list: list[str] = []
comp_str_list: list[str] = []
for pth in os.listdir(dist_dir):
root_dir = osp.join(dist_dir, pth)
if not osp.isdir(root_dir):
continue
dpath_list: list[str] = []
for root, dirs, filenames in os.walk(root_dir):
for dpath in dirs:
relpath = osp.relpath(osp.join(root, dpath), proj_dir)
dpath_list.append(relpath)
dir_ids[relpath] = generate_id()
files_dict.setdefault(osp.dirname(relpath), [])
for filename in filenames:
relpath = osp.relpath(osp.join(root, filename), proj_dir)
file_ids[relpath] = generate_id()
files_dict.setdefault(osp.dirname(relpath), []).append(relpath)
# Create the base directory structure in XML:
base_name = osp.basename(root_dir)
base_path = osp.relpath(root_dir, proj_dir)
base_id = dir_ids[base_path] = generate_id()
dir_xml = ET.Element("Directory", Id=base_id, Name=base_name)
# Nesting directories, recursively, in XML:
for dpath in sorted(dpath_list):
dname = osp.basename(dpath)
parent = dir_xml
for element in parent.iter():
if element.get("Id") == dir_ids[osp.dirname(dpath)]:
parent = element
break
else:
raise ValueError(f"Parent directory not found for {dpath}")
ET.SubElement(parent, "Directory", Id=dir_ids[dpath], Name=dname)
space = " " * 4
ET.indent(dir_xml, space=space, level=4)
dir_str_list.append(space * 4 + ET.tostring(dir_xml, encoding="unicode"))
# Create additionnal components for each file in the directory structure:
for dpath in sorted([base_path] + dpath_list):
did = dir_ids[dpath]
files = files_dict.get(dpath, [])
if files:
# This is a directory with files, so we need to create components:
for path in files:
fid = file_ids[path]
guid = str(uuid.uuid4())
comp_xml = ET.Element("Component", Id=fid, Directory=did, Guid=guid)
ET.SubElement(comp_xml, "File", Source=path, KeyPath="yes")
ET.indent(comp_xml, space=space, level=3)
comp_str = space * 3 + ET.tostring(comp_xml, encoding="unicode")
comp_str_list.append(comp_str)
elif dpath != base_path:
# This is an empty directory, so we need to create a folder:
guid = str(uuid.uuid4())
cdid = f"CreateFolder_{did}"
comp_xml = ET.Element("Component", Id=cdid, Directory=did, Guid=guid)
ET.SubElement(comp_xml, "CreateFolder")
ET.indent(comp_xml, space=space, level=3)
comp_str = space * 3 + ET.tostring(comp_xml, encoding="unicode")
comp_str_list.append(comp_str)
dir_str = "\n".join(dir_str_list).replace("><", ">\n<")
# print("Directory structure:\n", dir_str)
comp_str = "\n".join(comp_str_list).replace("><", ">\n<")
# print("Component structure:\n", comp_str)
# Create the .wxs file:
with open(wxs_path, "r", encoding="utf-8") as fd:
wxs = fd.read()
wxs = insert_text_after(dir_str, "<!-- Automatically inserted directories -->", wxs)
wxs = insert_text_after(comp_str, "<!-- Automatically inserted components -->", wxs)
wxs = wxs.replace("{version}", version_msi)
wxs = wxs.replace("{product_code}", product_code)
wxs = wxs.replace("{install_folder_name}", install_folder_name)
wxs = wxs.replace("{display_folder_name}", display_folder_name)
with open(output_path, "w", encoding="utf-8") as fd:
fd.write(wxs)
print("Successfully created:", output_path)
def run() -> None:
"""Run the script."""
parser = argparse.ArgumentParser(
description="Make a WiX Toolset .wxs file for the DataLab Windows installer."
)
parser.add_argument("product_name", help="Product name")
parser.add_argument("version", help="Product version")
args = parser.parse_args()
make_wxs(args.product_name, args.version)
if __name__ == "__main__":
if len(os.sys.argv) == 1:
# For testing purposes:
make_wxs("DataLab", "0.14.2")
else:
run()
# After making the .wxs file, run the following command to create the .msi file:
# wix build .\wix\DataLab.wxs -ext WixToolset.UI.wixext
|