# HG changeset patch # User Tero Marttila # Date 1244211958 -10800 # Node ID 14d73f54476492d6f3f9352d2d384b22783f9861 # Parent 3071d0709c4ae34bdd9d803e7ce96f0bfa5f7ada expressive HTML-rendering module with doctests diff -r 3071d0709c4a -r 14d73f544764 degal/html.py --- a/degal/html.py Thu Jun 04 11:23:28 2009 +0300 +++ b/degal/html.py Fri Jun 05 17:25:58 2009 +0300 @@ -3,89 +3,280 @@ """ from cgi import escape - -def tag_attr (name, value) : - return u'%s="%s"' % (name.rstrip('_'), escape(value, True)) - -def tag_subcontent (subcontent) : - if not subcontent : - # skip - return +import itertools as _itertools - elif not isinstance(subcontent, basestring) and hasattr(subcontent, '__iter__') : - # render each sub-item recursively - for sc in subcontent : - for line in tag_subcontent(sc) : - yield line - - else : - yield subcontent +class IRenderable (object) : + """ + Something that's renderable as the contents of a HTML tag + """ -def tag_content (content) : - if not content: - # no output - return + def render_raw_lines (self, indent=u'\t') : + """ + Render the indented lines for tag and contents, without newlines + """ + + abstract + +class Tag (IRenderable) : + """ + A HTML tag, with attributes and contents, which can a mixture of data and other renderables(tags). + + Provides various kinds of rendering output + """ + + @staticmethod + def process_contents (contents) : + """ + Postprocess contents iterable to return new list. + + Items that are None will be omitted from the return value. + + >>> Tag.process_contents([]) + [] + >>> Tag.process_contents([None]) + [] + >>> Tag.process_contents([u'foo']) + [u'foo'] + """ + + return [c for c in contents if c is not None] + + @staticmethod + def process_attrs (attrs) : + """ + Postprocess attributes. + + Key-value pairs where the value is None will be ommitted, and any trailing underscores in the key omitted. + + TODO: only remove one underscore + + >>> Tag.process_attrs(dict()) + {} + >>> Tag.process_attrs(dict(foo='bar')) + {'foo': 'bar'} + >>> Tag.process_attrs(dict(class_='bar', frob=None)) + {'class': 'bar'} + """ + + return dict((k.rstrip('_'), v) for k, v in attrs.iteritems() if v is not None) + + def __init__ (self, name, *contents, **attrs) : + """ + Construct tag with given name/attributes or contents. + + The filtering rules desribed in process_contents/process_attrs apply. + + >>> Tag('foo') + Tag('foo') + >>> Tag('foo', 'quux') + Tag('foo', 'quux') + >>> Tag('foo', 'quux', bar=5) + Tag('foo', 'quux', bar=5) + >>> Tag('foo', class_='ten') + Tag('foo', class='ten') + """ + + self.name = name + + # store filtered/processed versions + self.contents = self.process_contents(contents) + self.attrs = self.process_attrs(attrs) + + def __call__ (self, *contents, **attrs) : + """ + Return a new Tag with this tag's attributes and contents, as well as the given attributes/contents. + + The filtering rules desribed in process_contents/process_attrs apply. + + >>> Tag('foo')('bar') + Tag('foo', 'bar') + >>> Tag('a', href='index.html')("Home") + Tag('a', 'Home', href='index.html') + >>> Tag('bar', None)(5, foo=None, class_='bar') + Tag('bar', 5, class='bar') + >>> Tag('a')('b')('c')(asdf=5) + Tag('a', 'b', 'c', asdf=5) + >>> t1 = Tag('a'); t2 = t1('b'); t1 + Tag('a') + """ + + # merge attrs/contents + # XXX: new_attrs is not an iterator... + new_attrs = dict(_itertools.chain(self.attrs.iteritems(), attrs.iteritems())) + new_contents = _itertools.chain(self.contents, contents) + + # build new tag + return Tag(self.name, *new_contents, **new_attrs) + + @staticmethod + def format_attr (name, value) : + """ + Format a single HTML tag attribute + + >>> Tag.format_attr('name', 'value') + u'name="value"' + >>> Tag.format_attr('this', '') + u'this="<a"b>"' + >>> Tag.format_attr('xx', 1337) + u'xx="1337"' + """ + + return u'%s="%s"' % (name, escape(unicode(value), True)) + + def render_attrs (self) : + """ + Return the HTML attributes string + + >>> Tag('x', foo=5, bar='<').render_attrs() + u'foo="5" bar="<"' + """ + + return " ".join(self.format_attr(n, v) for n, v in self.attrs.iteritems()) + + def render_contents (self, **render_opts) : + """ + Render the contents of the tag as a series of indented lines, with given render_lines options for subtags + + >>> list(Tag('x', 5).render_contents()) + [u'5'] + >>> list(Tag('x', 'line1', 'line2').render_contents()) + [u'line1', u'line2'] + >>> list(Tag('x', 'a', Tag('b', 'bb'), 'c').render_contents()) + [u'a', u'', u'\\tbb', u'', u'c'] + """ + + for content in self.contents : + if isinstance(content, IRenderable) : + # sub-tags + for line in content.render_raw_lines(**render_opts) : + yield line + + else : + # escape raw values + yield escape(unicode(content)) + + def render_raw_lines (self, indent=u'\t') : + """ + Render the tag and indented content + + >>> list(Tag('xx', 'yy', zz='foo').render_raw_lines(indent=' ')) + [u'', u' yy', u''] + """ + + # render attr string, including preceding space + attrs_stuff = (" " + self.render_attrs()) if self.attrs else "" + + if self.contents : + # tag with content + yield u"<%s%s>" % (self.name, attrs_stuff) + + for line in self.render_contents(indent=indent) : + yield indent + line + + yield u"" % (self.name, ) + + else : + # singleton tag + yield u"<%s%s />" % (self.name, attrs_stuff) - elif isinstance(content, basestring) : - # escape raw vlues - yield escape(unicode(content)) + def render_lines (self, indent=u'\t', newline=u'\n') : + """ + Render full output lines with given newlines - else : - # treat it as subcontent - for line in tag_subcontent(content) : - yield line + >>> list(Tag('xx', 'yy').render_lines()) + [u'\\n', u'\\tyy\\n', u'\\n'] + """ -def tag (name, *content, **attrs) : - attr_stuff = " " + " ".join(tag_attr(n, v) for n, v in attrs.iteritems()) if attrs else "" + for line in self.render_raw_lines(indent) : + yield line + newline + + def render_unicode (self, **render_opts) : + """ + Render full tag as a single unicode string + + >>> Tag('xx', 'yy').render_unicode() + u'\\n\\tyy\\n\\n' + """ + + return "".join(self.render_lines(**render_opts)) + + def render_str (self, charset='ascii', **render_opts) : + """ + Render full tag as an encoded string + + >>> Tag('xx', 'yy').render_str() + '\\n\\tyy\\n\\n' + """ + + return self.render_unicode(**render_opts).encode(charset) + + def render_out (self, stream, charset, **render_opts) : + """ + Render output into the given stream, encoding using the given charset + + >>> from StringIO import StringIO; buf = StringIO(); Tag('xx', 'yy').render_out(buf, 'ascii'); buf.getvalue() + '\\n\\tyy\\n\\n' + """ + + for line in self.render_lines(**render_opts) : + stream.write(line.encode(charset)) - if content and all(content) : - # tag with content - yield u"<%s%s>" % (name, attr_stuff) - - for c in content : - for line in tag_content(c) : - yield u"\t" + line + # default output + __str__ = render_str + __unicode__ = render_unicode - yield u"" % (name, ) + # default .render method + render = render_unicode - else : - # singleton tag - yield u"<%s%s />" % (name, attr_stuff) + def __repr__ (self) : + return 'Tag(%s)' % ', '.join( + [ + repr(self.name) + ] + [ + repr(c) for c in self.contents + ] + [ + '%s=%r' % (name, value) for name, value in self.attrs.iteritems() + ] + ) -def raw (data) : - yield data +class Text (IRenderable) : + """ + Raw HTML text + """ + + def __init__ (self, line) : + """ + Initialize to render as the given lines + """ + + self.lines = [line] + + def render_raw_lines (self, indent=u'\t') : + return self.lines class TagFactory (object) : - def __getattr__ (self, name) : - def build_tag (*content, **attrs) : - return tag(name, *content, **attrs) - - return build_tag - -tags = TagFactory() - -def render_lines (tags, charset=None) : - for line in tag_content(tags) : - if charset : - yield line.encode(charset) - - else : - yield line - -def render (tags, charset=None) : - data = u'\n'.join(render_lines(tags)) - - if charset : - return data.encode(charset) - - else : - return data - -def render_out (tags, out, charset='utf-8') : """ - Write the rendered tags into the given output stream using the given encoding + Build Tags with names give as attribute names """ - for line in render_lines(tags, charset) : - out.write(line + '\n') + def __getattr__ (self, name) : + """ + Get a Tag object with the given name, but no contents + >>> TagFactory().a(href='bar')('quux') + Tag('a', 'quux', href='bar') + """ + + return Tag(name) + +# pretty names +tag = Tag +tags = TagFactory() +raw = Text + +# testing +if __name__ == '__main__' : + import doctest + + doctest.testmod() +