Skip to content

Commit

Permalink
feat: Support datetime.(date|datetime) as a SchemaBase parameter (#…
Browse files Browse the repository at this point in the history
…3653)

* feat: Support `datetime.(date|datetime)` as a `SchemaBase` parameter

#3651

* test: Adds `test_to_dict_datetime`

* feat: Reject non-UTC timezones

The [vega-lite docs](https://vega.github.io/vega-lite/docs/datetime.html) don't mention the `utc` property - but this restricts us to `utc` or `None`

* test(typing): Adds `test_to_dict_datetime_typing`

Annotation support will be complete once `mypy` starts complaining that these comments are unused

* fix(typing): Remove unused ignore

https://github.com/vega/altair/actions/runs/11460348337/job/31886851940?pr=3653

* feat(typing): Adds `Temporal` alias

* build: run `generate-schema-wrapper`

**Expecting to fail `mypy`**

#3653 (comment)

* fix(typing): Remove unused ignores

#3653 (comment), https://github.com/vega/altair/actions/runs/11461315861/job/31890019636?pr=3653

* fix: Use object identity for `utc` test instead of name

Adds two tests with "fake" utc timezones

* refactor(typing): Narrow `selection_(interval|point)(value=...)` types

- Tentative
- Copied from `core.SelectionParameter`

* refactor(typing): Utilize `PrimitiveValue_T`

#3656

* refactor(typing): Factor out `_LiteralValue` alias

This was incomplete as it didn't include `None`, but was serving the same purpose as the generated alias

* feat(typing): Accurately type `selection_(interval|point)(value=...)`

Added some docs to the new types, since what they describe is somewhat complex

References:
- https://vega.github.io/vega-lite/docs/selection.html#current-limitations
- https://vega.github.io/vega-lite/docs/param-value.html
- https://vega.github.io/vega-lite/docs/selection.html#project
- https://github.com/vega/altair/blob/5a6f70dc193f7ed9c89e6cc22e95c9d885167939/altair/vegalite/v5/schema/vega-lite-schema.json#L23087-L23118

Resolves:
- #3653 (comment)
- #3653 (comment)

* test(typing): Add (failing) `test_selection_interval_value_typing`

Need to fix this, but adding this first to demonstrate the issue.
Currently the way I've typed it prevents using different types for each encoding.

The goal is for each encoding to restrict types independently.
E.g. here `x` is **only** `date`, `y` is **only** `float`

* fix(typing): Correct failing `selection_interval` typing

#3653 (comment)

* test(typing): Improve coverage for `test_selection_interval_value_typing`
  • Loading branch information
dangotbanned authored Oct 25, 2024
1 parent 5a6f70d commit f57df00
Show file tree
Hide file tree
Showing 10 changed files with 747 additions and 210 deletions.
31 changes: 31 additions & 0 deletions altair/utils/schemapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import contextlib
import copy
import datetime as dt
import inspect
import json
import sys
Expand Down Expand Up @@ -502,6 +503,34 @@ def _from_array_like(obj: Iterable[Any], /) -> list[Any]:
return list(obj)


def _from_date_datetime(obj: dt.date | dt.datetime, /) -> dict[str, Any]:
"""
Parse native `datetime.(date|datetime)` into a `DateTime`_ schema.
.. _DateTime:
https://vega.github.io/vega-lite/docs/datetime.html
"""
result: dict[str, Any] = {"year": obj.year, "month": obj.month, "date": obj.day}
if isinstance(obj, dt.datetime):
if obj.time() != dt.time.min:
us = obj.microsecond
ms = us if us == 0 else us // 1_000
result.update(
hours=obj.hour, minutes=obj.minute, seconds=obj.second, milliseconds=ms
)
if tzinfo := obj.tzinfo:
if tzinfo is dt.timezone.utc:
result["utc"] = True
else:
msg = (
f"Unsupported timezone {tzinfo!r}.\n"
"Only `'UTC'` or naive (local) datetimes are permitted.\n"
"See https://altair-viz.github.io/user_guide/generated/core/altair.DateTime.html"
)
raise TypeError(msg)
return result


def _todict(obj: Any, context: dict[str, Any] | None, np_opt: Any, pd_opt: Any) -> Any: # noqa: C901
"""Convert an object to a dict representation."""
if np_opt is not None:
Expand Down Expand Up @@ -532,6 +561,8 @@ def _todict(obj: Any, context: dict[str, Any] | None, np_opt: Any, pd_opt: Any)
return pd_opt.Timestamp(obj).isoformat()
elif _is_iterable(obj, exclude=(str, bytes)):
return _todict(_from_array_like(obj), context, np_opt, pd_opt)
elif isinstance(obj, dt.date):
return _from_date_datetime(obj)
else:
return obj

Expand Down
83 changes: 72 additions & 11 deletions altair/vegalite/v5/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import sys
import typing as t
import warnings
from collections.abc import Mapping, Sequence
from copy import deepcopy as _deepcopy
from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, overload

Expand All @@ -30,24 +31,32 @@
from .data import data_transformers
from .display import VEGA_VERSION, VEGAEMBED_VERSION, VEGALITE_VERSION, renderers
from .schema import SCHEMA_URL, channels, core, mixins
from .schema._typing import Map
from .schema._typing import Map, PrimitiveValue_T, SingleDefUnitChannel_T, Temporal
from .theme import themes

if sys.version_info >= (3, 14):
from typing import TypedDict
else:
from typing_extensions import TypedDict
if sys.version_info >= (3, 12):
from typing import Protocol, runtime_checkable
from typing import Protocol, TypeAliasType, runtime_checkable
else:
from typing_extensions import Protocol, runtime_checkable # noqa: F401
from typing_extensions import ( # noqa: F401
Protocol,
TypeAliasType,
runtime_checkable,
)
if sys.version_info >= (3, 11):
from typing import LiteralString
else:
from typing_extensions import LiteralString
if sys.version_info >= (3, 10):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias

if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
from collections.abc import Iterable, Iterator
from pathlib import Path
from typing import IO

Expand Down Expand Up @@ -85,7 +94,6 @@
ResolveMode_T,
SelectionResolution_T,
SelectionType_T,
SingleDefUnitChannel_T,
SingleTimeUnit_T,
StackOffset_T,
)
Expand All @@ -99,6 +107,7 @@
BindRadioSelect,
BindRange,
BinParams,
DateTime,
Expr,
ExprRef,
FacetedEncoding,
Expand Down Expand Up @@ -539,10 +548,8 @@ def check_fields_and_encodings(parameter: Parameter, field_name: str) -> bool:
```
"""

_LiteralValue: TypeAlias = Union[str, bool, float, int]
"""Primitive python value types."""

_FieldEqualType: TypeAlias = Union[_LiteralValue, Map, Parameter, SchemaBase]
_FieldEqualType: TypeAlias = Union[PrimitiveValue_T, Map, Parameter, SchemaBase]
"""Permitted types for equality checks on field values:
- `datum.field == ...`
Expand Down Expand Up @@ -633,7 +640,7 @@ class _ConditionExtra(TypedDict, closed=True, total=False): # type: ignore[call
param: Parameter | str
test: _TestPredicateType
value: Any
__extra_items__: _StatementType | OneOrSeq[_LiteralValue]
__extra_items__: _StatementType | OneOrSeq[PrimitiveValue_T]


_Condition: TypeAlias = _ConditionExtra
Expand Down Expand Up @@ -1436,9 +1443,63 @@ def selection(type: Optional[SelectionType_T] = Undefined, **kwds: Any) -> Param
return _selection(type=type, **kwds)


_SelectionPointValue: TypeAlias = "PrimitiveValue_T | Temporal | DateTime | Sequence[Mapping[SingleDefUnitChannel_T | LiteralString, PrimitiveValue_T | Temporal | DateTime]]"
"""
Point selections can be initialized with a single primitive value:
import altair as alt
alt.selection_point(fields=["year"], value=1980)
You can also provide a sequence of mappings between ``encodings`` or ``fields`` to **values**:
alt.selection_point(
fields=["cylinders", "year"],
value=[{"cylinders": 4, "year": 1981}, {"cylinders": 8, "year": 1972}],
)
"""

_SelectionIntervalValueMap: TypeAlias = Mapping[
SingleDefUnitChannel_T,
Union[
tuple[bool, bool],
tuple[float, float],
tuple[str, str],
tuple["Temporal | DateTime", "Temporal | DateTime"],
Sequence[bool],
Sequence[float],
Sequence[str],
Sequence["Temporal | DateTime"],
],
]
"""
Interval selections are initialized with a mapping between ``encodings`` to **values**:
import altair as alt
alt.selection_interval(
encodings=["longitude"],
empty=False,
value={"longitude": [-50, -110]},
)
The values specify the **start** and **end** of the interval selection.
You can use a ``tuple`` for type-checking each sequence has **two** elements:
alt.selection_interval(value={"x": (55, 160), "y": (13, 37)})
.. note::
Unlike :func:`.selection_point()`, the use of ``None`` is not permitted.
"""


def selection_interval(
name: str | None = None,
value: Optional[Any] = Undefined,
value: Optional[_SelectionIntervalValueMap] = Undefined,
bind: Optional[Binding | str] = Undefined,
empty: Optional[bool] = Undefined,
expr: Optional[str | Expr | Expression] = Undefined,
Expand Down Expand Up @@ -1551,7 +1612,7 @@ def selection_interval(

def selection_point(
name: str | None = None,
value: Optional[Any] = Undefined,
value: Optional[_SelectionPointValue] = Undefined,
bind: Optional[Binding | str] = Undefined,
empty: Optional[bool] = Undefined,
expr: Optional[Expr] = Undefined,
Expand Down
4 changes: 4 additions & 0 deletions altair/vegalite/v5/schema/_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import re
import sys
from collections.abc import Mapping, Sequence
from datetime import date, datetime
from typing import Annotated, Any, Generic, Literal, TypeVar, Union, get_args

if sys.version_info >= (3, 14): # https://peps.python.org/pep-0728/
Expand Down Expand Up @@ -87,6 +88,7 @@
"StepFor_T",
"StrokeCap_T",
"StrokeJoin_T",
"Temporal",
"TextBaseline_T",
"TextDirection_T",
"TimeInterval_T",
Expand Down Expand Up @@ -198,6 +200,8 @@ class PaddingKwds(TypedDict, total=False):
top: float


Temporal: TypeAlias = Union[date, datetime]

VegaThemes: TypeAlias = Literal[
"carbong10",
"carbong100",
Expand Down
Loading

0 comments on commit f57df00

Please sign in to comment.