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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Inno Setup installer - Adapter building a Windows setup.exe from a compiled
8bundle via the external ``ISCC.exe`` binary (Inno Setup 6).
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"""
17from __future__ import annotations
19# ///////////////////////////////////////////////////////////////
20# IMPORTS
21# ///////////////////////////////////////////////////////////////
22import copy
23import logging
24import os
25import shutil
26import subprocess
27import tempfile
28from pathlib import Path
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
38# ///////////////////////////////////////////////////////////////
39# CONSTANTS
40# ///////////////////////////////////////////////////////////////
42_ISDL_URL = "https://jrsoftware.org/isdl.php"
44# A hung ISCC process must not freeze the build pipeline indefinitely.
45ISCC_TIMEOUT_SECONDS = 600
47_logger = logging.getLogger(__name__)
49# ///////////////////////////////////////////////////////////////
50# FUNCTIONS
51# ///////////////////////////////////////////////////////////////
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.
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.
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.
68 Returns:
69 str: The detected executable's file name.
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]
83 project_candidate = f"{project_name}.exe"
84 if project_candidate in candidates:
85 return project_candidate
87 main_file_candidate = f"{Path(main_file).stem}.exe"
88 if main_file_candidate in candidates:
89 return main_file_candidate
91 raise InstallerConfigError(
92 f"cannot determine the main executable among {candidates}; "
93 "place a single .exe at the bundle root"
94 )
97# ///////////////////////////////////////////////////////////////
98# CLASSES
99# ///////////////////////////////////////////////////////////////
102class InnoSetupInstaller(BaseInstaller):
103 """Installer backed by the Inno Setup compiler (ISCC.exe)."""
105 # ////////////////////////////////////////////////
106 # BUILD
107 # ////////////////////////////////////////////////
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.
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)
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 )
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 )
150 argv = self._build_argv(
151 iscc_path, iss_path, version, bundle_dir, output_dir, main_exe
152 )
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 )
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 )
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)
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
189 # ////////////////////////////////////////////////
190 # ISS RESOLUTION
191 # ////////////////////////////////////////////////
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.
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
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()
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
231 # ////////////////////////////////////////////////
232 # ISCC COMMAND LINE
233 # ////////////////////////////////////////////////
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.
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 """
251 def _define(name: str, value: str) -> str:
252 if " " in value:
253 return f'/D{name}="{value}"'
254 return f"/D{name}={value}"
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
272 # ////////////////////////////////////////////////
273 # ISCC DETECTION
274 # ////////////////////////////////////////////////
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)
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
290 return None
292 # ////////////////////////////////////////////////
293 # METADATA
294 # ////////////////////////////////////////////////
296 def get_installer_name(self) -> str:
297 return "InnoSetup"