Files

1199 lines
39 KiB
Python

"""Implements the 'bdist_msi' command (create Windows installer packages)."""
from __future__ import annotations
import contextlib
import logging
import os
import re
import shutil
import warnings
from sysconfig import get_platform
from typing import ClassVar, ContextManager
from packaging.version import Version
from setuptools import Command
from cx_Freeze._compat import IS_MINGW, IS_WINDOWS
from cx_Freeze.exception import OptionError
__all__ = ["bdist_msi"]
if IS_MINGW or IS_WINDOWS:
@contextlib.contextmanager
def suppress_known_deprecation() -> ContextManager:
with warnings.catch_warnings():
warnings.filterwarnings("ignore", "'msilib' is deprecated")
yield
with suppress_known_deprecation():
from msilib import ( # pylint: disable=deprecated-module
CAB,
PID_AUTHOR,
PID_COMMENTS,
PID_KEYWORDS,
Binary,
Dialog,
Directory,
Feature,
add_data,
add_tables,
gen_uuid,
init_database,
make_id,
schema,
sequence,
)
from cx_Freeze.command._pydialog import PyDialog
# force the remove existing products action to happen first since Windows
# installer appears to be braindead and doesn't handle files shared between
# different "products" very well
install_execute_sequence = sequence.InstallExecuteSequence
for index, info in enumerate(install_execute_sequence):
if info[0] == "RemoveExistingProducts":
install_execute_sequence[index] = (info[0], info[1], 1450)
class bdist_msi(Command):
"""Create a Microsoft Installer (.msi) binary distribution."""
description = __doc__
user_options: ClassVar[list[tuple[str, str | None, str]]] = [
(
"bdist-dir=",
None,
"temporary directory for creating the distribution",
),
("dist-dir=", "d", "directory to put final built distributions in"),
(
"install-script=",
None,
"basename of installation script to be run after "
"installation or before deinstallation",
),
(
"keep-temp",
"k",
"keep the pseudo-installation tree around after "
"creating the distribution archive",
),
(
"pre-install-script=",
None,
"Fully qualified filename of a script to be run before "
"any files are installed. This script need not be in the "
"distribution",
),
(
"skip-build",
None,
"skip rebuilding everything (for testing/debugging)",
),
# cx_Freeze specific
("add-to-path=", None, "add target dir to PATH environment variable"),
("all-users=", None, "installation for all users (or just me)"),
(
"data=",
None,
"dictionary of data indexed by table name, and each value is a "
"tuple to include in table",
),
("directories=", None, "list of 3-tuples of directories to create"),
("environment-variables=", None, "list of environment variables"),
("extensions=", None, "Extensions for which to register Verbs"),
("initial-target-dir=", None, "initial target directory"),
("install-icon=", None, "icon path to add/remove programs "),
("product-code=", None, "product code to use"),
(
"summary-data=",
None,
"Dictionary of data to include in msi summary data stream. "
'Allowed keys are "author", "comments", "keywords".',
),
("target-name=", None, "name of the file to create"),
("target-version=", None, "version of the file to create"),
("upgrade-code=", None, "upgrade code to use"),
(
"license-file=",
None,
"rft formatted license file to include in the installer",
),
]
boolean_options: ClassVar[list[str]] = [
"keep-temp",
"skip-build",
]
x = y = 50
width = 370
height = 300
title = "[ProductName] Setup"
modeless = 1
modal = 3
_binary_columns: ClassVar[dict[str, int]] = {
"Binary": 1,
"Icon": 1,
"Patch": 4,
"SFPCatalog": 1,
"MsiDigitalCertificate": 1,
"MsiPatchHeaders": 1,
}
def add_config(self) -> None:
if self.add_to_path:
path = "Path"
if self.all_users:
path = "=-*" + path
add_data(
self.db,
"Environment",
[("E_PATH", path, r"[~];[TARGETDIR]", "TARGETDIR")],
)
if self.directories:
add_data(self.db, "Directory", self.directories)
if self.environment_variables:
add_data(self.db, "Environment", self.environment_variables)
# This is needed in case the AlwaysInstallElevated policy is set.
# Otherwise installation will not end up in TARGETDIR.
add_data(
self.db,
"Property",
[("SecureCustomProperties", "TARGETDIR;REINSTALLMODE")],
)
add_data(
self.db,
"CustomAction",
[
(
"A_SET_TARGET_DIR",
256 + 51,
"TARGETDIR",
self.initial_target_dir,
),
(
"A_SET_REINSTALL_MODE",
256 + 51,
"REINSTALLMODE",
"amus",
),
],
)
add_data(
self.db,
"InstallExecuteSequence",
[
("A_SET_TARGET_DIR", 'TARGETDIR=""', 401),
("A_SET_REINSTALL_MODE", 'REINSTALLMODE=""', 402),
],
)
add_data(
self.db,
"InstallUISequence",
[
("PrepareDlg", None, 140),
("A_SET_TARGET_DIR", 'TARGETDIR=""', 401),
("A_SET_REINSTALL_MODE", 'REINSTALLMODE=""', 402),
("SelectDirectoryDlg", "not Installed", 1230),
("LicenseAgreementDlg", "not Installed", 1240),
(
"MaintenanceTypeDlg",
"Installed and not Resume and not Preselected",
1250,
),
("ProgressDlg", None, 1280),
],
)
for idx, executable in enumerate(self.distribution.executables):
if (
executable.shortcut_name is not None
and executable.shortcut_dir is not None
):
base_name = os.path.basename(executable.target_name)
add_data(
self.db,
"Shortcut",
[
(
f"S_APP_{idx}",
os.fspath(executable.shortcut_dir),
executable.shortcut_name,
"TARGETDIR",
f"[TARGETDIR]{base_name}",
None,
None,
None,
None,
None,
None,
"TARGETDIR",
)
],
)
for table_name, table_data in self.data.items():
col = self._binary_columns.get(table_name)
if col is not None:
data = [
(*row[:col], Binary(row[col]), *row[col + 1 :])
for row in table_data
]
else:
data = table_data
add_data(self.db, table_name, data)
# If provided, add data to MSI's summary information stream
if len(self.summary_data) > 0:
for k in self.summary_data:
if k not in ["author", "comments", "keywords"]:
msg = f"Unknown key provided in summary-data: {k!r}"
raise OptionError(msg)
summary_info = self.db.GetSummaryInformation(5)
if "author" in self.summary_data:
summary_info.SetProperty(
PID_AUTHOR, self.summary_data["author"]
)
if "comments" in self.summary_data:
summary_info.SetProperty(
PID_COMMENTS, self.summary_data["comments"]
)
if "keywords" in self.summary_data:
summary_info.SetProperty(
PID_KEYWORDS, self.summary_data["keywords"]
)
summary_info.Persist()
def add_cancel_dialog(self) -> None:
dialog = Dialog(
self.db,
"CancelDlg",
50,
10,
260,
85,
3,
self.title,
"No",
"No",
"No",
)
dialog.text(
"Text",
48,
15,
194,
30,
3,
"Are you sure you want to cancel [ProductName] installation?",
)
button = dialog.pushbutton("Yes", 72, 57, 56, 17, 3, "Yes", "No")
button.event("EndDialog", "Exit")
button = dialog.pushbutton("No", 132, 57, 56, 17, 3, "No", "Yes")
button.event("EndDialog", "Return")
def add_error_dialog(self) -> None:
dialog = Dialog(
self.db,
"ErrorDlg",
50,
10,
330,
101,
65543,
self.title,
"ErrorText",
None,
None,
)
dialog.text("ErrorText", 50, 9, 280, 48, 3, "")
for text, pos in [
("No", 120),
("Yes", 240),
("Abort", 0),
("Cancel", 42),
("Ignore", 81),
("Ok", 159),
("Retry", 198),
]:
button = dialog.pushbutton(text[0], pos, 72, 81, 21, 3, text, None)
button.event("EndDialog", f"Error{text}")
def add_exit_dialog(self) -> None:
dialog = PyDialog(
self.db,
"ExitDialog",
self.x,
self.y,
self.width,
self.height,
self.modal,
self.title,
"Finish",
"Finish",
"Finish",
)
dialog.title("Completing the [ProductName] installer")
dialog.backbutton("< Back", "Finish", active=False)
dialog.cancelbutton("Cancel", "Back", active=False)
dialog.text(
"Description",
15,
235,
320,
20,
0x30003,
"Click the Finish button to exit the installer.",
)
button = dialog.nextbutton("Finish", "Cancel", name="Finish")
button.event("EndDialog", "Return")
def add_fatal_error_dialog(self) -> None:
dialog = PyDialog(
self.db,
"FatalError",
self.x,
self.y,
self.width,
self.height,
self.modal,
self.title,
"Finish",
"Finish",
"Finish",
)
dialog.title("[ProductName] installer ended prematurely")
dialog.backbutton("< Back", "Finish", active=False)
dialog.cancelbutton("Cancel", "Back", active=False)
dialog.text(
"Description1",
15,
70,
320,
80,
0x30003,
"[ProductName] setup ended prematurely because of an error. "
"Your system has not been modified. To install this program "
"at a later time, please run the installation again.",
)
dialog.text(
"Description2",
15,
155,
320,
20,
0x30003,
"Click the Finish button to exit the installer.",
)
button = dialog.nextbutton("Finish", "Cancel", name="Finish")
button.event("EndDialog", "Exit")
def add_files(self) -> None:
database = self.db
cab = CAB("distfiles")
feature = Feature(
database,
"default",
"Default Feature",
"Everything",
1,
directory="TARGETDIR",
)
feature.set_current()
rootdir = os.path.abspath(self.bdist_dir)
root = Directory(
database, cab, None, rootdir, "TARGETDIR", "SourceDir"
)
database.Commit()
todo = [root]
while todo:
directory = todo.pop()
for file in os.listdir(directory.absolute):
sep_comp = self.separate_components.get(
os.path.relpath(
os.path.join(directory.absolute, file),
self.bdist_dir,
)
)
if sep_comp is not None:
restore_component = directory.component
directory.start_component(
component=sep_comp,
flags=0,
feature=feature,
keyfile=file,
)
directory.add_file(file)
directory.component = restore_component
elif os.path.isdir(os.path.join(directory.absolute, file)):
sfile = directory.make_short(file)
new_dir = Directory(
database, cab, directory, file, file, f"{sfile}|{file}"
)
todo.append(new_dir)
else:
directory.add_file(file)
cab.commit(database)
def add_files_in_use_dialog(self) -> None:
dialog = PyDialog(
self.db,
"FilesInUse",
self.x,
self.y,
self.width,
self.height,
19,
self.title,
"Retry",
"Retry",
"Retry",
bitmap=False,
)
dialog.text(
"Title", 15, 6, 200, 15, 0x30003, r"{\DlgFontBold8}Files in Use"
)
dialog.text(
"Description",
20,
23,
280,
20,
0x30003,
"Some files that need to be updated are currently in use.",
)
dialog.text(
"Text",
20,
55,
330,
50,
3,
"The following applications are using files that need to be "
"updated by this setup. Close these applications and then "
"click Retry to continue the installation or Cancel to exit it.",
)
dialog.control(
"List",
"ListBox",
20,
107,
330,
130,
7,
"FileInUseProcess",
None,
None,
None,
)
button = dialog.backbutton("Exit", "Ignore", name="Exit")
button.event("EndDialog", "Exit")
button = dialog.nextbutton("Ignore", "Retry", name="Ignore")
button.event("EndDialog", "Ignore")
button = dialog.cancelbutton("Retry", "Exit", name="Retry")
button.event("EndDialog", "Retry")
def add_maintenance_type_dialog(self) -> None:
dialog = PyDialog(
self.db,
"MaintenanceTypeDlg",
self.x,
self.y,
self.width,
self.height,
self.modal,
self.title,
"Next",
"Next",
"Cancel",
)
dialog.title("Welcome to the [ProductName] Setup Wizard")
dialog.text(
"BodyText",
15,
63,
330,
42,
3,
"Select whether you want to repair or remove [ProductName].",
)
group = dialog.radiogroup(
"RepairRadioGroup",
15,
108,
330,
60,
3,
"MaintenanceForm_Action",
"",
"Next",
)
group.add("Repair", 0, 18, 300, 17, "&Repair [ProductName]")
group.add("Remove", 0, 36, 300, 17, "Re&move [ProductName]")
dialog.backbutton("< Back", None, active=False)
button = dialog.nextbutton("Finish", "Cancel")
button.event(
"[REINSTALL]", "ALL", 'MaintenanceForm_Action="Repair"', 5
)
button.event(
"[Progress1]", "Repairing", 'MaintenanceForm_Action="Repair"', 6
)
button.event(
"[Progress2]", "repairs", 'MaintenanceForm_Action="Repair"', 7
)
button.event("Reinstall", "ALL", 'MaintenanceForm_Action="Repair"', 8)
button.event("[REMOVE]", "ALL", 'MaintenanceForm_Action="Remove"', 11)
button.event(
"[Progress1]", "Removing", 'MaintenanceForm_Action="Remove"', 12
)
button.event(
"[Progress2]", "removes", 'MaintenanceForm_Action="Remove"', 13
)
button.event("Remove", "ALL", 'MaintenanceForm_Action="Remove"', 14)
button.event(
"EndDialog", "Return", 'MaintenanceForm_Action<>"Change"', 20
)
button = dialog.cancelbutton("Cancel", "RepairRadioGroup")
button.event("SpawnDialog", "CancelDlg")
def add_prepare_dialog(self) -> None:
dialog = PyDialog(
self.db,
"PrepareDlg",
self.x,
self.y,
self.width,
self.height,
self.modeless,
self.title,
"Cancel",
"Cancel",
"Cancel",
)
dialog.text(
"Description",
15,
70,
320,
40,
0x30003,
"Please wait while the installer prepares to guide you through "
"the installation.",
)
dialog.title("Welcome to the [ProductName] installer")
text = dialog.text(
"ActionText", 15, 110, 320, 20, 0x30003, "Pondering..."
)
text.mapping("ActionText", "Text")
text = dialog.text("ActionData", 15, 135, 320, 30, 0x30003, None)
text.mapping("ActionData", "Text")
dialog.backbutton("Back", None, active=False)
dialog.nextbutton("Next", None, active=False)
button = dialog.cancelbutton("Cancel", None)
button.event("SpawnDialog", "CancelDlg")
def add_progress_dialog(self) -> None:
dialog = PyDialog(
self.db,
"ProgressDlg",
self.x,
self.y,
self.width,
self.height,
self.modeless,
self.title,
"Cancel",
"Cancel",
"Cancel",
bitmap=False,
)
dialog.text(
"Title",
20,
15,
200,
15,
0x30003,
r"{\DlgFontBold8}[Progress1] [ProductName]",
)
dialog.text(
"Text",
35,
65,
300,
30,
3,
"Please wait while the installer [Progress2] [ProductName].",
)
dialog.text("StatusLabel", 35, 100, 35, 20, 3, "Status:")
text = dialog.text(
"ActionText", 70, 100, self.width - 70, 20, 3, "Pondering..."
)
text.mapping("ActionText", "Text")
control = dialog.control(
"ProgressBar",
"ProgressBar",
35,
120,
300,
10,
65537,
None,
"Progress done",
None,
None,
)
control.mapping("SetProgress", "Progress")
dialog.backbutton("< Back", "Next", active=False)
dialog.nextbutton("Next >", "Cancel", active=False)
button = dialog.cancelbutton("Cancel", "Back")
button.event("SpawnDialog", "CancelDlg")
def add_properties(self) -> None:
metadata = self.distribution.metadata
props = [
("DistVersion", metadata.get_version()),
("DefaultUIFont", "DlgFont8"),
("ErrorDialog", "ErrorDlg"),
("Progress1", "Install"),
("Progress2", "installs"),
("MaintenanceForm_Action", "Repair"),
("ALLUSERS", "2"),
]
if not self.all_users:
props.append(("MSIINSTALLPERUSER", "1"))
email = metadata.author_email or metadata.maintainer_email
if email:
props.append(("ARPCONTACT", email))
if metadata.url:
props.append(("ARPURLINFOABOUT", metadata.url))
if self.upgrade_code is not None:
if not _is_valid_guid(self.upgrade_code):
msg = "upgrade-code must be in valid GUID format"
raise ValueError(msg)
props.append(("UpgradeCode", self.upgrade_code.upper()))
if self.install_icon:
props.append(("ARPPRODUCTICON", "InstallIcon"))
add_data(self.db, "Property", props)
if self.install_icon:
add_data(
self.db,
"Icon",
[("InstallIcon", Binary(self.install_icon))],
)
def add_select_directory_dialog(self) -> None:
dialog = PyDialog(
self.db,
"SelectDirectoryDlg",
self.x,
self.y,
self.width,
self.height,
self.modal,
self.title,
"Next",
"Next",
"Cancel",
)
dialog.title("Select destination directory")
dialog.backbutton("< Back", None, active=False)
button = dialog.nextbutton("Next >", "Cancel")
button.event("SetTargetPath", "TARGETDIR", ordering=1)
button.event("SpawnWaitDialog", "WaitForCostingDlg", ordering=2)
button.event("EndDialog", "Return", ordering=3)
button = dialog.cancelbutton("Cancel", "DirectoryCombo")
button.event("SpawnDialog", "CancelDlg")
dialog.control(
"DirectoryCombo",
"DirectoryCombo",
15,
70,
272,
80,
393219,
"TARGETDIR",
None,
"DirectoryList",
None,
)
dialog.control(
"DirectoryList",
"DirectoryList",
15,
90,
308,
136,
3,
"TARGETDIR",
None,
"PathEdit",
None,
)
dialog.control(
"PathEdit",
"PathEdit",
15,
230,
306,
16,
3,
"TARGETDIR",
None,
"Next",
None,
)
button = dialog.pushbutton("Up", 306, 70, 18, 18, 3, "Up", None)
button.event("DirectoryListUp", "0")
button = dialog.pushbutton("NewDir", 324, 70, 30, 18, 3, "New", None)
button.event("DirectoryListNew", "0")
def add_text_styles(self) -> None:
add_data(
self.db,
"TextStyle",
[
("DlgFont8", "Tahoma", 9, None, 0),
("DlgFontBold8", "Tahoma", 8, None, 1),
("VerdanaBold10", "Verdana", 10, None, 1),
("VerdanaRed9", "Verdana", 9, 255, 0),
],
)
def add_ui(self) -> None:
self.add_text_styles()
self.add_error_dialog()
self.add_fatal_error_dialog()
self.add_cancel_dialog()
self.add_exit_dialog()
self.add_user_exit_dialog()
self.add_files_in_use_dialog()
self.add_wait_for_costing_dialog()
self.add_prepare_dialog()
self.add_license_dialog()
self.add_select_directory_dialog()
self.add_progress_dialog()
self.add_maintenance_type_dialog()
def add_upgrade_config(self, sversion) -> None:
if self.upgrade_code is not None:
add_data(
self.db,
"Upgrade",
[
(
self.upgrade_code,
None,
sversion,
None,
513,
None,
"REMOVEOLDVERSION",
),
(
self.upgrade_code,
sversion,
None,
None,
257,
None,
"REMOVENEWVERSION",
),
],
)
def add_user_exit_dialog(self) -> None:
dialog = PyDialog(
self.db,
"UserExit",
self.x,
self.y,
self.width,
self.height,
self.modal,
self.title,
"Finish",
"Finish",
"Finish",
)
dialog.title("[ProductName] installer was interrupted")
dialog.backbutton("< Back", "Finish", active=False)
dialog.cancelbutton("Cancel", "Back", active=False)
dialog.text(
"Description1",
15,
70,
320,
80,
0x30003,
"[ProductName] setup was interrupted. Your system has not "
"been modified. To install this program at a later time, "
"please run the installation again.",
)
dialog.text(
"Description2",
15,
155,
320,
20,
0x30003,
"Click the Finish button to exit the installer.",
)
button = dialog.nextbutton("Finish", "Cancel", name="Finish")
button.event("EndDialog", "Exit")
def add_license_dialog(self) -> None:
if self.license_file:
with open(self.license_file, encoding="utf-8") as file:
license_text = file.read()
dialog = PyDialog(
self.db,
"LicenseAgreementDlg",
self.x,
self.y,
self.width,
self.height,
self.modal,
self.title,
"Next",
"Next",
"Cancel",
)
dialog.title("License Agreement")
dialog.backbutton("< Back", None, active=False)
dialog.control(
name="Text",
type="ScrollableText",
x=15,
y=30,
w=self.width - 30,
h=self.height - 100,
attr=3,
text=license_text,
prop=None,
next=None,
help=None,
)
button = dialog.nextbutton(
name="Next",
title="Accept",
tabnext="Cancel",
active=0,
)
button.event("SpawnWaitDialog", "SelectDirectoryDlg", ordering=1)
button.event("EndDialog", "Return", ordering=2)
button = dialog.cancelbutton("Cancel", tabnext="Next")
button.event("SpawnDialog", "CancelDlg")
checkbox = dialog.checkbox(
"LicenseAcceptedCheckbox",
15,
230,
300,
20,
3,
"CheckboxProp",
"I &accept the terms in the License Agreement",
None,
)
# [proptery], value
checkbox.event("[LicenseAcceptedClicked]", "1")
add_data(
self.db,
"ControlCondition",
# Dialog, Control , Action, Condition
[
(
"LicenseAgreementDlg",
"Next",
"Enable",
"LicenseAcceptedClicked",
)
],
)
def add_wait_for_costing_dialog(self) -> None:
dialog = Dialog(
self.db,
"WaitForCostingDlg",
50,
10,
260,
85,
self.modal,
self.title,
"Return",
"Return",
"Return",
)
dialog.text(
"Text",
48,
15,
194,
30,
3,
"Please wait while the installer finishes determining your "
"disk space requirements.",
)
button = dialog.pushbutton(
"Return", 102, 57, 56, 17, 3, "Return", None
)
button.event("EndDialog", "Exit")
def _append_to_data(self, table, *line) -> None:
rows = self.data.setdefault(table, [])
line = tuple(line)
if line not in rows:
rows.append(line)
def initialize_options(self) -> None:
self.bdist_dir = None
self.keep_temp = 0
self.dist_dir = None
self.skip_build = None
self.install_script = None
self.pre_install_script = None
# cx_Freeze specific
self.upgrade_code = None
self.product_code = None
self.add_to_path = None
self.initial_target_dir = None
self.target_name = None
self.target_version = None
self.fullname = None
self.directories = None
self.environment_variables = None
self.data = None
self.summary_data = None
self.install_icon = None
self.all_users = False
self.extensions = None
self.license_file = None
def finalize_options(self) -> None:
self.set_undefined_options("bdist", ("skip_build", "skip_build"))
if self.bdist_dir is None:
bdist_base = self.get_finalized_command("bdist").bdist_base
self.bdist_dir = os.path.join(bdist_base, "msi")
self.set_undefined_options("bdist", ("dist_dir", "dist_dir"))
if self.pre_install_script:
msg = "the pre-install-script feature is not yet implemented"
raise OptionError(msg)
if self.install_script:
for script in self.distribution.scripts:
if self.install_script == os.path.basename(script):
break
else:
msg = (
f"install_script '{self.install_script}' not found in "
"scripts"
)
raise OptionError(msg)
self.install_script_key = None
# cx_Freeze specific
if self.target_name is None:
self.target_name = self.distribution.get_name()
if self.target_version is None and self.distribution.metadata.version:
self.target_version = self.distribution.metadata.version
name = self.target_name
version = self.target_version or self.distribution.get_version()
self.fullname = f"{name}-{version}"
platform = get_platform().replace("win-amd64", "win64")
if self.initial_target_dir is None:
if platform == "win64" or platform.startswith("mingw_x86_64"):
program_files_folder = "ProgramFiles64Folder"
else:
program_files_folder = "ProgramFilesFolder"
self.initial_target_dir = rf"[{program_files_folder}]\{name}"
if self.add_to_path is None:
self.add_to_path = False
if self.directories is None:
self.directories = []
if self.environment_variables is None:
self.environment_variables = []
if self.license_file is not None:
self.license_file = os.path.abspath(self.license_file)
if self.data is None:
self.data = {}
if not isinstance(self.summary_data, dict):
self.summary_data = {}
self.separate_components = {}
for idx, executable in enumerate(self.distribution.executables):
base_name = os.path.basename(executable.target_name)
# Trying to make these names unique from any directory name
self.separate_components[base_name] = make_id(
f"_cx_executable{idx}_{executable}"
)
if self.extensions is None:
self.extensions = []
for extension in self.extensions:
try:
ext = extension["extension"]
verb = extension["verb"]
executable = extension["executable"]
except KeyError:
msg = (
"Each extension must have at least extension, verb, "
"and executable"
)
raise ValueError(msg) from None
try:
component = self.separate_components[executable]
except KeyError:
msg = (
"Executable must be the base target name of one of the "
"distribution's executables"
)
raise ValueError(msg) from None
stem = os.path.splitext(executable)[0]
progid = make_id(f"{name}.{stem}.{version}")
mime = extension.get("mime", None)
# "%1" a better default for argument?
argument = extension.get("argument", None)
context = extension.get("context", f"{self.fullname} {verb}")
# Add rows via self.data to safely ignore duplicates
self._append_to_data(
"ProgId",
progid,
None,
None,
self.distribution.get_description() or "UNKNOWN",
None,
None,
)
self._append_to_data(
"Extension", ext, component, progid, mime, "default"
)
self._append_to_data("Verb", ext, verb, 0, context, argument)
if mime is not None:
self._append_to_data("MIME", mime, ext, "None")
# Registry entries that allow proper display of the app in menu
self._append_to_data(
"Registry",
f"{progid}-name",
-1,
rf"Software\Classes\{progid}",
"FriendlyAppName",
name,
component,
)
self._append_to_data(
"Registry",
f"{progid}-verb-{verb}",
-1,
rf"Software\Classes\{progid}\shell\{verb}",
"FriendlyAppName",
name,
component,
)
self._append_to_data(
"Registry",
f"{progid}-author",
-1,
rf"Software\Classes\{progid}\Application",
"ApplicationCompany",
self.distribution.get_author() or "UNKNOWN",
component,
)
def run(self) -> None:
if not self.skip_build:
self.run_command("build")
# install everything from build directory in a new prefix
install_dir = self.bdist_dir
install = self.reinitialize_command("install", reinit_subcommands=1)
install.prefix = install_dir
install.skip_build = self.skip_build
install.warn_dir = 0
logging.info("installing to %s", install_dir)
install.ensure_finalized()
install.run()
# make msi (by default in dist directory)
self.mkpath(self.dist_dir)
platform = get_platform().replace("win-amd64", "win64")
msi_name: str
if os.path.splitext(self.target_name)[1].lower() == ".msi":
msi_name = self.target_name
elif self.target_version:
msi_name = f"{self.fullname}-{platform}.msi"
else:
msi_name = f"{self.target_name}-{platform}.msi"
installer_name = os.path.join(self.dist_dir, msi_name)
installer_name = os.path.abspath(installer_name)
if os.path.exists(installer_name):
os.unlink(installer_name)
author = self.distribution.metadata.get_contact() or "UNKNOWN"
version = self.target_version or self.distribution.get_version()
# ProductVersion must be strictly numeric
base_version = Version(version).base_version
# msilib is reloaded in order to reset the "_directories" global member
# in that module. That member is used by msilib to prevent any two
# directories from having the same logical name. _directories might
# already have contents due to msilib having been previously used in
# the current instance of the python interpreter -- if so, it could
# prevent the root from getting the logical name TARGETDIR, breaking
# the MSI.
# importlib.reload(msilib)
if self.product_code is None:
self.product_code = gen_uuid()
self.db = init_database(
installer_name,
schema,
self.target_name,
self.product_code,
base_version,
author,
)
add_tables(self.db, sequence)
self.add_properties()
self.add_config()
self.add_upgrade_config(base_version)
self.add_ui()
self.add_files()
self.db.Commit()
self.distribution.dist_files.append(
("bdist_msi", base_version or "any", self.target_name)
)
if not self.keep_temp:
logging.info(
"removing '%s' (and everything under it)", install_dir
)
if not self.dry_run:
try:
shutil.rmtree(install_dir)
except OSError as exc:
logging.warning("error removing %s: %s", install_dir, exc)
# Cause the MSI file to be released. Without this, then if bdist_msi
# is run programmatically from within a larger script, subsequent
# editting of the MSI is blocked.
self.db = None
def _is_valid_guid(code) -> bool:
pattern = re.compile(
r"^\{[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}\}$", re.IGNORECASE
)
return isinstance(code, str) and pattern.match(code) is not None