phml.utils.transform.transform

phml.utils.transform.transform

Utility methods that revolve around transforming or manipulating the ast.

  1"""phml.utils.transform.transform
  2
  3Utility methods that revolve around transforming or manipulating the ast.
  4"""
  5
  6from typing import Callable, Optional
  7
  8from phml.nodes import AST, All_Nodes, Element, Root
  9from phml.utils.misc import heading_rank
 10from phml.utils.travel import walk
 11from phml.utils.validate.check import Test, check
 12
 13__all__ = [
 14    "filter_nodes",
 15    "remove_nodes",
 16    "map_nodes",
 17    "find_and_replace",
 18    "shift_heading",
 19    "replace_node",
 20]
 21
 22
 23def filter_nodes(
 24    tree: Root | Element | AST,
 25    condition: Test,
 26    strict: bool = True,
 27):
 28    """Take a given tree and filter the nodes with the condition.
 29    Only nodes passing the condition stay. If the parent node fails,
 30    all children are moved up in scope. Depth first
 31
 32    Same as remove_nodes but keeps the nodes that match.
 33
 34    Args:
 35        tree (Root | Element): The tree node to filter.
 36        condition (Test): The condition to apply to each node.
 37
 38    Returns:
 39        Root | Element: The given tree after being filtered.
 40    """
 41
 42    if tree.__class__.__name__ == "AST":
 43        tree = tree.tree
 44
 45    def filter_children(node):
 46        children = []
 47        for i, child in enumerate(node.children):
 48            if child.type in ["root", "element"]:
 49                node.children[i] = filter_children(node.children[i])
 50                if not check(child, condition, strict=strict):
 51                    for idx, _ in enumerate(child.children):
 52                        child.children[idx].parent = node
 53                    children.extend(node.children[i].children)
 54                else:
 55                    children.append(node.children[i])
 56            elif check(child, condition, strict=strict):
 57                children.append(node.children[i])
 58
 59        node.children = children
 60        if len(node.children) == 0 and isinstance(node, Element):
 61            node.startend = True
 62        return node
 63
 64    filter_children(tree)
 65
 66
 67def remove_nodes(
 68    tree: Root | Element | AST,
 69    condition: Test,
 70    strict: bool = True,
 71):
 72    """Take a given tree and remove the nodes that match the condition.
 73    If a parent node is removed so is all the children.
 74
 75    Same as filter_nodes except removes nodes that match.
 76
 77    Args:
 78        tree (Root | Element): The parent node to start recursively removing from.
 79        condition (Test): The condition to apply to each node.
 80    """
 81    if tree.__class__.__name__ == "AST":
 82        tree = tree.tree
 83
 84    def filter_children(node):
 85        node.children = [n for n in node.children if not check(n, condition, strict=strict)]
 86        for child in node.children:
 87            if child.type in ["root", "element"]:
 88                filter_children(child)
 89
 90        if len(node.children) == 0 and isinstance(node, Element):
 91            node.startend = True
 92
 93    filter_children(tree)
 94
 95
 96def map_nodes(tree: Root | Element | AST, transform: Callable):
 97    """Takes a tree and a callable that returns a node and maps each node.
 98
 99    Signature for the transform function should be as follows:
100
101    1. Takes a single argument that is the node.
102    2. Returns any type of node that is assigned to the original node.
103
104    ```python
105    def to_links(node):
106        return Element("a", {}, node.parent, children=node.children)
107            if node.type == "element"
108            else node
109    ```
110
111    Args:
112        tree (Root | Element): Tree to transform.
113        transform (Callable): The Callable that returns a node that is assigned
114        to each node.
115    """
116
117    if tree.__class__.__name__ == "AST":
118        tree = tree.tree
119
120    def recursive_map(node):
121        for i, child in enumerate(node.children):
122            if isinstance(child, Element):
123                recursive_map(node.children[i])
124                node.children[i] = transform(child)
125            else:
126                node.children[i] = transform(child)
127
128    recursive_map(tree)
129
130
131def replace_node(
132    start: Root | Element,
133    condition: Test,
134    replacement: Optional[All_Nodes | list[All_Nodes]],
135    strict: bool = True,
136):
137    """Search for a specific node in the tree and replace it with either
138    a node or list of nodes. If replacement is None the found node is just removed.
139
140    Args:
141        start (Root | Element): The starting point.
142        condition (test): Test condition to find the correct node.
143        replacement (All_Nodes | list[All_Nodes] | None): What to replace the node with.
144    """
145    for node in walk(start):
146        if check(node, condition, strict=strict):
147            if node.parent is not None:
148                idx = node.parent.children.index(node)
149                if replacement is not None:
150                    node.parent.children = (
151                        node.parent.children[:idx] + replacement + node.parent.children[idx + 1 :]
152                        if isinstance(replacement, list)
153                        else node.parent.children[:idx]
154                        + [replacement]
155                        + node.parent.children[idx + 1 :]
156                    )
157                else:
158                    node.parent.children.pop(idx)
159
160
161def find_and_replace(start: Root | Element, *replacements: tuple[str, str | Callable]) -> int:
162    """Takes a ast, root, or any node and replaces text in `text`
163    nodes with matching replacements.
164
165    First value in each replacement tuple is the regex to match and
166    the second value is what to replace it with. This can either be
167    a string or a callable that returns a string or a new node. If
168    a new node is returned then the text element will be split.
169    """
170    from re import finditer  # pylint: disable=import-outside-toplevel
171
172    for node in walk(start):
173        if node.type == "text":
174            for replacement in replacements:
175                if isinstance(replacement[1], str):
176                    for match in finditer(replacement[0], node.value):
177                        node.value = (
178                            node.value[: match.start()] + replacement[1] + node.value[match.end() :]
179                        )
180                else:
181                    raise NotImplementedError(
182                        "Callables are not yet supported for find_and_replace operations."
183                    )
184                # tada add ability to inject nodes in place of text replacement
185                # elif isinstance(replacement[1], Callable):
186                #     for match in finditer(replacement[0], n.value):
187                #         result = replacement[1](match.group())
188                #         if isinstance(result, str):
189                #             n.value = n.value[:match.start()]
190                #             + replacement[1]
191                #             + n.value[match.end():]
192                #         elif isinstance(result, All_Nodes):
193                #             pass
194                #         elif isinstance(result, list):
195                #             pass
196
197
198def shift_heading(node: Element, amount: int):
199    """Shift the heading by the amount specified.
200
201    value is clamped between 1 and 6.
202    """
203
204    rank = heading_rank(node)
205    rank += amount
206
207    node.tag = f"h{min(6, max(1, rank))}"
208
209
210def modify_children(func):
211    """Function wrapper that when called and passed an
212    AST, Root, or Element will apply the wrapped function
213    to each child. This means that whatever is returned
214    from the wrapped function will be assigned to the child.
215
216    The wrapped function will be passed the child node,
217    the index in the parents children, and the parent node
218    """
219    from phml.utils import visit_children  # pylint: disable=import-outside-toplevel
220
221    def inner(start: AST | Element | Root):
222        if isinstance(start, AST):
223            start = start.tree
224
225        for idx, child in enumerate(visit_children(start)):
226            start.children[idx] = func(child, idx, child.parent)
227
228    return inner
def filter_nodes( tree: phml.nodes.root.Root | phml.nodes.element.Element | phml.nodes.AST.AST, condition: Union[NoneType, str, list, dict, Callable], strict: bool = True):
24def filter_nodes(
25    tree: Root | Element | AST,
26    condition: Test,
27    strict: bool = True,
28):
29    """Take a given tree and filter the nodes with the condition.
30    Only nodes passing the condition stay. If the parent node fails,
31    all children are moved up in scope. Depth first
32
33    Same as remove_nodes but keeps the nodes that match.
34
35    Args:
36        tree (Root | Element): The tree node to filter.
37        condition (Test): The condition to apply to each node.
38
39    Returns:
40        Root | Element: The given tree after being filtered.
41    """
42
43    if tree.__class__.__name__ == "AST":
44        tree = tree.tree
45
46    def filter_children(node):
47        children = []
48        for i, child in enumerate(node.children):
49            if child.type in ["root", "element"]:
50                node.children[i] = filter_children(node.children[i])
51                if not check(child, condition, strict=strict):
52                    for idx, _ in enumerate(child.children):
53                        child.children[idx].parent = node
54                    children.extend(node.children[i].children)
55                else:
56                    children.append(node.children[i])
57            elif check(child, condition, strict=strict):
58                children.append(node.children[i])
59
60        node.children = children
61        if len(node.children) == 0 and isinstance(node, Element):
62            node.startend = True
63        return node
64
65    filter_children(tree)

Take a given tree and filter the nodes with the condition. Only nodes passing the condition stay. If the parent node fails, all children are moved up in scope. Depth first

Same as remove_nodes but keeps the nodes that match.

Args
  • tree (Root | Element): The tree node to filter.
  • condition (Test): The condition to apply to each node.
Returns

Root | Element: The given tree after being filtered.

def remove_nodes( tree: phml.nodes.root.Root | phml.nodes.element.Element | phml.nodes.AST.AST, condition: Union[NoneType, str, list, dict, Callable], strict: bool = True):
68def remove_nodes(
69    tree: Root | Element | AST,
70    condition: Test,
71    strict: bool = True,
72):
73    """Take a given tree and remove the nodes that match the condition.
74    If a parent node is removed so is all the children.
75
76    Same as filter_nodes except removes nodes that match.
77
78    Args:
79        tree (Root | Element): The parent node to start recursively removing from.
80        condition (Test): The condition to apply to each node.
81    """
82    if tree.__class__.__name__ == "AST":
83        tree = tree.tree
84
85    def filter_children(node):
86        node.children = [n for n in node.children if not check(n, condition, strict=strict)]
87        for child in node.children:
88            if child.type in ["root", "element"]:
89                filter_children(child)
90
91        if len(node.children) == 0 and isinstance(node, Element):
92            node.startend = True
93
94    filter_children(tree)

Take a given tree and remove the nodes that match the condition. If a parent node is removed so is all the children.

Same as filter_nodes except removes nodes that match.

Args
  • tree (Root | Element): The parent node to start recursively removing from.
  • condition (Test): The condition to apply to each node.
def map_nodes( tree: phml.nodes.root.Root | phml.nodes.element.Element | phml.nodes.AST.AST, transform: Callable):
 97def map_nodes(tree: Root | Element | AST, transform: Callable):
 98    """Takes a tree and a callable that returns a node and maps each node.
 99
100    Signature for the transform function should be as follows:
101
102    1. Takes a single argument that is the node.
103    2. Returns any type of node that is assigned to the original node.
104
105    ```python
106    def to_links(node):
107        return Element("a", {}, node.parent, children=node.children)
108            if node.type == "element"
109            else node
110    ```
111
112    Args:
113        tree (Root | Element): Tree to transform.
114        transform (Callable): The Callable that returns a node that is assigned
115        to each node.
116    """
117
118    if tree.__class__.__name__ == "AST":
119        tree = tree.tree
120
121    def recursive_map(node):
122        for i, child in enumerate(node.children):
123            if isinstance(child, Element):
124                recursive_map(node.children[i])
125                node.children[i] = transform(child)
126            else:
127                node.children[i] = transform(child)
128
129    recursive_map(tree)

Takes a tree and a callable that returns a node and maps each node.

Signature for the transform function should be as follows:

  1. Takes a single argument that is the node.
  2. Returns any type of node that is assigned to the original node.
def to_links(node):
    return Element("a", {}, node.parent, children=node.children)
        if node.type == "element"
        else node
Args
  • tree (Root | Element): Tree to transform.
  • transform (Callable): The Callable that returns a node that is assigned
  • to each node.
def find_and_replace( start: phml.nodes.root.Root | phml.nodes.element.Element, *replacements: tuple[str, typing.Union[str, typing.Callable]]) -> int:
162def find_and_replace(start: Root | Element, *replacements: tuple[str, str | Callable]) -> int:
163    """Takes a ast, root, or any node and replaces text in `text`
164    nodes with matching replacements.
165
166    First value in each replacement tuple is the regex to match and
167    the second value is what to replace it with. This can either be
168    a string or a callable that returns a string or a new node. If
169    a new node is returned then the text element will be split.
170    """
171    from re import finditer  # pylint: disable=import-outside-toplevel
172
173    for node in walk(start):
174        if node.type == "text":
175            for replacement in replacements:
176                if isinstance(replacement[1], str):
177                    for match in finditer(replacement[0], node.value):
178                        node.value = (
179                            node.value[: match.start()] + replacement[1] + node.value[match.end() :]
180                        )
181                else:
182                    raise NotImplementedError(
183                        "Callables are not yet supported for find_and_replace operations."
184                    )
185                # tada add ability to inject nodes in place of text replacement
186                # elif isinstance(replacement[1], Callable):
187                #     for match in finditer(replacement[0], n.value):
188                #         result = replacement[1](match.group())
189                #         if isinstance(result, str):
190                #             n.value = n.value[:match.start()]
191                #             + replacement[1]
192                #             + n.value[match.end():]
193                #         elif isinstance(result, All_Nodes):
194                #             pass
195                #         elif isinstance(result, list):
196                #             pass

Takes a ast, root, or any node and replaces text in text nodes with matching replacements.

First value in each replacement tuple is the regex to match and the second value is what to replace it with. This can either be a string or a callable that returns a string or a new node. If a new node is returned then the text element will be split.

def shift_heading(node: phml.nodes.element.Element, amount: int):
199def shift_heading(node: Element, amount: int):
200    """Shift the heading by the amount specified.
201
202    value is clamped between 1 and 6.
203    """
204
205    rank = heading_rank(node)
206    rank += amount
207
208    node.tag = f"h{min(6, max(1, rank))}"

Shift the heading by the amount specified.

value is clamped between 1 and 6.

def replace_node( start: phml.nodes.root.Root | phml.nodes.element.Element, condition: Union[NoneType, str, list, dict, Callable], replacement: Union[phml.nodes.root.Root, phml.nodes.element.Element, phml.nodes.text.Text, phml.nodes.comment.Comment, phml.nodes.doctype.DocType, phml.nodes.parent.Parent, phml.nodes.node.Node, phml.nodes.literal.Literal, list[phml.nodes.root.Root | phml.nodes.element.Element | phml.nodes.text.Text | phml.nodes.comment.Comment | phml.nodes.doctype.DocType | phml.nodes.parent.Parent | phml.nodes.node.Node | phml.nodes.literal.Literal], NoneType], strict: bool = True):
132def replace_node(
133    start: Root | Element,
134    condition: Test,
135    replacement: Optional[All_Nodes | list[All_Nodes]],
136    strict: bool = True,
137):
138    """Search for a specific node in the tree and replace it with either
139    a node or list of nodes. If replacement is None the found node is just removed.
140
141    Args:
142        start (Root | Element): The starting point.
143        condition (test): Test condition to find the correct node.
144        replacement (All_Nodes | list[All_Nodes] | None): What to replace the node with.
145    """
146    for node in walk(start):
147        if check(node, condition, strict=strict):
148            if node.parent is not None:
149                idx = node.parent.children.index(node)
150                if replacement is not None:
151                    node.parent.children = (
152                        node.parent.children[:idx] + replacement + node.parent.children[idx + 1 :]
153                        if isinstance(replacement, list)
154                        else node.parent.children[:idx]
155                        + [replacement]
156                        + node.parent.children[idx + 1 :]
157                    )
158                else:
159                    node.parent.children.pop(idx)

Search for a specific node in the tree and replace it with either a node or list of nodes. If replacement is None the found node is just removed.

Args
  • start (Root | Element): The starting point.
  • condition (test): Test condition to find the correct node.
  • replacement (All_Nodes | list[All_Nodes] | None): What to replace the node with.