fabiommendes/hyperpython

View on GitHub
src/hyperpython/core.py

Summary

Maintainability
A
3 hrs
Test Coverage
import copy
import io
import json
from collections.abc import Sequence

from markupsafe import Markup
from sidekick import lazy, delegate_to
from types import MappingProxyType

from .helpers import classes
from .renderers import dump_attrs, render_pretty
from .utils import escape as _escape

# https://www.w3.org/TR/html5/syntax.html#void-elements
VOID_ELEMENTS = {
    "area",
    "base",
    "br",
    "col",
    "embed",
    "hr",
    "img",
    "input",
    "keygen",
    "link",
    "meta",
    "param",
    "source",
    "track",
    "wbr",
}
SEQUENCE_TYPES = (tuple, list, type(x for x in []), type(map(lambda: 0, [])))
JUPYTER_NOTEBOOK_RENDER_HTML = True
cte = lambda value: lambda *args: value


class BaseElement:
    """
    Mixins for the Element API.
    """

    # Default values and properties
    tag = property(cte(None))
    attrs = MappingProxyType({})
    classes = property(lambda self: self.attrs.get("class", []))
    id = property(lambda self: self.attrs.get("id"))

    @id.setter
    def id(self, value):
        setitem = getattr(self.attrs, "__setitem__", None)
        if setitem is None:
            raise AttributeError("cannot set id of immutable type")
        setitem("id", value)

    children = ()
    requires = ()

    is_element = False
    is_void = False

    def __html__(self):
        return self.render()

    def __str__(self):
        return str(self.__html__())

    def _repr_html_(self):
        return self.__html__() if JUPYTER_NOTEBOOK_RENDER_HTML else repr(self)

    def _repr_child(self):
        return self.__repr__()

    def render(self):
        """
        Renders object as string.
        """
        file = io.StringIO()
        self.dump(file)
        return file.getvalue()

    def dump(self, file):
        """
        Dump contents of element in the given file.
        """
        raise NotImplementedError

    def json(self):
        raise NotImplementedError

    def copy(self):
        raise NotImplementedError

    def pretty(self, **kwargs):
        """
        Render a pretty printed HTML.

        This method is less efficient than .render(), but is useful for
        debugging
        """
        return render_pretty(self, **kwargs)

    def walk(self):
        """
        Walk over all elements in the object tree, including Elements and
        Text fragments.
        """

        yield self
        for obj in self.children:
            yield from obj.walk()

    def walk_tags(self):
        """
        Walk over all elements in the object tree, excluding Text fragments.
        """

        if self.is_element:
            yield self

        for obj in self.children:
            if obj.is_element:
                yield from obj.walk_tags()

    def add_child(self, value):
        """
        Add child element to data structure.

        Caveat: Hyperpython *do not* enforce immutability, but it is a good
        practice to keep HTML data structures immutable.
        """
        append = getattr(self.children, "append", None)
        if append is None:
            raise TypeError("cannot change immutable structure")
        else:
            append(as_child(value))
        return self


# ------------------------------------------------------------------------------
class Component(BaseElement):
    """
    Component that delegates the creation of HTML tree to an .html() method.
    """

    json = delegate_to("_tree")
    dump = delegate_to("_tree")
    tag = delegate_to("_tree")
    attrs = delegate_to("_tree")
    children = delegate_to("_tree")
    requires = delegate_to("_tree")
    is_void = delegate_to("_tree")
    is_element = delegate_to("_tree")

    @lazy
    def _tree(self):
        return self.html()

    def html(self, **kwargs):
        raise NotImplementedError("must be implemented in subclasses")

    def copy(self):
        new = copy.copy(self)
        new._tree = self._tree.copy()
        return new


# ------------------------------------------------------------------------------
class Element(BaseElement):
    """
    Represents an HTML element.
    """

    tag: str = ""
    attrs: dict
    children: list
    is_void: bool
    is_element = True

    def __init__(
            self, tag: str, attrs: dict, children: list, is_void=False, requires=()
    ):
        self.tag = tag
        self.attrs = {
            k: v
            for k, v in map(as_attr, attrs.keys(), attrs.values())
            if k is not None and v is not None
        }
        self.children = list(map(as_child, children))
        self.is_void = is_void
        self.requires = tuple(requires)

    def __getitem__(self, item):
        if self.is_void:
            raise ValueError("void elements cannot define children")

        if isinstance(item, SEQUENCE_TYPES):
            self.children.extend(map(as_child, item))
        elif item is None:
            pass
        else:
            self.children.append(as_child(item))
        return self

    def __repr__(self):
        attrs = self.attrs
        children = self.children
        if len(children) == 1 and isinstance(children[0], Text):
            children_repr = repr_child(children[0])
        else:
            data = ", ".join(repr_child(x) for x in children)
            children_repr = f"[{data}]"

        if attrs and children:
            return "h(%r, %s, %s)" % (self.tag, attrs, children_repr)
        elif attrs:
            return "h(%r, %s)" % (self.tag, attrs)
        elif children:
            return "h(%r, %s)" % (self.tag, children_repr)
        else:
            return "h(%r)" % self.tag

    def __eq__(self, other):
        if other.__class__ is self.__class__:
            return (
                    self.tag == other.tag
                    and self.attrs == other.attrs
                    and len(self.children) == len(other.children)
                    and all(x == y for x, y in zip(self.children, other.children))
            )
        return NotImplemented

    def dump(self, file):
        """
        Dumps HTML data into file.
        """
        write = file.write
        write("<")
        write(self.tag)
        if self.attrs:
            write(" ")
            pos = file.tell()
            dump_attrs(self.attrs, file)
            if file.tell() == pos:
                file.seek(pos - 1)
        write(">")
        if not self.is_void:
            for child in self.children:
                child.dump(file)
        write(f"</{self.tag}>")

    def json(self):
        """
        JSON-compatible representation of object.
        """
        json = {"tag": self.tag}
        if self.attrs:
            json["attrs"] = self.attrs
        if self.children:
            json["children"] = [x.json() for x in self.children]
        return json

    def copy(self):
        """
        Return a copy of object.
        """
        new = object.__new__(Element)
        new.tag = self.tag
        new.attrs = dict(self.attrs)
        new.children = list(self.children)
        new.is_void = self.is_void
        new.requires = self.requires
        return new

    def add_class(self, cls, first=False):
        """
        Add class or group of classes to the class list.
        """
        new_classes = classes(cls)
        try:
            old_classes = self.attrs["class"]
        except KeyError:
            self.attrs["class"] = list(new_classes)
        else:
            if first:
                new_classes = list(new_classes)
                class_set = set(new_classes)
                new_classes.extend(x for x in old_classes if x not in class_set)
                self.attrs["class"][:] = new_classes
            else:
                class_set = set(old_classes)
                old_classes.extend(x for x in new_classes if x not in class_set)
        return self

    def set_class(self, cls=()):
        """
        Replace all current classes by the new ones.
        """
        self.attrs["class"] = list(classes(cls))
        return self


# ------------------------------------------------------------------------------
class Text(str, BaseElement):
    """
    Represents regular text strings
    """

    def __html__(self):
        return _escape(str(self))

    def __getitem__(self, item):
        raise TypeError("Text elements cannot set children")

    def __repr__(self):
        return "Text(%r)" % str(self)

    def _repr_child(self):
        return repr(str(self))

    def render(self):
        return _escape(self)

    def dump(self, file):
        file.write(_escape(self))

    def copy(self, parent=None):
        return self

    def json(self):
        return {"text": str(self)}


class Blob(Markup, BaseElement):
    """
    A blob of raw HTML data.
    """

    def __init__(self, data):
        Markup.__init__(data)

    def __html__(self):
        return self

    def __getitem__(self, item):
        raise TypeError("HTML Blobs cannot set children")

    def __repr__(self):
        return "Blob(%r)" % str(self)

    def _repr_child(self):
        return repr(self)

    def render(self):
        return self.__html__()

    def dump(self, file):
        file.write(self)

    def copy(self, parent=None):
        return self

    def json(self):
        return {"raw": str(self)}


# ------------------------------------------------------------------------------
class Json(BaseElement):
    """
    Stores JSON data.

    Hyperpython stores the JSON objects as data instead of text blobs. Users can
    introspect the data content in the .data attribute.
    """

    _json_data = lazy(lambda self: json.dumps(self.data))

    def __init__(self, data):
        self.data = data

    def __repr__(self):
        return "Json(%r)" % self.data

    def __html__(self):
        return self._json_data

    def dump(self, file):
        json.dump(self.data, file)

    def json(self):
        return self.data

    def copy(self):
        return Json(self.data)


# ------------------------------------------------------------------------------
class Block(BaseElement, Sequence):
    """
    Represents a list of elements *not* wrapped in a tag.
    """

    classes = property(lambda self: [])

    def __init__(self, children, requires=()):
        self.children = list(map(as_child, children))
        self.requires = list(requires)

    def __iter__(self):
        return iter(self.children)

    def __getitem__(self, idx):
        return self.children[idx]

    def __len__(self):
        return len(self.children)

    def dump(self, file):
        for child in self.children:
            child.dump(file)

    def copy(self):
        return Block(list(self.children), requires=list(self.requires))

    def json(self):
        return {"body": [x.to_json() for x in self.children]}


#
# Helper functions
#
def as_attr(name, value):
    """
    Enforces an arbitrary pair of attribute name and value has a compatible
    Hyperpython values.

    Args:
        name: attribute name
        value: attribute value
    """
    if name == "class":
        value = list(classes(value))
        return name, value
    return name, value


def as_child(value):
    """
    Convert arbitrary object to a compatible Element object.
    """

    if isinstance(value, BaseElement):
        return value
    elif isinstance(value, Markup):
        return Blob(value)
    elif isinstance(value, str):
        return Text(value)
    elif isinstance(value, (int, float)):
        return Text(str(value))
    elif isinstance(value, Tag):
        return Element(value.tag, {}, [], value.tag in VOID_ELEMENTS)
    elif hasattr(value, "__html__"):
        return Blob(value.__html__())
    elif hasattr(value, "__hyperpython__"):
        return value.__hyperpython__()
    elif isinstance(value, list):
        return Block(value)
    else:
        data = str(value)
        if data == value:
            return Text(data)
        type_name = value.__class__.__name__
        raise TypeError("invalid type for a child node: %s" % type_name)


def repr_child(value):
    """
    Simplify representation of element, when it is inside a list of children.
    """
    # noinspection PyProtectedMember
    return value._repr_child()


class Tag:
    """
    Return an HTMLTag subclass for the given tag.
    """

    _h_function: callable

    def __init__(self, tag, help_text=None):
        self.tag = tag
        self.__doc__ = help_text

    def __call__(self, *args, **kwargs):
        try:
            h = self._h_function
        except AttributeError:
            from .tags import h
            Tag._h_function = h
        return h(self.tag, *args, **kwargs)

    def __getitem__(self, item):
        return self._h_function(self.tag)[item]