Skip to content

feat(data frame): Add generic type support via render return value #1502

New issue

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

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

Already on GitHub? # to your account

Merged
merged 10 commits into from
Jul 11, 2024
7 changes: 6 additions & 1 deletion shiny/_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"Concatenate",
"ParamSpec",
"TypeGuard",
"TypeIs",
"Never",
"Required",
"NotRequired",
Expand Down Expand Up @@ -43,9 +44,13 @@
assert_type,
)

if sys.version_info >= (3, 13):
from typing import TypeIs
else:
from typing_extensions import TypeIs

# The only purpose of the following line is so that pyright will put all of the
# conditional imports into the .pyi file when generating type stubs. Without this line,
# pyright will not include the above imports in the generated .pyi file, and it will
# result in a lot of red squiggles in user code.
_: 'Annotated |Concatenate[str, ParamSpec("P")] | ParamSpec | TypeGuard | NotRequired | Required | TypedDict | assert_type | Self' # type:ignore
_: 'Annotated |Concatenate[str, ParamSpec("P")] | ParamSpec | TypeGuard | TypeIs | NotRequired | Required | TypedDict | assert_type | Self' # type:ignore
6 changes: 4 additions & 2 deletions shiny/render/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
data_frame,
)
from ._data_frame_utils import CellSelection
from ._data_frame_utils._types import DataFrameLike, StyleInfo
from ._data_frame_utils._types import ( # noqa: F401
StyleInfo,
DataFrameLikeT as _DataFrameLikeT, # pyright: ignore[reportUnusedImport]
)
from ._deprecated import ( # noqa: F401
RenderFunction, # pyright: ignore[reportUnusedImport]
RenderFunctionAsync, # pyright: ignore[reportUnusedImport]
Expand Down Expand Up @@ -48,5 +51,4 @@
"CellValue",
"CellSelection",
"StyleInfo",
"DataFrameLike",
)
83 changes: 42 additions & 41 deletions shiny/render/_data_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,9 @@

import warnings

# TODO-barret; Make DataFrameLikeT generic bound to DataFrameLike. Add this generic type to the DataGrid and DataTable
# TODO-barret; Should `.input_cell_selection()` ever return None? Is that value even helpful? Empty lists would be much more user friendly.
# * For next release: Agreed to remove `None` type.
# * For this release: Immediately make PR to remove `.input_` from `.input_cell_selection()`
# TODO-barret-render.data_frame; Docs
# TODO-barret-render.data_frame; Add examples!
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Literal, Union, cast
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Literal, Union, cast

from htmltools import Tag

Expand Down Expand Up @@ -36,18 +32,18 @@
)
from ._data_frame_utils._styles import as_browser_style_infos
from ._data_frame_utils._tbl_data import (
apply_frame_patches,
as_data_frame_like,
apply_frame_patches__typed,
frame_columns,
frame_shape,
serialize_dtype,
subset_frame,
subset_frame__typed,
)
from ._data_frame_utils._types import (
CellPatchProcessed,
ColumnFilter,
ColumnSort,
DataFrameLike,
DataFrameLikeT,
FrameDtype,
FrameRender,
cell_patch_processed_to_jsonifiable,
frame_render_to_jsonifiable,
Expand All @@ -59,7 +55,14 @@
if TYPE_CHECKING:
from ..session import Session

from ._data_frame_utils._datagridtable import DataFrameResult
DataFrameResult = Union[
None,
DataFrameLikeT,
"DataGrid[DataFrameLikeT]",
"DataTable[DataFrameLikeT]",
]
DataFrameValue = Union[None, DataGrid[DataFrameLikeT], DataTable[DataFrameLikeT]]


# # TODO-future; Use `dataframe-api-compat>=0.2.6` to injest dataframes and return standardized dataframe structures
# # TODO-future: Find this type definition: https://github.com/data-apis/dataframe-api-compat/blob/273c0be45962573985b3a420869d0505a3f9f55d/dataframe_api_compat/polars_standard/dataframe_object.py#L22
Expand Down Expand Up @@ -92,7 +95,7 @@


@add_example()
class data_frame(Renderer[DataFrameResult]):
class data_frame(Renderer[DataFrameResult[DataFrameLikeT]]):
"""
Decorator for a function that returns a pandas `DataFrame` object (or similar) to
render as an interactive table or grid. Features fast virtualized scrolling, sorting,
Expand Down Expand Up @@ -164,11 +167,11 @@ class data_frame(Renderer[DataFrameResult]):
objects you can return from the rendering function to specify options.
"""

_value: reactive.Value[DataFrameResult | None]
_value: reactive.Value[DataFrameValue[DataFrameLikeT] | None]
"""
Reactive value of the data frame's rendered object.
"""
_type_hints: reactive.Value[dict[str, str] | None]
_type_hints: reactive.Value[list[FrameDtype] | None]
"""
Reactive value of the data frame's type hints for each column.

Expand Down Expand Up @@ -206,7 +209,7 @@ class data_frame(Renderer[DataFrameResult]):
Reactive value of the data frame's edits provided by the user.
"""

data: reactive.Calc_[DataFrameLike]
data: reactive.Calc_[DataFrameLikeT]
"""
Reactive value of the data frame's output data.

Expand All @@ -217,17 +220,17 @@ class data_frame(Renderer[DataFrameResult]):
Even if the rendered data value was not of type `pd.DataFrame` or `pl.DataFrame`, this method currently
converts it to a `pd.DataFrame`.
"""
_data_view_all: reactive.Calc_[DataFrameLike]
_data_view_all: reactive.Calc_[DataFrameLikeT]
"""
Reactive value of the full (sorted and filtered) data.
"""
_data_view_selected: reactive.Calc_[DataFrameLike]
_data_view_selected: reactive.Calc_[DataFrameLikeT]
"""
Reactive value of the selected rows of the (sorted and filtered) data.
"""

@add_example(ex_dir="../api-examples/data_frame_data_view")
def data_view(self, *, selected: bool = False) -> DataFrameLike:
def data_view(self, *, selected: bool = False) -> DataFrameLikeT:
"""
Reactive function that retrieves the data how it is viewed within the browser.

Expand Down Expand Up @@ -299,7 +302,7 @@ def data_view(self, *, selected: bool = False) -> DataFrameLike:
The row numbers of the data frame that are currently being viewed in the browser
after sorting and filtering has been applied.
"""
_data_patched: reactive.Calc_[DataFrameLike]
_data_patched: reactive.Calc_[DataFrameLikeT]
"""
Reactive value of the data frame's patched data.

Expand Down Expand Up @@ -339,8 +342,10 @@ def _init_reactives(self) -> None:
from .. import req

# Init
self._value: reactive.Value[DataFrameResult | None] = reactive.Value(None)
self._type_hints: reactive.Value[dict[str, str] | None] = reactive.Value(None)
self._value: reactive.Value[DataFrameValue[DataFrameLikeT] | None] = (
reactive.Value(None)
)
self._type_hints: reactive.Value[list[FrameDtype] | None] = reactive.Value(None)
self._cell_patch_map = reactive.Value({})

@reactive.calc
Expand All @@ -350,7 +355,7 @@ def self_cell_patches() -> list[CellPatchProcessed]:
self.cell_patches = self_cell_patches

@reactive.calc
def self_data() -> DataFrameLike:
def self_data() -> DataFrameLikeT:
value = self._value()
req(value)

Expand Down Expand Up @@ -423,14 +428,14 @@ def self_data_view_rows() -> tuple[int, ...]:
self.data_view_rows = self_data_view_rows

@reactive.calc
def self__data_patched() -> DataFrameLike:
return apply_frame_patches(self.data(), self.cell_patches())
def self__data_patched() -> DataFrameLikeT:
return apply_frame_patches__typed(self.data(), self.cell_patches())

self._data_patched = self__data_patched

# Apply filtering and sorting
# https://github.com/posit-dev/py-shiny/issues/1240
def _subset_data_view(selected: bool) -> DataFrameLike:
def _subset_data_view(selected: bool) -> DataFrameLikeT:
"""
Helper method to subset data according to what is viewed in the browser;

Expand All @@ -454,15 +459,15 @@ def _subset_data_view(selected: bool) -> DataFrameLike:
else:
rows = self.data_view_rows()

return subset_frame(self._data_patched(), rows=rows)
return subset_frame__typed(self._data_patched(), rows=rows)

# Helper reactives so that internal calculations can be cached for use in other calculations
@reactive.calc
def self__data_view() -> DataFrameLike:
def self__data_view() -> DataFrameLikeT:
return _subset_data_view(selected=False)

@reactive.calc
def self__data_view_selected() -> DataFrameLike:
def self__data_view_selected() -> DataFrameLikeT:
return _subset_data_view(selected=True)

self._data_view_all = self__data_view
Expand Down Expand Up @@ -721,7 +726,7 @@ async def _attempt_update_cell_style(self) -> None:
def auto_output_ui(self) -> Tag:
return ui.output_data_frame(id=self.output_id)

def __init__(self, fn: ValueFn[DataFrameResult]):
def __init__(self, fn: ValueFn[DataFrameResult[DataFrameLikeT]]):
super().__init__(fn)

# Set reactives from calculated properties
Expand Down Expand Up @@ -758,26 +763,22 @@ async def render(self) -> JsonifiableDict | None:
return None

if not isinstance(value, AbstractTabularData):
value = DataGrid(
as_data_frame_like(
value,
"@render.data_frame doesn't know how to render objects of type",
)
)
try:
value = DataGrid(value)
except TypeError as e:
raise TypeError(
"@render.data_frame doesn't know how to render objects of type ",
type(value),
) from e

# Set patches url handler for client
patch_key = self._set_patches_handler()
self._value.set(value)
self._value.set(value) # pyright: ignore[reportArgumentType]

# Use session context so `to_payload()` gets the correct session
with session_context(self._get_session()):
payload = value.to_payload()

type_hints = cast(
Union[Dict[str, str], None],
payload.get("typeHints", None),
)
self._type_hints.set(type_hints)
self._type_hints.set(payload["typeHints"])

ret: FrameRender = {
"payload": payload,
Expand Down
45 changes: 18 additions & 27 deletions shiny/render/_data_frame_utils/_datagridtable.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
from __future__ import annotations

import abc

# TODO-barret-future; make DataTable and DataGrid generic? By currently accepting `object`, it is difficult to capture the generic type of the data.
from typing import TYPE_CHECKING, Literal, Union
from typing import TYPE_CHECKING, Generic, Literal, Union

from ..._docstring import add_example, no_example
from ._selection import (
Expand All @@ -14,16 +12,15 @@
)
from ._styles import StyleFn, StyleInfo, as_browser_style_infos, as_style_infos
from ._tbl_data import as_data_frame_like, serialize_frame
from ._types import DataFrameLike, FrameJson, PandasCompatible
from ._types import DataFrameLikeT, FrameJson

if TYPE_CHECKING:

DataFrameResult = Union[
None,
DataFrameLike,
"DataGrid",
"DataTable",
PandasCompatible,
DataFrameLikeT,
"DataGrid[DataFrameLikeT]",
"DataTable[DataFrameLikeT]",
]

else:
Expand All @@ -38,7 +35,7 @@ def to_payload(self) -> FrameJson: ...


@add_example(ex_dir="../../api-examples/data_frame")
class DataGrid(AbstractTabularData):
class DataGrid(AbstractTabularData, Generic[DataFrameLikeT]):
"""
Holds the data and options for a :class:`~shiny.render.data_frame` output, for a
spreadsheet-like view.
Expand Down Expand Up @@ -100,33 +97,30 @@ class DataGrid(AbstractTabularData):
* :class:`~shiny.render.DataTable`
"""

data: DataFrameLike
data: DataFrameLikeT
width: str | float | None
height: str | float | None
summary: bool | str
filters: bool
editable: bool
selection_modes: SelectionModes
styles: list[StyleInfo] | StyleFn
styles: list[StyleInfo] | StyleFn[DataFrameLikeT]

def __init__(
self,
data: DataFrameLike | PandasCompatible,
data: DataFrameLikeT,
*,
width: str | float | None = "fit-content",
height: str | float | None = None,
summary: bool | str = True,
filters: bool = False,
editable: bool = False,
selection_mode: SelectionModeInput = "none",
styles: StyleInfo | list[StyleInfo] | StyleFn | None = None,
styles: StyleInfo | list[StyleInfo] | StyleFn[DataFrameLikeT] | None = None,
row_selection_mode: RowSelectionModeDeprecated = "deprecated",
):

self.data = as_data_frame_like(
data,
"The DataGrid() constructor didn't expect a 'data' argument of type",
)
self.data = as_data_frame_like(data)

self.width = width
self.height = height
Expand Down Expand Up @@ -161,7 +155,7 @@ def to_payload(self) -> FrameJson:


@no_example()
class DataTable(AbstractTabularData):
class DataTable(AbstractTabularData, Generic[DataFrameLikeT]):
"""
Holds the data and options for a :class:`~shiny.render.data_frame` output, for a
spreadsheet-like view.
Expand Down Expand Up @@ -223,32 +217,29 @@ class DataTable(AbstractTabularData):
* :class:`~shiny.render.DataGrid`
"""

data: DataFrameLike
data: DataFrameLikeT
width: str | float | None
height: str | float | None
summary: bool | str
filters: bool
editable: bool
selection_modes: SelectionModes
styles: list[StyleInfo] | StyleFn
styles: list[StyleInfo] | StyleFn[DataFrameLikeT]

def __init__(
self,
data: DataFrameLike | PandasCompatible,
data: DataFrameLikeT,
*,
width: str | float | None = "fit-content",
height: str | float | None = "500px",
summary: bool | str = True,
filters: bool = False,
editable: bool = False,
selection_mode: SelectionModeInput = "none",
styles: StyleInfo | list[StyleInfo] | StyleFn[DataFrameLikeT] | None = None,
row_selection_mode: Literal["deprecated"] = "deprecated",
styles: StyleInfo | list[StyleInfo] | StyleFn | None = None,
):

self.data = as_data_frame_like(
data,
"The DataTable() constructor didn't expect a 'data' argument of type",
)
self.data = as_data_frame_like(data)

self.width = width
self.height = height
Expand Down
Loading
Loading