1+ import logging
12import os
23import time
34from pathlib import Path
45import threading
56
7+ import asyncio
8+ import ipywidgets as widgets
9+
10+ from jdaviz .async_utils import (create_serial_task , queue_screenshot_async ,
11+ run_kernel_events_blocking_until ,
12+ serial_task_run_task , wait_for_change )
613from astropy import units as u
714from astropy .nddata import CCDData
815from glue .core .message import SubsetCreateMessage , SubsetDeleteMessage , SubsetUpdateMessage
3138 HAS_OPENCV = True
3239
3340__all__ = ['Export' ]
41+ logger = logging .getLogger (__name__ )
3442
3543
3644@tray_registry ('export' , label = "Export" ,
@@ -118,6 +126,7 @@ class Export(PluginTemplateMixin, ViewerSelectMixin, SubsetSelectMixin,
118126 # This is a temporary measure to allow server-installations to disable saving server-side until
119127 # saving client-side is supported for all exports.
120128 serverside_enabled = Bool (True ).tag (sync = True )
129+ _busy_doing_export = Bool (False ).tag (sync = True )
121130
122131 def __init__ (self , * args , ** kwargs ):
123132 super ().__init__ (* args , ** kwargs )
@@ -443,7 +452,7 @@ def _normalize_filename(self, filename=None, filetype=None, overwrite=False, def
443452
444453 @with_spinner ()
445454 def export (self , filename = None , show_dialog = None , overwrite = False ,
446- raise_error_for_overwrite = True ):
455+ raise_error_for_overwrite = True , block = True ):
447456 """
448457 Export selected item(s)
449458
@@ -462,6 +471,11 @@ def export(self, filename=None, show_dialog=None, overwrite=False,
462471 If `True`, raise exception when ``overwrite=False`` but
463472 output file already exists. Otherwise, a message will be sent
464473 to application snackbar instead.
474+
475+ block : bool
476+ If `True`, block until the export is complete, this is useful in
477+ a notebook context to ensure the export is complete before the
478+ next export is started.
465479 """
466480 if self .multiselect :
467481 raise NotImplementedError ("batch export not yet supported" )
@@ -508,7 +522,7 @@ def export(self, filename=None, show_dialog=None, overwrite=False,
508522 else :
509523 self .save_figure (viewer , filename , filetype , show_dialog = show_dialog ,
510524 width = f"{ self .image_width } px" if self .image_custom_size else None ,
511- height = f"{ self .image_height } px" if self .image_custom_size else None ) # noqa
525+ height = f"{ self .image_height } px" if self .image_custom_size else None , block = block ) # noqa
512526
513527 # restore marks to their original state
514528 for restore , mark in zip (restores , viewer .figure .marks ):
@@ -534,7 +548,7 @@ def export(self, filename=None, show_dialog=None, overwrite=False,
534548 raise FileExistsError (f"{ filename } exists but overwrite={ overwrite } " )
535549 return
536550
537- self .save_figure (plot , filename , filetype , show_dialog = show_dialog )
551+ self .save_figure (plot , filename , filetype , show_dialog = show_dialog , block = block )
538552
539553 elif len (self .plugin_table .selected ):
540554 filetype = self .plugin_table_format .selected
@@ -620,17 +634,40 @@ def vue_overwrite_from_ui(self, *args, **kwargs):
620634 self .overwrite_warn = False
621635
622636 def save_figure (self , viewer , filename = None , filetype = "png" , show_dialog = False ,
623- width = None , height = None ):
637+ width = None , height = None , block = True ):
624638 if filename is None :
625639 filename = self .filename_default
626640
627- # viewers in plugins will have viewer.app, other viewers have viewer.jdaviz_app
628- if hasattr (viewer , 'jdaviz_app' ):
629- app = viewer .jdaviz_app
630- else :
631- app = viewer .app
641+ if self ._busy_doing_export :
642+ raise ValueError ("Saving figure is still in progress. Use ` export(..., block=True)` to make sure the previous export is complete" ) # noqa
643+ self ._busy_doing_export = True
644+ self ._last_error = None
632645
633- def on_img_received (data ):
646+ async def save_figure_task ():
647+ try :
648+ await self ._save_figure_async (viewer , filename , filetype , show_dialog , width ,
649+ height )
650+ except BaseException as e :
651+ logger .error (f"Error saving figure: { e } " )
652+ self ._last_error = e
653+ finally :
654+ self ._busy_doing_export = False
655+ if block :
656+ event_loop = serial_task_run_task .get ()
657+ logger .warning (f"event loop: { event_loop } , now creating task" )
658+ event_loop .create_task (save_figure_task ())
659+ run_kernel_events_blocking_until (lambda : self ._busy_doing_export )
660+ if self ._last_error is not None :
661+ raise self ._last_error
662+ else :
663+ task = asyncio .create_task (save_figure_task ())
664+ create_serial_task (task )
665+ return task
666+
667+ async def _save_figure_async (self , viewer , filename , filetype , show_dialog , width , height ):
668+ # Things become a bit more easy to reason about using async/await instead of callbacks
669+ # So this internal method uses async/await instead of callbacks.
670+ def save_to_file (data ):
634671 try :
635672 with filename .open (mode = 'bw' ) as f :
636673 f .write (data )
@@ -643,17 +680,15 @@ def on_img_received(data):
643680 f"{ self .viewer .selected } exported to { str (filename )} " ,
644681 sender = self , color = "success" ))
645682
646- def get_png ( figure ):
647- if figure . _upload_png_callback is not None :
648- raise ValueError ( "previous png export is still in progress. Wait to complete before making another call to save_figure" ) # noqa: E501 # pragma: no cover
649-
650- figure . get_png_data ( on_img_received )
683+ # viewers in plugins will have viewer.app, other viewers have viewer.jdaviz_app
684+ if hasattr ( viewer , 'jdaviz_app' ) :
685+ app = viewer . jdaviz_app
686+ else :
687+ app = viewer . app
651688
652689 if (width is not None or height is not None ):
653690 assert width is not None and height is not None , \
654691 "Both width and height must be provided"
655- import ipywidgets as widgets
656- from typing import Callable
657692
658693 def _show_hidden (widget : widgets .Widget , width : str , height : str ):
659694 import ipyvuetify as v
@@ -669,42 +704,54 @@ def _show_hidden(widget: widgets.Widget, width: str, height: str):
669704 # TODO: we might want to remove it from the DOM
670705 app .invisible_children = [* app .invisible_children , wrapper_widget ]
671706
672- def _widget_after_first_display (widget : widgets .Widget , callback : Callable ):
707+ def _widget_after_first_display (widget : widgets .Widget ):
673708 if widget ._view_count is None :
674709 widget ._view_count = 0
675- called_callback = False
676-
677- def view_count_changed (change ):
678- nonlocal called_callback
679- if change ["new" ] == 1 and not called_callback :
680- called_callback = True
681- callback ()
682- widget .observe (view_count_changed , "_view_count" )
710+ logger .debug (f"waiting for view count to change for widget { type (widget )} " )
711+ return wait_for_change (widget , "_view_count" )
683712
684713 cloned_viewer = viewer ._clone_viewer_outside_app ()
685714 # make sure we will the size of our container which defines the
686715 # size of the figure
687716 cloned_viewer .figure .layout .width = "100%"
688717 cloned_viewer .figure .layout .height = "100%"
689718
690- def on_figure_displayed ():
691- # we need a bit of a delay to ensure the figure is fully displayed
692- # maybe this can be fixed on the bqplot side in the future
693- def wait_in_other_thread ():
694- import time
695- time .sleep (0.2 )
696- get_png (cloned_viewer .figure )
697- # wait in other thread to avoid blocking the main thread (widgets can update)
698- threading .Thread (target = wait_in_other_thread ).start ()
699- _widget_after_first_display (cloned_viewer .figure , on_figure_displayed )
719+ logger .debug ("calling _widget_after_first_display for widget" )
720+ display_future = _widget_after_first_display (cloned_viewer .figure )
721+ logger .debug (f"calling _show_hidden for widget { display_future } " )
700722 _show_hidden (cloned_viewer .figure , width , height )
701- elif filetype == 'png' :
702- # NOTE: get_png already check if _upload_png_callback is not None
703- get_png (viewer .figure )
704- elif filetype == 'svg' :
723+ logger .debug ("waiting for display future" )
724+ await display_future
725+ logger .debug ("display future done" )
726+ await asyncio .sleep (0.2 )
727+ logger .debug ("sleeping done" )
728+ if cloned_viewer .figure ._upload_png_callback is not None :
729+ raise ValueError ("previous svg export is still in progress. Wait to complete "
730+ "before making another call to save_figure" )
731+ if cloned_viewer .figure ._upload_svg_callback is not None :
732+ raise ValueError ("previous svg export is still in progress. Wait to complete "
733+ "before making another call to save_figure" )
734+ logger .debug ("queueing screenshot" )
735+ get_image_data_method = cloned_viewer .figure .get_svg_data if filetype == 'svg' else \
736+ cloned_viewer .figure .get_png_data
737+ data = await queue_screenshot_async (cloned_viewer .figure , get_image_data_method )
738+ logger .debug ("got data, saving to file {filename}" )
739+ save_to_file (data )
740+ logger .debug ("saved to file {filename}" )
741+ elif filetype in ['png' , 'svg' ]:
742+ if viewer .figure ._upload_png_callback is not None :
743+ raise ValueError ("previous png export is still in progress. Wait to complete "
744+ "before making another call to save_figure" )
705745 if viewer .figure ._upload_svg_callback is not None :
706- raise ValueError ("previous svg export is still in progress. Wait to complete before making another call to save_figure" ) # noqa
707- viewer .figure .get_svg_data (on_img_received )
746+ raise ValueError ("previous svg export is still in progress. Wait to complete "
747+ "before making another call to save_figure" )
748+ get_image_data_method = viewer .figure .get_svg_data if filetype == 'svg' else \
749+ viewer .figure .get_png_data
750+ logger .debug ("queueing screenshot" )
751+ data = await queue_screenshot_async (viewer .figure , get_image_data_method )
752+ logger .debug ("got data, saving to file {filename}" )
753+ save_to_file (data )
754+ logger .debug ("saved to file {filename}" )
708755 else :
709756 raise ValueError (f"Unsupported filetype={ filetype } for save_figure" )
710757
0 commit comments