diff --git a/plotnine/layer.py b/plotnine/layer.py index 47a85e928..2a40ebacd 100644 --- a/plotnine/layer.py +++ b/plotnine/layer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import decimal import typing from copy import copy, deepcopy from typing import Iterable, List, cast, overload @@ -257,6 +258,8 @@ def _make_layer_data(self, plot_data: DataLike | None): else: raise TypeError(f"Data has a bad type: {type(self.data)}") + self.data = _decimal_columns_to_float(self.data) + def _make_layer_mapping(self, plot_mapping: aes): """ Create the aesthetic mappings to be used by this layer @@ -778,3 +781,27 @@ def _resolve_position( raise PlotnineError(f"Unknown position of type {type(position_spec)}") return klass() + + +def _decimal_columns_to_float(data: pd.DataFrame) -> pd.DataFrame: + """ + Cast columns of decimal.Decimal values to float + + polars' `to_pandas()` maps a Decimal column onto a pandas object column + holding `decimal.Decimal` values. plotnine treats object columns as + discrete, so such a column cannot be used with a continuous scale. Casting + it to float lets it behave like any other numeric column. + + This runs for every dataframe, so we avoid the expense of `.dropna()` + (which builds a filtered copy) and use a mask to peek at the first + non-null value instead. + """ + for col in data.columns: + series = data[col] + if series.dtype == object: + mask = series.notna() + if mask.any() and isinstance( + series[mask.idxmax()], decimal.Decimal + ): + data[col] = series.astype("float64") + return data diff --git a/tests/test_ggplot_internals.py b/tests/test_ggplot_internals.py index 749e3ac7b..61f7ceb31 100644 --- a/tests/test_ggplot_internals.py +++ b/tests/test_ggplot_internals.py @@ -12,6 +12,7 @@ coord_trans, facet_null, geom_bar, + geom_col, geom_histogram, geom_line, geom_point, @@ -21,6 +22,7 @@ labs, lims, scale_x_continuous, + scale_y_continuous, stage, stat_identity, theme, @@ -365,6 +367,26 @@ def to_pandas(self): assert p2 == "to_pandas" +def test_decimal_columns(): + # A pandas object column of decimal.Decimal values (e.g. from polars' + # to_pandas()) would otherwise be treated as discrete and rejected by a + # continuous scale. See GH #1033. + from decimal import Decimal + + data = pd.DataFrame( + { + "x": ["a", "b", "c"], + "y": [Decimal(1), Decimal(2), Decimal(3)], + } + ) + p = ( + ggplot(data, aes("x", "y")) + + geom_col() + + scale_y_continuous(limits=(0, 5)) + ) + p._build() + + def test_callable_as_data(): def _fn(data): return data.rename(columns={"xx": "x", "yy": "y"})