phml.compiler
1from collections.abc import Callable 2from copy import deepcopy 3from typing import Any, Literal as Lit, NoReturn, overload 4 5from phml.components import ComponentManager 6from phml.embedded import Embedded 7from phml.helpers import normalize_indent 8from phml.nodes import AST, Element, Literal, LiteralType, Parent 9 10from .steps.base import scoped_step, post_step, setup_step 11 12from .steps import * 13 14__all__ = [ 15 "HypertextMarkupCompiler", 16 "setup_step", 17 "scoped_step", 18 "post_step", 19 "add_step", 20 "remove_step" 21] 22 23__SETUP__: list[Callable] = [] 24 25__STEPS__: list[Callable] = [ 26 step_replace_phml_wrapper, 27 step_expand_loop_tags, 28 step_execute_conditions, 29 step_compile_markdown, 30 step_execute_embedded_python, 31 step_substitute_components, 32] 33 34__POST__: list[Callable] = [ 35 step_add_cached_component_elements, 36] 37 38StepStage = Lit["setup", "scoped", "post"] 39 40@overload 41def add_step(step: Callable[[AST, ComponentManager, dict[str, Any]], None], stage: Lit["setup", "post"]) -> NoReturn: 42 ... 43 44@overload 45def add_step(step: Callable[[Parent, ComponentManager, dict[str, Any]], None], stage: Lit["scoped"]) -> NoReturn: 46 ... 47 48def add_step(step: Callable[[Parent, ComponentManager, dict[str, Any]], None]|Callable[[AST, ComponentManager, dict[str, Any]], None], stage: StepStage): 49 if stage == "setup": 50 __SETUP__.append(step) 51 elif stage == "scoped": 52 __STEPS__.append(step) 53 elif stage == "post": 54 __POST__.append(step) 55 56@overload 57def remove_step(step: Callable[[AST, ComponentManager, dict[str, Any]], None], stage: Lit["setup", "post"]) -> NoReturn: 58 ... 59 60@overload 61def remove_step(step: Callable[[Parent, ComponentManager, dict[str, Any]], None], stage: Lit["scoped"]) -> NoReturn: 62 ... 63 64def remove_step(step: Callable[[Parent, ComponentManager, dict[str, Any]], None]|Callable[[AST, ComponentManager, dict[str, Any]], None], stage: StepStage): 65 if stage == "setup": 66 __SETUP__.remove(step) 67 elif stage == "scoped": 68 __STEPS__.remove(step) 69 elif stage == "post": 70 __POST__.remove(step) 71 72class HypertextMarkupCompiler: 73 def _get_python_elements(self, node: Parent) -> list[Element]: 74 result = [] 75 for child in node: 76 if isinstance(child, Element): 77 if child.tag == "python": 78 result.append(child) 79 idx = node.index(child) 80 del node[idx] 81 else: 82 result.extend(self._get_python_elements(child)) 83 84 return result 85 86 def _process_scope_( 87 self, 88 node: Parent, 89 components: ComponentManager, 90 context: dict, 91 ): 92 """Process steps for a given scope/parent node.""" 93 94 # Core compile steps 95 for _step in __STEPS__: 96 _step(node, components, context) 97 98 # Recurse steps for each scope 99 for child in node: 100 if isinstance(child, Element): 101 self._process_scope_(child, components, context) 102 103 def compile( 104 self, node: Parent, _components: ComponentManager, **context: Any 105 ) -> Parent: 106 # get all python elements and process them 107 node = deepcopy(node) 108 p_elems = self._get_python_elements(node) 109 embedded = Embedded("") 110 for p_elem in p_elems: 111 embedded += Embedded(p_elem) 112 113 # Setup steps to collect data before comiling at different scopes 114 for step in __SETUP__: 115 step(node, _components, context) 116 117 # Recursively process scopes 118 context.update(embedded.context) 119 self._process_scope_(node, _components, context) 120 121 # Post compiling steps to finalize the ast 122 for step in __POST__: 123 step(node, _components, context) 124 125 return node 126 127 def _render_attribute(self, key: str, value: str | bool) -> str: 128 if isinstance(value, str): 129 return f'{key}="{value}"' 130 else: 131 return str(key) if value else f'{key}="false"' 132 133 def _render_element( 134 self, 135 element: Element, 136 indent: int = 0, 137 compress: str = "\n", 138 ) -> str: 139 attr_idt = 2 140 attrs = "" 141 lead_space = " " if len(element.attributes) > 0 else "" 142 if element.in_pre: 143 attrs = lead_space + " ".join( 144 self._render_attribute(key, value) 145 for key, value in element.attributes.items() 146 ) 147 elif len(element.attributes) > 1: 148 idt = indent + attr_idt if compress == "\n" else 1 149 attrs = ( 150 f"{compress}" 151 + " " * (idt) 152 + f'{compress}{" "*(idt)}'.join( 153 self._render_attribute(key, value) 154 for key, value in element.attributes.items() 155 ) 156 + f"{compress}{' '*(indent)}" 157 ) 158 elif len(element.attributes) == 1: 159 key, value = list(element.attributes.items())[0] 160 attrs = lead_space + self._render_attribute(key, value) 161 162 result = f"{' '*indent if not element.in_pre else ''}<{element.tag}{attrs}{'' if len(element) > 0 else '/'}>" 163 if len(element) == 0: 164 return result 165 166 if ( 167 compress != "\n" 168 or element.in_pre 169 or ( 170 element.tag not in ["script", "style", "python"] 171 and len(element) == 1 172 and Literal.is_text(element[0]) 173 and "\n" not in element[0].content 174 and "\n" not in result 175 ) 176 ): 177 children = self._render_tree_(element, _compress=compress) 178 result += children + f"</{element.tag}>" 179 else: 180 children = self._render_tree_(element, indent + 2, _compress=compress) 181 result += compress + children 182 result += f"{compress}{' '*indent}</{element.tag}>" 183 184 return result 185 186 def _render_literal( 187 self, 188 literal: Literal, 189 indent: int = 0, 190 compress: str = "\n", 191 ) -> str: 192 offset = " " * indent 193 if literal.in_pre: 194 offset = "" 195 compress = "" 196 content = literal.content 197 else: 198 content = literal.content.strip() 199 if compress == "\n": 200 content = normalize_indent(literal.content, indent) 201 content = content.strip() 202 elif not isinstance(literal.parent, AST) and literal.parent.tag in [ 203 "python", 204 "script", 205 "style", 206 ]: 207 content = normalize_indent(literal.content) 208 content = content.strip() 209 offset = "" 210 else: 211 lines = content.split("\n") 212 content = f"{compress}{offset}".join(lines) 213 214 if literal.name == LiteralType.Text: 215 return offset + content 216 217 if literal.name == LiteralType.Comment: 218 return f"{offset}<!--" + content + "-->" 219 return "" # pragma: no cover 220 221 def _render_tree_( 222 self, 223 node: Parent, 224 indent: int = 0, 225 _compress: str = "\n", 226 ): 227 result = [] 228 for child in node: 229 if isinstance(child, Element): 230 if child.tag == "doctype": 231 result.append("<!DOCTYPE html>") 232 else: 233 result.append(self._render_element(child, indent, _compress)) 234 elif isinstance(child, Literal): 235 result.append(self._render_literal(child, indent, _compress)) 236 else: 237 raise TypeError(f"Unknown renderable node type {type(child)}") 238 239 return _compress.join(result) 240 241 def render( 242 self, 243 node: Parent, 244 _compress: bool = False, 245 indent: int = 0, 246 ) -> str: 247 return self._render_tree_(node, indent, "" if _compress else "\n")
73class HypertextMarkupCompiler: 74 def _get_python_elements(self, node: Parent) -> list[Element]: 75 result = [] 76 for child in node: 77 if isinstance(child, Element): 78 if child.tag == "python": 79 result.append(child) 80 idx = node.index(child) 81 del node[idx] 82 else: 83 result.extend(self._get_python_elements(child)) 84 85 return result 86 87 def _process_scope_( 88 self, 89 node: Parent, 90 components: ComponentManager, 91 context: dict, 92 ): 93 """Process steps for a given scope/parent node.""" 94 95 # Core compile steps 96 for _step in __STEPS__: 97 _step(node, components, context) 98 99 # Recurse steps for each scope 100 for child in node: 101 if isinstance(child, Element): 102 self._process_scope_(child, components, context) 103 104 def compile( 105 self, node: Parent, _components: ComponentManager, **context: Any 106 ) -> Parent: 107 # get all python elements and process them 108 node = deepcopy(node) 109 p_elems = self._get_python_elements(node) 110 embedded = Embedded("") 111 for p_elem in p_elems: 112 embedded += Embedded(p_elem) 113 114 # Setup steps to collect data before comiling at different scopes 115 for step in __SETUP__: 116 step(node, _components, context) 117 118 # Recursively process scopes 119 context.update(embedded.context) 120 self._process_scope_(node, _components, context) 121 122 # Post compiling steps to finalize the ast 123 for step in __POST__: 124 step(node, _components, context) 125 126 return node 127 128 def _render_attribute(self, key: str, value: str | bool) -> str: 129 if isinstance(value, str): 130 return f'{key}="{value}"' 131 else: 132 return str(key) if value else f'{key}="false"' 133 134 def _render_element( 135 self, 136 element: Element, 137 indent: int = 0, 138 compress: str = "\n", 139 ) -> str: 140 attr_idt = 2 141 attrs = "" 142 lead_space = " " if len(element.attributes) > 0 else "" 143 if element.in_pre: 144 attrs = lead_space + " ".join( 145 self._render_attribute(key, value) 146 for key, value in element.attributes.items() 147 ) 148 elif len(element.attributes) > 1: 149 idt = indent + attr_idt if compress == "\n" else 1 150 attrs = ( 151 f"{compress}" 152 + " " * (idt) 153 + f'{compress}{" "*(idt)}'.join( 154 self._render_attribute(key, value) 155 for key, value in element.attributes.items() 156 ) 157 + f"{compress}{' '*(indent)}" 158 ) 159 elif len(element.attributes) == 1: 160 key, value = list(element.attributes.items())[0] 161 attrs = lead_space + self._render_attribute(key, value) 162 163 result = f"{' '*indent if not element.in_pre else ''}<{element.tag}{attrs}{'' if len(element) > 0 else '/'}>" 164 if len(element) == 0: 165 return result 166 167 if ( 168 compress != "\n" 169 or element.in_pre 170 or ( 171 element.tag not in ["script", "style", "python"] 172 and len(element) == 1 173 and Literal.is_text(element[0]) 174 and "\n" not in element[0].content 175 and "\n" not in result 176 ) 177 ): 178 children = self._render_tree_(element, _compress=compress) 179 result += children + f"</{element.tag}>" 180 else: 181 children = self._render_tree_(element, indent + 2, _compress=compress) 182 result += compress + children 183 result += f"{compress}{' '*indent}</{element.tag}>" 184 185 return result 186 187 def _render_literal( 188 self, 189 literal: Literal, 190 indent: int = 0, 191 compress: str = "\n", 192 ) -> str: 193 offset = " " * indent 194 if literal.in_pre: 195 offset = "" 196 compress = "" 197 content = literal.content 198 else: 199 content = literal.content.strip() 200 if compress == "\n": 201 content = normalize_indent(literal.content, indent) 202 content = content.strip() 203 elif not isinstance(literal.parent, AST) and literal.parent.tag in [ 204 "python", 205 "script", 206 "style", 207 ]: 208 content = normalize_indent(literal.content) 209 content = content.strip() 210 offset = "" 211 else: 212 lines = content.split("\n") 213 content = f"{compress}{offset}".join(lines) 214 215 if literal.name == LiteralType.Text: 216 return offset + content 217 218 if literal.name == LiteralType.Comment: 219 return f"{offset}<!--" + content + "-->" 220 return "" # pragma: no cover 221 222 def _render_tree_( 223 self, 224 node: Parent, 225 indent: int = 0, 226 _compress: str = "\n", 227 ): 228 result = [] 229 for child in node: 230 if isinstance(child, Element): 231 if child.tag == "doctype": 232 result.append("<!DOCTYPE html>") 233 else: 234 result.append(self._render_element(child, indent, _compress)) 235 elif isinstance(child, Literal): 236 result.append(self._render_literal(child, indent, _compress)) 237 else: 238 raise TypeError(f"Unknown renderable node type {type(child)}") 239 240 return _compress.join(result) 241 242 def render( 243 self, 244 node: Parent, 245 _compress: bool = False, 246 indent: int = 0, 247 ) -> str: 248 return self._render_tree_(node, indent, "" if _compress else "\n")
104 def compile( 105 self, node: Parent, _components: ComponentManager, **context: Any 106 ) -> Parent: 107 # get all python elements and process them 108 node = deepcopy(node) 109 p_elems = self._get_python_elements(node) 110 embedded = Embedded("") 111 for p_elem in p_elems: 112 embedded += Embedded(p_elem) 113 114 # Setup steps to collect data before comiling at different scopes 115 for step in __SETUP__: 116 step(node, _components, context) 117 118 # Recursively process scopes 119 context.update(embedded.context) 120 self._process_scope_(node, _components, context) 121 122 # Post compiling steps to finalize the ast 123 for step in __POST__: 124 step(node, _components, context) 125 126 return node
45def setup_step( 46 func: Callable[[AST, ComponentManager, dict[str, Any]], None] 47): # pragma: no cover 48 """Wrapper for setup compile processes. This wraps a function that takes an AST node, 49 the current context, and the component manager. The funciton is expected to mutate the AST recursively 50 51 Args: 52 Node (Parent): The parent node that is the current scope 53 components (ComponentManager): The manager instance for the components 54 context (dict[str, Any]): Additional global context from parent objects 55 56 Note: 57 There may be any combination of arguments, keyword only arguments, or catch alls with *arg and **kwarg. 58 This wrapper will predictably and automatically pass the arguments that are specified. 59 """ 60 61 @wraps(func) 62 def inner( 63 node: AST, 64 components: ComponentManager, 65 context: dict[str, Any], 66 ): 67 if not isinstance(node, AST): 68 raise TypeError( 69 f"Expected node to be an AST for step {func.__name__!r}." 70 + "Maybe try putting the step into the setup steps with add_step(<step>, 'setup')" 71 ) 72 return func(node, components, context) 73 74 return inner
Wrapper for setup compile processes. This wraps a function that takes an AST node, the current context, and the component manager. The funciton is expected to mutate the AST recursively
Args
- Node (Parent): The parent node that is the current scope
- components (ComponentManager): The manager instance for the components
- context (dict[str, Any]): Additional global context from parent objects
Note
There may be any combination of arguments, keyword only arguments, or catch alls with arg and *kwarg. This wrapper will predictably and automatically pass the arguments that are specified.
11def scoped_step( 12 func: Callable[[Parent, ComponentManager, dict[str, Any]], None] 13): # pragma: no cover 14 """Wrapper for compilation steps. This wraps a function that takes a parent node, 15 the current context, and component manager. The function is expected to mutate the children nodes. 16 It is also expected that the function is not recursive and only mutates the direct children of the node 17 passed in. 18 19 Args: 20 Node (Parent): The parent node that is the current scope 21 components (ComponentManager): The manager instance for the components 22 context (dict[str, Any]): Additional global context from parent objects 23 24 Note: 25 There may be any combination of arguments, keyword only arguments, or catch alls with *arg and **kwarg. 26 This wrapper will predictably and automatically pass the arguments that are specified. 27 """ 28 29 @wraps(func) 30 def inner( 31 node: Parent, 32 components: ComponentManager, 33 context: dict[str, Any], 34 ): 35 if not isinstance(node, Parent): 36 raise TypeError( 37 f"Expected node to be a parent for step {func.__name__!r}." 38 + "Maybe try putting the step into the scoped steps with add_step(<step>, 'scoped')" 39 ) 40 return func(node, components, context) 41 42 return inner
Wrapper for compilation steps. This wraps a function that takes a parent node, the current context, and component manager. The function is expected to mutate the children nodes. It is also expected that the function is not recursive and only mutates the direct children of the node passed in.
Args
- Node (Parent): The parent node that is the current scope
- components (ComponentManager): The manager instance for the components
- context (dict[str, Any]): Additional global context from parent objects
Note
There may be any combination of arguments, keyword only arguments, or catch alls with arg and *kwarg. This wrapper will predictably and automatically pass the arguments that are specified.
77def post_step( 78 func: Callable[[AST, ComponentManager, dict[str, Any]], None] 79): # pragma: no cover 80 """Wrapper for post compile processes. This wraps a function that takes an AST node, 81 the current context, and the component manager. The funciton is expected to mutate the AST recursively 82 83 Args: 84 Node (Parent): The parent node that is the current scope 85 components (ComponentManager): The manager instance for the components 86 context (dict[str, Any]): Additional global context from parent objects 87 88 Note: 89 There may be any combination of arguments, keyword only arguments, or catch alls with *arg and **kwarg. 90 This wrapper will predictably and automatically pass the arguments that are specified. 91 """ 92 93 @wraps(func) 94 def inner( 95 node: AST, 96 components: ComponentManager, 97 context: dict[str, Any], 98 ): 99 if not isinstance(node, AST): 100 raise TypeError( 101 f"Expected node to be an AST for step {func.__name__!r}." 102 + "Maybe try putting the step into the post steps with add_step(<step>, 'post')" 103 ) 104 return func(node, components, context) 105 106 return inner
Wrapper for post compile processes. This wraps a function that takes an AST node, the current context, and the component manager. The funciton is expected to mutate the AST recursively
Args
- Node (Parent): The parent node that is the current scope
- components (ComponentManager): The manager instance for the components
- context (dict[str, Any]): Additional global context from parent objects
Note
There may be any combination of arguments, keyword only arguments, or catch alls with arg and *kwarg. This wrapper will predictably and automatically pass the arguments that are specified.
49def add_step(step: Callable[[Parent, ComponentManager, dict[str, Any]], None]|Callable[[AST, ComponentManager, dict[str, Any]], None], stage: StepStage): 50 if stage == "setup": 51 __SETUP__.append(step) 52 elif stage == "scoped": 53 __STEPS__.append(step) 54 elif stage == "post": 55 __POST__.append(step)
65def remove_step(step: Callable[[Parent, ComponentManager, dict[str, Any]], None]|Callable[[AST, ComponentManager, dict[str, Any]], None], stage: StepStage): 66 if stage == "setup": 67 __SETUP__.remove(step) 68 elif stage == "scoped": 69 __STEPS__.remove(step) 70 elif stage == "post": 71 __POST__.remove(step)