Skip to content

Commit ef0f9f6

Browse files
authored
feat(data frame): Add generic type support via render return value (#1502)
1 parent 14ed037 commit ef0f9f6

File tree

19 files changed

+187
-133
lines changed

19 files changed

+187
-133
lines changed

shiny/_typing_extensions.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"Concatenate",
88
"ParamSpec",
99
"TypeGuard",
10+
"TypeIs",
1011
"Never",
1112
"Required",
1213
"NotRequired",
@@ -43,9 +44,13 @@
4344
assert_type,
4445
)
4546

47+
if sys.version_info >= (3, 13):
48+
from typing import TypeIs
49+
else:
50+
from typing_extensions import TypeIs
4651

4752
# The only purpose of the following line is so that pyright will put all of the
4853
# conditional imports into the .pyi file when generating type stubs. Without this line,
4954
# pyright will not include the above imports in the generated .pyi file, and it will
5055
# result in a lot of red squiggles in user code.
51-
_: 'Annotated |Concatenate[str, ParamSpec("P")] | ParamSpec | TypeGuard | NotRequired | Required | TypedDict | assert_type | Self' # type:ignore
56+
_: 'Annotated |Concatenate[str, ParamSpec("P")] | ParamSpec | TypeGuard | TypeIs | NotRequired | Required | TypedDict | assert_type | Self' # type:ignore

shiny/render/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
data_frame,
1414
)
1515
from ._data_frame_utils import CellSelection
16-
from ._data_frame_utils._types import DataFrameLike, StyleInfo
16+
from ._data_frame_utils._types import ( # noqa: F401
17+
StyleInfo,
18+
DataFrameLikeT as _DataFrameLikeT, # pyright: ignore[reportUnusedImport]
19+
)
1720
from ._deprecated import ( # noqa: F401
1821
RenderFunction, # pyright: ignore[reportUnusedImport]
1922
RenderFunctionAsync, # pyright: ignore[reportUnusedImport]
@@ -48,5 +51,4 @@
4851
"CellValue",
4952
"CellSelection",
5053
"StyleInfo",
51-
"DataFrameLike",
5254
)

shiny/render/_data_frame.py

Lines changed: 42 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,9 @@
22

33
import warnings
44

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

139
from htmltools import Tag
1410

@@ -36,18 +32,18 @@
3632
)
3733
from ._data_frame_utils._styles import as_browser_style_infos
3834
from ._data_frame_utils._tbl_data import (
39-
apply_frame_patches,
40-
as_data_frame_like,
35+
apply_frame_patches__typed,
4136
frame_columns,
4237
frame_shape,
4338
serialize_dtype,
44-
subset_frame,
39+
subset_frame__typed,
4540
)
4641
from ._data_frame_utils._types import (
4742
CellPatchProcessed,
4843
ColumnFilter,
4944
ColumnSort,
50-
DataFrameLike,
45+
DataFrameLikeT,
46+
FrameDtype,
5147
FrameRender,
5248
cell_patch_processed_to_jsonifiable,
5349
frame_render_to_jsonifiable,
@@ -59,7 +55,14 @@
5955
if TYPE_CHECKING:
6056
from ..session import Session
6157

62-
from ._data_frame_utils._datagridtable import DataFrameResult
58+
DataFrameResult = Union[
59+
None,
60+
DataFrameLikeT,
61+
"DataGrid[DataFrameLikeT]",
62+
"DataTable[DataFrameLikeT]",
63+
]
64+
DataFrameValue = Union[None, DataGrid[DataFrameLikeT], DataTable[DataFrameLikeT]]
65+
6366

6467
# # TODO-future; Use `dataframe-api-compat>=0.2.6` to injest dataframes and return standardized dataframe structures
6568
# # 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
@@ -92,7 +95,7 @@
9295

9396

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

167-
_value: reactive.Value[DataFrameResult | None]
170+
_value: reactive.Value[DataFrameValue[DataFrameLikeT] | None]
168171
"""
169172
Reactive value of the data frame's rendered object.
170173
"""
171-
_type_hints: reactive.Value[dict[str, str] | None]
174+
_type_hints: reactive.Value[list[FrameDtype] | None]
172175
"""
173176
Reactive value of the data frame's type hints for each column.
174177
@@ -206,7 +209,7 @@ class data_frame(Renderer[DataFrameResult]):
206209
Reactive value of the data frame's edits provided by the user.
207210
"""
208211

209-
data: reactive.Calc_[DataFrameLike]
212+
data: reactive.Calc_[DataFrameLikeT]
210213
"""
211214
Reactive value of the data frame's output data.
212215
@@ -217,17 +220,17 @@ class data_frame(Renderer[DataFrameResult]):
217220
Even if the rendered data value was not of type `pd.DataFrame` or `pl.DataFrame`, this method currently
218221
converts it to a `pd.DataFrame`.
219222
"""
220-
_data_view_all: reactive.Calc_[DataFrameLike]
223+
_data_view_all: reactive.Calc_[DataFrameLikeT]
221224
"""
222225
Reactive value of the full (sorted and filtered) data.
223226
"""
224-
_data_view_selected: reactive.Calc_[DataFrameLike]
227+
_data_view_selected: reactive.Calc_[DataFrameLikeT]
225228
"""
226229
Reactive value of the selected rows of the (sorted and filtered) data.
227230
"""
228231

229232
@add_example(ex_dir="../api-examples/data_frame_data_view")
230-
def data_view(self, *, selected: bool = False) -> DataFrameLike:
233+
def data_view(self, *, selected: bool = False) -> DataFrameLikeT:
231234
"""
232235
Reactive function that retrieves the data how it is viewed within the browser.
233236
@@ -299,7 +302,7 @@ def data_view(self, *, selected: bool = False) -> DataFrameLike:
299302
The row numbers of the data frame that are currently being viewed in the browser
300303
after sorting and filtering has been applied.
301304
"""
302-
_data_patched: reactive.Calc_[DataFrameLike]
305+
_data_patched: reactive.Calc_[DataFrameLikeT]
303306
"""
304307
Reactive value of the data frame's patched data.
305308
@@ -339,8 +342,10 @@ def _init_reactives(self) -> None:
339342
from .. import req
340343

341344
# Init
342-
self._value: reactive.Value[DataFrameResult | None] = reactive.Value(None)
343-
self._type_hints: reactive.Value[dict[str, str] | None] = reactive.Value(None)
345+
self._value: reactive.Value[DataFrameValue[DataFrameLikeT] | None] = (
346+
reactive.Value(None)
347+
)
348+
self._type_hints: reactive.Value[list[FrameDtype] | None] = reactive.Value(None)
344349
self._cell_patch_map = reactive.Value({})
345350

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

352357
@reactive.calc
353-
def self_data() -> DataFrameLike:
358+
def self_data() -> DataFrameLikeT:
354359
value = self._value()
355360
req(value)
356361

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

425430
@reactive.calc
426-
def self__data_patched() -> DataFrameLike:
427-
return apply_frame_patches(self.data(), self.cell_patches())
431+
def self__data_patched() -> DataFrameLikeT:
432+
return apply_frame_patches__typed(self.data(), self.cell_patches())
428433

429434
self._data_patched = self__data_patched
430435

431436
# Apply filtering and sorting
432437
# https://github.com/posit-dev/py-shiny/issues/1240
433-
def _subset_data_view(selected: bool) -> DataFrameLike:
438+
def _subset_data_view(selected: bool) -> DataFrameLikeT:
434439
"""
435440
Helper method to subset data according to what is viewed in the browser;
436441
@@ -454,15 +459,15 @@ def _subset_data_view(selected: bool) -> DataFrameLike:
454459
else:
455460
rows = self.data_view_rows()
456461

457-
return subset_frame(self._data_patched(), rows=rows)
462+
return subset_frame__typed(self._data_patched(), rows=rows)
458463

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

464469
@reactive.calc
465-
def self__data_view_selected() -> DataFrameLike:
470+
def self__data_view_selected() -> DataFrameLikeT:
466471
return _subset_data_view(selected=True)
467472

468473
self._data_view_all = self__data_view
@@ -721,7 +726,7 @@ async def _attempt_update_cell_style(self) -> None:
721726
def auto_output_ui(self) -> Tag:
722727
return ui.output_data_frame(id=self.output_id)
723728

724-
def __init__(self, fn: ValueFn[DataFrameResult]):
729+
def __init__(self, fn: ValueFn[DataFrameResult[DataFrameLikeT]]):
725730
super().__init__(fn)
726731

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

760765
if not isinstance(value, AbstractTabularData):
761-
value = DataGrid(
762-
as_data_frame_like(
763-
value,
764-
"@render.data_frame doesn't know how to render objects of type",
765-
)
766-
)
766+
try:
767+
value = DataGrid(value)
768+
except TypeError as e:
769+
raise TypeError(
770+
"@render.data_frame doesn't know how to render objects of type ",
771+
type(value),
772+
) from e
767773

768774
# Set patches url handler for client
769775
patch_key = self._set_patches_handler()
770-
self._value.set(value)
776+
self._value.set(value) # pyright: ignore[reportArgumentType]
771777

772778
# Use session context so `to_payload()` gets the correct session
773779
with session_context(self._get_session()):
774780
payload = value.to_payload()
775-
776-
type_hints = cast(
777-
Union[Dict[str, str], None],
778-
payload.get("typeHints", None),
779-
)
780-
self._type_hints.set(type_hints)
781+
self._type_hints.set(payload["typeHints"])
781782

782783
ret: FrameRender = {
783784
"payload": payload,

shiny/render/_data_frame_utils/_datagridtable.py

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
from __future__ import annotations
22

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

86
from ..._docstring import add_example, no_example
97
from ._selection import (
@@ -14,16 +12,15 @@
1412
)
1513
from ._styles import StyleFn, StyleInfo, as_browser_style_infos, as_style_infos
1614
from ._tbl_data import as_data_frame_like, serialize_frame
17-
from ._types import DataFrameLike, FrameJson, PandasCompatible
15+
from ._types import DataFrameLikeT, FrameJson
1816

1917
if TYPE_CHECKING:
2018

2119
DataFrameResult = Union[
2220
None,
23-
DataFrameLike,
24-
"DataGrid",
25-
"DataTable",
26-
PandasCompatible,
21+
DataFrameLikeT,
22+
"DataGrid[DataFrameLikeT]",
23+
"DataTable[DataFrameLikeT]",
2724
]
2825

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

3936

4037
@add_example(ex_dir="../../api-examples/data_frame")
41-
class DataGrid(AbstractTabularData):
38+
class DataGrid(AbstractTabularData, Generic[DataFrameLikeT]):
4239
"""
4340
Holds the data and options for a :class:`~shiny.render.data_frame` output, for a
4441
spreadsheet-like view.
@@ -100,33 +97,30 @@ class DataGrid(AbstractTabularData):
10097
* :class:`~shiny.render.DataTable`
10198
"""
10299

103-
data: DataFrameLike
100+
data: DataFrameLikeT
104101
width: str | float | None
105102
height: str | float | None
106103
summary: bool | str
107104
filters: bool
108105
editable: bool
109106
selection_modes: SelectionModes
110-
styles: list[StyleInfo] | StyleFn
107+
styles: list[StyleInfo] | StyleFn[DataFrameLikeT]
111108

112109
def __init__(
113110
self,
114-
data: DataFrameLike | PandasCompatible,
111+
data: DataFrameLikeT,
115112
*,
116113
width: str | float | None = "fit-content",
117114
height: str | float | None = None,
118115
summary: bool | str = True,
119116
filters: bool = False,
120117
editable: bool = False,
121118
selection_mode: SelectionModeInput = "none",
122-
styles: StyleInfo | list[StyleInfo] | StyleFn | None = None,
119+
styles: StyleInfo | list[StyleInfo] | StyleFn[DataFrameLikeT] | None = None,
123120
row_selection_mode: RowSelectionModeDeprecated = "deprecated",
124121
):
125122

126-
self.data = as_data_frame_like(
127-
data,
128-
"The DataGrid() constructor didn't expect a 'data' argument of type",
129-
)
123+
self.data = as_data_frame_like(data)
130124

131125
self.width = width
132126
self.height = height
@@ -161,7 +155,7 @@ def to_payload(self) -> FrameJson:
161155

162156

163157
@no_example()
164-
class DataTable(AbstractTabularData):
158+
class DataTable(AbstractTabularData, Generic[DataFrameLikeT]):
165159
"""
166160
Holds the data and options for a :class:`~shiny.render.data_frame` output, for a
167161
spreadsheet-like view.
@@ -223,32 +217,29 @@ class DataTable(AbstractTabularData):
223217
* :class:`~shiny.render.DataGrid`
224218
"""
225219

226-
data: DataFrameLike
220+
data: DataFrameLikeT
227221
width: str | float | None
228222
height: str | float | None
229223
summary: bool | str
230224
filters: bool
225+
editable: bool
231226
selection_modes: SelectionModes
232-
styles: list[StyleInfo] | StyleFn
227+
styles: list[StyleInfo] | StyleFn[DataFrameLikeT]
233228

234229
def __init__(
235230
self,
236-
data: DataFrameLike | PandasCompatible,
231+
data: DataFrameLikeT,
237232
*,
238233
width: str | float | None = "fit-content",
239234
height: str | float | None = "500px",
240235
summary: bool | str = True,
241236
filters: bool = False,
242237
editable: bool = False,
243238
selection_mode: SelectionModeInput = "none",
239+
styles: StyleInfo | list[StyleInfo] | StyleFn[DataFrameLikeT] | None = None,
244240
row_selection_mode: Literal["deprecated"] = "deprecated",
245-
styles: StyleInfo | list[StyleInfo] | StyleFn | None = None,
246241
):
247-
248-
self.data = as_data_frame_like(
249-
data,
250-
"The DataTable() constructor didn't expect a 'data' argument of type",
251-
)
242+
self.data = as_data_frame_like(data)
252243

253244
self.width = width
254245
self.height = height

0 commit comments

Comments
 (0)