From 7eddc28d205a8145c91285f6a0543e1b11d459d0 Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Mon, 27 Apr 2026 15:59:50 -0700 Subject: [PATCH] [SC-15877] support pandas Styler outputs with portable cell styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept pandas Styler as a valid table output type. TableOutputHandler now converts a Styler into a DataFrame whose styled cells are serialized as {value, bgcolor, color, fontWeight, textAlign} dicts — a portable, JSON-safe schema consumed by the documentation UI and report renderers. StatefulHTMLRenderer renders structured cells back to HTML for in-notebook display so notebook output matches portal rendering without leaking raw {'value': ...} JSON. Adds a how-to notebook demonstrating styled-cell output from a custom test, plus tests covering the Styler conversion path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../style_custom_test_tables.ipynb | 595 ++++++++++++++++++ tests/test_results.py | 82 ++- validmind/tests/load.py | 5 +- validmind/tests/output.py | 49 +- validmind/vm_models/html_renderer.py | 44 +- 5 files changed, 758 insertions(+), 17 deletions(-) create mode 100644 notebooks/how_to/tests/custom_tests/style_custom_test_tables.ipynb diff --git a/notebooks/how_to/tests/custom_tests/style_custom_test_tables.ipynb b/notebooks/how_to/tests/custom_tests/style_custom_test_tables.ipynb new file mode 100644 index 000000000..6ec5aed15 --- /dev/null +++ b/notebooks/how_to/tests/custom_tests/style_custom_test_tables.ipynb @@ -0,0 +1,595 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Style custom test tables\n", + "\n", + "Learn how to style tables returned by ValidMind custom tests. Start with native pandas `Styler` output for common full-cell formatting, then use the ValidMind structured cell format when you need to return the same basic styles without using pandas `Styler`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "::: {.content-hidden when-format=\"html\"}\n", + "## Contents \n", + "- [About ValidMind](#toc1__) \n", + " - [Before you begin](#toc1_1__) \n", + " - [New to ValidMind?](#toc1_2__) \n", + " - [Key concepts](#toc1_3__) \n", + "- [Setting up](#toc2__) \n", + " - [Install the ValidMind Library](#toc2_1__) \n", + " - [Initialize the ValidMind Library](#toc2_2__) \n", + " - [Register sample model](#toc2_2_1__) \n", + " - [Apply documentation template](#toc2_2_2__) \n", + " - [Get your code snippet](#toc2_2_3__) \n", + " - [Import packages](#toc2_3__) \n", + "- [Style a table with native pandas](#toc3__) \n", + " - [Run the pandas styled table test](#toc3_1__) \n", + " - [Log the pandas styled table result](#toc3_2__) \n", + "- [Use ValidMind structured cells](#toc4__) \n", + " - [Run the structured cell table test](#toc4_1__) \n", + " - [Log the structured cell table result](#toc4_2__) \n", + "- [Next steps](#toc5__) \n", + " - [Discover more learning resources](#toc5_1__) \n", + "- [Upgrade ValidMind](#toc6__) \n", + "\n", + ":::\n", + "\n", + "" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "## About ValidMind\n", + "\n", + "ValidMind is a suite of tools for managing model risk, including risk associated with AI and statistical models.\n", + "\n", + "You use the ValidMind Library to automate documentation and validation tests, and then use the ValidMind Platform to collaborate on model documentation. Together, these products simplify model risk management, facilitate compliance with regulations and institutional standards, and enhance collaboration between yourself and model validators." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Before you begin\n", + "\n", + "This notebook assumes you have basic familiarity with Python, including an understanding of how functions work. If you are new to Python, you can still run the notebook but we recommend further familiarizing yourself with the language.\n", + "\n", + "If you encounter errors due to missing modules in your Python environment, install the modules with `pip install`, and then re-run the notebook. For more help, refer to [Installing Python Modules](https://docs.python.org/3/installing/index.html)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "### New to ValidMind?\n", + "\n", + "If you haven't already seen our documentation on the [ValidMind Library](https://docs.validmind.ai/developer/validmind-library.html), we recommend you begin by exploring the available resources in this section. There, you can learn more about documenting models and running tests, as well as find code samples and our Python Library API reference.\n", + "\n", + "
For access to all features available in this notebook, you'll need access to a ValidMind account.\n", + "

\n", + "Register with ValidMind
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Key concepts\n", + "\n", + "**Model documentation**: A structured and detailed record pertaining to a model, encompassing key components such as its underlying assumptions, methodologies, data sources, inputs, performance metrics, evaluations, limitations, and intended uses. It serves to ensure transparency, adherence to regulatory requirements, and a clear understanding of potential risks associated with the model's application.\n", + "\n", + "**Documentation template**: Functions as a test suite and lays out the structure of model documentation, segmented into various sections and sub-sections. Documentation templates define the structure of your model documentation, specifying the tests that should be run, and how the results should be displayed.\n", + "\n", + "**Tests**: A function contained in the ValidMind Library, designed to run a specific quantitative test on the dataset or model. Tests are the building blocks of ValidMind, used to evaluate and document models and datasets, and can be run individually or as part of a suite defined by your model documentation template.\n", + "\n", + "**Custom tests**: Custom tests are functions that you define to evaluate your model, dataset, or another model artifact. These functions can be registered with the ValidMind Library and run with `run_test()`.\n", + "\n", + "**Test results**: Outputs generated by ValidMind tests. Test results can include descriptions, tables, figures, metrics, and raw data, and can be logged to the ValidMind Platform." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Setting up" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Install the ValidMind Library\n", + "\n", + "
Recommended Python versions\n", + "

\n", + "Python 3.8 <= x <= 3.14
\n", + "\n", + "To install the library:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -q validmind" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Initialize the ValidMind Library\n", + "\n", + "You can run the examples in this notebook without connecting to the ValidMind Platform. Initialize the ValidMind Library only when you want to log the generated test results to model documentation." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "#### Register sample model\n", + "\n", + "To log these examples to the ValidMind Platform, first register a sample model:\n", + "\n", + "1. In a browser, [log in to ValidMind](https://docs.validmind.ai/guide/configuration/log-in-to-validmind.html).\n", + "\n", + "2. In the left sidebar, navigate to the **Inventory** page.\n", + "\n", + "3. Click **+ Register new model** and follow the prompts." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "#### Apply documentation template\n", + "\n", + "Once you've registered your model, select a documentation template. A template predefines sections for your model documentation and provides a general outline to follow, making the documentation process much easier." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "#### Get your code snippet\n", + "\n", + "Initialize the ValidMind Library with the code snippet unique to each model and document, ensuring your test results are uploaded to the correct model and automatically populated in the right documentation section." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Load your model identifier credentials from an `.env` file\n", + "\n", + "%load_ext dotenv\n", + "%dotenv .env\n", + "\n", + "# Or replace with your code snippet\n", + "import validmind as vm\n", + "\n", + "vm.init(\n", + " # api_host=\"...\",\n", + " # api_key=\"...\",\n", + " # api_secret=\"...\",\n", + " # model=\"...\",\n", + " document=\"documentation\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Import packages\n", + "\n", + "Import pandas, ValidMind, and the test runner." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import validmind as vm\n", + "from validmind.tests import run_test" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Style a table with native pandas\n", + "\n", + "The simplest way to color a custom test table is to return a native pandas `Styler`. This lets you use the styling logic your team may already have in pandas while preserving common cell-level styles in ValidMind output.\n", + "\n", + "ValidMind preserves these pandas cell styles when displaying or logging the table:\n", + "\n", + "- `background` and `background-color`\n", + "- `color`\n", + "- `font-weight`\n", + "- `text-align`\n", + "\n", + "The example below styles rows by review status, highlights the observed values column, and formats numeric values as percentages." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "@vm.test(\"my_custom_tests.PandasStyledTable\")\n", + "def pandas_styled_table():\n", + " df = pd.DataFrame(\n", + " [\n", + " {\n", + " \"Check\": \"Missing values\",\n", + " \"Observed\": 0.008,\n", + " \"Threshold\": \"<= 1.0%\",\n", + " \"Status\": \"Pass\",\n", + " },\n", + " {\n", + " \"Check\": \"Outlier rate\",\n", + " \"Observed\": 0.027,\n", + " \"Threshold\": \"<= 2.0%\",\n", + " \"Status\": \"Review\",\n", + " },\n", + " {\n", + " \"Check\": \"Schema match\",\n", + " \"Observed\": \"1 column missing\",\n", + " \"Threshold\": \"0 missing\",\n", + " \"Status\": \"Fail\",\n", + " },\n", + " ]\n", + " )\n", + "\n", + " row_backgrounds = {\n", + " \"Pass\": \"#F6FBF8\",\n", + " \"Review\": \"#FFF9E8\",\n", + " \"Fail\": \"#FDF0F2\",\n", + " }\n", + " status_styles = {\n", + " \"Pass\": \"background-color: #DFF6E8; color: #0B6B3A; font-weight: 600; text-align: center\",\n", + " \"Review\": \"background-color: #FFF1BF; color: #835B00; font-weight: 600; text-align: center\",\n", + " \"Fail\": \"background-color: #FFD8DF; color: #A0182D; font-weight: 600; text-align: center\",\n", + " }\n", + "\n", + " def style_row(row):\n", + " row_background = row_backgrounds[row[\"Status\"]]\n", + " styles = []\n", + "\n", + " for column in row.index:\n", + " if column == \"Observed\":\n", + " styles.append(\"background-color: #EAF4FF; color: #083E44; text-align: center\")\n", + " elif column in [\"Check\", \"Threshold\"]:\n", + " styles.append(f\"background-color: {row_background}; color: #083E44; font-weight: 600\")\n", + " else:\n", + " styles.append(\"\")\n", + "\n", + " return styles\n", + "\n", + " return (\n", + " df.style.format({\"Observed\": lambda value: f\"{value:.1%}\" if isinstance(value, float) else value})\n", + " .apply(style_row, axis=1)\n", + " .map(lambda value: status_styles.get(value, \"\"), subset=[\"Status\"])\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Run the pandas styled table test\n", + "\n", + "Run the custom test by ID. The result renders the returned pandas `Styler` as a ValidMind result table." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pandas_result = run_test(\"my_custom_tests.PandasStyledTable\", generate_description=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Log the pandas styled table result\n", + "\n", + "If your notebook is connected to a ValidMind model documentation project, uncomment the line below to log the styled table result." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pandas_result.log()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Use ValidMind structured cells\n", + "\n", + "Native pandas styling is the preferred starting point for full-cell table colors. You can also return structured cell dictionaries directly when your custom test builds table output without pandas `Styler`.\n", + "\n", + "Return a dictionary with `value` plus style keys such as `bgcolor`, `color`, `fontWeight`, and `textAlign` for cells that need color." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def styled_cell(\n", + " value,\n", + " background,\n", + " color=\"#083E44\",\n", + " font_weight=400,\n", + " text_align=\"left\",\n", + "):\n", + " return {\n", + " \"value\": value,\n", + " \"bgcolor\": background,\n", + " \"color\": color,\n", + " \"fontWeight\": font_weight,\n", + " \"textAlign\": text_align,\n", + " }\n", + "\n", + "\n", + "@vm.test(\"my_custom_tests.StructuredStyledTable\")\n", + "def structured_styled_table():\n", + " rows = [\n", + " {\n", + " \"check\": \"Missing values\",\n", + " \"observed\": \"0.8%\",\n", + " \"threshold\": \"<= 1.0%\",\n", + " \"status\": \"Pass\",\n", + " \"row_background\": \"#F6FBF8\",\n", + " \"status_background\": \"#DFF6E8\",\n", + " \"status_color\": \"#0B6B3A\",\n", + " },\n", + " {\n", + " \"check\": \"Outlier rate\",\n", + " \"observed\": \"2.7%\",\n", + " \"threshold\": \"<= 2.0%\",\n", + " \"status\": \"Review\",\n", + " \"row_background\": \"#FFF9E8\",\n", + " \"status_background\": \"#FFF1BF\",\n", + " \"status_color\": \"#835B00\",\n", + " },\n", + " {\n", + " \"check\": \"Schema match\",\n", + " \"observed\": \"1 column missing\",\n", + " \"threshold\": \"0 missing\",\n", + " \"status\": \"Fail\",\n", + " \"row_background\": \"#FDF0F2\",\n", + " \"status_background\": \"#FFD8DF\",\n", + " \"status_color\": \"#A0182D\",\n", + " },\n", + " ]\n", + "\n", + " return pd.DataFrame(\n", + " [\n", + " {\n", + " \"Check\": styled_cell(row[\"check\"], row[\"row_background\"], font_weight=600),\n", + " \"Observed\": styled_cell(row[\"observed\"], \"#EAF4FF\", text_align=\"center\"),\n", + " \"Threshold\": styled_cell(row[\"threshold\"], row[\"row_background\"], font_weight=600),\n", + " \"Status\": styled_cell(\n", + " row[\"status\"],\n", + " row[\"status_background\"],\n", + " color=row[\"status_color\"],\n", + " font_weight=600,\n", + " text_align=\"center\",\n", + " ),\n", + " }\n", + " for row in rows\n", + " ]\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Run the structured cell table test\n", + "\n", + "Run the second custom test by ID. This example uses the same data as the pandas example, but returns a DataFrame containing structured cell dictionaries." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "structured_result = run_test(\"my_custom_tests.StructuredStyledTable\", generate_description=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Log the structured cell table result\n", + "\n", + "If your notebook is connected to a ValidMind model documentation project, uncomment the line below to log the structured table result." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "structured_result.log()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Next steps\n", + "\n", + "Use native pandas `Styler` when you need portable cell-level styles, and use structured ValidMind cells when you want to provide the same basic styles directly.\n", + "\n", + "Styled custom test tables are useful for compact review summaries, threshold checks, validation checklists, and other result tables where color improves scanability." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Discover more learning resources\n", + "\n", + "We offer many interactive notebooks to help you automate testing, documenting, validating, and more:\n", + "\n", + "- [Run tests & test suites](https://docs.validmind.ai/developer/how-to/testing-overview.html)\n", + "- [Use ValidMind Library features](https://docs.validmind.ai/developer/how-to/feature-overview.html)\n", + "- [Code samples by use case](https://docs.validmind.ai/guide/samples-jupyter-notebooks.html)\n", + "\n", + "Or, visit our [documentation](https://docs.validmind.ai/) to learn more about ValidMind." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Upgrade ValidMind\n", + "\n", + "
After installing ValidMind, you'll want to periodically make sure you are on the latest version to access any new features and other enhancements.
\n", + "\n", + "Retrieve the information for the currently installed version of ValidMind:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%pip show validmind" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If the version returned is lower than the version indicated in our [production open-source code](https://github.com/validmind/validmind-library/blob/prod/validmind/__version__.py), restart your notebook and run:\n", + "\n", + "```bash\n", + "%pip install --upgrade validmind\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You may need to restart your kernel after running the upgrade package for changes to be applied." + ] + }, + { + "cell_type": "markdown", + "id": "copyright-ed31d36e6d3f4888a16caa37556fe237", + "metadata": {}, + "source": [ + "\n", + "\n", + "\n", + "\n", + "***\n", + "\n", + "Copyright © 2023-2026 ValidMind Inc. All rights reserved.
\n", + "Refer to [LICENSE](https://github.com/validmind/validmind-library/blob/main/LICENSE) for details.
\n", + "SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial
" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/tests/test_results.py b/tests/test_results.py index 87b74c3c0..9bca5b782 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -20,6 +20,7 @@ from validmind.vm_models.figure import Figure from validmind.errors import InvalidParameterError +from validmind.tests.output import TableOutputHandler loop = asyncio.new_event_loop() @@ -53,7 +54,7 @@ def run_async(self, func, *args, **kwargs): def test_raw_data_initialization(self): """Test RawData initialization and methods""" - raw_data = RawData(log=True, dataset_duplicates=pd.DataFrame({'col1': [1, 2]})) + raw_data = RawData(log=True, dataset_duplicates=pd.DataFrame({"col1": [1, 2]})) self.assertTrue(raw_data.log) self.assertIsInstance(raw_data.dataset_duplicates, pd.DataFrame) @@ -108,6 +109,43 @@ def test_test_result_add_table(self): self.assertEqual(len(test_result.tables), 1) self.assertEqual(test_result.tables[0].title, "Test Table") + def test_table_output_handler_converts_pandas_styler(self): + """Test pandas Styler table outputs preserve portable cell styles.""" + test_result = TestResult(result_id="test_1") + df = pd.DataFrame( + { + "Check": ["Missing values", "Outlier rate"], + "Observed": [0.008, 0.027], + } + ) + styler = df.style.format({"Observed": "{:.1%}"}).map( + lambda value: ( + "background-color: #EAF4FF; color: #083E44; " + "font-weight: 600; text-align: center" + if isinstance(value, float) + else "" + ) + ) + + TableOutputHandler().process(styler, test_result) + + serialized_table = test_result.tables[0].serialize()["data"] + self.assertEqual(serialized_table[0]["Check"], "Missing values") + self.assertEqual( + serialized_table[0]["Observed"], + { + "value": "0.8%", + "bgcolor": "#EAF4FF", + "color": "#083E44", + "fontWeight": "600", + "textAlign": "center", + }, + ) + html = test_result.to_html() + self.assertIn("background-color: #EAF4FF", html) + self.assertIn("font-weight: 600", html) + self.assertNotIn("{'value': '0.8%'", html) + def test_test_result_add_figure(self): """Test adding figures to TestResult""" test_result = TestResult(result_id="test_1") @@ -229,7 +267,9 @@ async def test_text_generation_result_log_async(self, mock_log_text): ) @patch("validmind.vm_models.result.result.api_client.alog_text") - async def test_text_generation_result_log_async_with_section_id(self, mock_log_text): + async def test_text_generation_result_log_async_with_section_id( + self, mock_log_text + ): """Test async logging of TextGenerationResult forwards section_id""" text_result = TextGenerationResult( result_id="text_1", @@ -290,7 +330,9 @@ async def test_text_generation_result_log_async_uses_ai_revision_name( section_id=None, ) - async def test_text_generation_result_log_async_requires_section_id_for_new_block(self): + async def test_text_generation_result_log_async_requires_section_id_for_new_block( + self, + ): """Test new generated text requires a section_id for placement""" text_result = TextGenerationResult( result_id="text_1", @@ -462,26 +504,44 @@ def test_figure_interactive_toggle_plotly(self): try: html = figure.to_html() self.assertIsInstance(html, str) - self.assertIn("vm-plotly-data", html, "Default should include plotly data") + 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): + 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-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): + 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}") + 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) @@ -494,7 +554,9 @@ def test_figure_interactive_toggle_matplotlib_unaffected(self): # 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): + 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) diff --git a/validmind/tests/load.py b/validmind/tests/load.py index 9a9f13c53..f316ab99e 100644 --- a/validmind/tests/load.py +++ b/validmind/tests/load.py @@ -21,6 +21,7 @@ from uuid import uuid4 import pandas as pd +from pandas.io.formats.style import Styler from ..errors import LoadTestError, MissingDependencyError from ..html_templates.content_blocks import test_content_block_html @@ -49,7 +50,7 @@ FIGURE_TYPES = tuple( item for item in (Figure, MatplotlibFigure, PlotlyFigure) if inspect.isclass(item) ) -TABLE_TYPES = (pd.DataFrame, ResultTable) +TABLE_TYPES = (pd.DataFrame, Styler, ResultTable) GENERIC_TABLE_TYPES = (list, dict) @@ -450,7 +451,7 @@ def describe_test( html = test_content_block_html.format( test_id=test_id, uuid=str(uuid4()), - title=f'{details["Name"]}', + title=f"{details['Name']}", description=md_to_html(details["Description"].strip()), required_inputs=", ".join(details["Required Inputs"] or ["None"]), params_table="\n".join( diff --git a/validmind/tests/output.py b/validmind/tests/output.py index 648d3b98d..a73aef3c9 100644 --- a/validmind/tests/output.py +++ b/validmind/tests/output.py @@ -8,6 +8,7 @@ import numpy as np import pandas as pd +from pandas.io.formats.style import Styler from validmind.utils import is_html, md_to_html from validmind.vm_models.figure import ( @@ -78,7 +79,43 @@ def process(self, item: Any, result: TestResult) -> None: class TableOutputHandler(OutputHandler): def can_handle(self, item: Any) -> bool: - return isinstance(item, (list, pd.DataFrame, dict, ResultTable, tuple)) + return isinstance(item, (list, pd.DataFrame, Styler, dict, ResultTable, tuple)) + + def _convert_styler(self, styler: Styler) -> pd.DataFrame: + """Convert a pandas Styler to a DataFrame with portable cell styles.""" + styler._compute() + + df = styler.data.copy(deep=True).astype(object) + display_funcs = getattr(styler, "_display_funcs", {}) + + style_key_map = { + "background": "bgcolor", + "background-color": "bgcolor", + "color": "color", + "font-weight": "fontWeight", + "text-align": "textAlign", + } + + for (row_idx, col_idx), styles in styler.ctx.items(): + cell_styles = {} + + for css_property, css_value in styles: + style_key = style_key_map.get(css_property.lower()) + if style_key and css_value: + cell_styles[style_key] = css_value + + if not cell_styles: + continue + + value = df.iat[row_idx, col_idx] + formatter = display_funcs.get((row_idx, col_idx)) + + if formatter: + value = formatter(value) + + df.iat[row_idx, col_idx] = {"value": value, **cell_styles} + + return df def _convert_simple_type(self, data: Any) -> pd.DataFrame: """Convert a simple data type to a DataFrame.""" @@ -111,6 +148,8 @@ def _convert_to_dataframe(self, table_data: Any) -> pd.DataFrame: # Handle special cases by type if isinstance(table_data, pd.DataFrame): return table_data + elif isinstance(table_data, Styler): + return self._convert_styler(table_data) elif isinstance(table_data, (dict, str, type(None))): return self._convert_simple_type(table_data) elif isinstance(table_data, tuple): @@ -126,7 +165,13 @@ def _convert_to_dataframe(self, table_data: Any) -> pd.DataFrame: def process( self, item: Union[ - List[Dict[str, Any]], pd.DataFrame, Dict[str, Any], ResultTable, str, tuple + List[Dict[str, Any]], + pd.DataFrame, + Styler, + Dict[str, Any], + ResultTable, + str, + tuple, ], result: TestResult, ) -> None: diff --git a/validmind/vm_models/html_renderer.py b/validmind/vm_models/html_renderer.py index 8157c865d..61da05a88 100644 --- a/validmind/vm_models/html_renderer.py +++ b/validmind/vm_models/html_renderer.py @@ -6,6 +6,7 @@ HTML renderer for ValidMind components that preserves state in saved notebooks. """ +import html import json import uuid from typing import Any, Dict, List, Optional, Union @@ -19,6 +20,38 @@ class StatefulHTMLRenderer: # Plotly.js CDN URL - using a stable version PLOTLY_CDN_URL = "https://cdn.plot.ly/plotly-2.27.0.min.js" + @staticmethod + def _render_table_cell(value: Any) -> Any: + """Render structured table cells as HTML for notebook display.""" + if not isinstance(value, dict) or "value" not in value: + return value + + css_properties = { + "bgcolor": "background-color", + "backgroundColor": "background-color", + "color": "color", + "fontWeight": "font-weight", + "textAlign": "text-align", + } + styles = [] + + for key, css_property in css_properties.items(): + css_value = value.get(key) + if css_value is not None: + styles.append(f"{css_property}: {html.escape(str(css_value))}") + + styles.extend( + [ + "display: block", + "margin: -8px", + "padding: 8px", + "min-height: 22px", + ] + ) + + cell_value = html.escape(str(value["value"])) + return f'
{cell_value}
' + @staticmethod def _get_progress_css() -> str: """Get the CSS styles required for progress bars.""" @@ -150,11 +183,16 @@ def render_table( title_html = f"

{title}

" if title else "" + formatters = { + column: StatefulHTMLRenderer._render_table_cell for column in data.columns + } + # 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, + formatters=formatters, index=False, ) @@ -206,7 +244,7 @@ def render_accordion( return f"""
- {''.join(accordion_items)} + {"".join(accordion_items)}