From 434433e48c7d360d78b7686cc6eda70d5f780ad1 Mon Sep 17 00:00:00 2001 From: Ben Dichter Date: Fri, 1 Dec 2023 22:27:37 +0100 Subject: [PATCH] improve html display for tables (#998) --- CHANGELOG.md | 1 + src/hdmf/common/table.py | 29 ++++++ src/hdmf/container.py | 103 +++++++++++---------- tests/unit/common/test_table.py | 50 +++++++++- tests/unit/test_container.py | 52 ++++++----- tests/unit/test_multicontainerinterface.py | 43 +++++---- 6 files changed, 181 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6b5013bb..219422bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Minor Improvements - Updated `__gather_columns` to ignore the order of bases when generating columns from the super class. @mavaylon1 [#991](https://github.com/hdmf-dev/hdmf/pull/991) +- Improve HTML rendering of tables. @bendichter [#998](https://github.com/hdmf-dev/hdmf/pull/998) - Improved issue and PR templates. @rly [#1004](https://github.com/hdmf-dev/hdmf/pull/1004) ## HDMF 3.11.0 (October 30, 2023) diff --git a/src/hdmf/common/table.py b/src/hdmf/common/table.py index e6f492022..767bf4f2e 100644 --- a/src/hdmf/common/table.py +++ b/src/hdmf/common/table.py @@ -1192,6 +1192,35 @@ def to_dataframe(self, **kwargs): ret = self.__get_selection_as_df(sel) return ret + def _repr_html_(self) -> str: + """Generates the HTML representation of the object.""" + header_text = self.name if self.name == self.__class__.__name__ else f"{self.name} ({self.__class__.__name__})" + html_repr = self.css_style + self.js_script + html_repr += "
" + html_repr += f"

{header_text}

" + html_repr += self.generate_html_repr() + html_repr += "
" + return html_repr + + def generate_html_repr(self, level: int = 0, access_code: str = "", nrows: int = 4): + out = "" + for key, value in self.fields.items(): + if key not in ("id", "colnames", "columns"): + out += self._generate_field_html(key, value, level, access_code) + + inside = f"{self[:min(nrows, len(self))].to_html()}" + + if len(self) == nrows + 1: + inside += "

... and 1 more row.

" + elif len(self) > nrows + 1: + inside += f"

... and {len(self) - nrows} more rows.

" + + out += ( + f'
table{inside}
' + ) + return out + @classmethod @docval( {'name': 'df', 'type': pd.DataFrame, 'doc': 'source DataFrame'}, diff --git a/src/hdmf/container.py b/src/hdmf/container.py index c83f85e1c..ccc7a3df7 100644 --- a/src/hdmf/container.py +++ b/src/hdmf/container.py @@ -562,8 +562,10 @@ def __repr__(self): template += " {}: {}\n".format(k, v) return template - def _repr_html_(self): - CSS_STYLE = """ + @property + def css_style(self) -> str: + """CSS styles for the HTML representation.""" + return """ """ - JS_SCRIPT = """ + @property + def js_script(self) -> str: + """JavaScript for the HTML representation.""" + return """ """ - if self.name == self.__class__.__name__: - header_text = self.name - else: - header_text = f"{self.name} ({self.__class__.__name__})" - html_repr = CSS_STYLE - html_repr += JS_SCRIPT + + def _repr_html_(self) -> str: + """Generates the HTML representation of the object.""" + header_text = self.name if self.name == self.__class__.__name__ else f"{self.name} ({self.__class__.__name__})" + html_repr = self.css_style + self.js_script html_repr += "
" - html_repr += ( - f"

{header_text}

" - ) - html_repr += self._generate_html_repr(self.fields) + html_repr += f"

{header_text}

" + html_repr += self._generate_html_repr(self.fields, is_field=True) html_repr += "
" return html_repr - def _generate_html_repr(self, fields, level=0, access_code=".fields"): + def _generate_html_repr(self, fields, level=0, access_code="", is_field=False): + """Recursively generates HTML representation for fields.""" html_repr = "" if isinstance(fields, dict): for key, value in fields.items(): - current_access_code = f"{access_code}['{key}']" - if ( - isinstance(value, (list, dict, np.ndarray)) - or hasattr(value, "fields") - ): - label = key - if isinstance(value, dict): - label += f" ({len(value)})" - - html_repr += ( - f'
{label}' - ) - if hasattr(value, "fields"): - value = value.fields - current_access_code = current_access_code + ".fields" - html_repr += self._generate_html_repr( - value, level + 1, current_access_code - ) - html_repr += "
" - else: - html_repr += ( - f'
{key}: {value}
' - ) + current_access_code = f"{access_code}.{key}" if is_field else f"{access_code}['{key}']" + html_repr += self._generate_field_html(key, value, level, current_access_code) elif isinstance(fields, list): for index, item in enumerate(fields): - current_access_code = f"{access_code}[{index}]" - html_repr += ( - f'
{str(item)}
' - ) + access_code += f'[{index}]' + html_repr += self._generate_field_html(index, item, level, access_code) elif isinstance(fields, np.ndarray): - str_ = str(fields).replace("\n", "
") - html_repr += ( - f'
{str_}
' - ) + html_repr += self._generate_array_html(fields, level) else: pass return html_repr + def _generate_field_html(self, key, value, level, access_code): + """Generates HTML for a single field.""" + + if isinstance(value, (int, float, str, bool)): + return f'
{key}: {value}
' + + if hasattr(value, "generate_html_repr"): + html_content = value.generate_html_repr(level + 1, access_code) + + elif hasattr(value, '__repr_html__'): + html_content = value.__repr_html__() + + elif hasattr(value, "fields"): + html_content = self._generate_html_repr(value.fields, level + 1, access_code, is_field=True) + elif isinstance(value, (list, dict, np.ndarray)): + html_content = self._generate_html_repr(value, level + 1, access_code, is_field=False) + else: + html_content = f'{value}' + html_repr = ( + f'
{key}' + ) + html_repr += html_content + html_repr += "
" + + return html_repr + + def _generate_array_html(self, array, level): + """Generates HTML for a NumPy array.""" + str_ = str(array).replace("\n", "
") + return f'
{str_}
' + @staticmethod def __smart_str(v, num_indent): """ diff --git a/tests/unit/common/test_table.py b/tests/unit/common/test_table.py index 1854fae22..3f358d22a 100644 --- a/tests/unit/common/test_table.py +++ b/tests/unit/common/test_table.py @@ -9,14 +9,26 @@ from hdmf import TermSet, TermSetWrapper from hdmf.backends.hdf5 import H5DataIO, HDF5IO from hdmf.backends.hdf5.h5tools import H5_TEXT, H5PY_3 -from hdmf.common import (DynamicTable, VectorData, VectorIndex, ElementIdentifiers, EnumData, - DynamicTableRegion, get_manager, SimpleMultiContainer) +from hdmf.common import ( + DynamicTable, + VectorData, + VectorIndex, + ElementIdentifiers, + EnumData, + DynamicTableRegion, + get_manager, + SimpleMultiContainer, +) from hdmf.testing import TestCase, H5RoundTripMixin, remove_test_file from hdmf.utils import StrDataset from hdmf.data_utils import DataChunkIterator -from tests.unit.helpers.utils import (get_temp_filepath, FooExtendDynamicTable0, - FooExtendDynamicTable1, FooExtendDynamicTable2) +from tests.unit.helpers.utils import ( + get_temp_filepath, + FooExtendDynamicTable0, + FooExtendDynamicTable1, + FooExtendDynamicTable2, +) try: import linkml_runtime # noqa: F401 @@ -772,6 +784,36 @@ def test_repr(self): expected = expected % id(table) self.assertEqual(str(table), expected) + def test_repr_html(self): + table = self.with_spec() + html = table._repr_html_() + + assert html == ( + '\n \n \n ' + '\n

with_spec (DynamicTable)

description: ' + 'a test table
table\n \n \n \n ' + '\n \n \n \n \n \n ' + '\n \n \n \n \n \n ' + '\n
foobarbaz
id
' + ) + + def test_add_column_existing_attr(self): table = self.with_table_columns() attrs = ['name', 'description', 'parent', 'id', 'fields'] # just a few diff --git a/tests/unit/test_container.py b/tests/unit/test_container.py index 311093aa0..0e3fbc9eb 100644 --- a/tests/unit/test_container.py +++ b/tests/unit/test_container.py @@ -7,7 +7,7 @@ from hdmf.common.resources import HERD from hdmf.testing import TestCase from hdmf.utils import docval -from hdmf.common import (DynamicTable, VectorData, DynamicTableRegion) +from hdmf.common import DynamicTable, VectorData, DynamicTableRegion from hdmf.backends.hdf5.h5tools import HDF5IO @@ -420,7 +420,6 @@ def __init__(self, **kwargs): obj.set_data_io("data2", H5DataIO, chunks=True) - class TestHTMLRepr(TestCase): class ContainerWithChildAndData(Container): @@ -446,29 +445,32 @@ def test_repr_html_(self): child_obj1 = Container('test child 1') obj1 = self.ContainerWithChildAndData(child=child_obj1, data=[1, 2, 3], str="hello") assert obj1._repr_html_() == ( - '\n \n \n \n' - '

test ' - 'name (ContainerWithChildAndData)

child
data
1
2
3
<' - 'div style="margin-left: 0px;" class="container-fields">st' - 'r: hello
' + '\n \n \n ' + '\n

test name (' + 'ContainerWithChildAndData)

child
data
0: 1
1: 2
2: ' + '3
str: hello
' ) diff --git a/tests/unit/test_multicontainerinterface.py b/tests/unit/test_multicontainerinterface.py index 4b1dc0c87..c705d0a6e 100644 --- a/tests/unit/test_multicontainerinterface.py +++ b/tests/unit/test_multicontainerinterface.py @@ -330,26 +330,29 @@ def test_repr_html_(self): self.assertEqual( foo._repr_html_(), ( - '\n \n \n \n

FooSingle

containers (2)
obj1
obj2
' + '\n \n \n ' + ' \n

FooSingle

containers
obj1
obj2
' ) )