Coverage for src/ezcompiler/shared/_installer_config.py: 97.63%

121 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 16:37 +0000

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

2# INSTALLER_CONFIG - Inno Setup installer configuration 

3# Project: ezcompiler 

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

5 

6""" 

7Installer config - Configuration for the Inno Setup installer stage. 

8 

9Holds the curated option set driving .iss generation, plus two raw escape 

10hatches (``extra_setup_directives`` / ``extra_sections``) covering every 

11Inno directive not worth a typed field. Validates shape only: it knows 

12nothing about .iss syntax or ISCC. 

13""" 

14 

15from __future__ import annotations 

16 

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

18# IMPORTS 

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

20import dataclasses as _dc 

21import re 

22from collections.abc import Iterable 

23from dataclasses import dataclass, field 

24from pathlib import Path 

25from typing import Any, Literal 

26 

27from .exceptions import ConfigurationError 

28 

29# /////////////////////////////////////////////////////////////// 

30# CONSTANTS 

31# /////////////////////////////////////////////////////////////// 

32 

33# Languages shipped as .isl files with Inno Setup 6. 

34INNO_LANGUAGES = frozenset( 

35 { 

36 "armenian", 

37 "brazilianportuguese", 

38 "bulgarian", 

39 "catalan", 

40 "corsican", 

41 "czech", 

42 "danish", 

43 "dutch", 

44 "english", 

45 "finnish", 

46 "french", 

47 "german", 

48 "hebrew", 

49 "hungarian", 

50 "icelandic", 

51 "italian", 

52 "japanese", 

53 "norwegian", 

54 "polish", 

55 "portuguese", 

56 "russian", 

57 "slovak", 

58 "slovenian", 

59 "spanish", 

60 "turkish", 

61 "ukrainian", 

62 } 

63) 

64 

65# [Setup] directives driven by typed fields — an extra colliding with one of 

66# these would produce two contradictory lines whose winner depends on order. 

67MANAGED_SETUP_DIRECTIVES = frozenset( 

68 { 

69 "appid", 

70 "appname", 

71 "appversion", 

72 "apppublisher", 

73 "apppublisherurl", 

74 "appsupporturl", 

75 "appupdatesurl", 

76 "architecturesallowed", 

77 "architecturesinstallin64bitmode", 

78 "closeapplications", 

79 "compression", 

80 "defaultdirname", 

81 "defaultgroupname", 

82 "licensefile", 

83 "outputdir", 

84 "outputbasefilename", 

85 "privilegesrequired", 

86 "restartapplications", 

87 "setupiconfile", 

88 "solidcompression", 

89 "versioninfoversion", 

90 "wizardstyle", 

91 } 

92) 

93 

94# Sections generated from typed fields. 

95MANAGED_SECTIONS = frozenset( 

96 { 

97 "setup", 

98 "files", 

99 "icons", 

100 "run", 

101 "tasks", 

102 "languages", 

103 "registry", 

104 "uninstalldelete", 

105 } 

106) 

107 

108_GUID_RE = re.compile( 

109 r"^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}" 

110 r"-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$" 

111) 

112 

113_LEGACY_PREFIX = "installer_" 

114_ARCHITECTURES = ("x64", "x86", "arm64", "auto") 

115_WIZARD_STYLES = ("modern", "classic") 

116 

117# /////////////////////////////////////////////////////////////// 

118# FUNCTIONS 

119# /////////////////////////////////////////////////////////////// 

120 

121 

122def raise_on_legacy_installer_keys(keys: Iterable[str]) -> None: 

123 """Reject pre-4.0.0 ``installer_*`` keys, naming every replacement. 

124 

125 Both shapes reach this: the top-level flat keys (``installer_enabled`` 

126 next to ``project_name``) and the nested ones v3.4.0's own ``to_dict()`` 

127 wrote inside the ``installer`` table. Success depends on the user reading 

128 their fix in the message, so every offending key is spelled out. 

129 

130 Args: 

131 keys: Configuration keys to inspect. 

132 

133 Raises: 

134 ConfigurationError: If any key carries the legacy prefix. 

135 """ 

136 legacy = sorted(key for key in keys if key.startswith(_LEGACY_PREFIX)) 

137 if not legacy: 

138 return 

139 

140 renames = ", ".join(f"{key} -> {key[len(_LEGACY_PREFIX) :]}" for key in legacy) 

141 raise ConfigurationError( 

142 f"Flat installer keys are no longer supported: {', '.join(legacy)}. " 

143 f"Move them into the [tool.ezcompiler.installer] section without the " 

144 f"'installer_' prefix ({renames})." 

145 ) 

146 

147 

148# /////////////////////////////////////////////////////////////// 

149# CLASSES 

150# /////////////////////////////////////////////////////////////// 

151 

152 

153@dataclass 

154class InstallerConfig: 

155 """Configuration of the Inno Setup installer stage.""" 

156 

157 # //////////////////////////////////////////////// 

158 # FRAME 

159 # //////////////////////////////////////////////// 

160 

161 enabled: bool = False 

162 iss_path: Path | None = None 

163 output_dir: Path | None = None 

164 iscc_path: Path | None = None 

165 

166 # //////////////////////////////////////////////// 

167 # IDENTITY 

168 # //////////////////////////////////////////////// 

169 

170 # None -> deterministic UUIDv5 derived from company_name + project_name. 

171 app_id: str | None = None 

172 publisher_url: str = "" 

173 support_url: str = "" 

174 updates_url: str = "" 

175 architecture: Literal["x64", "x86", "arm64", "auto"] = "x64" 

176 

177 # //////////////////////////////////////////////// 

178 # LOCATION AND PRIVILEGES 

179 # //////////////////////////////////////////////// 

180 

181 per_user: bool = False 

182 

183 # //////////////////////////////////////////////// 

184 # SHORTCUTS AND POST-INSTALL 

185 # //////////////////////////////////////////////// 

186 

187 desktop_icon: bool = True 

188 start_menu_group: str | None = None 

189 launch_after_install: bool = True 

190 add_to_path: bool = False 

191 

192 # //////////////////////////////////////////////// 

193 # UPGRADE AND UNINSTALL 

194 # //////////////////////////////////////////////// 

195 

196 close_running_app: bool = True 

197 uninstall_delete: list[str] = field(default_factory=list) 

198 

199 # //////////////////////////////////////////////// 

200 # WIZARD UX 

201 # //////////////////////////////////////////////// 

202 

203 license_file: Path | None = None 

204 languages: list[str] = field(default_factory=lambda: ["english"]) 

205 wizard_style: Literal["modern", "classic"] = "modern" 

206 

207 # //////////////////////////////////////////////// 

208 # OUTPUT AND SIGNING 

209 # //////////////////////////////////////////////// 

210 

211 # Free-form: the algorithm list belongs to Inno, duplicating it here would 

212 # diverge at every Inno release. ISCC rejects an invalid value itself. 

213 compression: str = "lzma2/max" 

214 sign_tool_name: str | None = None 

215 sign_tool_command: str | None = None 

216 

217 # //////////////////////////////////////////////// 

218 # ESCAPE HATCHES 

219 # //////////////////////////////////////////////// 

220 

221 extra_setup_directives: dict[str, str] = field(default_factory=dict) 

222 extra_sections: dict[str, list[str]] = field(default_factory=dict) 

223 

224 # //////////////////////////////////////////////// 

225 # INITIALIZATION AND VALIDATION 

226 # //////////////////////////////////////////////// 

227 

228 def __post_init__(self) -> None: 

229 """ 

230 Coerce path-like fields and validate configuration. 

231 

232 Shape checks (bounds, GUID format, collisions) run unconditionally, 

233 even when ``enabled`` is False, so a disabled but malformed config is 

234 still caught early. Costly checks (file existence) run only when 

235 ``enabled`` is True. 

236 

237 Raises: 

238 ConfigurationError: If any validation fails. 

239 """ 

240 self._coerce_paths() 

241 self._validate_architecture() 

242 self._validate_wizard_style() 

243 self._validate_languages() 

244 self._validate_app_id() 

245 self._validate_sign_tool() 

246 self._validate_uninstall_delete() 

247 self._validate_extra_setup_directives() 

248 self._validate_extra_sections() 

249 if self.enabled: 

250 self._validate_paths_exist() 

251 

252 def _coerce_paths(self) -> None: 

253 """Coerce str path fields to Path, before existence checks run.""" 

254 if isinstance(self.iss_path, str): 

255 self.iss_path = Path(self.iss_path) 

256 if isinstance(self.output_dir, str): 

257 self.output_dir = Path(self.output_dir) 

258 if isinstance(self.iscc_path, str): 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true

259 self.iscc_path = Path(self.iscc_path) 

260 if isinstance(self.license_file, str): 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true

261 self.license_file = Path(self.license_file) 

262 

263 def _validate_paths_exist(self) -> None: 

264 """ 

265 Validate that provided paths point to existing files. 

266 

267 Raises: 

268 ConfigurationError: If iss_path or license_file is set but absent. 

269 """ 

270 if self.iss_path is not None and not self.iss_path.is_file(): 

271 raise ConfigurationError(f"iss_path not found: {self.iss_path}") 

272 if self.license_file is not None and not self.license_file.is_file(): 

273 raise ConfigurationError(f"license_file not found: {self.license_file}") 

274 

275 def _validate_architecture(self) -> None: 

276 """Raises ConfigurationError if architecture is not a supported value.""" 

277 if self.architecture not in _ARCHITECTURES: 

278 raise ConfigurationError( 

279 f"Invalid architecture: {self.architecture!r}. " 

280 f"Must be one of {_ARCHITECTURES}" 

281 ) 

282 

283 def _validate_wizard_style(self) -> None: 

284 """Raises ConfigurationError if wizard_style is not a supported value.""" 

285 if self.wizard_style not in _WIZARD_STYLES: 

286 raise ConfigurationError( 

287 f"Invalid wizard_style: {self.wizard_style!r}. " 

288 f"Must be one of {_WIZARD_STYLES}" 

289 ) 

290 

291 def _validate_languages(self) -> None: 

292 """ 

293 Validate languages: non-empty, known, without duplicates. 

294 

295 Raises: 

296 ConfigurationError: If languages is empty, contains an unknown 

297 Inno language, or a duplicate entry. 

298 """ 

299 if not self.languages: 

300 raise ConfigurationError("languages cannot be empty") 

301 

302 unknown = [lang for lang in self.languages if lang not in INNO_LANGUAGES] 

303 if unknown: 

304 raise ConfigurationError( 

305 f"Unknown languages: {unknown}. Must be one of {sorted(INNO_LANGUAGES)}" 

306 ) 

307 

308 if len(self.languages) != len(set(self.languages)): 

309 raise ConfigurationError(f"Duplicate languages in {self.languages}") 

310 

311 def _validate_app_id(self) -> None: 

312 """Raises ConfigurationError if app_id is set but not a valid GUID.""" 

313 if self.app_id is not None and not _GUID_RE.match(self.app_id): 

314 raise ConfigurationError( 

315 f"app_id must be a GUID of the form " 

316 f"'{{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}}', got: {self.app_id!r}" 

317 ) 

318 

319 def _validate_sign_tool(self) -> None: 

320 """ 

321 Validate that sign_tool_name and sign_tool_command are set together. 

322 

323 Raises: 

324 ConfigurationError: If only one of the pair is provided. 

325 """ 

326 if self.sign_tool_name is not None and self.sign_tool_command is None: 

327 raise ConfigurationError( 

328 "sign_tool_command is required when sign_tool_name is set" 

329 ) 

330 if self.sign_tool_command is not None and self.sign_tool_name is None: 

331 raise ConfigurationError( 

332 "sign_tool_name is required when sign_tool_command is set" 

333 ) 

334 

335 def _validate_uninstall_delete(self) -> None: 

336 """ 

337 Validate uninstall_delete entries stay within the app directory. 

338 

339 Rejects an absolute entry (POSIX-style leading slash or a drive 

340 letter, e.g. ``C:\\Windows``) and any entry containing a ``..`` 

341 segment, including after an Inno constant like ``{app}``. 

342 

343 Raises: 

344 ConfigurationError: If an entry escapes the app directory. 

345 """ 

346 for entry in self.uninstall_delete: 

347 if entry.startswith(("/", "\\")) or (len(entry) > 1 and entry[1] == ":"): 

348 raise ConfigurationError( 

349 f"uninstall_delete entry must be relative to the app " 

350 f"directory, got absolute path: {entry!r}" 

351 ) 

352 segments = re.split(r"[\\/]", entry) 

353 if ".." in segments: 

354 raise ConfigurationError( 

355 f"uninstall_delete entry must not escape the app " 

356 f"directory with '..': {entry!r}" 

357 ) 

358 

359 def _validate_extra_setup_directives(self) -> None: 

360 """ 

361 Validate extra_setup_directives does not shadow a managed directive. 

362 

363 Raises: 

364 ConfigurationError: If a key collides with a managed directive, 

365 case-insensitively. 

366 """ 

367 for key in self.extra_setup_directives: 

368 if key.lower() in MANAGED_SETUP_DIRECTIVES: 

369 raise ConfigurationError( 

370 f"extra_setup_directives key {key!r} collides with a " 

371 f"managed directive ({key!r} is set via a typed field)" 

372 ) 

373 

374 def _validate_extra_sections(self) -> None: 

375 """ 

376 Validate extra_sections does not shadow a managed section. 

377 

378 Inno Setup section names are case-insensitive, so the comparison 

379 happens in lowercase. 

380 

381 Raises: 

382 ConfigurationError: If a key collides with a managed section, 

383 case-insensitively. 

384 """ 

385 for key in self.extra_sections: 

386 if key.lower() in MANAGED_SECTIONS: 

387 raise ConfigurationError( 

388 f"extra_sections key {key!r} collides with a managed " 

389 f"section (section names are case-insensitive in Inno Setup)" 

390 ) 

391 

392 # //////////////////////////////////////////////// 

393 # SERIALIZATION METHODS 

394 # //////////////////////////////////////////////// 

395 

396 def to_dict(self) -> dict[str, Any]: 

397 """ 

398 Convert configuration to a flat dictionary. 

399 

400 Every field is emitted, including defaults, so a round-trip through 

401 ``from_dict`` reconstructs an equal instance. Path fields are 

402 serialized to str (or None). 

403 

404 Returns: 

405 dict[str, Any]: Configuration as a flat dictionary. 

406 """ 

407 return { 

408 "enabled": self.enabled, 

409 "iss_path": str(self.iss_path) if self.iss_path is not None else None, 

410 "output_dir": ( 

411 str(self.output_dir) if self.output_dir is not None else None 

412 ), 

413 "iscc_path": (str(self.iscc_path) if self.iscc_path is not None else None), 

414 "app_id": self.app_id, 

415 "publisher_url": self.publisher_url, 

416 "support_url": self.support_url, 

417 "updates_url": self.updates_url, 

418 "architecture": self.architecture, 

419 "per_user": self.per_user, 

420 "desktop_icon": self.desktop_icon, 

421 "start_menu_group": self.start_menu_group, 

422 "launch_after_install": self.launch_after_install, 

423 "add_to_path": self.add_to_path, 

424 "close_running_app": self.close_running_app, 

425 "uninstall_delete": self.uninstall_delete, 

426 "license_file": ( 

427 str(self.license_file) if self.license_file is not None else None 

428 ), 

429 "languages": self.languages, 

430 "wizard_style": self.wizard_style, 

431 "compression": self.compression, 

432 "sign_tool_name": self.sign_tool_name, 

433 "sign_tool_command": self.sign_tool_command, 

434 "extra_setup_directives": self.extra_setup_directives, 

435 "extra_sections": self.extra_sections, 

436 } 

437 

438 @classmethod 

439 def from_dict(cls, data: dict[str, Any]) -> InstallerConfig: 

440 """ 

441 Create an InstallerConfig from a dictionary. 

442 

443 None values are filtered out (a TOML-generated dict carries explicit 

444 nulls for unset fields; they must not override dataclass defaults). 

445 An unknown key is rejected, naming it. 

446 

447 Args: 

448 data: Configuration dictionary, e.g. as loaded from TOML. 

449 

450 Returns: 

451 InstallerConfig: New configuration instance. 

452 

453 Raises: 

454 ConfigurationError: If data contains an unknown key. 

455 """ 

456 # The concrete class, not ``cls``: a ``type[Self]`` does not satisfy 

457 # the ``DataclassInstance`` protocol for type checkers, and nothing 

458 # subclasses InstallerConfig. 

459 valid_fields = {f.name for f in _dc.fields(InstallerConfig)} 

460 # v3.4.0's own to_dict() nested the flat keys inside the installer 

461 # table, so they reach here rather than the top-level scan in 

462 # CompilerConfig.from_dict — both shapes must name the replacement. 

463 raise_on_legacy_installer_keys(data) 

464 unknown = set(data) - valid_fields 

465 if unknown: 

466 raise ConfigurationError( 

467 f"Unknown installer configuration key(s): {sorted(unknown)}" 

468 ) 

469 

470 kwargs = {key: value for key, value in data.items() if value is not None} 

471 return cls(**kwargs)