Repository: PrefectHQ/prefect

Issue:
Re-create tutorial images and deprecate `BokehRunner`
In response to #606 , I'm going through all of our images and I can't help but notice that the mermaid generated images / the images generated with `flow.visualize()` look much nicer than the screenshots of `BokehRunner` images.  Given the difficulty in maintaining / testing the `BokehRunner` app on top of this, I'm going to go out on a limb and vote that we deprecate the `BokehRunner` and recreate all tutorial images with mermaid and / or `flow.visualize()` (with flow state kwarg for colored states).


Discussion:
Sad, as you put a lot of effort into it. It's yours and it's your call.
Yea, but gotta keep everything in tip-top shape!  Unless people think there's a strong argument for keeping it, with the UI starting to come online and the maintenance burden, I feel like we'd be better served by removing it.  @joshmeek any thoughts on this?
I'm cool with `flow.visualize()` since it's baked in. Why would we need to deprecate the `BokehRunner` though? 
Because we don't actually have a good way of testing it and maintaining it; I haven't used it in a while so I couldn't even tell you if it currently works with mapping, etc.
Makes sense, I push for `.visualize()`. Is there currently a way to color the tasks in it?
Yup, if you provide a flow state object to `visualize` it will color the tasks accordingly (see https://docs.prefect.io/tutorials/visualization.html and the section called "Static Flow Visualization - Post Run")

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@
 
 extras = {
     "dev": dev_requires,
-    "viz": ["bokeh >= 0.13.0, < 0.14", "graphviz >= 0.8.3"],
+    "viz": ["graphviz >= 0.8.3"],
     "templates": ["jinja2 >= 2.0, < 3.0"],
 }
 
diff --git a/src/prefect/utilities/bokeh_app.py b/src/prefect/utilities/bokeh_app.py
deleted file mode 100644
--- a/src/prefect/utilities/bokeh_app.py
+++ /dev/null
@@ -1,247 +0,0 @@
-# Licensed under LICENSE.md; also available at https://www.prefect.io/licenses/alpha-eula
-
-import os
-import random
-import sys
-from collections import defaultdict
-
-import cloudpickle
-from bokeh.events import ButtonClick
-from bokeh.io import curdoc
-from bokeh.layouts import column, row
-from bokeh.models import (
-    Arrow,
-    CategoricalColorMapper,
-    CustomJS,
-    HoverTool,
-    Label,
-    LabelSet,
-    Legend,
-    LegendItem,
-    NormalHead,
-)
-from bokeh.models.widgets import Button
-from bokeh.plotting import ColumnDataSource, figure
-
-from prefect.engine import state
-
-colors = set(("Not Run", "black"))
-
-
-def enumerate_colors(start):
-    colors.add((start.__name__, start.color))
-    for substate in start.__subclasses__():
-        colors.add((substate.__name__, substate.color))
-        enumerate_colors(substate)
-
-
-enumerate_colors(state.State)
-
-color_map = CategoricalColorMapper(
-    factors=[x for x, y in colors], palette=[y for x, y in colors]
-)
-
-
-def get_state_name(task, task_states, not_run=False):
-    if not_run:
-        return "Not Run"
-    s = task_states.get(task)
-    if s is not None:
-        return s.__class__.__name__
-    else:
-        return "Pending"
-
-
-def get_state_msg(task, task_states):
-    s = task_states.get(task)
-    if s is not None:
-        words = repr(s).split()
-        cleaned = "<br>".join([" ".join(words[:5]), " ".join(words[5:])])
-        return cleaned
-    else:
-        return "Pending"
-
-
-def compute_layout(runner):
-    depths = runner._compute_depths()
-    max_depth = max([depth for depth in depths.values()])
-    widths = {
-        x: sum([1 for task, depth in depths.items() if depth == x])
-        for x in range(max_depth + 1)
-    }
-    inits, depth_counts = {}, {x: 0 for x in widths}
-    for task, depth in depths.items():
-        width = widths[depth]
-        depth_count = depth_counts[depth]
-        inits[task] = (
-            1 - (depth_count + 1) * 2 / (width + 1),
-            1 - (depth + 1) * 2 / (max_depth + 2),
-        )
-        depth_counts[depth] += 1
-
-    graph_layout = inits
-    xnoise, ynoise = (
-        [random.random() / 25 for _ in runner.flow.tasks],
-        [random.random() / 5 for _ in runner.flow.tasks],
-    )
-    return depths, graph_layout, xnoise, ynoise
-
-
-def compile_data(runner):
-    plot_data = defaultdict(list)
-
-    not_run = runner.flow.tasks.difference(
-        set(runner.flow.sorted_tasks(runner.start_tasks))
-    )
-    for task in runner.flow.sorted_tasks():
-        plot_data["task"].append(id(task))
-        plot_data["name"].append(task.name)
-        plot_data["state"].append(
-            get_state_name(task, runner.task_states, not_run=(task in not_run))
-        )
-        plot_data["message"].append(get_state_msg(task, runner.task_states))
-        plot_data["x"].append(graph_layout[task][0])
-        plot_data["y"].append(graph_layout[task][1])
-
-    plot_data["x"] = [x + n for x, n in zip(plot_data["x"], xnoise)]
-    plot_data["y"] = [y + n for y, n in zip(plot_data["y"], ynoise)]
-
-    return plot_data
-
-
-## load and format data
-data_dir = os.environ.get("BOKEH_RUNNER")
-
-with open(data_dir, "rb") as g:
-    runner = cloudpickle.load(g)
-
-depths, graph_layout, xnoise, ynoise = compute_layout(runner)
-
-
-## set up Bokeh components
-source = ColumnDataSource(data=compile_data(runner))
-flow_source = ColumnDataSource(
-    data=dict(
-        name=["Overall Flow State"],
-        message=['Pending("Some terminal tasks are still pending.")'],
-        state=["Pending"],
-    )
-)
-
-## configure Plot + tools
-plot = figure(
-    title=runner.title,
-    x_range=(-1.1, 1.1),
-    y_range=(-1.1, 1.1),
-    tools="",
-    toolbar_location=None,
-)
-
-plot.xgrid.grid_line_color = None
-plot.ygrid.grid_line_color = None
-plot.xaxis.major_tick_line_color = None  # turn off x-axis major ticks
-plot.xaxis.minor_tick_line_color = None  # turn off x-axis minor ticks
-
-plot.yaxis.major_tick_line_color = None  # turn off y-axis major ticks
-plot.yaxis.minor_tick_line_color = None  # turn off y-axis minor ticks
-plot.axis.visible = False
-
-plot.circle(
-    "x",
-    "y",
-    size=25,
-    source=source,
-    fill_color={"field": "state", "transform": color_map},
-    alpha=0.5,
-    legend="state",
-)
-plot.circle(
-    -1.0,
-    1.0,
-    size=35,
-    source=flow_source,
-    fill_color={"field": "state", "transform": color_map},
-    alpha=0.5,
-)
-
-
-for edge in list(runner.flow.edges):
-    a, b = edge.upstream_task, edge.downstream_task
-    a_index = source.data["task"].index(id(a))
-    b_index = source.data["task"].index(id(b))
-    plot.add_layout(
-        Arrow(
-            end=NormalHead(fill_color="grey", size=7, fill_alpha=0.5),
-            x_start=source.data["x"][a_index],
-            y_start=source.data["y"][a_index],
-            x_end=source.data["x"][b_index],
-            y_end=source.data["y"][b_index],
-            line_alpha=0.5,
-        )
-    )
-
-labels = LabelSet(
-    x="x",
-    y="y",
-    text="name",
-    source=source,
-    x_offset=10,
-    y_offset=10,
-    render_mode="canvas",
-    text_font_size="8pt",
-)
-flow_label = LabelSet(
-    x=-1.0,
-    y=1.0,
-    text="name",
-    source=flow_source,
-    x_offset=-10,
-    y_offset=-35,
-    render_mode="canvas",
-    text_font_size="9pt",
-)
-plot.renderers.append(labels)
-plot.renderers.append(flow_label)
-hover = HoverTool(tooltips=[("Name:", "@name"), ("State:", "@message{safe}")])
-plot.add_tools(hover)
-
-
-not_run = runner.flow.tasks.difference(
-    set(runner.flow.sorted_tasks(runner.start_tasks))
-)
-on_depth = {
-    "depth": min(
-        [depths[t] for t in runner.flow.sorted_tasks(runner.start_tasks)], default=0
-    )
-}
-
-
-def update(*args):
-    to_compute = [t for t, depth in depths.items() if depth == on_depth["depth"]]
-
-    while to_compute:
-        task = to_compute.pop(0)
-        runner.task_states.update({task: runner.flow_state.result.get(task)})
-    new_data = compile_data(runner)
-    source.data = new_data
-    on_depth["depth"] += 1
-    if on_depth["depth"] >= max(depths.values()) + 1:
-        flow_source.data = dict(
-            name=["Overall Flow State"],
-            message=[repr(runner.flow_state)],
-            state=[runner.flow_state.__class__.__name__],
-        )
-
-
-def quit_app(*args):
-    sys.exit()
-
-
-run_button = Button(label="Run Next Tasks", button_type="success")
-run_button.on_event(ButtonClick, update)
-quit = Button(
-    label="Exit", button_type="danger", callback=CustomJS(code="window.close()")
-)
-quit.on_event(ButtonClick, quit_app)
-
-curdoc().add_root(row(plot, column(run_button, quit), width=1500))
diff --git a/src/prefect/utilities/bokeh_runner.py b/src/prefect/utilities/bokeh_runner.py
deleted file mode 100644
--- a/src/prefect/utilities/bokeh_runner.py
+++ /dev/null
@@ -1,236 +0,0 @@
-# Licensed under LICENSE.md; also available at https://www.prefect.io/licenses/alpha-eula
-"""
-The BokehRunner class is a special [FlowRunner](../engine/flow_runner.html) subclass meant for debugging, exploring, visualizing, and
-demonstrating Prefect Flow state logic.
-
-This is _not_ intended as a replacement for a full-fledge UI, but simply an additional tool
-for locally inspecting and groking how Prefect handles Flow execution logic, including how
-states propagate throughout the Flow.
-
-**Example:**
-
-```python
-from prefect import Flow, Parameter, task
-from prefect.utilities.bokeh_runner import BokehRunner
-
-@task
-def add(x, y):
-    return x + y
-
-@task
-def div(x, y):
-    return x / y
-
-with Flow("bokeh-example") as f:
-    x, y = Parameter("x"), Parameter("y")
-    t1 = add(x, y)
-    t2 = div(x, y)
-    t3 = add(t1, t2)
-
-BokehRunner(flow=f).run(parameters={"x": 5, "y": 0}) # opens a new webapp
-```
-
-<img src='/bokeh_runner_doc_example.png'>
-"""
-
-import os
-import subprocess
-import tempfile
-from collections import defaultdict
-from typing import Any, Dict, Iterable, Iterator, List, Union
-
-import cloudpickle
-
-import prefect
-
-try:
-    import bokeh
-except ImportError:
-    msg = (
-        "This feature requires bokeh.\n"
-        "Try re-installing prefect with `pip install prefect[viz]`"
-    )
-    raise ImportError(msg)
-
-
-class BokehRunner(prefect.engine.flow_runner.FlowRunner):
-    """
-    The BokehRunner class is a special [FlowRunner](../engine/flow_runner.html) subclass meant for debugging, exploring, visualizing, and
-    demonstrating Prefect Flow state logic.
-
-    Initialized and handled exactly like the standard FlowRunner class.
-
-    Args:
-        - flow (Flow): the `Flow` to be run
-        - task_runner_cls (TaskRunner, optional): The class used for running
-            individual Tasks. Defaults to [TaskRunner](../engine/task_runner.html)
-        - state_handlers (Iterable[Callable], optional): A list of state change handlers
-            that will be called whenever the flow changes state, providing an
-            opportunity to inspect or modify the new state. The handler
-            will be passed the flow runner instance, the old (prior) state, and the new
-            (current) state, with the following signature:
-
-            ```
-                state_handler(
-                    flow_runner: FlowRunner,
-                    old_state: State,
-                    new_state: State) -> State
-            ```
-
-            If multiple functions are passed, then the `new_state` argument will be the
-            result of the previous handler.
-
-    Example:
-        ```python
-        from prefect.utilities.bokeh_runner import BokehRunner
-        from prefect import task, Flow
-
-        @task
-        def add(x, y):
-            return x + y
-
-        with Flow() as f:
-            one = add(0, 1)
-            two = add(0, 2)
-            res = add(one, two)
-
-        BokehRunner(flow=f).run() # opens up a webapp
-        ```
-    """
-
-    def run(  # type: ignore
-        self,
-        state: "prefect.engine.state.State" = None,
-        task_states: Dict["prefect.Task", "prefect.engine.state.State"] = None,
-        start_tasks: Iterable["prefect.Task"] = None,
-        parameters: Dict[str, Any] = None,
-        executor: "prefect.engine.executors.Executor" = None,
-        context: Dict[str, Any] = None,
-        title: str = None,
-        viz: bool = True,
-    ) -> "prefect.engine.state.State":
-        """
-        Runs the Flow, and then opens up a Bokeh webapp for retroactively inspecting
-        the execution of the Flow.
-
-        Args:
-            - state (State, optional): starting state for the Flow. Defaults to
-                `Pending`
-            - task_states (dict, optional): dictionary of task states to begin
-                computation with, with keys being Tasks and values their corresponding state
-            - start_tasks ([Task], optional): list of Tasks to begin computation
-                from; if any `start_tasks` have upstream dependencies, their states may need to be provided as well.
-                Defaults to `self.flow.root_tasks()`
-            - parameters (dict, optional): dictionary of any needed Parameter
-                values, with keys being strings representing Parameter names and values being their corresponding values
-            - executor (Executor, optional): executor to use when performing
-                computation; defaults to the executor provided in your prefect configuration
-            - context (dict, optional): prefect.Context to use for execution
-            - title (str, optional): optional title for the webapp plot;
-                defaults to "Prefect Flow Interactive Demonstration: flow.name"
-            - viz (bool, optional): if `False`, will simply run the Flow and
-                return its state (with all tasks returned) without opening the webapp.
-
-        Returns:
-            - State: `State` representing the final post-run state of the `Flow`.
-
-        """
-        self.task_states = task_states or {}
-        self.start_tasks = start_tasks or []
-        self.parameters = parameters or {}
-        self.flow_state = super().run(
-            state=state,
-            task_states=task_states,
-            start_tasks=start_tasks,
-            return_tasks=self.flow.tasks,
-            parameters=parameters,
-            executor=executor,
-            context=context,
-        )
-        self._reset_flow(self.flow_state)
-        self.title = title or "Prefect Flow Interactive Demonstration: {}".format(
-            self.flow.name
-        )
-
-        if viz:
-            with tempfile.NamedTemporaryFile() as tmp:
-                cloudpickle.dump(self, tmp)
-                env = os.environ
-                env.update({"BOKEH_RUNNER": tmp.name})
-                tmp.flush()
-                bokeh_app = os.path.join(os.path.dirname(__file__), "bokeh_app.py")
-                subprocess.run(
-                    "bokeh serve --show {}".format(bokeh_app).split(), env=env
-                )
-        return self.flow_state
-
-    def _reset_flow(self, state: "prefect.engine.state.State") -> None:
-        """
-        Expands out any mapped tasks into the appropriate number of copies, based
-        on the data provided in `state`.  Creates a new Flow and a new flow state
-        and saves them in `self.flow` and `self.flow_state` attributes, in place.
-
-        Args:
-            - state (State): the final, post-run Flow state used for determining
-                how many task copies to make for mapped tasks
-
-        Returns:
-            - None
-        """
-        map_counts = defaultdict(lambda: 0)  # type: dict
-        mapped_tasks = {}  # type: Dict["prefect.Task", List["prefect.Task"]]
-        edges = []
-        assert isinstance(state.result, dict)
-
-        for task in self.flow.sorted_tasks():
-            old_edges = self.flow.edges_to(task)
-            mapped_edges = [e for e in old_edges if e.mapped]
-            unmapped_edges = [e for e in old_edges if not e.mapped]
-
-            # update unmapped edges
-            for edge in unmapped_edges:
-                if edge.upstream_task in mapped_tasks:
-                    new_edges = [
-                        prefect.core.edge.Edge(upstream_task=t, downstream_task=task)
-                        for t in mapped_tasks[edge.upstream_task]
-                    ]
-                    edges.extend(new_edges)
-                else:
-                    edges.append(edge)
-
-            # update mapped edges
-            for edge in mapped_edges:
-                # create copies corresponding to each mapped result
-                new_tasks = [task.copy() for _ in state.result[task]]
-                mapped_tasks[task] = new_tasks
-                for t in new_tasks:
-                    t.name = task.name + "<mapped-{}>".format(map_counts[task.name])
-                    map_counts[task.name] += 1
-
-                if edge.upstream_task in mapped_tasks:
-                    upstream = [t for t in mapped_tasks[edge.upstream_task]]
-                else:
-                    upstream = [edge.upstream_task for _ in range(len(new_tasks))]
-
-                new_edges = [
-                    prefect.core.edge.Edge(
-                        upstream_task=up, downstream_task=task, key=edge.key
-                    )
-                    for up, task in zip(upstream, new_tasks)
-                ]
-                edges.extend(new_edges)
-                states = state.result.pop(task)
-                for t, s in zip(new_tasks, states):
-                    state.result[t] = s
-
-        self.flow = prefect.Flow(edges=edges, name=self.flow.name)
-        self.flow_state = state
-
-    def _compute_depths(self) -> Dict["prefect.Task", int]:
-        flow = self.flow
-        depths = {task: 0 for task in flow.tasks}
-        for task in flow.sorted_tasks():
-            depths[task] = max(
-                [depths[ut] + 1 for ut in flow.upstream_tasks(task)], default=0
-            )
-        return depths


Test patch: