def test_serialize_final_report(self): report = Report("/not/a/script.py", "") _enqueue(report, INIT_MSG) _enqueue(report, TEXT_DELTA_MSG) _enqueue(report, EMPTY_DELTA_MSG) files = report.serialize_final_report_to_files() # Validate our messages. messages = [_parse_msg(msg_string) for _, msg_string in files[:-1]] self.assertEqual(3, len(messages)) self.assertEqual("initialize", messages[0].WhichOneof("type")) self.assertEqual("text1", messages[1].delta.new_element.text.body) self.assertEqual("empty", messages[2].delta.new_element.WhichOneof("type")) # Validate the manifest, which should be the final file. _, manifest_string = files[-1] manifest = StaticManifest() manifest.ParseFromString(manifest_string) self.assertEqual("script", manifest.name) self.assertEqual(3, manifest.num_messages) self.assertEqual(StaticManifest.DONE, manifest.server_status) self.assertEqual("", manifest.external_server_ip) self.assertEqual("", manifest.internal_server_ip) self.assertEqual(0, manifest.server_port)
class ReportSession(object): """ Contains session data for a single "user" of an active report (that is, a connected browser tab). Each ReportSession has its own Report, root DeltaGenerator, ScriptRunner, and widget state. A ReportSession is attached to each thread involved in running its Report. """ _next_id = 0 def __init__(self, ioloop, script_path, script_argv): """Initialize the ReportSession. Parameters ---------- ioloop : tornado.ioloop.IOLoop The Tornado IOLoop that we're running within script_path : str Path of the Python file from which this report is generated. script_argv : list of str Command-line arguments to run the script with. """ # Each ReportSession gets a unique ID self.id = ReportSession._next_id ReportSession._next_id += 1 self._ioloop = ioloop self._report = Report(script_path, script_argv) self._state = ReportSessionState.REPORT_NOT_RUNNING self._main_dg = DeltaGenerator(self.enqueue, container=BlockPath.MAIN) self._sidebar_dg = DeltaGenerator(self.enqueue, container=BlockPath.SIDEBAR) self._widget_states = WidgetStates() self._local_sources_watcher = LocalSourcesWatcher( self._report, self._on_source_file_changed) self._sent_initialize_message = False self._storage = None self._maybe_reuse_previous_run = False self._run_on_save = config.get_option('server.runOnSave') # The ScriptRequestQueue is the means by which we communicate # with the active ScriptRunner. self._script_request_queue = ScriptRequestQueue() self._scriptrunner = None LOGGER.debug('ReportSession initialized (id=%s)', self.id) def flush_browser_queue(self): """Clears the report queue and returns the messages it contained. The Server calls this periodically to deliver new messages to the browser connected to this report. Returns ------- list[ForwardMsg] The messages that were removed from the queue and should be delivered to the browser. """ return self._report.flush_browser_queue() def shutdown(self): """Shuts down the ReportSession. It's an error to use a ReportSession after it's been shut down. """ if self._state != ReportSessionState.SHUTDOWN_REQUESTED: LOGGER.debug('Shutting down (id=%s)', self.id) # Shut down the ScriptRunner, if one is active. # self._state must not be set to SHUTDOWN_REQUESTED until # after this is called. if self._scriptrunner is not None: self._enqueue_script_request(ScriptRequest.SHUTDOWN) self._state = ReportSessionState.SHUTDOWN_REQUESTED self._local_sources_watcher.close() def enqueue(self, msg): """Enqueues a new ForwardMsg to our browser queue. This can be called on both the main thread and a ScriptRunner run thread. Parameters ---------- msg : ForwardMsg The message to enqueue Returns ------- bool True if the message was enqueued, or False if client.displayEnabled is not set """ if not config.get_option('client.displayEnabled'): return False # If we have an active ScriptRunner, signal that it can handle # an execution control request. (Copy the scriptrunner reference # to avoid it being unset from underneath us, as this function can # be called outside the main thread.) scriptrunner = self._scriptrunner if scriptrunner is not None: scriptrunner.maybe_handle_execution_control_request() self._report.enqueue(msg) return True def enqueue_exception(self, e): """Enqueues an Exception message. Parameters ---------- e : BaseException """ # This does a few things: # 1) Clears the current report in the browser. # 2) Marks the current report as "stopped" in the browser. # 3) HACK: Resets any script params that may have been broken (e.g. the # command-line when rerunning with wrong argv[0]) self._on_scriptrunner_event( ScriptRunnerEvent.SCRIPT_STOPPED_WITH_SUCCESS) self._on_scriptrunner_event(ScriptRunnerEvent.SCRIPT_STARTED) self._on_scriptrunner_event( ScriptRunnerEvent.SCRIPT_STOPPED_WITH_SUCCESS) msg = ForwardMsg() msg.delta.id = 0 exception_proto.marshall(msg.delta.new_element.exception, e) self.enqueue(msg) def request_rerun(self, argv=None, widget_state=None): """Signal that we're interested in running the script. If the script is not already running, it will be started immediately. Otherwise, a rerun will be requested. Parameters ---------- argv : dict | None The new command line arguments to run the script with, or None to use the argv from the previous run of the script. widget_state : dict | None The widget state dictionary to run the script with, or None to use the widget state from the previous run of the script. """ self._enqueue_script_request(ScriptRequest.RERUN, RerunData(argv, widget_state)) def _on_source_file_changed(self): """One of our source files changed. Schedule a rerun if appropriate.""" if self._run_on_save: self.request_rerun() else: self._enqueue_file_change_message() def _clear_queue(self): self._report.clear() def _on_scriptrunner_event(self, event, exception=None, widget_states=None): """Called when our ScriptRunner emits an event. This is *not* called on the main thread. Parameters ---------- event : ScriptRunnerEvent exception : BaseException | None An exception thrown during compilation. Set only for the SCRIPT_STOPPED_WITH_COMPILE_ERROR event. widget_states : streamlit.proto.Widget_pb2.WidgetStates | None The ScriptRunner's final WidgetStates. Set only for the SHUTDOWN event. """ LOGGER.debug('OnScriptRunnerEvent: %s', event) prev_state = self._state if event == ScriptRunnerEvent.SCRIPT_STARTED: if self._state != ReportSessionState.SHUTDOWN_REQUESTED: self._state = ReportSessionState.REPORT_IS_RUNNING if config.get_option('server.liveSave'): # Enqueue into the IOLoop so it runs without blocking AND runs # on the main thread. self._ioloop.spawn_callback(self._save_running_report) self._clear_queue() self._maybe_enqueue_initialize_message() self._enqueue_new_report_message() elif (event == ScriptRunnerEvent.SCRIPT_STOPPED_WITH_SUCCESS or event == ScriptRunnerEvent.SCRIPT_STOPPED_WITH_COMPILE_ERROR): if self._state != ReportSessionState.SHUTDOWN_REQUESTED: self._state = ReportSessionState.REPORT_NOT_RUNNING self._enqueue_report_finished_message() if config.get_option('server.liveSave'): # Enqueue into the IOLoop so it runs without blocking AND runs # on the main thread. self._ioloop.spawn_callback(self._save_final_report_and_quit) script_succeeded = \ event == ScriptRunnerEvent.SCRIPT_STOPPED_WITH_SUCCESS if script_succeeded: # When a script completes successfully, we update our # LocalSourcesWatcher to account for any source code changes # that change which modules should be watched. (This is run on # the main thread, because LocalSourcesWatcher is not # thread safe.) self._ioloop.spawn_callback( self._local_sources_watcher.update_watched_modules) else: # When a script fails to compile, we send along the exception. from streamlit.elements import exception_proto msg = ForwardMsg() exception_proto.marshall( msg.session_event.script_compilation_exception, exception) self.enqueue(msg) elif event == ScriptRunnerEvent.SHUTDOWN: # When ScriptRunner shuts down, update our local reference to it, # and check to see if we need to spawn a new one. (This is run on # the main thread.) def on_shutdown(): self._widget_states = widget_states self._scriptrunner = None # Because a new ScriptEvent could have been enqueued while the # scriptrunner was shutting down, we check to see if we should # create a new one. (Otherwise, a newly-enqueued ScriptEvent # won't be processed until another event is enqueued.) self._maybe_create_scriptrunner() self._ioloop.spawn_callback(on_shutdown) # Send a message if our run state changed report_was_running = prev_state == ReportSessionState.REPORT_IS_RUNNING report_is_running = self._state == ReportSessionState.REPORT_IS_RUNNING if report_is_running != report_was_running: self._enqueue_session_state_changed_message() def _enqueue_session_state_changed_message(self): msg = ForwardMsg() msg.session_state_changed.run_on_save = self._run_on_save msg.session_state_changed.report_is_running = \ self._state == ReportSessionState.REPORT_IS_RUNNING self.enqueue(msg) def _enqueue_file_change_message(self): LOGGER.debug('Enqueuing report_changed message (id=%s)', self.id) msg = ForwardMsg() msg.session_event.report_changed_on_disk = True self.enqueue(msg) def _maybe_enqueue_initialize_message(self): if self._sent_initialize_message: return self._sent_initialize_message = True msg = ForwardMsg() imsg = msg.initialize imsg.config.sharing_enabled = (config.get_option('global.sharingMode') != 'off') LOGGER.debug('New browser connection: sharing_enabled=%s', imsg.config.sharing_enabled) imsg.config.gather_usage_stats = ( config.get_option('browser.gatherUsageStats')) LOGGER.debug('New browser connection: gather_usage_stats=%s', imsg.config.gather_usage_stats) imsg.environment_info.streamlit_version = __version__ imsg.environment_info.python_version = ('.'.join( map(str, sys.version_info))) imsg.session_state.run_on_save = self._run_on_save imsg.session_state.report_is_running = ( self._state == ReportSessionState.REPORT_IS_RUNNING) imsg.user_info.installation_id = __installation_id__ imsg.user_info.email = Credentials.get_current().activation.email self.enqueue(msg) def _enqueue_new_report_message(self): self._report.generate_new_id() msg = ForwardMsg() msg.new_report.id = self._report.report_id msg.new_report.command_line.extend(self._report.argv) msg.new_report.name = self._report.name msg.new_report.script_path = self._report.script_path self.enqueue(msg) def _enqueue_report_finished_message(self): msg = ForwardMsg() msg.report_finished = True self.enqueue(msg) def handle_rerun_script_request(self, command_line=None, widget_state=None, is_preheat=False): """Tells the ScriptRunner to re-run its report. Parameters ---------- command_line : str | None The new command line arguments to run the script with, or None to use its previous command line value. widget_state : WidgetStates | None The WidgetStates protobuf to run the script with, or None to use its previous widget states. is_preheat: boolean True if this ReportSession should run the script immediately, and then ignore the next rerun request if it matches the already-ran argv and widget state. """ old_argv = self._report.argv if command_line is not None: self._report.parse_argv_from_command_line(command_line) if is_preheat: self._maybe_reuse_previous_run = True # For next time. elif self._maybe_reuse_previous_run: # If this is a "preheated" ReportSession, reuse the previous run if # the argv and widget state matches. But only do this one time # ever. self._maybe_reuse_previous_run = False has_widget_state = (widget_state is not None and len(widget_state.widgets) > 0) has_new_argv = old_argv != self._report.argv if not has_widget_state and not has_new_argv: LOGGER.debug( 'Skipping rerun since the preheated run is the same') return self.request_rerun(self._report.argv, widget_state) def handle_stop_script_request(self): """Tells the ScriptRunner to stop running its report.""" self._enqueue_script_request(ScriptRequest.STOP) def handle_clear_cache_request(self): """Clears this report's cache. Because this cache is global, it will be cleared for all users. """ # Setting verbose=True causes clear_cache to print to stdout. # Since this command was initiated from the browser, the user # doesn't need to see the results of the command in their # terminal. caching.clear_cache() def handle_set_run_on_save_request(self, new_value): """Changes our run_on_save flag to the given value. The browser will be notified of the change. Parameters ---------- new_value : bool New run_on_save value """ self._run_on_save = new_value self._enqueue_session_state_changed_message() def _enqueue_script_request(self, request, data=None): """Enqueue a ScriptEvent into our ScriptEventQueue. If a script thread is not already running, one will be created to handle the event. Parameters ---------- request : ScriptRequest The type of request. data : Any Data associated with the request, if any. """ if self._state == ReportSessionState.SHUTDOWN_REQUESTED: LOGGER.warning('Discarding %s request after shutdown' % request) return self._script_request_queue.enqueue(request, data) self._maybe_create_scriptrunner() def _maybe_create_scriptrunner(self): """Create a new ScriptRunner if we have unprocessed script requests. This is called every time a ScriptRequest is enqueued, and also after a ScriptRunner shuts down, in case new requests were enqueued during its termination. This function should only be called on the main thread. """ if (self._state == ReportSessionState.SHUTDOWN_REQUESTED or self._scriptrunner is not None or not self._script_request_queue.has_request): return # Create the ScriptRunner, attach event handlers, and start it self._scriptrunner = ScriptRunner( report=self._report, main_dg=self._main_dg, sidebar_dg=self._sidebar_dg, widget_states=self._widget_states, request_queue=self._script_request_queue) self._scriptrunner.on_event.connect(self._on_scriptrunner_event) self._scriptrunner.start() @tornado.gen.coroutine def handle_save_request(self, ws): """Save serialized version of report deltas to the cloud.""" @tornado.gen.coroutine def progress(percent): progress_msg = ForwardMsg() progress_msg.upload_report_progress = percent yield ws.write_message(progress_msg.SerializeToString(), binary=True) # Indicate that the save is starting. try: yield progress(0) url = yield self._save_final_report(progress) # Indicate that the save is done. progress_msg = ForwardMsg() progress_msg.report_uploaded = url yield ws.write_message(progress_msg.SerializeToString(), binary=True) except Exception as e: # Horrible hack to show something if something breaks. err_msg = '%s: %s' % (type(e).__name__, str(e) or 'No further details.') progress_msg = ForwardMsg() progress_msg.report_uploaded = err_msg yield ws.write_message(progress_msg.SerializeToString(), binary=True) raise e @tornado.gen.coroutine def _save_running_report(self): files = self._report.serialize_running_report_to_files() url = yield self._get_storage().save_report_files( self._report.report_id, files) if config.get_option('server.liveSave'): util.print_url('Saved running report', url) raise tornado.gen.Return(url) @tornado.gen.coroutine def _save_final_report(self, progress=None): files = self._report.serialize_final_report_to_files() url = yield self._get_storage().save_report_files( self._report.report_id, files, progress) if config.get_option('server.liveSave'): util.print_url('Saved final report', url) raise tornado.gen.Return(url) @tornado.gen.coroutine def _save_final_report_and_quit(self): yield self._save_final_report() self._ioloop.stop() def _get_storage(self): if self._storage is None: self._storage = Storage() return self._storage
class ReportSession(object): """ Contains session data for a single "user" of an active report (that is, a connected browser tab). Each ReportSession has its own Report, root DeltaGenerator, ScriptRunner, and widget state. A ReportSession is attached to each thread involved in running its Report. """ _next_id = 0 def __init__(self, ioloop, script_path, command_line): """Initialize the ReportSession. Parameters ---------- ioloop : tornado.ioloop.IOLoop The Tornado IOLoop that we're running within. script_path : str Path of the Python file from which this report is generated. command_line : str Command line as input by the user. """ # Each ReportSession gets a unique ID self.id = ReportSession._next_id ReportSession._next_id += 1 self._ioloop = ioloop self._report = Report(script_path, command_line) self._state = ReportSessionState.REPORT_NOT_RUNNING self._uploaded_file_mgr = UploadedFileManager() self._widget_states = WidgetStates() self._local_sources_watcher = LocalSourcesWatcher( self._report, self._on_source_file_changed) self._sent_initialize_message = False self._storage = None self._maybe_reuse_previous_run = False self._run_on_save = config.get_option("server.runOnSave") # The ScriptRequestQueue is the means by which we communicate # with the active ScriptRunner. self._script_request_queue = ScriptRequestQueue() self._scriptrunner = None LOGGER.debug("ReportSession initialized (id=%s)", self.id) def flush_browser_queue(self): """Clears the report queue and returns the messages it contained. The Server calls this periodically to deliver new messages to the browser connected to this report. Returns ------- list[ForwardMsg] The messages that were removed from the queue and should be delivered to the browser. """ return self._report.flush_browser_queue() def shutdown(self): """Shuts down the ReportSession. It's an error to use a ReportSession after it's been shut down. """ if self._state != ReportSessionState.SHUTDOWN_REQUESTED: LOGGER.debug("Shutting down (id=%s)", self.id) self._uploaded_file_mgr.delete_all_files() # Shut down the ScriptRunner, if one is active. # self._state must not be set to SHUTDOWN_REQUESTED until # after this is called. if self._scriptrunner is not None: self._enqueue_script_request(ScriptRequest.SHUTDOWN) self._state = ReportSessionState.SHUTDOWN_REQUESTED self._local_sources_watcher.close() def enqueue(self, msg): """Enqueues a new ForwardMsg to our browser queue. This can be called on both the main thread and a ScriptRunner run thread. Parameters ---------- msg : ForwardMsg The message to enqueue """ if not config.get_option("client.displayEnabled"): return # Avoid having two maybe_handle_execution_control_request running on # top of each other when tracer is installed. This leads to a lock # contention. if not config.get_option("runner.installTracer"): # If we have an active ScriptRunner, signal that it can handle an # execution control request. (Copy the scriptrunner reference to # avoid it being unset from underneath us, as this function can be # called outside the main thread.) scriptrunner = self._scriptrunner if scriptrunner is not None: scriptrunner.maybe_handle_execution_control_request() self._report.enqueue(msg) def enqueue_exception(self, e): """Enqueues an Exception message. Parameters ---------- e : BaseException """ # This does a few things: # 1) Clears the current report in the browser. # 2) Marks the current report as "stopped" in the browser. # 3) HACK: Resets any script params that may have been broken (e.g. the # command-line when rerunning with wrong argv[0]) self._on_scriptrunner_event( ScriptRunnerEvent.SCRIPT_STOPPED_WITH_SUCCESS) self._on_scriptrunner_event(ScriptRunnerEvent.SCRIPT_STARTED) self._on_scriptrunner_event( ScriptRunnerEvent.SCRIPT_STOPPED_WITH_SUCCESS) msg = ForwardMsg() msg.metadata.delta_id = 0 exception_proto.marshall(msg.delta.new_element.exception, e) self.enqueue(msg) def request_rerun(self, widget_state=None): """Signal that we're interested in running the script. If the script is not already running, it will be started immediately. Otherwise, a rerun will be requested. Parameters ---------- widget_state : dict | None The widget state dictionary to run the script with, or None to use the widget state from the previous run of the script. """ self._enqueue_script_request(ScriptRequest.RERUN, RerunData(widget_state)) def _on_source_file_changed(self): """One of our source files changed. Schedule a rerun if appropriate.""" if self._run_on_save: self.request_rerun() else: self._enqueue_file_change_message() def _clear_queue(self): self._report.clear() def _on_scriptrunner_event(self, event, exception=None, widget_states=None): """Called when our ScriptRunner emits an event. This is *not* called on the main thread. Parameters ---------- event : ScriptRunnerEvent exception : BaseException | None An exception thrown during compilation. Set only for the SCRIPT_STOPPED_WITH_COMPILE_ERROR event. widget_states : streamlit.proto.Widget_pb2.WidgetStates | None The ScriptRunner's final WidgetStates. Set only for the SHUTDOWN event. """ LOGGER.debug("OnScriptRunnerEvent: %s", event) prev_state = self._state if event == ScriptRunnerEvent.SCRIPT_STARTED: if self._state != ReportSessionState.SHUTDOWN_REQUESTED: self._state = ReportSessionState.REPORT_IS_RUNNING if config.get_option("server.liveSave"): # Enqueue into the IOLoop so it runs without blocking AND runs # on the main thread. self._ioloop.spawn_callback(self._save_running_report) self._clear_queue() self._maybe_enqueue_initialize_message() self._enqueue_new_report_message() elif (event == ScriptRunnerEvent.SCRIPT_STOPPED_WITH_SUCCESS or event == ScriptRunnerEvent.SCRIPT_STOPPED_WITH_COMPILE_ERROR): if self._state != ReportSessionState.SHUTDOWN_REQUESTED: self._state = ReportSessionState.REPORT_NOT_RUNNING script_succeeded = event == ScriptRunnerEvent.SCRIPT_STOPPED_WITH_SUCCESS self._enqueue_report_finished_message( ForwardMsg.FINISHED_SUCCESSFULLY if script_succeeded else ForwardMsg.FINISHED_WITH_COMPILE_ERROR) if config.get_option("server.liveSave"): # Enqueue into the IOLoop so it runs without blocking AND runs # on the main thread. self._ioloop.spawn_callback(self._save_final_report_and_quit) if script_succeeded: # When a script completes successfully, we update our # LocalSourcesWatcher to account for any source code changes # that change which modules should be watched. (This is run on # the main thread, because LocalSourcesWatcher is not # thread safe.) self._ioloop.spawn_callback( self._local_sources_watcher.update_watched_modules) else: # When a script fails to compile, we send along the exception. from streamlit.elements import exception_proto msg = ForwardMsg() exception_proto.marshall( msg.session_event.script_compilation_exception, exception) self.enqueue(msg) elif event == ScriptRunnerEvent.SHUTDOWN: # When ScriptRunner shuts down, update our local reference to it, # and check to see if we need to spawn a new one. (This is run on # the main thread.) def on_shutdown(): self._widget_states = widget_states self._scriptrunner = None # Because a new ScriptEvent could have been enqueued while the # scriptrunner was shutting down, we check to see if we should # create a new one. (Otherwise, a newly-enqueued ScriptEvent # won't be processed until another event is enqueued.) self._maybe_create_scriptrunner() self._ioloop.spawn_callback(on_shutdown) # Send a message if our run state changed report_was_running = prev_state == ReportSessionState.REPORT_IS_RUNNING report_is_running = self._state == ReportSessionState.REPORT_IS_RUNNING if report_is_running != report_was_running: self._enqueue_session_state_changed_message() def _enqueue_session_state_changed_message(self): msg = ForwardMsg() msg.session_state_changed.run_on_save = self._run_on_save msg.session_state_changed.report_is_running = ( self._state == ReportSessionState.REPORT_IS_RUNNING) self.enqueue(msg) def _enqueue_file_change_message(self): LOGGER.debug("Enqueuing report_changed message (id=%s)", self.id) msg = ForwardMsg() msg.session_event.report_changed_on_disk = True self.enqueue(msg) def _maybe_enqueue_initialize_message(self): if self._sent_initialize_message: return self._sent_initialize_message = True msg = ForwardMsg() imsg = msg.initialize imsg.config.sharing_enabled = config.get_option( "global.sharingMode") != "off" imsg.config.gather_usage_stats = config.get_option( "browser.gatherUsageStats") imsg.config.max_cached_message_age = config.get_option( "global.maxCachedMessageAge") imsg.config.mapbox_token = config.get_option("mapbox.token") LOGGER.debug( "New browser connection: " "gather_usage_stats=%s, " "sharing_enabled=%s, " "max_cached_message_age=%s", imsg.config.gather_usage_stats, imsg.config.sharing_enabled, imsg.config.max_cached_message_age, ) imsg.environment_info.streamlit_version = __version__ imsg.environment_info.python_version = ".".join( map(str, sys.version_info)) imsg.session_state.run_on_save = self._run_on_save imsg.session_state.report_is_running = ( self._state == ReportSessionState.REPORT_IS_RUNNING) imsg.user_info.installation_id = __installation_id__ if Credentials.get_current().activation: imsg.user_info.email = Credentials.get_current().activation.email else: imsg.user_info.email = "" imsg.command_line = self._report.command_line self.enqueue(msg) def _enqueue_new_report_message(self): self._report.generate_new_id() msg = ForwardMsg() msg.new_report.id = self._report.report_id msg.new_report.name = self._report.name msg.new_report.script_path = self._report.script_path self.enqueue(msg) def _enqueue_report_finished_message(self, status): """Enqueues a report_finished ForwardMsg. Parameters ---------- status : ReportFinishedStatus """ msg = ForwardMsg() msg.report_finished = status self.enqueue(msg) def handle_rerun_script_request(self, command_line=None, widget_state=None, is_preheat=False): """Tells the ScriptRunner to re-run its report. Parameters ---------- command_line : str | None The new command line arguments to run the script with, or None to use its previous command line value. widget_state : WidgetStates | None The WidgetStates protobuf to run the script with, or None to use its previous widget states. is_preheat: boolean True if this ReportSession should run the script immediately, and then ignore the next rerun request if it matches the already-ran widget state. """ if is_preheat: self._maybe_reuse_previous_run = True # For next time. elif self._maybe_reuse_previous_run: # If this is a "preheated" ReportSession, reuse the previous run if # the widget state matches. But only do this one time ever. self._maybe_reuse_previous_run = False has_widget_state = (widget_state is not None and len(widget_state.widgets) > 0) if not has_widget_state: LOGGER.debug( "Skipping rerun since the preheated run is the same") return self.request_rerun(widget_state) def handle_upload_file(self, upload_file): self._uploaded_file_mgr.create_or_clear_file( widget_id=upload_file.widget_id, name=upload_file.name, size=upload_file.size, last_modified=upload_file.lastModified, chunks=upload_file.chunks, ) self.handle_rerun_script_request(widget_state=self._widget_states) def handle_upload_file_chunk(self, upload_file_chunk): progress = self._uploaded_file_mgr.process_chunk( widget_id=upload_file_chunk.widget_id, index=upload_file_chunk.index, data=upload_file_chunk.data, ) if progress == 1: self.handle_rerun_script_request(widget_state=self._widget_states) def handle_delete_uploaded_file(self, delete_uploaded_file): self._uploaded_file_mgr.delete_file( widget_id=delete_uploaded_file.widget_id) self.handle_rerun_script_request(widget_state=self._widget_states) def handle_stop_script_request(self): """Tells the ScriptRunner to stop running its report.""" self._enqueue_script_request(ScriptRequest.STOP) def handle_clear_cache_request(self): """Clears this report's cache. Because this cache is global, it will be cleared for all users. """ # Setting verbose=True causes clear_cache to print to stdout. # Since this command was initiated from the browser, the user # doesn't need to see the results of the command in their # terminal. caching.clear_cache() def handle_set_run_on_save_request(self, new_value): """Changes our run_on_save flag to the given value. The browser will be notified of the change. Parameters ---------- new_value : bool New run_on_save value """ self._run_on_save = new_value self._enqueue_session_state_changed_message() def _enqueue_script_request(self, request, data=None): """Enqueue a ScriptEvent into our ScriptEventQueue. If a script thread is not already running, one will be created to handle the event. Parameters ---------- request : ScriptRequest The type of request. data : Any Data associated with the request, if any. """ if self._state == ReportSessionState.SHUTDOWN_REQUESTED: LOGGER.warning("Discarding %s request after shutdown" % request) return self._script_request_queue.enqueue(request, data) self._maybe_create_scriptrunner() def _maybe_create_scriptrunner(self): """Create a new ScriptRunner if we have unprocessed script requests. This is called every time a ScriptRequest is enqueued, and also after a ScriptRunner shuts down, in case new requests were enqueued during its termination. This function should only be called on the main thread. """ if (self._state == ReportSessionState.SHUTDOWN_REQUESTED or self._scriptrunner is not None or not self._script_request_queue.has_request): return # Create the ScriptRunner, attach event handlers, and start it self._scriptrunner = ScriptRunner( report=self._report, enqueue_forward_msg=self.enqueue, widget_states=self._widget_states, request_queue=self._script_request_queue, uploaded_file_mgr=self._uploaded_file_mgr, ) self._scriptrunner.on_event.connect(self._on_scriptrunner_event) self._scriptrunner.start() @tornado.gen.coroutine def handle_save_request(self, ws): """Save serialized version of report deltas to the cloud. "Progress" ForwardMsgs will be sent to the client during the upload. These messages are sent "out of band" - that is, they don't get enqueued into the ReportQueue (because they're not part of the report). Instead, they're written directly to the report's WebSocket. Parameters ---------- ws : _BrowserWebSocketHandler The report's websocket handler. """ @tornado.gen.coroutine def progress(percent): progress_msg = ForwardMsg() progress_msg.upload_report_progress = percent yield ws.write_message(serialize_forward_msg(progress_msg), binary=True) # Indicate that the save is starting. try: yield progress(0) url = yield self._save_final_report(progress) # Indicate that the save is done. progress_msg = ForwardMsg() progress_msg.report_uploaded = url yield ws.write_message(serialize_forward_msg(progress_msg), binary=True) except Exception as e: # Horrible hack to show something if something breaks. err_msg = "%s: %s" % (type(e).__name__, str(e) or "No further details.") progress_msg = ForwardMsg() progress_msg.report_uploaded = err_msg yield ws.write_message(serialize_forward_msg(progress_msg), binary=True) LOGGER.warning("Failed to save report:", exc_info=e) @tornado.gen.coroutine def _save_running_report(self): files = self._report.serialize_running_report_to_files() url = yield self._get_storage().save_report_files( self._report.report_id, files) if config.get_option("server.liveSave"): url_util.print_url("Saved running app", url) raise tornado.gen.Return(url) @tornado.gen.coroutine def _save_final_report(self, progress_coroutine=None): files = self._report.serialize_final_report_to_files() url = yield self._get_storage().save_report_files( self._report.report_id, files, progress_coroutine) if config.get_option("server.liveSave"): url_util.print_url("Saved final app", url) raise tornado.gen.Return(url) @tornado.gen.coroutine def _save_final_report_and_quit(self): yield self._save_final_report() self._ioloop.stop() def _get_storage(self): if self._storage is None: sharing_mode = config.get_option("global.sharingMode") if sharing_mode == "s3": self._storage = S3Storage() elif sharing_mode == "file": self._storage = FileStorage() else: raise RuntimeError("Unsupported sharing mode '%s'" % sharing_mode) return self._storage
class ReportContext(object): """ Contains session data for a single "user" of an active report (that is, a connected browser tab). Each ReportContext has its own Report, root DeltaGenerator, ScriptRunner, and widget state. A ReportContext is attached to each thread involved in running its Report. """ _next_id = 0 def __init__(self, ioloop, script_path, script_argv): """Initialize the ReportContext. Parameters ---------- ioloop : tornado.ioloop.IOLoop The Tornado IOLoop that we're running within script_path : str Path of the Python file from which this report is generated. script_argv : list of str Command-line arguments to run the script with. """ # Each ReportContext gets a unique ID self.id = ReportContext._next_id ReportContext._next_id += 1 self._ioloop = ioloop self._report = Report(script_path, script_argv) self._root_dg = DeltaGenerator(self.enqueue) self._scriptrunner = ScriptRunner(self._report) self._sent_initialize_message = False self._is_shutdown = False self._storage = None self._maybe_reuse_previous_run = False # ScriptRunner event handlers self._scriptrunner.on_state_changed.connect( self._enqueue_script_state_changed_message) self._scriptrunner.on_file_change_not_handled.connect( self._enqueue_file_change_message) self._scriptrunner.on_script_compile_error.connect( self._on_script_compile_error) # Kick off the scriptrunner's run loop, but don't run the script # itself. self._scriptrunner.start_run_loop(self) LOGGER.debug('ReportContext initialized (id=%s)', self.id) @property def root_dg(self): """ Returns ------- DeltaGenerator The report's root DeltaGenerator """ return self._root_dg @property def widgets(self): """ Returns ------- Widgets Our ScriptRunner's widget state dictionary """ return self._scriptrunner.widgets def flush_browser_queue(self): """Clears the report queue and returns the messages it contained. The Server calls this periodically to deliver new messages to the browser connected to this report. Returns ------- list[ForwardMsg] The messages that were removed from the queue and should be delivered to the browser. """ return self._report.flush_browser_queue() def shutdown(self): """Shuts down the ReportContext. It's an error to use a ReportContext after it's been shut down. """ if not self._is_shutdown: LOGGER.debug('Shutting down (id=%s)', self.id) self._is_shutdown = True self._scriptrunner.request_shutdown() def enqueue(self, msg): """Enqueues a new ForwardMsg to our browser queue. Parameters ---------- msg : ForwardMsg The message to enqueue Returns ------- bool True if the message was enqueued, or False if client.displayEnabled is not set """ if not config.get_option('client.displayEnabled'): return False self._scriptrunner.maybe_handle_execution_control_request() self._report.enqueue(msg) return True def enqueue_exception(self, e): """Enqueues an Exception message. Parameters ---------- e : BaseException """ # This does a few things: # 1) Clears the current report in the browser. # 2) Marks the current report as "stopped" in the browser. # 3) HACK: Resets any script params that may have been broken (e.g. the # command-line when rerunning with wrong argv[0]) self._enqueue_script_state_changed_message(ScriptState.STOPPED) self._enqueue_script_state_changed_message(ScriptState.RUNNING) self._enqueue_script_state_changed_message(ScriptState.STOPPED) msg = protobuf.ForwardMsg() msg.delta.id = 0 exception_proto.marshall(msg.delta.new_element.exception, e) self.enqueue(msg) def _clear_queue(self): self._report.clear() def _enqueue_script_state_changed_message(self, new_script_state): if new_script_state == ScriptState.RUNNING: if config.get_option('server.liveSave'): # Enqueue into the IOLoop so it runs without blocking AND runs # on the main thread. self._ioloop.spawn_callback(self._save_running_report) self._clear_queue() self._maybe_enqueue_initialize_message() self._enqueue_new_report_message() self._enqueue_session_state_changed_message() if new_script_state == ScriptState.STOPPED: self._enqueue_report_finished_message() if config.get_option('server.liveSave'): # Enqueue into the IOLoop so it runs without blocking AND runs # on the main thread. self._ioloop.spawn_callback(self._save_final_report_and_quit) def _enqueue_session_state_changed_message(self): msg = protobuf.ForwardMsg() msg.session_state_changed.run_on_save = self._scriptrunner.run_on_save msg.session_state_changed.report_is_running = \ self._scriptrunner.is_running() self.enqueue(msg) def _enqueue_file_change_message(self, _): LOGGER.debug('Enqueuing report_changed message (id=%s)', self.id) msg = protobuf.ForwardMsg() msg.session_event.report_changed_on_disk = True self.enqueue(msg) def _on_script_compile_error(self, exc): """Handles exceptions caught by ScriptRunner during script compilation. We deliver these exceptions to the client via SessionEvent messages. "Normal" exceptions that are thrown during script execution show up as inline elements in the report, but compilation exceptions are handled specially, so that the frontend can leave the previous report up. """ from streamlit.elements import exception_proto msg = protobuf.ForwardMsg() exception_proto.marshall( msg.session_event.script_compilation_exception, exc) self.enqueue(msg) def _maybe_enqueue_initialize_message(self): if self._sent_initialize_message: return self._sent_initialize_message = True msg = protobuf.ForwardMsg() imsg = msg.initialize imsg.sharing_enabled = (config.get_option('global.sharingMode') != 'off') LOGGER.debug('New browser connection: sharing_enabled=%s', msg.initialize.sharing_enabled) imsg.gather_usage_stats = ( config.get_option('browser.gatherUsageStats')) LOGGER.debug('New browser connection: gather_usage_stats=%s', msg.initialize.gather_usage_stats) imsg.streamlit_version = __version__ imsg.session_state.run_on_save = self._scriptrunner.run_on_save imsg.session_state.report_is_running = self._scriptrunner.is_running() imsg.user_info.installation_id = __installation_id__ imsg.user_info.email = Credentials.get_current().activation.email self.enqueue(msg) def _enqueue_new_report_message(self): self._report.generate_new_id() msg = protobuf.ForwardMsg() msg.new_report.id = self._report.report_id msg.new_report.command_line.extend(self._report.argv) msg.new_report.name = self._report.name self.enqueue(msg) def _enqueue_report_finished_message(self): msg = protobuf.ForwardMsg() msg.report_finished = True self.enqueue(msg) def handle_rerun_script_request(self, command_line=None, widget_state=None, is_preheat=False): """Tells the ScriptRunner to re-run its report. Parameters ---------- command_line : str | None The new command line arguments to run the script with, or None to use its previous command line value. widget_state : WidgetStates | None The WidgetStates protobuf to run the script with, or None to use its previous widget states. is_preheat: boolean True if this ReportContext should run the script immediately, and then ignore the next rerun request if it matches the already-ran argv and widget state. """ old_argv = self._report.argv if command_line is not None: self._report.parse_argv_from_command_line(command_line) if is_preheat: self._maybe_reuse_previous_run = True # For next time. elif self._maybe_reuse_previous_run: # If this is a "preheated" ReportContext, reuse the previous run if # the argv and widget state matches. But only do this one time # ever. self._maybe_reuse_previous_run = False has_widget_state = (widget_state is not None and len(widget_state.widgets) > 0) has_new_argv = old_argv != self._report.argv if not has_widget_state and not has_new_argv: LOGGER.debug( 'Skipping rerun since the preheated run is the same') return self._scriptrunner.request_rerun(self._report.argv, widget_state) def handle_stop_script_request(self): """Tells the ScriptRunner to stop running its report.""" self._scriptrunner.request_stop() def handle_clear_cache_request(self): """Clears this report's cache. Because this cache is global, it will be cleared for all users. """ # Setting verbose=True causes clear_cache to print to stdout. # Since this command was initiated from the browser, the user # doesn't need to see the results of the command in their # terminal. caching.clear_cache() def handle_set_run_on_save_request(self, new_value): """Changes ScriptRunner's run_on_save flag to the given value. The browser will be notified of the change. Parameters ---------- new_value : bool New run_on_save value """ self._scriptrunner.run_on_save = new_value self._enqueue_session_state_changed_message() @tornado.gen.coroutine def handle_save_request(self, ws): """Save serialized version of report deltas to the cloud.""" @tornado.gen.coroutine def progress(percent): progress_msg = protobuf.ForwardMsg() progress_msg.upload_report_progress = percent yield ws.write_message(progress_msg.SerializeToString(), binary=True) # Indicate that the save is starting. try: yield progress(0) url = yield self._save_final_report(progress) # Indicate that the save is done. progress_msg = protobuf.ForwardMsg() progress_msg.report_uploaded = url yield ws.write_message(progress_msg.SerializeToString(), binary=True) except Exception as e: # Horrible hack to show something if something breaks. err_msg = '%s: %s' % (type(e).__name__, str(e) or 'No further details.') progress_msg = protobuf.ForwardMsg() progress_msg.report_uploaded = err_msg yield ws.write_message(progress_msg.SerializeToString(), binary=True) raise e @tornado.gen.coroutine def _save_running_report(self): files = self._report.serialize_running_report_to_files() url = yield self._get_storage().save_report_files( self._report.report_id, files) if config.get_option('server.liveSave'): util.print_url('Saved running report', url) raise tornado.gen.Return(url) @tornado.gen.coroutine def _save_final_report(self, progress=None): files = self._report.serialize_final_report_to_files() url = yield self._get_storage().save_report_files( self._report.report_id, files, progress) if config.get_option('server.liveSave'): util.print_url('Saved final report', url) raise tornado.gen.Return(url) @tornado.gen.coroutine def _save_final_report_and_quit(self): yield self._save_final_report() self._ioloop.stop() def _get_storage(self): if self._storage is None: self._storage = Storage() return self._storage