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
« 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
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
12from .steps import *
13from .steps.base import post_step, scoped_step, setup_step
15__all__ = [
16 "HypertextMarkupCompiler",
17 "setup_step",
18 "scoped_step",
19 "post_step",
20 "add_step",
21 "remove_step",
22]
24__SETUP__: list[Callable] = []
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]
35__POST__: list[Callable] = [
36 step_add_cached_component_elements,
37]
39StepStage = Lit["setup", "scoped", "post"]
42@overload
43def add_step(
44 step: Callable[[AST, ComponentManager, dict[str, Any]], None],
45 stage: Lit["setup", "post"],
46) -> NoReturn:
47 ...
50@overload
51def add_step(
52 step: Callable[[Parent, ComponentManager, dict[str, Any]], None],
53 stage: Lit["scoped"],
54) -> NoReturn:
55 ...
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)
71@overload
72def remove_step(
73 step: Callable[[AST, ComponentManager, dict[str, Any]], None],
74 stage: Lit["setup", "post"],
75) -> NoReturn:
76 ...
79@overload
80def remove_step(
81 step: Callable[[Parent, ComponentManager, dict[str, Any]], None],
82 stage: Lit["scoped"],
83) -> NoReturn:
84 ...
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)
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))
112 return result
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."""
122 # Core compile steps
123 for _step in __STEPS__:
124 _step(node, components, context)
126 # Recurse steps for each scope
127 for child in node:
128 if isinstance(child, Element):
129 self._process_scope_(child, components, context)
131 @overload
132 def compile(
133 self, node: AST, _components: ComponentManager, **context: Any
134 ) -> AST:
135 ...
137 @overload
138 def compile(
139 self, node: Parent, _components: ComponentManager, **context: Any
140 ) -> Parent:
141 ...
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)
153 # Setup steps to collect data before comiling at different scopes
154 for step in __SETUP__:
155 step(node, _components, context)
157 # Recursively process scopes
158 context.update(embedded.context)
159 self._process_scope_(node, _components, context)
161 # Post compiling steps to finalize the ast
162 for step in __POST__:
163 step(node, _components, context)
165 return node
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"'
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)
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
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}>"
224 return result
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)
254 if literal.name == LiteralType.Text:
255 return offset + content
257 if literal.name == LiteralType.Comment:
258 return f"{offset}<!--" + content + "-->"
259 return "" # pragma: no cover
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)}")
279 return _compress.join(result)
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")