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

89 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 16:37 +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 

10The ``.iss`` script is rendered once from ``InstallerConfig`` via 

11``_iss_renderer.render_iss`` (committable, version-independent). Volatile 

12values — version, bundle/output paths, and the detected main executable — 

13are never baked into the script: they reach ISCC as ``/D`` command-line 

14defines, computed fresh on every build. 

15""" 

16 

17from __future__ import annotations 

18 

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

20# IMPORTS 

21# /////////////////////////////////////////////////////////////// 

22import copy 

23import logging 

24import os 

25import shutil 

26import subprocess 

27import tempfile 

28from pathlib import Path 

29 

30from ..shared.exceptions import ( 

31 InstallerBuildError, 

32 InstallerConfigError, 

33 IsccNotFoundError, 

34) 

35from ._iss_renderer import render_iss, sanitize_version_info 

36from .base_installer import BaseInstaller 

37 

38# /////////////////////////////////////////////////////////////// 

39# CONSTANTS 

40# /////////////////////////////////////////////////////////////// 

41 

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

43 

44# A hung ISCC process must not freeze the build pipeline indefinitely. 

45ISCC_TIMEOUT_SECONDS = 600 

46 

47_logger = logging.getLogger(__name__) 

48 

49# /////////////////////////////////////////////////////////////// 

50# FUNCTIONS 

51# /////////////////////////////////////////////////////////////// 

52 

53 

54def detect_main_exe(bundle_dir: Path, project_name: str, main_file: str) -> str: 

55 """Detect the main executable at the root of the compiled bundle. 

56 

57 Never guesses among several candidates: ISCC cannot itself detect that 

58 the wrong one was picked, so an ambiguous bundle raises instead of 

59 silently choosing one at random. 

60 

61 Args: 

62 bundle_dir: Directory containing the compiled application. 

63 project_name: Project name; preferred candidate is 

64 ``f"{project_name}.exe"``. 

65 main_file: Entry-point script; fallback candidate is its stem 

66 with a ``.exe`` extension. 

67 

68 Returns: 

69 str: The detected executable's file name. 

70 

71 Raises: 

72 InstallerConfigError: If the bundle has no executable, or several 

73 are present and none matches the project name or main file. 

74 """ 

75 candidates = sorted(p.name for p in bundle_dir.glob("*.exe")) 

76 if not candidates: 

77 raise InstallerConfigError( 

78 f"Bundle directory contains no executable: {bundle_dir}" 

79 ) 

80 if len(candidates) == 1: 

81 return candidates[0] 

82 

83 project_candidate = f"{project_name}.exe" 

84 if project_candidate in candidates: 

85 return project_candidate 

86 

87 main_file_candidate = f"{Path(main_file).stem}.exe" 

88 if main_file_candidate in candidates: 

89 return main_file_candidate 

90 

91 raise InstallerConfigError( 

92 f"cannot determine the main executable among {candidates}; " 

93 "place a single .exe at the bundle root" 

94 ) 

95 

96 

97# /////////////////////////////////////////////////////////////// 

98# CLASSES 

99# /////////////////////////////////////////////////////////////// 

100 

101 

102class InnoSetupInstaller(BaseInstaller): 

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

104 

105 # //////////////////////////////////////////////// 

106 # BUILD 

107 # //////////////////////////////////////////////// 

108 

109 def build( 

110 self, 

111 bundle_dir: Path, 

112 app_name: str, 

113 version: str, 

114 output_dir: Path, 

115 *, 

116 company_name: str = "", 

117 icon: str = "", 

118 main_file: str = "", 

119 ) -> Path: 

120 """Detect the main executable, resolve the .iss, and compile it. 

121 

122 ``company_name`` and ``icon`` are keyword-only: they feed 

123 ``render_iss`` (the former drives the deterministic AppId GUID via 

124 ``resolve_app_id``, so it must match whatever ``generate iss`` uses 

125 for the same product — see ``InstallerService.build_installer``). 

126 ``main_file`` is the entry-point script: it disambiguates a bundle 

127 holding several executables when none matches ``app_name`` (a bundle 

128 built from ``main.py`` for project ``MyApp`` emits ``main.exe``). 

129 """ 

130 self._validate_bundle_dir(bundle_dir) 

131 main_exe = detect_main_exe(bundle_dir, app_name, main_file or app_name) 

132 

133 iscc_path = self._config.iscc_path or self._find_iscc() 

134 if not iscc_path: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true

135 raise IsccNotFoundError( 

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

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

138 ) 

139 

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

141 # ISCC resolves a relative icon path against the .iss file's own 

142 # directory (the ephemeral tempfile.mkdtemp(), not the process cwd 

143 # or output_dir) — absolutize against cwd so a user-supplied icon 

144 # still resolves once render_iss threads it through. 

145 resolved_icon = str(Path(icon).resolve()) if icon else "" 

146 iss_path, tmp_dir = self._resolve_iss_path( 

147 app_name, company_name, resolved_icon 

148 ) 

149 

150 argv = self._build_argv( 

151 iscc_path, iss_path, version, bundle_dir, output_dir, main_exe 

152 ) 

153 

154 # No try/finally around this call: the temporary .iss must survive 

155 # any failure (including a timeout) so it can still be inspected — 

156 # only the success path below removes it. 

157 # Argument vector built internally, no shell, with a timeout. 

158 result = subprocess.run( # noqa: S603 # nosec B603 

159 argv, 

160 capture_output=True, 

161 check=False, 

162 timeout=ISCC_TIMEOUT_SECONDS, 

163 ) 

164 

165 if result.returncode != 0: 

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

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

168 raise InstallerBuildError( 

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

170 f"(script kept at {iss_path})" 

171 ) 

172 

173 _logger.debug( 

174 "ISCC.exe succeeded: %s", 

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

176 ) 

177 if tmp_dir is not None: 

178 shutil.rmtree(tmp_dir, ignore_errors=True) 

179 

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

181 if not setup_exe.is_file(): 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true

182 raise InstallerBuildError( 

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

184 "If using a custom iss_path, ensure its OutputBaseFilename " 

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

186 ) 

187 return setup_exe 

188 

189 # //////////////////////////////////////////////// 

190 # ISS RESOLUTION 

191 # //////////////////////////////////////////////// 

192 

193 def _resolve_iss_path( 

194 self, app_name: str, company_name: str, icon: str 

195 ) -> tuple[Path, str | None]: 

196 """Return the .iss path to compile, and its temp dir if ephemeral. 

197 

198 In file mode (``config.iss_path`` set), the user's script is the 

199 source of truth and is never rendered or written. Otherwise the 

200 script is rendered from ``InstallerConfig`` and written as an 

201 ephemeral ``utf-8-sig`` file — ISCC only reads UTF-8 with a BOM. 

202 """ 

203 if self._config.iss_path is not None: 

204 if not self._config.iss_path.is_file(): 

205 raise InstallerConfigError( 

206 f"iss_path not found: {self._config.iss_path}" 

207 ) 

208 return self._config.iss_path, None 

209 

210 # ISCC resolves LicenseFile against the .iss file's own directory — 

211 # the mkdtemp() below — so a relative path would never be found. 

212 # Same reasoning as the icon, absolutized by build(). 

213 config = self._config 

214 license_file = config.license_file 

215 if license_file is not None and not license_file.is_absolute(): 

216 config = copy.copy(config) 

217 config.license_file = license_file.resolve() 

218 

219 iss_text = render_iss( 

220 config, 

221 project_name=app_name, 

222 company_name=company_name, 

223 icon=icon, 

224 standalone=False, 

225 ) 

226 tmp_dir = tempfile.mkdtemp() 

227 iss_path = Path(tmp_dir) / f"{app_name}.iss" 

228 iss_path.write_text(iss_text, encoding="utf-8-sig") 

229 return iss_path, tmp_dir 

230 

231 # //////////////////////////////////////////////// 

232 # ISCC COMMAND LINE 

233 # //////////////////////////////////////////////// 

234 

235 def _build_argv( 

236 self, 

237 iscc_path: Path, 

238 iss_path: Path, 

239 version: str, 

240 bundle_dir: Path, 

241 output_dir: Path, 

242 main_exe: str, 

243 ) -> list[str]: 

244 """Build the ISCC argv, quoting each ``/D`` value as one argument. 

245 

246 Passing a list to ``subprocess.run`` (never ``shell=True``) does not 

247 dispense with quoting: ISCC re-parses the ``/D`` value itself, so a 

248 value containing a space must still be wrapped in quotes. 

249 """ 

250 

251 def _define(name: str, value: str) -> str: 

252 if " " in value: 

253 return f'/D{name}="{value}"' 

254 return f"/D{name}={value}" 

255 

256 argv = [ 

257 str(iscc_path), 

258 "/Q", 

259 _define("MyAppVersion", version), 

260 _define("BundleDir", str(bundle_dir.resolve())), 

261 _define("OutputDir", str(output_dir.resolve())), 

262 _define("MainExe", main_exe), 

263 _define("VersionInfo", sanitize_version_info(version)), 

264 ] 

265 if self._config.sign_tool_name: 

266 argv.append( 

267 f"/S{self._config.sign_tool_name}={self._config.sign_tool_command}" 

268 ) 

269 argv.append(str(iss_path)) 

270 return argv 

271 

272 # //////////////////////////////////////////////// 

273 # ISCC DETECTION 

274 # //////////////////////////////////////////////// 

275 

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

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

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

279 if found: 279 ↛ 282line 279 didn't jump to line 282 because the condition on line 279 was always true

280 return Path(found) 

281 

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

283 base = os.environ.get(env_var) 

284 if not base: 

285 continue 

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

287 if candidate.is_file(): 

288 return candidate 

289 

290 return None 

291 

292 # //////////////////////////////////////////////// 

293 # METADATA 

294 # //////////////////////////////////////////////// 

295 

296 def get_installer_name(self) -> str: 

297 return "InnoSetup"