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")
class HypertextMarkupCompiler:
 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")
HypertextMarkupCompiler()
def compile( self, node: phml.nodes.Parent, _components: phml.components.ComponentManager, **context: Any) -> phml.nodes.Parent:
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
def render( self, node: phml.nodes.Parent, _compress: bool = False, indent: int = 0) -> str:
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")
def setup_step( func: Callable[[phml.nodes.AST, phml.components.ComponentManager, dict[str, Any]], NoneType]):
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.

def scoped_step( func: Callable[[phml.nodes.Parent, phml.components.ComponentManager, dict[str, Any]], NoneType]):
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.

def post_step( func: Callable[[phml.nodes.AST, phml.components.ComponentManager, dict[str, Any]], NoneType]):
 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.

def add_step( step: collections.abc.Callable[[phml.nodes.Parent, phml.components.ComponentManager, dict[str, typing.Any]], None] | collections.abc.Callable[[phml.nodes.AST, phml.components.ComponentManager, dict[str, typing.Any]], None], stage: Literal['setup', 'scoped', 'post']):
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)
def remove_step( step: collections.abc.Callable[[phml.nodes.Parent, phml.components.ComponentManager, dict[str, typing.Any]], None] | collections.abc.Callable[[phml.nodes.AST, phml.components.ComponentManager, dict[str, typing.Any]], None], stage: Literal['setup', 'scoped', 'post']):
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)