import copy
import io
from collections import Sequence
from types import MappingProxyType
from markupsafe import Markup
from sidekick import lazy, delegate_to
from .helpers import classes
from .renderers import dump_attrs, render_pretty
from .utils import unescape, escape as _escape
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
# ------------------------------------------------------------------------------
[docs]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
[docs] 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}>')
[docs] 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
[docs] 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
[docs] 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
[docs] def set_class(self, cls=()):
"""
Replace all current classes by the new ones.
"""
self.attrs['class'] = list(classes(cls))
return self
# ------------------------------------------------------------------------------
[docs]class Text(Markup, BaseElement):
"""
It extends the Markup object with a Element-compatible API.
"""
unescaped = property(unescape)
def __new__(cls, data, escape=None):
return super().__new__(cls, data)
def __init__(self, data, escape=None):
if escape is None:
escape = not isinstance(data, Markup)
if escape and isinstance(data, Markup):
escape = False
self.escape = escape
def __html__(self):
if self.escape:
return _escape(str(self))
return self
def __getitem__(self, item):
raise TypeError('Text elements cannot set children')
def __repr__(self):
return 'Text(%r)' % str(self)
def _repr_child(self):
if self.escape:
return repr(str(self))
else:
return repr(self)
[docs] def render(self):
return self.__html__()
[docs] def dump(self, file):
if self.escape:
file.write(_escape(self))
else:
file.write(self)
def copy(self, parent=None):
return Text(self, escape=self.escape)
def json(self):
return {'text': str(self)} if self.escape else {'raw': str(self)}
[docs]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)
[docs] 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, (Element, Text)):
return value
elif isinstance(value, (str, Markup)):
return Text(value)
elif isinstance(value, (int, float)):
return Text(str(value))
elif isinstance(value, Tag):
# noinspection PyProtectedMember
return Tag._h_function(value.tag)
elif hasattr(value, '__html__'):
return Text(value.__html__(), escape=False)
elif hasattr(value, '__hyperpython__'):
return value.__hyperpython__()
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 = None
def __init__(self, tag, help_text=None):
self.tag = tag
self.__doc__ = help_text
def __call__(self, *args, **kwargs):
return self._h_function(self.tag, *args, **kwargs)
def __getitem__(self, item):
return self._h_function(self.tag)[item]