diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4fc7a2a8..4eb1a3f1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/databooks/data_models/cell.py b/databooks/data_models/cell.py index ceb01374..0229ea35 100644 --- a/databooks/data_models/cell.py +++ b/databooks/data_models/cell.py @@ -1,7 +1,7 @@ """Data models - Cells and components.""" from __future__ import annotations -from typing import Any, Dict, Iterable, List, Optional, Sequence, Union +from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Union from pydantic import PositiveInt, validator from rich.console import Console, ConsoleOptions, ConsoleRenderable, RenderResult @@ -11,6 +11,7 @@ from rich.text import Text from databooks.data_models.base import DatabooksBase +from databooks.data_models.rich_helpers import HtmlTable from databooks.logging import get_logger logger = get_logger(__file__) @@ -145,23 +146,22 @@ class CellDisplayDataOutput(DatabooksBase): @property def rich_output(self) -> Sequence[ConsoleRenderable]: """Dynamically compute the rich output - also in `CellExecuteResultOutput`.""" - mime_func = { - "image/png": None, - "text/html": None, + mime_func: Dict[str, Callable[[str], Optional[ConsoleRenderable]]] = { + "image/png": lambda s: None, + "text/html": lambda s: HtmlTable("".join(s)).rich(), "text/plain": lambda s: Text("".join(s)), } - supported = [k for k, v in mime_func.items() if v is not None] - not_supported = [ - Text(f"<โจRichโจ `{mime}` not currently supported ๐ข>") - for mime in self.data.keys() - if mime not in supported - ] - return not_supported + [ - next( - mime_func[mime](content) # type: ignore - for mime, content in self.data.items() - if mime in supported - ) + _rich = { + mime: mime_func.get(mime, lambda s: None)(content) # try to render element + for mime, content in self.data.items() + } + return [ + *[ + Text(f"<โจRichโจ `{mime}` not available ๐ข>") + for mime, renderable in _rich.items() + if renderable is None + ], + next(renderable for renderable in _rich.values() if renderable is not None), ] def __rich_console__( diff --git a/databooks/data_models/rich_helpers.py b/databooks/data_models/rich_helpers.py new file mode 100644 index 00000000..604369b5 --- /dev/null +++ b/databooks/data_models/rich_helpers.py @@ -0,0 +1,64 @@ +"""Rich helpers functions for rich renderables in data models.""" +from html.parser import HTMLParser +from typing import Any, List, Optional, Tuple + +from rich import box +from rich.table import Table + +HtmlAttr = Tuple[str, Optional[str]] + + +class HtmlTable(HTMLParser): + """Rich table from HTML string.""" + + def __init__(self, html: str, *args: Any, **kwargs: Any) -> None: + """Initialize parser.""" + super().__init__(*args, **kwargs) + self.table = self.thead = self.tbody = self.body = self.th = self.td = False + self.headers: List[str] = [] + self.row: List[str] = [] + self.rows: List[List[str]] = [] + self.feed(html) + + def handle_starttag(self, tag: str, attrs: List[HtmlAttr]) -> None: + """Active tags are indicated via instance boolean properties.""" + if getattr(self, tag, None): + raise ValueError(f"Already in `{tag}`.") + setattr(self, tag, True) + + def handle_endtag(self, tag: str) -> None: + """Write table properties when closing tags.""" + if not getattr(self, tag): + raise ValueError(f"Cannot end unopened `{tag}`.") + + # If we are ending a row, either set a table header or row + if tag == "tr": + if self.thead: + self.headers = self.row + if self.tbody: + self.rows.append(self.row) + self.row = [] # restart row values + setattr(self, tag, False) + + def handle_data(self, data: str) -> None: + """Append data depending on active tags.""" + if self.table and (self.th or self.td): + self.row.append(data) + + def rich(self, **tbl_kwargs: Any) -> Optional[Table]: + """Generate `rich` representation of table.""" + if not self.rows and not self.headers: # HTML is not a table + return None + + _ncols = len(self.rows[0]) + _headers = [""] * (_ncols - len(self.headers)) + self.headers + if any(len(row) != _ncols for row in self.rows): + raise ValueError(f"Expected all rows to have {_ncols} columns.") + + _box = tbl_kwargs.pop("box", box.SIMPLE_HEAVY) + _row_styles = tbl_kwargs.pop("row_styles", ["on bright_black", ""]) + + table = Table(*_headers, box=_box, row_styles=_row_styles, **tbl_kwargs) + for row in self.rows: + table.add_row(*row) + return table diff --git a/docs/images/databooks-diff.gif b/docs/images/databooks-diff.gif index e973625b..dbb3c6e8 100644 Binary files a/docs/images/databooks-diff.gif and b/docs/images/databooks-diff.gif differ diff --git a/docs/images/databooks-show.gif b/docs/images/databooks-show.gif index 256dfc4f..8a26a83e 100644 Binary files a/docs/images/databooks-show.gif and b/docs/images/databooks-show.gif differ diff --git a/tests/test_cli.py b/tests/test_cli.py index 9c4e31aa..befac6b4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -390,8 +390,9 @@ def test_show() -> None: with resources.path("tests.files", "tui-demo.ipynb") as nb_path: result = runner.invoke(app, ["show", str(nb_path)]) assert result.exit_code == 0 - assert result.output == dedent( - """\ + assert ( + result.output + == """\ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ tui-demo.ipynb โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ Python 3 (ipykernel) โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ @@ -437,18 +438,20 @@ def test_show() -> None: A dataframe! ๐ผ Out [5]: -<โจRichโจ `text/html` not currently supported ๐ข> - col0 col1 col2 -0 0.849474 0.756456 0.268569 -1 0.511937 0.357224 0.570879 -2 0.836116 0.928280 0.946514 -3 0.803129 0.540215 0.335783 -4 0.074853 0.661168 0.344527 -5 0.299696 0.782420 0.970147 -6 0.159906 0.566822 0.243798 -7 0.896461 0.174406 0.758376 -8 0.708324 0.895195 0.769364 -9 0.860726 0.381919 0.329727 + \n\ + col0 col1 col2 \n\ + โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ \n\ + 0 0.849474 0.756456 0.268569 \n\ + 1 0.511937 0.357224 0.570879 \n\ + 2 0.836116 0.928280 0.946514 \n\ + 3 0.803129 0.540215 0.335783 \n\ + 4 0.074853 0.661168 0.344527 \n\ + 5 0.299696 0.782420 0.970147 \n\ + 6 0.159906 0.566822 0.243798 \n\ + 7 0.896461 0.174406 0.758376 \n\ + 8 0.708324 0.895195 0.769364 \n\ + 9 0.860726 0.381919 0.329727 \n\ + \n\ In [ ]: โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ โ โ diff --git a/tests/test_data_models/test_rich_helpers.py b/tests/test_data_models/test_rich_helpers.py new file mode 100644 index 00000000..c03ba04c --- /dev/null +++ b/tests/test_data_models/test_rich_helpers.py @@ -0,0 +1,114 @@ +from databooks.data_models.rich_helpers import HtmlTable +from tests.test_tui import render + + +def test_html_table() -> None: + """HTML can be rendered to a `rich` table.""" + html = [ + "
\n", + " | col0 | \n", + "col1 | \n", + "col2 | \n", + "
---|---|---|---|
0 | \n", + "0.849474 | \n", + "0.756456 | \n", + "0.268569 | \n", + "
1 | \n", + "0.511937 | \n", + "0.357224 | \n", + "0.570879 | \n", + "
2 | \n", + "0.836116 | \n", + "0.928280 | \n", + "0.946514 | \n", + "
3 | \n", + "0.803129 | \n", + "0.540215 | \n", + "0.335783 | \n", + "
4 | \n", + "0.074853 | \n", + "0.661168 | \n", + "0.344527 | \n", + "
5 | \n", + "0.299696 | \n", + "0.782420 | \n", + "0.970147 | \n", + "
6 | \n", + "0.159906 | \n", + "0.566822 | \n", + "0.243798 | \n", + "
7 | \n", + "0.896461 | \n", + "0.174406 | \n", + "0.758376 | \n", + "
8 | \n", + "0.708324 | \n", + "0.895195 | \n", + "0.769364 | \n", + "
9 | \n", + "0.860726 | \n", + "0.381919 | \n", + "0.329727 | \n", + "