diff --git a/.gitignore b/.gitignore index 2044005..1905b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -134,4 +134,8 @@ notes.txt # Ruff .ruff_cache -.ruff_cache/* \ No newline at end of file +.ruff_cache/* + +# Claude Code +CLAUDE.md +plan.md \ No newline at end of file diff --git a/README.md b/README.md index 5752aa7..f9ffa5d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,32 @@ chart +## Plotting with DataFrames + +easychart works with pandas, Polars, PyArrow, and other dataframe libraries. Pandas is a dependency while other dataframe libraries are supported via [narwhals](https://github.com/narwhals-dev/narwhals). + +```python +import pandas as pd +import easychart + +# pandas Series uses index as x-axis +data = pd.Series([10, 20, 30], index=["a", "b", "c"], name="values") +chart = easychart.new() +chart.plot(data) +chart +``` + +```python +import polars as pl +import easychart + +# Polars DataFrame +data = pl.DataFrame({"x": [1, 2, 3], "y": [10, 20, 30]}) +chart = easychart.new() +chart.plot(data) +chart +``` + ## Documentation Complete documentation is hosted on [read the docs](https://easychart.readthedocs.io/en/latest/). Have a look at one of the [25+ example charts](https://easychart.readthedocs.io/en/latest/contents/examples/index.html). diff --git a/easychart/__init__.py b/easychart/__init__.py index 7633670..3bf06d7 100644 --- a/easychart/__init__.py +++ b/easychart/__init__.py @@ -2,6 +2,9 @@ import inspect import re +import narwhals.stable.v2 as nw +from narwhals.stable.v2 import dependencies as nw_dep + from easychart.config import config from easychart.models import Chart, Plot, Grid @@ -371,6 +374,12 @@ def heatmap( ------- easychart.Chart """ + # Convert non-pandas DataFrames (Polars, etc.) to pandas + # Heatmap relies heavily on pandas operations (.stack(), .rename_axis(), etc.) + # so convert to pandas first via narwhals + if not isinstance(data, pd.DataFrame) and nw_dep.is_into_dataframe(data): + data = nw.from_native(data, eager_only=True).to_pandas() + if isinstance(data, pd.DataFrame): if not ( isinstance(data.index, pd.DatetimeIndex) diff --git a/easychart/encoders.py b/easychart/encoders.py index 3834f17..af830af 100644 --- a/easychart/encoders.py +++ b/easychart/encoders.py @@ -4,6 +4,9 @@ import pandas as pd import datetime +import narwhals.stable.v2 as nw +from narwhals.stable.v2 import dependencies as nw_dep + def default(value): """ @@ -60,6 +63,16 @@ def default(value): if isinstance(value, (pd.DataFrame, pd.Series, pd.Index)): return value.values.tolist() + # Handle non-pandas DataFrames/Series (Polars, etc.) + # Since we already convert in series.py, this is a safety net + if nw_dep.is_into_series(value): + s = nw.from_native(value, series_only=True, eager_only=True) + return s.to_list() + + if nw_dep.is_into_dataframe(value): + df = nw.from_native(value, eager_only=True) + return df.rows() + if isinstance( value, ( diff --git a/easychart/models/series.py b/easychart/models/series.py index 442e293..272e603 100644 --- a/easychart/models/series.py +++ b/easychart/models/series.py @@ -2,6 +2,9 @@ import pandas as pd import collections +import narwhals.stable.v2 as nw +from narwhals.stable.v2 import dependencies as nw_dep + import easychart.internals as internals @@ -82,11 +85,16 @@ def append(self, data=None, **kwargs): if isinstance(kwargs["color"], int): kwargs["colorIndex"] = kwargs.pop("color") - if isinstance(data, pd.Series): + # Extract series name (works for pandas and polars) + if nw_dep.is_into_series(data): + s = nw.from_native(data, series_only=True, eager_only=True) if "name" not in kwargs: - kwargs["name"] = data.name + # Use None for empty names so Highcharts defaults to "Series 1" etc. + kwargs["name"] = s.name or None + # Data conversion if isinstance(data, (pd.Series, pd.DataFrame)): + # pandas: maintain current behavior (uses index by default) if "index" in kwargs: if isinstance(kwargs["index"], collections.abc.Iterable): data = data.values.tolist() @@ -98,6 +106,16 @@ def append(self, data=None, **kwargs): else: data = data.reset_index().values.tolist() + elif nw_dep.is_into_series(data): + # Non-pandas Series (Polars, etc.): no index concept, just values + s = nw.from_native(data, series_only=True, eager_only=True) + data = s.to_list() + + elif nw_dep.is_into_dataframe(data): + # Non-pandas DataFrame (Polars, PyArrow, etc.): convert to list of rows + df = nw.from_native(data, eager_only=True) + data = df.rows() + if "index" in kwargs and isinstance(kwargs["index"], collections.abc.Iterable): data = [ internals.flatten(i, v) for (i, v) in zip(kwargs.pop("index"), data) diff --git a/setup.py b/setup.py index 85594dc..d2df9fb 100644 --- a/setup.py +++ b/setup.py @@ -26,6 +26,7 @@ "jinja2", "requests", "ipython", + "narwhals>=2.0.0", ], include_package_data=True, ) diff --git a/tests/unit-tests/test_narwhals.py b/tests/unit-tests/test_narwhals.py new file mode 100644 index 0000000..1f4a454 --- /dev/null +++ b/tests/unit-tests/test_narwhals.py @@ -0,0 +1,98 @@ +import pytest +import pandas as pd + +pl = pytest.importorskip("polars") +pa = pytest.importorskip("pyarrow") + +import easychart +from easychart.encoders import default + + +class TestPolarsSupport: + """Tests for Polars DataFrame/Series support via narwhals""" + + def test_polars_series(self): + ps = pl.Series("test_series", [1, 2, 3]) + chart = easychart.new() + chart.plot(ps) + assert chart.series[0]["data"] == [1, 2, 3] + assert chart.series[0]["name"] == "test_series" + + def test_polars_series_unnamed(self): + ps = pl.Series([1, 2, 3]) + chart = easychart.new() + chart.plot(ps) + assert chart.series[0]["data"] == [1, 2, 3] + + def test_polars_dataframe(self): + df = pl.DataFrame({"a": [1, 2], "b": [3, 4]}) + chart = easychart.new() + chart.plot(df) + assert chart.series[0]["data"] == [(1, 3), (2, 4)] + + def test_polars_heatmap(self): + df = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9]}) + chart = easychart.heatmap(df) + assert chart.chart.type == "heatmap" + # Verify data was converted and plotted + assert len(chart.series[0]["data"]) == 9 # 3x3 grid + + +class TestPyArrowSupport: + """Tests for PyArrow Table support via narwhals""" + + def test_pyarrow_dataframe(self): + df = pa.table({"a": [1, 2], "b": [3, 4]}) + chart = easychart.new() + chart.plot(df) + assert chart.series[0]["data"] == [(1, 3), (2, 4)] + + def test_pyarrow_heatmap(self): + df = pa.table({"a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9]}) + chart = easychart.heatmap(df) + assert chart.chart.type == "heatmap" + assert len(chart.series[0]["data"]) == 9 # 3x3 grid + + +class TestPandasRegression: + """Ensure pandas behavior is unchanged""" + + def test_pandas_series_with_index(self): + ps = pd.Series([10, 20, 30], index=[0, 1, 2], name="pandas_test") + chart = easychart.new() + chart.plot(ps) + # Pandas includes index by default + assert chart.series[0]["data"] == [[0, 10], [1, 20], [2, 30]] + assert chart.series[0]["name"] == "pandas_test" + + def test_pandas_series_without_index(self): + ps = pd.Series([10, 20, 30], name="pandas_test") + chart = easychart.new() + chart.plot(ps, index=False) + assert chart.series[0]["data"] == [10, 20, 30] + + def test_pandas_dataframe(self): + df = pd.DataFrame({"a": [1, 2], "b": [3, 4]}) + chart = easychart.new() + chart.plot(df) + # Pandas includes index + assert chart.series[0]["data"] == [[0, 1, 3], [1, 2, 4]] + + +class TestEncoderSupport: + """Test JSON encoder handles Polars types""" + + def test_encoder_polars_series(self): + ps = pl.Series("test", [1, 2, 3]) + result = default(ps) + assert result == [1, 2, 3] + + def test_encoder_polars_dataframe(self): + df = pl.DataFrame({"a": [1, 2], "b": [3, 4]}) + result = default(df) + assert result == [(1, 3), (2, 4)] + + def test_encoder_pandas_still_works(self): + ps = pd.Series([1, 2, 3]) + result = default(ps) + assert result == [1, 2, 3]