Coverage for phml\compiler\__init__.py: 100%

116 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-04-12 14:31 -0500

1from collections.abc import Callable 

2from copy import deepcopy 

3from typing import Any 

4from typing import Literal as Lit 

5from typing import NoReturn, overload 

6 

7from phml.components import ComponentManager 

8from phml.embedded import Embedded 

9from phml.helpers import normalize_indent 

10from phml.nodes import AST, Element, Literal, LiteralType, Parent 

11 

12from .steps import * 

13from .steps.base import post_step, scoped_step, setup_step 

14 

15__all__ = [ 

16 "HypertextMarkupCompiler", 

17 "setup_step", 

18 "scoped_step", 

19 "post_step", 

20 "add_step", 

21 "remove_step", 

22] 

23 

24__SETUP__: list[Callable] = [] 

25 

26__STEPS__: list[Callable] = [ 

27 step_replace_phml_wrapper, 

28 step_expand_loop_tags, 

29 step_execute_conditions, 

30 step_compile_markdown, 

31 step_execute_embedded_python, 

32 step_substitute_components, 

33] 

34 

35__POST__: list[Callable] = [ 

36 step_add_cached_component_elements, 

37] 

38 

39StepStage = Lit["setup", "scoped", "post"] 

40 

41 

42@overload 

43def add_step( 

44 step: Callable[[AST, ComponentManager, dict[str, Any]], None], 

45 stage: Lit["setup", "post"], 

46) -> NoReturn: 

47 ... 

48 

49 

50@overload 

51def add_step( 

52 step: Callable[[Parent, ComponentManager, dict[str, Any]], None], 

53 stage: Lit["scoped"], 

54) -> NoReturn: 

55 ... 

56 

57 

58def add_step( 

59 step: Callable[[Parent, ComponentManager, dict[str, Any]], None] 

60 | Callable[[AST, ComponentManager, dict[str, Any]], None], 

61 stage: StepStage, 

62): 

63 if stage == "setup" and step not in __SETUP__: 

64 __SETUP__.append(step) 

65 elif stage == "scoped" and step not in __STEPS__: 

66 __STEPS__.append(step) 

67 elif stage == "post" and step not in __POST__: 

68 __POST__.append(step) 

69 

70 

71@overload 

72def remove_step( 

73 step: Callable[[AST, ComponentManager, dict[str, Any]], None], 

74 stage: Lit["setup", "post"], 

75) -> NoReturn: 

76 ... 

77 

78 

79@overload 

80def remove_step( 

81 step: Callable[[Parent, ComponentManager, dict[str, Any]], None], 

82 stage: Lit["scoped"], 

83) -> NoReturn: 

84 ... 

85 

86 

87def remove_step( 

88 step: Callable[[Parent, ComponentManager, dict[str, Any]], None] 

89 | Callable[[AST, ComponentManager, dict[str, Any]], None], 

90 stage: StepStage, 

91): 

92 if stage == "setup": 

93 __SETUP__.remove(step) 

94 elif stage == "scoped": 

95 __STEPS__.remove(step) 

96 elif stage == "post": 

97 __POST__.remove(step) 

98 

99 

100class HypertextMarkupCompiler: 

101 def _get_python_elements(self, node: Parent) -> list[Element]: 

102 result = [] 

103 for child in node: 

104 if isinstance(child, Element): 

105 if child.tag == "python": 

106 result.append(child) 

107 idx = node.index(child) 

108 del node[idx] 

109 else: 

110 result.extend(self._get_python_elements(child)) 

111 

112 return result 

113 

114 def _process_scope_( 

115 self, 

116 node: Parent, 

117 components: ComponentManager, 

118 context: dict, 

119 ): 

120 """Process steps for a given scope/parent node.""" 

121 

122 # Core compile steps 

123 for _step in __STEPS__: 

124 _step(node, components, context) 

125 

126 # Recurse steps for each scope 

127 for child in node: 

128 if isinstance(child, Element): 

129 self._process_scope_(child, components, context) 

130 

131 @overload 

132 def compile( 

133 self, node: AST, _components: ComponentManager, **context: Any 

134 ) -> AST: 

135 ... 

136 

137 @overload 

138 def compile( 

139 self, node: Parent, _components: ComponentManager, **context: Any 

140 ) -> Parent: 

141 ... 

142 

143 def compile( 

144 self, node: Parent, _components: ComponentManager, **context: Any 

145 ) -> Parent: 

146 # get all python elements and process them 

147 node = deepcopy(node) 

148 p_elems = self._get_python_elements(node) 

149 embedded = Embedded("") 

150 for p_elem in p_elems: 

151 embedded += Embedded(p_elem) 

152 

153 # Setup steps to collect data before comiling at different scopes 

154 for step in __SETUP__: 

155 step(node, _components, context) 

156 

157 # Recursively process scopes 

158 context.update(embedded.context) 

159 self._process_scope_(node, _components, context) 

160 

161 # Post compiling steps to finalize the ast 

162 for step in __POST__: 

163 step(node, _components, context) 

164 

165 return node 

166 

167 def _render_attribute(self, key: str, value: str | bool) -> str: 

168 if isinstance(value, str): 

169 return f'{key}="{value}"' 

170 else: 

171 return str(key) if value else f'{key}="false"' 

172 

173 def _render_element( 

174 self, 

175 element: Element, 

176 indent: int = 0, 

177 compress: str = "\n", 

178 ) -> str: 

179 attr_idt = 2 

180 attrs = "" 

181 lead_space = " " if len(element.attributes) > 0 else "" 

182 if element.in_pre: 

183 attrs = lead_space + " ".join( 

184 self._render_attribute(key, value) 

185 for key, value in element.attributes.items() 

186 ) 

187 elif len(element.attributes) > 1: 

188 idt = indent + attr_idt if compress == "\n" else 1 

189 attrs = ( 

190 f"{compress}" 

191 + " " * (idt) 

192 + f'{compress}{" "*(idt)}'.join( 

193 self._render_attribute(key, value) 

194 for key, value in element.attributes.items() 

195 ) 

196 + f"{compress}{' '*(indent)}" 

197 ) 

198 elif len(element.attributes) == 1: 

199 key, value = list(element.attributes.items())[0] 

200 attrs = lead_space + self._render_attribute(key, value) 

201 

202 result = f"{' '*indent if not element.in_pre else ''}<{element.tag}{attrs}{'' if element.children is not None else '/'}>" 

203 if element.children is None: 

204 return result 

205 

206 if ( 

207 compress != "\n" 

208 or element.in_pre 

209 or ( 

210 element.tag not in ["script", "style", "python"] 

211 and len(element) == 1 

212 and Literal.is_text(element[0]) 

213 and "\n" not in element[0].content 

214 and "\n" not in result 

215 ) 

216 ): 

217 children = self._render_tree_(element, _compress="") 

218 result += children + f"</{element.tag}>" 

219 else: 

220 children = self._render_tree_(element, indent + 2, _compress=compress) 

221 result += compress + children 

222 result += f"{compress}{' '*indent}</{element.tag}>" 

223 

224 return result 

225 

226 def _render_literal( 

227 self, 

228 literal: Literal, 

229 indent: int = 0, 

230 compress: str = "\n", 

231 ) -> str: 

232 offset = " " * indent 

233 if literal.in_pre: 

234 offset = "" 

235 compress = "" 

236 content = literal.content 

237 else: 

238 content = literal.content.strip() 

239 if compress == "\n": 

240 content = normalize_indent(literal.content, indent) 

241 content = content.strip() 

242 elif not isinstance(literal.parent, AST) and literal.parent.tag in [ 

243 "python", 

244 "script", 

245 "style", 

246 ]: 

247 content = normalize_indent(literal.content) 

248 content = content.strip() 

249 offset = "" 

250 else: 

251 lines = content.split("\n") 

252 content = f"{compress}{offset}".join(lines) 

253 

254 if literal.name == LiteralType.Text: 

255 return offset + content 

256 

257 if literal.name == LiteralType.Comment: 

258 return f"{offset}<!--" + content + "-->" 

259 return "" # pragma: no cover 

260 

261 def _render_tree_( 

262 self, 

263 node: Parent, 

264 indent: int = 0, 

265 _compress: str = "\n", 

266 ): 

267 result = [] 

268 for child in node: 

269 if isinstance(child, Element): 

270 if child.tag == "doctype": 

271 result.append("<!DOCTYPE html>") 

272 else: 

273 result.append(self._render_element(child, indent, _compress)) 

274 elif isinstance(child, Literal): 

275 result.append(self._render_literal(child, indent, _compress)) 

276 else: 

277 raise TypeError(f"Unknown renderable node type {type(child)}") 

278 

279 return _compress.join(result) 

280 

281 def render( 

282 self, 

283 node: Parent, 

284 _compress: bool = False, 

285 indent: int = 0, 

286 ) -> str: 

287 return self._render_tree_(node, indent, "" if _compress else "\n")