Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support a wider range of iterables in SchemaBase.to_dict #3501

Merged
merged 27 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1ff970c
feat: Support a wider range of iterables in `SchemaBase.to_dict`
dangotbanned Jul 24, 2024
1f3dfd3
test: Update validation errors to use non-iterable element type
dangotbanned Jul 24, 2024
3275c5e
fix: Prevent `to_dict` method being called on `pd.Series`
dangotbanned Jul 24, 2024
d37bacc
test: Add tests for iterables and ranges
dangotbanned Jul 24, 2024
84c599f
fix(typing): Ignore type errors for tests
dangotbanned Jul 24, 2024
438ddf9
refactor: Use `narwhals.stable.v1`
dangotbanned Jul 24, 2024
04c292a
Merge branch 'main' into to-dict-from-arraylike
dangotbanned Jul 27, 2024
7fe3929
Merge branch 'main' into to-dict-from-arraylike
dangotbanned Jul 29, 2024
6552880
Merge branch 'main' into to-dict-from-arraylike
dangotbanned Jul 30, 2024
c46333c
Merge branch 'main' into to-dict-from-arraylike
dangotbanned Jul 31, 2024
6840181
Merge branch 'main' into to-dict-from-arraylike
dangotbanned Aug 2, 2024
73c873c
Merge remote-tracking branch 'upstream/main' into to-dict-from-arraylike
dangotbanned Aug 2, 2024
4dbe986
Merge branch 'main' into to-dict-from-arraylike
dangotbanned Aug 4, 2024
e15bbed
test: Increase coverage in `test_to_dict_iterables`
dangotbanned Aug 5, 2024
755670b
docs: Add a doc for `test_to_dict_iterables`
dangotbanned Aug 5, 2024
bc4f4aa
revert: Change `test_chart_validation_errors` back to demonstrate fai…
dangotbanned Aug 5, 2024
7e57e34
docs: Update User Guide to use `Sequence`
dangotbanned Aug 5, 2024
009b040
Merge branch 'main' into to-dict-from-arraylike
dangotbanned Aug 7, 2024
f9feebc
test: Fix `test_chart_validation_errors` failure verbosity
dangotbanned Aug 8, 2024
f335404
refactor: Move `inspect.cleandoc` inside of `test_chart_validation_er…
dangotbanned Aug 8, 2024
bbe33d4
revert: Restore original fix to `test_chart_validation_errors`
dangotbanned Aug 8, 2024
877b8d8
Merge branch 'main' into to-dict-from-arraylike
dangotbanned Aug 8, 2024
3c7e244
Merge branch 'main' into to-dict-from-arraylike
dangotbanned Aug 8, 2024
e07b953
docs: Remove "ordered" descriptor from `Sequence`
dangotbanned Aug 9, 2024
301e8f7
test: Remove missed `inspect.cleandoc`
dangotbanned Aug 9, 2024
8739239
test: Only modify message, not input to `test_chart_validation_errors`
dangotbanned Aug 9, 2024
1575e1e
style: fix oddly formatted `test_multiple_field_strings_in_condition`
dangotbanned Aug 9, 2024
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
23 changes: 22 additions & 1 deletion altair/utils/schemapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import jsonschema
import jsonschema.exceptions
import jsonschema.validators
import narwhals.stable.v1 as nw
from packaging.version import Version

# This leads to circular imports with the vegalite module. Currently, this works
Expand Down Expand Up @@ -488,6 +489,14 @@ def _subclasses(cls: type[Any]) -> Iterator[type[Any]]:
yield cls


def _from_array_like(obj: Iterable[Any], /) -> list[Any]:
try:
ser = nw.from_native(obj, strict=True, series_only=True)
return ser.to_list()
except TypeError:
return list(obj)
joelostblom marked this conversation as resolved.
Show resolved Hide resolved


def _todict(obj: Any, context: dict[str, Any] | None, np_opt: Any, pd_opt: Any) -> Any:
"""Convert an object to a dict representation."""
if np_opt is not None:
Expand All @@ -512,10 +521,16 @@ def _todict(obj: Any, context: dict[str, Any] | None, np_opt: Any, pd_opt: Any)
for k, v in obj.items()
if v is not Undefined
}
elif hasattr(obj, "to_dict"):
elif (
hasattr(obj, "to_dict")
and (module_name := obj.__module__)
and module_name.startswith("altair")
dangotbanned marked this conversation as resolved.
Show resolved Hide resolved
):
return obj.to_dict()
elif pd_opt is not None and isinstance(obj, pd_opt.Timestamp):
return pd_opt.Timestamp(obj).isoformat()
elif _is_iterable(obj, exclude=(str, bytes)):
return _todict(_from_array_like(obj), context, np_opt, pd_opt)
else:
return obj

Expand Down Expand Up @@ -1230,6 +1245,12 @@ def _is_list(obj: Any | list[Any]) -> TypeIs[list[Any]]:
return isinstance(obj, list)


def _is_iterable(
obj: Any, *, exclude: type | tuple[type, ...] = (str, bytes)
) -> TypeIs[Iterable[Any]]:
return not isinstance(obj, exclude) and isinstance(obj, Iterable)


def _passthrough(*args: Any, **kwds: Any) -> Any | dict[str, Any]:
return args[0] if args else kwds

Expand Down
55 changes: 47 additions & 8 deletions tests/utils/test_schemapi.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
# ruff: noqa: W291
from __future__ import annotations

import copy
import inspect
import io
import json
import pickle
import warnings
from collections import deque
from functools import partial

import jsonschema
import jsonschema.exceptions
import numpy as np
import pandas as pd
import polars as pl
import pytest

import altair as alt
Expand Down Expand Up @@ -524,23 +529,23 @@ def chart_error_example__wrong_tooltip_type_in_faceted_chart():
return (
alt.Chart(pd.DataFrame({"a": [1]}))
.mark_point()
.encode(tooltip=[{"wrong"}])
.encode(tooltip=[{5000}])
.facet()
)


def chart_error_example__wrong_tooltip_type_in_layered_chart():
# Error: Wrong data type to pass to tooltip
return alt.layer(
alt.Chart().mark_point().encode(tooltip=[{"wrong"}]),
alt.Chart().mark_point().encode(tooltip=[{5000}]),
dangotbanned marked this conversation as resolved.
Show resolved Hide resolved
)


def chart_error_example__two_errors_in_layered_chart():
# Error 1: Wrong data type to pass to tooltip
# Error 2: `Color` has no parameter named 'invalidArgument'
return alt.layer(
alt.Chart().mark_point().encode(tooltip=[{"wrong"}]),
alt.Chart().mark_point().encode(tooltip=[{5000}]),
alt.Chart().mark_line().encode(alt.Color(invalidArgument="unknown")),
)

Expand Down Expand Up @@ -656,21 +661,21 @@ def chart_error_example__four_errors():
(
chart_error_example__wrong_tooltip_type_in_faceted_chart,
inspect.cleandoc(
r"""'{'wrong'}' is an invalid value for `field`. Valid values are of type 'string' or 'object'.$"""
r"""'\[5000\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.$"""
),
),
(
chart_error_example__wrong_tooltip_type_in_layered_chart,
inspect.cleandoc(
r"""'{'wrong'}' is an invalid value for `field`. Valid values are of type 'string' or 'object'.$"""
r"""'\[5000\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.$"""
),
),
(
chart_error_example__two_errors_in_layered_chart,
inspect.cleandoc(
r"""Multiple errors were found.

Error 1: '{'wrong'}' is an invalid value for `field`. Valid values are of type 'string' or 'object'.
Error 1: '\[5000\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.

Error 2: `Color` has no parameter named 'invalidArgument'

Expand All @@ -687,7 +692,7 @@ def chart_error_example__four_errors():
inspect.cleandoc(
r"""Multiple errors were found.

Error 1: '{'wrong'}' is an invalid value for `field`. Valid values are of type 'string' or 'object'.
Error 1: '\[5000\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.

Error 2: '4' is an invalid value for `bandPosition`. Valid values are of type 'number'.$"""
),
Expand All @@ -697,7 +702,7 @@ def chart_error_example__four_errors():
inspect.cleandoc(
r"""Multiple errors were found.

Error 1: '{'wrong'}' is an invalid value for `field`. Valid values are of type 'string' or 'object'.
Error 1: '\[5000\]' is an invalid value for `field`. Valid values are of type 'string' or 'object'.

Error 2: `Color` has no parameter named 'invalidArgument'

Expand Down Expand Up @@ -948,3 +953,37 @@ def test_to_dict_expand_mark_spec():
chart = alt.Chart().mark_bar()
assert chart.to_dict()["mark"] == {"type": "bar"}
assert chart.mark == "bar"


@pytest.mark.parametrize(
"expected",
[list("cdfabe"), [0, 3, 4, 5, 8]],
)
@pytest.mark.parametrize(
"tp",
[
tuple,
list,
deque,
pl.Series,
pd.Series,
pd.Index,
pd.Categorical,
pd.CategoricalIndex,
np.array,
],
)
dangotbanned marked this conversation as resolved.
Show resolved Hide resolved
def test_to_dict_iterables(tp, expected) -> None:
x_dict = alt.X("x:N", sort=tp(expected)).to_dict()
actual = x_dict["sort"] # type: ignore
assert actual == expected


@pytest.mark.parametrize(
"tp", [range, np.arange, partial(pl.int_range, eager=True), pd.RangeIndex]
)
def test_to_dict_range(tp) -> None:
expected = [0, 1, 2, 3, 4]
x_dict = alt.X("x:O", sort=tp(0, 5)).to_dict()
actual = x_dict["sort"] # type: ignore
assert actual == expected
23 changes: 22 additions & 1 deletion tools/schemapi/schemapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import jsonschema
import jsonschema.exceptions
import jsonschema.validators
import narwhals.stable.v1 as nw
from packaging.version import Version

# This leads to circular imports with the vegalite module. Currently, this works
Expand Down Expand Up @@ -486,6 +487,14 @@ def _subclasses(cls: type[Any]) -> Iterator[type[Any]]:
yield cls


def _from_array_like(obj: Iterable[Any], /) -> list[Any]:
try:
ser = nw.from_native(obj, strict=True, series_only=True)
return ser.to_list()
except TypeError:
return list(obj)


def _todict(obj: Any, context: dict[str, Any] | None, np_opt: Any, pd_opt: Any) -> Any:
"""Convert an object to a dict representation."""
if np_opt is not None:
Expand All @@ -510,10 +519,16 @@ def _todict(obj: Any, context: dict[str, Any] | None, np_opt: Any, pd_opt: Any)
for k, v in obj.items()
if v is not Undefined
}
elif hasattr(obj, "to_dict"):
elif (
hasattr(obj, "to_dict")
and (module_name := obj.__module__)
and module_name.startswith("altair")
):
return obj.to_dict()
elif pd_opt is not None and isinstance(obj, pd_opt.Timestamp):
return pd_opt.Timestamp(obj).isoformat()
elif _is_iterable(obj, exclude=(str, bytes)):
return _todict(_from_array_like(obj), context, np_opt, pd_opt)
else:
return obj

Expand Down Expand Up @@ -1228,6 +1243,12 @@ def _is_list(obj: Any | list[Any]) -> TypeIs[list[Any]]:
return isinstance(obj, list)


def _is_iterable(
obj: Any, *, exclude: type | tuple[type, ...] = (str, bytes)
joelostblom marked this conversation as resolved.
Show resolved Hide resolved
) -> TypeIs[Iterable[Any]]:
return not isinstance(obj, exclude) and isinstance(obj, Iterable)


def _passthrough(*args: Any, **kwds: Any) -> Any | dict[str, Any]:
return args[0] if args else kwds

Expand Down