Skip to content

Commit 87e173d

Browse files
authored
Andrew/organize test errors (#254)
* Separate into separate files * Separate into separate files * Add timeouts to error log * Add state to profiler * Fix bug in detecting plotly's internal js * Add plotly to dev deps * Add basic tests for page generator * Add changelog
1 parent 4b0dddb commit 87e173d

File tree

11 files changed

+652
-419
lines changed

11 files changed

+652
-419
lines changed

src/py/CHANGELOG.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
v1.0.0rc5
2+
- Fix bug by which plotly's internal JS was ignored
3+
- Adds testing for page generators

src/py/kaleido/_kaleido_tab.py

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import json
5+
import time
6+
from typing import TYPE_CHECKING
7+
8+
import logistro
9+
from choreographer.errors import DevtoolsProtocolError
10+
11+
from ._utils import ErrorEntry, to_thread
12+
13+
if TYPE_CHECKING:
14+
from pathlib import Path
15+
from typing import Any
16+
17+
import choreographer as choreo
18+
19+
_logger = logistro.getLogger(__name__)
20+
21+
_TEXT_FORMATS = ("svg", "json") # eps
22+
23+
24+
class JavascriptError(RuntimeError): # TODO(A): process better # noqa: TD003, FIX002
25+
"""Used to report errors from javascript."""
26+
27+
28+
### Error definitions ###
29+
class KaleidoError(Exception):
30+
"""An error to interpret errors from Kaleido's JS side."""
31+
32+
def __init__(self, code, message):
33+
"""
34+
Construct an error object.
35+
36+
Args:
37+
code: the number code of the error.
38+
message: the message of the error.
39+
40+
"""
41+
super().__init__(message)
42+
self._code = code
43+
self._message = message
44+
45+
def __str__(self):
46+
"""Display the KaleidoError nicely."""
47+
return f"Error {self._code}: {self._message}"
48+
49+
50+
def _check_error(result):
51+
e = _check_error_ret(result)
52+
if e:
53+
raise e
54+
55+
56+
def _check_error_ret(result): # Utility
57+
"""Check browser response for errors. Helper function."""
58+
if "error" in result:
59+
return DevtoolsProtocolError(result)
60+
if result.get("result", {}).get("result", {}).get("subtype", None) == "error":
61+
return JavascriptError(str(result.get("result")))
62+
return None
63+
64+
65+
def _make_console_logger(name, log):
66+
"""Create printer specifically for console events. Helper function."""
67+
68+
async def console_printer(event):
69+
_logger.debug2(f"{name}:{event}") # TODO(A): parse # noqa: TD003, FIX002
70+
log.append(str(event))
71+
72+
return console_printer
73+
74+
75+
class _KaleidoTab:
76+
"""
77+
A Kaleido tab is a wrapped choreographer tab providing the functions we need.
78+
79+
The choreographer tab can be access through the `self.tab` attribute.
80+
"""
81+
82+
tab: choreo.Tab
83+
"""The underlying choreographer tab."""
84+
85+
javascript_log: list[Any]
86+
"""A list of console outputs from the tab."""
87+
88+
def __init__(self, tab, *, _stepper=False):
89+
"""
90+
Create a new _KaleidoTab.
91+
92+
Args:
93+
tab: the choreographer tab to wrap.
94+
95+
"""
96+
self.tab = tab
97+
self.javascript_log = []
98+
self._stepper = _stepper
99+
100+
def _regenerate_javascript_console(self):
101+
tab = self.tab
102+
self.javascript_log = []
103+
_logger.debug2("Subscribing to all console prints for tab {tab}.")
104+
tab.unsubscribe("Runtime.consoleAPICalled")
105+
tab.subscribe(
106+
"Runtime.consoleAPICalled",
107+
_make_console_logger("tab js console", self.javascript_log),
108+
)
109+
110+
async def navigate(self, url: str | Path = ""):
111+
"""
112+
Navigate to the kaleidofier script. This is effectively the real initialization.
113+
114+
Args:
115+
url: Override the location of the kaleidofier script if necessary.
116+
117+
"""
118+
tab = self.tab
119+
javascript_ready = tab.subscribe_once("Runtime.executionContextCreated")
120+
while javascript_ready.done():
121+
_logger.debug2("Clearing an old Runtime.executionContextCreated")
122+
javascript_ready = tab.subscribe_once("Runtime.executionContextCreated")
123+
page_ready = tab.subscribe_once("Page.loadEventFired")
124+
while page_ready.done():
125+
_logger.debug2("Clearing a old Page.loadEventFired")
126+
page_ready = tab.subscribe_once("Page.loadEventFired")
127+
128+
_logger.debug2(f"Calling Page.navigate on {tab}")
129+
_check_error(await tab.send_command("Page.navigate", params={"url": url}))
130+
# Must enable after navigating.
131+
_logger.debug2(f"Calling Page.enable on {tab}")
132+
_check_error(await tab.send_command("Page.enable"))
133+
_logger.debug2(f"Calling Runtime.enable on {tab}")
134+
_check_error(await tab.send_command("Runtime.enable"))
135+
136+
await javascript_ready
137+
self._current_js_id = (
138+
javascript_ready.result()
139+
.get("params", {})
140+
.get("context", {})
141+
.get("id", None)
142+
)
143+
if not self._current_js_id:
144+
raise RuntimeError(
145+
"Refresh sequence didn't work for reload_tab_with_javascript."
146+
"Result {javascript_ready.result()}.",
147+
)
148+
await page_ready
149+
self._regenerate_javascript_console()
150+
151+
async def reload(self):
152+
"""Reload the tab, and set the javascript runtime id."""
153+
tab = self.tab
154+
_logger.debug(f"Reloading tab {tab} with javascript.")
155+
javascript_ready = tab.subscribe_once("Runtime.executionContextCreated")
156+
while javascript_ready.done():
157+
_logger.debug2("Clearing an old Runtime.executionContextCreated")
158+
javascript_ready = tab.subscribe_once("Runtime.executionContextCreated")
159+
is_loaded = tab.subscribe_once("Page.loadEventFired")
160+
while is_loaded.done():
161+
_logger.debug2("Clearing an old Page.loadEventFired")
162+
is_loaded = tab.subscribe_once("Page.loadEventFired")
163+
_logger.debug2(f"Calling Page.reload on {tab}")
164+
_check_error(await tab.send_command("Page.reload"))
165+
await javascript_ready
166+
self._current_js_id = (
167+
javascript_ready.result()
168+
.get("params", {})
169+
.get("context", {})
170+
.get("id", None)
171+
)
172+
if not self._current_js_id:
173+
raise RuntimeError(
174+
"Refresh sequence didn't work for reload_tab_with_javascript."
175+
"Result {javascript_ready.result()}.",
176+
)
177+
await is_loaded
178+
self._regenerate_javascript_console()
179+
180+
async def console_print(self, message: str) -> None:
181+
"""
182+
Print something to the javascript console.
183+
184+
Args:
185+
message: The thing to print.
186+
187+
"""
188+
jsfn = r"function()" r"{" f"console.log('{message}')" r"}"
189+
params = {
190+
"functionDeclaration": jsfn,
191+
"returnByValue": False,
192+
"userGesture": True,
193+
"awaitPromise": True,
194+
"executionContextId": self._current_js_id,
195+
}
196+
197+
# send request to run script in chromium
198+
_logger.debug("Calling js function")
199+
result = await self.tab.send_command("Runtime.callFunctionOn", params=params)
200+
_logger.debug(f"Sent javascript got result: {result}")
201+
_check_error(result)
202+
203+
def _finish_profile(self, profile, state, error=None, size_mb=None):
204+
_logger.debug("Finishing profile")
205+
profile["duration"] = float(f"{time.perf_counter() - profile['start']:.6f}")
206+
del profile["start"]
207+
profile["state"] = state
208+
if self.javascript_log:
209+
profile["js_console"] = self.javascript_log
210+
if error:
211+
profile["error"] = error
212+
if size_mb:
213+
profile["megabytes"] = size_mb
214+
215+
async def _write_fig( # noqa: C901, PLR0915 too much complexity, statements
216+
self,
217+
spec,
218+
full_path,
219+
*,
220+
topojson=None,
221+
error_log=None,
222+
profiler=None,
223+
):
224+
"""
225+
Call the plotly renderer via javascript.
226+
227+
Args:
228+
spec: the processed plotly figure
229+
full_path: the path to write the image too. if its a directory, we will try
230+
to generate a name. If the path contains an extension,
231+
"path/to/my_image.png", that extension will be the format used if not
232+
overridden in `opts`.
233+
opts: dictionary describing format, width, height, and scale of image
234+
topojson: topojsons are used to customize choropleths
235+
error_log: A supplied list, will be populated with `ErrorEntry`s
236+
which can be converted to strings. Note, this is for
237+
collections errors that have to do with plotly. They will
238+
not be thrown. Lower level errors (kaleido, choreographer)
239+
will still be thrown. If not passed, all errors raise.
240+
profiler: a supplied dictionary to collect stats about the operation
241+
242+
"""
243+
if profiler is not None:
244+
profile = {
245+
"name": full_path.name,
246+
"start": time.perf_counter(),
247+
"state": "INIT",
248+
}
249+
_logger.info(f"Value of stepper: {self._stepper}")
250+
tab = self.tab
251+
_logger.debug(f"In tab {tab.target_id[:4]} write_fig for {full_path.name}.")
252+
execution_context_id = self._current_js_id
253+
254+
_logger.info(f"Processing {full_path.name}")
255+
# js script
256+
kaleido_jsfn = (
257+
r"function(spec, ...args)"
258+
r"{"
259+
r"return kaleido_scopes.plotly(spec, ...args).then(JSON.stringify);"
260+
r"}"
261+
)
262+
263+
# params
264+
arguments = [{"value": spec}]
265+
arguments.append({"value": topojson if topojson else None})
266+
arguments.append({"value": self._stepper})
267+
params = {
268+
"functionDeclaration": kaleido_jsfn,
269+
"arguments": arguments,
270+
"returnByValue": False,
271+
"userGesture": True,
272+
"awaitPromise": True,
273+
"executionContextId": execution_context_id,
274+
}
275+
276+
_logger.info(f"Sending big command for {full_path.name}.")
277+
profile["state"] = "SENDING"
278+
result = await tab.send_command("Runtime.callFunctionOn", params=params)
279+
profile["state"] = "SENT"
280+
_logger.info(f"Sent big command for {full_path.name}.")
281+
e = _check_error_ret(result)
282+
if e:
283+
if profiler is not None:
284+
self._finish_profile(profile, "ERROR", e)
285+
profiler[tab.target_id].append(profile)
286+
if error_log is not None:
287+
error_log.append(ErrorEntry(full_path.name, e, self.javascript_log))
288+
_logger.error(f"Failed {full_path.name}", exc_info=e)
289+
else:
290+
_logger.error(f"Raising error on {full_path.name}")
291+
raise e
292+
_logger.debug2(f"Result of function call: {result}")
293+
if self._stepper:
294+
print(f"Image {full_path.name} was sent to browser") # noqa: T201
295+
input("Press Enter to continue...")
296+
if e:
297+
return
298+
299+
img = await self._img_from_response(result)
300+
if isinstance(img, BaseException):
301+
if profiler is not None:
302+
self._finish_profile(profile, "ERROR", img)
303+
profiler[tab.target_id].append(profile)
304+
if error_log is not None:
305+
error_log.append(
306+
ErrorEntry(full_path.name, img, self.javascript_log),
307+
)
308+
_logger.info(f"Failed {full_path.name}")
309+
return
310+
else:
311+
raise img
312+
313+
def write_image(binary):
314+
with full_path.open("wb") as file:
315+
file.write(binary)
316+
317+
_logger.info(f"Starting write of {full_path.name}")
318+
await to_thread(write_image, img)
319+
_logger.info(f"Wrote {full_path.name}")
320+
if profiler is not None:
321+
self._finish_profile(
322+
profile,
323+
"WROTE",
324+
None,
325+
full_path.stat().st_size / 1000000,
326+
)
327+
profiler[tab.target_id].append(profile)
328+
329+
async def _img_from_response(self, response):
330+
js_response = json.loads(response.get("result").get("result").get("value"))
331+
332+
if js_response["code"] != 0:
333+
return KaleidoError(js_response["code"], js_response["message"])
334+
335+
response_format = js_response.get("format")
336+
img = js_response.get("result")
337+
if response_format == "pdf":
338+
pdf_params = {
339+
"printBackground": True,
340+
"marginTop": 0.1,
341+
"marginBottom": 0.1,
342+
"marginLeft": 0.1,
343+
"marginRight": 0.1,
344+
"preferCSSPageSize": False,
345+
"pageRanges": "1",
346+
}
347+
pdf_response = await self.tab.send_command(
348+
"Page.printToPDF",
349+
params=pdf_params,
350+
)
351+
e = _check_error_ret(pdf_response)
352+
if e:
353+
return e
354+
img = pdf_response.get("result").get("data")
355+
# Base64 decode binary types
356+
if response_format not in _TEXT_FORMATS:
357+
img = base64.b64decode(img)
358+
else:
359+
img = str.encode(img)
360+
return img

src/py/kaleido/_mocker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,6 @@ async def _main(error_log=None, profiler=None):
176176
args.n = 1
177177
args.headless = False
178178
args.timeout = 0
179-
kaleido.kaleido.set_stepper()
180179
if args.format == "svg":
181180
warnings.warn(
182181
"Stepper won't render svgs. It's feasible, "
@@ -190,6 +189,7 @@ async def _main(error_log=None, profiler=None):
190189
n=args.n,
191190
headless=args.headless,
192191
timeout=args.timeout,
192+
stepper=args.stepper,
193193
) as k:
194194
await k.write_fig_from_object(
195195
_load_figures_from_paths(paths),

0 commit comments

Comments
 (0)