Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b28fdf3

Browse files
committed
Add fully async StreamWriter for streaming.
1 parent fb07014 commit b28fdf3

File tree

2 files changed

+297
-0
lines changed

2 files changed

+297
-0
lines changed

plotly/plotly/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
plot_mpl,
1919
get_figure,
2020
Stream,
21+
StreamWriter,
2122
image,
2223
grid_ops,
2324
meta_ops,

plotly/plotly/plotly.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,25 @@
1919
import base64
2020
import copy
2121
import json
22+
import socket
2223
import os
24+
import threading
25+
import time
2326
import warnings
27+
from collections import deque
2428

2529
import requests
2630
import six
2731
import six.moves
32+
import trollius
33+
from trollius import From, Return, TimeoutError
2834

2935
from plotly import exceptions, tools, utils, version
3036
from plotly.plotly import chunked_requests
3137
from plotly.session import (sign_in, update_session_plot_options,
3238
get_session_plot_options, get_session_credentials,
3339
get_session_config)
40+
from plotly.utils import HttpResponseParser, DisconnectThread
3441

3542
__all__ = None
3643

@@ -354,6 +361,295 @@ def get_figure(file_owner_or_url, file_id=None, raw=False):
354361
"There was an error retrieving this file")
355362

356363

364+
class StreamWriter(object):
365+
"""
366+
Class to help stream to Plotly's streaming server.
367+
368+
"""
369+
linear_back_off_rate = 0.250 # additional seconds per retry
370+
linear_wait_max = 16 # seconds
371+
372+
exponential_back_off_base = 5 # base for exponentiation
373+
exponential_wait_max = 320 # seconds
374+
375+
eol = '\r\n' # end of line character for our http communication
376+
377+
def __init__(self, token, ignore_status_codes=(None, 200),
378+
ignore_errors=(), queue_length=500, initial_write_timeout=4):
379+
"""
380+
Initialize a StreamWriter which can write to Plotly's streaming server.
381+
382+
:param (str) token: The Plotly streaming token which links a trace to
383+
this data stream. *Not* your Plotly api_key.
384+
385+
:param ignore_status_codes: Http status codes to ignore from server and
386+
reconnect on.
387+
388+
:param (int) queue_length: The maximum number of messages to store if
389+
we're waiting to communicate with server.
390+
391+
:param (int) initial_write_timeout: Seconds to wait for a response from
392+
the streaming server after the initial http request is sent.
393+
394+
:param ignore_errors: Exception classes to ignore and reconnect on.
395+
Useful if you want quietly reconnect on network hiccups.
396+
397+
"""
398+
self.token = token
399+
self.ignore_status_codes = ignore_status_codes
400+
self.ignore_errors = ignore_errors
401+
self.queue_length = queue_length
402+
self.initial_write_timeout = initial_write_timeout
403+
404+
self._queue = deque(maxlen=self.queue_length) # prevent memory leaks
405+
self._last_beat = None
406+
407+
self._connect_errors = 0
408+
self._last_connect_error = time.time()
409+
self._disconnections = 0
410+
self._last_disconnection = time.time()
411+
412+
self._thread = None
413+
self._reader = None
414+
self._writer = None
415+
416+
self._error = None
417+
self._response = None
418+
419+
# hold reference to germain credentials/config at instantiation time.
420+
self.host = get_config()['plotly_streaming_domain']
421+
self.port = 80
422+
423+
self._headers = {'Plotly-Streamtoken': self.token, 'Host': self.host,
424+
'Transfer-Encoding': 'chunked',
425+
'Content-Type': 'text/plain'}
426+
427+
def _reset(self):
428+
"""Prep some attributes to reconnect."""
429+
self._response = None
430+
self._error = None
431+
self._last_beat = None
432+
self._reader = None
433+
self._writer = None
434+
435+
def _back_off_linearly(self):
436+
"""Back off linearly if connect exceptions are thrown in the thread."""
437+
now = time.time()
438+
if now - self._last_connect_error > self.linear_wait_max * 2:
439+
self._connect_errors = 0
440+
self._last_connect_error = now
441+
442+
self._connect_errors += 1
443+
wait_time = self._connect_errors * self.linear_back_off_rate
444+
if wait_time > self.linear_wait_max:
445+
raise exceptions.TooManyConnectFailures
446+
else:
447+
time.sleep(wait_time)
448+
449+
def _back_off_exponentially(self):
450+
"""Back off exponentially if peer keeps disconnecting."""
451+
now = time.time()
452+
if now - self._last_disconnection > self.exponential_wait_max * 2:
453+
self._disconnections = 0
454+
self._last_disconnection = now
455+
456+
self._disconnections += 1
457+
wait_time = self.exponential_back_off_base ** self._disconnections
458+
if wait_time > self.exponential_wait_max:
459+
raise exceptions.TooManyConnectFailures
460+
else:
461+
time.sleep(wait_time)
462+
463+
def _raise_for_response(self):
464+
"""If we got a response, the server disconnected. Possibly raise."""
465+
if self._response is None:
466+
return
467+
468+
try:
469+
response = self.response
470+
message = response.read()
471+
status = response.status
472+
except (AttributeError, six.moves.http_client.BadStatusLine):
473+
message = ''
474+
status = None
475+
476+
if status not in self.ignore_status_codes:
477+
raise exceptions.ClosedConnection(message, status_code=status)
478+
479+
# if we didn't raise here, we need to at least back off
480+
if status >= 500:
481+
self._back_off_linearly() # it's the server's fault.
482+
else:
483+
self._back_off_exponentially() # it's the client's fault.
484+
485+
def _raise_for_error(self):
486+
"""If there was an error during reading/writing, possibly raise."""
487+
if self._error is None:
488+
return
489+
490+
if not isinstance(self._error, self.ignore_errors):
491+
raise self._error
492+
493+
# if we didn't raise here, we need to at least back off
494+
if isinstance(self._error, socket.error):
495+
self._back_off_linearly()
496+
497+
# TODO: do we need to dive into the socket error numbers here?
498+
499+
def _check_pulse(self):
500+
"""Streams get shut down after 60 seconds of inactivity."""
501+
self._last_beat = self._last_beat or time.time()
502+
now = time.time()
503+
if now - self._last_beat > 30:
504+
self._last_beat = now
505+
if not self._queue:
506+
self.write('')
507+
508+
def _initial_write(self):
509+
"""Write our request-line and readers with a blank body."""
510+
self._writer.write('POST / HTTP/1.1')
511+
self._writer.write(self.eol)
512+
for header, header_value in self._headers.items():
513+
self._writer.write('{}: {}'.format(header, header_value))
514+
self._writer.write(self.eol)
515+
self._writer.write(self.eol)
516+
517+
def _write(self):
518+
"""Check the queue, if it's not empty, write a chunk!"""
519+
if self._queue:
520+
self._chunk = self._queue.pop()
521+
hex_len = format(len(self._chunk), 'x')
522+
self._writer.write()
523+
message = '{}\r\n{}\r\n'.format(hex_len, self._chunk)
524+
self._writer.write(message.encode('utf-8'))
525+
526+
@trollius.coroutine
527+
def _read(self, timeout=0.01):
528+
"""
529+
Read the whole buffer or return None if nothing's there.
530+
531+
:return: (str|None)
532+
533+
"""
534+
try:
535+
data = yield From(trollius.wait_for(self._reader.read(),
536+
timeout=timeout))
537+
except TimeoutError:
538+
data = None
539+
540+
# This is how we return from coroutines with trollius
541+
raise Return(data)
542+
543+
@trollius.coroutine
544+
def _read_write(self):
545+
"""
546+
Entry point into coroutine functionality.
547+
548+
Creates a reader and a writer by opening a connection to Plotly's
549+
streaming server. Then, it loops forever!
550+
551+
"""
552+
try:
553+
# open up a connection and get our StreamReader/StreamWriter
554+
current_thread = threading.current_thread()
555+
self._reader, self._writer = yield From(trollius.wait_for(
556+
trollius.open_connection(self.host, self.port), timeout=60
557+
))
558+
559+
# wait for an initial failure response, e.g., bad headers
560+
self._initial_write()
561+
self._response = yield From(self._read(self.initial_write_timeout))
562+
563+
# read/write until server responds or thread is disconnected.
564+
while self._response is None and current_thread.connected:
565+
self._check_pulse()
566+
self._response = yield From(self._read()) # usually just None.
567+
self._write()
568+
569+
except Exception as e:
570+
self._error = e
571+
finally:
572+
if self._writer:
573+
self._writer.close()
574+
575+
def _thread_func(self, loop):
576+
"""
577+
StreamWriters have threads that loop coroutines forever.
578+
579+
:param loop: From `trollius.get_event_loop()`
580+
581+
"""
582+
trollius.set_event_loop(loop)
583+
loop.run_until_complete(self._read_write())
584+
585+
@property
586+
def connected(self):
587+
"""Returns state of the StreamWriter's thread."""
588+
return self._thread and self._thread.isAlive()
589+
590+
@property
591+
def response(self):
592+
"""Return a response object parsed from server string response."""
593+
if self._response is None:
594+
return None
595+
return HttpResponseParser(self._response).get_response()
596+
597+
def open(self):
598+
"""
599+
Standard `open` API. Strictly unnecessary as it opens on `write`.
600+
601+
If not already opened, start a new daemon thread to consume the buffer.
602+
603+
"""
604+
if self.connected:
605+
return
606+
607+
# if we've previously hit a failure, we should let the user know
608+
self._raise_for_response()
609+
self._raise_for_error()
610+
611+
# reset our error, response, pulse information, etc.
612+
self._reset()
613+
614+
# start up a new daemon thread
615+
loop = trollius.get_event_loop()
616+
self._thread = DisconnectThread(target=self._thread_func, args=(loop,))
617+
self._thread.setDaemon(True)
618+
self._thread.start()
619+
620+
def write(self, chunk):
621+
"""
622+
Standard `write` API.
623+
624+
Reopens connection if closed and appends chunk to buffer.
625+
626+
If chunk isn't a str|unicode, `json.dumps` is called with chunk.
627+
628+
:param (str|unicode|dict) chunk: The data to be queued to send.
629+
630+
"""
631+
if not self.connected:
632+
self.open()
633+
634+
if not isinstance(chunk, six.string_types):
635+
chunk = json.dumps(chunk, cls=utils.PlotlyJSONEncoder)
636+
637+
self._queue.appendleft(chunk + '\n') # needs at least 1 trailing '\n'
638+
639+
def close(self):
640+
"""
641+
Standard `close` API.
642+
643+
Ensures thread is disconnected.
644+
645+
"""
646+
if not self.connected:
647+
return
648+
649+
self._thread.disconnect() # lets thread know to stop communicating
650+
self._thread = None
651+
652+
357653
@utils.template_doc(**tools.get_config_file())
358654
class Stream:
359655
"""

0 commit comments

Comments
 (0)