diff --git a/pyproject.toml b/pyproject.toml index 57ea73feb..b171896cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "validmind" -version = "2.10.6" +version = "2.11.0" description = "ValidMind Library" readme = "README.pypi.md" requires-python = ">=3.9,<3.13" diff --git a/tests/test_results.py b/tests/test_results.py index a6f4d58e9..0eafb0679 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -1,9 +1,10 @@ import asyncio +import os import unittest from unittest.mock import patch import pandas as pd import matplotlib.pyplot as plt -from ipywidgets import HTML, VBox +import plotly.graph_objects as go from validmind.vm_models.result import ( TestResult, @@ -74,8 +75,9 @@ def test_error_result(self): self.assertEqual(error_result.error, error) self.assertEqual(error_result.message, "Test error message") - widget = error_result.to_widget() - self.assertIsInstance(widget, HTML) + html = error_result.to_html() + self.assertIsInstance(html, str) + self.assertIn("Test error message", html) def test_test_result_initialization(self): """Test TestResult initialization and basic methods""" @@ -180,8 +182,9 @@ def test_text_generation_result(self): self.assertEqual(text_result.title, "Text Test") self.assertEqual(text_result.description, "Generated text") - widget = text_result.to_widget() - self.assertIsInstance(widget, VBox) + html = text_result.to_html() + self.assertIsInstance(html, str) + self.assertIn("Generated text", html) def test_validate_log_config(self): """Test validation of log configuration""" @@ -290,26 +293,79 @@ def test_test_result_backward_compatibility(self): self.assertEqual(test_result.metric, 100) self.assertEqual(test_result._get_metric_display_value(), 100) - def test_test_result_metric_values_widget_display(self): - """Test MetricValues display in TestResult widgets""" + def test_test_result_metric_values_html_display(self): + """Test MetricValues display in TestResult HTML""" # Test scalar metric display - test_result_scalar = TestResult(result_id="test_scalar_widget") + test_result_scalar = TestResult(result_id="test_scalar_html") test_result_scalar.set_metric(0.95) - widget_scalar = test_result_scalar.to_widget() - self.assertIsInstance(widget_scalar, HTML) + html_scalar = test_result_scalar.to_html() + self.assertIsInstance(html_scalar, str) # Check that the metric value appears in the HTML - self.assertIn("0.95", widget_scalar.value) + self.assertIn("0.95", html_scalar) # Test list metric display - test_result_list = TestResult(result_id="test_list_widget") + test_result_list = TestResult(result_id="test_list_html") test_result_list.set_metric([0.1, 0.2, 0.3]) - widget_list = test_result_list.to_widget() + html_list = test_result_list.to_html() # Even with lists, when no tables/figures exist, it returns HTML - self.assertIsInstance(widget_list, HTML) + self.assertIsInstance(html_list, str) # Check that the list values appear in the HTML - self.assertIn("[0.1, 0.2, 0.3]", widget_list.value) + self.assertIn("[0.1, 0.2, 0.3]", html_list) + + def test_figure_interactive_toggle_plotly(self): + """Test that Plotly figures respect VALIDMIND_INTERACTIVE_FIGURES env var""" + plotly_fig = go.Figure(data=go.Scatter(x=[1, 2, 3], y=[4, 5, 6])) + figure = Figure(key="test_key", figure=plotly_fig, ref_id="test_ref") + + # Test enabled values (including default behavior) + enabled_values = [None, "true", "True", "TRUE", "1", "yes", "Yes", "YES"] + for value in enabled_values: + if value is None: + # Test default behavior (env var not set) + env_backup = os.environ.pop("VALIDMIND_INTERACTIVE_FIGURES", None) + try: + html = figure.to_html() + self.assertIsInstance(html, str) + self.assertIn("vm-plotly-data", html, "Default should include plotly data") + self.assertIn("vm-plotly-test_key", html) + finally: + if env_backup is not None: + os.environ["VALIDMIND_INTERACTIVE_FIGURES"] = env_backup + else: + with patch.dict(os.environ, {"VALIDMIND_INTERACTIVE_FIGURES": value}, clear=False): + html = figure.to_html() + self.assertIsInstance(html, str) + self.assertIn("vm-plotly-data", html, f"Should include plotly data for value: {value}") + self.assertIn("vm-plotly-test_key", html) + + # Test disabled values + disabled_values = ["false", "False", "FALSE", "0", "no", "No", "NO"] + for value in disabled_values: + with patch.dict(os.environ, {"VALIDMIND_INTERACTIVE_FIGURES": value}, clear=False): + html = figure.to_html() + self.assertIsInstance(html, str) + self.assertNotIn("vm-plotly-data", html, f"Should exclude plotly data for value: {value}") + self.assertNotIn("vm-plotly-test_key", html, f"Should exclude plotly container for value: {value}") + # Should still contain the static image + self.assertIn("data:image/png;base64", html) + self.assertIn("vm-img-test_key", html) + + def test_figure_interactive_toggle_matplotlib_unaffected(self): + """Test that matplotlib figures are unaffected by the toggle""" + matplotlib_fig = plt.figure() + plt.plot([1, 2, 3]) + figure = Figure(key="test_key", figure=matplotlib_fig, ref_id="test_ref") + + # Test that matplotlib figures never include plotly data regardless of setting + # Only need to test once since behavior is identical for all values + with patch.dict(os.environ, {"VALIDMIND_INTERACTIVE_FIGURES": "true"}, clear=False): + html = figure.to_html() + self.assertIsInstance(html, str) + self.assertNotIn("vm-plotly-data", html) + self.assertIn("data:image/png;base64", html) + self.assertIn("vm-img-test_key", html) if __name__ == "__main__": diff --git a/validmind/__version__.py b/validmind/__version__.py index 57a42d3df..7f6646a76 100644 --- a/validmind/__version__.py +++ b/validmind/__version__.py @@ -1 +1 @@ -__version__ = "2.10.6" +__version__ = "2.11.0" diff --git a/validmind/api_client.py b/validmind/api_client.py index c901be491..c0d14dac8 100644 --- a/validmind/api_client.py +++ b/validmind/api_client.py @@ -18,7 +18,6 @@ import aiohttp import requests from aiohttp import FormData -from ipywidgets import HTML, Accordion from .client_config import client_config from .errors import MissingAPICredentialsError, MissingModelIdError, raise_api_error @@ -404,9 +403,7 @@ def log_input(input_id: str, type: str, metadata: Dict[str, Any]) -> Dict[str, A return run_async(alog_input, input_id, type, metadata) -def log_text( - content_id: str, text: str, _json: Optional[Dict[str, Any]] = None -) -> Dict[str, Any]: +def log_text(content_id: str, text: str, _json: Optional[Dict[str, Any]] = None) -> str: """Logs free-form text to ValidMind API. Args: @@ -419,7 +416,7 @@ def log_text( Exception: If the API call fails. Returns: - ipywidgets.Accordion: An accordion widget containing the logged text as HTML. + str: HTML string containing the logged text in an accordion format. """ if not content_id or not isinstance(content_id, str): raise ValueError("`content_id` must be a non-empty string") @@ -431,8 +428,10 @@ def log_text( log_text = run_async(alog_metadata, content_id, text, _json) - return Accordion( - children=[HTML(log_text["text"])], + from .vm_models.html_renderer import StatefulHTMLRenderer + + return StatefulHTMLRenderer.render_accordion( + items=[log_text["text"]], titles=[f"Text Block: '{log_text['content_id']}'"], ) diff --git a/validmind/template.py b/validmind/template.py index 10517f877..ea5edd4df 100644 --- a/validmind/template.py +++ b/validmind/template.py @@ -2,9 +2,8 @@ # See the LICENSE file in the root of this repository for details. # SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial -from typing import Any, Dict, List, Optional, Type, Union - -from ipywidgets import HTML, Accordion, VBox, Widget +import uuid +from typing import Any, Dict, List, Optional, Type from .html_templates.content_blocks import ( failed_content_block_html, @@ -12,13 +11,14 @@ ) from .logging import get_logger from .tests import LoadTestError, describe_test -from .utils import display, is_notebook +from .utils import display, is_notebook, test_id_to_name from .vm_models import TestSuite +from .vm_models.html_renderer import StatefulHTMLRenderer logger = get_logger(__name__) CONTENT_TYPE_MAP = { - "test": "Threshold Test", + "test": "Test", "metric": "Metric", "unit_metric": "Unit Metric", "metadata_text": "Metadata Text", @@ -58,92 +58,136 @@ def _convert_sections_to_section_tree( return sorted(section_tree, key=lambda x: x.get("order", 9999)) -def _create_content_widget(content: Dict[str, Any]) -> Widget: +def _render_test_accordion(content: str, title: str) -> str: + """Render a test block accordion with styling matching text blocks. + + Args: + content: HTML content for the accordion item + title: Title for the accordion header + + Returns: + HTML string with accordion matching text block styling + """ + accordion_id = f"accordion-{uuid.uuid4().hex[:8]}" + item_id = f"{accordion_id}-item-0" + + return f""" +
+
+
+ + {title} +
+ +
+
+ + + """ + + +def _create_content_html(content: Dict[str, Any]) -> str: + """Create HTML representation of a content block.""" content_type = CONTENT_TYPE_MAP[content["content_type"]] if content["content_type"] not in ["metric", "test"]: - return HTML( - non_test_content_block_html.format( - content_id=content["content_id"], - content_type=content_type, - ) + return non_test_content_block_html.format( + content_id=content["content_id"], + content_type=content_type, ) try: test_html = describe_test(test_id=content["content_id"], show=False) + test_name = test_id_to_name(content["content_id"]) + # Wrap test/metric blocks in accordion with styling matching text blocks + return _render_test_accordion( + content=test_html, + title=f"{content_type}: {test_name} ('{content['content_id']}')", + ) except LoadTestError: - return HTML(failed_content_block_html.format(test_id=content["content_id"])) - - return Accordion( - children=[HTML(test_html)], - titles=[f"{content_type} Block: '{content['content_id']}'"], - ) + # Wrap failed test blocks in accordion for consistency + failed_html = failed_content_block_html.format(test_id=content["content_id"]) + return _render_test_accordion( + content=failed_html, + title=f"{content_type}: Failed to load ('{content['content_id']}')", + ) -def _create_sub_section_widget( +def _create_sub_section_html( sub_sections: List[Dict[str, Any]], section_number: str -) -> Union[HTML, Accordion]: +) -> str: + """Create HTML representation of a subsection.""" if not sub_sections: - return HTML("

Empty Section

") + return "

Empty Section

" - accordion = Accordion() + accordion_items = [] + accordion_titles = [] for i, section in enumerate(sub_sections): + section_content = "" if section["sections"]: - accordion.children = ( - *accordion.children, - _create_sub_section_widget( - section["sections"], section_number=f"{section_number}.{i + 1}" - ), + section_content = _create_sub_section_html( + section["sections"], section_number=f"{section_number}.{i + 1}" ) elif contents := section.get("contents", []): - contents_widget = VBox( - [_create_content_widget(content) for content in contents] - ) - - accordion.children = ( - *accordion.children, - contents_widget, - ) + content_htmls = [_create_content_html(content) for content in contents] + section_content = "".join(content_htmls) else: - accordion.children = ( - *accordion.children, - HTML("

Empty Section

"), - ) + section_content = "

Empty Section

" - accordion.set_title( - i, f"{section_number}.{i + 1}. {section['title']} ('{section['id']}')" + accordion_items.append(section_content) + accordion_titles.append( + f"{section_number}.{i + 1}. {section['title']} ('{section['id']}')" ) - return accordion + return StatefulHTMLRenderer.render_accordion(accordion_items, accordion_titles) -def _create_section_widget(tree: List[Dict[str, Any]]) -> Accordion: - widget = Accordion() +def _create_section_html(tree: List[Dict[str, Any]]) -> str: + """Create HTML representation of sections.""" + accordion_items = [] + accordion_titles = [] + for i, section in enumerate(tree): - sub_widget = None + section_content = "" if section.get("sections"): - sub_widget = _create_sub_section_widget(section["sections"], i + 1) + section_content = _create_sub_section_html(section["sections"], str(i + 1)) if section.get("contents"): - contents_widget = VBox( - [_create_content_widget(content) for content in section["contents"]] + contents_html = "".join( + [_create_content_html(content) for content in section["contents"]] ) - if sub_widget: - sub_widget.children = ( - *sub_widget.children, - contents_widget, - ) + if section_content: + section_content = section_content + contents_html else: - sub_widget = contents_widget + section_content = contents_html - if not sub_widget: - sub_widget = HTML("

Empty Section

") + if not section_content: + section_content = "

Empty Section

" - widget.children = (*widget.children, sub_widget) - widget.set_title(i, f"{i + 1}. {section['title']} ('{section['id']}')") + accordion_items.append(section_content) + accordion_titles.append(f"{i + 1}. {section['title']} ('{section['id']}')") - return widget + return StatefulHTMLRenderer.render_accordion(accordion_items, accordion_titles) def preview_template(template: str) -> None: @@ -156,9 +200,11 @@ def preview_template(template: str) -> None: logger.warning("preview_template() only works in Jupyter Notebook") return - display( - _create_section_widget(_convert_sections_to_section_tree(template["sections"])) + html_content = StatefulHTMLRenderer.get_base_css() + html_content += _create_section_html( + _convert_sections_to_section_tree(template["sections"]) ) + display(html_content) def _get_section_tests(section: Dict[str, Any]) -> List[str]: diff --git a/validmind/tests/load.py b/validmind/tests/load.py index 1392ab062..af3018905 100644 --- a/validmind/tests/load.py +++ b/validmind/tests/load.py @@ -21,7 +21,6 @@ from uuid import uuid4 import pandas as pd -from ipywidgets import HTML, Accordion from ..errors import LoadTestError, MissingDependencyError from ..html_templates.content_blocks import test_content_block_html @@ -381,7 +380,7 @@ def list_tests( def describe_test( test_id: Optional[TestID] = None, raw: bool = False, show: bool = True -) -> Union[str, HTML, Dict[str, Any]]: +) -> Union[str, Dict[str, Any]]: """Get or show details about the test This function can be used to see test details including the test name, description, @@ -433,9 +432,10 @@ def describe_test( if not show: return html - display( - Accordion( - children=[HTML(html)], - titles=[f"Test: {details['Name']} ('{test_id}')"], - ) + from ..vm_models.html_renderer import StatefulHTMLRenderer + + accordion_html = StatefulHTMLRenderer.render_accordion( + items=[html], + titles=[f"Test: {details['Name']} ('{test_id}')"], ) + display(accordion_html) diff --git a/validmind/tests/model_validation/sklearn/OverfitDiagnosis.py b/validmind/tests/model_validation/sklearn/OverfitDiagnosis.py index c7097b047..02d573716 100644 --- a/validmind/tests/model_validation/sklearn/OverfitDiagnosis.py +++ b/validmind/tests/model_validation/sklearn/OverfitDiagnosis.py @@ -266,7 +266,7 @@ def OverfitDiagnosis( results_train = {k: [] for k in results_headers} results_test = {k: [] for k in results_headers} - for region, df_region in train_df.groupby("bin"): + for region, df_region in train_df.groupby("bin", observed=True): _compute_metrics( results=results_train, region=region, diff --git a/validmind/tests/model_validation/sklearn/PopulationStabilityIndex.py b/validmind/tests/model_validation/sklearn/PopulationStabilityIndex.py index 45791bf34..bc8daa94e 100644 --- a/validmind/tests/model_validation/sklearn/PopulationStabilityIndex.py +++ b/validmind/tests/model_validation/sklearn/PopulationStabilityIndex.py @@ -48,7 +48,7 @@ def calculate_psi(score_initial, score_new, num_bins=10, mode="fixed"): # Bucketize the initial population and count the sample inside each bucket bins_initial = pd.cut(score_initial, bins=bins, labels=range(1, num_bins + 1)) df_initial = pd.DataFrame({"initial": score_initial, "bin": bins_initial}) - grp_initial = df_initial.groupby("bin").count() + grp_initial = df_initial.groupby("bin", observed=True).count() grp_initial["percent_initial"] = grp_initial["initial"] / sum( grp_initial["initial"] ) @@ -56,7 +56,7 @@ def calculate_psi(score_initial, score_new, num_bins=10, mode="fixed"): # Bucketize the new population and count the sample inside each bucket bins_new = pd.cut(score_new, bins=bins, labels=range(1, num_bins + 1)) df_new = pd.DataFrame({"new": score_new, "bin": bins_new}) - grp_new = df_new.groupby("bin").count() + grp_new = df_new.groupby("bin", observed=True).count() grp_new["percent_new"] = grp_new["new"] / sum(grp_new["new"]) # Compare the bins to calculate PSI diff --git a/validmind/tests/model_validation/sklearn/RobustnessDiagnosis.py b/validmind/tests/model_validation/sklearn/RobustnessDiagnosis.py index f758ac142..b29cc2f41 100644 --- a/validmind/tests/model_validation/sklearn/RobustnessDiagnosis.py +++ b/validmind/tests/model_validation/sklearn/RobustnessDiagnosis.py @@ -323,6 +323,8 @@ def RobustnessDiagnosis( model=model.input_id, ) # rename perturbation size for baseline + # Convert to object type first to avoid dtype incompatibility warning + results_df["Perturbation Size"] = results_df["Perturbation Size"].astype(object) results_df.loc[ results_df["Perturbation Size"] == 0.0, "Perturbation Size" ] = "Baseline (0.0)" diff --git a/validmind/tests/model_validation/sklearn/WeakspotsDiagnosis.py b/validmind/tests/model_validation/sklearn/WeakspotsDiagnosis.py index 6dc8a6180..1eacf1c37 100644 --- a/validmind/tests/model_validation/sklearn/WeakspotsDiagnosis.py +++ b/validmind/tests/model_validation/sklearn/WeakspotsDiagnosis.py @@ -261,7 +261,7 @@ def WeakspotsDiagnosis( r1 = {k: [] for k in results_headers} r2 = {k: [] for k in results_headers} - for region, df_region in df_1.groupby("bin"): + for region, df_region in df_1.groupby("bin", observed=True): _compute_metrics( results=r1, metrics=metrics, diff --git a/validmind/utils.py b/validmind/utils.py index 681314770..a114e7b04 100644 --- a/validmind/utils.py +++ b/validmind/utils.py @@ -536,14 +536,19 @@ def preview_test_config(config): def display(widget_or_html, syntax_highlighting=True, mathjax=True): - """Display widgets with extra goodies (syntax highlighting, MathJax, etc.).""" - if isinstance(widget_or_html, str): + """Display HTML content with extra goodies (syntax highlighting, MathJax, etc.).""" + if hasattr(widget_or_html, "to_html"): + html_content = widget_or_html.to_html() + ipy_display(HTML(html_content)) + syntax_highlighting = 'class="language-' in html_content + mathjax = "math/tex" in html_content + elif isinstance(widget_or_html, str): ipy_display(HTML(widget_or_html)) - # if html we can auto-detect if we actually need syntax highlighting or MathJax syntax_highlighting = 'class="language-' in widget_or_html mathjax = "math/tex" in widget_or_html else: - ipy_display(widget_or_html) + # Fallback: convert to string representation + ipy_display(HTML(str(widget_or_html))) if syntax_highlighting: ipy_display(HTML(python_syntax_highlighting)) diff --git a/validmind/vm_models/figure.py b/validmind/vm_models/figure.py index 2c99a8816..692c78a7c 100644 --- a/validmind/vm_models/figure.py +++ b/validmind/vm_models/figure.py @@ -8,17 +8,18 @@ import base64 import json +import os from dataclasses import dataclass from io import BytesIO from typing import Union -import ipywidgets as widgets import matplotlib import plotly.graph_objs as go from ..client_config import client_config from ..errors import UnsupportedFigureError from ..utils import get_full_typename +from .html_renderer import StatefulHTMLRenderer def is_matplotlib_figure(figure) -> bool: @@ -70,43 +71,34 @@ def __post_init__(self): def __repr__(self): return f"Figure(key={self.key}, ref_id={self.ref_id})" - def to_widget(self): + def to_html(self): """ - Returns the ipywidget compatible representation of the figure. Ideally - we would render images as-is, but Plotly FigureWidgets don't work well - on Google Colab when they are combined with ipywidgets. + Returns HTML representation that preserves state when notebook is saved. + This is the preferred method for displaying figures in notebooks. """ + metadata = {"key": self.key, "ref_id": self.ref_id, "type": self._type} + if is_matplotlib_figure(self.figure): tmpfile = BytesIO() self.figure.savefig(tmpfile, format="png") encoded = base64.b64encode(tmpfile.getvalue()).decode("utf-8") - return widgets.HTML( - value=f""" - - """ - ) + return StatefulHTMLRenderer.render_figure(encoded, self.key, metadata) elif is_plotly_figure(self.figure): - # FigureWidget can be displayed as-is but not on Google Colab. In this case - # we just return the image representation of the figure. - if client_config.running_on_colab: - png_file = self.figure.to_image(format="png") - encoded = base64.b64encode(png_file).decode("utf-8") - return widgets.HTML( - value=f""" - - """ - ) - else: - return self.figure + png_file = self.figure.to_image(format="png") + encoded = base64.b64encode(png_file).decode("utf-8") + # Add plotly-specific metadata only if interactive figures are enabled + if os.getenv("VALIDMIND_INTERACTIVE_FIGURES", "true").lower() in ( + "true", + "1", + "yes", + ): + metadata["plotly_json"] = self.figure.to_json() + return StatefulHTMLRenderer.render_figure(encoded, self.key, metadata) elif is_png_image(self.figure): encoded = base64.b64encode(self.figure).decode("utf-8") - return widgets.HTML( - value=f""" - - """ - ) + return StatefulHTMLRenderer.render_figure(encoded, self.key, metadata) else: raise UnsupportedFigureError( diff --git a/validmind/vm_models/html_progress.py b/validmind/vm_models/html_progress.py new file mode 100644 index 000000000..3e95e0595 --- /dev/null +++ b/validmind/vm_models/html_progress.py @@ -0,0 +1,170 @@ +# Copyright © 2023-2024 ValidMind Inc. All rights reserved. +# See the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +""" +HTML-based progress bar that preserves state in saved notebooks. +""" + +import uuid + +from IPython.display import HTML, display, update_display + +from .html_renderer import StatefulHTMLRenderer + + +class HTMLProgressBar: + """HTML-based progress bar that preserves state when notebook is saved.""" + + def __init__(self, max_value: int, description: str = "Running test suite..."): + """Initialize the progress bar. + + Args: + max_value: Maximum value for the progress bar + description: Initial description text + """ + self.max_value = max_value + self.value = 0 + self.description = description + self.bar_id = f"progress-{uuid.uuid4().hex[:8]}" + self._display_id = f"display-{self.bar_id}" + self._displayed = False + + def display(self): + """Display the progress bar.""" + if not self._displayed: + html_content = StatefulHTMLRenderer.render_live_progress_bar( + max_value=self.max_value, + description=self.description, + bar_id=self.bar_id, + ) + display(HTML(html_content), display_id=self._display_id) + self._displayed = True + + def update(self, value: int, description: str = None): + """Update the progress bar value and description. + + Args: + value: New progress value + description: Optional new description + """ + self.value = value + if description: + self.description = description + + if self._displayed: + self._update_fallback() + + def _update_fallback(self): + """Fallback method to update progress bar by replacing the entire HTML.""" + html_content = StatefulHTMLRenderer.render_progress_bar( + value=self.value, + max_value=self.max_value, + description=self.description, + bar_id=self.bar_id, + ) + try: + update_display(HTML(html_content), display_id=self._display_id) + except Exception: + pass + + def complete(self): + """Mark the progress bar as complete.""" + self.update(self.max_value, "Test suite complete!") + + def close(self): + """Close/hide the progress bar.""" + if self._displayed: + final_html = StatefulHTMLRenderer.render_progress_bar( + value=self.value, + max_value=self.max_value, + description=self.description, + bar_id=self.bar_id, + ) + update_display(HTML(final_html), display_id=self._display_id) + + +class HTMLLabel: + """HTML-based label that preserves state when notebook is saved.""" + + def __init__(self, value: str = ""): + """Initialize the label. + + Args: + value: Initial label text + """ + self.value = value + self.label_id = f"label-{uuid.uuid4().hex[:8]}" + self._display_id = f"display-{self.label_id}" + self._displayed = False + + def display(self): + """Display the label.""" + if not self._displayed: + html_content = f""" +
+ {self.value} +
+ """ + display(HTML(html_content), display_id=self._display_id) + self._displayed = True + + def update(self, value: str): + """Update the label text. + + Args: + value: New label text + """ + self.value = value + + if self._displayed: + update_script = f""" + + """ + display(HTML(update_script), display_id=f"update-{self._display_id}") + + +class HTMLBox: + """HTML-based container that preserves state when notebook is saved.""" + + def __init__( + self, + children=None, + layout_style="display: flex; align-items: center; gap: 10px;", + ): + """Initialize the box container. + + Args: + children: List of child elements + layout_style: CSS style for the container + """ + self.children = children or [] + self.layout_style = layout_style + self.box_id = f"box-{uuid.uuid4().hex[:8]}" + self._display_id = f"display-{self.box_id}" + self._displayed = False + + def display(self): + """Display the box and its children.""" + if not self._displayed: + child_html_parts = [] + for child in self.children: + if hasattr(child, "display"): + child.display() + if hasattr(child, "bar_id"): + child_html_parts.append(f'
') + elif hasattr(child, "label_id"): + child_html_parts.append(f'
') + + html_content = f""" +
+ {''.join(child_html_parts)} +
+ """ + display(HTML(html_content), display_id=self._display_id) + self._displayed = True diff --git a/validmind/vm_models/html_renderer.py b/validmind/vm_models/html_renderer.py new file mode 100644 index 000000000..4ace01814 --- /dev/null +++ b/validmind/vm_models/html_renderer.py @@ -0,0 +1,450 @@ +# Copyright © 2023-2024 ValidMind Inc. All rights reserved. +# See the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +""" +HTML renderer for ValidMind components that preserves state in saved notebooks. +""" + +import json +import uuid +from typing import Any, Dict, List, Optional, Union + +import pandas as pd + + +class StatefulHTMLRenderer: + """Renders ValidMind components as self-contained HTML with embedded state.""" + + # Plotly.js CDN URL - using a stable version + PLOTLY_CDN_URL = "https://cdn.plot.ly/plotly-2.27.0.min.js" + + @staticmethod + def render_figure( + figure_data: str, key: str, metadata: Optional[Dict[str, Any]] = None + ) -> str: + """Render a figure as HTML with embedded data. + + For Plotly figures, renders an interactive chart with the static image + as a fallback for environments without JavaScript support. + + Args: + figure_data: Base64-encoded image data + key: Unique key for the figure + metadata: Optional metadata to embed (may contain plotly_json for + interactive Plotly rendering) + + Returns: + HTML string with embedded figure and metadata + """ + metadata = metadata or {} + plotly_json = metadata.get("plotly_json") + + # Create a copy of metadata without plotly_json for the embedded metadata + # (to avoid duplicating the large JSON in the HTML) + metadata_for_embed = {k: v for k, v in metadata.items() if k != "plotly_json"} + metadata_for_embed["has_plotly_json"] = plotly_json is not None + metadata_json = json.dumps(metadata_for_embed, default=str) + + # Static image HTML (used as fallback or primary display) + img_html = f"""ValidMind Figure {key}""" + + if plotly_json: + plotly_cdn_url = StatefulHTMLRenderer.PLOTLY_CDN_URL + + # Render with static image visible by default, JavaScript upgrades to interactive + # This ensures the image shows even when scripts are blocked (e.g., Google Colab) + return f""" +
+ +
{img_html}
+ + + +
+ """ + else: + # Non-Plotly figures (matplotlib, PNG) - render static image only + return f""" +
+ {img_html} + +
+ """ + + @staticmethod + def render_table( + data: Union[pd.DataFrame, List[Dict[str, Any]]], + title: Optional[str] = None, + table_id: Optional[str] = None, + ) -> str: + """Render a table as HTML. + + Args: + data: DataFrame or list of dictionaries + title: Optional table title + table_id: Optional unique ID for the table + + Returns: + HTML string with table + """ + if isinstance(data, list): + data = pd.DataFrame(data) + + if table_id is None: + table_id = f"table-{uuid.uuid4().hex[:8]}" + + title_html = f"

{title}

" if title else "" + + # Convert DataFrame to HTML with styling + table_html = data.to_html( + classes="vm-table table table-striped table-hover", + table_id=table_id, + escape=False, + index=False, + ) + + return f""" +
+ {title_html} + {table_html} +
+ """ + + @staticmethod + def render_accordion( + items: List[str], titles: List[str], accordion_id: Optional[str] = None + ) -> str: + """Render an accordion component as HTML with JavaScript. + + Args: + items: List of HTML content for each accordion item + titles: List of titles for each accordion item + accordion_id: Optional unique ID for the accordion + + Returns: + HTML string with accordion and embedded JavaScript + """ + if accordion_id is None: + accordion_id = f"accordion-{uuid.uuid4().hex[:8]}" + + accordion_items = [] + + for i, (title, content) in enumerate(zip(titles, items)): + item_id = f"{accordion_id}-item-{i}" + accordion_items.append( + f""" +
+
+ + {title} +
+ +
+ """ + ) + + return f""" +
+ {''.join(accordion_items)} +
+ + + """ + + @staticmethod + def render_progress_bar( + value: int, max_value: int, description: str = "", bar_id: Optional[str] = None + ) -> str: + """Render a progress bar as HTML. + + Args: + value: Current progress value + max_value: Maximum value + description: Progress description + bar_id: Optional unique ID for the progress bar + + Returns: + HTML string with progress bar + """ + if bar_id is None: + bar_id = f"progress-{uuid.uuid4().hex[:8]}" + + percentage = (value / max_value * 100) if max_value > 0 else 0 + + return f""" +
+
{description}
+
+
+
+
+
{value}/{max_value} ({percentage:.1f}%)
+
+ """ + + @staticmethod + def render_live_progress_bar( + max_value: int, + description: str = "Running test suite...", + bar_id: Optional[str] = None, + ) -> str: + """Render a live-updating progress bar as HTML with JavaScript. + + Args: + max_value: Maximum value for the progress bar + description: Initial description text + bar_id: Optional unique ID for the progress bar + + Returns: + HTML string with live progress bar and update functions + """ + if bar_id is None: + bar_id = f"progress-{uuid.uuid4().hex[:8]}" + + return f""" +
+
{description}
+
+
+
+
+
0/{max_value} (0.0%)
+
+ + + """ + + @staticmethod + def render_result_header( + test_name: str, + passed: Optional[bool] = None, + metric: Optional[Union[int, float]] = None, + ) -> str: + """Render a test result header. + + Args: + test_name: Name of the test + passed: Whether the test passed (None for no status) + metric: Optional metric value + + Returns: + HTML string with result header + """ + if passed is None: + status_icon = "" + else: + status_icon = "✅" if passed else "❌" + + metric_html = f": {metric}" if metric is not None else "" + + return f""" +
+

{status_icon} {test_name}{metric_html}

+
+ """ + + @staticmethod + def render_description(description: str) -> str: + """Render a description with proper formatting. + + Args: + description: Description text (may contain HTML) + + Returns: + HTML string with formatted description + """ + formatted_description = description.replace("

", "").replace( + "

", "" + ) + + return f""" +
+ {formatted_description} +
+ """ + + @staticmethod + def render_parameters(params: Dict[str, Any]) -> str: + """Render parameters as formatted JSON. + + Args: + params: Parameters dictionary + + Returns: + HTML string with formatted parameters + """ + params_json = json.dumps(params, indent=2, default=str) + + return f""" +
+

Parameters:

+
+{params_json}
+            
+
+ """ + + @staticmethod + def get_base_css() -> str: + """Get base CSS styles for ValidMind HTML components. + + Returns: + CSS string with base styles + """ + return """ + + """ diff --git a/validmind/vm_models/result/result.py b/validmind/vm_models/result/result.py index 81ee46849..76b51bd36 100644 --- a/validmind/vm_models/result/result.py +++ b/validmind/vm_models/result/result.py @@ -15,28 +15,21 @@ import matplotlib import pandas as pd import plotly.graph_objs as go -from ipywidgets import HTML, VBox from ... import api_client from ...ai.utils import DescriptionFuture from ...errors import InvalidParameterError from ...logging import get_logger, log_api_operation -from ...utils import ( - HumanReadableEncoder, - NumpyEncoder, - display, - run_async, - test_id_to_name, -) +from ...utils import HumanReadableEncoder, display, run_async, test_id_to_name from ..figure import Figure, create_figure +from ..html_renderer import StatefulHTMLRenderer from ..input import VMInput from .pii_filter import PIIDetectionMode, get_pii_detection_mode, scan_df, scan_text from .utils import ( AI_REVISION_NAME, DEFAULT_REVISION_NAME, - figures_to_widgets, - get_result_template, - tables_to_widgets, + figures_to_html, + tables_to_html, update_metadata, ) @@ -135,8 +128,8 @@ def __str__(self) -> str: """May be overridden by subclasses.""" return self.__class__.__name__ - def to_widget(self): - """Create an ipywidget representation of the result... Must be overridden by subclasses.""" + def to_html(self): + """Generate HTML representation of the result. Must be overridden by subclasses.""" raise NotImplementedError def log(self): @@ -145,7 +138,10 @@ def log(self): def show(self): """Display the result... May be overridden by subclasses.""" - display(self.to_widget()) + if hasattr(self, "to_html"): + display(self.to_html()) + else: + display(str(self)) @dataclass @@ -159,8 +155,15 @@ class ErrorResult(Result): def __repr__(self) -> str: return f'ErrorResult(result_id="{self.result_id}")' - def to_widget(self): - return HTML(f"

{self.message}

{self.error}

") + def to_html(self): + """Generate HTML that persists in saved notebooks.""" + return f""" + {StatefulHTMLRenderer.get_base_css()} +
+

{self.message}

+

{self.error}

+
+ """ async def log_async(self): pass @@ -266,11 +269,9 @@ def _get_metric_display_value( Returns: The raw metric value, handling both metric and scorer fields. """ - # Check metric field first if self.metric is not None: return self.metric - # Check scorer field if self.scorer is not None: return self.scorer @@ -283,11 +284,9 @@ def _get_metric_serialized_value( Returns: The serialized metric value, handling both metric and scorer fields. """ - # Check metric field first if self.metric is not None: return self.metric - # Check scorer field if self.scorer is not None: return self.scorer @@ -387,39 +386,36 @@ def remove_figure(self, index: int = 0): self.figures.pop(index) - def to_widget(self): - metric_display_value = self._get_metric_display_value() - if ( - (self.metric is not None or self.scorer is not None) - and not self.tables - and not self.figures - ): - return HTML( - f"

{self.test_name}: {metric_display_value}

" + def to_html(self): + """Generate HTML that persists in saved notebooks.""" + metric_value = self._get_metric_display_value() + + if metric_value is not None and not self.tables and not self.figures: + return StatefulHTMLRenderer.render_result_header( + test_name=self.test_name, passed=self.passed, metric=metric_value ) - template_data = { - "test_name": self.test_name, - "passed_icon": "" if self.passed is None else "✅" if self.passed else "❌", - "description": self.description.replace("h3", "strong"), - "params": ( - json.dumps(self.params, cls=NumpyEncoder, indent=2) - if self.params - else None - ), - "show_metric": self.metric is not None, - "metric": metric_display_value, - } - rendered = get_result_template().render(**template_data) + html_parts = [StatefulHTMLRenderer.get_base_css()] - widgets = [HTML(rendered)] + html_parts.append( + StatefulHTMLRenderer.render_result_header( + test_name=self.test_name, passed=self.passed, metric=metric_value + ) + ) + + if self.description: + html_parts.append(StatefulHTMLRenderer.render_description(self.description)) + + if self.params: + html_parts.append(StatefulHTMLRenderer.render_parameters(self.params)) if self.tables: - widgets.extend(tables_to_widgets(self.tables)) + html_parts.append(tables_to_html(self.tables)) + if self.figures: - widgets.extend(figures_to_widgets(self.figures)) + html_parts.append(figures_to_html(self.figures)) - return VBox(widgets) + return f'
{"".join(html_parts)}
' @classmethod def _get_client_config(cls): @@ -447,7 +443,6 @@ def check_result_id_exist(self): # Iterate through all sections for section in client_config.documentation_template["sections"]: blocks = section.get("contents", []) - # Check each block in the section for block in blocks: if ( block.get("content_type") == "test" @@ -513,7 +508,6 @@ def serialize(self): "metadata": self.metadata, } - # Add metric type information if available metric_type = self._get_metric_type() if metric_type: serialized["metric_type"] = metric_type @@ -550,7 +544,6 @@ async def log_async( metric_value = self._get_metric_serialized_value() metric_type = self._get_metric_type() - # Use appropriate metric key based on type metric_key = self.result_id if metric_type == "scorer": metric_key = f"{self.result_id}_scorer" @@ -745,21 +738,23 @@ def test_name(self) -> str: """Get the test name, using custom title if available.""" return self.title or test_id_to_name(self.result_id) - def to_widget(self): - template_data = { - "test_name": self.test_name, - "description": self.description.replace("h3", "strong"), - "params": ( - json.dumps(self.params, cls=NumpyEncoder, indent=2) - if self.params - else None - ), - } - rendered = get_result_template().render(**template_data) + def to_html(self): + """Generate HTML that persists in saved notebooks.""" + html_parts = [StatefulHTMLRenderer.get_base_css()] + + html_parts.append( + StatefulHTMLRenderer.render_result_header( + test_name=self.test_name, passed=None + ) + ) + + if self.description: + html_parts.append(StatefulHTMLRenderer.render_description(self.description)) - widgets = [HTML(rendered)] + if self.params: + html_parts.append(StatefulHTMLRenderer.render_parameters(self.params)) - return VBox(widgets) + return f'
{"".join(html_parts)}
' def serialize(self): """Serialize the result for the API.""" @@ -791,7 +786,6 @@ def log( Args: content_id (str): The content ID to log the result to. """ - # Check description text for PII when available if self.description: try: from .pii_filter import check_text_for_pii diff --git a/validmind/vm_models/result/utils.py b/validmind/vm_models/result/utils.py index 508aac46d..e064e8e5f 100644 --- a/validmind/vm_models/result/utils.py +++ b/validmind/vm_models/result/utils.py @@ -5,12 +5,12 @@ import os from typing import TYPE_CHECKING, Dict, List, Union -from ipywidgets import HTML, GridBox, Layout from jinja2 import Template from ... import api_client from ...logging import get_logger from ..figure import Figure +from ..html_renderer import StatefulHTMLRenderer if TYPE_CHECKING: from .result import ResultTable @@ -49,66 +49,38 @@ async def update_metadata(content_id: str, text: str, _json: Union[Dict, List] = await api_client.alog_metadata(content_id, text, _json) -def tables_to_widgets(tables: List["ResultTable"]): - """Convert a list of tables to ipywidgets.""" - widgets = [ - HTML("

Tables

"), - ] +def tables_to_html(tables: List["ResultTable"]) -> str: + """Convert a list of tables to HTML.""" + if not tables: + return "" + + html_parts = ["

Tables

"] for table in tables: - html = "" - if table.title: - html += f"

{table.title}

" - - html += ( - table.data.reset_index(drop=True) - .style.format(precision=4) - .hide(axis="index") - .set_table_styles( - [ - { - "selector": "", - "props": [("width", "100%")], - }, - { - "selector": "th", - "props": [("text-align", "left")], - }, - { - "selector": "tbody tr:nth-child(even)", - "props": [("background-color", "#FFFFFF")], - }, - { - "selector": "tbody tr:nth-child(odd)", - "props": [("background-color", "#F5F5F5")], - }, - { - "selector": "td, th", - "props": [ - ("padding-left", "5px"), - ("padding-right", "5px"), - ], - }, - ] - ) - .set_properties(**{"text-align": "left"}) - .to_html(escape=False) + table_html = StatefulHTMLRenderer.render_table( + data=table.data, title=table.title ) + html_parts.append(table_html) - widgets.append(HTML(html)) + return "".join(html_parts) - return widgets +def figures_to_html(figures: List[Figure]) -> str: + """Convert a list of figures to HTML.""" + if not figures: + return "" -def figures_to_widgets(figures: List[Figure]) -> list: - """Convert a list of figures to ipywidgets.""" - num_columns = 2 if len(figures) > 1 else 1 + html_parts = ["

Figures

"] - plot_widgets = GridBox( - [figure.to_widget() for figure in figures], - layout=Layout( - grid_template_columns=f"repeat({num_columns}, 1fr)", - ), - ) + # Create a simple grid layout for multiple figures + if len(figures) > 1: + html_parts.append( + '
' + ) + for figure in figures: + html_parts.append(f"
{figure.to_html()}
") + html_parts.append("
") + else: + html_parts.append(figures[0].to_html()) - return [HTML("

Figures

"), plot_widgets] + return "".join(html_parts) diff --git a/validmind/vm_models/test_suite/runner.py b/validmind/vm_models/test_suite/runner.py index 145be09cd..4da5cdd2b 100644 --- a/validmind/vm_models/test_suite/runner.py +++ b/validmind/vm_models/test_suite/runner.py @@ -4,11 +4,9 @@ import asyncio -import ipywidgets as widgets -from IPython.display import display - from ...logging import get_logger from ...utils import is_notebook, run_async, run_async_check +from ..html_progress import HTMLBox, HTMLLabel, HTMLProgressBar from .summary import TestSuiteSummary from .test_suite import TestSuite @@ -25,9 +23,10 @@ class TestSuiteRunner: _test_configs: dict = None - pbar: widgets.IntProgress = None - pbar_description: widgets.Label = None - pbar_box: widgets.HBox = None + # HTML-based progress components + html_pbar: HTMLProgressBar = None + html_pbar_description: HTMLLabel = None + html_pbar_box: HTMLBox = None def __init__(self, suite: TestSuite, config: dict = None, inputs: dict = None): self.suite = suite @@ -65,15 +64,46 @@ def _start_progress_bar(self, send: bool = True): # if we are sending then there is a task for each test and logging its result num_tasks = self.suite.num_tests() * 2 if send else self.suite.num_tests() - self.pbar_description = widgets.Label(value="Running test suite...") - self.pbar = widgets.IntProgress(max=num_tasks, orientation="horizontal") - self.pbar_box = widgets.HBox([self.pbar_description, self.pbar]) - - display(self.pbar_box) + self.html_pbar_description = HTMLLabel(value="Running test suite...") + self.html_pbar = HTMLProgressBar( + max_value=num_tasks, description="Running test suite..." + ) + self.html_pbar_box = HTMLBox([self.html_pbar_description, self.html_pbar]) + self.html_pbar.display() def _stop_progress_bar(self): - self.pbar_description.value = "Test suite complete!" - self.pbar.close() + if self.html_pbar: + self.html_pbar.complete() + self.html_pbar.close() + if self.html_pbar_description: + self.html_pbar_description.update("Test suite complete!") + + def _update_progress_message(self, message: str): + """Updates HTML progress bar message.""" + if self.html_pbar: + self.html_pbar.update(self.html_pbar.value, message) + if self.html_pbar_description: + self.html_pbar_description.update(message) + + def _increment_progress(self): + """Increments HTML progress bar.""" + if self.html_pbar: + self.html_pbar.update(self.html_pbar.value + 1) + + async def _log_test_result(self, test): + """Logs a single test result to ValidMind.""" + sending_test_message = f"Sending result to ValidMind: {test.test_id}..." + self._update_progress_message(sending_test_message) + + try: + await test.log_async() + except Exception: + failure_message = "Failed to send result to ValidMind" + self._update_progress_message(failure_message) + logger.error(f"Failed to log result: {test.result}") + raise + + self._increment_progress() async def log_results(self): """Logs the results of the test suite to ValidMind. @@ -81,33 +111,32 @@ async def log_results(self): This method will be called after the test suite has been run and all results have been collected. This method will log the results to ValidMind. """ - self.pbar_description.value = ( + sending_message = ( f"Sending results of test suite '{self.suite.suite_id}' to ValidMind..." ) + self._update_progress_message(sending_message) tests = [test for section in self.suite.sections for test in section.tests] # TODO: use asyncio.gather here for better performance for test in tests: - self.pbar_description.value = ( - f"Sending result to ValidMind: {test.test_id}..." - ) - - try: - await test.log_async() - except Exception as e: - self.pbar_description.value = "Failed to send result to ValidMind" - logger.error(f"Failed to log result: {test.result}") - - raise e - - self.pbar.value += 1 + await self._log_test_result(test) async def _check_progress(self): done = False while not done: - if self.pbar.value == self.pbar.max: - self.pbar_description.value = "Test suite complete!" + progress_complete = False + if self.html_pbar and self.html_pbar.value >= self.html_pbar.max_value: + progress_complete = True + + if progress_complete: + completion_message = "Test suite complete!" + + if self.html_pbar: + self.html_pbar.update(self.html_pbar.max_value, completion_message) + if self.html_pbar_description: + self.html_pbar_description.update(completion_message) + done = True await asyncio.sleep(0.5) @@ -116,7 +145,12 @@ def summarize(self, show_link: bool = True): if not is_notebook(): return logger.info("Test suite done...") - self.pbar_description.value = "Collecting test results..." + collecting_message = "Collecting test results..." + + if self.html_pbar: + self.html_pbar.update(self.html_pbar.value, collecting_message) + if self.html_pbar_description: + self.html_pbar_description.update(collecting_message) summary = TestSuiteSummary( title=self.suite.title, @@ -124,7 +158,10 @@ def summarize(self, show_link: bool = True): sections=self.suite.sections, show_link=show_link, ) - summary.display() + + from ...utils import display as vm_display + + vm_display(summary) def run(self, send: bool = True, fail_fast: bool = False): """Runs the test suite, renders the summary and sends the results to ValidMind. @@ -139,12 +176,20 @@ def run(self, send: bool = True, fail_fast: bool = False): for section in self.suite.sections: for test in section.tests: - self.pbar_description.value = f"Running {test.name}" + running_message = f"Running {test.name}" + + if self.html_pbar: + self.html_pbar.update(self.html_pbar.value, running_message) + if self.html_pbar_description: + self.html_pbar_description.update(running_message) + test.run( fail_fast=fail_fast, config=self._test_configs.get(test.test_id, {}), ) - self.pbar.value += 1 + + if self.html_pbar: + self.html_pbar.update(self.html_pbar.value + 1) if send: run_async(self.log_results) diff --git a/validmind/vm_models/test_suite/summary.py b/validmind/vm_models/test_suite/summary.py index e3b53cab8..3e2c3af1e 100644 --- a/validmind/vm_models/test_suite/summary.py +++ b/validmind/vm_models/test_suite/summary.py @@ -5,10 +5,9 @@ from dataclasses import dataclass from typing import List, Optional -import ipywidgets as widgets - from ...logging import get_logger from ...utils import display, md_to_html +from ..html_renderer import StatefulHTMLRenderer from ..result import ErrorResult from .test_suite import TestSuiteSection, TestSuiteTest @@ -32,51 +31,43 @@ class TestSuiteSectionSummary: tests: List[TestSuiteTest] description: Optional[str] = None - _widgets: List[widgets.Widget] = None - - def __post_init__(self): - self._build_summary() + def display(self): + """Display the summary.""" + display(self.to_html()) - def _add_description(self): - """Add the section description to the summary.""" - if not self.description: - return + def to_html(self): + """Generate HTML representation.""" + html_parts = [StatefulHTMLRenderer.get_base_css()] - self._widgets.append( - widgets.HTML( - value=f'
{md_to_html(self.description)}
' + if self.description: + html_parts.append( + f'
{md_to_html(self.description)}
' ) - ) - def _add_tests_summary(self): - """Add the test results summary.""" - children = [] - titles = [] + accordion_items = [] + accordion_titles = [] for test in self.tests: - children.append(test.result.to_widget()) - titles.append( - f"❌ {test.result.name}: {test.name} ({test.test_id})" - if isinstance(test.result, ErrorResult) - else f"{test.result.name}: {test.name} ({test.test_id})" + if hasattr(test.result, "to_html"): + accordion_items.append(test.result.to_html()) + else: + # Fallback: create a simple HTML representation + accordion_items.append( + f'

Result: {test.result.name}

' + ) + + title_prefix = "❌ " if isinstance(test.result, ErrorResult) else "" + accordion_titles.append( + f"{title_prefix}{test.result.name}: {test.name} ({test.test_id})" ) - self._widgets.append(widgets.Accordion(children=children, titles=titles)) - - def _build_summary(self): - """Build the complete summary.""" - self._widgets = [] - - if self.description: - self._add_description() + if accordion_items: + accordion_html = StatefulHTMLRenderer.render_accordion( + accordion_items, accordion_titles + ) + html_parts.append(accordion_html) - self._add_tests_summary() - - self.summary = widgets.VBox(self._widgets) - - def display(self): - """Display the summary.""" - display(self.summary) + return f'
{"".join(html_parts)}
' @dataclass @@ -88,100 +79,58 @@ class TestSuiteSummary: sections: List[TestSuiteSection] show_link: bool = True - _widgets: List[widgets.Widget] = None + def display(self): + """Display the summary.""" + display(self.to_html()) - def __post_init__(self): - """Initialize the summary after the dataclass is created.""" - self._build_summary() + def to_html(self): + """Generate HTML representation of the complete test suite summary.""" + html_parts = [StatefulHTMLRenderer.get_base_css()] - def _add_title(self): - """Add the title to the summary.""" - title = f""" + title_html = f"""

Test Suite Results: {self.title}


- """.strip() - - self._widgets.append(widgets.HTML(value=title)) - - def _add_results_link(self): - """Add a link to documentation on ValidMind.""" - # avoid circular import - from ...api_client import get_api_host, get_api_model - - ui_host = get_api_host().replace("/api/v1/tracking", "").replace("api", "app") - link = f"{ui_host}model-inventory/{get_api_model()}" - results_link = f""" -

- Check out the updated documentation on - ValidMind. -

- """.strip() - - self._widgets.append(widgets.HTML(value=results_link)) - - def _add_description(self): - """Add the test suite description to the summary.""" - self._widgets.append( - widgets.HTML( - value=f'
{md_to_html(self.description)}
' + """ + html_parts.append(title_html) + + if self.show_link: + from ...api_client import get_api_host, get_api_model + + ui_host = ( + get_api_host().replace("/api/v1/tracking", "").replace("api", "app") ) - ) + link = f"{ui_host}model-inventory/{get_api_model()}" + results_link_html = f""" +

+ Check out the updated documentation on + ValidMind. +

+ """ + html_parts.append(results_link_html) - def _add_sections_summary(self): - """Append the section summary.""" - children = [] - titles = [] + html_parts.append(f'
{md_to_html(self.description)}
') + + if len(self.sections) == 1: + section_summary = TestSuiteSectionSummary(tests=self.sections[0].tests) + html_parts.append(section_summary.to_html()) + else: + section_items = [] + section_titles = [] - for section in self.sections: - if not section.tests: - continue + for section in self.sections: + if not section.tests: + continue - children.append( - TestSuiteSectionSummary( + section_summary = TestSuiteSectionSummary( description=section.description, tests=section.tests, - ).summary - ) - titles.append(id_to_name(section.section_id)) - - self._widgets.append(widgets.Accordion(children=children, titles=titles)) - - def _add_top_level_section_summary(self): - """Add the top-level section summary.""" - self._widgets.append( - TestSuiteSectionSummary(tests=self.sections[0].tests).summary - ) - - def _add_footer(self): - """Add the footer.""" - footer = """ - - """.strip() - - self._widgets.append(widgets.HTML(value=footer)) - - def _build_summary(self): - """Build the complete summary.""" - self._widgets = [] - - self._add_title() - if self.show_link: - self._add_results_link() - self._add_description() - if len(self.sections) == 1: - self._add_top_level_section_summary() - else: - self._add_sections_summary() + ) + section_items.append(section_summary.to_html()) + section_titles.append(id_to_name(section.section_id)) - self.summary = widgets.VBox(self._widgets) + if section_items: + sections_accordion = StatefulHTMLRenderer.render_accordion( + section_items, section_titles + ) + html_parts.append(sections_accordion) - def display(self): - """Display the summary.""" - display(self.summary) + return f'
{"".join(html_parts)}
'