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

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

2# ISS_RENDERER - Jinja2 rendering of Inno Setup scripts 

3# Project: ezcompiler 

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

5 

6""" 

7ISS renderer - Renders an Inno Setup .iss script from an InstallerConfig. 

8 

9Shared by the build pipeline (ephemeral mode) and the ``generate iss`` 

10command (standalone mode). Knows nothing about ISCC. 

11 

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""" 

17 

18from __future__ import annotations 

19 

20# /////////////////////////////////////////////////////////////// 

21# IMPORTS 

22# /////////////////////////////////////////////////////////////// 

23import re 

24import uuid 

25from pathlib import Path 

26 

27import jinja2 

28from jinja2 import Environment, FileSystemLoader, StrictUndefined 

29 

30from ..shared import InstallerConfig 

31from ..shared.exceptions import InstallerRenderError 

32 

33# /////////////////////////////////////////////////////////////// 

34# CONSTANTS 

35# /////////////////////////////////////////////////////////////// 

36 

37_TEMPLATE_DIR = Path(__file__).parent.parent / "assets" / "templates" / "installer" 

38_TEMPLATE_NAME = "setup.iss.jinja" 

39 

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") 

42 

43_FORBIDDEN_IN_ISS = ('"', "\n", "\r", "\t") 

44_VERSION_QUAD_RE = re.compile(r"\d+(?:\.\d+)*") 

45 

46# /////////////////////////////////////////////////////////////// 

47# FUNCTIONS 

48# /////////////////////////////////////////////////////////////// 

49 

50 

51def escape_iss(value: str) -> str: 

52 """Escape a value for safe interpolation into an Inno Setup script. 

53 

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("{", "{{") 

64 

65 

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. 

68 

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() + "}" 

76 

77 

78def sanitize_version_info(version: str) -> str: 

79 """Coerce ``version`` into the numeric quadruplet Inno's VersionInfoVersion needs. 

80 

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) 

91 

92 

93def build_environment() -> Environment: 

94 """Build the Jinja2 environment used to render Inno Setup scripts. 

95 

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 

115 

116 

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``. 

126 

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 

147 

148 

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. 

156 

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 

179 

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