Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,8 @@ notes.txt

# Ruff
.ruff_cache
.ruff_cache/*
.ruff_cache/*

# Claude Code
CLAUDE.md
plan.md
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,32 @@ chart

<img src="https://raw.githubusercontent.com/dschenck/easychart/latest/docs/static/demo%20(1).svg"/>

## 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).
9 changes: 9 additions & 0 deletions easychart/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions easychart/encoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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,
(
Expand Down
22 changes: 20 additions & 2 deletions easychart/models/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"jinja2",
"requests",
"ipython",
"narwhals>=2.0.0",
],
include_package_data=True,
)
98 changes: 98 additions & 0 deletions tests/unit-tests/test_narwhals.py
Original file line number Diff line number Diff line change
@@ -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]