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"""
+
+
+
+ """
+
+
+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"""
"""
+
+ 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"""
+
+ """
+ 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"""
+
+ """
+ )
+
+ 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"""
+
+ """
+
+ @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)}
'