Skip to content

Commit 18da98f

Browse files
jonasvddivanovmgjvdd
authored
Feat/plotly6 (#338)
* Parametrize test_utils.py on is_figure * 🔍 remove dtype parsing as orjon>3.10 supports float16 #118 * 💪 refactor: streamline JupyterDash integration and remove unused persistent inline logic * 💨 move construct_update_data_patch method into the FigureResampler class * 🐐 refactor: enhance test utilities and add support for Plotly>=6 data handling * 🙏 enhance serialization tests for plotly>6 * 📝 remove debug print statement and enhance type handling for hf_x * 🔒 update dependency versions in pyproject.toml to Support plotly 6 #334 * 🔍 drop python3.7 CI workflow and upgrade upload-artifact action * 🙏 fix pickling of figurewidget resampler * 🙏 fix tests * 💨 migration of code towards new upload artifact * 💪 enhance CI workflow to improve test result uploads and add retention settings * 🕳️ fix: ensure correct dtype handling for aggregated x indices in PlotlyAggregatorParser * ⬆️ chore: update dependency constraints for pandas and pyarrow in pyproject.toml * 🙈 fix linting * 🔍 fix: correct spelling in streamlit_app.py comments and update dash-extensions and pyarrow versions in requirements.txt * ⬆️ chore: update ipywidgets version constraint to allow for newer versions * 🚧 test: set random seed for reproducibility in test_wrap_aggregate * 🙈 chore: update ipywidgets version constraint for serialization support * 🙈 * 🔍 ci: conditionally skip tests on Python 3.12 for Ubuntu (as it keeps hanging in github actions) * 🔍 ci: exclude Python 3.12 on Ubuntu from test matrix to prevent hangs * 🖊️ review code * 🧹 cleanup comments --------- Co-authored-by: Maxim Ivanov <[email protected]> Co-authored-by: jeroen <[email protected]>
1 parent bb30c91 commit 18da98f

16 files changed

+2929
-2001
lines changed

.github/workflows/test.yml

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,15 @@ on:
1919

2020
jobs:
2121
test:
22-
2322
runs-on: ${{ matrix.os }}
2423
strategy:
2524
fail-fast: false
2625
matrix:
2726
os: ['windows-latest', 'macOS-latest', 'ubuntu-latest']
28-
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12']
29-
exclude: # Python < 3.8 is not supported on Apple Silicon ARM64
30-
- os: macOS-latest
31-
python-version: '3.7'
32-
include: # So run on older version on Intel CPU
33-
- os: macOS-13
34-
python-version: '3.7'
27+
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
28+
exclude:
29+
- os: ubuntu-latest
30+
python-version: '3.12'
3531
defaults:
3632
run:
3733
shell: bash
@@ -76,12 +72,15 @@ jobs:
7672
run: |
7773
poetry run pytest --cov=plotly_resampler --junitxml=junit/test-results-${{ matrix.python-version }}.xml --cov-report=xml tests
7874
- name: Upload pytest test results
79-
uses: actions/upload-artifact@v3
75+
# Use always() to always run this step to publish test results when there are test failures
76+
if: ${{ always() && hashFiles('junit/test-results-${{ matrix.python-version }}.xml') != '' }}
77+
uses: actions/upload-artifact@v4
8078
with:
81-
name: pytest-results-${{ matrix.python-version }}
79+
name: pytest-results-${{ matrix.python-version }}-${{ matrix.os }}-${{ github.run_number }}
8280
path: junit/test-results-${{ matrix.python-version }}.xml
83-
# Use always() to always run this step to publish test results when there are test failures
84-
if: ${{ always() }}
81+
overwrite: true
82+
retention-days: 7
83+
compression-level: 5
8584

8685
- name: Upload coverage to Codecov
8786
uses: codecov/codecov-action@v3

examples/other_apps/streamlit_app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
__author__ = "Jeroen Van Der Donckt"
1818

19-
# Explicitely set pio.templates in order to have colored traces in the streamlit app!
19+
# Explicitly set pio.templates in order to have colored traces in the streamlit app!
2020
# -> https://discuss.streamlit.io/t/streamlit-overrides-colours-of-plotly-chart/34943/5
2121
import plotly.io as pio
2222

examples/requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
pyfunctional>=1.4.3
22
dash-bootstrap-components>=1.2.0
3-
dash-extensions==1.0.1 # fixated on this version as more recent versions do not work
3+
dash-extensions==1.0.20 # fixated on this version as more recent versions do not work
44
ipywidgets>=7.7.0
55
memory-profiler>=0.60.0
66
line-profiler>=3.5.1
7-
pyarrow>=6.0.0
7+
pyarrow>=17.0.0
88
kaleido>=0.2.1
99
flask-cors>=3.0.10

plotly_resampler/aggregation/plotly_aggregator_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def aggregate(
190190
agg_x = (
191191
start_idx
192192
+ hf_trace_data["x"].start
193-
+ indices * hf_trace_data["x"].step
193+
+ indices.astype(hf_trace_data["x"].dtype) * hf_trace_data["x"].step
194194
)
195195
else:
196196
agg_x = hf_x[indices]

plotly_resampler/figure_resampler/figure_resampler.py

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import os
1414
import warnings
1515
from pathlib import Path
16-
from typing import List, Optional, Tuple
16+
from typing import List, Optional, Tuple, Union
1717

1818
import dash
1919
import plotly.graph_objects as go
@@ -26,15 +26,9 @@
2626
MinMaxLTTB,
2727
)
2828
from .figure_resampler_interface import AbstractFigureAggregator
29+
from .jupyter_dash_persistent_inline_output import JupyterDashPersistentInlineOutput
2930
from .utils import is_figure, is_fr
3031

31-
try:
32-
from .jupyter_dash_persistent_inline_output import JupyterDashPersistentInlineOutput
33-
34-
_jupyter_dash_installed = True
35-
except ImportError:
36-
_jupyter_dash_installed = False
37-
3832
# Default arguments for the Figure overview
3933
ASSETS_FOLDER = Path(__file__).parent.joinpath("assets").absolute().__str__()
4034
_DEFAULT_OVERVIEW_LAYOUT_KWARGS = {
@@ -242,7 +236,6 @@ def __init__(
242236
self._host: str | None = None
243237
# Certain functions will be different when using persistent inline
244238
# (namely `show_dash` and `stop_callback`)
245-
self._is_persistent_inline = False
246239

247240
def _get_subplot_rows_and_cols_from_grid(self) -> Tuple[int, int]:
248241
"""Get the number of rows and columns of the figure's grid.
@@ -535,7 +528,9 @@ def show_dash(
535528
constructor via the ``show_dash_kwargs`` argument.
536529
537530
"""
538-
available_modes = ["external", "inline", "inline_persistent", "jupyterlab"]
531+
available_modes = list(dash._jupyter.JupyterDisplayMode.__args__) + [
532+
"inline_persistent"
533+
]
539534
assert (
540535
mode is None or mode in available_modes
541536
), f"mode must be one of {available_modes}"
@@ -576,25 +571,6 @@ def show_dash(
576571
init_dash_kwargs["external_scripts"] = ["https://cdn.jsdelivr.net/npm/lodash/lodash.min.js" ]
577572
# fmt: on
578573

579-
if mode == "inline_persistent":
580-
mode = "inline"
581-
if _jupyter_dash_installed:
582-
# Inline persistent mode: we display a static image of the figure when the
583-
# app is not reachable
584-
# Note: this is the "inline" behavior of JupyterDashInlinePersistentOutput
585-
app = JupyterDashPersistentInlineOutput("local_app", **init_dash_kwargs)
586-
self._is_persistent_inline = True
587-
else:
588-
# If Jupyter Dash is not installed, inline persistent won't work and hence
589-
# we default to normal inline mode with a normal Dash app
590-
app = dash.Dash("local_app", **init_dash_kwargs)
591-
warnings.warn(
592-
"'jupyter_dash' is not installed. The persistent inline mode will not work. Defaulting to standard inline mode."
593-
)
594-
else:
595-
# jupyter dash uses a normal Dash app as figure
596-
app = dash.Dash("local_app", **init_dash_kwargs)
597-
598574
# fmt: off
599575
div = dash.html.Div(
600576
children=[
@@ -620,18 +596,19 @@ def show_dash(
620596
**graph_properties,
621597
),
622598
]
623-
app.layout = div
624599

600+
# Create the app, populate the layout and register the resample callback
601+
app = dash.Dash("local_app", **init_dash_kwargs)
602+
app.layout = div
625603
self.register_update_graph_callback(
626604
app,
627605
"resample-figure",
628606
"overview-figure" if self._create_overview else None,
629607
)
630608

631-
height_param = "height" if self._is_persistent_inline else "jupyter_height"
632-
633609
# 2. Run the app
634-
if mode == "inline" and height_param not in kwargs:
610+
height_param = "height" if mode == "inline_persistent" else "jupyter_height"
611+
if "inline" in mode and height_param not in kwargs:
635612
# If app height is not specified -> re-use figure height for inline dash app
636613
# Note: default layout height is 450 (whereas default app height is 650)
637614
# See: https://plotly.com/python/reference/layout/#layout-height
@@ -646,9 +623,11 @@ def show_dash(
646623
self._host = kwargs.get("host", "127.0.0.1")
647624
self._port = kwargs.get("port", "8050")
648625

649-
# function signature is slightly different for the Dash and JupyterDash implementations
650-
if self._is_persistent_inline:
651-
app.run(mode=mode, **kwargs)
626+
# function signatures are slightly different for the (Jupyter)Dash and the
627+
# JupyterDashInlinePersistent implementations
628+
if mode == "inline_persistent":
629+
jpi = JupyterDashPersistentInlineOutput(self)
630+
jpi.run_app(app=app, **kwargs)
652631
else:
653632
app.run(jupyter_mode=mode, **kwargs)
654633

@@ -665,18 +644,10 @@ def stop_server(self, warn: bool = True):
665644
This only works if the dash-app was started with [`show_dash`][figure_resampler.figure_resampler.FigureResampler.show_dash].
666645
"""
667646
if self._app is not None:
668-
servers_dict = (
669-
self._app._server_threads
670-
if self._is_persistent_inline
671-
else dash.jupyter_dash._servers
672-
)
647+
servers_dict = dash.jupyter_dash._servers
673648
old_server = servers_dict.get((self._host, self._port))
674649
if old_server:
675-
if self._is_persistent_inline:
676-
old_server.kill()
677-
old_server.join()
678-
else:
679-
old_server.shutdown()
650+
old_server.shutdown()
680651
del servers_dict[(self._host, self._port)]
681652
elif warn:
682653
warnings.warn(
@@ -685,6 +656,47 @@ def stop_server(self, warn: bool = True):
685656
+ "\t- the dash-server wasn't started with 'show_dash'"
686657
)
687658

659+
def construct_update_data_patch(
660+
self, relayout_data: dict
661+
) -> Union[dash.Patch, dash.no_update]:
662+
"""Construct the Patch of the to-be-updated front-end data, based on the layout
663+
change.
664+
665+
Attention
666+
---------
667+
This method is tightly coupled with Dash app callbacks. It takes the front-end
668+
figure its ``relayoutData`` as input and returns the ``dash.Patch`` which needs
669+
to be sent to the ``figure`` property for the corresponding ``dcc.Graph``.
670+
671+
Parameters
672+
----------
673+
relayout_data: dict
674+
A dict containing the ``relayoutData`` (i.e., the changed layout data) of
675+
the corresponding front-end graph.
676+
677+
Returns
678+
-------
679+
dash.Patch:
680+
The Patch object containing the figure updates which needs to be sent to
681+
the front-end.
682+
683+
"""
684+
update_data = self._construct_update_data(relayout_data)
685+
if not isinstance(update_data, list) or len(update_data) <= 1:
686+
return dash.no_update
687+
688+
patched_figure = dash.Patch() # create patch
689+
for trace in update_data[1:]: # skip first item as it contains the relayout
690+
trace_index = trace.pop("index") # the index of the corresponding trace
691+
# All the other items are the trace properties which needs to be updated
692+
for k, v in trace.items():
693+
# NOTE: we need to use the `patched_figure` as a dict, and not
694+
# `patched_figure.data` as the latter will replace **all** the
695+
# data for the corresponding trace, and we just want to update the
696+
# specific trace its properties.
697+
patched_figure["data"][trace_index][k] = v
698+
return patched_figure
699+
688700
def register_update_graph_callback(
689701
self,
690702
app: dash.Dash,

plotly_resampler/figure_resampler/figure_resampler_interface.py

Lines changed: 7 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -371,10 +371,8 @@ def _check_update_trace_data(
371371
)
372372

373373
# -------------------- Set the hf_trace_data_props -------------------
374-
# Parse the data types to an orjson compatible format
375-
# NOTE: this can be removed once orjson supports f16
376-
trace["x"] = self._parse_dtype_orjson(agg_x)
377-
trace["y"] = self._parse_dtype_orjson(agg_y)
374+
trace["x"] = agg_x
375+
trace["y"] = agg_y
378376
trace["name"] = self._parse_trace_name(
379377
hf_trace_data, end_idx - start_idx, agg_x
380378
)
@@ -578,7 +576,7 @@ def _parse_get_trace_props(
578576
A namedtuple which serves as a datacontainer.
579577
580578
"""
581-
hf_x: np.ndarray = (
579+
hf_x: np.ndarray | pd.Index = (
582580
# fmt: off
583581
(np.asarray(trace["x"]) if trace["x"] is not None else None)
584582
if hasattr(trace, "x") and hf_x is None
@@ -593,6 +591,8 @@ def _parse_get_trace_props(
593591
else np.asarray(hf_x)
594592
# fmt: on
595593
)
594+
if pd.core.dtypes.common.is_datetime64_any_dtype(hf_x):
595+
hf_x = pd.Index(hf_x)
596596

597597
hf_y = (
598598
trace["y"]
@@ -1037,7 +1037,7 @@ def add_trace(
10371037
[trace], **self._add_trace_to_add_traces_kwargs(trace_kwargs)
10381038
)
10391039

1040-
return super(self._figure_class, self).add_traces(
1040+
return super().add_traces(
10411041
[trace], **self._add_trace_to_add_traces_kwargs(trace_kwargs)
10421042
)
10431043

@@ -1169,7 +1169,7 @@ def add_traces(
11691169
assert trace is not None
11701170
data[i] = trace
11711171

1172-
return super(self._figure_class, self).add_traces(data, **traces_kwargs)
1172+
return super().add_traces(data, **traces_kwargs)
11731173

11741174
def _clear_figure(self):
11751175
"""Clear the current figure object its data and layout."""
@@ -1259,47 +1259,6 @@ def _parse_relayout(self, relayout_dict: dict) -> dict:
12591259
extra_layout_updates[f"{axis}.autorange"] = None
12601260
return extra_layout_updates
12611261

1262-
def construct_update_data_patch(
1263-
self, relayout_data: dict
1264-
) -> Union[dash.Patch, dash.no_update]:
1265-
"""Construct the Patch of the to-be-updated front-end data, based on the layout
1266-
change.
1267-
1268-
Attention
1269-
---------
1270-
This method is tightly coupled with Dash app callbacks. It takes the front-end
1271-
figure its ``relayoutData`` as input and returns the ``dash.Patch`` which needs
1272-
to be sent to the ``figure`` property for the corresponding ``dcc.Graph``.
1273-
1274-
Parameters
1275-
----------
1276-
relayout_data: dict
1277-
A dict containing the ``relayoutData`` (i.e., the changed layout data) of
1278-
the corresponding front-end graph.
1279-
1280-
Returns
1281-
-------
1282-
dash.Patch:
1283-
The Patch object containing the figure updates which needs to be sent to
1284-
the front-end.
1285-
1286-
"""
1287-
update_data = self._construct_update_data(relayout_data)
1288-
if not isinstance(update_data, list) or len(update_data) <= 1:
1289-
return dash.no_update
1290-
1291-
patched_figure = dash.Patch() # create patch
1292-
for trace in update_data[1:]: # skip first item as it contains the relayout
1293-
trace_index = trace.pop("index") # the index of the corresponding trace
1294-
# All the other items are the trace properties which needs to be updated
1295-
for k, v in trace.items():
1296-
# NOTE: we need to use the `patched_figure` as a dict, and not
1297-
# `patched_figure.data` as the latter will replace **all** the
1298-
# data for the corresponding trace, and we just want to update the
1299-
# specific trace its properties.
1300-
patched_figure["data"][trace_index][k] = v
1301-
return patched_figure
1302-
13031262
def _construct_update_data(
13041263
self,
13051264
relayout_data: dict,
@@ -1400,17 +1359,6 @@ def _construct_update_data(
14001359
layout_traces_list.append(trace_reduced)
14011360
return layout_traces_list
14021361

1403-
@staticmethod
1404-
def _parse_dtype_orjson(series: np.ndarray) -> np.ndarray:
1405-
"""Verify the orjson compatibility of the series and convert it if needed."""
1406-
# NOTE:
1407-
# * float16 and float128 aren't supported with latest orjson versions (3.8.1)
1408-
# * this method assumes that the it will not get a float128 series
1409-
# -> this method can be removed if orjson supports float16
1410-
if series.dtype == np.float16:
1411-
return series.astype(np.float32)
1412-
return series
1413-
14141362
@staticmethod
14151363
def _re_matches(regex: re.Pattern, strings: Iterable[str]) -> List[str]:
14161364
"""Returns all the items in ``strings`` which regex.match(es) ``regex``."""

plotly_resampler/figure_resampler/figurewidget_resampler.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,3 +326,8 @@ def reload_data(self):
326326
)
327327
# TODO: when we know which traces have changed we can use
328328
# a new -> `update_xaxis_str` argument.
329+
330+
def __reduce__(self):
331+
# Needed for pickling
332+
# Specifically set the class name, as the metaclass is not easily picklable
333+
return FigureWidgetResampler, *list(super().__reduce__())[1:]

0 commit comments

Comments
 (0)