diff --git a/python/markupever/_display.py b/python/markupever/_display.py index 7ca6028..53778d8 100644 --- a/python/markupever/_display.py +++ b/python/markupever/_display.py @@ -12,7 +12,7 @@ def __init__(self, siblings: bool): self.siblings = siblings self.children = False - def __str__(self): + def __str__(self) -> str: # match (siblings, children) # (true, true) => "│ ", # (true, false) => "├── ", @@ -42,7 +42,7 @@ def __init__(self, ignore_root: bool): self.tokens: list[_DisplayCharacterToken] = [] self.ignore_root = ignore_root - def indent(self, siblings: bool): + def indent(self, siblings: bool) -> "_Indentation": length = len(self.tokens) if length > 0: self.tokens[length - 1].children = True @@ -50,7 +50,7 @@ def indent(self, siblings: bool): self.tokens.append(_DisplayCharacterToken(siblings)) return self - def deindent(self): + def deindent(self) -> "_Indentation": self.tokens.pop() return self diff --git a/python/markupever/_rustlib/__init__.pyi b/python/markupever/_rustlib/__init__.pyi index 26a8dbe..b9652aa 100644 --- a/python/markupever/_rustlib/__init__.pyi +++ b/python/markupever/_rustlib/__init__.pyi @@ -174,7 +174,7 @@ class QualName: "html", "xml", "xhtml", "xmlns", "xlink", "svg", "mathml", "*" ] = ..., prefix: str | None = ..., - ): ... + ) -> QualName: ... @property def local(self) -> str: """The local name (e.g. `table` in `` above).""" diff --git a/python/markupever/dom.py b/python/markupever/dom.py index 928ea0b..233a0f1 100644 --- a/python/markupever/dom.py +++ b/python/markupever/dom.py @@ -4,6 +4,11 @@ from . import _rustlib, iterators from ._rustlib import QualName as QualName +if typing.TYPE_CHECKING: # pragma: no cover + from ._rustlib import _Node + +_R_co = typing.TypeVar("_R_co", bound="_Node", covariant=True) + class TreeDom: __slots__ = ("_raw",) @@ -61,12 +66,12 @@ def serialize( indent=indent, is_html=is_html, include_self=include_self ) # pragma: no cover - def __iter__(self) -> typing.Generator["BaseNode", typing.Any, None]: + def __iter__(self) -> typing.Iterator["Node"]: """Iterates the nodes in insert order - don't matter which are orphan which not.""" for rn in _rustlib.iter.Iterator(self._raw): yield BaseNode._wrap(rn) - def __eq__(self, val: "TreeDom") -> bool: + def __eq__(self, val: object) -> bool: if not isinstance(val, TreeDom): return False @@ -101,14 +106,14 @@ def __str__(self) -> str: return res[:-1] # remove the last '\n' - def __repr__(self): + def __repr__(self) -> str: return f"TreeDom(len={len(self)}, namespaces={self.namespaces()})" class _ConfigNode: __slots__ = ("basetype", "invalid_ordering") - def __init__(self, basetype: type | None, invalid_ordering: tuple[int]): + def __init__(self, basetype: type | None, invalid_ordering: tuple[int, ...]): self.basetype = basetype self.invalid_ordering = invalid_ordering @@ -126,7 +131,7 @@ class Ordering: """Means create and insert the node as the `prev_sibling`.""" -class BaseNode: +class BaseNode(typing.Generic[_R_co]): """ Base class for DOM nodes, providing core tree navigation, manipulation, and serialization methods. @@ -140,12 +145,11 @@ class BaseNode: Subclasses should define their specific node type configuration via _CONFIG. """ - __slots__ = ("_raw",) + __slots__ = ("__raw",) _CONFIG: _ConfigNode = _ConfigNode(None, ()) - _SUBCLASS_WRAP = {} - def __init__(self, node: typing.Any): + def __init__(self, node: _R_co): if self._CONFIG.basetype is not None and not isinstance( node, self._CONFIG.basetype ): @@ -158,20 +162,36 @@ def __init__(self, node: typing.Any): f"expected one of _rustlib nodes implementations (such as _rustlib.Element, _rustlib.Comment, ...), got {type(node).__name__}" ) - self._raw = node + self.__raw = node + + @property + def _raw(self) -> _R_co: + # A read-only property, so that BaseNode can be covariant in _R_co. + return self.__raw @classmethod - def _wrap(cls, node: typing.Any) -> "BaseNode": - try: - _type = cls._SUBCLASS_WRAP[type(node)] - except KeyError: + def _wrap(cls, node: "_Node") -> "Node": + if isinstance(node, _rustlib.Element): + return Element(node) + if isinstance(node, _rustlib.Text): + return Text(node) + if isinstance(node, _rustlib.Comment): + return Comment(node) + if isinstance(node, _rustlib.Doctype): + return Doctype(node) + if isinstance(node, _rustlib.ProcessingInstruction): + return ProcessingInstruction(node) + # Checked last, because mypy doesn't narrow isinstance() with a class whose + # __new__ returns NoReturn. + if not isinstance(node, _rustlib.Document): raise TypeError( f"the type of node is not acceptable ({type(node).__name__})." - ) from None - - return _type(node) + ) + return Document(node) - def _connect_node(self, ordering: int, dom, child): + def _connect_node( + self, ordering: int, dom: _rustlib.TreeDom, child: "_Node" + ) -> None: if ordering in self._CONFIG.invalid_ordering: raise ValueError("This ordering value is not acceptable for this type.") @@ -192,36 +212,32 @@ def _connect_node(self, ordering: int, dom, child): "ordering must be one of Ordering variables like Ordering.APPEND, Ordering.PREPEND, ..." ) - def __init_subclass__(cls): - assert cls._CONFIG.basetype is not None - BaseNode._SUBCLASS_WRAP[cls._CONFIG.basetype] = cls - @property - def parent(self) -> typing.Optional["BaseNode"]: + def parent(self) -> typing.Optional["Node"]: """Returns the parent of this node.""" parent = self._raw.parent() return BaseNode._wrap(parent) if parent is not None else None @property - def prev_sibling(self) -> typing.Optional["BaseNode"]: + def prev_sibling(self) -> typing.Optional["Node"]: """Returns the previous sibling of this node.""" prev_sibling = self._raw.prev_sibling() return BaseNode._wrap(prev_sibling) if prev_sibling is not None else None @property - def next_sibling(self) -> typing.Optional["BaseNode"]: + def next_sibling(self) -> typing.Optional["Node"]: """Returns the next sibling of this node.""" next_sibling = self._raw.next_sibling() return BaseNode._wrap(next_sibling) if next_sibling is not None else None @property - def first_child(self) -> typing.Optional["BaseNode"]: + def first_child(self) -> typing.Optional["Node"]: """Returns the first child of this node.""" first_child = self._raw.first_child() return BaseNode._wrap(first_child) if first_child is not None else None @property - def last_child(self) -> typing.Optional["BaseNode"]: + def last_child(self) -> typing.Optional["Node"]: """Returns the last child of this node.""" last_child = self._raw.last_child() return BaseNode._wrap(last_child) if last_child is not None else None @@ -272,7 +288,9 @@ def descendants(self) -> iterators.Descendants: """Returns an iterator which iterates over this node and its descendants.""" return iterators.Descendants(self) - def attach(self, node: "BaseNode", *, ordering: int = Ordering.APPEND) -> None: + def attach( + self, node: "BaseNode[_Node]", *, ordering: int = Ordering.APPEND + ) -> None: """ Attaches a node to the current node with a specified ordering. @@ -330,7 +348,7 @@ def select_one(self, expr: str, offset: int = 0) -> typing.Optional["Element"]: else: return node - def strings(self, strip: bool = False): + def strings(self, strip: bool = False) -> typing.Iterator[str]: """ Retrieve text content from descendant text nodes. @@ -386,47 +404,23 @@ def serialize( indent, is_html=is_html, include_self=include_self ).decode("utf-8") - def __eq__(self, value): + def __eq__(self, value: object) -> bool: if isinstance(value, BaseNode): value = value._raw return self._raw == value - def __ne__(self, value): # pragma: no cover + def __ne__(self, value: object) -> bool: # pragma: no cover if isinstance(value, BaseNode): value = value._raw return self._raw != value - def __le__(self, value): # pragma: no cover - if isinstance(value, BaseNode): - value = value._raw - - return self._raw <= value - - def __lt__(self, value): # pragma: no cover - if isinstance(value, BaseNode): - value = value._raw - - return self._raw < value - - def __ge__(self, value): # pragma: no cover - if isinstance(value, BaseNode): - value = value._raw - - return self._raw >= value - - def __gt__(self, value): # pragma: no cover - if isinstance(value, BaseNode): - value = value._raw - - return self._raw > value - def __repr__(self) -> str: # pragma: no cover return repr(self._raw) -class Document(BaseNode): +class Document(BaseNode[_rustlib.Document]): """The root of a document.""" _CONFIG = _ConfigNode(_rustlib.Document, (Ordering.AFTER, Ordering.BEFORE)) @@ -469,7 +463,7 @@ def create_text(self, content: str, *, ordering: int = Ordering.APPEND) -> "Text def create_element( self, - name: str, + name: str | _rustlib.QualName, attrs: ( typing.Sequence[tuple[_rustlib.QualName | str, str]] | dict[_rustlib.QualName | str, str] @@ -505,7 +499,7 @@ def create_processing_instruction( return ProcessingInstruction(node) -class Doctype(BaseNode): +class Doctype(BaseNode[_rustlib.Doctype]): """ the doctype is the required preamble found at the top of all documents. Its sole purpose is to prevent a browser from switching into so-called "quirks mode" @@ -541,7 +535,7 @@ def public_id(self, value: str) -> None: self._raw.public_id = value -class Comment(BaseNode): +class Comment(BaseNode[_rustlib.Comment]): """ The Comment interface represents textual notations within markup; although it is generally not visually shown, such comments are available to be read in the source view. @@ -560,44 +554,44 @@ def content(self) -> str: def content(self, value: str) -> None: self._raw.content = value - def __eq__(self, value): + def __eq__(self, value: object) -> bool: if isinstance(value, str): return self._raw.content == value return super().__eq__(value) - def __ne__(self, value): # pragma: no cover + def __ne__(self, value: object) -> bool: # pragma: no cover if isinstance(value, str): return self._raw.content != value return super().__ne__(value) - def __le__(self, value): # pragma: no cover + def __le__(self, value: object) -> bool: # pragma: no cover if isinstance(value, str): return self._raw.content <= value - return super().__le__(value) + return NotImplemented - def __lt__(self, value): # pragma: no cover + def __lt__(self, value: object) -> bool: # pragma: no cover if isinstance(value, str): return self._raw.content < value - return super().__lt__(value) + return NotImplemented - def __ge__(self, value): # pragma: no cover + def __ge__(self, value: object) -> bool: # pragma: no cover if isinstance(value, str): return self._raw.content >= value - return super().__ge__(value) + return NotImplemented - def __gt__(self, value): # pragma: no cover + def __gt__(self, value: object) -> bool: # pragma: no cover if isinstance(value, str): return self._raw.content > value - return super().__gt__(value) + return NotImplemented -class Text(BaseNode): +class Text(BaseNode[_rustlib.Text]): """A text node.""" _CONFIG = _ConfigNode(_rustlib.Text, (Ordering.APPEND, Ordering.PREPEND)) @@ -610,41 +604,41 @@ def content(self) -> str: def content(self, value: str) -> None: self._raw.content = value - def __eq__(self, value): + def __eq__(self, value: object) -> bool: if isinstance(value, str): return self._raw.content == value return super().__eq__(value) - def __ne__(self, value): # pragma: no cover + def __ne__(self, value: object) -> bool: # pragma: no cover if isinstance(value, str): return self._raw.content != value return super().__ne__(value) - def __le__(self, value): # pragma: no cover + def __le__(self, value: object) -> bool: # pragma: no cover if isinstance(value, str): return self._raw.content <= value - return super().__le__(value) + return NotImplemented - def __lt__(self, value): # pragma: no cover + def __lt__(self, value: object) -> bool: # pragma: no cover if isinstance(value, str): return self._raw.content < value - return super().__lt__(value) + return NotImplemented - def __ge__(self, value): # pragma: no cover + def __ge__(self, value: object) -> bool: # pragma: no cover if isinstance(value, str): return self._raw.content >= value - return super().__ge__(value) + return NotImplemented - def __gt__(self, value): # pragma: no cover + def __gt__(self, value: object) -> bool: # pragma: no cover if isinstance(value, str): return self._raw.content > value - return super().__gt__(value) + return NotImplemented _D = typing.TypeVar("_D") @@ -666,13 +660,13 @@ class AttrsList: def __init__(self, attrs: _rustlib.AttrsList): self.__raw = attrs - def append(self, key: _rustlib.QualName | str, value: str): + def append(self, key: _rustlib.QualName | str, value: str) -> None: """ Appends a key-value pair into attributes list. """ self.__raw.push(key, value) - def insert(self, index: int, key: _rustlib.QualName | str, value: str): + def insert(self, index: int, key: _rustlib.QualName | str, value: str) -> None: """ Inserts a key-value pair at position `index` within the list, shifting all elements after it to the right. """ @@ -681,16 +675,15 @@ def insert(self, index: int, key: _rustlib.QualName | str, value: str): def _find_by_key( self, key: _rustlib.QualName | str, - default: _D = None, start: int = 0, - ) -> tuple[str | _D, int]: + ) -> tuple[str | None, int]: for index, item in itertools.islice(enumerate(self.__raw.items()), start, None): k, v = item if k == key: return v, index - return default, -1 + return None, -1 def _find_by_item( self, @@ -708,7 +701,7 @@ def _find_by_item( def index( self, - key: _rustlib.QualName | str | tuple, + key: _rustlib.QualName | str | tuple[_rustlib.QualName | str, str], start: int = 0, ) -> int: """ @@ -720,7 +713,7 @@ def index( Raises `ValueError` if no matching key or key-value pair is found. """ if isinstance(key, tuple): - index = self._find_by_item(*key, start=start) + index = self._find_by_item(key[0], key[1], start=start) else: _, index = self._find_by_key(key, start=start) @@ -729,12 +722,22 @@ def index( return index + @typing.overload + def get( + self, key: _rustlib.QualName | str, default: None = None, start: int = 0 + ) -> str | None: ... + + @typing.overload + def get( + self, key: _rustlib.QualName | str, default: _D, start: int = 0 + ) -> str | _D: ... + def get( self, key: _rustlib.QualName | str, - default: _D = None, + default: _D | None = None, start: int = 0, - ) -> str | _D: + ) -> str | _D | None: """ Retrieve the value associated with a given key in the attributes list. Returns the value associated with the key if found, otherwise the default value. @@ -768,7 +771,7 @@ def pop(self, index: int = -1) -> tuple[_rustlib.QualName, str]: def remove( self, - key: _rustlib.QualName | str | tuple, + key: _rustlib.QualName | str | tuple[_rustlib.QualName | str, str], start: int = 0, ) -> None: """ @@ -786,14 +789,23 @@ def reverse(self) -> None: """Reverses the order of elements in the list.""" self.__raw.reverse() - def extend(self, m: dict | typing.Iterable[tuple]): + def extend( + self, + m: dict[_rustlib.QualName | str, str] + | typing.Iterable[tuple[_rustlib.QualName | str, str]], + ) -> None: """ Extend the attributes list by appending key-value pairs from the iterable or dictionary. """ + items: typing.Iterable[tuple[_rustlib.QualName | str, str]] if isinstance(m, dict): - m = m.items() + # A dict is also an iterable of its keys, so type checkers can't tell from + # isinstance() which of the two types m has. + items = typing.cast("dict[_rustlib.QualName | str, str]", m).items() + else: + items = m - for key, val in m: + for key, val in items: self.__raw.push(key, val) def clear(self) -> None: @@ -816,16 +828,18 @@ def __len__(self) -> int: """Returns `len(self)`.""" return len(self.__raw) - def __iter__(self): + def __iter__(self) -> typing.Iterator[QualName]: """Returns a generator of attribute keys.""" return self.keys() - def __contains__(self, key: _rustlib.QualName | str | tuple) -> bool: + def __contains__( + self, key: _rustlib.QualName | str | tuple[_rustlib.QualName | str, str] + ) -> bool: """ Returns `True` if the list has the specified key, else `False`. """ if isinstance(key, tuple): - index = self._find_by_item(*key) + index = self._find_by_item(key[0], key[1]) else: _, index = self._find_by_key(key) @@ -862,14 +876,15 @@ def __setitem__( - val: A value to set, either as a string or a (key, value) tuple. """ if not isinstance(index, int): - if isinstance(val, str): - val = (index, val) + key, value = (index, val) if isinstance(val, str) else val _, index = self._find_by_key(index) if index == -1: - self.__raw.push(*val) + self.__raw.push(key, value) return + val = (key, value) + self.__raw.update_item(index, val[0], val[1]) @typing.overload @@ -878,7 +893,9 @@ def __getitem__(self, index: str | _rustlib.QualName) -> str: ... @typing.overload def __getitem__(self, index: int) -> tuple[_rustlib.QualName, str]: ... - def __getitem__(self, index): + def __getitem__( + self, index: int | str | _rustlib.QualName + ) -> str | tuple[_rustlib.QualName, str]: if not isinstance(index, int): _, index_i = self._find_by_key(index) if index_i == -1: @@ -893,7 +910,7 @@ def __repr__(self) -> str: return repr(self.__raw) -class Element(BaseNode): +class Element(BaseNode[_rustlib.Element]): """An element node.""" _CONFIG = _ConfigNode(_rustlib.Element, ()) @@ -997,7 +1014,7 @@ def create_text( def create_element( self, - name: str, + name: str | _rustlib.QualName, attrs: ( typing.Sequence[tuple[_rustlib.QualName | str, str]] | dict[_rustlib.QualName | str, str] @@ -1035,7 +1052,7 @@ def create_processing_instruction( return ProcessingInstruction(node) -class ProcessingInstruction(BaseNode): +class ProcessingInstruction(BaseNode[_rustlib.ProcessingInstruction]): """ The ProcessingInstruction interface represents a processing instruction; that is, a Node which embeds an instruction targeting a specific application but that can @@ -1061,3 +1078,9 @@ def data(self) -> str: @data.setter def data(self, value: str) -> None: self._raw.data = value + + +Node: typing.TypeAlias = ( + Document | Doctype | Comment | Text | Element | ProcessingInstruction +) +"""Any node.""" diff --git a/python/markupever/iterators.py b/python/markupever/iterators.py index 84c6524..6c88d92 100644 --- a/python/markupever/iterators.py +++ b/python/markupever/iterators.py @@ -4,23 +4,24 @@ if typing.TYPE_CHECKING: # pragma: no cover from . import dom + from ._rustlib import _Node class _IteratorMetaClass: """Bridge _rustlib iterators to Python""" - _BASECLASS: typing.Callable[["dom.BaseNode"], typing.Iterable] + _BASECLASS: typing.Callable[["_Node"], typing.Iterator["_Node"]] __slots__ = ("_raw",) - def __init__(self, value: "dom.BaseNode"): + def __init__(self, value: "dom.BaseNode[_Node]"): self._raw = iter(self._BASECLASS(value._raw)) - def __iter__(self): + def __iter__(self) -> typing.Iterator["dom.Node"]: """Returns `iter(self)`""" return self - def __next__(self) -> "dom.BaseNode": + def __next__(self) -> "dom.Node": """Returns `next(self)`""" from .dom import BaseNode @@ -68,21 +69,28 @@ class EdgeTraverse: __slots__ = ("closed", "node") - def __init__(self, node: "dom.BaseNode", closed: bool) -> None: + def __init__(self, node: "dom.Node", closed: bool) -> None: self.node = node self.closed = closed - def __repr__(self): + def __repr__(self) -> str: if self.closed: return f"EdgeTraverse[closed]({self.node})" return f"EdgeTraverse[opened]({self.node})" -class Traverse(_IteratorMetaClass): +class Traverse: """Iterator which traverses a tree.""" - _BASECLASS = _rustlib.iter.Traverse + __slots__ = ("_raw",) + + def __init__(self, value: "dom.BaseNode[_Node]"): + self._raw = iter(_rustlib.iter.Traverse(value._raw)) + + def __iter__(self) -> typing.Iterator[EdgeTraverse]: + """Returns `iter(self)`""" + return self def __next__(self) -> EdgeTraverse: from .dom import BaseNode @@ -103,14 +111,19 @@ class Select: __slots__ = ("__limit", "__offset", "__raw") def __init__( - self, value: "dom.BaseNode", expr: str, *, limit: int = 0, offset: int = 0 + self, + value: "dom.BaseNode[_Node]", + expr: str, + *, + limit: int = 0, + offset: int = 0, ) -> None: self.__raw = iter(_rustlib.Select(value._raw, expr)) self.__limit = limit or -1 self.__offset = offset - 1 - def __iter__(self): + def __iter__(self) -> typing.Iterator["dom.Element"]: return self def __next__(self) -> "dom.Element":