Tortoise and the Hare
=====================

Controllers have two methods of talking with Tor...

* **Synchronous** - Most commonly you make a request to Tor then receive its
  reply. The :func:`~stem.control.Controller.get_info` calls in the `first
  tutorial <the_little_relay_that_could.html>`_ are an example of this.

* **Asynchronous** - Controllers can subscribe to be notified when various
  kinds of events occur within Tor (see the :data:`~stem.control.EventType`).
  Stem's users provide a callback function to
  :func:`~stem.control.Controller.add_event_listener` which is then notified
  when the event occurs.

Try to avoid lengthy operations within event callbacks. They're notified by a
single dedicated event thread, and blocking this thread will prevent the
delivery of further events.

With that out of the way lets see an example. The following is a `curses
<https://docs.python.org/2/howto/curses.html>`_ application that graphs the
bandwidth usage of Tor...

.. image:: /_static/bandwidth_graph_output.png

To do this it listens to **BW events**
(the class for which is a :class:`~stem.response.events.BandwidthEvent`). These
are events that Tor emits each second saying the number of bytes downloaded and
uploaded.

.. code-block:: python
  :emphasize-lines: 53-55,62-67

  import curses
  import functools

  from stem.control import EventType, Controller
  from stem.util import str_tools

  # colors that curses can handle

  COLOR_LIST = {
    "red": curses.COLOR_RED,
    "green": curses.COLOR_GREEN,
    "yellow": curses.COLOR_YELLOW,
    "blue": curses.COLOR_BLUE,
    "cyan": curses.COLOR_CYAN,
    "magenta": curses.COLOR_MAGENTA,
    "black": curses.COLOR_BLACK,
    "white": curses.COLOR_WHITE,
  }

  GRAPH_WIDTH = 40
  GRAPH_HEIGHT = 8

  DOWNLOAD_COLOR = "green"
  UPLOAD_COLOR = "blue"

  def main():
    with Controller.from_port(port = 9051) as controller:
      controller.authenticate()

      try:
        # This makes curses initialize and call draw_bandwidth_graph() with a
        # reference to the screen, followed by additional arguments (in this
        # case just the controller).

        curses.wrapper(draw_bandwidth_graph, controller)
      except KeyboardInterrupt:
        pass  # the user hit ctrl+c

  def draw_bandwidth_graph(stdscr, controller):
    window = Window(stdscr)

    # (downloaded, uploaded) tuples for the last 40 seconds

    bandwidth_rates = [(0, 0)] * GRAPH_WIDTH

    # Making a partial that wraps the window and bandwidth_rates with a function
    # for Tor to call when it gets a BW event. This causes the 'window' and
    # 'bandwidth_rates' to be provided as the first two arguments whenever
    # 'bw_event_handler()' is called.

    bw_event_handler = functools.partial(_handle_bandwidth_event, window, bandwidth_rates)

    # Registering this listener with Tor. Tor reports a BW event each second.

    controller.add_event_listener(bw_event_handler, EventType.BW)

    # Pause the main thread until the user hits any key... and no, don't you dare
    # ask where the 'any' key is. :P

    stdscr.getch()

  def _handle_bandwidth_event(window, bandwidth_rates, event):
    # callback for when tor provides us with a BW event

    bandwidth_rates.insert(0, (event.read, event.written))
    bandwidth_rates = bandwidth_rates[:GRAPH_WIDTH]  # truncate old values
    _render_graph(window, bandwidth_rates)

  def _render_graph(window, bandwidth_rates):
    window.erase()

    download_rates = [entry[0] for entry in bandwidth_rates]
    upload_rates = [entry[1] for entry in bandwidth_rates]

    # show the latest values at the top

    label = "Downloaded (%s/s):" % str_tools.size_label(download_rates[0], 1)
    window.addstr(0, 1, label, DOWNLOAD_COLOR, curses.A_BOLD)

    label = "Uploaded (%s/s):" % str_tools.size_label(upload_rates[0], 1)
    window.addstr(0, GRAPH_WIDTH + 7, label, UPLOAD_COLOR, curses.A_BOLD)

    # draw the graph bounds in KB

    max_download_rate = max(download_rates)
    max_upload_rate = max(upload_rates)

    window.addstr(1, 1, "%4i" % (max_download_rate / 1024), DOWNLOAD_COLOR)
    window.addstr(GRAPH_HEIGHT, 1, "   0", DOWNLOAD_COLOR)

    window.addstr(1, GRAPH_WIDTH + 7, "%4i" % (max_upload_rate / 1024), UPLOAD_COLOR)
    window.addstr(GRAPH_HEIGHT, GRAPH_WIDTH + 7, "   0", UPLOAD_COLOR)

    # draw the graph

    for col in xrange(GRAPH_WIDTH):
      col_height = GRAPH_HEIGHT * download_rates[col] / max(max_download_rate, 1)

      for row in xrange(col_height):
        window.addstr(GRAPH_HEIGHT - row, col + 6, " ", DOWNLOAD_COLOR, curses.A_STANDOUT)

      col_height = GRAPH_HEIGHT * upload_rates[col] / max(max_upload_rate, 1)

      for row in xrange(col_height):
        window.addstr(GRAPH_HEIGHT - row, col + GRAPH_WIDTH + 12, " ", UPLOAD_COLOR, curses.A_STANDOUT)

    window.refresh()

  class Window(object):
    """
    Simple wrapper for the curses standard screen object.
    """

    def __init__(self, stdscr):
      self._stdscr = stdscr

      # Mappings of names to the curses color attribute. Initially these all
      # reference black text, but if the terminal can handle color then
      # they're set with that foreground color.

      self._colors = dict([(color, 0) for color in COLOR_LIST])

      # allows for background transparency

      try:
        curses.use_default_colors()
      except curses.error:
        pass

      # makes the cursor invisible

      try:
        curses.curs_set(0)
      except curses.error:
        pass

      # initializes colors if the terminal can handle them

      try:
        if curses.has_colors():
          color_pair = 1

          for name, foreground in COLOR_LIST.items():
            background = -1  # allows for default (possibly transparent) background
            curses.init_pair(color_pair, foreground, background)
            self._colors[name] = curses.color_pair(color_pair)
            color_pair += 1
      except curses.error:
        pass

    def addstr(self, y, x, msg, color = None, attr = curses.A_NORMAL):
      # Curses throws an error if we try to draw a message that spans out of the
      # window's bounds (... seriously?), so doing our best to avoid that.

      if color is not None:
        if color not in self._colors:
          recognized_colors = ", ".join(self._colors.keys())
          raise ValueError("The '%s' color isn't recognized: %s" % (color, recognized_colors))

        attr |= self._colors[color]

      max_y, max_x = self._stdscr.getmaxyx()

      if max_x > x and max_y > y:
        try:
          self._stdscr.addstr(y, x, msg[:max_x - x], attr)
        except:
          pass  # maybe an edge case while resizing the window

    def erase(self):
      self._stdscr.erase()

    def refresh(self):
      self._stdscr.refresh()

  if __name__ == '__main__':
    main()

