From 50a282bcc55cd2ee83abcb8afe088744a76a9654 Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Mon, 4 Aug 2025 23:19:17 -0700 Subject: [PATCH 01/11] WIP --- html_rendering_demo.py | 82 +++++ notebooks/demo.ipynb | 359 ++++++++++++++++++++ validmind/template.py | 110 ++++++ validmind/utils.py | 9 +- validmind/vm_models/figure.py | 34 ++ validmind/vm_models/html_progress.py | 168 ++++++++++ validmind/vm_models/html_renderer.py | 390 ++++++++++++++++++++++ validmind/vm_models/result/result.py | 69 ++++ validmind/vm_models/result/utils.py | 37 ++ validmind/vm_models/test_suite/runner.py | 120 ++++++- validmind/vm_models/test_suite/summary.py | 83 +++++ 11 files changed, 1446 insertions(+), 15 deletions(-) create mode 100644 html_rendering_demo.py create mode 100644 notebooks/demo.ipynb create mode 100644 validmind/vm_models/html_progress.py create mode 100644 validmind/vm_models/html_renderer.py diff --git a/html_rendering_demo.py b/html_rendering_demo.py new file mode 100644 index 000000000..8b1939c26 --- /dev/null +++ b/html_rendering_demo.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +""" +Demo script showing the new HTML rendering capabilities for state-preserving notebooks. +""" + +import pandas as pd +import matplotlib.pyplot as plt +from validmind.vm_models.result.result import TestResult +from validmind.utils import display + +def demo_html_rendering(): + """Demonstrate HTML rendering with state preservation.""" + print("šŸŽÆ ValidMind HTML Rendering Demo") + print("=" * 50) + + # Create a sample test result + result = TestResult( + name="Model Performance Analysis", + result_id="model_performance_analysis", + description="## Analysis Summary\n\nThis analysis shows **excellent model performance** with high accuracy across all metrics. The model demonstrates strong predictive capabilities.", + metric=0.94, + passed=True + ) + + # Add performance metrics table + metrics_df = pd.DataFrame({ + 'Metric': ['Accuracy', 'Precision', 'Recall', 'F1-Score', 'AUC'], + 'Training': [0.94, 0.92, 0.91, 0.92, 0.96], + 'Validation': [0.93, 0.91, 0.90, 0.90, 0.95], + 'Test': [0.94, 0.92, 0.89, 0.90, 0.94] + }) + result.add_table(metrics_df, title="Model Performance Metrics") + + # Add feature importance table + features_df = pd.DataFrame({ + 'Feature': ['Income', 'Age', 'Credit_Score', 'Employment_Length', 'Debt_Ratio'], + 'Importance': [0.35, 0.22, 0.18, 0.15, 0.10], + 'P_Value': [0.001, 0.002, 0.005, 0.01, 0.03] + }) + result.add_table(features_df, title="Feature Importance Analysis") + + # Add a performance chart + fig, ax = plt.subplots(figsize=(8, 5)) + metrics = ['Accuracy', 'Precision', 'Recall', 'F1-Score'] + training_scores = [0.94, 0.92, 0.91, 0.92] + test_scores = [0.94, 0.92, 0.89, 0.90] + + x = range(len(metrics)) + width = 0.35 + + ax.bar([i - width/2 for i in x], training_scores, width, label='Training', alpha=0.8) + ax.bar([i + width/2 for i in x], test_scores, width, label='Test', alpha=0.8) + + ax.set_xlabel('Metrics') + ax.set_ylabel('Score') + ax.set_title('Model Performance: Training vs Test') + ax.set_xticks(x) + ax.set_xticklabels(metrics) + ax.legend() + ax.grid(True, alpha=0.3) + + result.add_figure(fig) + + # Display using the new HTML rendering + print("\nšŸ“Š Displaying result with HTML rendering (state-preserving):") + display(result) + + print("\n✨ Benefits of HTML Rendering:") + print("• āœ… State preserved when notebook is saved") + print("• āœ… Works across all Jupyter environments") + print("• āœ… No dependency on ipywidgets backend") + print("• āœ… Consistent rendering when shared") + print("• āœ… Interactive elements with pure HTML/CSS/JS") + + # Show the raw HTML length for reference + html_content = result.to_html() + print(f"\nšŸ“ Generated HTML size: {len(html_content):,} characters") + + print("\nšŸŽ‰ Demo complete! The result above will retain its state when you save this notebook.") + +if __name__ == "__main__": + demo_html_rendering() \ No newline at end of file diff --git a/notebooks/demo.ipynb b/notebooks/demo.ipynb new file mode 100644 index 000000000..3cec97b64 --- /dev/null +++ b/notebooks/demo.ipynb @@ -0,0 +1,359 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "dcd10d34", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from validmind.vm_models.result.result import TestResult\n", + "from validmind.utils import display" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "29af432b", + "metadata": {}, + "outputs": [], + "source": [ + "def demo_html_rendering():\n", + " \"\"\"Demonstrate HTML rendering with state preservation.\"\"\"\n", + " print(\"šŸŽÆ ValidMind HTML Rendering Demo\")\n", + " print(\"=\" * 50)\n", + "\n", + " # Create a sample test result\n", + " result = TestResult(\n", + " name=\"Model Performance Analysis\",\n", + " result_id=\"model_performance_analysis\",\n", + " description=\"## Analysis Summary\\n\\nThis analysis shows **excellent model performance** with high accuracy across all metrics. The model demonstrates strong predictive capabilities.\",\n", + " metric=0.94,\n", + " passed=True\n", + " )\n", + "\n", + " # Add performance metrics table\n", + " metrics_df = pd.DataFrame({\n", + " 'Metric': ['Accuracy', 'Precision', 'Recall', 'F1-Score', 'AUC'],\n", + " 'Training': [0.94, 0.92, 0.91, 0.92, 0.96],\n", + " 'Validation': [0.93, 0.91, 0.90, 0.90, 0.95],\n", + " 'Test': [0.94, 0.92, 0.89, 0.90, 0.94]\n", + " })\n", + " result.add_table(metrics_df, title=\"Model Performance Metrics\")\n", + "\n", + " # Add feature importance table\n", + " features_df = pd.DataFrame({\n", + " 'Feature': ['Income', 'Age', 'Credit_Score', 'Employment_Length', 'Debt_Ratio'],\n", + " 'Importance': [0.35, 0.22, 0.18, 0.15, 0.10],\n", + " 'P_Value': [0.001, 0.002, 0.005, 0.01, 0.03]\n", + " })\n", + " result.add_table(features_df, title=\"Feature Importance Analysis\")\n", + "\n", + " # Add a performance chart\n", + " fig, ax = plt.subplots(figsize=(8, 5))\n", + " metrics = ['Accuracy', 'Precision', 'Recall', 'F1-Score']\n", + " training_scores = [0.94, 0.92, 0.91, 0.92]\n", + " test_scores = [0.94, 0.92, 0.89, 0.90]\n", + "\n", + " x = range(len(metrics))\n", + " width = 0.35\n", + "\n", + " ax.bar([i - width/2 for i in x], training_scores, width, label='Training', alpha=0.8)\n", + " ax.bar([i + width/2 for i in x], test_scores, width, label='Test', alpha=0.8)\n", + "\n", + " ax.set_xlabel('Metrics')\n", + " ax.set_ylabel('Score')\n", + " ax.set_title('Model Performance: Training vs Test')\n", + " ax.set_xticks(x)\n", + " ax.set_xticklabels(metrics)\n", + " ax.legend()\n", + " ax.grid(True, alpha=0.3)\n", + "\n", + " result.add_figure(fig)\n", + "\n", + " # Display using the new HTML rendering\n", + " print(\"\\nšŸ“Š Displaying result with HTML rendering (state-preserving):\")\n", + " display(result)\n", + "\n", + " print(\"\\n✨ Benefits of HTML Rendering:\")\n", + " print(\"• āœ… State preserved when notebook is saved\")\n", + " print(\"• āœ… Works across all Jupyter environments\")\n", + " print(\"• āœ… No dependency on ipywidgets backend\")\n", + " print(\"• āœ… Consistent rendering when shared\")\n", + " print(\"• āœ… Interactive elements with pure HTML/CSS/JS\")\n", + "\n", + " # Show the raw HTML length for reference\n", + " html_content = result.to_html()\n", + " print(f\"\\nšŸ“ Generated HTML size: {len(html_content):,} characters\")\n", + "\n", + " print(\"\\nšŸŽ‰ Demo complete! The result above will retain its state when you save this notebook.\")\n", + " return html_content" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2e20be00", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "šŸŽÆ ValidMind HTML Rendering Demo\n", + "==================================================\n", + "\n", + "šŸ“Š Displaying result with HTML rendering (state-preserving):\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + " \n", + " \n", + "
\n", + "

āœ… Model Performance Analysis: 0.94

\n", + "
\n", + " \n", + "
\n", + " ## Analysis Summary\n", + "\n", + "This analysis shows **excellent model performance** with high accuracy across all metrics. The model demonstrates strong predictive capabilities.\n", + "
\n", + "

Tables

\n", + "
\n", + "

Model Performance Metrics

\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
MetricTrainingValidationTest
Accuracy0.940.930.94
Precision0.920.910.92
Recall0.910.900.89
F1-Score0.920.900.90
AUC0.960.950.94
\n", + "
\n", + " \n", + "
\n", + "

Feature Importance Analysis

\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
FeatureImportanceP_Value
Income0.350.001
Age0.220.002
Credit_Score0.180.005
Employment_Length0.150.010
Debt_Ratio0.100.030
\n", + "
\n", + "

Figures

\n", + "
\n", + " \"ValidMind\n", + " \n", + "
\n", + "
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "✨ Benefits of HTML Rendering:\n", + "• āœ… State preserved when notebook is saved\n", + "• āœ… Works across all Jupyter environments\n", + "• āœ… No dependency on ipywidgets backend\n", + "• āœ… Consistent rendering when shared\n", + "• āœ… Interactive elements with pure HTML/CSS/JS\n", + "\n", + "šŸ“ Generated HTML size: 48,174 characters\n", + "\n", + "šŸŽ‰ Demo complete! The result above will retain its state when you save this notebook.\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAssAAAHwCAYAAABQXSIoAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuNSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/xnp5ZAAAACXBIWXMAAA9hAAAPYQGoP6dpAACAIUlEQVR4nO3dd3hT1ePH8XfSQQejgMwyW0gLsn8KoiBDhsheorhYCoLgAAeK+gXBgSKgiCgiCF9QhDJF2bJkKVNFVoHSsqGldNCV3N8fNfm2NoGWli4/r+fp88Ad556bnCSfnJx7rskwDAMREREREcnAnNcVEBERERHJrxSWRURERERcUFgWEREREXFBYVlERERExAWFZRERERERFxSWRURERERcUFgWEREREXFBYVlERERExAWFZRERERERF9zzugIi+d2uXbt48sknHf//7LPPaNOmzQ33iYyMpFmzZlitVgA2bNhApUqVblsdf/75Z4YMGYK/vz8bN27MVlmffvop06ZNo3379nzyySc33T4iIoIHHnjA6TqTyYSnpyd+fn7ceeed9OzZ86aPXU7asWMHU6ZM4ejRo5jNZu6++25mzJiRa8eXWxcUFJTlfbp37877779/G2oDTzzxBLt37+bNN9/k8ccfz1ZZ9nNbuXIlFoslJ6pXYN3o/eNGnnvuOYYPH34bapRRaGgogYGBuXIsyZ8UlkWyaO3atTcNfGvXrnUE5X+TOnXq4Onp6fi/YRgkJSURERHBxo0b2bhxI3379uXtt9++7XU5ffo0Tz/9NMnJyZQqVYqKFSve1i8skrMaNWqUYVlkZCSnTp3C09OTOnXqZFhfrVq1XKiZ5KQiRYo4fa7PnTvHuXPnKFq0qNMvFBUqVLjtdbt06RITJkwgPDyckJCQ2348yb8UlkUyyd3dnZSUFDZt2kRycjIeHh4ut12zZk0u1iz/mDp1qtNAmpyczLRp05gxYwYLFiygefPmtG7d+rbWZf369SQnJ1OlShV++OEHihQpcluPJznr22+/zbBsyZIljB49mjJlyjhdfzt98MEHXL9+nTJlymS7rB9//BGAypUrZ7usgs7Vc2n/hat27drMmzcvD2oGW7du5aeffuLOO+/Mk+NL/qExyyKZVLRoUWrVqkV0dDS7du1yuV1UVBS7d++mVq1auVi7/M3Dw4MXX3yRhg0bArBgwYLbfszIyEgA6tatq6As2VaxYkUCAwMpXrx4tssKDAwkMDAw3a8wIpJ/KSyLZEG7du2A1GEWrqxfv56UlBQefPDB3KpWgdGqVSsAfv/999t+LPswGAUSERHJDoVlkSxo3749ABs3bsRmszndZvXq1ZhMJkewduXPP/9k5MiRNG/enDp16nDPPfcwZMgQtm/f7nKf06dPM3r0aFq2bEn9+vXp3r07K1asuGm9169fz8CBA2nSpAl169alTZs2jB8/nosXL95035xUtGhRAOLi4jKsCw8P56233qJ169bUqVOHJk2aMHjwYHbs2JFh24iICIKCgujUqRPHjx+nT58+1K1bl2bNmvHf//6XoKAgvv76awCWLl1KUFBQhgvGTp06le54jRs3pn///vz0009ZPh6kXrR11113YRgGCxYsoEuXLtSvX5/77ruP1157jStXrgBw6NAhhgwZwt133029evXo06cPmzdvdvp4Xb58mcmTJ9OjRw/uvvtu6tSpQ9OmTRk4cKDToT6ffvopQUFBzJo1i4iICF555RWaNWtGnTp1aNOmDR9++CExMTFOj3XlyhWmTJlCx44dadCgAY0aNeKxxx5zDBn4p9jYWKZNm0bnzp2pX78+jRo14pFHHuH77793Ol5/165djuchIiLCaZk5wX6cwYMH89tvv9G5c2fq1KlDq1at0j1mhw8fZsyYMbRv356GDRtSt25dWrZsyciRI/nzzz8zlPvEE08QFBTkeL4hdVhIUFAQ48aNIzIyknHjxtGyZUvq1KlDixYtePvtt52+xuyPw9GjRx3LXnvtNYKCgli9ejWHDx9m+PDh3HPPPdStW5eHHnqIGTNmkJSU5PSc//jjD0aMGEHz5s2pV68e3bt3JyQkxNFuMzPkKSQkhKCgIDp37uxym08++YSgoCBeeOEFx7IrV67w3nvv0b59e+rUqUOjRo3o2rUrU6ZM4erVqzc9bnYkJSUxZ84cevbsScOGDWnQoAHdu3dn1qxZJCYmOt1n165dDB06lKZNm3LnnXc6Xk//bOetW7dm9OjRQOp7dWYfRymcNGZZJAsCAwOpUaMGx48fZ+/evdx1113p1tuHaDRs2JBy5cq5LGf+/PlMmDABq9VKiRIlCA4O5vz58/z888/8/PPPDBw4kFdeeSXdPnv37mXw4MFcu3aNokWLUqNGDSIiInj55Zcz1MPO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2rZtyz333APAgQMHHMvXrFnDyZMn8ff3Z8qUKZQqVcqx7qGHHuKRRx4BYPny5UBquz906BDVq1dnypQp3HHHHY7ta9SowZQpUzCbzXz77beOoScA3t7ejgvbbjSTTE567rnn8PX1BXCc1+7duwFo0KABAwYMSPfrQeXKlRkwYACQOrduVkycODHdrAkNGzakY8eOQPrH+2b8/PyYOnVqusf1ySefdDyX+/fvdyyfNWsWCQkJtG3blueff97xXuLj48OECROy/IuRvcfY2VAke69yx44dHcexDyPp3Lmzo01D6uM4atQoWrduna5nN6f8/vvv/Pzzz5QsWZLp06ena+cVKlTgk08+wcfHhw0bNnD48GEgtRf88uXLlChRgg4dOqQrr1u3bjz88MN07NiR2NjYHK+vFHwKyyJZFBwcTNWqVTlz5gyHDh1yLI+JiWH79u00aNCA8uXLO903Li6O3377DYC+ffs63aZPnz54enpy8eJFx9jJrVu3AvDggw86DRq9evXKsGzv3r1cvXqVO+64wxGS/umhhx4CYMuWLa5ON0seeOABx3jMoKAggoODufvuux0/l9o/3NIOw7DfRMUeLP6pY8eOmEwm/vrrLy5dupRhvT2IZpb9XF09/m3atKFixYokJCSwc+fOLB/v/vvvz7DM398fgCZNmqQb/gA4QlHacdwVK1Zk165d7Nq1y+nP915eXo5/JyQkZFgfFBTkdBhQ9erVAdIFgk2bNgGpj3Pacu2ee+451qxZw1tvvQWk3mAHUh8nZ7OMWCwWLBYLycnJTh+/3OTsuXrooYfYv38/c+fOdbqP/cvT9evXM30cPz8/6tevn2G5s8f7Zho3buz0eXBW1s8//wzAww8/nGF7s9ns+KKTWffddx933HEHp06d4o8//nAst9lsjjG99kANOELqRx99xObNm9ONE27Xrh2ff/65y9dZdtjbYNOmTdN9ubMrXbq0o3PA/novWbIkxYoVIzo6mtdff51jx46l22fcuHF89NFHjqE6ImlpzLLILWjXrh0zZ85k7dq11K5dG0h9A09OTr7hLBjh4eGkpKQ4xoU64+3tTUBAAIcPH+bUqVPUq1ePU6dOATgdRwjO73Z2/PhxAOLj4x29l/8UHR0NwMmTJ13WOSv+eVMSs9mMj48P5cqVo2HDhnTo0CFdD1RsbCznzp0DYPLkyRnG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s9lM+/btmTNnDq+//nqGfQICAli8eDFPPvkkpUqV4ujRoxQvXpw33niDl19+2elx3N3dmTJlCpMnT+a+++4jLi6OI0eOUKRIEdq2bcv8+fPp3bt31k88h3Xo0IFly5bRq1cv/Pz8OHLkCFFRUdStW5fXX3+dL7/8MkdvndyuXbt0xzt8+LDjRiWffvopU6ZMybUbZzhTvHhxFi9ezBNPPOG4LXZYWBhVq1blueeeY/ny5Y6ePmdfkrKqdu3aLF++nKeeeoqyZcty9OhR4uLiuO+++5g5cybDhg1Lt32NGjVYvnw5zz77LAEBAZw6dYrTp09TpUoVBgwYwNKlS6lYsWK263U7NGjQgKVLl9KuXTtKly7N8ePHuXz5Mg0bNmTChAl88803jpt55MRje7t17dqV//73v7Ro0QKr1crRo0epXLkyH330Ef379wdwOpTnZtq1a+cYx+3swj5InV/6u+++Y8CAAVSvXp3z589z7NgxihcvzsMPP8zKlStdzvGeXeXKlWPRokW8/PLL3HnnnZw5c8ZxsXWfPn1Yvnx5hqE2bdq04b///S/t2rXD19eXY8eOceHCBWrXrs2rr77Kd999l266werVqzN+/HiqVKnCpUuXCA8PT/drhPx7mAxnk3uKiIhIgbZgwQLGjh3Lvffe6xhOIyJZp55lERGRAui1116je/fujrtg/pP9zp/OLmYUkcxTWBYRESmAatasyaFDh5g0aZLjjnWQeoHdl19+ycaNG/H09KRnz555WEuRgk/DMERERAqg+Ph4HnnkEY4cOYKbmxtVq1bF29vbcYGdh4cHY8eOVVgWySaFZRERkQIqISGBkJAQVqxYQXh4ODExMZQpU4bGjRvzxBNPOOZqF5Fbp7AsIiIiIuKCxiyLiIiIiLigsCwiIiIi4oLu4HcbGIaBzabRLdllMoEGCcntovYlt5Pal9xuamPZYzabMJlMmdpWYfk2sNkMIiPj8roaBZrJBB4ebiQnW/VmIDlO7UtuJ7Uvud3UxrKvVClf3NwyF5Y1DENERERExAWFZRERERERFxSWRURERERcUFgWEREREXFBYVlERERExAWFZRERERERFxSWRURERERcUFgWEREREXFBYVlERERExAXdwU9ERERuyjAMrFYrhmHL66r866Xe6tpMSortX30HP5PJjJubW6ZvW32rFJZFRETEJZvN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fMGoc2YkJJvwKuKJe3I8RsK1vK5OjnD38sLL05MUU/5rX7rAT0RERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREX3PO6ApkVHR3NtGnTWL9+PZcuXaJkyZI0b96cYcOG4e/vn+XybDYbixYtYunSpRw7dozk5GQCAwPp3bs3jz76KCaT6TachYiIiIgUJAUiLEdHR/PII49w4sQJfH19sVgsREREEBISwrp165g3bx7BwcGZLi8xMZGhQ4eybds2zGYzAQEBxMfHc+jQIcaOHcuvv/7Kxx9/rMAsIiIi8i9XIIZhvPnmm5w4cYIWLVqwZcsWlixZwtatW+nRowfXrl3jpZdewmq1Zrq8Dz/8kG3btlGhQgWWLl3KqlWr+Pnnn5kxYwY+Pj78+OOPrFix4jaekYiIiIgUBPk+LIeGhrJ27Vp8fHyYOHEiRYsWBaBIkSKMHz+ewMBAQkNDWbduXabKCw8PZ8GCBbi7uzNz5sx0PdKtWrWif//+AISEhOT8yYiIiIhIgZLvw/KKFSswDIPWrVvj5+eXbp2bmxs9evQA4Mcff8xUeT/88ANWq5UuXbpQs2bNDOt79OjBiy++SM+ePbNddxEREREp2PL9mOWDBw8C0LBhQ6frGzRoAMCePXsyVd6OHTsAeOCBB5yur1SpEkOGDMliLUVERESkMMr3YTksLAxIDbHOVKxYEYDLly8TFxeHr6/vDcs7duwYAAEBAcTExBASEsJvv/1GfHw8gYGB9OnThxo1auTgGYiIiIhIQZXvw3JUVBRAhiEYdiVKlEi37Y3CcmJiIpGRkQCcP3+efv36ceHCBcf6X375hQULFvD222/z8MMP50DtRURERKQgy/dhOSEhAQAvLy+n69MuT0xMvGFZcXFxjn+/9NJLFC9enJkzZ9KkSROioqKYPXs2c+bM4e2336Zy5co0bdr0luudW7POmUxgAKa//yT/M5lyr33kBLWxgkXtS263gtTG1L4KnvzYvvJ9WHZzc8Nms7lcf6N1/5Q2TF+/fp1FixZRuXJlAMqXL8/o0aO5cuUKK1euZPLkybcclk0m8PBwu6V9s8psNpFimCAfNq5s+ftcUl80hefETCZwdzdjNheccyqUbUztK99Q+ypYClobK5TtCwptG8vN9pWVhy3fh2Vvb2+Sk5Nd9honJSU5/u2q99muSJEijn937drVEZTTGjJkCCtXruTAgQNcuXKF0qVLZ7nOhgHJyZmf9zk73NzMGG5mMFKPW2j8fS6GAUYhOjHDgJQUG1Zr5r/k5bVC2cbUvvINta+CpaC1sULZvqDQtrHcbF9ZedjyfVj28/Pj2rVrXL161en6tMtLlSp1w7KKFi2KyWTCMAyCgoKcblOtWjXc3d1JSUnhzJkztxSWIfdelPbjGDheO5LPGQXsTVttrGBR+5LbrSC1MbWvgic/tq98P89yQEAAAGfOnHG6/uzZswCUKVMGb2/vG5bl6enpclYNO5PJ5PhJw90933+XEBEREZHbKN+H5Tp16gBw4MABp+v3798PQP369TNVXr169QD4448/nK4/e/YsycnJmM1m/P39s1hbERERESlM8n1Ybtu2LQDr16/PMBTDarWydOlSALp06ZKp8h566CEAVq9enW7aOLv58+cDcPfdd6eblk5ERERE/n3yfVgODg6mZcuWxMbGMmLECMe8y4mJiYwZM4bQ0FCqV6/uCNV2kZGRhIaGcvr06XTLW7duTcOGDYmPj2fw4MHp1v/444/897//BeDZZ5+9zWcmIiIiIvldgRiUO3bsWPr27cuuXbto1aoVAQEBREREEB0dTbFixfjss88wm9Pn/vnz5zNt2jT8/f3ZuHGjY7nZbGbq1Kk89dRT/PXXXzz44IMEBgYSHx9PREQEAM8//3y25lgWERERkcIh3/csQ+ocyCEhITzxxBOUKlWKo0eP4ubmRqdOnVi8eDGBgYFZKq9cuXIsXbqUESNGEBAQwOnTp4mLi6NZs2Z89dVXDB069DadiYiIiIgUJAWiZxmgZMmSjBkzhjFjxmRq++HDhzN8+HCX6729vRk2bBjDhg3LqSqKiIiISCFTIHqWRURERETygsKyiIiIiIgLCssiIiIiIi4oLIuIiIiIuJBjF/hFRkayc+dOTp48SWxsLK+++iqJiYns27ePe+65J6cOIyIiIiKSa7IdlpOTk/noo4/49ttvSU5Odix/9dVXOX36NP3796dWrVp8/vnnlCtXLruHExERERHJNdkahmGz2Rg2bBhz584lJSWFoKCgdLeIjouLw2w2c+jQIR599FHH3fdERERERAqCbIXlkJAQtmzZQkBAACtWrGDZsmUEBAQ41jdo0IDVq1dTs2ZNzp07x6xZs7JdYRERERGR3JLtsGwymfjkk0+oUaOG020qV67Mp59+itlsTnfbaRERERGR/C5bYfnYsWMEBATc9HbT1apVo1q1akRERGTncCIiIiIiuSpbYdlqtWI2Z64IDw8P3NzcsnM4EREREZFcla2wXLlyZU6ePElkZOQNt7t8+TLHjx+ncuXK2TmciIiIiEiuylZYbt++PSkpKbz11lvppo1LKykpiTfeeAOr1UqbNm2yczgRERERkVyVrXmW+/fvz4oVK9iwYQPdunXjgQce4NKlSwCsW7eO0NBQli5dSlhYGBUqVKBfv345UWcRERERkVyRrbDs6+vL119/zfDhw/nrr784ceKEY92IESMAMAyDqlWrMn36dIoXL5692oqIiIiI5KJs38GvUqVKhISEsG7dOjZs2MDx48eJi4vD29ubqlWr0rJlSzp27Iinp2dO1FdEREREJNdkKyyvW7eO4OBgKleuTPv27Wnfvn1O1UtEREREJM9l6wK/9957j06dOnH16tUcqo6IiIiISP6RrbB86dIlAgIC8PPzy6HqiIiIiIjkH9kKy9WqVePs2bMkJCTkVH1ERERERPKNbIXl//znP6SkpDBw4EB27NhBfHx8TtVLRERERCTPZesCv5kzZ1KxYkX27t3LgAEDAPD29qZIkSJOtzeZTGzfvj07hxQRERERyTXZCsubNm3KsCw+Pt5lD7PJZMrO4UREREREclW2wvLcuXNzqh4iIiIiIvlOtsJy48aNc6oeIiIiIiL5Trbv4GdnGAZ//vknp06dIi4uDh8fH6pWrcqdd96Jm5tbTh1GRERERCTX5EhYDgkJ4ZNPPuHixYsZ1vn5+fH888/zyCOP5MShRERERERyTbbD8kcffcSsWbMwDANPT08CAgLw8fEhJiaGkydPEhUVxdixYwkLC+PVV1/NiTqLiIiIiOSKbIXlHTt28NVXX+Hp6cnIkSPp06cPXl5ejvXXr19n4cKFfPzxx8yZM4dWrVppnLOIiIiIFBjZuinJ3LlzMZlMvPPOOzz11FPpgjKkzrncr18/xo0bh2EYLFiwIFuVFRERERHJTdkKy/v376dMmTJ07dr1htt169aNMmXKsH///uwcTkREREQkV2UrLMfExFC+fPlMbVuhQgWuXLmSncOJiIiIiOSqbIXlUqVKERYWhs1mu+F2VquVsLAwSpYsmZ3DiYiIiIjkqmyF5bvvvptr164xa9asG243a9YsoqOjufvuu7NzOBERERGRXJWt2TAGDhzI6tWrmTx5MufOnePRRx+lZs2ajvVHjx7l22+/ZeHChbi5udG/f/9sV1hEREREJLdkKyzXrl2b119/nfHjx/Ptt9/y7bff4u7ujo+PD/Hx8aSkpABgMpl4/fXXqVOnTo5UWkREREQkN2RrGAbAY489xpw5c2jcuDFubm4kJycTHR1NcnIyZrOZJk2aMGfOHB577LGcqK+IiIiISK7JkdtdN2nShCZNmhAfH094eDhxcXH4+PhQpUoVfHx8cuIQIiIiIiK5LkfCckJCAhs3buShhx4iKCjIsXzhwoUkJSXRtWtXihcvnhOHEhERERHJNdkehrF9+3ZatGjByJEjuXDhQrp1P/30E++++y4PPvggO3bsyO6hRERERERyVbbC8sGDB3nmmWeIjo6mZs2aJCcnp1v/0EMPUb9+fSIjIxk6dCgnTpzIVmVFRERERHJTtsLyzJkzSUlJoX///qxYsYJKlSqlW//www/z3XffMWjQIK5fv84XX3yRrcqKiIiIiOSmbIXlPXv2UKpUKUaNGnXD7V544QVKlCjB9u3bs3M4EREREZFcla2wHBMTQ8WKFXFzc7vhdu7u7lSuXJmrV69m53AiIiIiIrkqW2G5bNmyhIeHY7Vab7idzWbjzJkz+Pn5ZedwIiIiIiK5KlthuUmTJly7do3PP//8htvNnj2bqKgoGjdunJ3DiYiIiIjkqmzNs9yvXz9++OEHPvvsM06ePEmPHj2oWbMmPj4+XL9+nePHj7N8+XJWrFiBu7s7gwYNyql6i4iIiIjcdtkKyxaLhXHjxvHWW2+xatUqfvzxxwzbGIaBu7s777zzDrVq1crO4UREREREclW2b0rSrVs3li9fTu/evSlTpgyGYTj+/Pz86Ny5M4sXL6Z79+45UV8RERERkVyTI7e7rl69Ou+88w4ASUlJREVF4e3trVtci4iIiEiBdsth+dy5c5QvXx6TyZRueVhYGIsXLyYsLIySJUty33330aFDh5tOLyciIiIikt9kOSzPnz+fGTNmcOXKFTZs2ECFChUc6xYuXMi4ceOw2WwYhgHAsmXLmD17NjNmzKBMmTI5V3MRERERkdssS2F54sSJzJ492xGEo6OjHWH50KFDjB07FpvNhre3N7179+aOO+5g7dq1/PHHHzz33HN89913GXqiRURERETyq0yH5T///JPZs2djMpkYOnQovXv3pnz58o71H330ETabDZPJxIwZM2jSpAkAgwYN4tlnn2XLli388MMPdO7cOefPQkRERETkNsj0bBghISEAvPTSSwwfPjxdUL506RI7d+7EZDJx//33O4IygNls5pVXXsEwDFatWpWDVRcRERERub0yHZZ37dpFkSJFePLJJzOs27ZtGzabDYAHH3www/rAwEAqVKjA4cOHs1FVEREREZHclemwfPHiRSpUqICnp2eGdbt27XL8+95773W6/x133EFkZOQtVFFEREREJG9kOiwnJSW5nDd59+7dAFSpUoVy5co53SY2NhZvb+9bqKKIiIiISN7IdFguXbo0Fy9ezLD85MmTnD17FpPJ5LJXOTY2lvDwcEqXLn3rNRURERERyWWZDssNGjTg/PnzGcYdr1y50vHvBx54wOm+y5YtIyUlhYYNG95iNVOnqZswYQKtWrWiTp06NG/enNdff50zZ87ccplp7d27l1q1atG6descKU9ERERECr5Mh+WuXbtiGAYjR47k2LFjAOzcuZNvvvkGk8lExYoVnfYs//HHH0ydOhWTyUS7du1uqZLR0dE88sgjzJ07l+joaCwWC4mJiYSEhNCtW7dsXziYmJjIG2+84bhIUUREREQEsjDPcosWLXjwwQdZvXo1Xbp0wdPTk6SkJAzDwGw2884772A2/y97r1mzhs2bN7Nq1SoSExO57777aNGixS1V8s033+TEiRO0aNGCjz/+mKJFi5KYmMh//vMflixZwksvvcTKlStv+Zba06ZN48SJE7e0r4iIiIgUXpnuWQaYNGkSgwYNwsvLi8TERAzDoFy5cnzyyScZepU//PBDli5dSmJiIg0aNGDy5Mm3VMHQ0FDWrl2Lj48PEydOpGjRogAUKVKE8ePHExgYSGhoKOvWrbul8v/880++/vprvLy8bml/ERERESm8shSW3dzcGDVqFNu3b2fp0qWsXLmSjRs30qZNmwzb1q9fnxYtWjBx4kTmz5/vciaNm1mxYgWGYdC6dWv8/Pwy1KdHjx4A/Pjjj1kuOzk5mdGjRzvuSigiIiIiklamh2Gk5e3tTa1atW64zaRJk26pQv908OBBAJcXBzZo0ACAPXv2ZLnsL774giNHjvDss89isVhuuY4iIiIiUjhlqWc5L4SFhQFQqVIlp+srVqwIwOXLl4mLi8t0uUePHmXGjBkEBASoV1lEREREnMr3YTkqKgogwxAMuxIlSmTY9masViuvv/46KSkpjB8/3uldCUVEREREbmkYRm5KSEgAcHkBXtrliYmJmSpz9uzZ/P777zz22GP83//9X/Yr6YTJdFuKdXocAzD9/Sf5n8mUe+0jJ6iNFSxqX3K7FaQ2pvZV8OTH9pXvw7Kbm9sN5z/O6tzIp06d4tNPP6VChQq89NJL2a2eUyYTeHjc2jR2WWU2m0gxTJAPG1e2/H0uqS+awnNiJhO4u5sxmwvOORXKNqb2lW+ofRUsBa2NFcr2BYW2jeVm+8rKw5bvw7K3tzfJyckue42TkpIc/77Z9G+GYfD666+TkJDA2LFjHdPQ5TTDgORk620p+5/c3MwYbmYwUo9baPx9LoaR+rwVFoYBKSk2rNaCcwOcQtnG1L7yDbWvgqWgtbFC2b6g0Lax3GxfWXnY8n1Y9vPz49q1a1y9etXp+rTLS5UqdcOy5s+fz549e+jUqdMt3yAls3Kr7dqPY+B47Ug+ZxSwN221sYJF7Utut4LUxtS+Cp782L7yfVgOCAjg9OnTnDlzxun6s2fPAlCmTBm8vb1vWNaaNWsA+OGHH/jhhx+cbnPmzBmCgoIA2LBhg8tZOERERESk8Mv3YblOnTps2rSJAwcO0Ldv3wzr9+/fD6TeBOVmLBYLKSkpTtddu3aN48eP4+npSZ06dYDUuwSKiIiIyL9Xvg/Lbdu2Zdq0aaxfv56rV6+mm0LOarWydOlSALp06XLTst58802X637++WeGDBlCmTJl+Pbbb7NdbxEREREp+PL9PMvBwcG0bNmS2NhYRowY4ZhLOTExkTFjxhAaGkr16tVp27Ztuv0iIyMJDQ3l9OnTeVFtERERESkE8n3PMsDYsWPp27cvu3btolWrVgQEBBAREUF0dDTFihXjs88+w2xOn/vnz5/PtGnT8Pf3Z+PGjXlUcxEREREpyPJ9zzJA+fLlCQkJ4YknnqBUqVIcPXoUNzc3OnXqxOLFiwkMDMzrKoqIiIhIIVQgepYBSpYsyZgxYxgzZkymth8+fDjDhw/PdPmtWrXiyJEjt1o9ERERESmECkTPsoiIiIhIXlBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQF97yuQGZFR0czbdo01q9fz6VLlyhZsiTNmzdn2LBh+Pv7Z7m80NBQvvrqK3bt2sXFixfx8vIiODiYXr160a1bt5w/AREREREpcApEWI6OjuaRRx7hxIkT+Pr6YrFYiIiIICQk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bt3KuHHjAOjduzfFihVzrLOPLz579my26920aVPq169PTEwMw4cPTzfrxu7du5k0aRIA/fv3B6Bz584AfP7552zbti1dWT/99BN79uzBbDbTrFmzbNdNRORmNGZZRKSAMJlMtG3blgULFnD9+vUbDsGwq1GjBh988AGvvPIKM2bMYN68eVSvXp2oqCjOnDkDwL333suoUaPS7RcUFMTPP//MN998w44dO+jQoQODBw++5XpPnjyZfv36sWvXLlq1akXNmjWJiYkhPDwcgF69etGtWzcAunbtysaNG1mzZg0DBw6kfPny3HHHHVy8eJGLFy8C8NJLLzl6mEVEbieFZRGRAqRdu3YsWLAA4KZDMOw6dOiAxWLh66+/ZseOHRw5cgQPDw/q1q1L586d6du3Lx4eHun2eeaZZ7hw4QIbN27kxIkTjhuF3Cp/f3+WLFnC7NmzWbNmjeO2140aNaJv376O3mRIDdeTJk3irrvuYtWqVRw/fpzLly9TsmRJ2rZty2OPPUbTpk2zVR8RkcwyGTkxIE1EREREpBDSmGURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXPh/giBFjc55bxgAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "html = demo_html_rendering()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "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.9.20" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/validmind/template.py b/validmind/template.py index 10517f877..6bf68a710 100644 --- a/validmind/template.py +++ b/validmind/template.py @@ -14,6 +14,7 @@ from .tests import LoadTestError, describe_test from .utils import display, is_notebook from .vm_models import TestSuite +from .vm_models.html_renderer import StatefulHTMLRenderer logger = get_logger(__name__) @@ -161,6 +162,115 @@ def preview_template(template: str) -> None: ) +def preview_template_html(template: str) -> str: + """Generate HTML preview of a template that preserves state. + + Args: + template (dict): The template to preview. + + Returns: + HTML string representation of the template + """ + section_tree = _convert_sections_to_section_tree(template["sections"]) + return _create_section_html(section_tree) + + +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 f""" +
+

{content_type} Block: '{content['content_id']}'

+

Content ID: {content['content_id']}

+

Content Type: {content_type}

+
+ """ + + try: + test_html = describe_test(test_id=content["content_id"], show=False) + return f""" +
+

{content_type} Block: '{content['content_id']}'

+
+ {test_html} +
+
+ """ + except LoadTestError: + return f""" +
+

āŒ Failed Test Block: '{content['content_id']}'

+

Test could not be loaded

+
+ """ + + +def _create_sub_section_html(sub_sections: List[Dict[str, Any]], section_number: str) -> str: + """Create HTML for sub-sections.""" + if not sub_sections: + return "

Empty Section

" + + accordion_items = [] + accordion_titles = [] + + for i, section in enumerate(sub_sections): + section_num = f"{section_number}.{i + 1}" + + if section["sections"]: + # Has sub-sections + content_html = _create_sub_section_html(section["sections"], section_num) + elif contents := section.get("contents", []): + # Has content blocks + content_parts = [_create_content_html(content) for content in contents] + content_html = "".join(content_parts) + else: + # Empty section + content_html = "

Empty Section

" + + accordion_items.append(content_html) + accordion_titles.append(f"{section_num}. {section['title']} ('{section['id']}')") + + return StatefulHTMLRenderer.render_accordion(accordion_items, accordion_titles) + + +def _create_section_html(tree: List[Dict[str, Any]]) -> str: + """Create HTML representation of the section tree.""" + html_parts = [StatefulHTMLRenderer.get_base_css()] + + accordion_items = [] + accordion_titles = [] + + for i, section in enumerate(tree): + section_content_parts = [] + + # Add sub-sections if they exist + if section.get("sections"): + sub_section_html = _create_sub_section_html(section["sections"], str(i + 1)) + section_content_parts.append(sub_section_html) + + # Add direct content blocks if they exist + if section.get("contents"): + content_parts = [_create_content_html(content) for content in section["contents"]] + section_content_parts.extend(content_parts) + + # Combine all content for this section + if section_content_parts: + section_html = "".join(section_content_parts) + else: + section_html = "

Empty Section

" + + accordion_items.append(section_html) + accordion_titles.append(f"{i + 1}. {section['title']} ('{section['id']}')") + + if accordion_items: + main_accordion = StatefulHTMLRenderer.render_accordion(accordion_items, accordion_titles) + html_parts.append(main_accordion) + + return f'
{"".join(html_parts)}
' + + def _get_section_tests(section: Dict[str, Any]) -> List[str]: """ Get all the tests in a section and its subsections. diff --git a/validmind/utils.py b/validmind/utils.py index 5d8306a05..ce9a5ee8a 100644 --- a/validmind/utils.py +++ b/validmind/utils.py @@ -534,7 +534,14 @@ 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): + # Check if the object has a to_html method and prefer that over to_widget + if hasattr(widget_or_html, 'to_html'): + html_content = widget_or_html.to_html() + ipy_display(HTML(html_content)) + # if html we can auto-detect if we actually need syntax highlighting or MathJax + 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 diff --git a/validmind/vm_models/figure.py b/validmind/vm_models/figure.py index 2c99a8816..f78f73421 100644 --- a/validmind/vm_models/figure.py +++ b/validmind/vm_models/figure.py @@ -19,6 +19,7 @@ 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: @@ -113,6 +114,39 @@ def to_widget(self): f"Figure type {type(self.figure)} not supported for plotting" ) + def to_html(self): + """ + 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 StatefulHTMLRenderer.render_figure(encoded, self.key, metadata) + + elif is_plotly_figure(self.figure): + png_file = self.figure.to_image(format="png") + encoded = base64.b64encode(png_file).decode("utf-8") + # Add plotly-specific metadata + 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 StatefulHTMLRenderer.render_figure(encoded, self.key, metadata) + + else: + raise UnsupportedFigureError( + f"Figure type {type(self.figure)} not supported for plotting" + ) + def serialize(self): """ Serializes the Figure to a dictionary so it can be sent to the API. diff --git a/validmind/vm_models/html_progress.py b/validmind/vm_models/html_progress.py new file mode 100644 index 000000000..b308fa294 --- /dev/null +++ b/validmind/vm_models/html_progress.py @@ -0,0 +1,168 @@ +# 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: + # Always use fallback method for reliability + 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: + pass # Silently fail if update doesn't work + + 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: + # Replace with final state HTML that preserves the completed state + 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: + # Display each child first + 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'
') + + # Create container + html_content = f""" +
+ {''.join(child_html_parts)} +
+ """ + display(HTML(html_content), display_id=self._display_id) + self._displayed = True \ No newline at end of file diff --git a/validmind/vm_models/html_renderer.py b/validmind/vm_models/html_renderer.py new file mode 100644 index 000000000..ed603dd0a --- /dev/null +++ b/validmind/vm_models/html_renderer.py @@ -0,0 +1,390 @@ +# 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.""" + + @staticmethod + def render_figure( + figure_data: str, + key: str, + metadata: Optional[Dict[str, Any]] = None + ) -> str: + """Render a figure as HTML with embedded data. + + Args: + figure_data: Base64-encoded image data + key: Unique key for the figure + metadata: Optional metadata to embed + + Returns: + HTML string with embedded figure and metadata + """ + metadata = metadata or {} + metadata_json = json.dumps(metadata, default=str) + + return f""" +
+ ValidMind Figure {key} + +
+ """ + + @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 + """ + # Replace h3 tags with strong tags for better nesting + 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 """ + + """ \ No newline at end of file diff --git a/validmind/vm_models/result/result.py b/validmind/vm_models/result/result.py index ecc763af4..1d3775f55 100644 --- a/validmind/vm_models/result/result.py +++ b/validmind/vm_models/result/result.py @@ -29,14 +29,17 @@ test_id_to_name, ) from ..figure import Figure, create_figure +from ..html_renderer import StatefulHTMLRenderer from ..input import VMInput from .utils import ( AI_REVISION_NAME, DEFAULT_REVISION_NAME, check_for_sensitive_data, figures_to_widgets, + figures_to_html, get_result_template, tables_to_widgets, + tables_to_html, update_metadata, ) @@ -164,6 +167,16 @@ def __repr__(self) -> str: 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 @@ -351,6 +364,42 @@ def to_widget(self): return VBox(widgets) + def to_html(self): + """Generate HTML that persists in saved notebooks.""" + if self.metric 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=self.metric + ) + + html_parts = [StatefulHTMLRenderer.get_base_css()] + + # Add result header + html_parts.append(StatefulHTMLRenderer.render_result_header( + test_name=self.test_name, + passed=self.passed, + metric=self.metric + )) + + # Add description + if self.description: + html_parts.append(StatefulHTMLRenderer.render_description(self.description)) + + # Add parameters + if self.params: + html_parts.append(StatefulHTMLRenderer.render_parameters(self.params)) + + # Add tables + if self.tables: + html_parts.append(tables_to_html(self.tables)) + + # Add figures + if self.figures: + html_parts.append(figures_to_html(self.figures)) + + return f'
{"".join(html_parts)}
' + @classmethod def _get_client_config(cls): """Get the client config, loading it if not cached.""" @@ -645,6 +694,26 @@ def to_widget(self): return VBox(widgets) + def to_html(self): + """Generate HTML that persists in saved notebooks.""" + html_parts = [StatefulHTMLRenderer.get_base_css()] + + # Add result header + html_parts.append(StatefulHTMLRenderer.render_result_header( + test_name=self.test_name, + passed=None + )) + + # Add description + if self.description: + html_parts.append(StatefulHTMLRenderer.render_description(self.description)) + + # Add parameters + if self.params: + html_parts.append(StatefulHTMLRenderer.render_parameters(self.params)) + + return f'
{"".join(html_parts)}
' + def serialize(self): """Serialize the result for the API.""" return { diff --git a/validmind/vm_models/result/utils.py b/validmind/vm_models/result/utils.py index a9563f90d..0383791dc 100644 --- a/validmind/vm_models/result/utils.py +++ b/validmind/vm_models/result/utils.py @@ -14,6 +14,7 @@ from ..dataset import VMDataset from ..figure import Figure from ..input import VMInput +from ..html_renderer import StatefulHTMLRenderer if TYPE_CHECKING: from .result import ResultTable @@ -127,6 +128,23 @@ def tables_to_widgets(tables: List["ResultTable"]): return widgets +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: + table_html = StatefulHTMLRenderer.render_table( + data=table.data, + title=table.title + ) + html_parts.append(table_html) + + return "".join(html_parts) + + def figures_to_widgets(figures: List[Figure]) -> list: """Convert a list of figures to ipywidgets.""" num_columns = 2 if len(figures) > 1 else 1 @@ -139,3 +157,22 @@ def figures_to_widgets(figures: List[Figure]) -> list: ) return [HTML("

Figures

"), plot_widgets] + + +def figures_to_html(figures: List[Figure]) -> str: + """Convert a list of figures to HTML.""" + if not figures: + return "" + + html_parts = ["

Figures

"] + + # 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 "".join(html_parts) diff --git a/validmind/vm_models/test_suite/runner.py b/validmind/vm_models/test_suite/runner.py index 145be09cd..244925d8b 100644 --- a/validmind/vm_models/test_suite/runner.py +++ b/validmind/vm_models/test_suite/runner.py @@ -9,6 +9,7 @@ from ...logging import get_logger from ...utils import is_notebook, run_async, run_async_check +from ..html_progress import HTMLProgressBar, HTMLLabel, HTMLBox from .summary import TestSuiteSummary from .test_suite import TestSuite @@ -28,6 +29,11 @@ class TestSuiteRunner: 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,13 +71,28 @@ 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() + # Use HTML progress bar instead of ipywidgets + 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]) + + # Display the HTML components + self.html_pbar.display() + + # Keep the old widgets as fallback for compatibility 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) - def _stop_progress_bar(self): + # Update HTML progress bar + 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!") + + # Keep old widget updates for compatibility self.pbar_description.value = "Test suite complete!" self.pbar.close() @@ -81,33 +102,76 @@ 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 = ( - f"Sending results of test suite '{self.suite.suite_id}' to ValidMind..." - ) + # Update HTML progress bar description + sending_message = f"Sending results of test suite '{self.suite.suite_id}' to ValidMind..." + if self.html_pbar: + self.html_pbar.update(self.html_pbar.value, sending_message) + if self.html_pbar_description: + self.html_pbar_description.update(sending_message) + + # Keep old widget updates for compatibility + self.pbar_description.value = 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}..." - ) + sending_test_message = f"Sending result to ValidMind: {test.test_id}..." + + # Update HTML progress bar + if self.html_pbar: + self.html_pbar.update(self.html_pbar.value, sending_test_message) + if self.html_pbar_description: + self.html_pbar_description.update(sending_test_message) + + # Keep old widget updates for compatibility + self.pbar_description.value = sending_test_message try: await test.log_async() except Exception as e: - self.pbar_description.value = "Failed to send result to ValidMind" + failure_message = "Failed to send result to ValidMind" + + # Update HTML progress bar + if self.html_pbar: + self.html_pbar.update(self.html_pbar.value, failure_message) + if self.html_pbar_description: + self.html_pbar_description.update(failure_message) + + # Keep old widget updates for compatibility + self.pbar_description.value = failure_message logger.error(f"Failed to log result: {test.result}") raise e + # Update HTML progress bar value + if self.html_pbar: + self.html_pbar.update(self.html_pbar.value + 1) + + # Keep old widget updates for compatibility self.pbar.value += 1 async def _check_progress(self): done = False while not done: - if self.pbar.value == self.pbar.max: - self.pbar_description.value = "Test suite complete!" + # Check HTML progress bar completion + progress_complete = False + if self.html_pbar and self.html_pbar.value >= self.html_pbar.max_value: + progress_complete = True + elif self.pbar and self.pbar.value == self.pbar.max: + progress_complete = True + + if progress_complete: + completion_message = "Test suite complete!" + + # Update HTML progress bar + 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) + + # Keep old widget updates for compatibility + self.pbar_description.value = completion_message done = True await asyncio.sleep(0.5) @@ -116,7 +180,16 @@ 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..." + + # Update HTML progress bar + 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) + + # Keep old widget updates for compatibility + self.pbar_description.value = collecting_message summary = TestSuiteSummary( title=self.suite.title, @@ -124,7 +197,10 @@ def summarize(self, show_link: bool = True): sections=self.suite.sections, show_link=show_link, ) - summary.display() + + # Use HTML rendering by default for better state preservation + 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,11 +215,27 @@ 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}" + + # Update HTML progress bar + 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) + + # Keep old widget updates for compatibility + self.pbar_description.value = running_message + test.run( fail_fast=fail_fast, config=self._test_configs.get(test.test_id, {}), ) + + # Update progress value + if self.html_pbar: + self.html_pbar.update(self.html_pbar.value + 1) + + # Keep old widget updates for compatibility self.pbar.value += 1 if send: diff --git a/validmind/vm_models/test_suite/summary.py b/validmind/vm_models/test_suite/summary.py index e3b53cab8..4bf8bb413 100644 --- a/validmind/vm_models/test_suite/summary.py +++ b/validmind/vm_models/test_suite/summary.py @@ -9,6 +9,7 @@ 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 @@ -78,6 +79,33 @@ def display(self): """Display the summary.""" display(self.summary) + def to_html(self): + """Generate HTML representation.""" + html_parts = [StatefulHTMLRenderer.get_base_css()] + + if self.description: + html_parts.append(f'
{md_to_html(self.description)}
') + + # Create accordion content + accordion_items = [] + accordion_titles = [] + + for test in self.tests: + if hasattr(test.result, 'to_html'): + accordion_items.append(test.result.to_html()) + else: + # Fallback to widget rendering wrapped in HTML + accordion_items.append(str(test.result.to_widget().value)) + + title_prefix = "āŒ " if isinstance(test.result, ErrorResult) else "" + accordion_titles.append(f"{title_prefix}{test.result.name}: {test.name} ({test.test_id})") + + if accordion_items: + accordion_html = StatefulHTMLRenderer.render_accordion(accordion_items, accordion_titles) + html_parts.append(accordion_html) + + return f'
{"".join(html_parts)}
' + @dataclass class TestSuiteSummary: @@ -185,3 +213,58 @@ def _build_summary(self): def display(self): """Display the summary.""" display(self.summary) + + def to_html(self): + """Generate HTML representation of the complete test suite summary.""" + html_parts = [StatefulHTMLRenderer.get_base_css()] + + # Add title + title_html = f""" +

Test Suite Results: {self.title}


+ """ + html_parts.append(title_html) + + # Add results link if needed + if self.show_link: + # 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_html = f""" +

+ Check out the updated documentation on + ValidMind. +

+ """ + html_parts.append(results_link_html) + + # Add description + html_parts.append(f'
{md_to_html(self.description)}
') + + # Add sections + if len(self.sections) == 1: + # Single section - render tests directly + section_summary = TestSuiteSectionSummary(tests=self.sections[0].tests) + html_parts.append(section_summary.to_html()) + else: + # Multiple sections - create accordion + section_items = [] + section_titles = [] + + for section in self.sections: + if not section.tests: + continue + + section_summary = TestSuiteSectionSummary( + description=section.description, + tests=section.tests, + ) + section_items.append(section_summary.to_html()) + section_titles.append(id_to_name(section.section_id)) + + if section_items: + sections_accordion = StatefulHTMLRenderer.render_accordion(section_items, section_titles) + html_parts.append(sections_accordion) + + return f'
{"".join(html_parts)}
' From 661ee2ade84cf2bf9379b94f62cb1e6b6ed269ae Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Wed, 10 Dec 2025 13:14:56 -0800 Subject: [PATCH 02/11] make lint --- validmind/vm_models/html_progress.py | 2 +- validmind/vm_models/test_suite/runner.py | 78 +++++++++++------------- 2 files changed, 36 insertions(+), 44 deletions(-) diff --git a/validmind/vm_models/html_progress.py b/validmind/vm_models/html_progress.py index a50c456c7..e0a9caa29 100644 --- a/validmind/vm_models/html_progress.py +++ b/validmind/vm_models/html_progress.py @@ -66,7 +66,7 @@ def _update_fallback(self): ) try: update_display(HTML(html_content), display_id=self._display_id) - except: + except Exception: pass # Silently fail if update doesn't work def complete(self): diff --git a/validmind/vm_models/test_suite/runner.py b/validmind/vm_models/test_suite/runner.py index a80db98ce..c6731ca9f 100644 --- a/validmind/vm_models/test_suite/runner.py +++ b/validmind/vm_models/test_suite/runner.py @@ -5,7 +5,6 @@ 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 @@ -98,61 +97,54 @@ def _stop_progress_bar(self): self.pbar_description.value = "Test suite complete!" self.pbar.close() + def _update_progress_message(self, message: str): + """Updates both HTML and widget progress bar messages.""" + if self.html_pbar: + self.html_pbar.update(self.html_pbar.value, message) + if self.html_pbar_description: + self.html_pbar_description.update(message) + + # Keep old widget updates for compatibility + self.pbar_description.value = message + + def _increment_progress(self): + """Increments both HTML and widget progress bars.""" + if self.html_pbar: + self.html_pbar.update(self.html_pbar.value + 1) + + # Keep old widget updates for compatibility + self.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. 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. """ - # Update HTML progress bar description sending_message = ( f"Sending results of test suite '{self.suite.suite_id}' to ValidMind..." ) - if self.html_pbar: - self.html_pbar.update(self.html_pbar.value, sending_message) - if self.html_pbar_description: - self.html_pbar_description.update(sending_message) - - # Keep old widget updates for compatibility - self.pbar_description.value = sending_message + 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: - sending_test_message = f"Sending result to ValidMind: {test.test_id}..." - - # Update HTML progress bar - if self.html_pbar: - self.html_pbar.update(self.html_pbar.value, sending_test_message) - if self.html_pbar_description: - self.html_pbar_description.update(sending_test_message) - - # Keep old widget updates for compatibility - self.pbar_description.value = sending_test_message - - try: - await test.log_async() - except Exception as e: - failure_message = "Failed to send result to ValidMind" - - # Update HTML progress bar - if self.html_pbar: - self.html_pbar.update(self.html_pbar.value, failure_message) - if self.html_pbar_description: - self.html_pbar_description.update(failure_message) - - # Keep old widget updates for compatibility - self.pbar_description.value = failure_message - logger.error(f"Failed to log result: {test.result}") - - raise e - - # Update HTML progress bar value - if self.html_pbar: - self.html_pbar.update(self.html_pbar.value + 1) - - # Keep old widget updates for compatibility - self.pbar.value += 1 + await self._log_test_result(test) async def _check_progress(self): done = False From 9c95f67a7d5a5a91ef6385d5de3dd08feec03234 Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Wed, 10 Dec 2025 13:22:53 -0800 Subject: [PATCH 03/11] Cleanup --- html_rendering_demo.py | 82 ----- notebooks/demo.ipynb | 359 ---------------------- validmind/utils.py | 3 - validmind/vm_models/html_progress.py | 6 +- validmind/vm_models/html_renderer.py | 1 - validmind/vm_models/result/result.py | 16 - validmind/vm_models/test_suite/runner.py | 18 -- validmind/vm_models/test_suite/summary.py | 8 - 8 files changed, 1 insertion(+), 492 deletions(-) delete mode 100644 html_rendering_demo.py delete mode 100644 notebooks/demo.ipynb diff --git a/html_rendering_demo.py b/html_rendering_demo.py deleted file mode 100644 index 8b1939c26..000000000 --- a/html_rendering_demo.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env python3 -""" -Demo script showing the new HTML rendering capabilities for state-preserving notebooks. -""" - -import pandas as pd -import matplotlib.pyplot as plt -from validmind.vm_models.result.result import TestResult -from validmind.utils import display - -def demo_html_rendering(): - """Demonstrate HTML rendering with state preservation.""" - print("šŸŽÆ ValidMind HTML Rendering Demo") - print("=" * 50) - - # Create a sample test result - result = TestResult( - name="Model Performance Analysis", - result_id="model_performance_analysis", - description="## Analysis Summary\n\nThis analysis shows **excellent model performance** with high accuracy across all metrics. The model demonstrates strong predictive capabilities.", - metric=0.94, - passed=True - ) - - # Add performance metrics table - metrics_df = pd.DataFrame({ - 'Metric': ['Accuracy', 'Precision', 'Recall', 'F1-Score', 'AUC'], - 'Training': [0.94, 0.92, 0.91, 0.92, 0.96], - 'Validation': [0.93, 0.91, 0.90, 0.90, 0.95], - 'Test': [0.94, 0.92, 0.89, 0.90, 0.94] - }) - result.add_table(metrics_df, title="Model Performance Metrics") - - # Add feature importance table - features_df = pd.DataFrame({ - 'Feature': ['Income', 'Age', 'Credit_Score', 'Employment_Length', 'Debt_Ratio'], - 'Importance': [0.35, 0.22, 0.18, 0.15, 0.10], - 'P_Value': [0.001, 0.002, 0.005, 0.01, 0.03] - }) - result.add_table(features_df, title="Feature Importance Analysis") - - # Add a performance chart - fig, ax = plt.subplots(figsize=(8, 5)) - metrics = ['Accuracy', 'Precision', 'Recall', 'F1-Score'] - training_scores = [0.94, 0.92, 0.91, 0.92] - test_scores = [0.94, 0.92, 0.89, 0.90] - - x = range(len(metrics)) - width = 0.35 - - ax.bar([i - width/2 for i in x], training_scores, width, label='Training', alpha=0.8) - ax.bar([i + width/2 for i in x], test_scores, width, label='Test', alpha=0.8) - - ax.set_xlabel('Metrics') - ax.set_ylabel('Score') - ax.set_title('Model Performance: Training vs Test') - ax.set_xticks(x) - ax.set_xticklabels(metrics) - ax.legend() - ax.grid(True, alpha=0.3) - - result.add_figure(fig) - - # Display using the new HTML rendering - print("\nšŸ“Š Displaying result with HTML rendering (state-preserving):") - display(result) - - print("\n✨ Benefits of HTML Rendering:") - print("• āœ… State preserved when notebook is saved") - print("• āœ… Works across all Jupyter environments") - print("• āœ… No dependency on ipywidgets backend") - print("• āœ… Consistent rendering when shared") - print("• āœ… Interactive elements with pure HTML/CSS/JS") - - # Show the raw HTML length for reference - html_content = result.to_html() - print(f"\nšŸ“ Generated HTML size: {len(html_content):,} characters") - - print("\nšŸŽ‰ Demo complete! The result above will retain its state when you save this notebook.") - -if __name__ == "__main__": - demo_html_rendering() \ No newline at end of file diff --git a/notebooks/demo.ipynb b/notebooks/demo.ipynb deleted file mode 100644 index 3cec97b64..000000000 --- a/notebooks/demo.ipynb +++ /dev/null @@ -1,359 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "dcd10d34", - "metadata": {}, - "outputs": [], - "source": [ - "import pandas as pd\n", - "import matplotlib.pyplot as plt\n", - "from validmind.vm_models.result.result import TestResult\n", - "from validmind.utils import display" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "29af432b", - "metadata": {}, - "outputs": [], - "source": [ - "def demo_html_rendering():\n", - " \"\"\"Demonstrate HTML rendering with state preservation.\"\"\"\n", - " print(\"šŸŽÆ ValidMind HTML Rendering Demo\")\n", - " print(\"=\" * 50)\n", - "\n", - " # Create a sample test result\n", - " result = TestResult(\n", - " name=\"Model Performance Analysis\",\n", - " result_id=\"model_performance_analysis\",\n", - " description=\"## Analysis Summary\\n\\nThis analysis shows **excellent model performance** with high accuracy across all metrics. The model demonstrates strong predictive capabilities.\",\n", - " metric=0.94,\n", - " passed=True\n", - " )\n", - "\n", - " # Add performance metrics table\n", - " metrics_df = pd.DataFrame({\n", - " 'Metric': ['Accuracy', 'Precision', 'Recall', 'F1-Score', 'AUC'],\n", - " 'Training': [0.94, 0.92, 0.91, 0.92, 0.96],\n", - " 'Validation': [0.93, 0.91, 0.90, 0.90, 0.95],\n", - " 'Test': [0.94, 0.92, 0.89, 0.90, 0.94]\n", - " })\n", - " result.add_table(metrics_df, title=\"Model Performance Metrics\")\n", - "\n", - " # Add feature importance table\n", - " features_df = pd.DataFrame({\n", - " 'Feature': ['Income', 'Age', 'Credit_Score', 'Employment_Length', 'Debt_Ratio'],\n", - " 'Importance': [0.35, 0.22, 0.18, 0.15, 0.10],\n", - " 'P_Value': [0.001, 0.002, 0.005, 0.01, 0.03]\n", - " })\n", - " result.add_table(features_df, title=\"Feature Importance Analysis\")\n", - "\n", - " # Add a performance chart\n", - " fig, ax = plt.subplots(figsize=(8, 5))\n", - " metrics = ['Accuracy', 'Precision', 'Recall', 'F1-Score']\n", - " training_scores = [0.94, 0.92, 0.91, 0.92]\n", - " test_scores = [0.94, 0.92, 0.89, 0.90]\n", - "\n", - " x = range(len(metrics))\n", - " width = 0.35\n", - "\n", - " ax.bar([i - width/2 for i in x], training_scores, width, label='Training', alpha=0.8)\n", - " ax.bar([i + width/2 for i in x], test_scores, width, label='Test', alpha=0.8)\n", - "\n", - " ax.set_xlabel('Metrics')\n", - " ax.set_ylabel('Score')\n", - " ax.set_title('Model Performance: Training vs Test')\n", - " ax.set_xticks(x)\n", - " ax.set_xticklabels(metrics)\n", - " ax.legend()\n", - " ax.grid(True, alpha=0.3)\n", - "\n", - " result.add_figure(fig)\n", - "\n", - " # Display using the new HTML rendering\n", - " print(\"\\nšŸ“Š Displaying result with HTML rendering (state-preserving):\")\n", - " display(result)\n", - "\n", - " print(\"\\n✨ Benefits of HTML Rendering:\")\n", - " print(\"• āœ… State preserved when notebook is saved\")\n", - " print(\"• āœ… Works across all Jupyter environments\")\n", - " print(\"• āœ… No dependency on ipywidgets backend\")\n", - " print(\"• āœ… Consistent rendering when shared\")\n", - " print(\"• āœ… Interactive elements with pure HTML/CSS/JS\")\n", - "\n", - " # Show the raw HTML length for reference\n", - " html_content = result.to_html()\n", - " print(f\"\\nšŸ“ Generated HTML size: {len(html_content):,} characters\")\n", - "\n", - " print(\"\\nšŸŽ‰ Demo complete! The result above will retain its state when you save this notebook.\")\n", - " return html_content" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "2e20be00", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "šŸŽÆ ValidMind HTML Rendering Demo\n", - "==================================================\n", - "\n", - "šŸ“Š Displaying result with HTML rendering (state-preserving):\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - " \n", - " \n", - "
\n", - "

āœ… Model Performance Analysis: 0.94

\n", - "
\n", - " \n", - "
\n", - " ## Analysis Summary\n", - "\n", - "This analysis shows **excellent model performance** with high accuracy across all metrics. The model demonstrates strong predictive capabilities.\n", - "
\n", - "

Tables

\n", - "
\n", - "

Model Performance Metrics

\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
MetricTrainingValidationTest
Accuracy0.940.930.94
Precision0.920.910.92
Recall0.910.900.89
F1-Score0.920.900.90
AUC0.960.950.94
\n", - "
\n", - " \n", - "
\n", - "

Feature Importance Analysis

\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
FeatureImportanceP_Value
Income0.350.001
Age0.220.002
Credit_Score0.180.005
Employment_Length0.150.010
Debt_Ratio0.100.030
\n", - "
\n", - "

Figures

\n", - "
\n", - " \"ValidMind\n", - " \n", - "
\n", - "
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "✨ Benefits of HTML Rendering:\n", - "• āœ… State preserved when notebook is saved\n", - "• āœ… Works across all Jupyter environments\n", - "• āœ… No dependency on ipywidgets backend\n", - "• āœ… Consistent rendering when shared\n", - "• āœ… Interactive elements with pure HTML/CSS/JS\n", - "\n", - "šŸ“ Generated HTML size: 48,174 characters\n", - "\n", - "šŸŽ‰ Demo complete! The result above will retain its state when you save this notebook.\n" - ] - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAssAAAHwCAYAAABQXSIoAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuNSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/xnp5ZAAAACXBIWXMAAA9hAAAPYQGoP6dpAACAIUlEQVR4nO3dd3hT1ePH8XfSQQejgMwyW0gLsn8KoiBDhsheorhYCoLgAAeK+gXBgSKgiCgiCF9QhDJF2bJkKVNFVoHSsqGldNCV3N8fNfm2NoGWli4/r+fp88Ad556bnCSfnJx7rskwDAMREREREcnAnNcVEBERERHJrxSWRURERERcUFgWEREREXFBYVlERERExAWFZRERERERFxSWRURERERcUFgWEREREXFBYVlERERExAWFZRERERERF9zzugIi+d2uXbt48sknHf//7LPPaNOmzQ33iYyMpFmzZlitVgA2bNhApUqVblsdf/75Z4YMGYK/vz8bN27MVlmffvop06ZNo3379nzyySc33T4iIoIHHnjA6TqTyYSnpyd+fn7ceeed9OzZ86aPXU7asWMHU6ZM4ejRo5jNZu6++25mzJiRa8eXWxcUFJTlfbp37877779/G2oDTzzxBLt37+bNN9/k8ccfz1ZZ9nNbuXIlFoslJ6pXYN3o/eNGnnvuOYYPH34bapRRaGgogYGBuXIsyZ8UlkWyaO3atTcNfGvXrnUE5X+TOnXq4Onp6fi/YRgkJSURERHBxo0b2bhxI3379uXtt9++7XU5ffo0Tz/9NMnJyZQqVYqKFSve1i8skrMaNWqUYVlkZCSnTp3C09OTOnXqZFhfrVq1XKiZ5KQiRYo4fa7PnTvHuXPnKFq0qNMvFBUqVLjtdbt06RITJkwgPDyckJCQ2348yb8UlkUyyd3dnZSUFDZt2kRycjIeHh4ut12zZk0u1iz/mDp1qtNAmpyczLRp05gxYwYLFiygefPmtG7d+rbWZf369SQnJ1OlShV++OEHihQpcluPJznr22+/zbBsyZIljB49mjJlyjhdfzt98MEHXL9+nTJlymS7rB9//BGAypUrZ7usgs7Vc2n/hat27drMmzcvD2oGW7du5aeffuLOO+/Mk+NL/qExyyKZVLRoUWrVqkV0dDS7du1yuV1UVBS7d++mVq1auVi7/M3Dw4MXX3yRhg0bArBgwYLbfszIyEgA6tatq6As2VaxYkUCAwMpXrx4tssKDAwkMDAw3a8wIpJ/KSyLZEG7du2A1GEWrqxfv56UlBQefPDB3KpWgdGqVSsAfv/999t+LPswGAUSERHJDoVlkSxo3749ABs3bsRmszndZvXq1ZhMJkewduXPP/9k5MiRNG/enDp16nDPPfcwZMgQtm/f7nKf06dPM3r0aFq2bEn9+vXp3r07K1asuGm9169fz8CBA2nSpAl169alTZs2jB8/nosXL95035xUtGhRAOLi4jKsCw8P56233qJ169bUqVOHJk2aMHjwYHbs2JFh24iICIKCgujUqRPHjx+nT58+1K1bl2bNmvHf//6XoKAgvv76awCWLl1KUFBQhgvGTp06le54jRs3pn///vz0009ZPh6kXrR11113YRgGCxYsoEuXLtSvX5/77ruP1157jStXrgBw6NAhhgwZwt133029evXo06cPmzdvdvp4Xb58mcmTJ9OjRw/uvvtu6tSpQ9OmTRk4cKDToT6ffvopQUFBzJo1i4iICF555RWaNWtGnTp1aNOmDR9++CExMTFOj3XlyhWmTJlCx44dadCgAY0aNeKxxx5zDBn4p9jYWKZNm0bnzp2pX78+jRo14pFHHuH77793Ol5/165djuchIiLCaZk5wX6cwYMH89tvv9G5c2fq1KlDq1at0j1mhw8fZsyYMbRv356GDRtSt25dWrZsyciRI/nzzz8zlPvEE08QFBTkeL4hdVhIUFAQ48aNIzIyknHjxtGyZUvq1KlDixYtePvtt52+xuyPw9GjRx3LXnvtNYKCgli9ejWHDx9m+PDh3HPPPdStW5eHHnqIGTNmkJSU5PSc//jjD0aMGEHz5s2pV68e3bt3JyQkxNFuMzPkKSQkhKCgIDp37uxym08++YSgoCBeeOEFx7IrV67w3nvv0b59e+rUqUOjRo3o2rUrU6ZM4erVqzc9bnYkJSUxZ84cevbsScOGDWnQoAHdu3dn1qxZJCYmOt1n165dDB06lKZNm3LnnXc6Xk//bOetW7dm9OjRQOp7dWYfRymcNGZZJAsCAwOpUaMGx48fZ+/evdx1113p1tuHaDRs2JBy5cq5LGf+/PlMmDABq9VKiRIlCA4O5vz58/z888/8/PPPDBw4kFdeeSXdPnv37mXw4MFcu3aNokWLUqNGDSIiInj55Zcz1MPO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2rZtyz333APAgQMHHMvXrFnDyZMn8ff3Z8qUKZQqVcqx7qGHHuKRRx4BYPny5UBquz906BDVq1dnypQp3HHHHY7ta9SowZQpUzCbzXz77beOoScA3t7ejgvbbjSTTE567rnn8PX1BXCc1+7duwFo0KABAwYMSPfrQeXKlRkwYACQOrduVkycODHdrAkNGzakY8eOQPrH+2b8/PyYOnVqusf1ySefdDyX+/fvdyyfNWsWCQkJtG3blueff97xXuLj48OECROy/IuRvcfY2VAke69yx44dHcexDyPp3Lmzo01D6uM4atQoWrduna5nN6f8/vvv/Pzzz5QsWZLp06ena+cVKlTgk08+wcfHhw0bNnD48GEgtRf88uXLlChRgg4dOqQrr1u3bjz88MN07NiR2NjYHK+vFHwKyyJZFBwcTNWqVTlz5gyHDh1yLI+JiWH79u00aNCA8uXLO903Li6O3377DYC+ffs63aZPnz54enpy8eJFx9jJrVu3AvDggw86DRq9evXKsGzv3r1cvXqVO+64wxGS/umhhx4CYMuWLa5ON0seeOABx3jMoKAggoODufvuux0/l9o/3NIOw7DfRMUeLP6pY8eOmEwm/vrrLy5dupRhvT2IZpb9XF09/m3atKFixYokJCSwc+fOLB/v/vvvz7DM398fgCZNmqQb/gA4QlHacdwVK1Zk165d7Nq1y+nP915eXo5/JyQkZFgfFBTkdBhQ9erVAdIFgk2bNgGpj3Pacu2ee+451qxZw1tvvQWk3mAHUh8nZ7OMWCwWLBYLycnJTh+/3OTsuXrooYfYv38/c+fOdbqP/cvT9evXM30cPz8/6tevn2G5s8f7Zho3buz0eXBW1s8//wzAww8/nGF7s9ns+KKTWffddx933HEHp06d4o8//nAst9lsjjG99kANOELqRx99xObNm9ONE27Xrh2ff/65y9dZdtjbYNOmTdN9ubMrXbq0o3PA/novWbIkxYoVIzo6mtdff51jx46l22fcuHF89NFHjqE6ImlpzLLILWjXrh0zZ85k7dq11K5dG0h9A09OTr7hLBjh4eGkpKQ4xoU64+3tTUBAAIcPH+bUqVPUq1ePU6dOATgdRwjO73Z2/PhxAOLj4x29l/8UHR0NwMmTJ13WOSv+eVMSs9mMj48P5cqVo2HDhnTo0CFdD1RsbCznzp0DYPLkyRnG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s9lM+/btmTNnDq+//nqGfQICAli8eDFPPvkkpUqV4ujRoxQvXpw33niDl19+2elx3N3dmTJlCpMnT+a+++4jLi6OI0eOUKRIEdq2bcv8+fPp3bt31k88h3Xo0IFly5bRq1cv/Pz8OHLkCFFRUdStW5fXX3+dL7/8MkdvndyuXbt0xzt8+LDjRiWffvopU6ZMybUbZzhTvHhxFi9ezBNPPOG4LXZYWBhVq1blueeeY/ny5Y6ePmdfkrKqdu3aLF++nKeeeoqyZcty9OhR4uLiuO+++5g5cybDhg1Lt32NGjVYvnw5zz77LAEBAZw6dYrTp09TpUoVBgwYwNKlS6lYsWK263U7NGjQgKVLl9KuXTtKly7N8ePHuXz5Mg0bNmTChAl88803jpt55MRje7t17dqV//73v7Ro0QKr1crRo0epXLkyH330Ef379wdwOpTnZtq1a+cYx+3swj5InV/6u+++Y8CAAVSvXp3z589z7NgxihcvzsMPP8zKlStdzvGeXeXKlWPRokW8/PLL3HnnnZw5c8ZxsXWfPn1Yvnx5hqE2bdq04b///S/t2rXD19eXY8eOceHCBWrXrs2rr77Kd999l266werVqzN+/HiqVKnCpUuXCA8PT/drhPx7mAxnk3uKiIhIgbZgwQLGjh3Lvffe6xhOIyJZp55lERGRAui1116je/fujrtg/pP9zp/OLmYUkcxTWBYRESmAatasyaFDh5g0aZLjjnWQeoHdl19+ycaNG/H09KRnz555WEuRgk/DMERERAqg+Ph4HnnkEY4cOYKbmxtVq1bF29vbcYGdh4cHY8eOVVgWySaFZRERkQIqISGBkJAQVqxYQXh4ODExMZQpU4bGjRvzxBNPOOZqF5Fbp7AsIiIiIuKCxiyLiIiIiLigsCwiIiIi4oLu4HcbGIaBzabRLdllMoEGCcntovYlt5Pal9xuamPZYzabMJlMmdpWYfk2sNkMIiPj8roaBZrJBB4ebiQnW/VmIDlO7UtuJ7Uvud3UxrKvVClf3NwyF5Y1DENERERExAWFZRERERERFxSWRURERERcUFgWEREREXFBYVlERERExAWFZRERERERFxSWRURERERcUFgWEREREXFBYVlERERExAXdwU9ERERuyjAMrFYrhmHL66r866Xe6tpMSortX30HP5PJjJubW6ZvW32rFJZFRETEJZvN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fMGoc2YkJJvwKuKJe3I8RsK1vK5OjnD38sLL05MUU/5rX7rAT0RERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREXFJZFRERERFxQWBYRERERcUFhWURERETEBYVlEREREREX3PO6ApkVHR3NtGnTWL9+PZcuXaJkyZI0b96cYcOG4e/vn+XybDYbixYtYunSpRw7dozk5GQCAwPp3bs3jz76KCaT6TachYiIiIgUJAUiLEdHR/PII49w4sQJfH19sVgsREREEBISwrp165g3bx7BwcGZLi8xMZGhQ4eybds2zGYzAQEBxMfHc+jQIcaOHcuvv/7Kxx9/rMAsIiIi8i9XIIZhvPnmm5w4cYIWLVqwZcsWlixZwtatW+nRowfXrl3jpZdewmq1Zrq8Dz/8kG3btlGhQgWWLl3KqlWr+Pnnn5kxYwY+Pj78+OOPrFix4jaekYiIiIgUBPk+LIeGhrJ27Vp8fHyYOHEiRYsWBaBIkSKMHz+ewMBAQkNDWbduXabKCw8PZ8GCBbi7uzNz5sx0PdKtWrWif//+AISEhOT8yYiIiIhIgZLvw/KKFSswDIPWrVvj5+eXbp2bmxs9evQA4Mcff8xUeT/88ANWq5UuXbpQs2bNDOt79OjBiy++SM+ePbNddxEREREp2PL9mOWDBw8C0LBhQ6frGzRoAMCePXsyVd6OHTsAeOCBB5yur1SpEkOGDMliLUVERESkMMr3YTksLAxIDbHOVKxYEYDLly8TFxeHr6/vDcs7duwYAAEBAcTExBASEsJvv/1GfHw8gYGB9OnThxo1auTgGYiIiIhIQZXvw3JUVBRAhiEYdiVKlEi37Y3CcmJiIpGRkQCcP3+efv36ceHCBcf6X375hQULFvD222/z8MMP50DtRURERKQgy/dhOSEhAQAvLy+n69MuT0xMvGFZcXFxjn+/9NJLFC9enJkzZ9KkSROioqKYPXs2c+bM4e2336Zy5co0bdr0luudW7POmUxgAKa//yT/M5lyr33kBLWxgkXtS263gtTG1L4KnvzYvvJ9WHZzc8Nms7lcf6N1/5Q2TF+/fp1FixZRuXJlAMqXL8/o0aO5cuUKK1euZPLkybcclk0m8PBwu6V9s8psNpFimCAfNq5s+ftcUl80hefETCZwdzdjNheccyqUbUztK99Q+ypYClobK5TtCwptG8vN9pWVhy3fh2Vvb2+Sk5Nd9honJSU5/u2q99muSJEijn937drVEZTTGjJkCCtXruTAgQNcuXKF0qVLZ7nOhgHJyZmf9zk73NzMGG5mMFKPW2j8fS6GAUYhOjHDgJQUG1Zr5r/k5bVC2cbUvvINta+CpaC1sULZvqDQtrHcbF9ZedjyfVj28/Pj2rVrXL161en6tMtLlSp1w7KKFi2KyWTCMAyCgoKcblOtWjXc3d1JSUnhzJkztxSWIfdelPbjGDheO5LPGQXsTVttrGBR+5LbrSC1MbWvgic/tq98P89yQEAAAGfOnHG6/uzZswCUKVMGb2/vG5bl6enpclYNO5PJ5PhJw90933+XEBEREZHbKN+H5Tp16gBw4MABp+v3798PQP369TNVXr169QD4448/nK4/e/YsycnJmM1m/P39s1hbERERESlM8n1Ybtu2LQDr16/PMBTDarWydOlSALp06ZKp8h566CEAVq9enW7aOLv58+cDcPfdd6eblk5ERERE/n3yfVgODg6mZcuWxMbGMmLECMe8y4mJiYwZM4bQ0FCqV6/uCNV2kZGRhIaGcvr06XTLW7duTcOGDYmPj2fw4MHp1v/444/897//BeDZZ5+9zWcmIiIiIvldgRiUO3bsWPr27cuuXbto1aoVAQEBREREEB0dTbFixfjss88wm9Pn/vnz5zNt2jT8/f3ZuHGjY7nZbGbq1Kk89dRT/PXXXzz44IMEBgYSHx9PREQEAM8//3y25lgWERERkcIh3/csQ+ocyCEhITzxxBOUKlWKo0eP4ubmRqdOnVi8eDGBgYFZKq9cuXIsXbqUESNGEBAQwOnTp4mLi6NZs2Z89dVXDB069DadiYiIiIgUJAWiZxmgZMmSjBkzhjFjxmRq++HDhzN8+HCX6729vRk2bBjDhg3LqSqKiIiISCFTIHqWRURERETygsKyiIiIiIgLCssiIiIiIi4oLIuIiIiIuJBjF/hFRkayc+dOTp48SWxsLK+++iqJiYns27ePe+65J6cOIyIiIiKSa7IdlpOTk/noo4/49ttvSU5Odix/9dVXOX36NP3796dWrVp8/vnnlCtXLruHExERERHJNdkahmGz2Rg2bBhz584lJSWFoKCgdLeIjouLw2w2c+jQIR599FHH3fdERERERAqCbIXlkJAQtmzZQkBAACtWrGDZsmUEBAQ41jdo0IDVq1dTs2ZNzp07x6xZs7JdYRERERGR3JLtsGwymfjkk0+oUaOG020qV67Mp59+itlsTnfbaRERERGR/C5bYfnYsWMEBATc9HbT1apVo1q1akRERGTncCIiIiIiuSpbYdlqtWI2Z64IDw8P3NzcsnM4EREREZFcla2wXLlyZU6ePElkZOQNt7t8+TLHjx+ncuXK2TmciIiIiEiuylZYbt++PSkpKbz11lvppo1LKykpiTfeeAOr1UqbNm2yczgRERERkVyVrXmW+/fvz4oVK9iwYQPdunXjgQce4NKlSwCsW7eO0NBQli5dSlhYGBUqVKBfv345UWcRERERkVyRrbDs6+vL119/zfDhw/nrr784ceKEY92IESMAMAyDqlWrMn36dIoXL5692oqIiIiI5KJs38GvUqVKhISEsG7dOjZs2MDx48eJi4vD29ubqlWr0rJlSzp27Iinp2dO1FdEREREJNdkKyyvW7eO4OBgKleuTPv27Wnfvn1O1UtEREREJM9l6wK/9957j06dOnH16tUcqo6IiIiISP6RrbB86dIlAgIC8PPzy6HqiIiIiIjkH9kKy9WqVePs2bMkJCTkVH1ERERERPKNbIXl//znP6SkpDBw4EB27NhBfHx8TtVLRERERCTPZesCv5kzZ1KxYkX27t3LgAEDAPD29qZIkSJOtzeZTGzfvj07hxQRERERyTXZCsubNm3KsCw+Pt5lD7PJZMrO4UREREREclW2wvLcuXNzqh4iIiIiIvlOtsJy48aNc6oeIiIiIiL5Trbv4GdnGAZ//vknp06dIi4uDh8fH6pWrcqdd96Jm5tbTh1GRERERCTX5EhYDgkJ4ZNPPuHixYsZ1vn5+fH888/zyCOP5MShRERERERyTbbD8kcffcSsWbMwDANPT08CAgLw8fEhJiaGkydPEhUVxdixYwkLC+PVV1/NiTqLiIiIiOSKbIXlHTt28NVXX+Hp6cnIkSPp06cPXl5ejvXXr19n4cKFfPzxx8yZM4dWrVppnLOIiIiIFBjZuinJ3LlzMZlMvPPOOzz11FPpgjKkzrncr18/xo0bh2EYLFiwIFuVFRERERHJTdkKy/v376dMmTJ07dr1htt169aNMmXKsH///uwcTkREREQkV2UrLMfExFC+fPlMbVuhQgWuXLmSncOJiIiIiOSqbIXlUqVKERYWhs1mu+F2VquVsLAwSpYsmZ3DiYiIiIjkqmyF5bvvvptr164xa9asG243a9YsoqOjufvuu7NzOBERERGRXJWt2TAGDhzI6tWrmTx5MufOnePRRx+lZs2ajvVHjx7l22+/ZeHChbi5udG/f/9sV1hEREREJLdkKyzXrl2b119/nfHjx/Ptt9/y7bff4u7ujo+PD/Hx8aSkpABgMpl4/fXXqVOnTo5UWkREREQkN2RrGAbAY489xpw5c2jcuDFubm4kJycTHR1NcnIyZrOZJk2aMGfOHB577LGcqK+IiIiISK7JkdtdN2nShCZNmhAfH094eDhxcXH4+PhQpUoVfHx8cuIQIiIiIiK5LkfCckJCAhs3buShhx4iKCjIsXzhwoUkJSXRtWtXihcvnhOHEhERERHJNdkehrF9+3ZatGjByJEjuXDhQrp1P/30E++++y4PPvggO3bsyO6hRERERERyVbbC8sGDB3nmmWeIjo6mZs2aJCcnp1v/0EMPUb9+fSIjIxk6dCgnTpzIVmVFRERERHJTtsLyzJkzSUlJoX///qxYsYJKlSqlW//www/z3XffMWjQIK5fv84XX3yRrcqKiIiIiOSmbIXlPXv2UKpUKUaNGnXD7V544QVKlCjB9u3bs3M4EREREZFcla2wHBMTQ8WKFXFzc7vhdu7u7lSuXJmrV69m53AiIiIiIrkqW2G5bNmyhIeHY7Vab7idzWbjzJkz+Pn5ZedwIiIiIiK5KlthuUmTJly7do3PP//8htvNnj2bqKgoGjdunJ3DiYiIiIjkqmzNs9yvXz9++OEHPvvsM06ePEmPHj2oWbMmPj4+XL9+nePHj7N8+XJWrFiBu7s7gwYNyql6i4iIiIjcdtkKyxaLhXHjxvHWW2+xatUqfvzxxwzbGIaBu7s777zzDrVq1crO4UREREREclW2b0rSrVs3li9fTu/evSlTpgyGYTj+/Pz86Ny5M4sXL6Z79+45UV8RERERkVyTI7e7rl69Ou+88w4ASUlJREVF4e3trVtci4iIiEiBdsth+dy5c5QvXx6TyZRueVhYGIsXLyYsLIySJUty33330aFDh5tOLyciIiIikt9kOSzPnz+fGTNmcOXKFTZs2ECFChUc6xYuXMi4ceOw2WwYhgHAsmXLmD17NjNmzKBMmTI5V3MRERERkdssS2F54sSJzJ492xGEo6OjHWH50KFDjB07FpvNhre3N7179+aOO+5g7dq1/PHHHzz33HN89913GXqiRURERETyq0yH5T///JPZs2djMpkYOnQovXv3pnz58o71H330ETabDZPJxIwZM2jSpAkAgwYN4tlnn2XLli388MMPdO7cOefPQkRERETkNsj0bBghISEAvPTSSwwfPjxdUL506RI7d+7EZDJx//33O4IygNls5pVXXsEwDFatWpWDVRcRERERub0yHZZ37dpFkSJFePLJJzOs27ZtGzabDYAHH3www/rAwEAqVKjA4cOHs1FVEREREZHclemwfPHiRSpUqICnp2eGdbt27XL8+95773W6/x133EFkZOQtVFFEREREJG9kOiwnJSW5nDd59+7dAFSpUoVy5co53SY2NhZvb+9bqKKIiIiISN7IdFguXbo0Fy9ezLD85MmTnD17FpPJ5LJXOTY2lvDwcEqXLn3rNRURERERyWWZDssNGjTg/PnzGcYdr1y50vHvBx54wOm+y5YtIyUlhYYNG95iNVOnqZswYQKtWrWiTp06NG/enNdff50zZ87ccplp7d27l1q1atG6descKU9ERERECr5Mh+WuXbtiGAYjR47k2LFjAOzcuZNvvvkGk8lExYoVnfYs//HHH0ydOhWTyUS7du1uqZLR0dE88sgjzJ07l+joaCwWC4mJiYSEhNCtW7dsXziYmJjIG2+84bhIUUREREQEsjDPcosWLXjwwQdZvXo1Xbp0wdPTk6SkJAzDwGw2884772A2/y97r1mzhs2bN7Nq1SoSExO57777aNGixS1V8s033+TEiRO0aNGCjz/+mKJFi5KYmMh//vMflixZwksvvcTKlStv+Zba06ZN48SJE7e0r4iIiIgUXpnuWQaYNGkSgwYNwsvLi8TERAzDoFy5cnzyyScZepU//PBDli5dSmJiIg0aNGDy5Mm3VMHQ0FDWrl2Lj48PEydOpGjRogAUKVKE8ePHExgYSGhoKOvWrbul8v/880++/vprvLy8bml/ERERESm8shSW3dzcGDVqFNu3b2fp0qWsXLmSjRs30qZNmwzb1q9fnxYtWjBx4kTmz5/vciaNm1mxYgWGYdC6dWv8/Pwy1KdHjx4A/Pjjj1kuOzk5mdGjRzvuSigiIiIiklamh2Gk5e3tTa1atW64zaRJk26pQv908OBBAJcXBzZo0ACAPXv2ZLnsL774giNHjvDss89isVhuuY4iIiIiUjhlqWc5L4SFhQFQqVIlp+srVqwIwOXLl4mLi8t0uUePHmXGjBkEBASoV1lEREREnMr3YTkqKgogwxAMuxIlSmTY9masViuvv/46KSkpjB8/3uldCUVEREREbmkYRm5KSEgAcHkBXtrliYmJmSpz9uzZ/P777zz22GP83//9X/Yr6YTJdFuKdXocAzD9/Sf5n8mUe+0jJ6iNFSxqX3K7FaQ2pvZV8OTH9pXvw7Kbm9sN5z/O6tzIp06d4tNPP6VChQq89NJL2a2eUyYTeHjc2jR2WWU2m0gxTJAPG1e2/H0uqS+awnNiJhO4u5sxmwvOORXKNqb2lW+ofRUsBa2NFcr2BYW2jeVm+8rKw5bvw7K3tzfJyckue42TkpIc/77Z9G+GYfD666+TkJDA2LFjHdPQ5TTDgORk620p+5/c3MwYbmYwUo9baPx9LoaR+rwVFoYBKSk2rNaCcwOcQtnG1L7yDbWvgqWgtbFC2b6g0Lax3GxfWXnY8n1Y9vPz49q1a1y9etXp+rTLS5UqdcOy5s+fz549e+jUqdMt3yAls3Kr7dqPY+B47Ug+ZxSwN221sYJF7Utut4LUxtS+Cp782L7yfVgOCAjg9OnTnDlzxun6s2fPAlCmTBm8vb1vWNaaNWsA+OGHH/jhhx+cbnPmzBmCgoIA2LBhg8tZOERERESk8Mv3YblOnTps2rSJAwcO0Ldv3wzr9+/fD6TeBOVmLBYLKSkpTtddu3aN48eP4+npSZ06dYDUuwSKiIiIyL9Xvg/Lbdu2Zdq0aaxfv56rV6+mm0LOarWydOlSALp06XLTst58802X637++WeGDBlCmTJl+Pbbb7NdbxEREREp+PL9PMvBwcG0bNmS2NhYRowY4ZhLOTExkTFjxhAaGkr16tVp27Ztuv0iIyMJDQ3l9OnTeVFtERERESkE8n3PMsDYsWPp27cvu3btolWrVgQEBBAREUF0dDTFihXjs88+w2xOn/vnz5/PtGnT8Pf3Z+PGjXlUcxEREREpyPJ9zzJA+fLlCQkJ4YknnqBUqVIcPXoUNzc3OnXqxOLFiwkMDMzrKoqIiIhIIVQgepYBSpYsyZgxYxgzZkymth8+fDjDhw/PdPmtWrXiyJEjt1o9ERERESmECkTPsoiIiIhIXlBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQF97yuQGZFR0czbdo01q9fz6VLlyhZsiTNmzdn2LBh+Pv7Z7m80NBQvvrqK3bt2sXFixfx8vIiODiYXr160a1bt5w/AREREREpcApEWI6OjuaRRx7hxIkT+Pr6YrFYiIiIICQk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bt3KuHHjAOjduzfFihVzrLOPLz579my26920aVPq169PTEwMw4cPTzfrxu7du5k0aRIA/fv3B6Bz584AfP7552zbti1dWT/99BN79uzBbDbTrFmzbNdNRORmNGZZRKSAMJlMtG3blgULFnD9+vUbDsGwq1GjBh988AGvvPIKM2bMYN68eVSvXp2oqCjOnDkDwL333suoUaPS7RcUFMTPP//MN998w44dO+jQoQODBw++5XpPnjyZfv36sWvXLlq1akXNmjWJiYkhPDwcgF69etGtWzcAunbtysaNG1mzZg0DBw6kfPny3HHHHVy8eJGLFy8C8NJLLzl6mEVEbieFZRGRAqRdu3YsWLAA4KZDMOw6dOiAxWLh66+/ZseOHRw5cgQPDw/q1q1L586d6du3Lx4eHun2eeaZZ7hw4QIbN27kxIkTjhuF3Cp/f3+WLFnC7NmzWbNmjeO2140aNaJv376O3mRIDdeTJk3irrvuYtWqVRw/fpzLly9TsmRJ2rZty2OPPUbTpk2zVR8RkcwyGTkxIE1EREREpBDSmGURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXFBYFhERERFxQWFZRERERMQFhWURERERERcUlkVEREREXPh/giBFjc55bxgAAAAASUVORK5CYII=", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "html = demo_html_rendering()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "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.9.20" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/validmind/utils.py b/validmind/utils.py index ec7570864..82df125d4 100644 --- a/validmind/utils.py +++ b/validmind/utils.py @@ -537,16 +537,13 @@ def preview_test_config(config): def display(widget_or_html, syntax_highlighting=True, mathjax=True): """Display widgets with extra goodies (syntax highlighting, MathJax, etc.).""" - # Check if the object has a to_html method and prefer that over to_widget if hasattr(widget_or_html, "to_html"): html_content = widget_or_html.to_html() ipy_display(HTML(html_content)) - # if html we can auto-detect if we actually need syntax highlighting or MathJax 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: diff --git a/validmind/vm_models/html_progress.py b/validmind/vm_models/html_progress.py index e0a9caa29..3e95e0595 100644 --- a/validmind/vm_models/html_progress.py +++ b/validmind/vm_models/html_progress.py @@ -53,7 +53,6 @@ def update(self, value: int, description: str = None): self.description = description if self._displayed: - # Always use fallback method for reliability self._update_fallback() def _update_fallback(self): @@ -67,7 +66,7 @@ def _update_fallback(self): try: update_display(HTML(html_content), display_id=self._display_id) except Exception: - pass # Silently fail if update doesn't work + pass def complete(self): """Mark the progress bar as complete.""" @@ -76,7 +75,6 @@ def complete(self): def close(self): """Close/hide the progress bar.""" if self._displayed: - # Replace with final state HTML that preserves the completed state final_html = StatefulHTMLRenderer.render_progress_bar( value=self.value, max_value=self.max_value, @@ -154,7 +152,6 @@ def __init__( def display(self): """Display the box and its children.""" if not self._displayed: - # Display each child first child_html_parts = [] for child in self.children: if hasattr(child, "display"): @@ -164,7 +161,6 @@ def display(self): elif hasattr(child, "label_id"): child_html_parts.append(f'
') - # Create container html_content = f"""
{''.join(child_html_parts)} diff --git a/validmind/vm_models/html_renderer.py b/validmind/vm_models/html_renderer.py index 00ab1d74a..a4117b32a 100644 --- a/validmind/vm_models/html_renderer.py +++ b/validmind/vm_models/html_renderer.py @@ -266,7 +266,6 @@ def render_description(description: str) -> str: Returns: HTML string with formatted description """ - # Replace h3 tags with strong tags for better nesting formatted_description = description.replace("

", "").replace( "

", "" ) diff --git a/validmind/vm_models/result/result.py b/validmind/vm_models/result/result.py index e21985a07..938de7fad 100644 --- a/validmind/vm_models/result/result.py +++ b/validmind/vm_models/result/result.py @@ -279,11 +279,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 @@ -296,11 +294,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 @@ -443,26 +439,21 @@ def to_html(self): html_parts = [StatefulHTMLRenderer.get_base_css()] - # Add result header html_parts.append( StatefulHTMLRenderer.render_result_header( test_name=self.test_name, passed=self.passed, metric=self.metric ) ) - # Add description if self.description: html_parts.append(StatefulHTMLRenderer.render_description(self.description)) - # Add parameters if self.params: html_parts.append(StatefulHTMLRenderer.render_parameters(self.params)) - # Add tables if self.tables: html_parts.append(tables_to_html(self.tables)) - # Add figures if self.figures: html_parts.append(figures_to_html(self.figures)) @@ -494,7 +485,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" @@ -560,7 +550,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 @@ -597,7 +586,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" @@ -812,18 +800,15 @@ def to_html(self): """Generate HTML that persists in saved notebooks.""" html_parts = [StatefulHTMLRenderer.get_base_css()] - # Add result header html_parts.append( StatefulHTMLRenderer.render_result_header( test_name=self.test_name, passed=None ) ) - # Add description if self.description: html_parts.append(StatefulHTMLRenderer.render_description(self.description)) - # Add parameters if self.params: html_parts.append(StatefulHTMLRenderer.render_parameters(self.params)) @@ -859,7 +844,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/test_suite/runner.py b/validmind/vm_models/test_suite/runner.py index c6731ca9f..a2364252c 100644 --- a/validmind/vm_models/test_suite/runner.py +++ b/validmind/vm_models/test_suite/runner.py @@ -70,30 +70,24 @@ 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() - # Use HTML progress bar instead of ipywidgets 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]) - - # Display the HTML components self.html_pbar.display() - # Keep the old widgets as fallback for compatibility 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]) def _stop_progress_bar(self): - # Update HTML progress bar 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!") - # Keep old widget updates for compatibility self.pbar_description.value = "Test suite complete!" self.pbar.close() @@ -104,7 +98,6 @@ def _update_progress_message(self, message: str): if self.html_pbar_description: self.html_pbar_description.update(message) - # Keep old widget updates for compatibility self.pbar_description.value = message def _increment_progress(self): @@ -112,7 +105,6 @@ def _increment_progress(self): if self.html_pbar: self.html_pbar.update(self.html_pbar.value + 1) - # Keep old widget updates for compatibility self.pbar.value += 1 async def _log_test_result(self, test): @@ -150,7 +142,6 @@ async def _check_progress(self): done = False while not done: - # Check HTML progress bar completion progress_complete = False if self.html_pbar and self.html_pbar.value >= self.html_pbar.max_value: progress_complete = True @@ -160,13 +151,11 @@ async def _check_progress(self): if progress_complete: completion_message = "Test suite complete!" - # Update HTML progress bar 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) - # Keep old widget updates for compatibility self.pbar_description.value = completion_message done = True @@ -178,13 +167,11 @@ def summarize(self, show_link: bool = True): collecting_message = "Collecting test results..." - # Update HTML progress bar 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) - # Keep old widget updates for compatibility self.pbar_description.value = collecting_message summary = TestSuiteSummary( @@ -194,7 +181,6 @@ def summarize(self, show_link: bool = True): show_link=show_link, ) - # Use HTML rendering by default for better state preservation from ...utils import display as vm_display vm_display(summary) @@ -214,13 +200,11 @@ def run(self, send: bool = True, fail_fast: bool = False): for test in section.tests: running_message = f"Running {test.name}" - # Update HTML progress bar 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) - # Keep old widget updates for compatibility self.pbar_description.value = running_message test.run( @@ -228,11 +212,9 @@ def run(self, send: bool = True, fail_fast: bool = False): config=self._test_configs.get(test.test_id, {}), ) - # Update progress value if self.html_pbar: self.html_pbar.update(self.html_pbar.value + 1) - # Keep old widget updates for compatibility self.pbar.value += 1 if send: diff --git a/validmind/vm_models/test_suite/summary.py b/validmind/vm_models/test_suite/summary.py index 0d4151f10..4ac60935a 100644 --- a/validmind/vm_models/test_suite/summary.py +++ b/validmind/vm_models/test_suite/summary.py @@ -88,7 +88,6 @@ def to_html(self): f'
{md_to_html(self.description)}
' ) - # Create accordion content accordion_items = [] accordion_titles = [] @@ -224,15 +223,12 @@ def to_html(self): """Generate HTML representation of the complete test suite summary.""" html_parts = [StatefulHTMLRenderer.get_base_css()] - # Add title title_html = f"""

Test Suite Results: {self.title}


""" html_parts.append(title_html) - # Add results link if needed if self.show_link: - # avoid circular import from ...api_client import get_api_host, get_api_model ui_host = ( @@ -247,16 +243,12 @@ def to_html(self): """ html_parts.append(results_link_html) - # Add description html_parts.append(f'
{md_to_html(self.description)}
') - # Add sections if len(self.sections) == 1: - # Single section - render tests directly section_summary = TestSuiteSectionSummary(tests=self.sections[0].tests) html_parts.append(section_summary.to_html()) else: - # Multiple sections - create accordion section_items = [] section_titles = [] From ec91c1bd87478f7f9289025c19bd6f2676bcf1bc Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Wed, 10 Dec 2025 14:25:14 -0800 Subject: [PATCH 04/11] Remove ipywidgets --- pyproject.toml | 1 - tests/test_results.py | 31 +-- validmind/api_client.py | 13 +- validmind/template.py | 219 +++++----------------- validmind/tests/load.py | 14 +- validmind/utils.py | 5 +- validmind/vm_models/figure.py | 44 ----- validmind/vm_models/result/result.py | 74 +------- validmind/vm_models/result/utils.py | 66 ------- validmind/vm_models/test_suite/runner.py | 30 +-- validmind/vm_models/test_suite/summary.py | 148 +-------------- 11 files changed, 92 insertions(+), 553 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 57ea73feb..8ff8c43a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ authors = [ ] dependencies = [ "aiohttp[speedups]", - "ipywidgets", "kaleido (>=0.2.1,!=0.2.1.post1,<1.0.0)", "matplotlib", "mistune (>=3.0.2,<4.0.0)", diff --git a/tests/test_results.py b/tests/test_results.py index a6f4d58e9..89e7dc564 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -3,7 +3,6 @@ from unittest.mock import patch import pandas as pd import matplotlib.pyplot as plt -from ipywidgets import HTML, VBox from validmind.vm_models.result import ( TestResult, @@ -74,8 +73,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 +180,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 +291,26 @@ 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) if __name__ == "__main__": 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 0a4f6f40e..52d347aab 100644 --- a/validmind/template.py +++ b/validmind/template.py @@ -2,9 +2,7 @@ # 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 +from typing import Any, Dict, List, Optional, Type from .html_templates.content_blocks import ( failed_content_block_html, @@ -59,92 +57,80 @@ 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 _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) except LoadTestError: - return HTML(failed_content_block_html.format(test_id=content["content_id"])) + return failed_content_block_html.format(test_id=content["content_id"]) - return Accordion( - children=[HTML(test_html)], - titles=[f"{content_type} Block: '{content['content_id']}'"], - ) + return test_html -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_html(tree: List[Dict[str, Any]]) -> str: + """Create HTML representation of sections.""" + accordion_items = [] + accordion_titles = [] -def _create_section_widget(tree: List[Dict[str, Any]]) -> Accordion: - widget = Accordion() 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: @@ -157,126 +143,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"]) ) - - -def preview_template_html(template: str) -> str: - """Generate HTML preview of a template that preserves state. - - Args: - template (dict): The template to preview. - - Returns: - HTML string representation of the template - """ - section_tree = _convert_sections_to_section_tree(template["sections"]) - return _create_section_html(section_tree) - - -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 f""" -
-

{content_type} Block: '{content['content_id']}'

-

Content ID: {content['content_id']}

-

Content Type: {content_type}

-
- """ - - try: - test_html = describe_test(test_id=content["content_id"], show=False) - return f""" -
-

{content_type} Block: '{content['content_id']}'

-
- {test_html} -
-
- """ - except LoadTestError: - return f""" -
-

āŒ Failed Test Block: '{content['content_id']}'

-

Test could not be loaded

-
- """ - - -def _create_sub_section_html( - sub_sections: List[Dict[str, Any]], section_number: str -) -> str: - """Create HTML for sub-sections.""" - if not sub_sections: - return "

Empty Section

" - - accordion_items = [] - accordion_titles = [] - - for i, section in enumerate(sub_sections): - section_num = f"{section_number}.{i + 1}" - - if section["sections"]: - # Has sub-sections - content_html = _create_sub_section_html(section["sections"], section_num) - elif contents := section.get("contents", []): - # Has content blocks - content_parts = [_create_content_html(content) for content in contents] - content_html = "".join(content_parts) - else: - # Empty section - content_html = "

Empty Section

" - - accordion_items.append(content_html) - accordion_titles.append( - f"{section_num}. {section['title']} ('{section['id']}')" - ) - - return StatefulHTMLRenderer.render_accordion(accordion_items, accordion_titles) - - -def _create_section_html(tree: List[Dict[str, Any]]) -> str: - """Create HTML representation of the section tree.""" - html_parts = [StatefulHTMLRenderer.get_base_css()] - - accordion_items = [] - accordion_titles = [] - - for i, section in enumerate(tree): - section_content_parts = [] - - # Add sub-sections if they exist - if section.get("sections"): - sub_section_html = _create_sub_section_html(section["sections"], str(i + 1)) - section_content_parts.append(sub_section_html) - - # Add direct content blocks if they exist - if section.get("contents"): - content_parts = [ - _create_content_html(content) for content in section["contents"] - ] - section_content_parts.extend(content_parts) - - # Combine all content for this section - if section_content_parts: - section_html = "".join(section_content_parts) - else: - section_html = "

Empty Section

" - - accordion_items.append(section_html) - accordion_titles.append(f"{i + 1}. {section['title']} ('{section['id']}')") - - if accordion_items: - main_accordion = StatefulHTMLRenderer.render_accordion( - accordion_items, accordion_titles - ) - html_parts.append(main_accordion) - - return f'
{"".join(html_parts)}
' + 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/utils.py b/validmind/utils.py index 82df125d4..a114e7b04 100644 --- a/validmind/utils.py +++ b/validmind/utils.py @@ -536,7 +536,7 @@ def preview_test_config(config): def display(widget_or_html, syntax_highlighting=True, mathjax=True): - """Display widgets with extra goodies (syntax highlighting, MathJax, etc.).""" + """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)) @@ -547,7 +547,8 @@ def display(widget_or_html, syntax_highlighting=True, mathjax=True): 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 2fa6e5743..b7d8693b2 100644 --- a/validmind/vm_models/figure.py +++ b/validmind/vm_models/figure.py @@ -12,7 +12,6 @@ from io import BytesIO from typing import Union -import ipywidgets as widgets import matplotlib import plotly.graph_objs as go @@ -71,49 +70,6 @@ def __post_init__(self): def __repr__(self): return f"Figure(key={self.key}, ref_id={self.ref_id})" - def to_widget(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. - """ - 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""" - - """ - ) - - 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 - - elif is_png_image(self.figure): - encoded = base64.b64encode(self.figure).decode("utf-8") - return widgets.HTML( - value=f""" - - """ - ) - - else: - raise UnsupportedFigureError( - f"Figure type {type(self.figure)} not supported for plotting" - ) - def to_html(self): """ Returns HTML representation that preserves state when notebook is saved. diff --git a/validmind/vm_models/result/result.py b/validmind/vm_models/result/result.py index 938de7fad..7333c23cf 100644 --- a/validmind/vm_models/result/result.py +++ b/validmind/vm_models/result/result.py @@ -15,19 +15,12 @@ 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 @@ -36,10 +29,7 @@ AI_REVISION_NAME, DEFAULT_REVISION_NAME, figures_to_html, - figures_to_widgets, - get_result_template, tables_to_html, - tables_to_widgets, update_metadata, ) @@ -138,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): @@ -148,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 @@ -162,9 +155,6 @@ 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""" @@ -396,40 +386,6 @@ 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}

" - ) - - 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) - - widgets = [HTML(rendered)] - - if self.tables: - widgets.extend(tables_to_widgets(self.tables)) - if self.figures: - widgets.extend(figures_to_widgets(self.figures)) - - return VBox(widgets) - def to_html(self): """Generate HTML that persists in saved notebooks.""" if self.metric is not None and not self.tables and not self.figures: @@ -780,22 +736,6 @@ 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) - - widgets = [HTML(rendered)] - - return VBox(widgets) - def to_html(self): """Generate HTML that persists in saved notebooks.""" html_parts = [StatefulHTMLRenderer.get_base_css()] diff --git a/validmind/vm_models/result/utils.py b/validmind/vm_models/result/utils.py index 7f98d350e..e064e8e5f 100644 --- a/validmind/vm_models/result/utils.py +++ b/validmind/vm_models/result/utils.py @@ -5,7 +5,6 @@ import os from typing import TYPE_CHECKING, Dict, List, Union -from ipywidgets import HTML, GridBox, Layout from jinja2 import Template from ... import api_client @@ -50,57 +49,6 @@ 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

"), - ] - - 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) - ) - - widgets.append(HTML(html)) - - return widgets - - def tables_to_html(tables: List["ResultTable"]) -> str: """Convert a list of tables to HTML.""" if not tables: @@ -117,20 +65,6 @@ def tables_to_html(tables: List["ResultTable"]) -> str: return "".join(html_parts) -def figures_to_widgets(figures: List[Figure]) -> list: - """Convert a list of figures to ipywidgets.""" - num_columns = 2 if len(figures) > 1 else 1 - - plot_widgets = GridBox( - [figure.to_widget() for figure in figures], - layout=Layout( - grid_template_columns=f"repeat({num_columns}, 1fr)", - ), - ) - - return [HTML("

Figures

"), plot_widgets] - - def figures_to_html(figures: List[Figure]) -> str: """Convert a list of figures to HTML.""" if not figures: diff --git a/validmind/vm_models/test_suite/runner.py b/validmind/vm_models/test_suite/runner.py index a2364252c..4da5cdd2b 100644 --- a/validmind/vm_models/test_suite/runner.py +++ b/validmind/vm_models/test_suite/runner.py @@ -4,8 +4,6 @@ import asyncio -import ipywidgets as widgets - from ...logging import get_logger from ...utils import is_notebook, run_async, run_async_check from ..html_progress import HTMLBox, HTMLLabel, HTMLProgressBar @@ -25,10 +23,6 @@ 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 @@ -77,10 +71,6 @@ def _start_progress_bar(self, send: bool = True): self.html_pbar_box = HTMLBox([self.html_pbar_description, self.html_pbar]) self.html_pbar.display() - 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]) - def _stop_progress_bar(self): if self.html_pbar: self.html_pbar.complete() @@ -88,25 +78,18 @@ def _stop_progress_bar(self): if self.html_pbar_description: self.html_pbar_description.update("Test suite complete!") - self.pbar_description.value = "Test suite complete!" - self.pbar.close() - def _update_progress_message(self, message: str): - """Updates both HTML and widget progress bar messages.""" + """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) - self.pbar_description.value = message - def _increment_progress(self): - """Increments both HTML and widget progress bars.""" + """Increments HTML progress bar.""" if self.html_pbar: self.html_pbar.update(self.html_pbar.value + 1) - self.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}..." @@ -145,8 +128,6 @@ async def _check_progress(self): progress_complete = False if self.html_pbar and self.html_pbar.value >= self.html_pbar.max_value: progress_complete = True - elif self.pbar and self.pbar.value == self.pbar.max: - progress_complete = True if progress_complete: completion_message = "Test suite complete!" @@ -156,7 +137,6 @@ async def _check_progress(self): if self.html_pbar_description: self.html_pbar_description.update(completion_message) - self.pbar_description.value = completion_message done = True await asyncio.sleep(0.5) @@ -172,8 +152,6 @@ def summarize(self, show_link: bool = True): if self.html_pbar_description: self.html_pbar_description.update(collecting_message) - self.pbar_description.value = collecting_message - summary = TestSuiteSummary( title=self.suite.title, description=self.suite.description, @@ -205,8 +183,6 @@ def run(self, send: bool = True, fail_fast: bool = False): if self.html_pbar_description: self.html_pbar_description.update(running_message) - self.pbar_description.value = running_message - test.run( fail_fast=fail_fast, config=self._test_configs.get(test.test_id, {}), @@ -215,8 +191,6 @@ def run(self, send: bool = True, fail_fast: bool = False): if self.html_pbar: self.html_pbar.update(self.html_pbar.value + 1) - self.pbar.value += 1 - if send: run_async(self.log_results) run_async_check(self._check_progress) diff --git a/validmind/vm_models/test_suite/summary.py b/validmind/vm_models/test_suite/summary.py index 4ac60935a..3e2c3af1e 100644 --- a/validmind/vm_models/test_suite/summary.py +++ b/validmind/vm_models/test_suite/summary.py @@ -5,8 +5,6 @@ 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 @@ -33,51 +31,9 @@ class TestSuiteSectionSummary: tests: List[TestSuiteTest] description: Optional[str] = None - _widgets: List[widgets.Widget] = None - - def __post_init__(self): - self._build_summary() - - def _add_description(self): - """Add the section description to the summary.""" - if not self.description: - return - - self._widgets.append( - widgets.HTML( - value=f'
{md_to_html(self.description)}
' - ) - ) - - def _add_tests_summary(self): - """Add the test results summary.""" - children = [] - 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})" - ) - - 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() - - self._add_tests_summary() - - self.summary = widgets.VBox(self._widgets) - def display(self): """Display the summary.""" - display(self.summary) + display(self.to_html()) def to_html(self): """Generate HTML representation.""" @@ -95,8 +51,10 @@ def to_html(self): if hasattr(test.result, "to_html"): accordion_items.append(test.result.to_html()) else: - # Fallback to widget rendering wrapped in HTML - accordion_items.append(str(test.result.to_widget().value)) + # 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( @@ -121,103 +79,9 @@ class TestSuiteSummary: sections: List[TestSuiteSection] show_link: bool = True - _widgets: List[widgets.Widget] = None - - def __post_init__(self): - """Initialize the summary after the dataclass is created.""" - self._build_summary() - - def _add_title(self): - """Add the title to the summary.""" - title = 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)}
' - ) - ) - - def _add_sections_summary(self): - """Append the section summary.""" - children = [] - titles = [] - - for section in self.sections: - if not section.tests: - continue - - children.append( - 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() - - self.summary = widgets.VBox(self._widgets) - def display(self): """Display the summary.""" - display(self.summary) + display(self.to_html()) def to_html(self): """Generate HTML representation of the complete test suite summary.""" From c09bc0841d9c0a148f43bbbf2e2eaaec8bb45567 Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Wed, 10 Dec 2025 14:39:38 -0800 Subject: [PATCH 05/11] Backwards compatible plotly JSON rendering --- validmind/vm_models/html_renderer.py | 76 +++++++++++++++++++++++++--- validmind/vm_models/result/result.py | 8 +-- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/validmind/vm_models/html_renderer.py b/validmind/vm_models/html_renderer.py index a4117b32a..dbbb6f627 100644 --- a/validmind/vm_models/html_renderer.py +++ b/validmind/vm_models/html_renderer.py @@ -16,28 +16,92 @@ 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 + metadata: Optional metadata to embed (may contain plotly_json for + interactive Plotly rendering) Returns: HTML string with embedded figure and metadata """ metadata = metadata or {} - metadata_json = json.dumps(metadata, default=str) + plotly_json = metadata.get("plotly_json") - return f""" -
- ValidMind Figure {key} + alt="ValidMind Figure {key}"/>""" + + if plotly_json: + # Prepare fallback image HTML for JavaScript (escape quotes, remove newlines) + img_html_escaped = img_html.replace("\n", "").replace("'", "\\'") + plotly_cdn_url = StatefulHTMLRenderer.PLOTLY_CDN_URL + + # Render interactive Plotly chart with static image fallback + return f""" +
+
+ + + + +
+ """ + else: + # Non-Plotly figures (matplotlib, PNG) - render static image only + return f""" +
+ {img_html}
""" diff --git a/validmind/vm_models/result/result.py b/validmind/vm_models/result/result.py index 7333c23cf..76b51bd36 100644 --- a/validmind/vm_models/result/result.py +++ b/validmind/vm_models/result/result.py @@ -388,16 +388,18 @@ def remove_figure(self, index: int = 0): def to_html(self): """Generate HTML that persists in saved notebooks.""" - if self.metric is not None and not self.tables and not self.figures: + 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=self.metric + test_name=self.test_name, passed=self.passed, metric=metric_value ) html_parts = [StatefulHTMLRenderer.get_base_css()] html_parts.append( StatefulHTMLRenderer.render_result_header( - test_name=self.test_name, passed=self.passed, metric=self.metric + test_name=self.test_name, passed=self.passed, metric=metric_value ) ) From dd810ebcd63df86bed92551ab58b07f7d15c585d Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Wed, 10 Dec 2025 15:09:40 -0800 Subject: [PATCH 06/11] Support for VALIDMIND_INTERACTIVE_FIGURES --- poetry.lock | 162 +++++++++++++++++++--------------- tests/test_results.py | 55 ++++++++++++ validmind/vm_models/figure.py | 10 ++- 3 files changed, 156 insertions(+), 71 deletions(-) diff --git a/poetry.lock b/poetry.lock index 325d47776..ad341f608 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "aiodns" @@ -706,10 +706,6 @@ files = [ {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"}, {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"}, {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2"}, - {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec"}, {file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"}, {file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"}, {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"}, @@ -722,14 +718,8 @@ files = [ {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"}, {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"}, {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0"}, - {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b"}, {file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"}, {file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"}, - {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28"}, - {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f"}, {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"}, {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"}, {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"}, @@ -740,24 +730,8 @@ files = [ {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"}, {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"}, {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111"}, - {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839"}, {file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"}, {file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"}, - {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5"}, - {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0"}, - {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284"}, - {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7"}, - {file = "Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0"}, - {file = "Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b"}, {file = "Brotli-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1"}, {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d"}, {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b"}, @@ -767,10 +741,6 @@ files = [ {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2"}, {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354"}, {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2"}, - {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52"}, {file = "Brotli-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460"}, {file = "Brotli-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579"}, {file = "Brotli-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c"}, @@ -782,10 +752,6 @@ files = [ {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74"}, {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b"}, {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38"}, - {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c"}, {file = "Brotli-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95"}, {file = "Brotli-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68"}, {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3"}, @@ -798,10 +764,6 @@ files = [ {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a"}, {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088"}, {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943"}, - {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a"}, {file = "Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b"}, {file = "Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0"}, {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"}, @@ -814,10 +776,6 @@ files = [ {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"}, {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"}, {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f"}, - {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb"}, {file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"}, {file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"}, {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, @@ -978,6 +936,7 @@ files = [ {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] +markers = {dev = "sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\""} [package.dependencies] pycparser = "*" @@ -1161,7 +1120,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\""} +markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "comm" @@ -2650,7 +2609,7 @@ files = [ {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, ] -markers = {main = "platform_system == \"Linux\" and python_version == \"3.9\" and platform_machine == \"x86_64\" and (extra == \"llm\" or extra == \"all\" or extra == \"pytorch\" or extra == \"nlp\") or extra == \"llm\""} +markers = {main = "platform_system == \"Linux\" and python_version < \"3.10\" and platform_machine == \"x86_64\" and (extra == \"llm\" or extra == \"all\" or extra == \"pytorch\" or extra == \"nlp\") or extra == \"llm\""} [package.dependencies] zipp = ">=3.20" @@ -2671,7 +2630,7 @@ description = "Read resources from Python packages" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_version == \"3.9\"" +markers = "python_version < \"3.10\"" files = [ {file = "importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec"}, {file = "importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c"}, @@ -5238,7 +5197,7 @@ description = "NVIDIA Collective Communication Library (NCCL) Runtime" optional = true python-versions = ">=3" groups = ["main"] -markers = "platform_system == \"Linux\" and platform_machine != \"aarch64\" and (platform_machine == \"x86_64\" or extra == \"all\" or extra == \"xgboost\") and (extra == \"all\" or extra == \"llm\" or extra == \"pytorch\" or extra == \"nlp\" or extra == \"xgboost\")" +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (extra == \"all\" or extra == \"llm\" or extra == \"pytorch\" or extra == \"nlp\" or extra == \"xgboost\") or platform_system == \"Linux\" and platform_machine != \"aarch64\" and (extra == \"all\" or extra == \"xgboost\") and (extra == \"all\" or extra == \"xgboost\" or extra == \"llm\" or extra == \"pytorch\" or extra == \"nlp\") and (extra == \"all\" or extra == \"xgboost\" or extra == \"llm\" or extra == \"pytorch\")" files = [ {file = "nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9ddf1a245abc36c550870f26d537a9b6087fb2e2e3d6e0ef03374c6fd19d984f"}, {file = "nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adf27ccf4238253e0b826bce3ff5fa532d65fc42322c8bfdfaf28024c0fbe039"}, @@ -6181,23 +6140,6 @@ cryptography = "<44.1" [package.extras] server = ["flask (>=1.1)", "gunicorn"] -[[package]] -name = "presidio-structured" -version = "0.0.4a0" -description = "Presidio structured package - analyzes and anonymizes structured and semi-structured data." -optional = true -python-versions = "<4.0,>=3.9" -groups = ["main"] -markers = "python_version < \"3.11\" and extra == \"pii-detection\"" -files = [ - {file = "presidio_structured-0.0.4a0-py3-none-any.whl", hash = "sha256:7cc63b48038a177684cb9512d481571814c04331a0f4ddeb09299cc76803258b"}, -] - -[package.dependencies] -pandas = ">=1.5.2" -presidio-analyzer = ">=2.2" -presidio-anonymizer = ">=2.2" - [[package]] name = "presidio-structured" version = "0.0.6" @@ -6205,7 +6147,7 @@ description = "Presidio structured package - analyzes and anonymizes structured optional = true python-versions = "<4.0,>=3.9" groups = ["main"] -markers = "python_version >= \"3.11\" and extra == \"pii-detection\"" +markers = "extra == \"pii-detection\"" files = [ {file = "presidio_structured-0.0.6-py3-none-any.whl", hash = "sha256:f3454c86857a00db9828e684895da43411bcc7d750cac0a52e15d68f6c6455a1"}, ] @@ -6214,6 +6156,7 @@ files = [ pandas = ">=1.5.2" presidio-analyzer = ">=2.2" presidio-anonymizer = ">=2.2" +spacy = {version = "<3.8.4", markers = "python_version < \"3.10\""} [[package]] name = "prometheus-client" @@ -6733,6 +6676,7 @@ files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] +markers = {dev = "sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\""} [[package]] name = "pydantic" @@ -8349,7 +8293,7 @@ files = [ {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, ] -markers = {main = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (extra == \"all\" or extra == \"llm\" or extra == \"pytorch\" or extra == \"nlp\" or extra == \"pii-detection\") or python_version == \"3.12\" and (extra == \"llm\" or extra == \"pii-detection\" or extra == \"all\" or extra == \"pytorch\" or extra == \"nlp\") or extra == \"llm\" or extra == \"pii-detection\""} +markers = {main = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (extra == \"all\" or extra == \"llm\" or extra == \"pytorch\" or extra == \"nlp\" or extra == \"pii-detection\") or (extra == \"llm\" or extra == \"pii-detection\") and (extra == \"llm\" or extra == \"pii-detection\" or extra == \"all\" or extra == \"pytorch\" or extra == \"nlp\") and (extra == \"llm\" or extra == \"pii-detection\" or extra == \"all\" or extra == \"pytorch\") or python_version >= \"3.12\" and (extra == \"llm\" or extra == \"pii-detection\" or extra == \"all\" or extra == \"pytorch\" or extra == \"nlp\")"} [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] @@ -8523,6 +8467,86 @@ files = [ {file = "soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a"}, ] +[[package]] +name = "spacy" +version = "3.8.3" +description = "Industrial-strength Natural Language Processing (NLP) in Python" +optional = true +python-versions = "<3.13,>=3.9" +groups = ["main"] +markers = "python_version < \"3.11\" and extra == \"pii-detection\"" +files = [ + {file = "spacy-3.8.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b530a5cbb077601d03bdd71bf1ded4de4b7fb0362b5443c5183c628cfa81ffdc"}, + {file = "spacy-3.8.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b28a5f7b77400ebf7e23aa24a82a2d35f97071cd5ef1ad0f859aa9b323fff59a"}, + {file = "spacy-3.8.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcfd24a00da30ca53570f5b1c3535c1fa95b633f2a12b3d08395c9552ffb53c"}, + {file = "spacy-3.8.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3630ea33608a6db8045fad7e0ba22f864c61ea351445488a89af1734e434a37"}, + {file = "spacy-3.8.3-cp310-cp310-win_amd64.whl", hash = "sha256:20839fa04cc2156ab613e40db54c25031304fdc1dd369930bc01c366586d0079"}, + {file = "spacy-3.8.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b16b8f9c544cdccd1bd23fc6bf6e2f1d667a1ee285a9b31bdb4a89e2d61345b4"}, + {file = "spacy-3.8.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f62e45a2259acc51cd8eb185f978848928f2f698ba174b283253485fb7691b04"}, + {file = "spacy-3.8.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57a267ea25dd8b7ec3e55accd1592d2d0847f0c6277a55145af5bb08e318bab4"}, + {file = "spacy-3.8.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45bc5fc8d399089607e3e759aee98362ffb007e39386531f195f42dcddcc94dc"}, + {file = "spacy-3.8.3-cp311-cp311-win_amd64.whl", hash = "sha256:9e348359d54418a5752305975f1268013135255bd656a783aa3397b3bd4dd5e9"}, + {file = "spacy-3.8.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b01e50086515fa6d43275be11a762a3a3285d9aabbe27b4f3b98a08083f1d2a1"}, + {file = "spacy-3.8.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:187f9732362d0dc52b16c80e67decf58ff91605e34b251c50c7dc5212082fcb4"}, + {file = "spacy-3.8.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7517bc969bca924cbdba4e14e0ce16e66d32967468ad27490e95c9b4d8d8aa8"}, + {file = "spacy-3.8.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:460948437c5571367105554b1e67549f957ba8dd6ee7e1594e719f9a88c398bb"}, + {file = "spacy-3.8.3-cp312-cp312-win_amd64.whl", hash = "sha256:1f14d4e2b1e6ab144ee546236f2c32b255f91f24939e62436c3a9c2ee200c6d1"}, + {file = "spacy-3.8.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f6020603633ec47374af71e936671d5992d68e592661dffac940f5596d77696"}, + {file = "spacy-3.8.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:72b492651534460bf4fe842f7efa462887f9e215de86146b862df6238b952650"}, + {file = "spacy-3.8.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a630119aaa7a6180635eb8f21b27509654882847480c8423a657582b4a9bdd3"}, + {file = "spacy-3.8.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8563ba9cbb71a629c7dc8c2db98f0348416dc0f0927de0e9ed8b448f707b5248"}, + {file = "spacy-3.8.3-cp39-cp39-win_amd64.whl", hash = "sha256:608beca075f7611083e93c91625d7e6c5885e2672cb5ec1b9f274cab6c82c816"}, + {file = "spacy-3.8.3.tar.gz", hash = "sha256:81a967dc3d6a5a0a9ab250559483fe2092306582a9192f98be7a63bdce2797f7"}, +] + +[package.dependencies] +catalogue = ">=2.0.6,<2.1.0" +cymem = ">=2.0.2,<2.1.0" +jinja2 = "*" +langcodes = ">=3.2.0,<4.0.0" +murmurhash = ">=0.28.0,<1.1.0" +numpy = {version = ">=1.19.0", markers = "python_version >= \"3.9\""} +packaging = ">=20.0" +preshed = ">=3.0.2,<3.1.0" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<3.0.0" +requests = ">=2.13.0,<3.0.0" +setuptools = "*" +spacy-legacy = ">=3.0.11,<3.1.0" +spacy-loggers = ">=1.0.0,<2.0.0" +srsly = ">=2.4.3,<3.0.0" +thinc = ">=8.3.0,<8.4.0" +tqdm = ">=4.38.0,<5.0.0" +typer = ">=0.3.0,<1.0.0" +wasabi = ">=0.9.1,<1.2.0" +weasel = ">=0.1.0,<0.5.0" + +[package.extras] +apple = ["thinc-apple-ops (>=1.0.0,<2.0.0)"] +cuda = ["cupy (>=5.0.0b4,<13.0.0)"] +cuda-autodetect = ["cupy-wheel (>=11.0.0,<13.0.0)"] +cuda100 = ["cupy-cuda100 (>=5.0.0b4,<13.0.0)"] +cuda101 = ["cupy-cuda101 (>=5.0.0b4,<13.0.0)"] +cuda102 = ["cupy-cuda102 (>=5.0.0b4,<13.0.0)"] +cuda110 = ["cupy-cuda110 (>=5.0.0b4,<13.0.0)"] +cuda111 = ["cupy-cuda111 (>=5.0.0b4,<13.0.0)"] +cuda112 = ["cupy-cuda112 (>=5.0.0b4,<13.0.0)"] +cuda113 = ["cupy-cuda113 (>=5.0.0b4,<13.0.0)"] +cuda114 = ["cupy-cuda114 (>=5.0.0b4,<13.0.0)"] +cuda115 = ["cupy-cuda115 (>=5.0.0b4,<13.0.0)"] +cuda116 = ["cupy-cuda116 (>=5.0.0b4,<13.0.0)"] +cuda117 = ["cupy-cuda117 (>=5.0.0b4,<13.0.0)"] +cuda11x = ["cupy-cuda11x (>=11.0.0,<13.0.0)"] +cuda12x = ["cupy-cuda12x (>=11.5.0,<13.0.0)"] +cuda80 = ["cupy-cuda80 (>=5.0.0b4,<13.0.0)"] +cuda90 = ["cupy-cuda90 (>=5.0.0b4,<13.0.0)"] +cuda91 = ["cupy-cuda91 (>=5.0.0b4,<13.0.0)"] +cuda92 = ["cupy-cuda92 (>=5.0.0b4,<13.0.0)"] +ja = ["sudachidict_core (>=20211220)", "sudachipy (>=0.5.2,!=0.6.1)"] +ko = ["natto-py (>=0.9.0)"] +lookups = ["spacy_lookups_data (>=1.0.3,<1.1.0)"] +th = ["pythainlp (>=2.0)"] +transformers = ["spacy_transformers (>=1.1.2,<1.4.0)"] + [[package]] name = "spacy" version = "3.8.7" @@ -8530,7 +8554,7 @@ description = "Industrial-strength Natural Language Processing (NLP) in Python" optional = true python-versions = "<3.14,>=3.9" groups = ["main"] -markers = "extra == \"pii-detection\"" +markers = "python_version >= \"3.11\" and extra == \"pii-detection\"" files = [ {file = "spacy-3.8.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6ec0368ce96cd775fb14906f04b771c912ea8393ba30f8b35f9c4dc47a420b8e"}, {file = "spacy-3.8.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5672f8a0fe7a3847e925544890be60015fbf48a60a838803425f82e849dd4f18"}, @@ -10419,7 +10443,7 @@ files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, ] -markers = {main = "extra == \"llm\" or python_version == \"3.9\""} +markers = {main = "extra == \"llm\" or python_version < \"3.10\""} [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] @@ -10558,4 +10582,4 @@ xgboost = ["xgboost"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.13" -content-hash = "025dbfea51cfe322f2f1bfce521c72eb20b8106e5e77221d480a7af9bcaf9f76" +content-hash = "4733b4bd0861cc08d173af08b92ae2694c58e6be4f3583749d296c7e336699aa" diff --git a/tests/test_results.py b/tests/test_results.py index 89e7dc564..0eafb0679 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -1,8 +1,10 @@ import asyncio +import os import unittest from unittest.mock import patch import pandas as pd import matplotlib.pyplot as plt +import plotly.graph_objects as go from validmind.vm_models.result import ( TestResult, @@ -312,6 +314,59 @@ def test_test_result_metric_values_html_display(self): # Check that the list values appear in the HTML 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__": unittest.main() diff --git a/validmind/vm_models/figure.py b/validmind/vm_models/figure.py index b7d8693b2..692c78a7c 100644 --- a/validmind/vm_models/figure.py +++ b/validmind/vm_models/figure.py @@ -8,6 +8,7 @@ import base64 import json +import os from dataclasses import dataclass from io import BytesIO from typing import Union @@ -86,8 +87,13 @@ def to_html(self): elif is_plotly_figure(self.figure): png_file = self.figure.to_image(format="png") encoded = base64.b64encode(png_file).decode("utf-8") - # Add plotly-specific metadata - metadata["plotly_json"] = self.figure.to_json() + # 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): From 50f237bcbb84f17ceca17a61491fc54b5fe69a23 Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Wed, 10 Dec 2025 15:35:03 -0800 Subject: [PATCH 07/11] 2.11.0 --- pyproject.toml | 2 +- validmind/__version__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8ff8c43a4..d06bae339 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/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" From d9e6c7bc798fad70092dc10fc30a53dc6140f53b Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Wed, 10 Dec 2025 16:46:07 -0800 Subject: [PATCH 08/11] Fix rendering of preview_template --- validmind/template.py | 67 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/validmind/template.py b/validmind/template.py index 52d347aab..ea5edd4df 100644 --- a/validmind/template.py +++ b/validmind/template.py @@ -2,6 +2,7 @@ # See the LICENSE file in the root of this repository for details. # SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial +import uuid from typing import Any, Dict, List, Optional, Type from .html_templates.content_blocks import ( @@ -10,14 +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", @@ -57,6 +58,53 @@ def _convert_sections_to_section_tree( return sorted(section_tree, key=lambda x: x.get("order", 9999)) +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"]] @@ -69,10 +117,19 @@ def _create_content_html(content: Dict[str, Any]) -> str: 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 failed_content_block_html.format(test_id=content["content_id"]) - - return test_html + # 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_html( From 514b429dff8d55649271585bb8130b797a0e5b6c Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Wed, 10 Dec 2025 16:50:17 -0800 Subject: [PATCH 09/11] Fix warnings on some tests --- validmind/tests/model_validation/sklearn/OverfitDiagnosis.py | 2 +- .../model_validation/sklearn/PopulationStabilityIndex.py | 4 ++-- .../tests/model_validation/sklearn/RobustnessDiagnosis.py | 2 ++ .../tests/model_validation/sklearn/WeakspotsDiagnosis.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) 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, From fa9fb641c48a3c2bda586a47ebd5f68e589c15de Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Mon, 15 Dec 2025 10:20:01 -0800 Subject: [PATCH 10/11] Restore lock changes --- poetry.lock | 162 +++++++++++++++++++++---------------------------- pyproject.toml | 1 + 2 files changed, 70 insertions(+), 93 deletions(-) diff --git a/poetry.lock b/poetry.lock index ad341f608..325d47776 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiodns" @@ -706,6 +706,10 @@ files = [ {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"}, {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"}, {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec"}, {file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"}, {file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"}, {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"}, @@ -718,8 +722,14 @@ files = [ {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"}, {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"}, {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b"}, {file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"}, {file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f"}, {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"}, {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"}, {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"}, @@ -730,8 +740,24 @@ files = [ {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"}, {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"}, {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839"}, {file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"}, {file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"}, + {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5"}, + {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7"}, + {file = "Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0"}, + {file = "Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b"}, {file = "Brotli-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1"}, {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d"}, {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b"}, @@ -741,6 +767,10 @@ files = [ {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2"}, {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354"}, {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52"}, {file = "Brotli-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460"}, {file = "Brotli-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579"}, {file = "Brotli-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c"}, @@ -752,6 +782,10 @@ files = [ {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74"}, {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b"}, {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c"}, {file = "Brotli-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95"}, {file = "Brotli-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68"}, {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3"}, @@ -764,6 +798,10 @@ files = [ {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a"}, {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088"}, {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a"}, {file = "Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b"}, {file = "Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0"}, {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"}, @@ -776,6 +814,10 @@ files = [ {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"}, {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"}, {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb"}, {file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"}, {file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"}, {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, @@ -936,7 +978,6 @@ files = [ {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] -markers = {dev = "sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\""} [package.dependencies] pycparser = "*" @@ -1120,7 +1161,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\""} [[package]] name = "comm" @@ -2609,7 +2650,7 @@ files = [ {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, ] -markers = {main = "platform_system == \"Linux\" and python_version < \"3.10\" and platform_machine == \"x86_64\" and (extra == \"llm\" or extra == \"all\" or extra == \"pytorch\" or extra == \"nlp\") or extra == \"llm\""} +markers = {main = "platform_system == \"Linux\" and python_version == \"3.9\" and platform_machine == \"x86_64\" and (extra == \"llm\" or extra == \"all\" or extra == \"pytorch\" or extra == \"nlp\") or extra == \"llm\""} [package.dependencies] zipp = ">=3.20" @@ -2630,7 +2671,7 @@ description = "Read resources from Python packages" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_version < \"3.10\"" +markers = "python_version == \"3.9\"" files = [ {file = "importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec"}, {file = "importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c"}, @@ -5197,7 +5238,7 @@ description = "NVIDIA Collective Communication Library (NCCL) Runtime" optional = true python-versions = ">=3" groups = ["main"] -markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (extra == \"all\" or extra == \"llm\" or extra == \"pytorch\" or extra == \"nlp\" or extra == \"xgboost\") or platform_system == \"Linux\" and platform_machine != \"aarch64\" and (extra == \"all\" or extra == \"xgboost\") and (extra == \"all\" or extra == \"xgboost\" or extra == \"llm\" or extra == \"pytorch\" or extra == \"nlp\") and (extra == \"all\" or extra == \"xgboost\" or extra == \"llm\" or extra == \"pytorch\")" +markers = "platform_system == \"Linux\" and platform_machine != \"aarch64\" and (platform_machine == \"x86_64\" or extra == \"all\" or extra == \"xgboost\") and (extra == \"all\" or extra == \"llm\" or extra == \"pytorch\" or extra == \"nlp\" or extra == \"xgboost\")" files = [ {file = "nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9ddf1a245abc36c550870f26d537a9b6087fb2e2e3d6e0ef03374c6fd19d984f"}, {file = "nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adf27ccf4238253e0b826bce3ff5fa532d65fc42322c8bfdfaf28024c0fbe039"}, @@ -6140,6 +6181,23 @@ cryptography = "<44.1" [package.extras] server = ["flask (>=1.1)", "gunicorn"] +[[package]] +name = "presidio-structured" +version = "0.0.4a0" +description = "Presidio structured package - analyzes and anonymizes structured and semi-structured data." +optional = true +python-versions = "<4.0,>=3.9" +groups = ["main"] +markers = "python_version < \"3.11\" and extra == \"pii-detection\"" +files = [ + {file = "presidio_structured-0.0.4a0-py3-none-any.whl", hash = "sha256:7cc63b48038a177684cb9512d481571814c04331a0f4ddeb09299cc76803258b"}, +] + +[package.dependencies] +pandas = ">=1.5.2" +presidio-analyzer = ">=2.2" +presidio-anonymizer = ">=2.2" + [[package]] name = "presidio-structured" version = "0.0.6" @@ -6147,7 +6205,7 @@ description = "Presidio structured package - analyzes and anonymizes structured optional = true python-versions = "<4.0,>=3.9" groups = ["main"] -markers = "extra == \"pii-detection\"" +markers = "python_version >= \"3.11\" and extra == \"pii-detection\"" files = [ {file = "presidio_structured-0.0.6-py3-none-any.whl", hash = "sha256:f3454c86857a00db9828e684895da43411bcc7d750cac0a52e15d68f6c6455a1"}, ] @@ -6156,7 +6214,6 @@ files = [ pandas = ">=1.5.2" presidio-analyzer = ">=2.2" presidio-anonymizer = ">=2.2" -spacy = {version = "<3.8.4", markers = "python_version < \"3.10\""} [[package]] name = "prometheus-client" @@ -6676,7 +6733,6 @@ files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] -markers = {dev = "sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\""} [[package]] name = "pydantic" @@ -8293,7 +8349,7 @@ files = [ {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, ] -markers = {main = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (extra == \"all\" or extra == \"llm\" or extra == \"pytorch\" or extra == \"nlp\" or extra == \"pii-detection\") or (extra == \"llm\" or extra == \"pii-detection\") and (extra == \"llm\" or extra == \"pii-detection\" or extra == \"all\" or extra == \"pytorch\" or extra == \"nlp\") and (extra == \"llm\" or extra == \"pii-detection\" or extra == \"all\" or extra == \"pytorch\") or python_version >= \"3.12\" and (extra == \"llm\" or extra == \"pii-detection\" or extra == \"all\" or extra == \"pytorch\" or extra == \"nlp\")"} +markers = {main = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (extra == \"all\" or extra == \"llm\" or extra == \"pytorch\" or extra == \"nlp\" or extra == \"pii-detection\") or python_version == \"3.12\" and (extra == \"llm\" or extra == \"pii-detection\" or extra == \"all\" or extra == \"pytorch\" or extra == \"nlp\") or extra == \"llm\" or extra == \"pii-detection\""} [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] @@ -8467,86 +8523,6 @@ files = [ {file = "soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a"}, ] -[[package]] -name = "spacy" -version = "3.8.3" -description = "Industrial-strength Natural Language Processing (NLP) in Python" -optional = true -python-versions = "<3.13,>=3.9" -groups = ["main"] -markers = "python_version < \"3.11\" and extra == \"pii-detection\"" -files = [ - {file = "spacy-3.8.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b530a5cbb077601d03bdd71bf1ded4de4b7fb0362b5443c5183c628cfa81ffdc"}, - {file = "spacy-3.8.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b28a5f7b77400ebf7e23aa24a82a2d35f97071cd5ef1ad0f859aa9b323fff59a"}, - {file = "spacy-3.8.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcfd24a00da30ca53570f5b1c3535c1fa95b633f2a12b3d08395c9552ffb53c"}, - {file = "spacy-3.8.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3630ea33608a6db8045fad7e0ba22f864c61ea351445488a89af1734e434a37"}, - {file = "spacy-3.8.3-cp310-cp310-win_amd64.whl", hash = "sha256:20839fa04cc2156ab613e40db54c25031304fdc1dd369930bc01c366586d0079"}, - {file = "spacy-3.8.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b16b8f9c544cdccd1bd23fc6bf6e2f1d667a1ee285a9b31bdb4a89e2d61345b4"}, - {file = "spacy-3.8.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f62e45a2259acc51cd8eb185f978848928f2f698ba174b283253485fb7691b04"}, - {file = "spacy-3.8.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57a267ea25dd8b7ec3e55accd1592d2d0847f0c6277a55145af5bb08e318bab4"}, - {file = "spacy-3.8.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45bc5fc8d399089607e3e759aee98362ffb007e39386531f195f42dcddcc94dc"}, - {file = "spacy-3.8.3-cp311-cp311-win_amd64.whl", hash = "sha256:9e348359d54418a5752305975f1268013135255bd656a783aa3397b3bd4dd5e9"}, - {file = "spacy-3.8.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b01e50086515fa6d43275be11a762a3a3285d9aabbe27b4f3b98a08083f1d2a1"}, - {file = "spacy-3.8.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:187f9732362d0dc52b16c80e67decf58ff91605e34b251c50c7dc5212082fcb4"}, - {file = "spacy-3.8.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7517bc969bca924cbdba4e14e0ce16e66d32967468ad27490e95c9b4d8d8aa8"}, - {file = "spacy-3.8.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:460948437c5571367105554b1e67549f957ba8dd6ee7e1594e719f9a88c398bb"}, - {file = "spacy-3.8.3-cp312-cp312-win_amd64.whl", hash = "sha256:1f14d4e2b1e6ab144ee546236f2c32b255f91f24939e62436c3a9c2ee200c6d1"}, - {file = "spacy-3.8.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f6020603633ec47374af71e936671d5992d68e592661dffac940f5596d77696"}, - {file = "spacy-3.8.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:72b492651534460bf4fe842f7efa462887f9e215de86146b862df6238b952650"}, - {file = "spacy-3.8.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a630119aaa7a6180635eb8f21b27509654882847480c8423a657582b4a9bdd3"}, - {file = "spacy-3.8.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8563ba9cbb71a629c7dc8c2db98f0348416dc0f0927de0e9ed8b448f707b5248"}, - {file = "spacy-3.8.3-cp39-cp39-win_amd64.whl", hash = "sha256:608beca075f7611083e93c91625d7e6c5885e2672cb5ec1b9f274cab6c82c816"}, - {file = "spacy-3.8.3.tar.gz", hash = "sha256:81a967dc3d6a5a0a9ab250559483fe2092306582a9192f98be7a63bdce2797f7"}, -] - -[package.dependencies] -catalogue = ">=2.0.6,<2.1.0" -cymem = ">=2.0.2,<2.1.0" -jinja2 = "*" -langcodes = ">=3.2.0,<4.0.0" -murmurhash = ">=0.28.0,<1.1.0" -numpy = {version = ">=1.19.0", markers = "python_version >= \"3.9\""} -packaging = ">=20.0" -preshed = ">=3.0.2,<3.1.0" -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<3.0.0" -requests = ">=2.13.0,<3.0.0" -setuptools = "*" -spacy-legacy = ">=3.0.11,<3.1.0" -spacy-loggers = ">=1.0.0,<2.0.0" -srsly = ">=2.4.3,<3.0.0" -thinc = ">=8.3.0,<8.4.0" -tqdm = ">=4.38.0,<5.0.0" -typer = ">=0.3.0,<1.0.0" -wasabi = ">=0.9.1,<1.2.0" -weasel = ">=0.1.0,<0.5.0" - -[package.extras] -apple = ["thinc-apple-ops (>=1.0.0,<2.0.0)"] -cuda = ["cupy (>=5.0.0b4,<13.0.0)"] -cuda-autodetect = ["cupy-wheel (>=11.0.0,<13.0.0)"] -cuda100 = ["cupy-cuda100 (>=5.0.0b4,<13.0.0)"] -cuda101 = ["cupy-cuda101 (>=5.0.0b4,<13.0.0)"] -cuda102 = ["cupy-cuda102 (>=5.0.0b4,<13.0.0)"] -cuda110 = ["cupy-cuda110 (>=5.0.0b4,<13.0.0)"] -cuda111 = ["cupy-cuda111 (>=5.0.0b4,<13.0.0)"] -cuda112 = ["cupy-cuda112 (>=5.0.0b4,<13.0.0)"] -cuda113 = ["cupy-cuda113 (>=5.0.0b4,<13.0.0)"] -cuda114 = ["cupy-cuda114 (>=5.0.0b4,<13.0.0)"] -cuda115 = ["cupy-cuda115 (>=5.0.0b4,<13.0.0)"] -cuda116 = ["cupy-cuda116 (>=5.0.0b4,<13.0.0)"] -cuda117 = ["cupy-cuda117 (>=5.0.0b4,<13.0.0)"] -cuda11x = ["cupy-cuda11x (>=11.0.0,<13.0.0)"] -cuda12x = ["cupy-cuda12x (>=11.5.0,<13.0.0)"] -cuda80 = ["cupy-cuda80 (>=5.0.0b4,<13.0.0)"] -cuda90 = ["cupy-cuda90 (>=5.0.0b4,<13.0.0)"] -cuda91 = ["cupy-cuda91 (>=5.0.0b4,<13.0.0)"] -cuda92 = ["cupy-cuda92 (>=5.0.0b4,<13.0.0)"] -ja = ["sudachidict_core (>=20211220)", "sudachipy (>=0.5.2,!=0.6.1)"] -ko = ["natto-py (>=0.9.0)"] -lookups = ["spacy_lookups_data (>=1.0.3,<1.1.0)"] -th = ["pythainlp (>=2.0)"] -transformers = ["spacy_transformers (>=1.1.2,<1.4.0)"] - [[package]] name = "spacy" version = "3.8.7" @@ -8554,7 +8530,7 @@ description = "Industrial-strength Natural Language Processing (NLP) in Python" optional = true python-versions = "<3.14,>=3.9" groups = ["main"] -markers = "python_version >= \"3.11\" and extra == \"pii-detection\"" +markers = "extra == \"pii-detection\"" files = [ {file = "spacy-3.8.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6ec0368ce96cd775fb14906f04b771c912ea8393ba30f8b35f9c4dc47a420b8e"}, {file = "spacy-3.8.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5672f8a0fe7a3847e925544890be60015fbf48a60a838803425f82e849dd4f18"}, @@ -10443,7 +10419,7 @@ files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, ] -markers = {main = "extra == \"llm\" or python_version < \"3.10\""} +markers = {main = "extra == \"llm\" or python_version == \"3.9\""} [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] @@ -10582,4 +10558,4 @@ xgboost = ["xgboost"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.13" -content-hash = "4733b4bd0861cc08d173af08b92ae2694c58e6be4f3583749d296c7e336699aa" +content-hash = "025dbfea51cfe322f2f1bfce521c72eb20b8106e5e77221d480a7af9bcaf9f76" diff --git a/pyproject.toml b/pyproject.toml index d06bae339..b171896cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ authors = [ ] dependencies = [ "aiohttp[speedups]", + "ipywidgets", "kaleido (>=0.2.1,!=0.2.1.post1,<1.0.0)", "matplotlib", "mistune (>=3.0.2,<4.0.0)", From 19bff7e601eb1082db2e63e0ec3ff326332b8751 Mon Sep 17 00:00:00 2001 From: Andres Rodriguez Date: Mon, 15 Dec 2025 14:53:26 -0800 Subject: [PATCH 11/11] Fix fallback support for Colab --- validmind/vm_models/html_renderer.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/validmind/vm_models/html_renderer.py b/validmind/vm_models/html_renderer.py index dbbb6f627..4ace01814 100644 --- a/validmind/vm_models/html_renderer.py +++ b/validmind/vm_models/html_renderer.py @@ -52,22 +52,22 @@ def render_figure( alt="ValidMind Figure {key}"/>""" if plotly_json: - # Prepare fallback image HTML for JavaScript (escape quotes, remove newlines) - img_html_escaped = img_html.replace("\n", "").replace("'", "\\'") plotly_cdn_url = StatefulHTMLRenderer.PLOTLY_CDN_URL - # Render interactive Plotly chart with static image fallback + # 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}