Coverage for phml\nodes.py: 99%
163 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-06 15:20 -0500
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-06 15:20 -0500
1from __future__ import annotations
3from enum import StrEnum, unique
4from types import NoneType
5from typing import Any, Iterator, NoReturn, TypeAlias, overload
7from saimll import SAIML
9Attribute: TypeAlias = str | bool
12class Missing:
13 pass
16MISSING = Missing()
19def p_code(value) -> str: # pragma: no cover
20 """Get python code representation of phml nodes."""
21 if value is None:
22 return "None"
23 return value.__p_code__()
26@unique
27class LiteralType(StrEnum):
28 Text = "text"
29 Comment = "comment"
31 @staticmethod
32 def From(type: str) -> str:
33 types = ["text", "comment"]
34 if type in types:
35 return type
36 raise ValueError(f"Expected on of {', '.join(types)}")
39@unique
40class NodeType(StrEnum):
41 AST = "ast"
42 ELEMENT = "element"
43 LITERAL = "literal"
46class Point:
47 """Represents one place in a source file.
49 The line field (1-indexed integer) represents a line in a source file. The column field
50 (1-indexed integer) represents a column in a source file. The offset field (0-indexed integer)
51 represents a character in a source file.
52 """
54 def __init__(self, line: int, column: int) -> None:
55 if line is None or line < 0:
56 raise IndexError(f"Point.line must be >= 0 but was {line}")
58 self.line = line
60 if column is None or column < 0:
61 raise IndexError(f"Point.column must be >= 0 but was {column}")
63 self.column = column
65 def __eq__(self, _o) -> bool:
66 return (
67 isinstance(_o, self.__class__)
68 and _o.line == self.line
69 and _o.column == self.column
70 )
72 @staticmethod
73 def from_dict(data: dict) -> Point:
74 return Point(data["line"], data["column"])
76 def __p_code__(self) -> str:
77 return f"Point({self.line}, {self.column})"
79 def __repr__(self) -> str:
80 return f"{self.line}:{self.column}"
82 def __str__(self) -> str:
83 return f"\x1b[38;5;244m{self.line}:{self.column}\x1b[39m"
86class Position:
87 """Position represents the location of a node in a source file.
89 The `start` field of `Position` represents the place of the first character
90 of the parsed source region. The `end` field of Position represents the place
91 of the first character after the parsed source region, whether it exists or not.
92 The value of the `start` and `end` fields implement the `Point` interface.
94 The `indent` field of `Position` represents the start column at each index
95 (plus start line) in the source region, for elements that span multiple lines.
97 If the syntactic unit represented by a node is not present in the source file at
98 the time of parsing, the node is said to be `generated` and it must not have positional
99 information.
100 """
102 @overload
103 def __init__(
104 self,
105 start: Point,
106 end: Point,
107 ) -> None:
108 """
109 Args:
110 start (tuple[int, int, int | None]): Tuple representing the line, column, and optional
111 offset of the start point.
112 end (tuple[int, int, int | None]): Tuple representing the line, column, and optional
113 offset of the end point.
114 indent (Optional[int], optional): The indent amount for the start of the position.
115 """
116 ...
118 @overload
119 def __init__(
120 self,
121 start: tuple[int, int],
122 end: tuple[int, int],
123 ) -> None:
124 """
125 Args:
126 start (tuple[int, int, int | None]): Tuple representing the line, column, and optional
127 offset of the start point.
128 end (tuple[int, int, int | None]): Tuple representing the line, column, and optional
129 offset of the end point.
130 indent (Optional[int], optional): The indent amount for the start of the position.
131 """
132 ...
134 def __init__(self, start: Point | tuple[int, int], end: Point | tuple[int, int]):
135 """
136 Args:
137 start (Point): Starting point of the position.
138 end (Point): End point of the position.
139 indent (int | None): The indent amount for the start of the position.
140 """
142 self.start = Point(start[0], start[1]) if isinstance(start, tuple) else start
143 self.end = Point(end[0], end[1]) if isinstance(end, tuple) else end
145 def __p_code__(self) -> str:
146 return f"Position({p_code(self.start)}, {p_code(self.end)})"
148 def __eq__(self, _o):
149 return (
150 isinstance(_o, Position) and _o.start == self.start and _o.end == self.end
151 )
153 @staticmethod
154 def from_pos(pos: Position) -> Position:
155 """Create a new position from another position object."""
156 return Position(
157 (pos.start.line, pos.start.column),
158 (pos.end.line, pos.end.column),
159 )
161 @staticmethod
162 def from_dict(data: dict) -> Position | None:
163 if data is None:
164 return None
165 return Position(Point.from_dict(data["start"]), Point.from_dict(data["end"]))
167 def as_dict(self) -> dict:
168 """Convert the position object to a dict."""
169 return {
170 "start": {
171 "line": self.start.line,
172 "column": self.start.column,
173 },
174 "end": {
175 "line": self.end.line,
176 "column": self.end.column,
177 },
178 }
180 def __repr__(self) -> str:
181 # indent = f" ~ {self.indent}" if self.indent is not None else ""
182 return f"<{self.start!r}-{self.end!r}>"
184 def __str__(self) -> str:
185 return f"\x1b[38;5;8m<\x1b[39m{self.start}\x1b[38;5;8m-\x1b[39m{self.end}\x1b[38;5;8m>\x1b[39m"
188class Node:
189 """Base phml node. Defines a type and basic interactions."""
191 def __init__(
192 self,
193 _type: NodeType,
194 position: Position | None = None,
195 parent: Parent | None = None,
196 in_pre: bool = False,
197 ) -> None:
198 self._position = position
199 self.parent = parent
200 self._type = _type
201 self.in_pre = in_pre
203 def __p_code__(self) -> str:
204 in_pre = f", in_pre={self.in_pre}" if self.in_pre else ""
205 return f"Node({self.type!r}, position={p_code(self.position)}{in_pre})"
207 def __eq__(self, _o):
208 return (
209 isinstance(_o, self.__class__)
210 and self.type == _o.type
211 and self.in_pre == _o.in_pre
212 )
214 def as_dict(self) -> dict:
215 return {
216 "type": str(self._type),
217 }
219 @staticmethod
220 def from_dict(data: dict, in_pre: bool = False):
221 if data["type"] == NodeType.AST:
222 ast = AST(
223 children=[] if data["children"] is not None else None,
224 )
225 if data["children"] is not None:
226 for child in data["children"]:
227 ast.append(Node.from_dict(child, in_pre))
228 return ast
229 elif data["type"] == NodeType.ELEMENT:
230 return Element.from_dict(data)
231 elif data["type"] == NodeType.LITERAL:
232 return Literal(
233 LiteralType.From(data["name"]),
234 data["content"],
235 )
236 raise ValueError(
237 f"Phml ast dicts must have nodes with the following types: {NodeType.AST}, {NodeType.ELEMENT}, {NodeType.LITERAL}",
238 )
240 @property
241 def position(self) -> Position | None:
242 """The position of the node in the parsed phml text.
243 Is `None` if the node was generated.
244 """
245 return self._position
247 @property
248 def type(self) -> str:
249 """The node type. Either root, element, or litera."""
250 return self._type
252 def pos_as_str(self, color: bool = False) -> str: # pragma: no cover
253 """Return the position formatted as a string."""
255 position = ""
256 if self.position is not None:
257 if color:
258 start = self.position.start
259 end = self.position.end
260 position = SAIML.parse(
261 f"<[@F244]{start.line}[@F]-[@F244]{start.column}[@F]"
262 f":[@F244]{end.line}[@F]-[@F244]{end.column}[@F]>",
263 )
264 else:
265 start = self.position.start
266 end = self.position.end
267 position = f"<{start.line}-{start.column}:{end.line}-{end.column}>"
268 return position
270 def __repr__(self) -> str:
271 return f"{self.type}()"
273 def __format__(self, indent: int = 0, color: bool = False, text: bool = False):
274 if color:
275 return (
276 SAIML.parse(f"{' '*indent}[@Fred]{self.type}[@F]")
277 + f" {self.pos_as_str(True)}"
278 )
279 return f"{' '*indent}{self.type} {self.pos_as_str()}"
281 def __str__(self) -> str:
282 return self.__format__()
285class Parent(Node):
286 def __init__(
287 self,
288 _type: NodeType,
289 children: list[Node] | None,
290 position: Position | None = None,
291 parent: Parent | None = None,
292 in_pre: bool = False,
293 ) -> None:
294 super().__init__(_type, position, parent, in_pre)
295 self.children = [] if children is not None else None
297 if children is not None:
298 self.extend(children)
300 def __p_code__(self) -> str:
301 children = (
302 "None"
303 if self.children is None
304 else f"[{', '.join([p_code(child) for child in self])}]"
305 )
306 in_pre = f", in_pre={self.in_pre}" if self.in_pre else ""
307 return f"Parent({self.type!r}, position={p_code(self.position)}{in_pre}, children={children})"
309 def __iter__(self) -> Iterator[Parent | Literal]:
310 if self.children is not None:
311 yield from self.children
313 @overload
314 def __setitem__(self, key: int, value: Node) -> NoReturn:
315 ...
317 @overload
318 def __setitem__(self, key: slice, value: list) -> NoReturn:
319 ...
321 def __setitem__(self, key: int | slice, value: Node | list):
322 if self.children is not None:
323 if isinstance(key, int):
324 if not isinstance(value, Node):
325 raise ValueError(
326 "Can not assign value that is not phml.Node to children",
327 )
328 value.parent = self
329 self.children[key] = value
330 elif isinstance(key, slice):
331 if not isinstance(value, list):
332 raise ValueError(
333 "Can not assign value that is not list[phml.Node] to slice of children",
334 )
335 for v in value:
336 v.parent = self
337 self.children[key] = value
338 else:
339 raise ValueError("Invalid value type. Expected phml Node")
341 @overload
342 def __getitem__(self, _k: int) -> Parent | Literal:
343 ...
345 @overload
346 def __getitem__(self, _k: slice) -> list[Parent | Literal]:
347 ...
349 def __getitem__(
350 self,
351 key: int | slice,
352 ) -> Parent | Literal | list[Parent | Literal]:
353 if self.children is not None:
354 return self.children[key]
355 raise ValueError("A self closing element can not be indexed")
357 @overload
358 def __delitem__(self, key: int) -> NoReturn:
359 ...
361 @overload
362 def __delitem__(self, key: slice) -> NoReturn:
363 ...
365 def __delitem__(self, key: int | slice):
366 if self.children is not None:
367 del self.children[key]
368 else:
369 raise ValueError("Can not use del for a self closing elements children")
371 def pop(self, idx: int = 0) -> Node:
372 """Pop a node from the children. Defaults to index 0"""
373 if self.children is not None:
374 return self.children.pop(idx)
375 raise ValueError("A self closing element can not pop a child node")
377 def index(self, node: Node) -> int:
378 """Get the index of a node in the children."""
379 if self.children is not None:
380 return self.children.index(node)
381 raise ValueError("A self closing element can not be indexed")
383 def append(self, node: Node):
384 """Append a child node to the end of the children."""
385 if self.children is not None:
386 node.parent = self
387 self.children.append(node)
388 else:
389 raise ValueError(
390 "A child node can not be appended to a self closing element",
391 )
393 def extend(self, nodes: list):
394 """Extend the children with a list of nodes."""
395 if self.children is not None:
396 for child in nodes:
397 child.parent = self
398 self.children.extend(nodes)
399 else:
400 raise ValueError(
401 "A self closing element can not have it's children extended",
402 )
404 def insert(self, index: int, nodes: Node | list):
405 """Insert a child node or nodes into a specific index of the children."""
406 if self.children is not None:
407 if isinstance(nodes, list):
408 for n in nodes:
409 n.parent = self
410 self.children[index:index] = nodes
411 else:
412 self.children.insert(index, nodes)
413 else:
414 raise ValueError(
415 "A child node can not be inserted into a self closing element",
416 )
418 def remove(self, node: Node):
419 """Remove a child node from the children."""
420 if self.children is None:
421 raise ValueError(
422 "A child node can not be removed from a self closing element.",
423 )
424 self.children.remove(node)
426 def len_as_str(self, color: bool = False) -> str: # pragma: no cover
427 if color:
428 return SAIML.parse(
429 f"[@F66]{len(self) if self.children is not None else '/'}[@F]",
430 )
431 return f"{len(self) if self.children is not None else '/'}"
433 def __len__(self) -> int:
434 return len(self.children) if self.children is not None else 0
436 def __repr__(self) -> str:
437 return f"{self.type}(cldrn={self.len_as_str()})"
439 def __format__(self, indent: int = 0, color: bool = False, text: bool = False):
440 output = [f"{' '*indent}{self.type} [{self.len_as_str()}]{self.pos_as_str()}"]
441 if color:
442 output[0] = (
443 SAIML.parse(f"{' '*indent}[@Fred]{self.type}[@F]")
444 + f" [{self.len_as_str(True)}]"
445 + f" {self.pos_as_str(True)}"
446 )
447 for child in self.children or []:
448 output.extend(child.__format__(indent=indent + 2, color=color, text=text))
449 return output
451 def __str__(self) -> str:
452 return "\n".join(self.__format__())
454 def as_dict(self) -> dict:
455 return {
456 "children": [child.as_dict() for child in self.children]
457 if self.children is not None
458 else None,
459 **super().as_dict(),
460 }
463class AST(Parent):
464 def __init__(
465 self,
466 children: list[Node] | None = None,
467 position: Position | None = None,
468 in_pre: bool = False,
469 ) -> None:
470 super().__init__(NodeType.AST, children or [], position, None, in_pre)
472 def __eq__(self, _o):
473 return isinstance(_o, AST) and (
474 (_o.children is None and self.children is None)
475 or (len(_o) == len(self) and all(c1 == c2 for c1, c2 in zip(_o, self)))
476 )
478 def __p_code__(self) -> str:
479 children = (
480 "None"
481 if self.children is None
482 else f"[{', '.join([p_code(child) for child in self])}]"
483 )
484 in_pre = f", in_pre={self.in_pre}" if self.in_pre else ""
485 return f"AST(position={p_code(self.position)}, children={children}{in_pre})"
488class Element(Parent):
489 def __init__(
490 self,
491 tag: str,
492 attributes: dict[str, Attribute] | None = None,
493 children: list[Node] | None = None,
494 position: Position | None = None,
495 parent: Parent | None = None,
496 in_pre: bool = False,
497 ) -> None:
498 super().__init__(NodeType.ELEMENT, children, position, parent, in_pre)
499 self.tag = tag
500 self.attributes = attributes or {}
501 self.context = {}
503 def __p_code__(self) -> str:
504 children = (
505 "None"
506 if self.children is None
507 else f"[{', '.join([p_code(child) for child in self])}]"
508 )
509 in_pre = f", in_pre={self.in_pre}" if self.in_pre else ""
510 return f"Element({self.tag!r}, position={p_code(self.position)}, attributes={self.attributes}, children={children}{in_pre})"
512 def __eq__(self, _o) -> bool:
513 return (
514 isinstance(_o, Element)
515 and _o.tag == self.tag
516 and (
517 len(self.attributes) == len(_o.attributes)
518 and all(key in self.attributes for key in _o.attributes)
519 and all(_o.attributes[key] == value for key,value in self.attributes.items())
520 )
521 and (
522 (_o.children is None and self.children is None)
523 or (len(_o) == len(self) and all(c1 == c2 for c1, c2 in zip(_o, self)))
524 )
525 )
527 def as_dict(self) -> dict:
528 return {"tag": self.tag, "attributes": self.attributes, **super().as_dict()}
530 @staticmethod
531 def from_dict(data: dict, in_pre: bool = False) -> Element:
532 element = Element(
533 data["tag"],
534 attributes=data["attributes"],
535 children=[] if data["children"] is not None else None,
536 )
537 if data["children"] is not None:
538 element.children = [
539 Node.from_dict(child, in_pre or data["tag"] == "pre")
540 for child in data["children"]
541 ]
542 return element
544 @property
545 def tag_path(self) -> list[str]:
546 """Get the list of all the tags to the current element. Inclusive."""
547 path = [self.tag]
548 parent = self
549 while isinstance(parent.parent, Element):
550 path.append(parent.parent.tag)
551 parent = parent.parent
553 path.reverse()
554 return path
556 def __hash__(self) -> int:
557 return (
558 hash(self.tag)
559 + sum(hash(attr) for attr in self.attributes.values())
560 + hash(len(self))
561 )
563 def __contains__(self, _k: str) -> bool:
564 return _k in self.attributes
566 @overload
567 def __getitem__(self, _k: int) -> Parent | Literal:
568 ...
570 @overload
571 def __getitem__(self, _k: str) -> Attribute:
572 ...
574 @overload
575 def __getitem__(self, _k: slice) -> list[Parent | Literal]:
576 ...
578 def __getitem__(
579 self,
580 _k: str | int | slice,
581 ) -> Attribute | Parent | Literal | list[Parent | Literal]:
582 if isinstance(_k, str):
583 return self.attributes[_k]
585 if self.children is not None:
586 return self.children[_k]
588 raise ValueError("A self closing element can not have it's children indexed")
590 @overload
591 def __setitem__(self, key: int, value: Node) -> NoReturn:
592 ...
594 @overload
595 def __setitem__(self, key: slice, value: list) -> NoReturn:
596 ...
598 @overload
599 def __setitem__(self, key: str, value: Attribute) -> NoReturn:
600 ...
602 def __setitem__(self, key: str | int | slice, value: Attribute | Node | list):
603 if isinstance(key, str) and isinstance(value, Attribute):
604 self.attributes[key] = value
605 elif self.children is not None:
606 if isinstance(key, int) and isinstance(value, Node):
607 value.parent = self
608 self.children[key] = value
609 elif isinstance(key, slice) and isinstance(value, list):
610 for child in value:
611 child.parent = self
612 self.children[key] = value
613 else:
614 raise ValueError(
615 "A self closing element can not have a subset of it's children assigned to",
616 )
618 @overload
619 def __delitem__(self, key: int) -> NoReturn:
620 ...
622 @overload
623 def __delitem__(self, key: slice) -> NoReturn:
624 ...
626 @overload
627 def __delitem__(self, key: str) -> NoReturn:
628 ...
630 def __delitem__(self, key: str | int | slice):
631 if isinstance(key, str):
632 del self.attributes[key]
633 elif self.children is not None:
634 del self.children[key]
635 else:
636 raise ValueError("Can not use del for a self closing elements children")
638 @overload
639 def pop(self, idx: int = 0) -> Node:
640 ...
642 @overload
643 def pop(self, idx: str, _default: Any = MISSING) -> Attribute:
644 ...
646 def pop(self, idx: str | int = 0, _default: Any = MISSING) -> Attribute | Node:
647 """Pop a specific attribute from the elements attributes. A default value
648 can be provided for when the value is not found, otherwise an error is thrown.
649 """
650 if isinstance(idx, str):
651 if _default != MISSING:
652 return self.attributes.pop(idx, _default)
653 return self.attributes.pop(idx)
654 if self.children is not None:
655 return self.children.pop(idx)
657 raise ValueError("A self closing element can not pop a child node")
659 def get(self, key: str, _default: Any = MISSING) -> Attribute:
660 """Get a specific element attribute. Returns `None` if not found
661 unless `_default` is defined.
663 Args:
664 key (str): The name of the attribute to retrieve.
665 _default (str|bool): The default value to return if the key
666 isn't an attribute.
668 Returns:
669 str|bool|None: str or bool if the attribute exists or a default
670 was provided, else None
671 """
672 if not isinstance(_default, (Attribute, NoneType)) and _default != MISSING:
673 raise TypeError("_default value must be str, bool, or MISSING")
675 if key in self:
676 return self[key]
677 if _default != MISSING:
678 return _default
679 raise ValueError(f"Attribute {key!r} not found")
681 def attrs_as_str(self, indent: int, color: bool = False) -> str: # pragma: no cover
682 """Return a str representation of the attributes"""
683 if color:
684 attrs = (
685 (
686 f"\n{' '*(indent)}▸ "
687 + f"\n{' '*(indent)}▸ ".join(
688 str(key)
689 + ": "
690 + (
691 f"\x1b[32m{value!r}\x1b[39m"
692 if isinstance(value, str)
693 else f"\x1b[35m{value}\x1b[39m"
694 )
695 for key, value in self.attributes.items()
696 )
697 )
698 if len(self.attributes) > 0
699 else ""
700 )
701 else:
702 attrs = (
703 (
704 f"\n{' '*(indent)}▸ "
705 + f"\n{' '*(indent)}▸ ".join(
706 f"{key}: {value!r}" for key, value in self.attributes.items()
707 )
708 )
709 if len(self.attributes) > 0
710 else ""
711 )
713 return attrs
715 def __repr__(self) -> str:
716 return f"{self.type}.{self.tag}(cldrn={self.len_as_str()}, attrs={self.attributes})"
718 def __format__(
719 self,
720 indent: int = 0,
721 color: bool = False,
722 text: bool = False,
723 ) -> list[str]: # pragma: no cover
724 output: list[str] = []
725 if color:
726 output.append(
727 f"{' '*indent}"
728 + SAIML.parse(f"[@Fred]{self.type}[@F]" + f".[@Fblue]{self.tag}[@F]")
729 + f" [{self.len_as_str(True)}]"
730 + f" {self.pos_as_str(True)}"
731 + f"{self.attrs_as_str(indent+2, True)}",
732 )
733 else:
734 output.append(
735 f"{' '*indent}{self.type}.{self.tag}"
736 + f" [{self.len_as_str()}]{self.pos_as_str()}{self.attrs_as_str(indent+2)}",
737 )
739 for child in self.children or []:
740 output.extend(child.__format__(indent=indent + 2, color=color, text=text))
741 return output
743 def __str__(self) -> str:
744 return "\n".join(self.__format__())
747class Literal(Node):
748 def __init__(
749 self,
750 name: str,
751 content: str,
752 parent: Parent | None = None,
753 position: Position | None = None,
754 in_pre: bool = False,
755 ) -> None:
756 super().__init__(NodeType.LITERAL, position, parent, in_pre)
757 self.name = name
758 self.content = content
760 def __hash__(self) -> int:
761 return hash(self.content) + hash(str(self.name))
763 def __p_code__(self) -> str:
764 in_pre = ", in_pre=True" if self.in_pre else ""
765 return f"Literal({str(self.name)!r}, {self.content!r}{in_pre})"
767 def __eq__(self, _o) -> bool:
768 return (
769 isinstance(_o, Literal)
770 and _o.type == self.type
771 and self.name == _o.name
772 and self.content == _o.content
773 )
775 def as_dict(self) -> dict:
776 return {"name": str(self.name), "content": self.content, **super().as_dict()}
778 @staticmethod
779 def is_text(node: Node) -> bool:
780 """Check if a node is a literal and a text node."""
781 return isinstance(node, Literal) and node.name == LiteralType.Text
783 @staticmethod
784 def is_comment(node: Node) -> bool:
785 """Check if a node is a literal and a comment."""
786 return isinstance(node, Literal) and node.name == LiteralType.Comment
788 def __repr__(self) -> str: # pragma: no cover
789 return f"{self.type}.{self.name}(len={len(self.content)})"
791 def __format__(
792 self,
793 indent: int = 0,
794 color: bool = False,
795 text: bool = False,
796 ): # pragma: no cover
797 from .helpers import normalize_indent
799 content = ""
800 if text:
801 offset = " " * (indent + 2)
802 content = (
803 f'{offset}"""\n{normalize_indent(self.content, indent+4)}\n{offset}"""'
804 )
805 if color:
806 return [
807 SAIML.parse(
808 f"{' '*indent}[@Fred]{self.type}[@F].[@Fblue]{self.name}[@F]"
809 + (f"\n[@Fgreen]{content}[@F]" if text else ""),
810 ),
811 ]
812 return [
813 f"{' '*indent}{self.type}.{self.name}" + (f"\n{content}" if text else ""),
814 ]
816 def __str__(self) -> str: # pragma: no cover
817 return self.__format__()[0]
820def inspect(
821 node: Node,
822 color: bool = False,
823 text: bool = False,
824) -> str: # pragma: no cover
825 """Inspected a given node recursively.
827 Args:
828 node (Node): Any type of node to inspect.
829 color (bool): Whether to return a string with ansi encoding. Default False.
830 text (bool): Whether to include the text from comment and text nodes. Default False.
832 Return:
833 A formatted multiline string representation of the node and it's children.
834 """
835 return "\n".join(node.__format__(color=color, text=text))