diff --git a/marimo/_plugins/ui/_impl/tables/narwhals_table.py b/marimo/_plugins/ui/_impl/tables/narwhals_table.py index 41ffe317c45..f7fdebd0033 100644 --- a/marimo/_plugins/ui/_impl/tables/narwhals_table.py +++ b/marimo/_plugins/ui/_impl/tables/narwhals_table.py @@ -715,9 +715,37 @@ def _sanitize_table_value(self, value: Any) -> Any: # Handle Pillow images if DependencyManager.pillow.imported(): - from PIL import Image + try: + from PIL import Image + + if isinstance(value, Image.Image): + return io_to_data_url(value, "image/png") + except Exception: + LOGGER.debug( + "Unable to convert image to data URL", exc_info=True + ) - if isinstance(value, Image.Image): - return io_to_data_url(value, "image/png") + # Handle Matplotlib figures + if DependencyManager.matplotlib.imported(): + try: + import matplotlib.figure + from matplotlib.axes import Axes + + from marimo._output.formatting import as_html + from marimo._plugins.stateless.flex import vstack + + if isinstance(value, matplotlib.figure.Figure): + html = as_html(vstack([str(value), value])) + mimetype, data = html._mime_() + + if isinstance(value, Axes): + html = as_html(vstack([str(value), value])) + mimetype, data = html._mime_() + return {"mimetype": mimetype, "data": data} + except Exception: + LOGGER.debug( + "Error converting matplotlib figures to HTML", + exc_info=True, + ) return value diff --git a/marimo/_smoke_tests/pandas/pandas_subplots.py b/marimo/_smoke_tests/pandas/pandas_subplots.py new file mode 100644 index 00000000000..0187883908e --- /dev/null +++ b/marimo/_smoke_tests/pandas/pandas_subplots.py @@ -0,0 +1,122 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "pandas", +# "matplotlib", +# ] +# /// + +import marimo + +__generated_with = "0.17.0" +app = marimo.App(width="medium") + + +@app.cell +def _(): + import marimo as mo + return (mo,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md( + """ + # Issue #6893: Pandas Subplots Not Displaying + + This smoke test reproduces the issue where pandas DataFrame box plots + with `subplots=True` don't render properly in marimo. + + **Expected behavior**: Box plots should display as images + + **Actual behavior**: Only textual representation appears + + The issue occurs because `df.plot.box(subplots=True)` returns a numpy + ndarray of matplotlib Axes objects, which marimo doesn't currently format. + """ + ) + return + + +@app.cell +def _(): + import pandas as pd + import matplotlib + + # Load NYC taxi data from stable GitHub URL + taxi_url = ( + "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/taxis.csv" + ) + df = pd.read_csv(taxi_url) + df + return (df,) + + +@app.cell(hide_code=True) +def _(mo): + mo.md("""## Test Case 1: Box plot WITHOUT subplots (works correctly)""") + return + + +@app.cell +def _(df): + # This should work - returns a single Axes object + df[["distance", "total"]].plot.box() + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md( + """ + ## Test Case 2: Box plot WITH subplots (reproduces issue #6893) + + This is the exact scenario from the bug report. + """ + ) + return + + +@app.cell +def _(df): + # This reproduces the issue - returns ndarray of Axes + df[["distance", "total"]].plot.box(subplots=True) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md("""## Test Case 3: Multiple subplots with custom layout""") + return + + +@app.cell +def _(df): + # Test with 2x2 layout - also returns ndarray of Axes + df[["distance", "total", "fare", "tip"]].plot.box( + subplots=True, layout=(2, 2), figsize=(10, 8) + ) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md( + """ + ## Test Case 4: 1D array of subplots + + Test with a single row of subplots. + """ + ) + return + + +@app.cell +def _(df): + # Returns 1D ndarray of Axes + df[["fare", "tip", "tolls"]].plot.box(subplots=True, layout=(1, 3)) + return + + +if __name__ == "__main__": + app.run() diff --git a/marimo/_smoke_tests/sql/numpy_sql.py b/marimo/_smoke_tests/sql/numpy_sql.py new file mode 100644 index 00000000000..0b3e4eeca4a --- /dev/null +++ b/marimo/_smoke_tests/sql/numpy_sql.py @@ -0,0 +1,43 @@ +import marimo + +__generated_with = "0.17.0" +app = marimo.App(width="medium", sql_output="native") + + +@app.cell +def _(): + import marimo as mo + import pandas as pd + import numpy as np + import sqlglot + import pyarrow + return np, pd + + +@app.cell +def _(np, pd): + example_df = pd.DataFrame( + [ + [np.random.random(size=[2, 2]) for _col in range(2)] + for _row in range(3) + ], + columns=["A", "B"], + ) + return (example_df,) + + +@app.cell +def _(example_df): + import duckdb + + res = duckdb.sql( + f""" + SELECT COUNT(*) FROM example_df + """, + ) + print(res) + return + + +if __name__ == "__main__": + app.run() diff --git a/tests/_plugins/ui/_impl/tables/test_narwhals.py b/tests/_plugins/ui/_impl/tables/test_narwhals.py index 1feb8b99e3b..3a75ed755cc 100644 --- a/tests/_plugins/ui/_impl/tables/test_narwhals.py +++ b/tests/_plugins/ui/_impl/tables/test_narwhals.py @@ -1600,3 +1600,117 @@ def test_calculate_top_k_rows_cache_invalidation(df: Any) -> None: # Verify the actual results are different assert result1 != result2 + + +@pytest.mark.skipif(not HAS_DEPS, reason="optional dependencies not installed") +class TestSanitizeTableValue: + """Tests for the _sanitize_table_value method.""" + + def setUp(self) -> None: + import polars as pl + + self.data = pl.DataFrame({"A": [1, 2, 3]}) + self.manager = NarwhalsTableManager.from_dataframe(self.data) + + def test_sanitize_none(self) -> None: + """Test that None values are returned as-is.""" + manager = self._get_manager() + assert manager._sanitize_table_value(None) is None + + def test_sanitize_primitive_values(self) -> None: + """Test that primitive values are returned unchanged.""" + manager = self._get_manager() + assert manager._sanitize_table_value(42) == 42 + assert manager._sanitize_table_value("hello") == "hello" + assert manager._sanitize_table_value(3.14) == 3.14 + assert manager._sanitize_table_value(True) is True + + @pytest.mark.skipif( + not DependencyManager.pillow.has(), + reason="Pillow not installed", + ) + def test_sanitize_pillow_image(self) -> None: + """Test that Pillow images are converted to data URLs.""" + from PIL import Image + + manager = self._get_manager() + + # Create a simple test image + img = Image.new("RGB", (10, 10), color="red") + + result = manager._sanitize_table_value(img) + + # Verify it returns a data URL string + assert isinstance(result, str) + assert result.startswith("data:image/png;base64,") + + @pytest.mark.skipif( + not DependencyManager.matplotlib.has(), + reason="Matplotlib not installed", + ) + def test_sanitize_matplotlib_figure(self) -> None: + """Test that Matplotlib figures are returned unchanged (no conversion).""" + import matplotlib.pyplot as plt + + manager = self._get_manager() + + # Create a simple figure + fig, ax = plt.subplots() + ax.plot([1, 2, 3], [1, 2, 3]) + + result = manager._sanitize_table_value(fig) + + # Figure is currently returned unchanged because there's no return statement for figures + # (only for axes) + assert result == fig + + plt.close(fig) + + @pytest.mark.skipif( + not DependencyManager.matplotlib.has(), + reason="Matplotlib not installed", + ) + def test_sanitize_matplotlib_axes(self) -> None: + """Test that Matplotlib axes are converted to HTML.""" + import matplotlib.pyplot as plt + + manager = self._get_manager() + + # Create a simple axes + fig, ax = plt.subplots() + ax.plot([1, 2, 3], [1, 2, 3]) + + result = manager._sanitize_table_value(ax) + + # Verify it returns a dict with mimetype and data + assert isinstance(result, dict) + assert "mimetype" in result + assert "data" in result + + plt.close(fig) + + def test_sanitize_unsupported_types(self) -> None: + """Test that unsupported types are returned unchanged.""" + manager = self._get_manager() + + # Test various unsupported types + class CustomClass: + pass + + obj = CustomClass() + assert manager._sanitize_table_value(obj) == obj + + # Test dict + d = {"key": "value"} + assert manager._sanitize_table_value(d) == d + + # Test list + lst = [1, 2, 3] + assert manager._sanitize_table_value(lst) == lst + + def _get_manager(self) -> NarwhalsTableManager[Any]: + """Helper method to create a manager.""" + import polars as pl + + data = pl.DataFrame({"A": [1, 2, 3]}) + return NarwhalsTableManager.from_dataframe(data)