aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cros_utils/tiny_render.py181
-rwxr-xr-xcros_utils/tiny_render_test.py177
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()