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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Installer config - Configuration for the Inno Setup installer stage.
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"""
15from __future__ import annotations
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
27from .exceptions import ConfigurationError
29# ///////////////////////////////////////////////////////////////
30# CONSTANTS
31# ///////////////////////////////////////////////////////////////
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)
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)
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)
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)
113_LEGACY_PREFIX = "installer_"
114_ARCHITECTURES = ("x64", "x86", "arm64", "auto")
115_WIZARD_STYLES = ("modern", "classic")
117# ///////////////////////////////////////////////////////////////
118# FUNCTIONS
119# ///////////////////////////////////////////////////////////////
122def raise_on_legacy_installer_keys(keys: Iterable[str]) -> None:
123 """Reject pre-4.0.0 ``installer_*`` keys, naming every replacement.
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.
130 Args:
131 keys: Configuration keys to inspect.
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
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 )
148# ///////////////////////////////////////////////////////////////
149# CLASSES
150# ///////////////////////////////////////////////////////////////
153@dataclass
154class InstallerConfig:
155 """Configuration of the Inno Setup installer stage."""
157 # ////////////////////////////////////////////////
158 # FRAME
159 # ////////////////////////////////////////////////
161 enabled: bool = False
162 iss_path: Path | None = None
163 output_dir: Path | None = None
164 iscc_path: Path | None = None
166 # ////////////////////////////////////////////////
167 # IDENTITY
168 # ////////////////////////////////////////////////
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"
177 # ////////////////////////////////////////////////
178 # LOCATION AND PRIVILEGES
179 # ////////////////////////////////////////////////
181 per_user: bool = False
183 # ////////////////////////////////////////////////
184 # SHORTCUTS AND POST-INSTALL
185 # ////////////////////////////////////////////////
187 desktop_icon: bool = True
188 start_menu_group: str | None = None
189 launch_after_install: bool = True
190 add_to_path: bool = False
192 # ////////////////////////////////////////////////
193 # UPGRADE AND UNINSTALL
194 # ////////////////////////////////////////////////
196 close_running_app: bool = True
197 uninstall_delete: list[str] = field(default_factory=list)
199 # ////////////////////////////////////////////////
200 # WIZARD UX
201 # ////////////////////////////////////////////////
203 license_file: Path | None = None
204 languages: list[str] = field(default_factory=lambda: ["english"])
205 wizard_style: Literal["modern", "classic"] = "modern"
207 # ////////////////////////////////////////////////
208 # OUTPUT AND SIGNING
209 # ////////////////////////////////////////////////
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
217 # ////////////////////////////////////////////////
218 # ESCAPE HATCHES
219 # ////////////////////////////////////////////////
221 extra_setup_directives: dict[str, str] = field(default_factory=dict)
222 extra_sections: dict[str, list[str]] = field(default_factory=dict)
224 # ////////////////////////////////////////////////
225 # INITIALIZATION AND VALIDATION
226 # ////////////////////////////////////////////////
228 def __post_init__(self) -> None:
229 """
230 Coerce path-like fields and validate configuration.
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.
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()
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)
263 def _validate_paths_exist(self) -> None:
264 """
265 Validate that provided paths point to existing files.
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}")
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 )
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 )
291 def _validate_languages(self) -> None:
292 """
293 Validate languages: non-empty, known, without duplicates.
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")
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 )
308 if len(self.languages) != len(set(self.languages)):
309 raise ConfigurationError(f"Duplicate languages in {self.languages}")
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 )
319 def _validate_sign_tool(self) -> None:
320 """
321 Validate that sign_tool_name and sign_tool_command are set together.
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 )
335 def _validate_uninstall_delete(self) -> None:
336 """
337 Validate uninstall_delete entries stay within the app directory.
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}``.
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 )
359 def _validate_extra_setup_directives(self) -> None:
360 """
361 Validate extra_setup_directives does not shadow a managed directive.
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 )
374 def _validate_extra_sections(self) -> None:
375 """
376 Validate extra_sections does not shadow a managed section.
378 Inno Setup section names are case-insensitive, so the comparison
379 happens in lowercase.
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 )
392 # ////////////////////////////////////////////////
393 # SERIALIZATION METHODS
394 # ////////////////////////////////////////////////
396 def to_dict(self) -> dict[str, Any]:
397 """
398 Convert configuration to a flat dictionary.
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).
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 }
438 @classmethod
439 def from_dict(cls, data: dict[str, Any]) -> InstallerConfig:
440 """
441 Create an InstallerConfig from a dictionary.
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.
447 Args:
448 data: Configuration dictionary, e.g. as loaded from TOML.
450 Returns:
451 InstallerConfig: New configuration instance.
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 )
470 kwargs = {key: value for key, value in data.items() if value is not None}
471 return cls(**kwargs)