Coverage for src/ezcompiler/adapters/_iss_renderer.py: 100.00%
62 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# ISS_RENDERER - Jinja2 rendering of Inno Setup scripts
3# Project: ezcompiler
4# ///////////////////////////////////////////////////////////////
6"""
7ISS renderer - Renders an Inno Setup .iss script from an InstallerConfig.
9Shared by the build pipeline (ephemeral mode) and the ``generate iss``
10command (standalone mode). Knows nothing about ISCC.
12Jinja2 delimiters are customized because the defaults collide head-on with
13Inno syntax: ``{#`` opens a Jinja comment, which would swallow the rest of a
14file containing ``{#MyAppName}``, and ``{{`` opens an expression, which the
15AppId line ``{{{#MyAppName}...}`` would trigger.
16"""
18from __future__ import annotations
20# ///////////////////////////////////////////////////////////////
21# IMPORTS
22# ///////////////////////////////////////////////////////////////
23import re
24import uuid
25from pathlib import Path
27import jinja2
28from jinja2 import Environment, FileSystemLoader, StrictUndefined
30from ..shared import InstallerConfig
31from ..shared.exceptions import InstallerRenderError
33# ///////////////////////////////////////////////////////////////
34# CONSTANTS
35# ///////////////////////////////////////////////////////////////
37_TEMPLATE_DIR = Path(__file__).parent.parent / "assets" / "templates" / "installer"
38_TEMPLATE_NAME = "setup.iss.jinja"
40# Fixed namespace so a project's AppId is stable across builds and machines.
41_APP_ID_NAMESPACE = uuid.UUID("6f1d5a4e-8c2b-4f7a-9e3d-1b5c7a9e0f42")
43_FORBIDDEN_IN_ISS = ('"', "\n", "\r", "\t")
44_VERSION_QUAD_RE = re.compile(r"\d+(?:\.\d+)*")
46# ///////////////////////////////////////////////////////////////
47# FUNCTIONS
48# ///////////////////////////////////////////////////////////////
51def escape_iss(value: str) -> str:
52 """Escape a value for safe interpolation into an Inno Setup script.
54 Only ``{`` is special in Inno syntax: it introduces a constant (e.g.
55 ``{app}``), and ``{{`` is the documented escape for a literal brace.
56 ``}`` has no special meaning and is emitted as-is; doubling it would
57 produce a spurious extra closing brace in the rendered script. Also
58 rejects characters that would truncate or split the generated directive.
59 """
60 for character in _FORBIDDEN_IN_ISS:
61 if character in value:
62 raise ValueError(f"invalid character {character!r} in {value!r}")
63 return value.replace("{", "{{")
66def resolve_app_id(app_id: str | None, company_name: str, project_name: str) -> str:
67 """Return the explicit ``app_id``, or derive a stable one deterministically.
69 The derived id is a UUID5 keyed by company and project name, so it stays
70 identical across builds and machines instead of varying per version.
71 """
72 if app_id:
73 return app_id
74 derived = uuid.uuid5(_APP_ID_NAMESPACE, f"{company_name}\x00{project_name}")
75 return "{" + str(derived).upper() + "}"
78def sanitize_version_info(version: str) -> str:
79 """Coerce ``version`` into the numeric quadruplet Inno's VersionInfoVersion needs.
81 Locates the leading run of dot-separated digits (ignoring a leading
82 ``v`` and any trailing pre-release/build suffix), drops leading zeros
83 via ``int()``, and pads or truncates to exactly four components.
84 """
85 match = _VERSION_QUAD_RE.search(version)
86 if not match:
87 raise ValueError(f"cannot derive a version quadruplet from {version!r}")
88 components = [str(int(number)) for number in match.group().split(".")[:4]]
89 components += ["0"] * (4 - len(components))
90 return ".".join(components)
93def build_environment() -> Environment:
94 """Build the Jinja2 environment used to render Inno Setup scripts.
96 Uses custom delimiters (``<% %>`` / ``<< >>`` / ``<# #>``) because the
97 defaults collide with Inno Setup's own ``{#`` and ``{{`` syntax.
98 """
99 env = Environment( # nosec B701 - .iss is not HTML; escape_iss handles safety
100 loader=FileSystemLoader(_TEMPLATE_DIR),
101 undefined=StrictUndefined,
102 autoescape=False, # noqa: S701 - .iss is not HTML; escape_iss handles safety
103 keep_trailing_newline=True,
104 trim_blocks=True,
105 lstrip_blocks=True,
106 block_start_string="<%",
107 block_end_string="%>",
108 variable_start_string="<<",
109 variable_end_string=">>",
110 comment_start_string="<#",
111 comment_end_string="#>",
112 )
113 env.filters["iss"] = escape_iss
114 return env
117def render_iss(
118 config: InstallerConfig,
119 *,
120 project_name: str,
121 company_name: str,
122 icon: str,
123 standalone: bool,
124) -> str:
125 """Render the .iss script for ``config``.
127 Deliberately excludes ``version``, ``bundle_dir``, ``output_dir`` and
128 ``main_exe``: those volatile values reach ISCC as ``/D`` command-line
129 defines (Task 5), so the rendered script stays committable and never
130 bakes in a stale build.
131 """
132 env = build_environment()
133 _validate_escaped_fields(config, project_name, company_name, icon)
134 try:
135 template = env.get_template(_TEMPLATE_NAME)
136 app_id = resolve_app_id(config.app_id, company_name, project_name)
137 return template.render(
138 config=config,
139 project_name=project_name,
140 company_name=company_name,
141 icon=icon,
142 standalone=standalone,
143 app_id=app_id,
144 )
145 except jinja2.TemplateError as exc:
146 raise InstallerRenderError(f"failed to render .iss template: {exc}") from exc
149def _validate_escaped_fields(
150 config: InstallerConfig, project_name: str, company_name: str, icon: str
151) -> None:
152 """Run every value the template passes through the ``iss`` filter
153 through ``escape_iss`` up front, so a rejected character can be
154 reported against the field that carries it instead of a generic
155 template-rendering failure.
157 Raises:
158 InstallerRenderError: If a field contains a character ``escape_iss``
159 rejects (quote, newline, tab, or carriage return).
160 """
161 fields: dict[str, str] = {
162 "project_name": project_name,
163 "company_name": company_name,
164 "start_menu_group": config.start_menu_group or project_name,
165 "compression": config.compression,
166 }
167 if config.publisher_url:
168 fields["publisher_url"] = config.publisher_url
169 if config.support_url:
170 fields["support_url"] = config.support_url
171 if config.updates_url:
172 fields["updates_url"] = config.updates_url
173 if config.license_file:
174 fields["license_file"] = str(config.license_file)
175 if icon:
176 fields["icon"] = icon
177 if config.sign_tool_name:
178 fields["sign_tool_name"] = config.sign_tool_name
180 for field_name, value in fields.items():
181 try:
182 escape_iss(value)
183 except ValueError as exc:
184 raise InstallerRenderError(
185 f"invalid value for field {field_name!r}: {exc}"
186 ) from exc