diff --git a/py/visdom/__init__.py b/py/visdom/__init__.py index d69ae40a..5e7c6dee 100644 --- a/py/visdom/__init__.py +++ b/py/visdom/__init__.py @@ -6,6 +6,8 @@ # This source code is licensed under the license found in the # LICENSE file in the root directory of this source tree. +from visdom.utils.shared_utils import get_new_window_id +from visdom import server import os.path import requests import traceback diff --git a/py/visdom/server.py b/py/visdom/server.py deleted file mode 100644 index 988334b6..00000000 --- a/py/visdom/server.py +++ /dev/null @@ -1,1890 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright 2017-present, Facebook, Inc. -# All rights reserved. -# -# This source code is licensed under the license found in the -# LICENSE file in the root directory of this source tree. - -"""Server""" - -import argparse -import copy -import getpass -import hashlib -import inspect -import json -import jsonpatch -import logging -import math -import os -import sys -import time -import traceback -import uuid -import warnings -from os.path import expanduser -from collections import OrderedDict -try: - # for after python 3.8 - from collections.abc import Mapping, Sequence -except ImportError: - # for python 3.7 and below - from collections import Mapping, Sequence - -from zmq.eventloop import ioloop -ioloop.install() # Needs to happen before any tornado imports! - -import tornado.ioloop # noqa E402: gotta install ioloop first -import tornado.web # noqa E402: gotta install ioloop first -import tornado.websocket # noqa E402: gotta install ioloop first -import tornado.escape # noqa E402: gotta install ioloop first - -LAYOUT_FILE = 'layouts.json' -DEFAULT_ENV_PATH = '%s/.visdom/' % expanduser("~") -DEFAULT_PORT = 8097 -DEFAULT_HOSTNAME = "localhost" -DEFAULT_BASE_URL = "/" - -here = os.path.abspath(os.path.dirname(__file__)) -COMPACT_SEPARATORS = (',', ':') - -_seen_warnings = set() - -MAX_SOCKET_WAIT = 15 - -assert sys.version_info[0] >= 3, 'To use visdom with python 2, downgrade to v0.1.8.9' - - -def warn_once(msg, warningtype=None): - """ - Raise a warning, but only once. - :param str msg: Message to display - :param Warning warningtype: Type of warning, e.g. DeprecationWarning - """ - global _seen_warnings - if msg not in _seen_warnings: - _seen_warnings.add(msg) - warnings.warn(msg, warningtype, stacklevel=2) - - -def check_auth(f): - def _check_auth(self, *args, **kwargs): - self.last_access = time.time() - if self.login_enabled and not self.current_user: - self.set_status(400) - return - f(self, *args, **kwargs) - return _check_auth - - -def get_rand_id(): - return str(uuid.uuid4()) - - -def ensure_dir_exists(path): - """Make sure the parent dir exists for path so we can write a file.""" - try: - os.makedirs(os.path.dirname(path)) - except OSError as e1: - assert e1.errno == 17 # errno.EEXIST - pass - - -def get_path(filename): - """Get the path to an asset.""" - cwd = os.path.dirname( - os.path.abspath(inspect.getfile(inspect.currentframe()))) - return os.path.join(cwd, filename) - - -def escape_eid(eid): - """Replace slashes with underscores, to avoid recognizing them - as directories. - """ - - return eid.replace('/', '_') - - -def extract_eid(args): - """Extract eid from args. If eid does not exist in args, - it returns 'main'.""" - - eid = 'main' if args.get('eid') is None else args.get('eid') - return escape_eid(eid) - - -def set_cookie(value=None): - """Create cookie secret key for authentication""" - if value is not None: - cookie_secret = value - else: - cookie_secret = input("Please input your cookie secret key here: ") - with open(DEFAULT_ENV_PATH + "COOKIE_SECRET", "w") as cookie_file: - cookie_file.write(cookie_secret) - - -def hash_password(password): - """Hashing Password with SHA-256""" - return hashlib.sha256(password.encode("utf-8")).hexdigest() - - -tornado_settings = { - "autoescape": None, - "debug": "/dbg/" in __file__, - "static_path": get_path('static'), - "template_path": get_path('static'), - "compiled_template_cache": False -} - - -def serialize_env(state, eids, env_path=DEFAULT_ENV_PATH): - env_ids = [i for i in eids if i in state] - if env_path is not None: - for env_id in env_ids: - env_path_file = os.path.join(env_path, "{0}.json".format(env_id)) - with open(env_path_file, 'w') as fn: - fn.write(json.dumps(state[env_id])) - return env_ids - - -def serialize_all(state, env_path=DEFAULT_ENV_PATH): - serialize_env(state, list(state.keys()), env_path=env_path) - - -class Application(tornado.web.Application): - def __init__(self, port=DEFAULT_PORT, base_url='', - env_path=DEFAULT_ENV_PATH, readonly=False, - user_credential=None, use_frontend_client_polling=False): - self.env_path = env_path - self.state = self.load_state() - self.layouts = self.load_layouts() - self.subs = {} - self.sources = {} - self.port = port - self.base_url = base_url - self.readonly = readonly - self.user_credential = user_credential - self.login_enabled = False - self.last_access = time.time() - self.wrap_socket = use_frontend_client_polling - - if user_credential: - self.login_enabled = True - with open(DEFAULT_ENV_PATH + "COOKIE_SECRET", "r") as fn: - tornado_settings["cookie_secret"] = fn.read() - - tornado_settings['static_url_prefix'] = self.base_url + "/static/" - tornado_settings['debug'] = True - handlers = [ - (r"%s/events" % self.base_url, PostHandler, {'app': self}), - (r"%s/update" % self.base_url, UpdateHandler, {'app': self}), - (r"%s/close" % self.base_url, CloseHandler, {'app': self}), - (r"%s/socket" % self.base_url, SocketHandler, {'app': self}), - (r"%s/socket_wrap" % self.base_url, SocketWrap, {'app': self}), - (r"%s/vis_socket" % self.base_url, - VisSocketHandler, {'app': self}), - (r"%s/vis_socket_wrap" % self.base_url, - VisSocketWrap, {'app': self}), - (r"%s/env/(.*)" % self.base_url, EnvHandler, {'app': self}), - (r"%s/compare/(.*)" % self.base_url, - CompareHandler, {'app': self}), - (r"%s/save" % self.base_url, SaveHandler, {'app': self}), - (r"%s/error/(.*)" % self.base_url, ErrorHandler, {'app': self}), - (r"%s/win_exists" % self.base_url, ExistsHandler, {'app': self}), - (r"%s/win_data" % self.base_url, DataHandler, {'app': self}), - (r"%s/delete_env" % self.base_url, - DeleteEnvHandler, {'app': self}), - (r"%s/win_hash" % self.base_url, HashHandler, {'app': self}), - (r"%s/env_state" % self.base_url, EnvStateHandler, {'app': self}), - (r"%s/fork_env" % self.base_url, ForkEnvHandler, {'app': self}), - (r"%s(.*)" % self.base_url, IndexHandler, {'app': self}), - ] - super(Application, self).__init__(handlers, **tornado_settings) - - def get_last_access(self): - if len(self.subs) > 0 or len(self.sources) > 0: - # update the last access time to now, as someone - # is currently connected to the server - self.last_access = time.time() - return self.last_access - - def save_layouts(self): - if self.env_path is None: - warn_once( - 'Saving and loading to disk has no effect when running with ' - 'env_path=None.', - RuntimeWarning - ) - return - layout_filepath = os.path.join(self.env_path, 'view', LAYOUT_FILE) - with open(layout_filepath, 'w') as fn: - fn.write(self.layouts) - - def load_layouts(self): - if self.env_path is None: - warn_once( - 'Saving and loading to disk has no effect when running with ' - 'env_path=None.', - RuntimeWarning - ) - return "" - layout_filepath = os.path.join(self.env_path, 'view', LAYOUT_FILE) - ensure_dir_exists(layout_filepath) - if os.path.isfile(layout_filepath): - with open(layout_filepath, 'r') as fn: - return fn.read() - else: - return "" - - def load_state(self): - state = {} - env_path = self.env_path - if env_path is None: - warn_once( - 'Saving and loading to disk has no effect when running with ' - 'env_path=None.', - RuntimeWarning - ) - return {'main': {'jsons': {}, 'reload': {}}} - ensure_dir_exists(env_path) - env_jsons = [i for i in os.listdir(env_path) if '.json' in i] - - for env_json in env_jsons: - env_path_file = os.path.join(env_path, env_json) - try: - with open(env_path_file, 'r') as fn: - env_data = tornado.escape.json_decode(fn.read()) - except Exception as e: - logging.warn( - "Failed loading environment json: {} - {}".format( - env_path_file, repr(e))) - continue - - eid = env_json.replace('.json', '') - state[eid] = {'jsons': env_data['jsons'], - 'reload': env_data['reload']} - - if 'main' not in state and 'main.json' not in env_jsons: - state['main'] = {'jsons': {}, 'reload': {}} - serialize_env(state, ['main'], env_path=self.env_path) - - return state - - -def broadcast_envs(handler, target_subs=None): - if target_subs is None: - target_subs = handler.subs.values() - for sub in target_subs: - sub.write_message(json.dumps( - {'command': 'env_update', 'data': list(handler.state.keys())} - )) - - -def send_to_sources(handler, msg): - target_sources = handler.sources.values() - for source in target_sources: - source.write_message(json.dumps(msg)) - - -class BaseWebSocketHandler(tornado.websocket.WebSocketHandler): - def get_current_user(self): - """ - This method determines the self.current_user - based the value of cookies that set in POST method - at IndexHandler by self.set_secure_cookie - """ - try: - return self.get_secure_cookie("user_password") - except Exception: # Not using secure cookies - return None - - -class VisSocketHandler(BaseWebSocketHandler): - def initialize(self, app): - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - - def check_origin(self, origin): - return True - - def open(self): - if self.login_enabled and not self.current_user: - self.close() - return - self.sid = str(hex(int(time.time() * 10000000))[2:]) - if self not in list(self.sources.values()): - self.eid = 'main' - self.sources[self.sid] = self - logging.info('Opened visdom socket from ip: {}'.format( - self.request.remote_ip)) - - self.write_message( - json.dumps({'command': 'alive', 'data': 'vis_alive'})) - - def on_message(self, message): - logging.info('from visdom client: {}'.format(message)) - msg = tornado.escape.json_decode(tornado.escape.to_basestring(message)) - - cmd = msg.get('cmd') - if cmd == 'echo': - for sub in self.sources.values(): - sub.write_message(json.dumps(msg)) - - def on_close(self): - if self in list(self.sources.values()): - self.sources.pop(self.sid, None) - - -class VisSocketWrapper(): - def __init__(self, app): - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - self.app = app - self.messages = [] - self.last_read_time = time.time() - self.open() - try: - if not self.app.socket_wrap_monitor.is_running(): - self.app.socket_wrap_monitor.start() - except AttributeError: - self.app.socket_wrap_monitor = tornado.ioloop.PeriodicCallback( - self.socket_wrap_monitor_thread, 15000 - ) - self.app.socket_wrap_monitor.start() - - # TODO refactor the two socket wrappers into a wrapper class - def socket_wrap_monitor_thread(self): - if len(self.subs) > 0 or len(self.sources) > 0: - for sub in list(self.subs.values()): - if time.time() - sub.last_read_time > MAX_SOCKET_WAIT: - sub.close() - for sub in list(self.sources.values()): - if time.time() - sub.last_read_time > MAX_SOCKET_WAIT: - sub.close() - else: - self.app.socket_wrap_monitor.stop() - - def open(self): - if self.login_enabled and not self.current_user: - print("AUTH Failed in SocketHandler") - self.close() - return - self.sid = get_rand_id() - if self not in list(self.sources.values()): - self.eid = 'main' - self.sources[self.sid] = self - logging.info('Mocking visdom socket: {}'.format(self.sid)) - - self.write_message( - json.dumps({'command': 'alive', 'data': 'vis_alive'})) - - def on_message(self, message): - logging.info('from visdom client: {}'.format(message)) - msg = tornado.escape.json_decode(tornado.escape.to_basestring(message)) - - cmd = msg.get('cmd') - if cmd == 'echo': - for sub in self.sources.values(): - sub.write_message(json.dumps(msg)) - - def close(self): - if self in list(self.sources.values()): - self.sources.pop(self.sid, None) - - def write_message(self, msg): - self.messages.append(msg) - - def get_messages(self): - to_send = [] - while len(self.messages) > 0: - message = self.messages.pop() - if type(message) is dict: - # Not all messages are being formatted the same way (JSON) - # TODO investigate - message = json.dumps(message) - to_send.append(message) - self.last_read_time = time.time() - return to_send - - -class SocketHandler(BaseWebSocketHandler): - def initialize(self, app): - self.port = app.port - self.env_path = app.env_path - self.app = app - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.broadcast_layouts() - self.readonly = app.readonly - self.login_enabled = app.login_enabled - - def check_origin(self, origin): - return True - - def broadcast_layouts(self, target_subs=None): - if target_subs is None: - target_subs = self.subs.values() - for sub in target_subs: - sub.write_message(json.dumps( - {'command': 'layout_update', 'data': self.app.layouts} - )) - - def open(self): - if self.login_enabled and not self.current_user: - print("AUTH Failed in SocketHandler") - self.close() - return - self.sid = get_rand_id() - if self not in list(self.subs.values()): - self.eid = 'main' - self.subs[self.sid] = self - logging.info( - 'Opened new socket from ip: {}'.format(self.request.remote_ip)) - - self.write_message( - json.dumps({'command': 'register', 'data': self.sid, - 'readonly': self.readonly})) - self.broadcast_layouts([self]) - broadcast_envs(self, [self]) - - def on_message(self, message): - logging.info('from web client: {}'.format(message)) - msg = tornado.escape.json_decode(tornado.escape.to_basestring(message)) - - cmd = msg.get('cmd') - - if self.readonly: - return - - if cmd == 'close': - if 'data' in msg and 'eid' in msg: - logging.info('closing window {}'.format(msg['data'])) - p_data = self.state[msg['eid']]['jsons'].pop(msg['data'], None) - event = { - 'event_type': 'close', - 'target': msg['data'], - 'eid': msg['eid'], - 'pane_data': p_data, - } - send_to_sources(self, event) - elif cmd == 'save': - # save localStorage window metadata - if 'data' in msg and 'eid' in msg: - msg['eid'] = escape_eid(msg['eid']) - self.state[msg['eid']] = \ - copy.deepcopy(self.state[msg['prev_eid']]) - self.state[msg['eid']]['reload'] = msg['data'] - self.eid = msg['eid'] - serialize_env(self.state, [self.eid], env_path=self.env_path) - elif cmd == 'delete_env': - if 'eid' in msg: - logging.info('closing environment {}'.format(msg['eid'])) - del self.state[msg['eid']] - if self.env_path is not None: - p = os.path.join( - self.env_path, - "{0}.json".format(msg['eid']) - ) - os.remove(p) - broadcast_envs(self) - elif cmd == 'save_layouts': - if 'data' in msg: - self.app.layouts = msg.get('data') - self.app.save_layouts() - self.broadcast_layouts() - elif cmd == 'forward_to_vis': - packet = msg.get('data') - environment = self.state[packet['eid']] - if packet.get('pane_data') is not False: - packet['pane_data'] = environment['jsons'][packet['target']] - send_to_sources(self, msg.get('data')) - elif cmd == 'layout_item_update': - eid = msg.get('eid') - win = msg.get('win') - self.state[eid]['reload'][win] = msg.get('data') - elif cmd == 'pop_embeddings_pane': - packet = msg.get('data') - eid = packet['eid'] - win = packet['target'] - p = self.state[eid]['jsons'][win] - p['content']['selected'] = None - p['content']['data'] = p['old_content'].pop() - if len(p['old_content']) == 0: - p['content']['has_previous'] = False - p['contentID'] = get_rand_id() - broadcast(self, p, eid) - - def on_close(self): - if self in list(self.subs.values()): - self.subs.pop(self.sid, None) - - -# TODO condense some of the functionality between this class and the -# original SocketHandler class -class ClientSocketWrapper(): - """ - Wraps all of the socket actions in regular request handling, thus - allowing all of the same information to be sent via a polling interface - """ - def __init__(self, app): - self.port = app.port - self.env_path = app.env_path - self.app = app - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.readonly = app.readonly - self.login_enabled = app.login_enabled - self.messages = [] - self.last_read_time = time.time() - self.open() - try: - if not self.app.socket_wrap_monitor.is_running(): - self.app.socket_wrap_monitor.start() - except AttributeError: - self.app.socket_wrap_monitor = tornado.ioloop.PeriodicCallback( - self.socket_wrap_monitor_thread, 15000 - ) - self.app.socket_wrap_monitor.start() - - def socket_wrap_monitor_thread(self): - # TODO mark wrapped subs and sources separately - if len(self.subs) > 0 or len(self.sources) > 0: - for sub in list(self.subs.values()): - if time.time() - sub.last_read_time > MAX_SOCKET_WAIT: - sub.close() - for sub in list(self.sources.values()): - if time.time() - sub.last_read_time > MAX_SOCKET_WAIT: - sub.close() - else: - self.app.socket_wrap_monitor.stop() - - def broadcast_layouts(self, target_subs=None): - if target_subs is None: - target_subs = self.subs.values() - for sub in target_subs: - sub.write_message(json.dumps( - {'command': 'layout_update', 'data': self.app.layouts} - )) - - def open(self): - if self.login_enabled and not self.current_user: - print("AUTH Failed in SocketHandler") - self.close() - return - self.sid = get_rand_id() - if self not in list(self.subs.values()): - self.eid = 'main' - self.subs[self.sid] = self - logging.info('Mocking new socket: {}'.format(self.sid)) - - self.write_message( - json.dumps({'command': 'register', 'data': self.sid, - 'readonly': self.readonly})) - self.broadcast_layouts([self]) - broadcast_envs(self, [self]) - - def on_message(self, message): - logging.info('from web client: {}'.format(message)) - msg = tornado.escape.json_decode(tornado.escape.to_basestring(message)) - - cmd = msg.get('cmd') - - if self.readonly: - return - - if cmd == 'close': - if 'data' in msg and 'eid' in msg: - logging.info('closing window {}'.format(msg['data'])) - p_data = self.state[msg['eid']]['jsons'].pop(msg['data'], None) - event = { - 'event_type': 'close', - 'target': msg['data'], - 'eid': msg['eid'], - 'pane_data': p_data, - } - send_to_sources(self, event) - elif cmd == 'save': - # save localStorage window metadata - if 'data' in msg and 'eid' in msg: - msg['eid'] = escape_eid(msg['eid']) - self.state[msg['eid']] = \ - copy.deepcopy(self.state[msg['prev_eid']]) - self.state[msg['eid']]['reload'] = msg['data'] - self.eid = msg['eid'] - serialize_env(self.state, [self.eid], env_path=self.env_path) - elif cmd == 'delete_env': - if 'eid' in msg: - logging.info('closing environment {}'.format(msg['eid'])) - del self.state[msg['eid']] - if self.env_path is not None: - p = os.path.join( - self.env_path, - "{0}.json".format(msg['eid']) - ) - os.remove(p) - broadcast_envs(self) - elif cmd == 'save_layouts': - if 'data' in msg: - self.app.layouts = msg.get('data') - self.app.save_layouts() - self.broadcast_layouts() - elif cmd == 'forward_to_vis': - packet = msg.get('data') - environment = self.state[packet['eid']] - packet['pane_data'] = environment['jsons'][packet['target']] - send_to_sources(self, msg.get('data')) - elif cmd == 'layout_item_update': - eid = msg.get('eid') - win = msg.get('win') - self.state[eid]['reload'][win] = msg.get('data') - - def close(self): - if self in list(self.subs.values()): - self.subs.pop(self.sid, None) - - def write_message(self, msg): - self.messages.append(msg) - - def get_messages(self): - to_send = [] - while len(self.messages) > 0: - message = self.messages.pop() - if type(message) is dict: - # Not all messages are being formatted the same way (JSON) - # TODO investigate - message = json.dumps(message) - to_send.append(message) - self.last_read_time = time.time() - return to_send - - -class BaseHandler(tornado.web.RequestHandler): - def __init__(self, *request, **kwargs): - self.include_host = False - super(BaseHandler, self).__init__(*request, **kwargs) - - def get_current_user(self): - """ - This method determines the self.current_user - based the value of cookies that set in POST method - at IndexHandler by self.set_secure_cookie - """ - try: - return self.get_secure_cookie("user_password") - except Exception: # Not using secure cookies - return None - - def write_error(self, status_code, **kwargs): - logging.error("ERROR: %s: %s" % (status_code, kwargs)) - if "exc_info" in kwargs: - logging.info('Traceback: {}'.format( - traceback.format_exception(*kwargs["exc_info"]))) - if self.settings.get("debug") and "exc_info" in kwargs: - logging.error("rendering error page") - exc_info = kwargs["exc_info"] - # exc_info is a tuple consisting of: - # 1. The class of the Exception - # 2. The actual Exception that was thrown - # 3. The traceback opbject - try: - params = { - 'error': exc_info[1], - 'trace_info': traceback.format_exception(*exc_info), - 'request': self.request.__dict__ - } - - self.render("error.html", **params) - logging.error("rendering complete") - except Exception as e: - logging.error(e) - - -def update_window(p, args): - """Adds new args to a window if they exist""" - content = p['content'] - layout_update = args.get('layout', {}) - for layout_name, layout_val in layout_update.items(): - if layout_val is not None: - content['layout'][layout_name] = layout_val - opts = args.get('opts', {}) - for opt_name, opt_val in opts.items(): - if opt_val is not None: - p[opt_name] = opt_val - - if 'legend' in opts: - pdata = p['content']['data'] - for i, d in enumerate(pdata): - d['name'] = opts['legend'][i] - return p - - -def window(args): - """ Build a window dict structure for sending to client """ - uid = args.get('win', 'window_' + get_rand_id()) - if uid is None: - uid = 'window_' + get_rand_id() - opts = args.get('opts', {}) - - ptype = args['data'][0]['type'] - - p = { - 'command': 'window', - 'id': str(uid), - 'title': opts.get('title', ''), - 'inflate': opts.get('inflate', True), - 'width': opts.get('width'), - 'height': opts.get('height'), - 'contentID': get_rand_id(), # to detected updated windows - } - - if ptype == 'image_history': - p.update({ - 'content': [args['data'][0]['content']], - 'selected': 0, - 'type': ptype, - 'show_slider': opts.get('show_slider', True) - }) - elif ptype in ['image', 'text', 'properties']: - p.update({'content': args['data'][0]['content'], 'type': ptype}) - elif ptype in ['embeddings']: - p.update({ - 'content': args['data'][0]['content'], - 'type': ptype, - 'old_content': [], # Used to cache previous to prevent recompute - }) - p['content']['has_previous'] = False - else: - p['content'] = {'data': args['data'], 'layout': args['layout']} - p['type'] = 'plot' - - return p - - -def broadcast(self, msg, eid): - for s in self.subs: - if type(self.subs[s].eid) is list: - if eid in self.subs[s].eid: - self.subs[s].write_message(msg) - else: - if self.subs[s].eid == eid: - self.subs[s].write_message(msg) - - -def register_window(self, p, eid): - # in case env doesn't exist - is_new_env = False - if eid not in self.state: - is_new_env = True - self.state[eid] = {'jsons': {}, 'reload': {}} - - env = self.state[eid]['jsons'] - - if p['id'] in env: - p['i'] = env[p['id']]['i'] - else: - p['i'] = len(env) - - env[p['id']] = p - - broadcast(self, p, eid) - if is_new_env: - broadcast_envs(self) - self.write(p['id']) - - -class PostHandler(BaseHandler): - def initialize(self, app): - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - self.handlers = { - 'update': UpdateHandler, - 'save': SaveHandler, - 'close': CloseHandler, - 'win_exists': ExistsHandler, - 'delete_env': DeleteEnvHandler, - } - - @check_auth - def post(self): - req = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - - if req.get('func') is not None: - raise Exception( - 'Support for Lua Torch was deprecated following `v0.1.8.4`. ' - "If you'd like to use torch support, you'll need to download " - "that release. You can follow the usage instructions there, " - "but it is no longer officially supported." - ) - - eid = extract_eid(req) - p = window(req) - - register_window(self, p, eid) - - -class ExistsHandler(BaseHandler): - def initialize(self, app): - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - - @staticmethod - def wrap_func(handler, args): - eid = extract_eid(args) - if args['win'] in handler.state[eid]['jsons']: - handler.write('true') - else: - handler.write('false') - - @check_auth - def post(self): - args = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - self.wrap_func(self, args) - - -def order_by_key(kv): - key, val = kv - return key - - -# Based on json-stable-stringify-python from @haochi with some usecase modifications -def recursive_order(node): - if isinstance(node, Mapping): - ordered_mapping = OrderedDict(sorted(node.items(), key=order_by_key)) - for key, value in ordered_mapping.items(): - ordered_mapping[key] = recursive_order(value) - return ordered_mapping - elif isinstance(node, Sequence): - if isinstance(node, (bytes,)): - return node - elif isinstance(node, (str,)): - return node - else: - return [recursive_order(item) for item in node] - if isinstance(node, float) and node.is_integer(): - return int(node) - return node - - -def stringify(node): - return json.dumps(recursive_order(node), separators=COMPACT_SEPARATORS) - - -def hash_md_window(window_json): - json_string = stringify(window_json).encode("utf-8") - return hashlib.md5(json_string).hexdigest() - - -class UpdateHandler(BaseHandler): - def initialize(self, app): - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - - @staticmethod - def update_packet(p, args): - old_p = copy.deepcopy(p) - p = UpdateHandler.update(p, args) - p['contentID'] = get_rand_id() - # TODO: make_patch isn't high performance. - # If bottlenecked we should build the patch ourselves. - patch = jsonpatch.make_patch(old_p, p) - return p, patch.patch - - @staticmethod - def update(p, args): - # Update text in window, separated by a line break - if p['type'] == 'text': - p['content'] += "
" + args['data'][0]['content'] - return p - if p['type'] == 'embeddings': - # TODO embeddings updates should be handled outside of the regular - # update flow, as update packets are easy to create manually and - # expensive to calculate otherwise - if args['data']['update_type'] == 'EntitySelected': - p['content']['selected'] = args['data']['selected'] - elif args['data']['update_type'] == 'RegionSelected': - p['content']['selected'] = None - print(len(p['content']['data'])) - p['old_content'].append(p['content']['data']) - p['content']['has_previous'] = True - p['content']['data'] = args['data']['points'] - print(len(p['content']['data'])) - return p - if p['type'] == 'image_history': - utype = args['data'][0]['type'] - if utype == 'image_history': - p['content'].append(args['data'][0]['content']) - p['selected'] = len(p['content']) - 1 - elif utype == 'image_update_selected': - # TODO implement python client function for this - # Bound the update to within the dims of the array - selected = args['data'] - selected_not_neg = max(0, selected) - selected_exists = min(len(p['content'])-1, selected_not_neg) - p['selected'] = selected_exists - return p - - pdata = p['content']['data'] - - new_data = args.get('data') - p = update_window(p, args) - name = args.get('name') - if name is None and new_data is None: - return p # we only updated the opts or layout - append = args.get('append') - - idxs = list(range(len(pdata))) - - if name is not None: - assert len(new_data) == 1 or args.get('delete') - idxs = [i for i in idxs if pdata[i]['name'] == name] - - # Delete a trace - if args.get('delete'): - for idx in idxs: - del pdata[idx] - return p - - # inject new trace - if len(idxs) == 0: - idx = len(pdata) - pdata.append(dict(pdata[0])) # plot is not empty, clone an entry - idxs = [idx] - append = False - pdata[idx] = new_data[0] - for k, v in new_data[0].items(): - pdata[idx][k] = v - pdata[idx]['name'] = name - return p - - # Update traces - for n, idx in enumerate(idxs): - if all(math.isnan(i) or i is None for i in new_data[n]['x']): - continue - # handle data for plotting - for axis in ['x', 'y']: - pdata[idx][axis] = (pdata[idx][axis] + new_data[n][axis]) \ - if append else new_data[n][axis] - - # handle marker properties - if 'marker' not in new_data[n]: - continue - if 'marker' not in pdata[idx]: - pdata[idx]['marker'] = {} - pdata_marker = pdata[idx]['marker'] - for marker_prop in ['color']: - if marker_prop not in new_data[n]['marker']: - continue - if marker_prop not in pdata[idx]['marker']: - pdata[idx]['marker'][marker_prop] = [] - pdata_marker[marker_prop] = ( - pdata_marker[marker_prop] + - new_data[n]['marker'][marker_prop]) if append else \ - new_data[n]['marker'][marker_prop] - - return p - - @staticmethod - def wrap_func(handler, args): - eid = extract_eid(args) - - if args['win'] not in handler.state[eid]['jsons']: - # Append to a window that doesn't exist attempts to create - # that window - append = args.get('append') - if append: - p = window(args) - register_window(handler, p, eid) - else: - handler.write('win does not exist') - return - - p = handler.state[eid]['jsons'][args['win']] - - if not (p['type'] == 'text' or p['type'] == 'image_history' - or p['type'] == 'embeddings' - or p['content']['data'][0]['type'] in - ['scatter', 'scattergl', 'custom']): - handler.write( - 'win is not scatter, custom, image_history, embeddings, or text; ' - 'was {}'.format(p['content']['data'][0]['type'])) - return - - p, diff_packet = UpdateHandler.update_packet(p, args) - # send the smaller of the patch and the updated pane - if len(stringify(p)) <= len(stringify(diff_packet)): - broadcast(handler, p, eid) - else: - hashed = hash_md_window(p) - broadcast_packet = { - 'command': 'window_update', - 'win': args['win'], - 'env': eid, - 'content': diff_packet, - 'finalHash': hashed - } - broadcast(handler, broadcast_packet, eid) - handler.write(p['id']) - - @check_auth - def post(self): - if self.login_enabled and not self.current_user: - self.set_status(400) - return - args = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - self.wrap_func(self, args) - - -class CloseHandler(BaseHandler): - def initialize(self, app): - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - - @staticmethod - def wrap_func(handler, args): - eid = extract_eid(args) - win = args.get('win') - - keys = \ - list(handler.state[eid]['jsons'].keys()) if win is None else [win] - for win in keys: - handler.state[eid]['jsons'].pop(win, None) - broadcast( - handler, json.dumps({'command': 'close', 'data': win}), eid - ) - - @check_auth - def post(self): - args = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - self.wrap_func(self, args) - - -class SocketWrap(BaseHandler): - def initialize(self, app): - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - self.app = app - - @check_auth - def post(self): - """Either write a message to the socket, or query what's there""" - # TODO formalize failure reasons - args = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - type = args.get('message_type') - sid = args.get('sid') - socket_wrap = self.subs.get(sid) - # ensure a wrapper still exists for this connection - if socket_wrap is None: - self.write(json.dumps({'success': False, 'reason': 'closed'})) - return - - # handle the requests - if type == 'query': - messages = socket_wrap.get_messages() - self.write(json.dumps({ - 'success': True, 'messages': messages - })) - elif type == 'send': - msg = args.get('message') - if msg is None: - self.write(json.dumps({'success': False, 'reason': 'no msg'})) - else: - socket_wrap.on_message(msg) - self.write(json.dumps({'success': True})) - else: - self.write(json.dumps({'success': False, 'reason': 'invalid'})) - - @check_auth - def get(self): - """Create a new socket wrapper for this requester, return the id""" - new_sub = ClientSocketWrapper(self.app) - self.write(json.dumps({'success': True, 'sid': new_sub.sid})) - - -# TODO refactor socket wrappers to one class -class VisSocketWrap(BaseHandler): - def initialize(self, app): - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - self.app = app - - @check_auth - def post(self): - """Either write a message to the socket, or query what's there""" - # TODO formalize failure reasons - args = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - type = args.get('message_type') - sid = args.get('sid') - - if sid is None: - new_sub = VisSocketWrapper(self.app) - self.write(json.dumps({'success': True, 'sid': new_sub.sid})) - return - - socket_wrap = self.sources.get(sid) - # ensure a wrapper still exists for this connection - if socket_wrap is None: - self.write(json.dumps({'success': False, 'reason': 'closed'})) - return - - # handle the requests - if type == 'query': - messages = socket_wrap.get_messages() - self.write(json.dumps({ - 'success': True, 'messages': messages - })) - elif type == 'send': - msg = args.get('message') - if msg is None: - self.write(json.dumps({'success': False, 'reason': 'no msg'})) - else: - socket_wrap.on_message(msg) - self.write(json.dumps({'success': True})) - else: - self.write(json.dumps({'success': False, 'reason': 'invalid'})) - - -class DeleteEnvHandler(BaseHandler): - def initialize(self, app): - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - - @staticmethod - def wrap_func(handler, args): - eid = extract_eid(args) - if eid is not None: - del handler.state[eid] - if handler.env_path is not None: - p = os.path.join(handler.env_path, "{0}.json".format(eid)) - os.remove(p) - broadcast_envs(handler) - - @check_auth - def post(self): - args = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - self.wrap_func(self, args) - - -class EnvStateHandler(BaseHandler): - def initialize(self, app): - self.app = app - self.state = app.state - self.login_enabled = app.login_enabled - - @staticmethod - def wrap_func(handler, args): - # TODO if an env is provided return the state of that env - all_eids = list(handler.state.keys()) - handler.write(json.dumps(all_eids)) - - @check_auth - def post(self): - args = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - self.wrap_func(self, args) - - -class ForkEnvHandler(BaseHandler): - def initialize(self, app): - self.app = app - self.state = app.state - self.subs = app.subs - self.login_enabled = app.login_enabled - - @staticmethod - def wrap_func(handler, args): - prev_eid = escape_eid(args.get('prev_eid')) - eid = escape_eid(args.get('eid')) - - assert prev_eid in handler.state, 'env to be forked doesn\'t exit' - - handler.state[eid] = copy.deepcopy(handler.state[prev_eid]) - serialize_env(handler.state, [eid], env_path=handler.app.env_path) - broadcast_envs(handler) - - handler.write(eid) - - @check_auth - def post(self): - args = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - self.wrap_func(self, args) - - -class HashHandler(BaseHandler): - def initialize(self, app): - self.app = app - self.state = app.state - self.login_enabled = app.login_enabled - - @staticmethod - def wrap_func(handler, args): - eid = extract_eid(args) - handler_json = handler.state[eid]['jsons'] - if args['win'] in handler_json: - hashed = hash_md_window(handler_json[args['win']]) - handler.write(hashed) - else: - handler.write('false') - - @check_auth - def post(self): - args = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - self.wrap_func(self, args) - - -def load_env(state, eid, socket, env_path=DEFAULT_ENV_PATH): - """ load an environment to a client by socket """ - env = {} - if eid in state: - env = state.get(eid) - elif env_path is not None: - p = os.path.join(env_path, eid.strip(), '.json') - if os.path.exists(p): - with open(p, 'r') as fn: - env = tornado.escape.json_decode(fn.read()) - state[eid] = env - - if 'reload' in env: - socket.write_message( - json.dumps({'command': 'reload', 'data': env['reload']}) - ) - - jsons = list(env.get('jsons', {}).values()) - windows = sorted(jsons, key=lambda k: ('i' not in k, k.get('i', None))) - for v in windows: - socket.write_message(v) - - socket.write_message(json.dumps({'command': 'layout'})) - socket.eid = eid - - -def gather_envs(state, env_path=DEFAULT_ENV_PATH): - if env_path is not None: - items = [i.replace('.json', '') for i in os.listdir(env_path) - if '.json' in i] - else: - items = [] - return sorted(list(set(items + list(state.keys())))) - - -def compare_envs(state, eids, socket, env_path=DEFAULT_ENV_PATH): - logging.info('comparing envs') - eidNums = {e: str(i) for i, e in enumerate(eids)} - env = {} - envs = {} - for eid in eids: - if eid in state: - envs[eid] = state.get(eid) - elif env_path is not None: - p = os.path.join(env_path, eid.strip(), '.json') - if os.path.exists(p): - with open(p, 'r') as fn: - env = tornado.escape.json_decode(fn.read()) - state[eid] = env - envs[eid] = env - - res = copy.deepcopy(envs[list(envs.keys())[0]]) - name2Wid = {res['jsons'][wid].get('title', None): wid + '_compare' - for wid in res.get('jsons', {}) - if 'title' in res['jsons'][wid]} - for wid in list(res['jsons'].keys()): - res['jsons'][wid + '_compare'] = res['jsons'][wid] - res['jsons'][wid] = None - res['jsons'].pop(wid) - - for ix, eid in enumerate(envs.keys()): - env = envs[eid] - for wid in env.get('jsons', {}).keys(): - win = env['jsons'][wid] - if win.get('type', None) != 'plot': - continue - if 'content' not in win: - continue - if 'title' not in win: - continue - title = win['title'] - if title not in name2Wid or title == '': - continue - - destWid = name2Wid[title] - destWidJson = res['jsons'][destWid] - # Combine plots with the same window title. If plot data source was - # labeled "name" in the legend, rename to "envId_legend" where - # envId is enumeration of the selected environments (not the long - # environment id string). This makes plot lines more readable. - if ix == 0: - if 'name' not in destWidJson['content']['data'][0]: - continue # Skip windows with unnamed data - destWidJson['has_compare'] = False - destWidJson['content']['layout']['showlegend'] = True - destWidJson['contentID'] = get_rand_id() - for dataIdx, data in enumerate(destWidJson['content']['data']): - if 'name' not in data: - break # stop working with this plot, not right format - destWidJson['content']['data'][dataIdx]['name'] = \ - '{}_{}'.format(eidNums[eid], data['name']) - else: - if 'name' not in destWidJson['content']['data'][0]: - continue # Skip windows with unnamed data - # has_compare will be set to True only if the window title is - # shared by at least 2 envs. - destWidJson['has_compare'] = True - for _dataIdx, data in enumerate(win['content']['data']): - data = copy.deepcopy(data) - if 'name' not in data: - destWidJson['has_compare'] = False - break # stop working with this plot, not right format - data['name'] = '{}_{}'.format(eidNums[eid], data['name']) - destWidJson['content']['data'].append(data) - - # Make sure that only plots that are shared by at least two envs are shown. - # Check has_compare flag - for destWid in list(res['jsons'].keys()): - if ('has_compare' not in res['jsons'][destWid]) or \ - (not res['jsons'][destWid]['has_compare']): - del res['jsons'][destWid] - - # create legend mapping environment names to environment numbers so one can - # look it up for the new legend - tableRows = [" {} {} ".format(v, eidNums[v]) - for v in eidNums] - - tbl = """" - {}
""".format(' '.join(tableRows)) - - res['jsons']['window_compare_legend'] = { - "command": "window", - "id": "window_compare_legend", - "title": "compare_legend", - "inflate": True, - "width": None, - "height": None, - "contentID": "compare_legend", - "content": tbl, - "type": "text", - "layout": {"title": "compare_legend"}, - "i": 1, - "has_compare": True, - } - if 'reload' in res: - socket.write_message( - json.dumps({'command': 'reload', 'data': res['reload']}) - ) - - jsons = list(res.get('jsons', {}).values()) - windows = sorted(jsons, key=lambda k: ('i' not in k, k.get('i', None))) - for v in windows: - socket.write_message(v) - - socket.write_message(json.dumps({'command': 'layout'})) - socket.eid = eids - - -class EnvHandler(BaseHandler): - def initialize(self, app): - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - self.wrap_socket = app.wrap_socket - - @check_auth - def get(self, eid): - items = gather_envs(self.state, env_path=self.env_path) - active = '' if eid not in items else eid - self.render( - 'index.html', - user=getpass.getuser(), - items=items, - active_item=active, - wrap_socket=self.wrap_socket, - ) - - @check_auth - def post(self, args): - msg_args = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - if 'sid' in msg_args: - sid = msg_args['sid'] - if sid in self.subs: - load_env(self.state, args, self.subs[sid], - env_path=self.env_path) - if 'eid' in msg_args: - eid = msg_args['eid'] - if eid not in self.state: - self.state[eid] = {'jsons': {}, 'reload': {}} - broadcast_envs(self) - - -class CompareHandler(BaseHandler): - def initialize(self, app): - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.env_path = app.env_path - self.login_enabled = app.login_enabled - self.wrap_socket = app.wrap_socket - - @check_auth - def get(self, eids): - items = gather_envs(self.state) - eids = eids.split('+') - # Filter out eids that don't exist - eids = [x for x in eids if x in items] - eids = '+'.join(eids) - self.render( - 'index.html', - user=getpass.getuser(), - items=items, - active_item=eids, - wrap_socket=self.wrap_socket, - ) - - @check_auth - def post(self, args): - sid = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - )['sid'] - if sid in self.subs: - compare_envs(self.state, args.split('+'), self.subs[sid], - self.env_path) - - -class SaveHandler(BaseHandler): - def initialize(self, app): - self.state = app.state - self.subs = app.subs - self.sources = app.sources - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - - @staticmethod - def wrap_func(handler, args): - envs = args['data'] - envs = [escape_eid(eid) for eid in envs] - # this drops invalid env ids - ret = serialize_env(handler.state, envs, env_path=handler.env_path) - handler.write(json.dumps(ret)) - - @check_auth - def post(self): - args = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - self.wrap_func(self, args) - - -class DataHandler(BaseHandler): - def initialize(self, app): - self.state = app.state - self.subs = app.subs - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - - @staticmethod - def wrap_func(handler, args): - eid = extract_eid(args) - - if 'data' in args: - # Load data from client - data = json.loads(args['data']) - - if eid not in handler.state: - handler.state[eid] = {'jsons': {}, 'reload': {}} - - if 'win' in args and args['win'] is None: - handler.state[eid]['jsons'] = data - else: - handler.state[eid]['jsons'][args['win']] = data - - broadcast_envs(handler) - else: - # Dump data to client - if 'win' in args and args['win'] is None: - handler.write(json.dumps(handler.state[eid]['jsons'])) - else: - assert args['win'] in handler.state[eid]['jsons'], \ - "Window {} doesn't exist in env {}".format(args['win'], eid) - handler.write(json.dumps(handler.state[eid]['jsons'][args['win']])) - - @check_auth - def post(self): - args = tornado.escape.json_decode( - tornado.escape.to_basestring(self.request.body) - ) - self.wrap_func(self, args) - - -class IndexHandler(BaseHandler): - def initialize(self, app): - self.state = app.state - self.port = app.port - self.env_path = app.env_path - self.login_enabled = app.login_enabled - self.user_credential = app.user_credential - self.base_url = app.base_url if app.base_url != '' else '/' - self.wrap_socket = app.wrap_socket - - def get(self, args, **kwargs): - items = gather_envs(self.state, env_path=self.env_path) - if (not self.login_enabled) or self.current_user: - """self.current_user is an authenticated user provided by Tornado, - available when we set self.get_current_user in BaseHandler, - and the default value of self.current_user is None - """ - self.render( - 'index.html', - user=getpass.getuser(), - items=items, - active_item='', - wrap_socket=self.wrap_socket, - ) - elif self.login_enabled: - self.render( - 'login.html', - user=getpass.getuser(), - items=items, - active_item='', - base_url=self.base_url - ) - - def post(self, arg, **kwargs): - json_obj = tornado.escape.json_decode(self.request.body) - username = json_obj["username"] - password = hash_password(json_obj["password"]) - - if ((username == self.user_credential["username"]) and - (password == self.user_credential["password"])): - self.set_secure_cookie("user_password", username + password) - else: - self.set_status(400) - - -class ErrorHandler(BaseHandler): - def get(self, text): - error_text = text or "test error" - raise Exception(error_text) - - -# function that downloads and installs javascript, css, and font dependencies: -def download_scripts(proxies=None, install_dir=None): - import visdom - print("Checking for scripts.") - - # location in which to download stuff: - if install_dir is None: - install_dir = os.path.dirname(visdom.__file__) - - # all files that need to be downloaded: - b = 'https://unpkg.com/' - bb = '%sbootstrap@3.3.7/dist/' % b - ext_files = { - # - js - '%sjquery@3.1.1/dist/jquery.min.js' % b: 'jquery.min.js', - '%sbootstrap@3.3.7/dist/js/bootstrap.min.js' % b: 'bootstrap.min.js', - '%sreact@16.2.0/umd/react.production.min.js' % b: 'react-react.min.js', - '%sreact-dom@16.2.0/umd/react-dom.production.min.js' % b: - 'react-dom.min.js', - '%sreact-modal@3.1.10/dist/react-modal.min.js' % b: - 'react-modal.min.js', - 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS-MML_SVG': # noqa - 'mathjax-MathJax.js', - # here is another url in case the cdn breaks down again. - # https://raw.githubusercontent.com/plotly/plotly.js/master/dist/plotly.min.js - 'https://cdn.plot.ly/plotly-latest.min.js': 'plotly-plotly.min.js', - # Stanford Javascript Crypto Library for Password Hashing - '%ssjcl@1.0.7/sjcl.js' % b: 'sjcl.js', - - # - css - '%sreact-resizable@1.4.6/css/styles.css' % b: - 'react-resizable-styles.css', - '%sreact-grid-layout@0.16.3/css/styles.css' % b: - 'react-grid-layout-styles.css', - '%scss/bootstrap.min.css' % bb: 'bootstrap.min.css', - - # - fonts - '%sclassnames@2.2.5' % b: 'classnames', - '%slayout-bin-packer@1.4.0/dist/layout-bin-packer.js' % b: - 'layout_bin_packer.js', - '%sfonts/glyphicons-halflings-regular.eot' % bb: - 'glyphicons-halflings-regular.eot', - '%sfonts/glyphicons-halflings-regular.woff2' % bb: - 'glyphicons-halflings-regular.woff2', - '%sfonts/glyphicons-halflings-regular.woff' % bb: - 'glyphicons-halflings-regular.woff', - '%sfonts/glyphicons-halflings-regular.ttf' % bb: - 'glyphicons-halflings-regular.ttf', - '%sfonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular' % bb: # noqa - 'glyphicons-halflings-regular.svg#glyphicons_halflingsregular', - } - - # make sure all relevant folders exist: - dir_list = [ - '%s' % install_dir, - '%s/static' % install_dir, - '%s/static/js' % install_dir, - '%s/static/css' % install_dir, - '%s/static/fonts' % install_dir, - ] - for directory in dir_list: - if not os.path.exists(directory): - os.makedirs(directory) - - # set up proxy handler: - from urllib import request - from urllib.error import HTTPError, URLError - handler = request.ProxyHandler(proxies) if proxies is not None \ - else request.BaseHandler() - opener = request.build_opener(handler) - request.install_opener(opener) - - built_path = os.path.join(here, 'static/version.built') - is_built = visdom.__version__ == 'no_version_file' - if os.path.exists(built_path): - with open(built_path, 'r') as build_file: - build_version = build_file.read().strip() - if build_version == visdom.__version__: - is_built = True - else: - os.remove(built_path) - if not is_built: - print('Downloading scripts, this may take a little while') - - # download files one-by-one: - for (key, val) in ext_files.items(): - - # set subdirectory: - if val.endswith('.js'): - sub_dir = 'js' - elif val.endswith('.css'): - sub_dir = 'css' - else: - sub_dir = 'fonts' - - # download file: - filename = '%s/static/%s/%s' % (install_dir, sub_dir, val) - if not os.path.exists(filename) or not is_built: - req = request.Request(key, - headers={'User-Agent': 'Chrome/30.0.0.0'}) - try: - data = opener.open(req).read() - with open(filename, 'wb') as fwrite: - fwrite.write(data) - except HTTPError as exc: - logging.error('Error {} while downloading {}'.format( - exc.code, key)) - except URLError as exc: - logging.error('Error {} while downloading {}'.format( - exc.reason, key)) - - if not is_built: - with open(built_path, 'w+') as build_file: - build_file.write(visdom.__version__) - - -def start_server(port=DEFAULT_PORT, hostname=DEFAULT_HOSTNAME, - base_url=DEFAULT_BASE_URL, env_path=DEFAULT_ENV_PATH, - readonly=False, print_func=None, user_credential=None, - use_frontend_client_polling=False): - print("It's Alive!") - app = Application(port=port, base_url=base_url, env_path=env_path, - readonly=readonly, user_credential=user_credential, - use_frontend_client_polling=use_frontend_client_polling) - app.listen(port, max_buffer_size=1024 ** 3) - logging.info("Application Started") - - if "HOSTNAME" in os.environ and hostname == DEFAULT_HOSTNAME: - hostname = os.environ["HOSTNAME"] - else: - hostname = hostname - if print_func is None: - print( - "You can navigate to http://%s:%s%s" % (hostname, port, base_url)) - else: - print_func(port) - ioloop.IOLoop.instance().start() - app.subs = [] - app.sources = [] - - -def main(print_func=None): - parser = argparse.ArgumentParser(description='Start the visdom server.') - parser.add_argument('-port', metavar='port', type=int, - default=DEFAULT_PORT, - help='port to run the server on.') - parser.add_argument('--hostname', metavar='hostname', type=str, - default=DEFAULT_HOSTNAME, - help='host to run the server on.') - parser.add_argument('-base_url', metavar='base_url', type=str, - default=DEFAULT_BASE_URL, - help='base url for server (default = /).') - parser.add_argument('-env_path', metavar='env_path', type=str, - default=DEFAULT_ENV_PATH, - help='path to serialized session to reload.') - parser.add_argument('-logging_level', metavar='logger_level', - default='INFO', - help='logging level (default = INFO). Can take ' - 'logging level name or int (example: 20)') - parser.add_argument('-readonly', help='start in readonly mode', - action='store_true') - parser.add_argument('-enable_login', default=False, action='store_true', - help='start the server with authentication') - parser.add_argument('-force_new_cookie', default=False, - action='store_true', - help='start the server with the new cookie, ' - 'available when -enable_login provided') - parser.add_argument('-use_frontend_client_polling', default=False, - action='store_true', - help='Have the frontend communicate via polling ' - 'rather than over websockets.') - FLAGS = parser.parse_args() - - # Process base_url - base_url = FLAGS.base_url if FLAGS.base_url != DEFAULT_BASE_URL else "" - assert base_url == '' or base_url.startswith('/'), \ - 'base_url should start with /' - assert base_url == '' or not base_url.endswith('/'), \ - 'base_url should not end with / as it is appended automatically' - - try: - logging_level = int(FLAGS.logging_level) - except (ValueError,): - try: - logging_level = logging._checkLevel(FLAGS.logging_level) - except ValueError: - raise KeyError( - "Invalid logging level : {0}".format(FLAGS.logging_level) - ) - - logging.getLogger().setLevel(logging_level) - - if FLAGS.enable_login: - enable_env_login = 'VISDOM_USE_ENV_CREDENTIALS' - use_env = os.environ.get(enable_env_login, False) - if use_env: - username_var = 'VISDOM_USERNAME' - password_var = 'VISDOM_PASSWORD' - username = os.environ.get(username_var) - password = os.environ.get(password_var) - if not (username and password): - print( - '*** Warning ***\n' - 'You have set the {0} env variable but probably ' - 'forgot to setup one (or both) {{ {1}, {2} }} ' - 'variables.\nYou should setup these variables with ' - 'proper username and password to enable logging. Try to ' - 'setup the variables, or unset {0} to input credentials ' - 'via command line prompt instead.\n' - .format(enable_env_login, username_var, password_var)) - sys.exit(1) - - else: - username = input("Please input your username: ") - password = getpass.getpass(prompt="Please input your password: ") - - user_credential = { - "username": username, - "password": hash_password(hash_password(password)) - } - - need_to_set_cookie = ( - not os.path.isfile(DEFAULT_ENV_PATH + "COOKIE_SECRET") - or FLAGS.force_new_cookie) - - if need_to_set_cookie: - if use_env: - cookie_var = 'VISDOM_COOKIE' - env_cookie = os.environ.get(cookie_var) - if env_cookie is None: - print( - 'The cookie file is not found. Please setup {0} env ' - 'variable to provide a cookie value, or unset {1} env ' - 'variable to input credentials and cookie via command ' - 'line prompt.'.format(cookie_var, enable_env_login)) - sys.exit(1) - else: - env_cookie = None - set_cookie(env_cookie) - - else: - user_credential = None - - start_server(port=FLAGS.port, hostname=FLAGS.hostname, base_url=base_url, - env_path=FLAGS.env_path, readonly=FLAGS.readonly, - print_func=print_func, user_credential=user_credential, - use_frontend_client_polling=FLAGS.use_frontend_client_polling) - - -def download_scripts_and_run(): - download_scripts() - main() - - -if __name__ == "__main__": - download_scripts_and_run() diff --git a/py/visdom/server/__main__.py b/py/visdom/server/__main__.py new file mode 100644 index 00000000..aa1195d5 --- /dev/null +++ b/py/visdom/server/__main__.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +import sys + +assert sys.version_info[0] >= 3, 'To use visdom with python 2, downgrade to v0.1.8.9' + +if __name__ == "__main__": + from visdom.server.run_server import download_scripts_and_run + download_scripts_and_run() diff --git a/py/visdom/server/app.py b/py/visdom/server/app.py new file mode 100644 index 00000000..7f64fef3 --- /dev/null +++ b/py/visdom/server/app.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +""" +Main application class that pulls handlers together and maintains +all of the required state about the currently running server. +""" + +from visdom.utils.shared_utils import warn_once, ensure_dir_exists, get_visdom_path_to + +from visdom.utils.server_utils import ( + serialize_env, +) + +# TODO replace this next +from visdom.server.handlers.socket_handlers import * +from visdom.server.handlers.web_handlers import * + +import copy +import hashlib +import logging +import os +import time + +import tornado.web # noqa E402: gotta install ioloop first +import tornado.escape # noqa E402: gotta install ioloop first + +LAYOUT_FILE = 'layouts.json' + +tornado_settings = { + "autoescape": None, + "debug": "/dbg/" in __file__, + "static_path": get_visdom_path_to('static'), + "template_path": get_visdom_path_to('static'), + "compiled_template_cache": False +} + +class Application(tornado.web.Application): + def __init__(self, port=DEFAULT_PORT, base_url='', + env_path=DEFAULT_ENV_PATH, readonly=False, + user_credential=None, use_frontend_client_polling=False): + self.env_path = env_path + self.state = self.load_state() + self.layouts = self.load_layouts() + self.subs = {} + self.sources = {} + self.port = port + self.base_url = base_url + self.readonly = readonly + self.user_credential = user_credential + self.login_enabled = False + self.last_access = time.time() + self.wrap_socket = use_frontend_client_polling + + if user_credential: + self.login_enabled = True + with open(DEFAULT_ENV_PATH + "COOKIE_SECRET", "r") as fn: + tornado_settings["cookie_secret"] = fn.read() + + tornado_settings['static_url_prefix'] = self.base_url + "/static/" + tornado_settings['debug'] = True + handlers = [ + (r"%s/events" % self.base_url, PostHandler, {'app': self}), + (r"%s/update" % self.base_url, UpdateHandler, {'app': self}), + (r"%s/close" % self.base_url, CloseHandler, {'app': self}), + (r"%s/socket" % self.base_url, SocketHandler, {'app': self}), + (r"%s/socket_wrap" % self.base_url, SocketWrap, {'app': self}), + (r"%s/vis_socket" % self.base_url, + VisSocketHandler, {'app': self}), + (r"%s/vis_socket_wrap" % self.base_url, + VisSocketWrap, {'app': self}), + (r"%s/env/(.*)" % self.base_url, EnvHandler, {'app': self}), + (r"%s/compare/(.*)" % self.base_url, + CompareHandler, {'app': self}), + (r"%s/save" % self.base_url, SaveHandler, {'app': self}), + (r"%s/error/(.*)" % self.base_url, ErrorHandler, {'app': self}), + (r"%s/win_exists" % self.base_url, ExistsHandler, {'app': self}), + (r"%s/win_data" % self.base_url, DataHandler, {'app': self}), + (r"%s/delete_env" % self.base_url, + DeleteEnvHandler, {'app': self}), + (r"%s/win_hash" % self.base_url, HashHandler, {'app': self}), + (r"%s/env_state" % self.base_url, EnvStateHandler, {'app': self}), + (r"%s/fork_env" % self.base_url, ForkEnvHandler, {'app': self}), + (r"%s(.*)" % self.base_url, IndexHandler, {'app': self}), + ] + super(Application, self).__init__(handlers, **tornado_settings) + + def get_last_access(self): + if len(self.subs) > 0 or len(self.sources) > 0: + # update the last access time to now, as someone + # is currently connected to the server + self.last_access = time.time() + return self.last_access + + def save_layouts(self): + if self.env_path is None: + warn_once( + 'Saving and loading to disk has no effect when running with ' + 'env_path=None.', + RuntimeWarning + ) + return + layout_filepath = os.path.join(self.env_path, 'view', LAYOUT_FILE) + with open(layout_filepath, 'w') as fn: + fn.write(self.layouts) + + def load_layouts(self): + if self.env_path is None: + warn_once( + 'Saving and loading to disk has no effect when running with ' + 'env_path=None.', + RuntimeWarning + ) + return "" + layout_filepath = os.path.join(self.env_path, 'view', LAYOUT_FILE) + ensure_dir_exists(layout_filepath) + if os.path.isfile(layout_filepath): + with open(layout_filepath, 'r') as fn: + return fn.read() + else: + return "" + + def load_state(self): + state = {} + env_path = self.env_path + if env_path is None: + warn_once( + 'Saving and loading to disk has no effect when running with ' + 'env_path=None.', + RuntimeWarning + ) + return {'main': {'jsons': {}, 'reload': {}}} + ensure_dir_exists(env_path) + env_jsons = [i for i in os.listdir(env_path) if '.json' in i] + + for env_json in env_jsons: + env_path_file = os.path.join(env_path, env_json) + try: + with open(env_path_file, 'r') as fn: + env_data = tornado.escape.json_decode(fn.read()) + except Exception as e: + logging.warn( + "Failed loading environment json: {} - {}".format( + env_path_file, repr(e))) + continue + + eid = env_json.replace('.json', '') + state[eid] = {'jsons': env_data['jsons'], + 'reload': env_data['reload']} + + if 'main' not in state and 'main.json' not in env_jsons: + state['main'] = {'jsons': {}, 'reload': {}} + serialize_env(state, ['main'], env_path=self.env_path) + + return state diff --git a/py/visdom/server/build.py b/py/visdom/server/build.py new file mode 100644 index 00000000..9ca41890 --- /dev/null +++ b/py/visdom/server/build.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +import visdom +from visdom.utils.shared_utils import ensure_dir_exists, get_visdom_path +import os +from urllib import request +from urllib.error import HTTPError, URLError + + +def download_scripts(proxies=None, install_dir=None): + """ + Function to download all of the javascript, css, and font dependencies, + and put them in the correct locations to run the server + """ + print("Checking for scripts.") + + # location in which to download stuff: + if install_dir is None: + install_dir = get_visdom_path() + + # all files that need to be downloaded: + b = 'https://unpkg.com/' + bb = '%sbootstrap@3.3.7/dist/' % b + ext_files = { + # - js + '%sjquery@3.1.1/dist/jquery.min.js' % b: 'jquery.min.js', + '%sbootstrap@3.3.7/dist/js/bootstrap.min.js' % b: 'bootstrap.min.js', + '%sreact@16.2.0/umd/react.production.min.js' % b: 'react-react.min.js', + '%sreact-dom@16.2.0/umd/react-dom.production.min.js' % b: + 'react-dom.min.js', + '%sreact-modal@3.1.10/dist/react-modal.min.js' % b: + 'react-modal.min.js', + 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS-MML_SVG': # noqa + 'mathjax-MathJax.js', + # here is another url in case the cdn breaks down again. + # https://raw.githubusercontent.com/plotly/plotly.js/master/dist/plotly.min.js + 'https://cdn.plot.ly/plotly-latest.min.js': 'plotly-plotly.min.js', + # Stanford Javascript Crypto Library for Password Hashing + '%ssjcl@1.0.7/sjcl.js' % b: 'sjcl.js', + + # - css + '%sreact-resizable@1.4.6/css/styles.css' % b: + 'react-resizable-styles.css', + '%sreact-grid-layout@0.16.3/css/styles.css' % b: + 'react-grid-layout-styles.css', + '%scss/bootstrap.min.css' % bb: 'bootstrap.min.css', + + # - fonts + '%sclassnames@2.2.5' % b: 'classnames', + '%slayout-bin-packer@1.4.0/dist/layout-bin-packer.js' % b: + 'layout_bin_packer.js', + '%sfonts/glyphicons-halflings-regular.eot' % bb: + 'glyphicons-halflings-regular.eot', + '%sfonts/glyphicons-halflings-regular.woff2' % bb: + 'glyphicons-halflings-regular.woff2', + '%sfonts/glyphicons-halflings-regular.woff' % bb: + 'glyphicons-halflings-regular.woff', + '%sfonts/glyphicons-halflings-regular.ttf' % bb: + 'glyphicons-halflings-regular.ttf', + '%sfonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular' % bb: # noqa + 'glyphicons-halflings-regular.svg#glyphicons_halflingsregular', + } + + # make sure all relevant folders exist: + dir_list = [ + '%s' % install_dir, + '%s/static' % install_dir, + '%s/static/js' % install_dir, + '%s/static/css' % install_dir, + '%s/static/fonts' % install_dir, + ] + for directory in dir_list: + if not os.path.exists(directory): + os.makedirs(directory) + + # set up proxy handler: + handler = request.ProxyHandler(proxies) if proxies is not None \ + else request.BaseHandler() + opener = request.build_opener(handler) + request.install_opener(opener) + + built_path = os.path.join(install_dir, 'static/version.built') + is_built = visdom.__version__ == 'no_version_file' + if os.path.exists(built_path): + with open(built_path, 'r') as build_file: + build_version = build_file.read().strip() + if build_version == visdom.__version__: + is_built = True + else: + os.remove(built_path) + if not is_built: + print('Downloading scripts, this may take a little while') + + # download files one-by-one: + for (key, val) in ext_files.items(): + + # set subdirectory: + if val.endswith('.js'): + sub_dir = 'js' + elif val.endswith('.css'): + sub_dir = 'css' + else: + sub_dir = 'fonts' + + # download file: + filename = '%s/static/%s/%s' % (install_dir, sub_dir, val) + if not os.path.exists(filename) or not is_built: + req = request.Request(key, + headers={'User-Agent': 'Chrome/30.0.0.0'}) + try: + data = opener.open(req).read() + with open(filename, 'wb') as fwrite: + fwrite.write(data) + except HTTPError as exc: + logging.error('Error {} while downloading {}'.format( + exc.code, key)) + except URLError as exc: + logging.error('Error {} while downloading {}'.format( + exc.reason, key)) + + if not is_built: + with open(built_path, 'w+') as build_file: + build_file.write(visdom.__version__) diff --git a/py/visdom/server/defaults.py b/py/visdom/server/defaults.py new file mode 100644 index 00000000..99957ef5 --- /dev/null +++ b/py/visdom/server/defaults.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +from os.path import expanduser + +DEFAULT_ENV_PATH = '%s/.visdom/' % expanduser("~") +DEFAULT_PORT = 8097 +DEFAULT_HOSTNAME = "localhost" +DEFAULT_BASE_URL = "/" diff --git a/py/visdom/server/handlers/base_handlers.py b/py/visdom/server/handlers/base_handlers.py new file mode 100644 index 00000000..21607999 --- /dev/null +++ b/py/visdom/server/handlers/base_handlers.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +""" +Contain the basic web request handlers that all other handlers derive from +""" + +import logging +import traceback + +import tornado.web +import tornado.websocket + + +class BaseWebSocketHandler(tornado.websocket.WebSocketHandler): + """ + Implements any required overriden functionality from the basic tornado + websocket handler. Also contains some shared logic for all WebSocketHandler + classes. + """ + + def get_current_user(self): + """ + This method determines the self.current_user + based the value of cookies that set in POST method + at IndexHandler by self.set_secure_cookie + """ + try: + return self.get_secure_cookie("user_password") + except Exception: # Not using secure cookies + return None + + +class BaseHandler(tornado.web.RequestHandler): + """ + Implements any required overriden functionality from the basic tornado + request handlers, and contains any convenient shared logic helpers. + """ + + def __init__(self, *request, **kwargs): + self.include_host = False + super(BaseHandler, self).__init__(*request, **kwargs) + + def get_current_user(self): + """ + This method determines the self.current_user + based the value of cookies that set in POST method + at IndexHandler by self.set_secure_cookie + """ + try: + return self.get_secure_cookie("user_password") + except Exception: # Not using secure cookies + return None + + def write_error(self, status_code, **kwargs): + logging.error("ERROR: %s: %s" % (status_code, kwargs)) + if "exc_info" in kwargs: + logging.info('Traceback: {}'.format( + traceback.format_exception(*kwargs["exc_info"]))) + if self.settings.get("debug") and "exc_info" in kwargs: + logging.error("rendering error page") + exc_info = kwargs["exc_info"] + # exc_info is a tuple consisting of: + # 1. The class of the Exception + # 2. The actual Exception that was thrown + # 3. The traceback opbject + try: + params = { + 'error': exc_info[1], + 'trace_info': traceback.format_exception(*exc_info), + 'request': self.request.__dict__ + } + + # TODO make an error.html page + self.render("error.html", **params) + logging.error("rendering complete") + except Exception as e: + logging.error(e) diff --git a/py/visdom/server/handlers/socket_handlers.py b/py/visdom/server/handlers/socket_handlers.py new file mode 100644 index 00000000..d3d1c891 --- /dev/null +++ b/py/visdom/server/handlers/socket_handlers.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +""" +Handlers for the different types of socket events. Mostly handles parsing and +processing the web events themselves and interfacing with the server as +necessary, but defers underlying manipulations of the server's data to +the data_model itself. +""" + +# TODO fix these imports +from visdom.utils.shared_utils import * +from visdom.utils.server_utils import * +from visdom.server.handlers.base_handlers import * +import copy +import getpass +import hashlib +import json +import jsonpatch +import logging +import math +import os +import time +from collections import OrderedDict +try: + # for after python 3.8 + from collections.abc import Mapping, Sequence +except ImportError: + # for python 3.7 and below + from collections import Mapping, Sequence + +import tornado.ioloop +import tornado.escape + +MAX_SOCKET_WAIT = 15 + +# TODO move the logic that actually parses environments and layouts to +# new classes in the data_model folder. +# TODO move generalized initialization logic from these handlers into the +# basehandler +# TODO abstract out any direct references to the app where possible from +# all handlers. Can instead provide accessor functions on the state? +# TODO abstract socket interaction logic such that both the regular +# sockets and the poll-based wrappers are using as much shared code as +# possible. Try to standardize the code between the client-server and +# visdom-server socket edges. +class VisSocketHandler(BaseWebSocketHandler): + def initialize(self, app): + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + + def check_origin(self, origin): + return True + + def open(self): + if self.login_enabled and not self.current_user: + self.close() + return + self.sid = str(hex(int(time.time() * 10000000))[2:]) + if self not in list(self.sources.values()): + self.eid = 'main' + self.sources[self.sid] = self + logging.info('Opened visdom socket from ip: {}'.format( + self.request.remote_ip)) + + self.write_message( + json.dumps({'command': 'alive', 'data': 'vis_alive'})) + + def on_message(self, message): + logging.info('from visdom client: {}'.format(message)) + msg = tornado.escape.json_decode(tornado.escape.to_basestring(message)) + + cmd = msg.get('cmd') + if cmd == 'echo': + for sub in self.sources.values(): + sub.write_message(json.dumps(msg)) + + def on_close(self): + if self in list(self.sources.values()): + self.sources.pop(self.sid, None) + + +class VisSocketWrapper(): + def __init__(self, app): + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + self.app = app + self.messages = [] + self.last_read_time = time.time() + self.open() + try: + if not self.app.socket_wrap_monitor.is_running(): + self.app.socket_wrap_monitor.start() + except AttributeError: + self.app.socket_wrap_monitor = tornado.ioloop.PeriodicCallback( + self.socket_wrap_monitor_thread, 15000 + ) + self.app.socket_wrap_monitor.start() + + # TODO refactor the two socket wrappers into a wrapper class + def socket_wrap_monitor_thread(self): + if len(self.subs) > 0 or len(self.sources) > 0: + for sub in list(self.subs.values()): + if time.time() - sub.last_read_time > MAX_SOCKET_WAIT: + sub.close() + for sub in list(self.sources.values()): + if time.time() - sub.last_read_time > MAX_SOCKET_WAIT: + sub.close() + else: + self.app.socket_wrap_monitor.stop() + + def open(self): + if self.login_enabled and not self.current_user: + print("AUTH Failed in SocketHandler") + self.close() + return + self.sid = get_rand_id() + if self not in list(self.sources.values()): + self.eid = 'main' + self.sources[self.sid] = self + logging.info('Mocking visdom socket: {}'.format(self.sid)) + + self.write_message( + json.dumps({'command': 'alive', 'data': 'vis_alive'})) + + def on_message(self, message): + logging.info('from visdom client: {}'.format(message)) + msg = tornado.escape.json_decode(tornado.escape.to_basestring(message)) + + cmd = msg.get('cmd') + if cmd == 'echo': + for sub in self.sources.values(): + sub.write_message(json.dumps(msg)) + + def close(self): + if self in list(self.sources.values()): + self.sources.pop(self.sid, None) + + def write_message(self, msg): + self.messages.append(msg) + + def get_messages(self): + to_send = [] + while len(self.messages) > 0: + message = self.messages.pop() + if type(message) is dict: + # Not all messages are being formatted the same way (JSON) + # TODO investigate + message = json.dumps(message) + to_send.append(message) + self.last_read_time = time.time() + return to_send + + +class SocketHandler(BaseWebSocketHandler): + def initialize(self, app): + self.port = app.port + self.env_path = app.env_path + self.app = app + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.broadcast_layouts() + self.readonly = app.readonly + self.login_enabled = app.login_enabled + + def check_origin(self, origin): + return True + + def broadcast_layouts(self, target_subs=None): + if target_subs is None: + target_subs = self.subs.values() + for sub in target_subs: + sub.write_message(json.dumps( + {'command': 'layout_update', 'data': self.app.layouts} + )) + + def open(self): + if self.login_enabled and not self.current_user: + print("AUTH Failed in SocketHandler") + self.close() + return + self.sid = get_rand_id() + if self not in list(self.subs.values()): + self.eid = 'main' + self.subs[self.sid] = self + logging.info( + 'Opened new socket from ip: {}'.format(self.request.remote_ip)) + + self.write_message( + json.dumps({'command': 'register', 'data': self.sid, + 'readonly': self.readonly})) + self.broadcast_layouts([self]) + broadcast_envs(self, [self]) + + def on_message(self, message): + logging.info('from web client: {}'.format(message)) + msg = tornado.escape.json_decode(tornado.escape.to_basestring(message)) + + cmd = msg.get('cmd') + + if self.readonly: + return + + if cmd == 'close': + if 'data' in msg and 'eid' in msg: + logging.info('closing window {}'.format(msg['data'])) + p_data = self.state[msg['eid']]['jsons'].pop(msg['data'], None) + event = { + 'event_type': 'close', + 'target': msg['data'], + 'eid': msg['eid'], + 'pane_data': p_data, + } + send_to_sources(self, event) + elif cmd == 'save': + # save localStorage window metadata + if 'data' in msg and 'eid' in msg: + msg['eid'] = escape_eid(msg['eid']) + self.state[msg['eid']] = \ + copy.deepcopy(self.state[msg['prev_eid']]) + self.state[msg['eid']]['reload'] = msg['data'] + self.eid = msg['eid'] + serialize_env(self.state, [self.eid], env_path=self.env_path) + elif cmd == 'delete_env': + if 'eid' in msg: + logging.info('closing environment {}'.format(msg['eid'])) + del self.state[msg['eid']] + if self.env_path is not None: + p = os.path.join( + self.env_path, + "{0}.json".format(msg['eid']) + ) + os.remove(p) + broadcast_envs(self) + elif cmd == 'save_layouts': + if 'data' in msg: + self.app.layouts = msg.get('data') + self.app.save_layouts() + self.broadcast_layouts() + elif cmd == 'forward_to_vis': + packet = msg.get('data') + environment = self.state[packet['eid']] + if packet.get('pane_data') is not False: + packet['pane_data'] = environment['jsons'][packet['target']] + send_to_sources(self, msg.get('data')) + elif cmd == 'layout_item_update': + eid = msg.get('eid') + win = msg.get('win') + self.state[eid]['reload'][win] = msg.get('data') + elif cmd == 'pop_embeddings_pane': + packet = msg.get('data') + eid = packet['eid'] + win = packet['target'] + p = self.state[eid]['jsons'][win] + p['content']['selected'] = None + p['content']['data'] = p['old_content'].pop() + if len(p['old_content']) == 0: + p['content']['has_previous'] = False + p['contentID'] = get_rand_id() + broadcast(self, p, eid) + + def on_close(self): + if self in list(self.subs.values()): + self.subs.pop(self.sid, None) + + +# TODO condense some of the functionality between this class and the +# original SocketHandler class +class ClientSocketWrapper(): + """ + Wraps all of the socket actions in regular request handling, thus + allowing all of the same information to be sent via a polling interface + """ + def __init__(self, app): + self.port = app.port + self.env_path = app.env_path + self.app = app + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.readonly = app.readonly + self.login_enabled = app.login_enabled + self.messages = [] + self.last_read_time = time.time() + self.open() + try: + if not self.app.socket_wrap_monitor.is_running(): + self.app.socket_wrap_monitor.start() + except AttributeError: + self.app.socket_wrap_monitor = tornado.ioloop.PeriodicCallback( + self.socket_wrap_monitor_thread, 15000 + ) + self.app.socket_wrap_monitor.start() + + def socket_wrap_monitor_thread(self): + # TODO mark wrapped subs and sources separately + if len(self.subs) > 0 or len(self.sources) > 0: + for sub in list(self.subs.values()): + if time.time() - sub.last_read_time > MAX_SOCKET_WAIT: + sub.close() + for sub in list(self.sources.values()): + if time.time() - sub.last_read_time > MAX_SOCKET_WAIT: + sub.close() + else: + self.app.socket_wrap_monitor.stop() + + def broadcast_layouts(self, target_subs=None): + if target_subs is None: + target_subs = self.subs.values() + for sub in target_subs: + sub.write_message(json.dumps( + {'command': 'layout_update', 'data': self.app.layouts} + )) + + def open(self): + if self.login_enabled and not self.current_user: + print("AUTH Failed in SocketHandler") + self.close() + return + self.sid = get_rand_id() + if self not in list(self.subs.values()): + self.eid = 'main' + self.subs[self.sid] = self + logging.info('Mocking new socket: {}'.format(self.sid)) + + self.write_message( + json.dumps({'command': 'register', 'data': self.sid, + 'readonly': self.readonly})) + self.broadcast_layouts([self]) + broadcast_envs(self, [self]) + + def on_message(self, message): + logging.info('from web client: {}'.format(message)) + msg = tornado.escape.json_decode(tornado.escape.to_basestring(message)) + + cmd = msg.get('cmd') + + if self.readonly: + return + + if cmd == 'close': + if 'data' in msg and 'eid' in msg: + logging.info('closing window {}'.format(msg['data'])) + p_data = self.state[msg['eid']]['jsons'].pop(msg['data'], None) + event = { + 'event_type': 'close', + 'target': msg['data'], + 'eid': msg['eid'], + 'pane_data': p_data, + } + send_to_sources(self, event) + elif cmd == 'save': + # save localStorage window metadata + if 'data' in msg and 'eid' in msg: + msg['eid'] = escape_eid(msg['eid']) + self.state[msg['eid']] = \ + copy.deepcopy(self.state[msg['prev_eid']]) + self.state[msg['eid']]['reload'] = msg['data'] + self.eid = msg['eid'] + serialize_env(self.state, [self.eid], env_path=self.env_path) + elif cmd == 'delete_env': + if 'eid' in msg: + logging.info('closing environment {}'.format(msg['eid'])) + del self.state[msg['eid']] + if self.env_path is not None: + p = os.path.join( + self.env_path, + "{0}.json".format(msg['eid']) + ) + os.remove(p) + broadcast_envs(self) + elif cmd == 'save_layouts': + if 'data' in msg: + self.app.layouts = msg.get('data') + self.app.save_layouts() + self.broadcast_layouts() + elif cmd == 'forward_to_vis': + packet = msg.get('data') + environment = self.state[packet['eid']] + packet['pane_data'] = environment['jsons'][packet['target']] + send_to_sources(self, msg.get('data')) + elif cmd == 'layout_item_update': + eid = msg.get('eid') + win = msg.get('win') + self.state[eid]['reload'][win] = msg.get('data') + + def close(self): + if self in list(self.subs.values()): + self.subs.pop(self.sid, None) + + def write_message(self, msg): + self.messages.append(msg) + + def get_messages(self): + to_send = [] + while len(self.messages) > 0: + message = self.messages.pop() + if type(message) is dict: + # Not all messages are being formatted the same way (JSON) + # TODO investigate + message = json.dumps(message) + to_send.append(message) + self.last_read_time = time.time() + return to_send + + +class SocketWrap(BaseHandler): + def initialize(self, app): + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + self.app = app + + @check_auth + def post(self): + """Either write a message to the socket, or query what's there""" + # TODO formalize failure reasons + args = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + type = args.get('message_type') + sid = args.get('sid') + socket_wrap = self.subs.get(sid) + # ensure a wrapper still exists for this connection + if socket_wrap is None: + self.write(json.dumps({'success': False, 'reason': 'closed'})) + return + + # handle the requests + if type == 'query': + messages = socket_wrap.get_messages() + self.write(json.dumps({ + 'success': True, 'messages': messages + })) + elif type == 'send': + msg = args.get('message') + if msg is None: + self.write(json.dumps({'success': False, 'reason': 'no msg'})) + else: + socket_wrap.on_message(msg) + self.write(json.dumps({'success': True})) + else: + self.write(json.dumps({'success': False, 'reason': 'invalid'})) + + @check_auth + def get(self): + """Create a new socket wrapper for this requester, return the id""" + new_sub = ClientSocketWrapper(self.app) + self.write(json.dumps({'success': True, 'sid': new_sub.sid})) + + +# TODO refactor socket wrappers to one class +class VisSocketWrap(BaseHandler): + def initialize(self, app): + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + self.app = app + + @check_auth + def post(self): + """Either write a message to the socket, or query what's there""" + # TODO formalize failure reasons + args = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + type = args.get('message_type') + sid = args.get('sid') + + if sid is None: + new_sub = VisSocketWrapper(self.app) + self.write(json.dumps({'success': True, 'sid': new_sub.sid})) + return + + socket_wrap = self.sources.get(sid) + # ensure a wrapper still exists for this connection + if socket_wrap is None: + self.write(json.dumps({'success': False, 'reason': 'closed'})) + return + + # handle the requests + if type == 'query': + messages = socket_wrap.get_messages() + self.write(json.dumps({ + 'success': True, 'messages': messages + })) + elif type == 'send': + msg = args.get('message') + if msg is None: + self.write(json.dumps({'success': False, 'reason': 'no msg'})) + else: + socket_wrap.on_message(msg) + self.write(json.dumps({'success': True})) + else: + self.write(json.dumps({'success': False, 'reason': 'invalid'})) diff --git a/py/visdom/server/handlers/web_handlers.py b/py/visdom/server/handlers/web_handlers.py new file mode 100644 index 00000000..f73a8aca --- /dev/null +++ b/py/visdom/server/handlers/web_handlers.py @@ -0,0 +1,585 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +""" +Handlers for the different types of web request events. Mostly handles parsing +and processing the web events themselves and interfacing with the server as +necessary, but defers underlying manipulations of the server's data to +the data_model itself. +""" + +# TODO fix these imports +from visdom.utils.shared_utils import * +from visdom.utils.server_utils import * +from visdom.server.handlers.base_handlers import BaseHandler +import copy +import getpass +import hashlib +import json +import jsonpatch +import logging +import math +import os +import time +from collections import OrderedDict +try: + # for after python 3.8 + from collections.abc import Mapping, Sequence +except ImportError: + # for python 3.7 and below + from collections import Mapping, Sequence + +import tornado.escape + +MAX_SOCKET_WAIT = 15 + + +# TODO move the logic that actually parses environments and layouts to +# new classes in the data_model folder. +# TODO move generalized initialization logic from these handlers into the +# basehandler +# TODO abstract out any direct references to the app where possible from +# all handlers. Can instead provide accessor functions on the state? +class PostHandler(BaseHandler): + def initialize(self, app): + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + + @check_auth + def post(self): + req = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + + if req.get('func') is not None: + raise Exception( + 'Support for Lua Torch was deprecated following `v0.1.8.4`. ' + "If you'd like to use torch support, you'll need to download " + "that release. You can follow the usage instructions there, " + "but it is no longer officially supported." + ) + + eid = extract_eid(req) + p = window(req) + + register_window(self, p, eid) + + +class ExistsHandler(BaseHandler): + def initialize(self, app): + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + + @staticmethod + def wrap_func(handler, args): + eid = extract_eid(args) + if args['win'] in handler.state[eid]['jsons']: + handler.write('true') + else: + handler.write('false') + + @check_auth + def post(self): + args = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + self.wrap_func(self, args) + + +class UpdateHandler(BaseHandler): + def initialize(self, app): + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + + @staticmethod + def update_packet(p, args): + old_p = copy.deepcopy(p) + p = UpdateHandler.update(p, args) + p['contentID'] = get_rand_id() + # TODO: make_patch isn't high performance. + # If bottlenecked we should build the patch ourselves. + patch = jsonpatch.make_patch(old_p, p) + return p, patch.patch + + @staticmethod + def update(p, args): + # Update text in window, separated by a line break + if p['type'] == 'text': + p['content'] += "
" + args['data'][0]['content'] + return p + if p['type'] == 'embeddings': + # TODO embeddings updates should be handled outside of the regular + # update flow, as update packets are easy to create manually and + # expensive to calculate otherwise + if args['data']['update_type'] == 'EntitySelected': + p['content']['selected'] = args['data']['selected'] + elif args['data']['update_type'] == 'RegionSelected': + p['content']['selected'] = None + print(len(p['content']['data'])) + p['old_content'].append(p['content']['data']) + p['content']['has_previous'] = True + p['content']['data'] = args['data']['points'] + print(len(p['content']['data'])) + return p + if p['type'] == 'image_history': + utype = args['data'][0]['type'] + if utype == 'image_history': + p['content'].append(args['data'][0]['content']) + p['selected'] = len(p['content']) - 1 + elif utype == 'image_update_selected': + # TODO implement python client function for this + # Bound the update to within the dims of the array + selected = args['data'] + selected_not_neg = max(0, selected) + selected_exists = min(len(p['content'])-1, selected_not_neg) + p['selected'] = selected_exists + return p + + pdata = p['content']['data'] + + new_data = args.get('data') + p = update_window(p, args) + name = args.get('name') + if name is None and new_data is None: + return p # we only updated the opts or layout + append = args.get('append') + + idxs = list(range(len(pdata))) + + if name is not None: + assert len(new_data) == 1 or args.get('delete') + idxs = [i for i in idxs if pdata[i]['name'] == name] + + # Delete a trace + if args.get('delete'): + for idx in idxs: + del pdata[idx] + return p + + # inject new trace + if len(idxs) == 0: + idx = len(pdata) + pdata.append(dict(pdata[0])) # plot is not empty, clone an entry + idxs = [idx] + append = False + pdata[idx] = new_data[0] + for k, v in new_data[0].items(): + pdata[idx][k] = v + pdata[idx]['name'] = name + return p + + # Update traces + for n, idx in enumerate(idxs): + if all(math.isnan(i) or i is None for i in new_data[n]['x']): + continue + # handle data for plotting + for axis in ['x', 'y']: + pdata[idx][axis] = (pdata[idx][axis] + new_data[n][axis]) \ + if append else new_data[n][axis] + + # handle marker properties + if 'marker' not in new_data[n]: + continue + if 'marker' not in pdata[idx]: + pdata[idx]['marker'] = {} + pdata_marker = pdata[idx]['marker'] + for marker_prop in ['color']: + if marker_prop not in new_data[n]['marker']: + continue + if marker_prop not in pdata[idx]['marker']: + pdata[idx]['marker'][marker_prop] = [] + pdata_marker[marker_prop] = ( + pdata_marker[marker_prop] + + new_data[n]['marker'][marker_prop]) if append else \ + new_data[n]['marker'][marker_prop] + + return p + + @staticmethod + def wrap_func(handler, args): + eid = extract_eid(args) + + if args['win'] not in handler.state[eid]['jsons']: + # Append to a window that doesn't exist attempts to create + # that window + append = args.get('append') + if append: + p = window(args) + register_window(handler, p, eid) + else: + handler.write('win does not exist') + return + + p = handler.state[eid]['jsons'][args['win']] + + if not (p['type'] == 'text' or p['type'] == 'image_history' + or p['type'] == 'embeddings' + or p['content']['data'][0]['type'] in + ['scatter', 'scattergl', 'custom']): + handler.write( + 'win is not scatter, custom, image_history, embeddings, or text; ' + 'was {}'.format(p['content']['data'][0]['type'])) + return + + p, diff_packet = UpdateHandler.update_packet(p, args) + # send the smaller of the patch and the updated pane + if len(stringify(p)) <= len(stringify(diff_packet)): + broadcast(handler, p, eid) + else: + hashed = hash_md_window(p) + broadcast_packet = { + 'command': 'window_update', + 'win': args['win'], + 'env': eid, + 'content': diff_packet, + 'finalHash': hashed + } + broadcast(handler, broadcast_packet, eid) + handler.write(p['id']) + + @check_auth + def post(self): + if self.login_enabled and not self.current_user: + self.set_status(400) + return + args = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + self.wrap_func(self, args) + + +class CloseHandler(BaseHandler): + def initialize(self, app): + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + + @staticmethod + def wrap_func(handler, args): + eid = extract_eid(args) + win = args.get('win') + + keys = \ + list(handler.state[eid]['jsons'].keys()) if win is None else [win] + for win in keys: + handler.state[eid]['jsons'].pop(win, None) + broadcast( + handler, json.dumps({'command': 'close', 'data': win}), eid + ) + + @check_auth + def post(self): + args = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + self.wrap_func(self, args) + + +class DeleteEnvHandler(BaseHandler): + def initialize(self, app): + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + + @staticmethod + def wrap_func(handler, args): + eid = extract_eid(args) + if eid is not None: + del handler.state[eid] + if handler.env_path is not None: + p = os.path.join(handler.env_path, "{0}.json".format(eid)) + os.remove(p) + broadcast_envs(handler) + + @check_auth + def post(self): + args = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + self.wrap_func(self, args) + + +class EnvStateHandler(BaseHandler): + def initialize(self, app): + self.app = app + self.state = app.state + self.login_enabled = app.login_enabled + + @staticmethod + def wrap_func(handler, args): + # TODO if an env is provided return the state of that env + all_eids = list(handler.state.keys()) + handler.write(json.dumps(all_eids)) + + @check_auth + def post(self): + args = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + self.wrap_func(self, args) + + +class ForkEnvHandler(BaseHandler): + def initialize(self, app): + self.app = app + self.state = app.state + self.subs = app.subs + self.login_enabled = app.login_enabled + + @staticmethod + def wrap_func(handler, args): + prev_eid = escape_eid(args.get('prev_eid')) + eid = escape_eid(args.get('eid')) + + assert prev_eid in handler.state, 'env to be forked doesn\'t exit' + + handler.state[eid] = copy.deepcopy(handler.state[prev_eid]) + serialize_env(handler.state, [eid], env_path=handler.app.env_path) + broadcast_envs(handler) + + handler.write(eid) + + @check_auth + def post(self): + args = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + self.wrap_func(self, args) + + +class HashHandler(BaseHandler): + def initialize(self, app): + self.app = app + self.state = app.state + self.login_enabled = app.login_enabled + + @staticmethod + def wrap_func(handler, args): + eid = extract_eid(args) + handler_json = handler.state[eid]['jsons'] + if args['win'] in handler_json: + hashed = hash_md_window(handler_json[args['win']]) + handler.write(hashed) + else: + handler.write('false') + + @check_auth + def post(self): + args = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + self.wrap_func(self, args) + + +class EnvHandler(BaseHandler): + def initialize(self, app): + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + self.wrap_socket = app.wrap_socket + + @check_auth + def get(self, eid): + items = gather_envs(self.state, env_path=self.env_path) + active = '' if eid not in items else eid + self.render( + 'index.html', + user=getpass.getuser(), + items=items, + active_item=active, + wrap_socket=self.wrap_socket, + ) + + @check_auth + def post(self, args): + msg_args = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + if 'sid' in msg_args: + sid = msg_args['sid'] + if sid in self.subs: + load_env(self.state, args, self.subs[sid], + env_path=self.env_path) + if 'eid' in msg_args: + eid = msg_args['eid'] + if eid not in self.state: + self.state[eid] = {'jsons': {}, 'reload': {}} + broadcast_envs(self) + + +class CompareHandler(BaseHandler): + def initialize(self, app): + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.env_path = app.env_path + self.login_enabled = app.login_enabled + self.wrap_socket = app.wrap_socket + + @check_auth + def get(self, eids): + items = gather_envs(self.state) + eids = eids.split('+') + # Filter out eids that don't exist + eids = [x for x in eids if x in items] + eids = '+'.join(eids) + self.render( + 'index.html', + user=getpass.getuser(), + items=items, + active_item=eids, + wrap_socket=self.wrap_socket, + ) + + @check_auth + def post(self, args): + sid = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + )['sid'] + if sid in self.subs: + compare_envs(self.state, args.split('+'), self.subs[sid], + self.env_path) + + +class SaveHandler(BaseHandler): + def initialize(self, app): + self.state = app.state + self.subs = app.subs + self.sources = app.sources + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + + @staticmethod + def wrap_func(handler, args): + envs = args['data'] + envs = [escape_eid(eid) for eid in envs] + # this drops invalid env ids + ret = serialize_env(handler.state, envs, env_path=handler.env_path) + handler.write(json.dumps(ret)) + + @check_auth + def post(self): + args = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + self.wrap_func(self, args) + + +class DataHandler(BaseHandler): + def initialize(self, app): + self.state = app.state + self.subs = app.subs + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + + @staticmethod + def wrap_func(handler, args): + eid = extract_eid(args) + + if 'data' in args: + # Load data from client + data = json.loads(args['data']) + + if eid not in handler.state: + handler.state[eid] = {'jsons': {}, 'reload': {}} + + if 'win' in args and args['win'] is None: + handler.state[eid]['jsons'] = data + else: + handler.state[eid]['jsons'][args['win']] = data + + broadcast_envs(handler) + else: + # Dump data to client + if 'win' in args and args['win'] is None: + handler.write(json.dumps(handler.state[eid]['jsons'])) + else: + assert args['win'] in handler.state[eid]['jsons'], \ + "Window {} doesn't exist in env {}".format(args['win'], eid) + handler.write(json.dumps(handler.state[eid]['jsons'][args['win']])) + + @check_auth + def post(self): + args = tornado.escape.json_decode( + tornado.escape.to_basestring(self.request.body) + ) + self.wrap_func(self, args) + + +class IndexHandler(BaseHandler): + def initialize(self, app): + self.state = app.state + self.port = app.port + self.env_path = app.env_path + self.login_enabled = app.login_enabled + self.user_credential = app.user_credential + self.base_url = app.base_url if app.base_url != '' else '/' + self.wrap_socket = app.wrap_socket + + def get(self, args, **kwargs): + items = gather_envs(self.state, env_path=self.env_path) + if (not self.login_enabled) or self.current_user: + """self.current_user is an authenticated user provided by Tornado, + available when we set self.get_current_user in BaseHandler, + and the default value of self.current_user is None + """ + self.render( + 'index.html', + user=getpass.getuser(), + items=items, + active_item='', + wrap_socket=self.wrap_socket, + ) + elif self.login_enabled: + self.render( + 'login.html', + user=getpass.getuser(), + items=items, + active_item='', + base_url=self.base_url + ) + + def post(self, arg, **kwargs): + json_obj = tornado.escape.json_decode(self.request.body) + username = json_obj["username"] + password = hash_password(json_obj["password"]) + + if ((username == self.user_credential["username"]) and + (password == self.user_credential["password"])): + self.set_secure_cookie("user_password", username + password) + else: + self.set_status(400) + + +class ErrorHandler(BaseHandler): + def get(self, text): + error_text = text or "test error" + raise Exception(error_text) diff --git a/py/visdom/server/run_server.py b/py/visdom/server/run_server.py new file mode 100644 index 00000000..91296ad4 --- /dev/null +++ b/py/visdom/server/run_server.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +""" +Provides simple entrypoints to set up and run the main visdom server. +""" + +from visdom.server.app import Application +from visdom.server.defaults import ( + DEFAULT_BASE_URL, + DEFAULT_ENV_PATH, + DEFAULT_HOSTNAME, + DEFAULT_PORT, +) +from visdom.server.build import download_scripts + +import argparse +import getpass +import logging +import os +import sys + +from tornado import ioloop + + +def start_server(port=DEFAULT_PORT, hostname=DEFAULT_HOSTNAME, + base_url=DEFAULT_BASE_URL, env_path=DEFAULT_ENV_PATH, + readonly=False, print_func=None, user_credential=None, + use_frontend_client_polling=False): + """Run a visdom server with the given arguments""" + logging.info("It's Alive!") + app = Application(port=port, base_url=base_url, env_path=env_path, + readonly=readonly, user_credential=user_credential, + use_frontend_client_polling=use_frontend_client_polling) + app.listen(port, max_buffer_size=1024 ** 3) + logging.info("Application Started") + + if "HOSTNAME" in os.environ and hostname == DEFAULT_HOSTNAME: + hostname = os.environ["HOSTNAME"] + else: + hostname = hostname + if print_func is None: + print( + "You can navigate to http://%s:%s%s" % (hostname, port, base_url)) + else: + print_func(port) + ioloop.IOLoop.instance().start() + app.subs = [] + app.sources = [] + + +def main(print_func=None): + """ + Run a server from the command line, first parsing arguments from the + command line + """ + parser = argparse.ArgumentParser(description='Start the visdom server.') + parser.add_argument('-port', metavar='port', type=int, + default=DEFAULT_PORT, + help='port to run the server on.') + parser.add_argument('--hostname', metavar='hostname', type=str, + default=DEFAULT_HOSTNAME, + help='host to run the server on.') + parser.add_argument('-base_url', metavar='base_url', type=str, + default=DEFAULT_BASE_URL, + help='base url for server (default = /).') + parser.add_argument('-env_path', metavar='env_path', type=str, + default=DEFAULT_ENV_PATH, + help='path to serialized session to reload.') + parser.add_argument('-logging_level', metavar='logger_level', + default='INFO', + help='logging level (default = INFO). Can take ' + 'logging level name or int (example: 20)') + parser.add_argument('-readonly', help='start in readonly mode', + action='store_true') + parser.add_argument('-enable_login', default=False, action='store_true', + help='start the server with authentication') + parser.add_argument('-force_new_cookie', default=False, + action='store_true', + help='start the server with the new cookie, ' + 'available when -enable_login provided') + parser.add_argument('-use_frontend_client_polling', default=False, + action='store_true', + help='Have the frontend communicate via polling ' + 'rather than over websockets.') + FLAGS = parser.parse_args() + + # Process base_url + base_url = FLAGS.base_url if FLAGS.base_url != DEFAULT_BASE_URL else "" + assert base_url == '' or base_url.startswith('/'), \ + 'base_url should start with /' + assert base_url == '' or not base_url.endswith('/'), \ + 'base_url should not end with / as it is appended automatically' + + try: + logging_level = int(FLAGS.logging_level) + except (ValueError,): + try: + logging_level = logging._checkLevel(FLAGS.logging_level) + except ValueError: + raise KeyError( + "Invalid logging level : {0}".format(FLAGS.logging_level) + ) + + logging.getLogger().setLevel(logging_level) + + if FLAGS.enable_login: + enable_env_login = 'VISDOM_USE_ENV_CREDENTIALS' + use_env = os.environ.get(enable_env_login, False) + if use_env: + username_var = 'VISDOM_USERNAME' + password_var = 'VISDOM_PASSWORD' + username = os.environ.get(username_var) + password = os.environ.get(password_var) + if not (username and password): + print( + '*** Warning ***\n' + 'You have set the {0} env variable but probably ' + 'forgot to setup one (or both) {{ {1}, {2} }} ' + 'variables.\nYou should setup these variables with ' + 'proper username and password to enable logging. Try to ' + 'setup the variables, or unset {0} to input credentials ' + 'via command line prompt instead.\n' + .format(enable_env_login, username_var, password_var)) + sys.exit(1) + + else: + username = input("Please input your username: ") + password = getpass.getpass(prompt="Please input your password: ") + + user_credential = { + "username": username, + "password": hash_password(hash_password(password)) + } + + need_to_set_cookie = ( + not os.path.isfile(DEFAULT_ENV_PATH + "COOKIE_SECRET") + or FLAGS.force_new_cookie) + + if need_to_set_cookie: + if use_env: + cookie_var = 'VISDOM_COOKIE' + env_cookie = os.environ.get(cookie_var) + if env_cookie is None: + print( + 'The cookie file is not found. Please setup {0} env ' + 'variable to provide a cookie value, or unset {1} env ' + 'variable to input credentials and cookie via command ' + 'line prompt.'.format(cookie_var, enable_env_login)) + sys.exit(1) + else: + env_cookie = None + set_cookie(env_cookie) + + else: + user_credential = None + + start_server(port=FLAGS.port, hostname=FLAGS.hostname, base_url=base_url, + env_path=FLAGS.env_path, readonly=FLAGS.readonly, + print_func=print_func, user_credential=user_credential, + use_frontend_client_polling=FLAGS.use_frontend_client_polling) + + +def download_scripts_and_run(): + download_scripts() + main() + + +if __name__ == "__main__": + download_scripts_and_run() diff --git a/py/visdom/utils/server_utils.py b/py/visdom/utils/server_utils.py new file mode 100644 index 00000000..ee33de48 --- /dev/null +++ b/py/visdom/utils/server_utils.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +""" +Utilities for the server architecture that don't really have +a more appropriate place. + +At the moment, this just inherited all of the floating functions +in the previous server.py class. +""" + +from visdom.server.defaults import ( + DEFAULT_BASE_URL, + DEFAULT_ENV_PATH, + DEFAULT_HOSTNAME, + DEFAULT_PORT, +) +from visdom.utils.shared_utils import get_new_window_id +from visdom.utils.shared_utils import ( + warn_once, + get_rand_id, + get_new_window_id, + ensure_dir_exists, +) +import copy +import hashlib +import json +import logging +import os +import time +from collections import OrderedDict +try: + # for after python 3.8 + from collections.abc import Mapping, Sequence +except ImportError: + # for python 3.7 and below + from collections import Mapping, Sequence + +from zmq.eventloop import ioloop +ioloop.install() # Needs to happen before any tornado imports! + +import tornado.escape # noqa E402: gotta install ioloop first + +LAYOUT_FILE = 'layouts.json' + +here = os.path.abspath(os.path.dirname(__file__)) +COMPACT_SEPARATORS = (',', ':') + +MAX_SOCKET_WAIT = 15 + +# ---- Vaguely server-security related functions ---- # + +def check_auth(f): + """ + Wrapper for server access methods to ensure that the access + is authorized. + """ + def _check_auth(handler, *args, **kwargs): + # TODO this should call a shared method of the handler + handler.last_access = time.time() + if handler.login_enabled and not handler.current_user: + handler.set_status(400) + return + f(handler, *args, **kwargs) + return _check_auth + +def set_cookie(value=None): + """Create cookie secret key for authentication""" + if value is not None: + cookie_secret = value + else: + cookie_secret = input("Please input your cookie secret key here: ") + with open(DEFAULT_ENV_PATH + "COOKIE_SECRET", "w") as cookie_file: + cookie_file.write(cookie_secret) + +def hash_password(password): + """Hashing Password with SHA-256""" + return hashlib.sha256(password.encode("utf-8")).hexdigest() + + +# ------- File management helprs ----- # + +def serialize_env(state, eids, env_path=DEFAULT_ENV_PATH): + env_ids = [i for i in eids if i in state] + if env_path is not None: + for env_id in env_ids: + env_path_file = os.path.join(env_path, "{0}.json".format(env_id)) + with open(env_path_file, 'w') as fn: + fn.write(json.dumps(state[env_id])) + return env_ids + + +def serialize_all(state, env_path=DEFAULT_ENV_PATH): + serialize_env(state, list(state.keys()), env_path=env_path) + + +# ------- Environment management helpers ----- # + + +def escape_eid(eid): + """Replace slashes with underscores, to avoid recognizing them + as directories. + """ + return eid.replace('/', '_') + + +def extract_eid(args): + """Extract eid from args. If eid does not exist in args, + it returns 'main'.""" + eid = 'main' if args.get('eid') is None else args.get('eid') + return escape_eid(eid) + + +def update_window(p, args): + """Adds new args to a window if they exist""" + content = p['content'] + layout_update = args.get('layout', {}) + for layout_name, layout_val in layout_update.items(): + if layout_val is not None: + content['layout'][layout_name] = layout_val + opts = args.get('opts', {}) + for opt_name, opt_val in opts.items(): + if opt_val is not None: + p[opt_name] = opt_val + + if 'legend' in opts: + pdata = p['content']['data'] + for i, d in enumerate(pdata): + d['name'] = opts['legend'][i] + return p + + +def window(args): + """ Build a window dict structure for sending to client """ + uid = args.get('win', get_new_window_id()) + if uid is None: + uid = get_new_window_id() + opts = args.get('opts', {}) + + ptype = args['data'][0]['type'] + + p = { + 'command': 'window', + 'id': str(uid), + 'title': opts.get('title', ''), + 'inflate': opts.get('inflate', True), + 'width': opts.get('width'), + 'height': opts.get('height'), + 'contentID': get_rand_id(), # to detected updated windows + } + + if ptype == 'image_history': + p.update({ + 'content': [args['data'][0]['content']], + 'selected': 0, + 'type': ptype, + 'show_slider': opts.get('show_slider', True) + }) + elif ptype in ['image', 'text', 'properties']: + p.update({'content': args['data'][0]['content'], 'type': ptype}) + elif ptype in ['embeddings']: + p.update({ + 'content': args['data'][0]['content'], + 'type': ptype, + 'old_content': [], # Used to cache previous to prevent recompute + }) + p['content']['has_previous'] = False + else: + p['content'] = {'data': args['data'], 'layout': args['layout']} + p['type'] = 'plot' + + return p + + +def gather_envs(state, env_path=DEFAULT_ENV_PATH): + if env_path is not None: + items = [i.replace('.json', '') for i in os.listdir(env_path) + if '.json' in i] + else: + items = [] + return sorted(list(set(items + list(state.keys())))) + + +def compare_envs(state, eids, socket, env_path=DEFAULT_ENV_PATH): + logging.info('comparing envs') + eidNums = {e: str(i) for i, e in enumerate(eids)} + env = {} + envs = {} + for eid in eids: + if eid in state: + envs[eid] = state.get(eid) + elif env_path is not None: + p = os.path.join(env_path, eid.strip(), '.json') + if os.path.exists(p): + with open(p, 'r') as fn: + env = tornado.escape.json_decode(fn.read()) + state[eid] = env + envs[eid] = env + + res = copy.deepcopy(envs[list(envs.keys())[0]]) + name2Wid = {res['jsons'][wid].get('title', None): wid + '_compare' + for wid in res.get('jsons', {}) + if 'title' in res['jsons'][wid]} + for wid in list(res['jsons'].keys()): + res['jsons'][wid + '_compare'] = res['jsons'][wid] + res['jsons'][wid] = None + res['jsons'].pop(wid) + + for ix, eid in enumerate(envs.keys()): + env = envs[eid] + for wid in env.get('jsons', {}).keys(): + win = env['jsons'][wid] + if win.get('type', None) != 'plot': + continue + if 'content' not in win: + continue + if 'title' not in win: + continue + title = win['title'] + if title not in name2Wid or title == '': + continue + + destWid = name2Wid[title] + destWidJson = res['jsons'][destWid] + # Combine plots with the same window title. If plot data source was + # labeled "name" in the legend, rename to "envId_legend" where + # envId is enumeration of the selected environments (not the long + # environment id string). This makes plot lines more readable. + if ix == 0: + if 'name' not in destWidJson['content']['data'][0]: + continue # Skip windows with unnamed data + destWidJson['has_compare'] = False + destWidJson['content']['layout']['showlegend'] = True + destWidJson['contentID'] = get_rand_id() + for dataIdx, data in enumerate(destWidJson['content']['data']): + if 'name' not in data: + break # stop working with this plot, not right format + destWidJson['content']['data'][dataIdx]['name'] = \ + '{}_{}'.format(eidNums[eid], data['name']) + else: + if 'name' not in destWidJson['content']['data'][0]: + continue # Skip windows with unnamed data + # has_compare will be set to True only if the window title is + # shared by at least 2 envs. + destWidJson['has_compare'] = True + for _dataIdx, data in enumerate(win['content']['data']): + data = copy.deepcopy(data) + if 'name' not in data: + destWidJson['has_compare'] = False + break # stop working with this plot, not right format + data['name'] = '{}_{}'.format(eidNums[eid], data['name']) + destWidJson['content']['data'].append(data) + + # Make sure that only plots that are shared by at least two envs are shown. + # Check has_compare flag + for destWid in list(res['jsons'].keys()): + if ('has_compare' not in res['jsons'][destWid]) or \ + (not res['jsons'][destWid]['has_compare']): + del res['jsons'][destWid] + + # create legend mapping environment names to environment numbers so one can + # look it up for the new legend + tableRows = [" {} {} ".format(v, eidNums[v]) + for v in eidNums] + + tbl = """" + {}
""".format(' '.join(tableRows)) + + res['jsons']['window_compare_legend'] = { + "command": "window", + "id": "window_compare_legend", + "title": "compare_legend", + "inflate": True, + "width": None, + "height": None, + "contentID": "compare_legend", + "content": tbl, + "type": "text", + "layout": {"title": "compare_legend"}, + "i": 1, + "has_compare": True, + } + if 'reload' in res: + socket.write_message( + json.dumps({'command': 'reload', 'data': res['reload']}) + ) + + jsons = list(res.get('jsons', {}).values()) + windows = sorted(jsons, key=lambda k: ('i' not in k, k.get('i', None))) + for v in windows: + socket.write_message(v) + + socket.write_message(json.dumps({'command': 'layout'})) + socket.eid = eids + + + +# ------- Broadcasting functions ---------- # + +def broadcast_envs(handler, target_subs=None): + if target_subs is None: + target_subs = handler.subs.values() + for sub in target_subs: + sub.write_message(json.dumps( + {'command': 'env_update', 'data': list(handler.state.keys())} + )) + + +def send_to_sources(handler, msg): + target_sources = handler.sources.values() + for source in target_sources: + source.write_message(json.dumps(msg)) + + +def load_env(state, eid, socket, env_path=DEFAULT_ENV_PATH): + """ load an environment to a client by socket """ + env = {} + if eid in state: + env = state.get(eid) + elif env_path is not None: + p = os.path.join(env_path, eid.strip(), '.json') + if os.path.exists(p): + with open(p, 'r') as fn: + env = tornado.escape.json_decode(fn.read()) + state[eid] = env + + if 'reload' in env: + socket.write_message( + json.dumps({'command': 'reload', 'data': env['reload']}) + ) + + jsons = list(env.get('jsons', {}).values()) + windows = sorted(jsons, key=lambda k: ('i' not in k, k.get('i', None))) + for v in windows: + socket.write_message(v) + + socket.write_message(json.dumps({'command': 'layout'})) + socket.eid = eid + + +def broadcast(self, msg, eid): + for s in self.subs: + if type(self.subs[s].eid) is list: + if eid in self.subs[s].eid: + self.subs[s].write_message(msg) + else: + if self.subs[s].eid == eid: + self.subs[s].write_message(msg) + + +def register_window(self, p, eid): + # in case env doesn't exist + is_new_env = False + if eid not in self.state: + is_new_env = True + self.state[eid] = {'jsons': {}, 'reload': {}} + + env = self.state[eid]['jsons'] + + if p['id'] in env: + p['i'] = env[p['id']]['i'] + else: + p['i'] = len(env) + + env[p['id']] = p + + broadcast(self, p, eid) + if is_new_env: + broadcast_envs(self) + self.write(p['id']) + + +# ----- Json patch helpers ---------- # + + +def order_by_key(kv): + key, val = kv + return key + + +# Based on json-stable-stringify-python from @haochi with some usecase modifications +def recursive_order(node): + if isinstance(node, Mapping): + ordered_mapping = OrderedDict(sorted(node.items(), key=order_by_key)) + for key, value in ordered_mapping.items(): + ordered_mapping[key] = recursive_order(value) + return ordered_mapping + elif isinstance(node, Sequence): + if isinstance(node, (bytes,)): + return node + elif isinstance(node, (str,)): + return node + else: + return [recursive_order(item) for item in node] + if isinstance(node, float) and node.is_integer(): + return int(node) + return node + + +def stringify(node): + return json.dumps(recursive_order(node), separators=COMPACT_SEPARATORS) + + +def hash_md_window(window_json): + json_string = stringify(window_json).encode("utf-8") + return hashlib.md5(json_string).hexdigest() diff --git a/py/visdom/utils/shared_utils.py b/py/visdom/utils/shared_utils.py new file mode 100644 index 00000000..23c164d4 --- /dev/null +++ b/py/visdom/utils/shared_utils.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +""" +Utilities that could be potentially useful in various different +parts of the visdom stack. Not to be used for particularly specific +helper functions. +""" + +import inspect +import uuid +import warnings +import os + +_seen_warnings = set() + + +def warn_once(msg, warningtype=None): + """ + Raise a warning, but only once. + :param str msg: Message to display + :param Warning warningtype: Type of warning, e.g. DeprecationWarning + """ + global _seen_warnings + if msg not in _seen_warnings: + _seen_warnings.add(msg) + warnings.warn(msg, warningtype, stacklevel=2) + + +def get_rand_id(): + """Returns a random id string""" + return str(uuid.uuid4()) + + +def get_new_window_id(): + """Return a string to be used for a new window""" + return f'win_{get_rand_id()}' + + +def ensure_dir_exists(path): + """Make sure the parent dir exists for path so we can write a file.""" + try: + os.makedirs(os.path.dirname(path)) + except OSError as e1: + assert e1.errno == 17 # errno.EEXIST + + +def get_visdom_path(): + """Get the path to the visdom/py/visdom directory.""" + cwd = os.path.dirname( + os.path.abspath(inspect.getfile(inspect.currentframe()))) + return os.path.dirname(cwd) + + +def get_visdom_path_to(filename): + """Get the path to a file in the visdom/py/visdom directory.""" + return os.path.join(get_visdom_path(), filename) diff --git a/setup.py b/setup.py index 147fa870..a0e5773c 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ def get_dist(pkgname): 'scipy', 'requests', 'tornado', - 'pyzmq', + # 'pyzmq', 'six', 'jsonpatch', 'websocket-client', @@ -59,6 +59,7 @@ def get_dist(pkgname): url='https://github.com/facebookresearch/visdom', description='A tool for visualizing live, rich data for Torch and Numpy', long_description=readme, + long_description_content_type="text/markdown", license='CC-BY-NC-4.0', # Package info