Coverage for src/ezcompiler/adapters/_innosetup_installer.py: 74.39%

62 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-04 11:22 +0000

1# /////////////////////////////////////////////////////////////// 

2# INNOSETUP_INSTALLER - Inno Setup installer adapter 

3# Project: ezcompiler 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Inno Setup installer - Adapter building a Windows setup.exe from a compiled 

8bundle via the external ``ISCC.exe`` binary (Inno Setup 6). 

9 

10No PyPI dependency: the ``.iss`` script is rendered from ezcompiler's own 

11template using the project's ``#PLACEHOLDER#`` string-substitution 

12convention, then compiled by shelling out to ISCC.exe. 

13""" 

14 

15from __future__ import annotations 

16 

17# /////////////////////////////////////////////////////////////// 

18# IMPORTS 

19# /////////////////////////////////////////////////////////////// 

20import os 

21import shutil 

22import subprocess 

23from pathlib import Path 

24 

25from ..shared.exceptions import ( 

26 InstallerBuildError, 

27 InstallerConfigError, 

28 IsccNotFoundError, 

29) 

30from .base_installer import BaseInstaller 

31 

32# /////////////////////////////////////////////////////////////// 

33# CONSTANTS 

34# /////////////////////////////////////////////////////////////// 

35 

36_TEMPLATE_PATH = ( 

37 Path(__file__).parent.parent 

38 / "assets" 

39 / "templates" 

40 / "installer" 

41 / "setup.iss.template" 

42) 

43_ISDL_URL = "https://jrsoftware.org/isdl.php" 

44 

45# /////////////////////////////////////////////////////////////// 

46# CLASSES 

47# /////////////////////////////////////////////////////////////// 

48 

49 

50class InnoSetupInstaller(BaseInstaller): 

51 """Installer backed by the Inno Setup compiler (ISCC.exe).""" 

52 

53 # //////////////////////////////////////////////// 

54 # BUILD 

55 # //////////////////////////////////////////////// 

56 

57 def build( 

58 self, bundle_dir: Path, app_name: str, version: str, output_dir: Path 

59 ) -> Path: 

60 """Render the .iss script and compile it into a setup.exe.""" 

61 self._validate_bundle_dir(bundle_dir) 

62 

63 iscc_path = self._config.get("iscc_path") or self._find_iscc() 

64 if not iscc_path: 

65 raise IsccNotFoundError( 

66 "ISCC.exe (Inno Setup 6) not found in PATH or default install " 

67 f"locations. Download it from {_ISDL_URL}." 

68 ) 

69 

70 iss_content = self._render_iss(bundle_dir, app_name, version, output_dir) 

71 output_dir.mkdir(parents=True, exist_ok=True) 

72 iss_path = output_dir / f"{app_name}.iss" 

73 iss_path.write_text(iss_content, encoding="utf-8") 

74 

75 result = subprocess.run( 

76 [str(iscc_path), str(iss_path)], 

77 capture_output=True, 

78 check=False, 

79 ) 

80 if result.returncode != 0: 

81 stderr = result.stderr.decode("utf-8", errors="replace") 

82 stdout = result.stdout.decode("utf-8", errors="replace") 

83 raise InstallerBuildError( 

84 f"ISCC.exe failed (exit {result.returncode}): {stderr or stdout}" 

85 ) 

86 

87 setup_exe = output_dir / f"{app_name}-{version}-setup.exe" 

88 if not setup_exe.is_file(): 

89 raise InstallerBuildError( 

90 f"ISCC.exe succeeded but expected installer not found at {setup_exe}. " 

91 "If using a custom installer_iss_path, ensure its OutputBaseFilename " 

92 f"matches '{app_name}-{version}-setup'." 

93 ) 

94 return setup_exe 

95 

96 # //////////////////////////////////////////////// 

97 # ISCC DETECTION 

98 # //////////////////////////////////////////////// 

99 

100 def _find_iscc(self) -> Path | None: 

101 """Locate ISCC.exe in PATH, then default install locations.""" 

102 found = shutil.which("ISCC.exe") or shutil.which("ISCC") 

103 if found: 

104 return Path(found) 

105 

106 for env_var in ("ProgramFiles(x86)", "ProgramFiles"): 

107 base = os.environ.get(env_var) 

108 if not base: 

109 continue 

110 candidate = Path(base) / "Inno Setup 6" / "ISCC.exe" 

111 if candidate.is_file(): 

112 return candidate 

113 

114 return None 

115 

116 # //////////////////////////////////////////////// 

117 # TEMPLATE RENDERING 

118 # //////////////////////////////////////////////// 

119 

120 def _render_iss( 

121 self, bundle_dir: Path, app_name: str, version: str, output_dir: Path 

122 ) -> str: 

123 """Render the .iss script content from the template or a user override.""" 

124 iss_path_override = self._config.get("iss_path") 

125 if iss_path_override is not None: 

126 iss_path_override = Path(iss_path_override) 

127 if not iss_path_override.is_file(): 127 ↛ 131line 127 didn't jump to line 131 because the condition on line 127 was always true

128 raise InstallerConfigError( 

129 f"installer_iss_path not found: {iss_path_override}" 

130 ) 

131 template = iss_path_override.read_text(encoding="utf-8") 

132 else: 

133 template = _TEMPLATE_PATH.read_text(encoding="utf-8") 

134 

135 # ISCC resolves relative paths against the .iss file's own directory 

136 # (output_dir), not the process cwd — so a relative icon path would be 

137 # looked up in the wrong place and fail with exit 2. Absolutize against 

138 # cwd, matching how compilers consume config.icon. 

139 icon = self._config.get("icon", "") 

140 icon_line = f"SetupIconFile={Path(icon).resolve()}" if icon else "" 

141 company_name = self._config.get("company_name", app_name) 

142 main_exe = self._config.get("main_exe", f"{app_name}.exe") 

143 

144 # Per-user install (no admin rights, %LOCALAPPDATA%\Programs) is 

145 # required for tufup in-place auto-update, which cannot self-elevate 

146 # to overwrite a Program Files install. 

147 per_user = self._config.get("per_user", False) 

148 default_dir = ( 

149 r"{localappdata}\Programs\{#MyAppName}" 

150 if per_user 

151 else r"{autopf}\{#MyAppName}" 

152 ) 

153 privileges_required = "lowest" if per_user else "admin" 

154 

155 replacements = { 

156 "#APP_NAME#": app_name, 

157 "#VERSION#": version, 

158 "#COMPANY_NAME#": company_name, 

159 # ISCC resolves relative paths against the .iss file's own 

160 # directory (output_dir), not the process cwd — must be absolute. 

161 "#BUNDLE_DIR#": str(bundle_dir.resolve()), 

162 "#OUTPUT_DIR#": str(output_dir.resolve()), 

163 "#ICON_LINE#": icon_line, 

164 "#MAIN_EXE#": main_exe, 

165 "#DEFAULT_DIR#": default_dir, 

166 "#PRIVILEGES_REQUIRED#": privileges_required, 

167 } 

168 

169 result = template 

170 for placeholder, value in replacements.items(): 

171 result = result.replace(placeholder, str(value)) 

172 return result 

173 

174 # //////////////////////////////////////////////// 

175 # METADATA 

176 # //////////////////////////////////////////////// 

177 

178 def get_installer_name(self) -> str: 

179 return "InnoSetup"