diff --git a/.vscode/launch.json b/.vscode/launch.json index 8729238..968b837 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,12 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Unit Tests", + "type": "python", + "request": "launch", + "module": "tests.unit" + }, { "name": "Simple", "type": "python", diff --git a/Simple/__init__.py b/Simple/__init__.py index 5c3e6d1..411870a 100644 --- a/Simple/__init__.py +++ b/Simple/__init__.py @@ -18,6 +18,7 @@ from .document import Document from .exceptions import ProcessException from .logs import create_logger +from .html import html @dataclass @@ -94,10 +95,12 @@ def main(args: List[str]) -> int: doc = Document(opts.input) with opts.output.open("wt") as f: logger.info(f"Writing output to '{opts.output}'") - f.write(doc.render(data).prettify()) + f.write(html(doc.render(data))) except ProcessException as ex: ex.doc.adapter.critical( - str(ex.exn), exc_info=None if opts.log_level > logging.DEBUG else ex + str(ex.exn), + exc_info=None if opts.log_level > logging.DEBUG else ex, + **ex.extra, ) return 1 except Exception as ex: diff --git a/Simple/document.py b/Simple/document.py index 149c93d..5590db2 100644 --- a/Simple/document.py +++ b/Simple/document.py @@ -5,6 +5,7 @@ https://opensource.org/licenses/MIT """ +from Simple.html.exceptions import DocumentException from .exceptions import ProcessException from .logs import DocumentLogAdapter import itertools @@ -12,9 +13,10 @@ import logging import copy from pathlib import Path -from typing import Any, Dict, Iterable, Iterator, List, Optional, TypeVar +from typing import * -from bs4 import BeautifulSoup, Tag # type: ignore +from .html.tags import Node, Tag, TextNode +from .html.document import Document as HTMLDocument, read logger = logging.getLogger(__name__) @@ -22,6 +24,7 @@ class Document: path: Path cwd: Path + html: HTMLDocument adapter: DocumentLogAdapter parent: Optional["Document"] is_component: bool @@ -39,19 +42,22 @@ def __init__( try: with self.path.open("rt") as f: - logger.debug(f"Parsing file '{path}'") - root = BeautifulSoup(f, "html.parser") + logger.info(f"Parsing file '{path}'") + self.html = read(f) except IOError as ex: - self.adapter.critical(f"Cannot parse document: {ex}") raise ProcessException(self, Exception(f"Cannot parse document: {ex}")) - - self.html = next(tags(itertools.chain(root.children))) - if self.html is not None and self.html.name == "def": - self.adapter.debug("Input is defining a component") + except DocumentException as ex: + raise ProcessException(self, ex) + + root = self.html.root + if root is None: + raise ProcessException(self, Exception("HTML is empty")) + if root.name == "def": + self.adapter.debug("Input is defining a component", extra=dict(node=root)) self.is_component = True - self.name = str(self.html.attrs["name"]) + self.name = root.attrs["name"].lower() try: - self.inputs = [s.strip() for s in self.html.attrs["props"].split(",")] + self.inputs = [s.strip() for s in root.attrs["props"].split(",")] except KeyError: self.inputs = [] else: @@ -60,61 +66,73 @@ def __init__( self.name = "__root__" self.components = {} - for tag in self.html.find_all("include"): # type: Tag - tag.extract() + for tag in self.html.find_all("include"): + # tag.remove() if "src" not in tag.attrs: raise ProcessException( - self, Exception("Component include does not have a source link") + self, + Exception("Component include does not have a source link"), + extra=dict(node=tag), ) src = tag.attrs["src"] component = Document(self.cwd / src, parent=self) if not component.is_component: raise ProcessException( - component, Exception("Does not define a component") + component, + Exception("Does not define a component"), + extra=dict(node=tag), ) self.components[component.name] = component - def render(self, context: Dict[str, str]) -> Tag: - # Getting a working copy of the structure - html = copy.deepcopy(self.html) - - self._replace_props(html, context) - self._replace_components(html, context) - - return html - - def _replace_components(self, html: BeautifulSoup, context: Dict[str, str]): - for name, component in self.components.items(): - tags = html.find_all(name.lower()) - if len(tags) == 0: - self.adapter.warn(f"<{name} /> is unused") - else: - for tag in tags: - props = dict(tag.attrs) - for s in props.keys(): - if s not in component.inputs: - self.adapter.warn(f"Unknown attribute '{s}' in <{name} />") - child_context = {**context, **props} - chtml = component.render(child_context) - tag.replace_with(chtml) - chtml.replace_with_children() - - def _replace_props(self, html: BeautifulSoup, context: Dict[str, str]): - for c in html.find_all("content"): - if "prop" not in c.attrs: - self.adapter.warn(f"Content tag should have a 'prop' attribute") - if c.remove is not None: - c.remove() - elif c.attrs["prop"] not in context: - self.adapter.warn( - f"Variable '{c.attrs['prop']}' not defined in context" + def render(self, context: Dict[str, str]) -> HTMLDocument: + return HTMLDocument( + [ + cnode + for node in ( + self.html.root.children if self.is_component else self.html.children ) - if c.remove is not None: - c.remove() - else: - self.adapter.debug(f"Replacing reference to {c.attrs['prop']}") - c.replace_with(context[c.attrs["prop"]]) - - -def tags(it: Iterator[Any]) -> Iterator[Tag]: - return filter(lambda v: isinstance(v, Tag), it) + for cnode in self.inflate(node, context) + ] + ) + + def inflate(self, node: Node, context: Dict[str, str]) -> List[Node]: + if isinstance(node, Tag): + if node.name in self.components: + component = self.components[node.name] + for s in node.attrs.keys(): + if s not in component.inputs: + self.adapter.warn( + f"Unknown attribute {s!r} in <{node.name}/>", + extra=dict(node=node), + ) + child_context = {**context, **node.attrs} + return component.render(child_context).children + + elif node.name == "include": + return [] + elif node.name == "content": + if "prop" not in node.attrs: + self.adapter.warn( + f"Content tag should have a prop attribute", + node=node, + ) + elif node.attrs["prop"] not in context: + self.adapter.warn( + f"Variable {node.attrs['prop']!r} not defined in context", + extra=dict( + node=node, + ), + ) + self.adapter.debug( + f"Replacing reference to {node.attrs['prop']!r}", + extra=dict( + node=node, + ), + ) + text = context[node.attrs["prop"]] + return [TextNode(range=node.range.start.range(text), text=text)] + children = [n for c in node.children for n in self.inflate(c, context)] + node = copy.deepcopy(node) + node.children = children + return [node] + return [node] diff --git a/Simple/exceptions.py b/Simple/exceptions.py index 9f343b5..3ddce8e 100644 --- a/Simple/exceptions.py +++ b/Simple/exceptions.py @@ -4,13 +4,20 @@ This software is released under the MIT License. https://opensource.org/licenses/MIT """ +from typing import Optional import Simple class ProcessException(Exception): - def __init__(self, doc: "Simple.document.Document", exn: Exception) -> None: + def __init__( + self, + doc: "Simple.document.Document", + exn: Exception, + extra: Optional[dict] = None, + ) -> None: self.doc = doc self.exn = exn + self.extra = extra or {} def __str__(self) -> str: return f"{self.doc.path}: {self.exn}" diff --git a/Simple/html/__init__.py b/Simple/html/__init__.py new file mode 100644 index 0000000..9f47733 --- /dev/null +++ b/Simple/html/__init__.py @@ -0,0 +1,45 @@ +""" + Copyright (c) 2021 SolarLiner, jdrprod, Arxaqapi + + This software is released under the MIT License. + https://opensource.org/licenses/MIT +""" + +from dataclasses import dataclass +import os +from typing import * + + +class HTML: + def __html__(self) -> str: + raise NotImplementedError() + + +def html(node: HTML) -> str: + return node.__html__() + + +@dataclass() +class Position: + line: int + col: int + + def range(self, text: str) -> "Range": + if "\n" in text: + lines = 1 + sum(1 for _ in filter(lambda c: c == "\n", text)) + new_col = text[::-1].index("\n") + return Range(start=self, end=Position(self.line + lines, new_col)) + else: + return Range(start=self, end=Position(self.line, self.col + len(text))) + + def __str__(self) -> str: + return f"{self.line}:{self.col}" + + +@dataclass() +class Range: + start: Position + end: Position + + def __str__(self) -> str: + return f"{self.start}-{self.end}" diff --git a/Simple/html/document.py b/Simple/html/document.py new file mode 100644 index 0000000..7cd47db --- /dev/null +++ b/Simple/html/document.py @@ -0,0 +1,185 @@ +from dataclasses import dataclass, field +import abc +import itertools as itt +import logging + +from html.parser import HTMLParser +import os +from typing import ( + Any, + Dict, + Iterable, + Iterator, + List, + Literal, + Optional, + Sequence, + TextIO, + Tuple, + TypeVar, + Union, +) + +from . import HTML, Position, Range, html +from .tags import Node, Comment, TextNode, Tag + +logger = logging.getLogger(__name__) + +K, V = tuple(map(TypeVar, ["K", "V"])) +AssocList = List[Tuple[K, V]] + +# From https://html.spec.whatwg.org/multipage/syntax.html#void-elements +SELF_CLOSING_TAGS = [ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +] + + +@dataclass() +class Document(HTML, Iterable[Node]): + children: List[Node] + + def roots(self) -> List[Tag]: + return list(filter(lambda s: isinstance(s, Tag), self.children)) + + @property + def root(self) -> Optional[Tag]: + roots = self.roots() + if len(roots) > 0: + return roots[0] + else: + return None + + def find_all(self, tag: str) -> Iterable[Tag]: + return itt.chain(*(t.find_all(tag) for t in self.roots())) + + def find(self, tag: str) -> Optional[Tag]: + return next(self.find_all(tag)) + + def __iter__(self) -> Iterator[Node]: + return itt.chain(*map(iter, self.children)) + + def __html__(self) -> str: + return "".join(map(html, self.children)) + + +class DocumentParser(HTMLParser): + children: List[Node] + _tag_stack: List[Tag] + + def __init__(self, data: Optional[str] = None): + super().__init__(convert_charrefs=True) + self.reset() + + if data is not None: + self.feed(data) + + @property + def complete(self): + return len(self._tag_stack) == 0 + + @property + def document(self) -> Document: + if not self.complete: + from .exceptions import DocumentException + + raise DocumentException( + "Document is not complete", + "You are probably missing a closing tag", + self._tag_stack_top(), + ) + return Document(self.children) + + def handle_starttag(self, tag: str, attrs: AssocList[str, str]): + if tag in SELF_CLOSING_TAGS: + self.handle_startendtag(tag, attrs) + else: + text = self.get_starttag_text() + child = Tag( + name=tag, attrs=dict(attrs), text=text, range=self.getpos().range(text) + ) + self._push_node(child) + + def handle_endtag(self, tag: str): + node = self._tag_stack.pop() + node.range.end = self.getpos() + node.consolidate_children() + + def handle_startendtag(self, tag: str, attrs: AssocList[str, str]) -> None: + text = self.get_starttag_text() + node = Tag( + name=tag, + attrs=dict(attrs), + text=text, + range=self.getpos().range(text), + self_closing=True, + ) + self._push_node(node, skip_stack=True) + + def handle_data(self, data: str) -> None: + self._push_node(TextNode(range=self.getpos().range(data), text=data)) + + def handle_comment(self, data: str) -> None: + if (parent := self._tag_stack_top()) is not None: + parent.add_child(Comment(range=self.getpos().range(data), data=data)) + else: + self.children.append(Comment(range=self.getpos().range(data), data=data)) + + def getpos(self) -> Position: + l, c = super().getpos() + return Position(l, c) + + def reset(self) -> None: + super().reset() + self.children = [] + self._tag_stack = [] + + def _tag_stack_top(self) -> Optional[Tag]: + if len(self._tag_stack) > 0: + return self._tag_stack[-1] + + def _push_node(self, node: Node, *, skip_stack: bool = False): + if len(self._tag_stack) == 0: + self.children.append(node) + if (parent := self._tag_stack_top()) is not None: + if isinstance(parent, Tag): + parent.add_child(node) + else: + self._tag_stack.pop() + self._push_node(node) + if isinstance(node, Tag) and not skip_stack: + self._tag_stack.append(node) + + +def parse(text: str) -> Optional[Document]: + from .document import DocumentParser + + p = DocumentParser(text) + return p.document + + +def read(f: Union[os.PathLike, TextIO]) -> Optional[Document]: + from .document import DocumentParser + + if hasattr(f, "read"): # Is file object + p = DocumentParser() + while chunk := f.read(512): + p.feed(chunk) + return p.document + else: + from pathlib import Path + + with Path(f).open("rt") as fp: + return read(fp) \ No newline at end of file diff --git a/Simple/html/exceptions.py b/Simple/html/exceptions.py new file mode 100644 index 0000000..6ce1e95 --- /dev/null +++ b/Simple/html/exceptions.py @@ -0,0 +1,42 @@ +""" + Copyright (c) 2021 SolarLiner, jdrprod, Arxaqapi + + This software is released under the MIT License. + https://opensource.org/licenses/MIT +""" + +from Simple.html import Range, html +from Simple.html.tags import Node +from typing import Optional + + +class DocumentException(Exception): + def __init__( + self, + msg: str, + hint: Optional[str] = None, + node: Optional[Node] = None, + default_range: Optional[Range] = None, + ) -> None: + self.node = node + self.hint = hint + if node: + self.range = node.range + else: + self.range = default_range + super().__init__(msg) + + def __str__(self) -> str: + if self.node: + node_html = html(self.node) + node_first_line = node_html[: node_html.index("\n")] + line_display = f"{self.range.start.line} |" + cont_display = "|".rjust(len(line_display), " ") + if self.hint: + return f"{super().__str__()}\n{line_display} {node_first_line}\n{cont_display} ...\n\t\033[36mhint\033[0m: {self.hint}" + else: + return f"{super().__str__()}\n{line_display} {node_first_line}\n{cont_display} ..." + elif self.range: + return f"{self.range}: {super().__init__()}" + else: + return super().__init__() diff --git a/Simple/html/tags.py b/Simple/html/tags.py new file mode 100644 index 0000000..c682de3 --- /dev/null +++ b/Simple/html/tags.py @@ -0,0 +1,127 @@ +""" + Copyright (c) 2021 SolarLiner, jdrprod, Arxaqapi + + This software is released under the MIT License. + https://opensource.org/licenses/MIT +""" +from dataclasses import dataclass, field +from typing import * +import itertools as itt + +from . import HTML, Position, Range, html + + +@dataclass() +class Node(HTML, Iterable["Node"]): + range: Range = field(repr=False) + text: Optional[str] + parent: Optional["Tag"] = field(init=False, repr=False, default=None) + + def replace_with_all(self, nodes: Sequence["Node"]) -> None: + if self.parent is None: + return + ix = self.parent.children.index(self) + self.parent.children = ( + self.parent.children[:ix] + nodes + self.parent.children[ix:] + ) + + def __str__(self): + return f"[{self.__class__.__name__} len={len(self.text)}]" + + def __html__(self): + return self.text + + def __iter__(self) -> Iterator["Node"]: + yield self + + +class TextNode(Node): + @staticmethod + def fuse(nodes: Iterable["TextNode"], startpos=Position(0, 0)) -> "TextNode": + if len(nodes) == 0: + return TextNode(range=Range(start=startpos, end=startpos), text="") + else: + start = next((t.range.start for t in nodes)) + text = "".join((t.text or "" for t in nodes)) + return TextNode(range=start.range(text), text=text) + + +class Comment(Node): + def __init__(self, range: Range, data: str): + super().__init__(range=range, text=data) + + def __html__(self): + return f"" + + +@dataclass() +class Tag(Node): + name: str + attrs: Dict[str, str] + children: List[Node] = field(default_factory=list, repr=False) + self_closing: bool = field(default=False, repr=False) + + @property + def inner_html(self) -> str: + return "".join(map(html, self.children)) + + def inner_tags(self) -> Iterable["Tag"]: + return filter(lambda s: isinstance(s, Tag), self) + + def add_child(self, node: "Node"): + node.parent = self + self.children.append(node) + + def find_all(self, tag: str) -> Iterable["Tag"]: + return filter(lambda s: s.name == tag, self.inner_tags()) + + def find(self, tag: str) -> Optional["Tag"]: + return next(self.find_all(tag)) + + def remove(self) -> "Tag": + if self.parent is not None: + self.parent.children.remove(self) + return self + + def replace_with_children(self) -> None: + self.replace_with_all(self.children) + + def consolidate_children(self) -> None: + def do_one( + t: Union[ + Tuple[Literal[False], Iterator[Node]], + Tuple[Literal[True], Iterator[TextNode]], + ] + ): + if t[0]: + return [TextNode.fuse(list(t[1]))] + else: + return list(t[1]) + + self.children = list( + itt.chain( + *map( + do_one, + itt.groupby( + self.children, key=lambda node: isinstance(node, TextNode) + ), + ) + ) + ) + + def __iter__(self) -> Iterator["Node"]: + yield self + for child in itt.chain(*map(iter, self.children)): + yield child + + def __str__(self): + if self.self_closing: + return self.text + else: + return f"{self.text}{''.join(map(str, self.children))}" + + def __html__(self): + if self.self_closing: + return self.text + else: + return f"{self.text}{self.inner_html}" diff --git a/Simple/logs.py b/Simple/logs.py index 237fae6..59d5dcd 100644 --- a/Simple/logs.py +++ b/Simple/logs.py @@ -6,6 +6,8 @@ import logging from typing import List, MutableMapping, Optional, Tuple +from .html.tags import Node + FORMAT = "%(levelname)s: %(message)s" @@ -23,20 +25,31 @@ def process(self, msg: str, kwargs: MutableMapping) -> Tuple[str, MutableMapping relpath = doc.path.relative_to(cwd) include_stack = self._get_include_stack() if len(include_stack) > 0: - incstack_str = "\n\t" + "\n\t".join( + include_stack_str = "\n\t" + "\n\t".join( "included from " + str(s.relative_to(cwd)) for s in include_stack ) else: - incstack_str = "" - extra = {"document": doc, "include-stack": include_stack} - if "pos" in kwargs: - (line, col) = kwargs["pos"] - return f"{relpath}:{line}:{col} {msg}{incstack_str}", { + include_stack_str = "" + extra = { + **kwargs.get("extra", {}), + "document": doc, + "include-stack": include_stack, + } + message = f"{msg}{include_stack_str}" + if self.logger.level <= logging.DEBUG and "context" in extra: + from pprint import pformat + + message += f"\n\tContext: {pformat(extra['context'])}" + + if "node" in extra: + node: Node = extra["node"] + range = node.range + return f"{relpath}:{range} {message}", { **kwargs, "extra": extra, } else: - return f"{relpath}: {msg}{incstack_str}", {**kwargs, "extra": extra} + return f"{relpath}: {message}", {**kwargs, "extra": extra} def _get_include_stack(self): from .document import Document diff --git a/poetry.lock b/poetry.lock index 4dd75a2..960accd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -17,21 +17,6 @@ python-versions = "*" [package.extras] test = ["coverage", "flake8", "pexpect", "wheel"] -[[package]] -name = "beautifulsoup4" -version = "4.9.3" -description = "Screen-scraping library" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -soupsieve = {version = ">1.2", markers = "python_version >= \"3.0\""} - -[package.extras] -html5lib = ["html5lib"] -lxml = ["lxml"] - [[package]] name = "black" version = "20.8b1" @@ -278,14 +263,6 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -[[package]] -name = "soupsieve" -version = "2.1" -description = "A modern CSS selector implementation for Beautiful Soup." -category = "main" -optional = false -python-versions = ">=3.5" - [[package]] name = "termcolor" version = "1.1.0" @@ -355,7 +332,7 @@ python-versions = "*" [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "971e5c37037086673704435c94ff819a00ffe59301a6941c487e021301839391" +content-hash = "b6a05bb776043cf7a75b7eb372a24a4beb82fca85de93adfd0a2bf46557b2f43" [metadata.files] appdirs = [ @@ -366,11 +343,6 @@ argcomplete = [ {file = "argcomplete-1.12.2-py2.py3-none-any.whl", hash = "sha256:17f01a9b9b9ece3e6b07058eae737ad6e10de8b4e149105f84614783913aba71"}, {file = "argcomplete-1.12.2.tar.gz", hash = "sha256:de0e1282330940d52ea92a80fea2e4b9e0da1932aaa570f84d268939d1897b04"}, ] -beautifulsoup4 = [ - {file = "beautifulsoup4-4.9.3-py2-none-any.whl", hash = "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35"}, - {file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"}, - {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, -] black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] @@ -571,10 +543,6 @@ six = [ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, ] -soupsieve = [ - {file = "soupsieve-2.1-py3-none-any.whl", hash = "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851"}, - {file = "soupsieve-2.1.tar.gz", hash = "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e"}, -] termcolor = [ {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, ] diff --git a/pyproject.toml b/pyproject.toml index 1dfb924..9237921 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ simple = "Simple.__main__:run" [tool.poetry.dependencies] python = "^3.9" -beautifulsoup4 = "^4.9.3" [tool.poetry.dev-dependencies] black = "^20.8b1" diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 3a15941..fdef217 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -26,9 +26,7 @@ def test_matches_snapshot(self): data = json.load(d) else: data = {} - self.assertEqual( - input.render(data).prettify(), expected.html.prettify(), str(self.path) - ) + self.assertEqual(input.render(data), expected.html, str(self.path)) class IntegrationTestSuite(unittest.TestSuite): diff --git a/tests/integration/__main__.py b/tests/integration/__main__.py new file mode 100644 index 0000000..f7298b9 --- /dev/null +++ b/tests/integration/__main__.py @@ -0,0 +1,6 @@ +from unittest import TextTestRunner +from pathlib import Path + +from . import IntegrationTestSuite + +TextTestRunner().run(IntegrationTestSuite(Path(__file__).parent)) \ No newline at end of file diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__main__.py b/tests/unit/__main__.py new file mode 100644 index 0000000..1897b55 --- /dev/null +++ b/tests/unit/__main__.py @@ -0,0 +1,7 @@ +import unittest +from pathlib import Path + + +loader = unittest.TestLoader() +suite = loader.discover(str(Path(__file__).parent)) +runner = unittest.TextTestRunner().run(suite) \ No newline at end of file diff --git a/tests/unit/test_document.py b/tests/unit/test_document.py new file mode 100644 index 0000000..3b62695 --- /dev/null +++ b/tests/unit/test_document.py @@ -0,0 +1,52 @@ +import unittest +from Simple.htmlparser import ( + DocumentParser, + Position, + Range, + Tag, + TextNode, + html, + parse, +) + + +class DocumentTests(unittest.TestCase): + def test_document_simple(self): + doc = parse("") + self.assertEqual(len(doc.children), 1) + + html_tag: Tag = doc.children[0] + self.assertIsInstance(html_tag, Tag) + self.assertEqual(html_tag.name, "html") + self.assertEqual(html_tag.children, []) + + def test_document_html_repr(self): + tests = [ + "", + "

Text inserted here

", + "Link here", + "Alternative text for image", + ] + for i, text in enumerate(tests): + with self.subTest(input=i + 1, text=text): + doc = parse(text) + rendered = html(doc) + self.assertEqual(text, rendered) + + def test_document_parse_chunked(self): + p = DocumentParser() + p.feed("Hell") + p.feed("o world!") + + p.flush() + doc = p.document + root: Tag = doc.root + body: Tag = root.children[0] + text_node: TextNode = body.children[0] + self.assertEqual(1, len(doc.children)) + self.assertIsInstance(root, Tag) + self.assertIsInstance(body, Tag) + self.assertIsInstance(text_node, TextNode) + self.assertEqual(text_node.text, "Hello world!")