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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Inno Setup installer - Adapter building a Windows setup.exe from a compiled
8bundle via the external ``ISCC.exe`` binary (Inno Setup 6).
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"""
15from __future__ import annotations
17# ///////////////////////////////////////////////////////////////
18# IMPORTS
19# ///////////////////////////////////////////////////////////////
20import os
21import shutil
22import subprocess
23from pathlib import Path
25from ..shared.exceptions import (
26 InstallerBuildError,
27 InstallerConfigError,
28 IsccNotFoundError,
29)
30from .base_installer import BaseInstaller
32# ///////////////////////////////////////////////////////////////
33# CONSTANTS
34# ///////////////////////////////////////////////////////////////
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"
45# ///////////////////////////////////////////////////////////////
46# CLASSES
47# ///////////////////////////////////////////////////////////////
50class InnoSetupInstaller(BaseInstaller):
51 """Installer backed by the Inno Setup compiler (ISCC.exe)."""
53 # ////////////////////////////////////////////////
54 # BUILD
55 # ////////////////////////////////////////////////
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)
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 )
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")
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 )
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
96 # ////////////////////////////////////////////////
97 # ISCC DETECTION
98 # ////////////////////////////////////////////////
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)
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
114 return None
116 # ////////////////////////////////////////////////
117 # TEMPLATE RENDERING
118 # ////////////////////////////////////////////////
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")
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")
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"
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 }
169 result = template
170 for placeholder, value in replacements.items():
171 result = result.replace(placeholder, str(value))
172 return result
174 # ////////////////////////////////////////////////
175 # METADATA
176 # ////////////////////////////////////////////////
178 def get_installer_name(self) -> str:
179 return "InnoSetup"