Skip to content

Commit 1e4e069

Browse files
authored
Merge pull request #87 from predict-idlab/os_matrix
📦 serialization support + 🎚️ update OS & python version in test-matrix
2 parents 2ed7423 + 126f84a commit 1e4e069

11 files changed

+1091
-43
lines changed

.github/workflows/test.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ on:
1212
jobs:
1313
build:
1414

15-
runs-on: ubuntu-latest
15+
runs-on: ${{ matrix.os }}
1616
strategy:
1717
fail-fast: false
1818
matrix:
19-
python-version: [3.7, 3.8, 3.9]
19+
os: ['windows-latest', 'macOS-latest', 'ubuntu-latest']
20+
python-version: ['3.7', '3.8', '3.9', '3.10']
2021

2122
steps:
2223
- uses: actions/checkout@v2

plotly_resampler/figure_resampler/figure_resampler.py

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
__author__ = "Jonas Van Der Donckt, Jeroen Van Der Donckt, Emiel Deprost"
1212

1313
import warnings
14-
from typing import Tuple
14+
from typing import Tuple, List
1515

1616
import dash
1717
import plotly.graph_objects as go
@@ -41,26 +41,98 @@ def __init__(
4141
show_mean_aggregation_size: bool = True,
4242
convert_traces_kwargs: dict | None = None,
4343
verbose: bool = False,
44+
show_dash_kwargs: dict | None = None,
4445
):
46+
"""Initialize a dynamic aggregation data mirror using a dash web app.
47+
48+
Parameters
49+
----------
50+
figure: BaseFigure
51+
The figure that will be decorated. Can be either an empty figure
52+
(e.g., ``go.Figure()``, ``make_subplots()``, ``go.FigureWidget``) or an
53+
existing figure.
54+
convert_existing_traces: bool
55+
A bool indicating whether the high-frequency traces of the passed ``figure``
56+
should be resampled, by default True. Hence, when set to False, the
57+
high-frequency traces of the passed ``figure`` will not be resampled.
58+
default_n_shown_samples: int, optional
59+
The default number of samples that will be shown for each trace,
60+
by default 1000.\n
61+
.. note::
62+
* This can be overridden within the :func:`add_trace` method.
63+
* If a trace withholds fewer datapoints than this parameter,
64+
the data will *not* be aggregated.
65+
default_downsampler: AbstractSeriesDownsampler
66+
An instance which implements the AbstractSeriesDownsampler interface and
67+
will be used as default downsampler, by default ``EfficientLTTB`` with
68+
_interleave_gaps_ set to True. \n
69+
.. note:: This can be overridden within the :func:`add_trace` method.
70+
resampled_trace_prefix_suffix: str, optional
71+
A tuple which contains the ``prefix`` and ``suffix``, respectively, which
72+
will be added to the trace its legend-name when a resampled version of the
73+
trace is shown. By default a bold, orange ``[R]`` is shown as prefix
74+
(no suffix is shown).
75+
show_mean_aggregation_size: bool, optional
76+
Whether the mean aggregation bin size will be added as a suffix to the trace
77+
its legend-name, by default True.
78+
convert_traces_kwargs: dict, optional
79+
A dict of kwargs that will be passed to the :func:`add_traces` method and
80+
will be used to convert the existing traces. \n
81+
.. note::
82+
This argument is only used when the passed ``figure`` contains data and
83+
``convert_existing_traces`` is set to True.
84+
verbose: bool, optional
85+
Whether some verbose messages will be printed or not, by default False.
86+
show_dash_kwargs: dict, optional
87+
A dict that will be used as default kwargs for the :func:`show_dash` method.
88+
Note that the passed kwargs will be take precedence over these defaults.
89+
90+
"""
4591
# Parse the figure input before calling `super`
46-
if is_figure(figure) and not is_fr(figure): # go.Figure
47-
# Base case, the figure does not need to be adjusted
92+
if is_figure(figure) and not is_fr(figure):
93+
# A go.Figure
94+
# => base case: the figure does not need to be adjusted
4895
f = figure
4996
else:
5097
# Create a new figure object and make sure that the trace uid will not get
5198
# adjusted when they are added.
5299
f = self._get_figure_class(go.Figure)()
53100
f._data_validator.set_uid = False
54101

55-
if isinstance(figure, BaseFigure): # go.FigureWidget or AbstractFigureAggregator
56-
# A base figure object, we first copy the layout and grid ref
102+
if isinstance(figure, BaseFigure):
103+
# A base figure object, can be;
104+
# - a go.FigureWidget
105+
# - a plotly-resampler figure: subclass of AbstractFigureAggregator
106+
# => we first copy the layout, grid_str and grid ref
57107
f.layout = figure.layout
108+
f._grid_str = figure._grid_str
58109
f._grid_ref = figure._grid_ref
59110
f.add_traces(figure.data)
111+
elif isinstance(figure, dict) and (
112+
"data" in figure or "layout" in figure # or "frames" in figure # TODO
113+
):
114+
# A figure as a dict, can be;
115+
# - a plotly figure as a dict (after calling `fig.to_dict()`)
116+
# - a pickled (plotly-resampler) figure (after loading a pickled figure)
117+
# => we first copy the layout, grid_str and grid ref
118+
f.layout = figure.get("layout")
119+
f._grid_str = figure.get("_grid_str")
120+
f._grid_ref = figure.get("_grid_ref")
121+
f.add_traces(figure.get("data"))
122+
# `pr_props` is not None when loading a pickled plotly-resampler figure
123+
f._pr_props = figure.get("pr_props")
124+
# `f._pr_props`` is an attribute to store properties of a
125+
# plotly-resampler figure. This attribute is only used to pass
126+
# information to the super() constructor. Once the super constructor is
127+
# called, the attribute is removed.
128+
129+
# f.add_frames(figure.get("frames")) TODO
60130
elif isinstance(figure, (dict, list)):
61131
# A single trace dict or a list of traces
62132
f.add_traces(figure)
63133

134+
self._show_dash_kwargs = show_dash_kwargs if show_dash_kwargs is not None else {}
135+
64136
super().__init__(
65137
f,
66138
convert_existing_traces,
@@ -129,7 +201,9 @@ def show_dash(
129201
``config`` parameter for this property in this method.
130202
See more https://dash.plotly.com/dash-core-components/graph
131203
**kwargs: dict
132-
Additional app.run_server() kwargs. e.g.: port
204+
Additional app.run_server() kwargs. e.g.: port, ...
205+
Also note that these kwargs take precedence over the ones passed to the
206+
constructor via the ``show_dash_kwargs`` argument.
133207
134208
"""
135209
graph_properties = {} if graph_properties is None else graph_properties
@@ -150,14 +224,19 @@ def show_dash(
150224

151225
# 2. Run the app
152226
if (
153-
self.layout.height is not None
154-
and mode == "inline"
227+
mode == "inline"
155228
and "height" not in kwargs
156229
):
157-
# If figure height is specified -> re-use is for inline dash app height
158-
kwargs["height"] = self.layout.height + 18
230+
# If app height is not specified -> re-use figure height for inline dash app
231+
# Note: default layout height is 450 (whereas default app height is 650)
232+
# See: https://plotly.com/python/reference/layout/#layout-height
233+
fig_height = self.layout.height if self.layout.height is not None else 450
234+
kwargs["height"] = fig_height + 18
235+
236+
# kwargs take precedence over the show_dash_kwargs
237+
kwargs = {**self._show_dash_kwargs, **kwargs}
159238

160-
# store the app information, so it can be killed
239+
# Store the app information, so it can be killed
161240
self._app = app
162241
self._host = kwargs.get("host", "127.0.0.1")
163242
self._port = kwargs.get("port", "8050")
@@ -213,3 +292,11 @@ def register_update_graph_callback(
213292
dash.dependencies.Input(graph_id, "relayoutData"),
214293
prevent_initial_call=True,
215294
)(self.construct_update_data)
295+
296+
def _get_pr_props_keys(self) -> List[str]:
297+
# Add the additional plotly-resampler properties of this class
298+
return super()._get_pr_props_keys() + ["_show_dash_kwargs"]
299+
300+
def _ipython_display_(self):
301+
# To display the figure inline as a dash app
302+
self.show_dash(mode="inline")

plotly_resampler/figure_resampler/figure_resampler_interface.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,21 @@ def __init__(
109109
assert not issubclass(type(figure), AbstractFigureAggregator)
110110
self._figure_class = figure.__class__
111111

112+
# Overwrite the passed arguments with the property dict values
113+
# (this is the case when the PR figure is created from a pickled object)
114+
if hasattr(figure, "_pr_props"):
115+
pr_props = figure._pr_props # a dict of PR properties
116+
if pr_props is not None:
117+
# Overwrite the default arguments with the serialized properties
118+
for k, v in pr_props.items():
119+
setattr(self, k, v)
120+
delattr(figure, "_pr_props") # should not be stored anymore
121+
112122
if convert_existing_traces:
113123
# call __init__ with the correct layout and set the `_grid_ref` of the
114124
# to-be-converted figure
115125
f_ = self._figure_class(layout=figure.layout)
126+
f_._grid_str = figure._grid_str
116127
f_._grid_ref = figure._grid_ref
117128
super().__init__(f_)
118129

@@ -682,7 +693,7 @@ def _parse_get_trace_props(
682693
if hf_y.dtype == "object":
683694
# But first, we try to parse to a numeric dtype (as this is the
684695
# behavior that plotly supports)
685-
# Note that a bool array of type object will remain a bool array (and
696+
# Note that a bool array of type object will remain a bool array (and
686697
# not will be transformed to an array of ints (0, 1))
687698
try:
688699
hf_y = pd.to_numeric(hf_y, errors="raise")
@@ -1256,3 +1267,42 @@ def _re_matches(regex: re.Pattern, strings: Iterable[str]) -> List[str]:
12561267
if m is not None:
12571268
matches.append(m.string)
12581269
return sorted(matches)
1270+
1271+
## Magic methods (to use plotly.py words :grin:)
1272+
1273+
def _get_pr_props_keys(self) -> List[str]:
1274+
"""Returns the keys (i.e., the names) of the plotly-resampler properties.
1275+
1276+
Note
1277+
----
1278+
This method is used to serialize the object in the `__reduce__` method.
1279+
1280+
"""
1281+
return [
1282+
"_hf_data",
1283+
"_global_n_shown_samples",
1284+
"_print_verbose",
1285+
"_show_mean_aggregation_size",
1286+
"_prefix",
1287+
"_suffix",
1288+
"_global_downsampler",
1289+
]
1290+
1291+
def __reduce__(self):
1292+
"""Overwrite the reduce method (which is used to support deep copying and
1293+
pickling).
1294+
1295+
Note
1296+
----
1297+
We do not overwrite the `to_dict` method, as this is used to send the figure
1298+
to the frontend (and thus should not capture the plotly-resampler properties).
1299+
"""
1300+
_, props = super().__reduce__()
1301+
assert len(props) == 1 # I don't know why this would be > 1
1302+
props = props[0]
1303+
1304+
# Add the plotly-resampler properties
1305+
props["pr_props"] = {}
1306+
for k in self._get_pr_props_keys():
1307+
props["pr_props"][k] = getattr(self, k)
1308+
return (self.__class__, (props,)) # (props,) to comply with plotly magic

plotly_resampler/figure_resampler/figurewidget_resampler.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,33 @@ def __init__(
5656
f = self._get_figure_class(go.FigureWidget)()
5757
f._data_validator.set_uid = False
5858

59-
if isinstance(figure, BaseFigure): # go.Figure or go.FigureWidget or AbstractFigureAggregator
60-
# A base figure object, we first copy the layout and grid ref
59+
if isinstance(figure, BaseFigure):
60+
# A base figure object, can be;
61+
# - a base plotly figure: go.Figure or go.FigureWidget
62+
# - a plotly-resampler figure: subclass of AbstractFigureAggregator
63+
# => we first copy the layout, grid_str and grid ref
6164
f.layout = figure.layout
65+
f._grid_str = figure._grid_str
6266
f._grid_ref = figure._grid_ref
6367
f.add_traces(figure.data)
68+
elif isinstance(figure, dict) and (
69+
"data" in figure or "layout" in figure # or "frames" in figure # TODO
70+
):
71+
# A figure as a dict, can be;
72+
# - a plotly figure as a dict (after calling `fig.to_dict()`)
73+
# - a pickled (plotly-resampler) figure (after loading a pickled figure)
74+
f.layout = figure.get("layout")
75+
f._grid_str = figure.get("_grid_str")
76+
f._grid_ref = figure.get("_grid_ref")
77+
f.add_traces(figure.get("data"))
78+
# `pr_props` is not None when loading a pickled plotly-resampler figure
79+
f._pr_props = figure.get("pr_props")
80+
# `f._pr_props`` is an attribute to store properties of a plotly-resampler
81+
# figure. This attribute is only used to pass information to the super()
82+
# constructor. Once the super constructor is called, the attribute is
83+
# removed.
84+
85+
# f.add_frames(figure.get("frames")) TODO
6486
elif isinstance(figure, (dict, list)):
6587
# A single trace dict or a list of traces
6688
f.add_traces(figure)

tests/conftest.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33

44
from typing import Union
55

6+
import os
67
import numpy as np
78
import pandas as pd
89
import plotly.graph_objects as go
910
import pytest
1011
from plotly.subplots import make_subplots
1112

12-
from plotly_resampler import FigureResampler, LTTB, EveryNthPoint, register_plotly_resampler, unregister_plotly_resampler
13+
from plotly_resampler import FigureResampler, LTTB, EveryNthPoint, unregister_plotly_resampler
1314

1415
# hyperparameters
1516
_nb_samples = 10_000
@@ -26,12 +27,27 @@ def registering_cleanup():
2627
unregister_plotly_resampler()
2728

2829

30+
def _remove_file(file_path):
31+
if os.path.exists(file_path):
32+
os.remove(file_path)
33+
34+
@pytest.fixture
35+
def pickle_figure():
36+
FIG_PATH = "fig.pkl"
37+
_remove_file(FIG_PATH)
38+
yield FIG_PATH
39+
_remove_file(FIG_PATH)
40+
41+
2942
@pytest.fixture
3043
def driver():
3144
from seleniumwire import webdriver
3245
from webdriver_manager.chrome import ChromeDriverManager, ChromeType
3346
from selenium.webdriver.chrome.options import Options
3447

48+
import time
49+
time.sleep(1)
50+
3551
options = Options()
3652
if not TESTING_LOCAL:
3753
if headless:
@@ -144,7 +160,7 @@ def example_figure() -> FigureResampler:
144160
name=f"room {i+1}",
145161
),
146162
hf_x=df_data_pc.index,
147-
hf_y=df_data_pc[c],
163+
hf_y=df_data_pc[c].astype(np.float32),
148164
row=2,
149165
col=1,
150166
downsampler=LTTB(interleave_gaps=True),
@@ -216,7 +232,7 @@ def example_figure_fig() -> go.Figure:
216232
go.Scattergl(
217233
name=f"room {i+1}",
218234
x=df_data_pc.index,
219-
y=df_data_pc[c],
235+
y=df_data_pc[c].astype(np.float32),
220236
),
221237
row=2,
222238
col=1,

tests/fr_selenium.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99

1010
from __future__ import annotations
1111

12-
__author__ = "Jonas Van Der Donckt"
12+
__author__ = "Jonas Van Der Donckt, Jeroen Van Der Donckt"
1313

14+
import sys
1415
import json
1516
import time
1617
from datetime import datetime, timedelta
@@ -25,8 +26,20 @@
2526
from selenium.webdriver.support.ui import WebDriverWait
2627

2728

29+
def not_on_linux():
30+
"""Return True if the current platform is not Linux.
31+
32+
Note: this will be used to add more waiting time to windows & mac os tests as
33+
- on these OS's serialization of the figure is necessary (to start the dash app in a
34+
multiprocessing.Process)
35+
https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
36+
- on linux, the browser (i.e., sending & getting requests) goes a lot faster
37+
"""
38+
return not sys.platform.startswith("linux")
39+
40+
2841
# https://www.blazemeter.com/blog/improve-your-selenium-webdriver-tests-with-pytest
29-
# and credate a parameterized driver.get method
42+
# and create a parameterized driver.get method
3043

3144

3245
class RequestParser:
@@ -173,12 +186,14 @@ def go_to_page(self):
173186
time.sleep(1)
174187
self.driver.get("http://localhost:{}".format(self.port))
175188
self.on_page = True
189+
if not_on_linux(): time.sleep(7) # bcs serialization of multiprocessing
176190

177191
def clear_requests(self, sleep_time_s=1):
178192
time.sleep(1)
179193
del self.driver.requests
180194

181195
def get_requests(self, delete: bool = True):
196+
if not_on_linux(): time.sleep(2) # bcs slower browser
182197
requests = self.driver.requests
183198
if delete:
184199
self.clear_requests()

0 commit comments

Comments
 (0)