diff options
-rw-r--r-- | cros_utils/tiny_render.py | 181 | ||||
-rwxr-xr-x | cros_utils/tiny_render_test.py | 177 |
2 files changed, 358 insertions, 0 deletions
diff --git a/cros_utils/tiny_render.py b/cros_utils/tiny_render.py new file mode 100644 index 00000000..629e7719 --- /dev/null +++ b/cros_utils/tiny_render.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 The Chromium OS Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""A super minimal module that allows rendering of readable text/html. + +Usage should be relatively straightforward. You wrap things you want to write +out in some of the nice types defined here, and then pass the result to one of +render_text_pieces/render_html_pieces. + +In HTML, the types should all nest nicely. In text, eh (nesting anything in +Bold is going to be pretty ugly, probably). + +Lists and tuples may be used to group different renderable elements. + +Example: + +render_text_pieces([ + Bold("Daily to-do list:"), + UnorderedList([ + "Write code", + "Go get lunch", + ["Fix ", Bold("some"), " of the bugs in the aforementioned code"], + [ + "Do one of the following:", + UnorderedList([ + "Nap", + "Round 2 of lunch", + ["Look at ", Link("https://google.com/?q=memes", "memes")], + ]), + ], + "What a rough day; time to go home", + ]), +]) + +Turns into + +**Daily to-do list:** + - Write code + - Go get lunch + - Fix **some** of the bugs in said code + - Do one of the following: + - Nap + - Round 2 of lunch + - Look at memes + - What a rough day; time to go home + +...And similarly in HTML, though with an actual link. + +The rendering functions should never mutate your input. +""" + +from __future__ import print_function + +import collections +import html +import typing as t + +Bold = collections.namedtuple('Bold', ['inner']) +LineBreak = collections.namedtuple('LineBreak', []) +Link = collections.namedtuple('Link', ['href', 'inner']) +UnorderedList = collections.namedtuple('UnorderedList', ['items']) +# Outputs different data depending on whether we're emitting text or HTML. +Switch = collections.namedtuple('Switch', ['text', 'html']) + +line_break = LineBreak() + +# Note that these build up their values in a funky way: they append to a list +# that ends up being fed to `''.join(into)`. This avoids quadratic string +# concatenation behavior. Probably doesn't matter, but I care. + +# Pieces are really a recursive type: +# Union[ +# Bold, +# LineBreak, +# Link, +# List[Piece], +# Tuple[...Piece], +# UnorderedList, +# str, +# ] +# +# It doesn't seem possible to have recursive types, so just go with Any. +Piece = t.Any # pylint: disable=invalid-name + + +def _render_text_pieces(piece: Piece, indent_level: int, + into: t.List[str]) -> None: + """Helper for |render_text_pieces|. Accumulates strs into |into|.""" + if isinstance(piece, LineBreak): + into.append('\n' + indent_level * ' ') + return + + if isinstance(piece, str): + into.append(piece) + return + + if isinstance(piece, Bold): + into.append('**') + _render_text_pieces(piece.inner, indent_level, into) + into.append('**') + return + + if isinstance(piece, Link): + # Don't even try; it's ugly more often than not. + _render_text_pieces(piece.inner, indent_level, into) + return + + if isinstance(piece, UnorderedList): + for p in piece.items: + _render_text_pieces([line_break, '- ', p], indent_level + 2, into) + return + + if isinstance(piece, Switch): + _render_text_pieces(piece.text, indent_level, into) + return + + if isinstance(piece, (list, tuple)): + for p in piece: + _render_text_pieces(p, indent_level, into) + return + + raise ValueError('Unknown piece type: %s' % type(piece)) + + +def render_text_pieces(piece: Piece) -> str: + """Renders the given Pieces into text.""" + into = [] + _render_text_pieces(piece, 0, into) + return ''.join(into) + + +def _render_html_pieces(piece: Piece, into: t.List[str]) -> None: + """Helper for |render_html_pieces|. Accumulates strs into |into|.""" + if piece is line_break: + into.append('<br />\n') + return + + if isinstance(piece, str): + into.append(html.escape(piece)) + return + + if isinstance(piece, Bold): + into.append('<b>') + _render_html_pieces(piece.inner, into) + into.append('</b>') + return + + if isinstance(piece, Link): + into.append('<a href="' + piece.href + '">') + _render_html_pieces(piece.inner, into) + into.append('</a>') + return + + if isinstance(piece, UnorderedList): + into.append('<ul>\n') + for p in piece.items: + into.append('<li>') + _render_html_pieces(p, into) + into.append('</li>\n') + into.append('</ul>\n') + return + + if isinstance(piece, Switch): + _render_html_pieces(piece.html, into) + return + + if isinstance(piece, (list, tuple)): + for p in piece: + _render_html_pieces(p, into) + return + + raise ValueError('Unknown piece type: %s' % type(piece)) + + +def render_html_pieces(piece: Piece) -> str: + """Renders the given Pieces into HTML.""" + into = [] + _render_html_pieces(piece, into) + return ''.join(into) diff --git a/cros_utils/tiny_render_test.py b/cros_utils/tiny_render_test.py new file mode 100755 index 00000000..114a1796 --- /dev/null +++ b/cros_utils/tiny_render_test.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright 2020 The Chromium OS Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""Tests for tiny_render.""" + +from __future__ import print_function + +import unittest + +import tiny_render + + +# Admittedly, the HTML generated by this isn't always _beautiful_ to read +# (especially with e.g., ordered lists). Since the intent is for the HTML to be +# shipped alongside the plain-text, the hope is that people won't have to +# subject themselves to reading the HTML often. :) +class Test(unittest.TestCase): + """Tests for tiny_render.""" + + def test_bold(self): + pieces = [ + tiny_render.Bold('hello'), + ', ', + tiny_render.Bold(['world', '!']), + ] + + self.assertEqual( + tiny_render.render_text_pieces(pieces), + '**hello**, **world!**', + ) + + self.assertEqual( + tiny_render.render_html_pieces(pieces), + '<b>hello</b>, <b>world!</b>', + ) + + def test_line_break(self): + pieces = [ + 'hello', + tiny_render.line_break, + ['world', '!'], + ] + + self.assertEqual( + tiny_render.render_text_pieces(pieces), + 'hello\nworld!', + ) + + self.assertEqual( + tiny_render.render_html_pieces(pieces), + 'hello<br />\nworld!', + ) + + def test_linkification(self): + pieces = [ + 'hello ', + tiny_render.Link(href='https://google.com', inner='world!'), + ] + + self.assertEqual( + tiny_render.render_text_pieces(pieces), + 'hello world!', + ) + + self.assertEqual( + tiny_render.render_html_pieces(pieces), + 'hello <a href="https://google.com">world!</a>', + ) + + def test_unordered_list(self): + pieces = [ + 'hello:', + tiny_render.UnorderedList([ + 'world', + 'w o r l d', + ]), + ] + + self.assertEqual( + tiny_render.render_text_pieces(pieces), + '\n'.join(( + 'hello:', + ' - world', + ' - w o r l d', + )), + ) + + self.assertEqual( + tiny_render.render_html_pieces(pieces), + '\n'.join(( + 'hello:<ul>', + '<li>world</li>', + '<li>w o r l d</li>', + '</ul>', + '', + )), + ) + + def test_nested_unordered_list(self): + pieces = [ + 'hello:', + tiny_render.UnorderedList([ + 'world', + ['and more:', tiny_render.UnorderedList(['w o r l d'])], + 'world2', + ]) + ] + + self.assertEqual( + tiny_render.render_text_pieces(pieces), + '\n'.join(( + 'hello:', + ' - world', + ' - and more:', + ' - w o r l d', + ' - world2', + )), + ) + + self.assertEqual( + tiny_render.render_html_pieces(pieces), + '\n'.join(( + 'hello:<ul>', + '<li>world</li>', + '<li>and more:<ul>', + '<li>w o r l d</li>', + '</ul>', + '</li>', + '<li>world2</li>', + '</ul>', + '', + )), + ) + + def test_switch(self): + pieces = ['hello ', tiny_render.Switch(text='text', html='html')] + self.assertEqual(tiny_render.render_text_pieces(pieces), 'hello text') + self.assertEqual(tiny_render.render_html_pieces(pieces), 'hello html') + + def test_golden(self): + pieces = [ + 'hello', + tiny_render.UnorderedList([ + tiny_render.Switch(text='text', html=tiny_render.Bold('html')), + 'the', + tiny_render.Bold('sun'), + ]), + tiny_render.line_break, + ['is', ' out!'], + ] + + self.assertEqual( + tiny_render.render_text_pieces(pieces), '\n'.join(( + 'hello', + ' - text', + ' - the', + ' - **sun**', + 'is out!', + ))) + + self.assertEqual( + tiny_render.render_html_pieces(pieces), '\n'.join(( + 'hello<ul>', + '<li><b>html</b></li>', + '<li>the</li>', + '<li><b>sun</b></li>', + '</ul>', + '<br />', + 'is out!', + ))) + + +if __name__ == '__main__': + unittest.main() |