Source code for enamlnative.core.dev

# -*- coding: utf-8 -*-
"""
Copyright (c) 2017, CodeLV.

Distributed under the terms of the MIT License.

The full license is in the file LICENSE, distributed with this software.

@author jrm

"""
import os
import sys
import json
import shutil
import inspect
import traceback
import enamlnative
from atom.api import (
    Atom, Instance, List, Subclass, ForwardInstance, Enum, Unicode, Int, Bool
)
from contextlib import contextmanager
from .bridge import Command

with enamlnative.imports():
    from .hotswap.api import Hotswapper

[docs]@contextmanager def cd(newdir): prevdir = os.getcwd() os.chdir(os.path.expanduser(newdir)) try: print("Entering into {}".format(newdir)) yield print("Returning to {}".format(prevdir)) finally:
os.chdir(prevdir) DEFAULT_CODE = """ from enamlnative.core.api import * from enamlnative.widgets.api import * enamldef ContentView(Flexbox): TextView: text = "Hello world!" """ INDEX_PAGE = """<html> <head> <title>Enaml-Native Playground</title> <!--Import Google Icon Font--> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <!-- Compiled and minified CSS --> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.100.2/css/materialize.min.css"> <link rel="shortcut icon" href="https://www.codelv.com/static/faveicon.png"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> </head> <body> <div class="nav-fixed"> <nav role="navigation" class="teal"> <div class="nav-wrapper" style="margin-left:1em;"> <a href="#" class="brand-logo">Enaml-Native Playground</a> <ul id="nav-mobile" class="right hide-on-med-and-down"> <li><a href="https://www.codelv.com/projects/enaml-native/">Project</a></li> <li><a href="https://www.codelv.com/projects/enaml-native/docs/">Docs</a></li> </ul> </div> </nav> </div> <div class="row" style="margin-bottom:0;"> <div class="col l3 m2 s12" style="padding:0; max-height:100%; overflow-y:scroll; background: #fff;"> <nav> <div class="nav-wrapper grey darken-3"> <a href="#files" style="margin-left:1em;">App</a> </div> </nav> <ul id="files" data-collapsible="accordion"> ${files} </ul> <nav> <div class="nav-wrapper grey darken-3"> <a href="#components" style="margin-left:1em;">Components</a> </div> </nav> <ul id="components" data-collapsible="accordion"> ${components} </ul> </div> <div class="col l9 m10 s12" style="padding:0;"> <div id="editor" style="height:100%;width:100%;">${code}</div> <!-- code --> </div> </div> <div class="fixed-action-btn"> <a id="run" class="btn-floating btn-large blue" href="#"> <i class="large material-icons">play_arrow</i> </a> <ul> <li><a id="hotswap" href="#" class="btn-floating red"><i class="material-icons">refresh</i></a></li> </ul> </div> <footer class="page-footer teal"> <div> <div class="row"> <div class="col l6 s12"> <h5 class="white-text">Enaml-Native Playground</h5> <p class="grey-text text-lighten-4">Test out enaml native app code right from the browser!</p> </div> <div class="col l4 offset-l2 s12"> <h5 class="white-text">Links</h5> <ul> <li><a class="grey-text text-lighten-3" href="https://www.codelv.com/projects/enaml-native/docs/">Docs</a></li> <li><a class="grey-text text-lighten-3" href="https://github.com/frmdstryr/enaml-native/">Code</a></li> <li><a class="grey-text text-lighten-3" href="https://www.codelv.com/projects/enaml-native/support/">Support</a></li> </ul> </div> </div> </div> <div class="footer-copyright"> <div style="margin-left:1em;"> © 2017 <a href="https://www.codelv.com">www.codelv.com</a> <a class="grey-text text-lighten-4 right" href="https://www.codelv.com/projects/enaml-native/">Python powered native apps</a> </div> </div> </footer> <!--Import jQuery before materialize.js--> <script type="text/javascript" src="https://code.jquery.com/jquery-3.2.1.min.js"></script> <!-- Compiled and minified JavaScript --> <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.100.2/js/materialize.min.js"></script> <!-- Editor --> <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.8/ace.js"></script> <script type="text/javascript"> $(document).ready(function(){ // Init components $('#components').collapsible(); // Init editor var editor = ace.edit("editor"); editor.setTheme("ace/theme/github"); editor.getSession().setMode("ace/mode/python"); // Init app dev session var enaml; // Saved files var state = { currentFile: "view.enaml", files: {}, }; $('.editor-file').click(function(e){ var file = $(this).attr('id'); // Save the current state state.files[state.currentFile] = editor.getValue(); var updateEditor = function(text){ // Load the new one state.currentFile = file; editor.setValue(text,1); }; // Try to pull from tmp first (previous updates) // fallback to source code fetch("tmp/"+file).then(function(r){ if (r.status==404) { fetch("source/"+file).then(function(r){ r.text().then(updateEditor); }); } else { r.text().then(updateEditor); } }); }); $('#run').click(function(e){ try { // Trigger a reload // Update current file state.files[state.currentFile] = editor.getValue(); enaml.send(JSON.stringify({ 'type':'reload', 'files':state.files })); } catch (ex) { console.log(ex); } }); $('#hotswap').click(function(e){ try { // Trigger a reload // Update current file state.files[state.currentFile] = editor.getValue(); // Push our changes updates enaml.send(JSON.stringify({ 'type':'hotswap', 'files':state.files })); } catch (ex) { console.log(ex); } }); var connect = function(){ var url = "ws://"+window.location.hostname+":8888/dev"; enaml = new WebSocket(url); enaml.onopen = function(e) { console.log("Connected"); } enaml.onmessage = function(e) { } enaml.onclose = function(e) { console.log("Disconnected"); connect(); } } connect(); }); </script> </body> </html>""" #: This is why enaml-web is awesome, doing this sucks! COMPONENT_FIELD_TMPL = """ """ COMPONENT_TMPL = """ <li> <div class="collapsible-header" id="component-{id}">{name}</div> <div class="collapsible-body" style="padding:0 1em;"> <table class="bordered striped" style="font-size: small;"> <thead> <tr><td>Attr</td><td>Info</td></tr> </thead> <tbody> {info} {items} </tbody> </table> </div> </li>""" DROPDOWN_TMPL = """ <a class='dropdown-button' href='#' data-activates='{id}'>{name}</a> <ul id='{id}' class='dropdown-content'> {items} </ul> """ FOLDER_TMPL = """ <li> <div class="collapsible-header" id="file-{id}">{name}</div> <div class="collapsible-body" style="padding:0 1em;"> {items} </div> </li> """ FILE_TMPL = """ <li> <div class="collapsible-header editor-file" id="{id}">{name}</div> </li> """
[docs]def get_app(): from .app import BridgedApplication
return BridgedApplication
[docs]class DevClient(Atom): """ Abstract dev client. Override `start` to implement """ #: Current websocket connection connection = Instance(object) #: Mode of operation mode = Enum('client', 'remote')
[docs] @classmethod def available(cls): """ Return True if this dev client impl can be used. """
return False
[docs] def start(self, session):
raise NotImplementedError
[docs] def write_message(self, data, binary=False): """ Write a message to the client """
raise NotImplementedError
[docs]class TornadoDevClient(DevClient):
[docs] @classmethod def available(cls): """ Return True if this dev client impl can be used. """ try: import tornado return True except ImportError:
return False
[docs] def start(self, session): from tornado.websocket import websocket_connect from tornado import gen @gen.coroutine def run(): #: Create local references mode = session.mode process_events = session.app.process_events app = session.app try: print("Dev client connecting {}...".format(session.url)) conn = yield websocket_connect(session.url) session.connected = True self.connection = conn #: Start remote debugger if mode == 'remote': app.load_view(app) while True: msg = yield conn.read_message() if msg is None: break if mode == 'remote': process_events(msg) else: r = session.handle_message(msg) conn.write_message(json.dumps(r)) session.connected = False except Exception as e: print("Dev client connection dropped: {}".format(e)) finally: if mode == 'remote': session.app.stop() else: #: Try again in a few seconds session.app.timed_call(1000, run) #: Start
session.app.deferred_call(run)
[docs] def write_message(self, data, binary=False): """ Write a message to the client """
self.connection.write_message(data, binary)
[docs]class TwistedDevClient(DevClient):
[docs] @classmethod def available(cls): """ Return True if this dev client impl can be used. """ try: from autobahn.twisted import websocket return True except ImportError:
return False
[docs] def start(self, session): from twisted.internet import reactor from twisted.internet.defer import inlineCallbacks from autobahn.twisted.websocket import ( WebSocketClientProtocol, WebSocketClientFactory ) client = self #: Create local references mode = session.mode app = session.app process_events = session.app.process_events class DevClient(WebSocketClientProtocol): def onConnect(self, response=None): print("Dev client connected!") session.connected = True client.connection = self #: Start remote debugger if mode == 'remote': app.load_view(app) def onOpen(self): pass def onMessage(self, payload, isBinary): if mode == 'remote': process_events(payload) else: r = session.handle_message(payload) self.sendMessage(json.dumps(r)) def onClose(self, wasClean, code, reason): print("Dev client disconnected: {} {} {}".format( wasClean, code, reason)) session.connected = False # Stop the app if remote debugging if mode == 'remote': session.app.stop() else: #: Try again in a few seconds session.app.timed_call(1000, run) factory = WebSocketClientFactory(session.url) factory.protocol = DevClient def run(): host = session.host port = session.port if mode == "remote": host = session.url.split("/")[2] if ":" in host: host, port = host.split(":") print("Starting websocket client...") reactor.connectTCP(host, int(port), factory) #: Start
session.app.deferred_call(run)
[docs] def write_message(self, data, binary=False): """ Write a message to the client """
self.connection.sendMessage(data, binary)
[docs]class DevServer(Atom): """ Abstract dev server. Override `start` to implement """
[docs] @classmethod def available(cls): """ Return True if this dev server impl can be used. """
return False
[docs] def get_component_members(self, declaration): members = declaration.members().values() try: #: Exclude parent class members parent = declaration.__mro__[1] class_members = [] inherited = parent.members().keys() class_members = [m for m in members if m.name not in inherited] #: If not a direct subclass of View, show the parent members if not class_members and parent.__name__ != 'View': #: Try again one more time parent = parent.__mro__[1] inherited = parent.members().keys() class_members = [m for m in members if m.name not in inherited] members = class_members except: pass
return [m for m in members if not m.name.startswith("_")]
[docs] def render_component_types(self, declaration, member): """ """ node_type = member.__class__.__name__.lower() node_id = "{}-{}".format(declaration.__name__, member.name).lower() #: Build items items = [] if isinstance(member, Enum): items = ["<li><a href='#1'>{}</a></li>".format(it) for it in member.items] #: TODO: show instance types for instances, tuples, lists, etc.. #elif isinstance(member, ) #: Render dropdown if needed if items: return DROPDOWN_TMPL.format(id=node_id, name=node_type, items="".join(items))
return "{}".format(node_type)
[docs] def render_files(self, root=None): """ Render the file path as accordions """ if root is None: tmp = os.environ.get('TMP') root = sys.path[1 if tmp and tmp in sys.path else 0] items = [] for filename in os.listdir(root): # for subdirname in dirnames: # path = os.path.join(dirname, subdirname) # items.append(FOLDER_TMPL.format( # name=subdirname, # id=path, # items=self.render_files(path) # )) #for filename in filenames: f,ext = os.path.splitext(filename) if ext in ['.py', '.enaml']: items.append(FILE_TMPL.format( name=filename, id=filename ))
return "".join(items)
[docs] def render_code(self): """ Try to load the previous code (if we had a crash or something) I should allow saving. """ tmp_dir = os.environ.get('TMP','') view_code = os.path.join(tmp_dir,'view.enaml') if os.path.exists(view_code): try: with open(view_code) as f: return f.read() except: pass
return DEFAULT_CODE
[docs] def render_component(self, declaration): """ Render a row of all the attributes """ items = ["""<tr><td>{name}</td><td>{type}</td></tr>""" .format(name=m.name, type=self.render_component_types(declaration, m)) for m in self.get_component_members(declaration)] info = [] parent = declaration.__mro__[1] #: Superclass info.append("<tr><td>extends component</td>" "<td><a href='#component-{id}'>{name}</a></td></td>" .format(id=parent.__name__.lower(), name=parent.__name__)) #: Source and example, only works with enamlnative builtins source_path = inspect.getfile(declaration).replace( ".pyo", ".py").replace(".pyc", ".py") if 'enamlnative' in source_path: source_link = "https://github.com/frmdstryr/" \ "enaml-native/tree/master/src/{}".format( source_path.split("assets/python")[1] ) info.append("<tr><td>source code</td>" "<td><a href='{}' target='_blank'>show</a></td></td>" .format(source_link)) #: Examples link example_link = "https://www.codelv.com/projects/" \ "enaml-native/docs/components#{}" \ .format(declaration.__name__.lower()) info.append("<tr><td>example usage</td>" "<td><a href='{}' target='_blank'>view</a></td></td>" .format(example_link)) return COMPONENT_TMPL.format(id=declaration.__name__.lower(), name=declaration.__name__, info="".join(info),
items="".join(items))
[docs] def render_editor(self): from enaml.widgets.toolkit_object import ToolkitObject from enamlnative.widgets import api #: Get all declared widgets widgets = [obj for (n, obj) in inspect.getmembers(api) if inspect.isclass(obj) and issubclass(obj, ToolkitObject)] #: Render to html components = "\n".join([self.render_component(w) for w in widgets]) #: Just a little hackish, but hey it works return INDEX_PAGE.replace( "${components}", components ).replace( "${code}", self.render_code() ).replace( "${files}", self.render_files()
)
[docs] def start(self, session):
raise NotImplementedError
[docs]class TornadoDevServer(DevServer):
[docs] @classmethod def available(cls): """ Return True if this dev server impl can be used. """ try: import tornado return True except ImportError:
return False
[docs] def start(self, session): with enamlnative.imports(): import tornado.ioloop import tornado.web import tornado.websocket ioloop = tornado.ioloop.IOLoop.current() server = self class DevWebSocketHandler(tornado.websocket.WebSocketHandler): def open(self): print("Dev server client connected!") def on_message(self, message): #: Delegate r = session.handle_message(message) self.write_message(json.dumps(r)) def on_close(self): print("Dev server client lost!") class MainHandler(tornado.web.RequestHandler): def get(self): #: Delegate self.write(server.render_editor()) def post(self): #: Allow posting events r = session.handle_message(self.request.body) self.write(json.dumps(r)) app = tornado.web.Application([ (r"/", MainHandler), (r"/dev", DevWebSocketHandler), (r"/tmp/(.*)", tornado.web.StaticFileHandler, { 'path': os.environ.get('TMP', sys.path[0])}), (r"/source/(.*)", tornado.web.StaticFileHandler, { 'path': sys.path[0]}), ]) #: Start listening app.listen(session.port)
print("Tornado dev server started on {}".format(session.port))
[docs]class TwistedDevServer(DevServer):
[docs] @classmethod def available(cls): """ Return True if this dev server impl can be used. """ try: import twisted import autobahn return True except ImportError:
return False
[docs] def start(self, session): server = self with enamlnative.imports(): from twisted.internet import reactor from twisted.web import resource from twisted.web.static import File from twisted.web.server import Site from autobahn.twisted.websocket import ( WebSocketServerFactory, WebSocketServerProtocol ) from autobahn.twisted.resource import WebSocketResource class DevWebSocketHandler(WebSocketServerProtocol): def onConnect(self, request): print("Client connecting: {}".format(request.peer)) def onOpen(self): print("WebSocket connection open.") def onMessage(self, payload, isBinary): r = session.handle_message(payload) self.sendMessage(json.dumps(r)) def onClose(self, wasClean, code, reason): print("WebSocket connection closed: {}".format(reason)) class MainHandler(resource.Resource): def render_GET(self, req): return server.render_editor() def render_POST(self, req): #: Allow posting events r = session.handle_message(req.content.getvalue()) return json.dumps(r) factory = WebSocketServerFactory( u"ws://0.0.0.0:{}".format(session.port)) factory.protocol = DevWebSocketHandler root = resource.Resource() root.putChild("", MainHandler()) root.putChild("dev", WebSocketResource(factory)) root.putChild("source", File(sys.path[0])) root.putChild("tmp", File(os.environ.get('TMP',sys.path[0]))) site = Site(root) reactor.listenTCP(session.port, site)
print("Twisted dev server started on {}".format(session.port))
[docs]class DevServerSession(Atom): """ Connect to a dev server running on the LAN or if host is 0.0.0.0 server a page to let code be pasted in. Note this should NEVER be used in a released app! """ #: Singleton Instance of this class _instance = None #: Reference to the current Application app = ForwardInstance(get_app) #: Host to connect to (in client mode) or #: if set to "server" it will enable "server" mode host = Unicode() #: Port to serve on (in server mode) or port to connect to (in client mode) port = Int(8888) #: URL to connect to (in client mode) url = Unicode('ws://192.168.21.119:8888/dev') #: Websocket connection state connected = Bool() #: Message buffer buf = Unicode() #: Dev session mode mode = Enum('client', 'server', 'remote') #: Hotswap support class hotswap = Instance(Hotswapper) #: Delegate dev server servers = List(Subclass(DevServer), default=[ TornadoDevServer, TwistedDevServer, ]) server = Instance(DevServer) #: Delegate dev client clients = List(Subclass(DevClient), default=[ TornadoDevClient, TwistedDevClient, ]) client = Instance(DevClient) # ------------------------------------------------------------------------- # Initialization # -------------------------------------------------------------------------
[docs] @classmethod def initialize(cls, *args, **kwargs): """ Create an instance of this class. """ try: return DevServerSession(*args, **kwargs) except ImportError:
pass
[docs] @classmethod def instance(cls): """ Get the singleton instance """
return cls._instance
[docs] def __init__(self, *args, **kwargs): """ Overridden constructor that forces only one instance to ever exist. """ if self.instance() is not None: raise RuntimeError("A DevServerClient instance already exists!") super(DevServerSession, self).__init__(*args, **kwargs)
DevServerSession._instance = self
[docs] def start(self): """ Start the dev session. Attempt to use tornado first, then try twisted """ print("Starting debug client cwd: {}".format(os.getcwd())) print("Sys path: {}".format(sys.path)) #: Initialize the hotswapper self.hotswap = Hotswapper(debug=False) if self.mode == 'server': self.server.start(self) else:
self.client.start(self) # ------------------------------------------------------------------------- # Defaults # ------------------------------------------------------------------------- def _default_mode(self): """ If host is set to server then serve it from the app! """ host = self.host if host == 'server': return 'server' elif host == 'remote': return 'remote' return 'client' def _default_url(self): """ Websocket URL to connect to and listen for reload requests """ host = 'localhost' if self.mode == 'remote' else self.host return 'ws://{}:{}/dev'.format(host, self.port) def _default_app(self): """ Application instance """ return get_app().instance() def _default_server(self): for Server in self.servers: if Server.available(): return Server() raise NotImplementedError( "No dev servers are available! " "Include tornado or twisted in your requirements!") def _default_client(self): for Client in self.clients: if Client.available(): return Client() raise NotImplementedError( "No dev clients are available! " "Include tornado or twisted in your requirements!") def _observe_connected(self, change): """ Log connection state changes """ print("Dev session {}".format("connected" if self.connected else "disconnected")) # ------------------------------------------------------------------------- # Dev Session API # -------------------------------------------------------------------------
[docs] def write_message(self, data, binary=False): """ Write a message to the active client """
self.client.write_message(data, binary=binary)
[docs] def handle_message(self, data): """ When we get a message """ msg = json.loads(data) print("Dev server message: {}".format(msg)) handler_name = 'do_{}'.format(msg['type']) if hasattr(self, handler_name): handler = getattr(self, handler_name) result = handler(msg) return {'ok': True, 'result': result} else: err = "Warning: Unhandled message: {}".format(msg) print(err)
return {'ok': False, 'message': err} # ------------------------------------------------------------------------- # Message handling API # -------------------------------------------------------------------------
[docs] def do_reload(self, msg): """ Called when the dev server wants to reload the view. """ #: TODO: This should use the autorelaoder app = self.app #: Show loading screen try: self.app.widget.showLoading("Reloading... Please wait.", now=True) #self.app.widget.restartPython(now=True) #sys.exit(0) except: #: TODO: Implement for iOS... pass self.save_changed_files(msg) if app.load_view is None: print("Warning: Reloading the view is not implemented. " "Please set `app.load_view` to support this.") return if app.view is not None: try: app.view.destroy() except: pass def wrapped(f): def safe_reload(*args, **kwargs): try: return f(*args, **kwargs) except: #: Display the error app.send_event(Command.ERROR, traceback.format_exc()) return safe_reload
app.deferred_call(wrapped(app.load_view), app)
[docs] def do_hotswap(self, msg): """ Attempt to hotswap the code """ #: Show hotswap tooltip try: self.app.widget.showTooltip("Hot swapping...", now=True) except: pass self.save_changed_files(msg) hotswap = self.hotswap app = self.app try: print("Attempting hotswap....") with hotswap.active(): hotswap.update(app.view) except: #: Display the error
app.send_event(Command.ERROR, traceback.format_exc()) # ------------------------------------------------------------------------- # Utility methods # -------------------------------------------------------------------------
[docs] def save_changed_files(self, msg): #: On iOS we can't write in the app bundle if os.environ.get('TMP'): tmp_dir = os.environ['TMP'] if not os.path.exists(tmp_dir): os.makedirs(tmp_dir) if tmp_dir not in sys.path: sys.path.insert(0, tmp_dir) import site reload(site) with cd(sys.path[0]): #: Clear cache if os.path.exists('__enamlcache__'): shutil.rmtree('__enamlcache__') for fn in msg['files']: print("Updating {}".format(fn)) folder = os.path.dirname(fn) if folder and not os.path.exists(folder): os.makedirs(folder) with open(fn, 'wb') as f:
f.write(msg['files'][fn].encode('utf-8'))