Пример #1
0
class ScriptRunner(object):
    def __init__(self, report, main_dg, sidebar_dg, widget_states,
                 request_queue):
        """Initialize the ScriptRunner.

        (The ScriptRunner won't start executing until start() is called.)

        Parameters
        ----------
        report : Report
            The ReportSession's report.

        main_dg : DeltaGenerator
            The ReportSession's main DeltaGenerator.

        sidebar_dg : DeltaGenerator
            The ReportSession's sidebar DeltaGenerator.

        widget_states : streamlit.proto.Widget_pb2.WidgetStates
            The ReportSession's current widget states

        request_queue : ScriptRequestQueue
            The queue that the ReportSession is publishing ScriptRequests to.
            ScriptRunner will continue running until the queue is empty,
            and then shut down.

        """
        self._report = report
        self._main_dg = main_dg
        self._sidebar_dg = sidebar_dg
        self._request_queue = request_queue

        self._widgets = Widgets()
        self._widgets.set_state(widget_states)

        self.on_event = Signal(doc="""Emitted when a ScriptRunnerEvent occurs.

            This signal is *not* emitted on the same thread that the
            ScriptRunner was created on.

            Parameters
            ----------
            event : ScriptRunnerEvent

            exception : BaseException | None
                Our compile error. 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.
            """)

        # Set to true when we process a SHUTDOWN request
        self._shutdown_requested = False

        # Set to true while we're executing. Used by
        # maybe_handle_execution_control_request.
        self._execing = False

        # This is initialized in start()
        self._script_thread = None

    def start(self):
        """Start a new thread to process the ScriptEventQueue.

        This must be called only once.

        """
        if self._script_thread is not None:
            raise Exception("ScriptRunner was already started")

        self._script_thread = ReportThread(
            main_dg=self._main_dg,
            sidebar_dg=self._sidebar_dg,
            widgets=self._widgets,
            target=self._process_request_queue,
            name="ScriptRunner.scriptThread",
        )
        self._script_thread.start()

    def _process_request_queue(self):
        """Process the ScriptRequestQueue and then exits.

        This is run in a separate thread.

        """
        LOGGER.debug("Beginning script thread")

        while not self._shutdown_requested and self._request_queue.has_request:
            request, data = self._request_queue.dequeue()
            if request == ScriptRequest.STOP:
                LOGGER.debug("Ignoring STOP request while not running")
            elif request == ScriptRequest.SHUTDOWN:
                LOGGER.debug("Shutting down")
                self._shutdown_requested = True
            elif request == ScriptRequest.RERUN:
                self._run_script(data)
            else:
                raise RuntimeError("Unrecognized ScriptRequest: %s" % request)

        # Send a SHUTDOWN event before exiting. This includes the widget values
        # as they existed after our last successful script run, which the
        # ReportSession will pass on to the next ScriptRunner that gets
        # created.
        self.on_event.send(ScriptRunnerEvent.SHUTDOWN,
                           widget_states=self._widgets.get_state())

    def _is_in_script_thread(self):
        """True if the calling function is running in the script thread"""
        return self._script_thread == threading.current_thread()

    def maybe_handle_execution_control_request(self):
        if not self._is_in_script_thread():
            # We can only handle execution_control_request if we're on the
            # script execution thread. However, it's possible for deltas to
            # be enqueued (and, therefore, for this function to be called)
            # in separate threads, so we check for that here.
            return

        if not self._execing:
            # If the _execing flag is not set, we're not actually inside
            # an exec() call. This happens when our script exec() completes,
            # we change our state to STOPPED, and a statechange-listener
            # enqueues a new ForwardEvent
            return

        # Pop the next request from our queue.
        request, data = self._request_queue.dequeue()
        if request is None:
            return

        LOGGER.debug("Received ScriptRequest: %s", request)
        if request == ScriptRequest.STOP:
            raise StopException()
        elif request == ScriptRequest.SHUTDOWN:
            self._shutdown_requested = True
            raise StopException()
        elif request == ScriptRequest.RERUN:
            raise RerunException(data)
        else:
            raise RuntimeError("Unrecognized ScriptRequest: %s" % request)

    def _install_tracer(self):
        """Install function that runs before each line of the script."""
        def trace_calls(frame, event, arg):
            self.maybe_handle_execution_control_request()
            return trace_calls

        # Python interpreters are not required to implement sys.settrace.
        if hasattr(sys, "settrace"):
            sys.settrace(trace_calls)

    @contextmanager
    def _set_execing_flag(self):
        """A context for setting the ScriptRunner._execing flag.

        Used by maybe_handle_execution_control_request to ensure that
        we only handle requests while we're inside an exec() call
        """
        if self._execing:
            raise RuntimeError("Nested set_execing_flag call")
        self._execing = True
        try:
            yield
        finally:
            self._execing = False

    def _run_script(self, rerun_data):
        """Run our script.

        Parameters
        ----------
        rerun_data: RerunData
            The RerunData to use.

        """
        assert self._is_in_script_thread()

        LOGGER.debug("Running script %s", rerun_data)

        # Reset delta generator so it starts from index 0.
        import streamlit as st

        st._reset(self._main_dg, self._sidebar_dg)

        self.on_event.send(ScriptRunnerEvent.SCRIPT_STARTED)

        # Compile the script. Any errors thrown here will be surfaced
        # to the user via a modal dialog in the frontend, and won't result
        # in their previous report disappearing.
        try:
            # Python 3 got rid of the native execfile() command, so we read
            # the file, compile it, and exec() it. This implementation is
            # compatible with both 2 and 3.
            with open(self._report.script_path) as f:
                filebody = f.read()

            if config.get_option("runner.magicEnabled"):
                filebody = magic.add_magic(filebody, self._report.script_path)

            code = compile(
                filebody,
                # Pass in the file path so it can show up in exceptions.
                self._report.script_path,
                # We're compiling entire blocks of Python, so we need "exec"
                # mode (as opposed to "eval" or "single").
                mode="exec",
                # Don't inherit any flags or "future" statements.
                flags=0,
                dont_inherit=1,
                # Parameter not supported in Python2:
                # optimize=-1,
            )

        except BaseException as e:
            # We got a compile error. Send an error event and bail immediately.
            LOGGER.debug("Fatal script error: %s" % e)
            self.on_event.send(
                ScriptRunnerEvent.SCRIPT_STOPPED_WITH_COMPILE_ERROR,
                exception=e)
            return

        # If we get here, we've successfully compiled our script. The next step
        # is to run it. Errors thrown during execution will be shown to the
        # user as ExceptionElements.

        # Update Report.argv
        if rerun_data.argv is not None:
            argv = rerun_data.argv
            self._report.argv = rerun_data.argv
        else:
            argv = self._report.argv

        # Update the Widget singleton with the new widget_state
        if rerun_data.widget_state is not None:
            self._widgets.set_state(rerun_data.widget_state)

        if config.get_option("runner.installTracer"):
            self._install_tracer()

        # This will be set to a RerunData instance if our execution
        # is interrupted by a RerunException.
        rerun_with_data = None

        try:
            # Create fake module. This gives us a name global namespace to
            # execute the code in.
            module = _new_module("__main__")

            # Install the fake module as the __main__ module. This allows
            # the pickle module to work inside the user's code, since it now
            # can know the module where the pickled objects stem from.
            # IMPORTANT: This means we can't use "if __name__ == '__main__'" in
            # our code, as it will point to the wrong module!!!
            sys.modules["__main__"] = module

            # Make it look like command-line args were set to whatever the user
            # asked them to be via the GUI.
            # IMPORTANT: This means we can't count on sys.argv in our code ---
            # but we already knew it from the argv surgery in cli.py.
            # TODO: Remove this feature when we implement interactivity!
            # This is not robust in a multi-user environment.
            sys.argv = argv

            # Add special variables to the module's globals dict.
            module.__dict__["__file__"] = self._report.script_path

            with modified_sys_path(self._report), self._set_execing_flag():
                exec(code, module.__dict__)

        except RerunException as e:
            rerun_with_data = e.rerun_data

        except StopException:
            pass

        except BaseException as e:
            # Show exceptions in the Streamlit report.
            LOGGER.debug(e)
            import streamlit as st

            st.exception(e)  # This is OK because we're in the script thread.
            # TODO: Clean up the stack trace, so it doesn't include
            # ScriptRunner.

        finally:
            self._widgets.reset_triggers()
            self.on_event.send(ScriptRunnerEvent.SCRIPT_STOPPED_WITH_SUCCESS)

        # Use _log_if_error() to make sure we never ever ever stop running the
        # script without meaning to.
        _log_if_error(_clean_problem_modules)

        if rerun_with_data is not None:
            self._run_script(rerun_with_data)
Пример #2
0
class ScriptRunner(object):
    def __init__(self, report):
        """Initialize.

        Parameters
        ----------
        report : Report
            The report with the script to run.

        """
        self._report = report
        self._event_queue = ScriptEventQueue()
        self._state = ScriptState.STOPPED
        self._last_run_data = RerunData(argv=report.argv,
                                        widget_state=WidgetStates())
        self._widgets = Widgets()

        self.run_on_save = config.get_option('server.runOnSave')

        self.on_state_changed = Signal(
            doc="""Emitted when the script's execution state state changes.

            Parameters
            ----------
            state : ScriptState
            """)

        self.on_file_change_not_handled = Signal(
            doc="Emitted when the file is modified and we haven't handled it.")

        self.on_script_compile_error = Signal(
            doc="""Emitted if our script fails to compile.  (*Not* emitted
            for normal exceptions thrown while a script is running.)

            Parameters
            ----------
            exception : Exception
                The exception that was thrown
            """)

        self._local_sources_watcher = LocalSourcesWatcher(
            self._report, self._on_source_file_changed)

        # Will be set to true when we process a SHUTDOWN event
        self._shutdown_requested = False

        self._script_thread = None
        self._ctx = None

        # Set to true while we're executing. Used by
        # maybe_handle_execution_control_request.
        self._execing = False

    @property
    def widgets(self):
        """
        Returns
        -------
        Widgets
            Our widget state dictionary

        """
        return self._widgets

    def start_run_loop(self, ctx):
        """Starts the ScriptRunner's thread.

        This must be called only once.

        Parameters
        ----------
        ctx : ReportContext
            The ReportContext that owns this ScriptRunner. This will be
            attached to the ScriptRunner's runloop thread.

        """

        assert self._script_thread is None, 'Already started!'
        self._ctx = ctx

        # start our thread
        self._script_thread = ReportThread(ctx,
                                           target=self._loop,
                                           name='ScriptRunner.loop')
        self._script_thread.start()

    def _set_state(self, new_state):
        if self._state == new_state:
            return

        LOGGER.debug('Scriptrunner state: %s -> %s' % (self._state, new_state))
        self._state = new_state
        self.on_state_changed.send(self._state)

    def is_running(self):
        return self._state == ScriptState.RUNNING

    def is_shutdown(self):
        return self._state == ScriptState.IS_SHUTDOWN

    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.

        """
        if self.is_shutdown():
            LOGGER.warning('Discarding RERUN event after shutdown')
            return
        self._event_queue.enqueue(ScriptEvent.RERUN,
                                  RerunData(argv, widget_state))

    def request_stop(self):
        if self.is_shutdown():
            LOGGER.warning('Discarding STOP event after shutdown')
            return
        self._event_queue.enqueue(ScriptEvent.STOP)

    def request_shutdown(self):
        if self.is_shutdown():
            LOGGER.warning('Discarding SHUTDOWN event after shutdown')
            return
        self._event_queue.enqueue(ScriptEvent.SHUTDOWN)

    def maybe_handle_execution_control_request(self):
        if self._script_thread != threading.current_thread():
            # We can only handle execution_control_request if we're on the
            # script execution thread. However, it's possible for deltas to
            # be enqueued (and, therefore, for this function to be called)
            # in separate threads, so we check for that here.
            return

        if not self._execing:
            # If the _execing flag is not set, we're not actually inside
            # an exec() call. This happens when our script exec() completes,
            # we change our state to STOPPED, and a statechange-listener
            # enqueues a new ForwardEvent
            return

        # Pop the next event from our queue. Don't block if there's no event
        event, event_data = self._event_queue.dequeue_nowait()
        if event is None:
            return

        LOGGER.debug('Received ScriptEvent: %s', event)
        if event == ScriptEvent.STOP:
            raise StopException()
        elif event == ScriptEvent.SHUTDOWN:
            self._shutdown_requested = True
            raise StopException()
        elif event == ScriptEvent.RERUN:
            raise RerunException(event_data)
        else:
            raise RuntimeError('Unrecognized ScriptEvent: %s' % event)

    def _on_source_file_changed(self):
        """One of our source files changed. Schedule a rerun if appropriate."""
        if self.run_on_save:
            self._event_queue.enqueue(ScriptEvent.RERUN,
                                      RerunData(argv=None, widget_state=None))
        else:
            self.on_file_change_not_handled.send()

    def _loop(self):
        """Our run loop.

        Continually pops events from the event_queue. Ends when we receive
        a SHUTDOWN event.

        """
        while not self._shutdown_requested:
            assert self._state == ScriptState.STOPPED

            # Dequeue our next event. If the event queue is empty, the thread
            # will go to sleep, and awake when there's a new event.
            event, event_data = self._event_queue.dequeue()
            if event == ScriptEvent.STOP:
                LOGGER.debug('Ignoring STOP event while not running')
            elif event == ScriptEvent.SHUTDOWN:
                LOGGER.debug('Shutting down')
                self._shutdown_requested = True
            elif event == ScriptEvent.RERUN:
                self._run_script(event_data)
            else:
                raise RuntimeError('Unrecognized ScriptEvent: %s' % event)

        # Release resources
        self._local_sources_watcher.close()

        self._set_state(ScriptState.IS_SHUTDOWN)

    def _install_tracer(self):
        """Install function that runs before each line of the script."""
        def trace_calls(frame, event, arg):
            self.maybe_handle_execution_control_request()
            return trace_calls

        # Python interpreters are not required to implement sys.settrace.
        if hasattr(sys, 'settrace'):
            sys.settrace(trace_calls)

    @contextmanager
    def _set_execing_flag(self):
        """A context for setting the ScriptRunner._execing flag.

        Used by maybe_handle_execution_control_request to ensure that
        we only handle requests while we're inside an exec() call
        """
        if self._execing:
            raise RuntimeError('Nested set_execing_flag call')
        self._execing = True
        try:
            yield
        finally:
            self._execing = False

    def _run_script(self, rerun_data):
        """Run our script.

        Parameters
        ----------
        rerun_data: RerunData
            The RerunData to use.

        """
        assert self._state == ScriptState.STOPPED
        LOGGER.debug('Running script %s', rerun_data)

        # Reset delta generator so it starts from index 0.
        import streamlit as st
        st._reset()

        self._set_state(ScriptState.RUNNING)

        # Compile the script. Any errors thrown here will be surfaced
        # to the user via a modal dialog, and won't result in their
        # previous report disappearing.
        try:
            # Python 3 got rid of the native execfile() command, so we now read
            # the file, compile it, and exec() it. This implementation is
            # compatible with both 2 and 3.
            with open(self._report.script_path) as f:
                filebody = f.read()

            if config.get_option('runner.magicEnabled'):
                filebody = magic.add_magic(filebody, self._report.script_path)

            code = compile(
                filebody,
                # Pass in the file path so it can show up in exceptions.
                self._report.script_path,
                # We're compiling entire blocks of Python, so we need "exec"
                # mode (as opposed to "eval" or "single").
                'exec',
                # Don't inherit any flags or "future" statements.
                flags=0,
                dont_inherit=1,
                # Parameter not supported in Python2:
                # optimize=-1,
            )

        except BaseException as e:
            # We got a compile error. Send the exception onto the client
            # as a SessionEvent and bail immediately.
            LOGGER.debug('Fatal script error: %s' % e)
            self.on_script_compile_error.send(e)
            self._set_state(ScriptState.STOPPED)
            return

        # If we get here, we've successfully compiled our script. The next step
        # is to run it. Errors thrown during execution will be shown to the
        # user as ExceptionElements.

        # Get our argv and widget_state for this run, defaulting to
        # self._last_run_data for missing values.
        # Also update self._last_run_data for the next run.
        argv = rerun_data.argv or self._last_run_data.argv
        widget_state = rerun_data.widget_state or \
                       self._last_run_data.widget_state
        self._last_run_data = RerunData(argv,
                                        reset_widget_triggers(widget_state))

        # Update the Widget singleton with the new widget_state
        self._ctx.widgets.set_state(widget_state)

        if config.get_option('runner.installTracer'):
            self._install_tracer()

        # This will be set to a RerunData instance if our execution
        # is interrupted by a RerunException.
        rerun_with_data = None

        try:
            # Create fake module. This gives us a name global namespace to
            # execute the code in.
            module = _new_module('__main__')

            # Install the fake module as the __main__ module. This allows
            # the pickle module to work inside the user's code, since it now
            # can know the module where the pickled objects stem from.
            # IMPORTANT: This means we can't use "if __name__ == '__main__'" in
            # our code, as it will point to the wrong module!!!
            sys.modules['__main__'] = module

            # Make it look like command-line args were set to whatever the user
            # asked them to be via the GUI.
            # IMPORTANT: This means we can't count on sys.argv in our code ---
            # but we already knew it from the argv surgery in cli.py.
            # TODO: Remove this feature when we implement interactivity!
            #  This is not robust in a multi-user environment.
            sys.argv = argv

            # Add special variables to the module's globals dict.
            module.__dict__['__file__'] = self._report.script_path

            with modified_sys_path(self._report), self._set_execing_flag():
                exec(code, module.__dict__)

        except RerunException as e:
            rerun_with_data = e.rerun_data

        except StopException:
            pass

        except BaseException as e:
            # Show exceptions in the Streamlit report.
            LOGGER.debug(e)
            st.exception(e)  # This is OK because we're in the script thread.
            # TODO: Clean up the stack trace, so it doesn't include
            # ScriptRunner.

        finally:
            self._set_state(ScriptState.STOPPED)

        # Use _log_if_error() to make sure we never ever ever stop running the
        # script without meaning to.
        _log_if_error(self._local_sources_watcher.update_watched_modules)
        _log_if_error(_clean_problem_modules)

        if rerun_with_data is not None:
            self._run_script(rerun_with_data)