From 97bd32093b3c9bc51f56361952c45f4a9525489b Mon Sep 17 00:00:00 2001 From: ceprio Date: Wed, 19 Aug 2020 14:47:34 -0400 Subject: [PATCH 01/14] Issue when validating the existance of an asset when the asset is a link. --- flexx/app/_assetstore.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flexx/app/_assetstore.py b/flexx/app/_assetstore.py index 7ad59e40..9209e34c 100644 --- a/flexx/app/_assetstore.py +++ b/flexx/app/_assetstore.py @@ -391,8 +391,9 @@ def associate_asset(self, mod_name, asset_name, source=None): str: the (relative) url at which the asset can be retrieved. """ # Get or create asset - if asset_name in self._assets: - asset = self._assets[asset_name] + name = asset_name.replace('\\', '/').split('/')[-1] + if name in self._assets: + asset = self._assets[name] if source is not None: t = 'associate_asset() for %s got source, but asset %r already exists.' raise TypeError(t % (mod_name, asset_name)) From 8b32cd7f2920c92ad40092789077f90c433746f3 Mon Sep 17 00:00:00 2001 From: ceprio Date: Mon, 12 Oct 2020 17:40:17 -0400 Subject: [PATCH 02/14] added eclipse settings to .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f43017e4..0862072b 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,5 @@ _website/ _live/ _gh-pages/ - +# Eclipse +.settings/ \ No newline at end of file From 52b3578646fbadfdf09ece8bb16a0238ea282acd Mon Sep 17 00:00:00 2001 From: ceprio Date: Wed, 14 Oct 2020 15:30:29 -0400 Subject: [PATCH 03/14] not working: flexx not compatible with gevent Use Quart instead --- flexx/_config.py | 5 + flexx/app/_app.py | 19 +- flexx/app/_flaskserver.py | 642 ++++++++++++++++++++++ flexx/app/_server.py | 22 +- flexxamples/howtos/python_side_widget2.py | 49 ++ 5 files changed, 725 insertions(+), 12 deletions(-) create mode 100644 flexx/app/_flaskserver.py create mode 100644 flexxamples/howtos/python_side_widget2.py diff --git a/flexx/_config.py b/flexx/_config.py index f1efaff7..d379b6fd 100644 --- a/flexx/_config.py +++ b/flexx/_config.py @@ -25,4 +25,9 @@ # tornado tornado_debug=('false', bool, 'Setting the tornado application debug flag ' 'allows autoreload and other debugging features.'), + + # flask + flask_debug=('false', bool, 'Setting the flask application debug flag ' + 'allows autoreload and other debugging features.'), + ) diff --git a/flexx/app/_app.py b/flexx/app/_app.py index 3475a0a3..0feae560 100644 --- a/flexx/app/_app.py +++ b/flexx/app/_app.py @@ -141,15 +141,26 @@ def launch(self, runtime=None, **runtime_kwargs): Arguments: runtime (str): the runtime to launch the application in. Default 'app or browser'. - runtime_kwargs: kwargs to pass to the ``webruntime.launch`` function. - A few names are passed to runtime kwargs if not already present - ('title' and 'icon'). + runtime_kwargs: kwargs to pass to the ``webruntime.launch`` function + and the create_server function. + create_server takes 'host', 'port', 'loop', 'backend' as parameters. + For webruntime.launch, a few names are passed to runtime kwargs if not + already present ('title' and 'icon'). Returns: Component: an instance of the given class. """ # creates server (and event loop) if it did not yet exist - current_server() + server_kwargs = {} + server_keys = ['host', 'port', 'loop', 'backend'] + for key, value in runtime_kwargs.items(): + if key in server_keys: + server_kwargs[key] = value + current_server(**server_kwargs) + # remove the server_keys + for key in server_keys: + if key in runtime_kwargs: + del runtime_kwargs[key] # Create session if not self._is_served: diff --git a/flexx/app/_flaskserver.py b/flexx/app/_flaskserver.py new file mode 100644 index 00000000..97651ce1 --- /dev/null +++ b/flexx/app/_flaskserver.py @@ -0,0 +1,642 @@ +""" +Serve web page and handle web sockets using Flask. +""" + +import json +import time +import asyncio +import socket +import mimetypes +import traceback +import threading +from urllib.parse import urlparse +# from concurrent.futures import ThreadPoolExecutor + +from flask import Flask, render_template +from flask_sockets import Sockets + +from ._app import manager +from ._session import get_page +from ._server import AbstractServer +from ._assetstore import assets +from ._clientcore import serializer + +from . import logger +from .. import config + + +app = Flask(__name__, static_folder='.') +app.debug = True + +sockets = Sockets(app) + +IMPORT_TIME = time.time() + + +def is_main_thread(): + """ Get whether this is the main thread. """ + return isinstance(threading.current_thread(), threading._MainThread) + +********************* here +class FlaskServer(AbstractServer): + """ Flexx Server implemented in Flask. + """ + + def __init__(self, *args, **kwargs): + self._app = None + self._server = None + super().__init__(*args, **kwargs) + + def _open(self, host, port, **kwargs): + # Note: does not get called if host is False. That way we can + # run Flexx in e.g. JLab's application. + + # Hook Flask up with asyncio. Flexx' BaseServer makes sure + # that the correct asyncio event loop is current (for this thread). + # http://www.tornadoweb.org/en/stable/asyncio.html + # todo: Since Flask v5.0 asyncio is autom used, deprecating AsyncIOMainLoop + self._io_loop = AsyncIOMainLoop() + # I am sorry for this hack, but Flask wont work otherwise :( + # I wonder how long it will take before this will bite me back. I guess + # we will be alright as long as there is no other Flask stuff going on. + if hasattr(IOLoop, "_current"): + IOLoop._current.instance = None + else: + IOLoop.current().instance = None + self._io_loop.make_current() + + # handle ssl, wether from configuration or given args + if config.ssl_certfile: + if 'ssl_options' not in kwargs: + kwargs['ssl_options'] = {} + if 'certfile' not in kwargs['ssl_options']: + kwargs['ssl_options']['certfile'] = config.ssl_certfile + + if config.ssl_keyfile: + if 'ssl_options' not in kwargs: + kwargs['ssl_options'] = {} + if 'keyfile' not in kwargs['ssl_options']: + kwargs['ssl_options']['keyfile'] = config.ssl_keyfile + + if config.tornado_debug: + app_kwargs = dict(debug=True) + else: + app_kwargs = dict() + # Create tornado application + self._app = Application([(r"/flexx/ws/(.*)", WSHandler), + (r"/flexx/(.*)", MainHandler), + (r"/(.*)", AppHandler), ], **app_kwargs) + self._app._io_loop = self._io_loop + # Create tornado server, bound to our own ioloop + if tornado.version_info < (5, ): + kwargs['io_loop'] = self._io_loop + self._server = HTTPServer(self._app, **kwargs) + + # Start server (find free port number if port not given) + if port: + # Turn port into int, use hashed port number if a string was given + try: + port = int(port) + except ValueError: + port = port_hash(port) + self._server.listen(port, host) + else: + # Try N ports in a repeatable range (easier, browser history, etc.) + prefered_port = port_hash('Flexx') + for i in range(8): + port = prefered_port + i + try: + self._server.listen(port, host) + break + except (OSError, IOError): + pass # address already in use + else: + # Ok, lets figure out a port + [sock] = netutil.bind_sockets(None, host, family=socket.AF_INET) + self._server.add_sockets([sock]) + port = sock.getsockname()[1] + + # Notify address, so its easy to e.g. copy and paste in the browser + self._serving = self._app._flexx_serving = host, port + proto = 'http' + if 'ssl_options' in kwargs: + proto = 'https' + # This string 'Serving apps at' is our 'ready' signal and is tested for. + logger.info('Serving apps at %s://%s:%i/' % (proto, host, port)) + + def _close(self): + self._server.stop() + + @property + def app(self): + """ The Flask Application object being used.""" + return self._app + + @property + def server(self): + """ The Flask HttpServer object being used.""" + return self._server + + @property + def protocol(self): + """ Get a string representing served protocol.""" + if self._server.ssl_options is not None: + return 'https' + + return 'http' + +def port_hash(name): + """ Given a string, returns a port number between 49152 and 65535 + + This range (of 2**14 posibilities) is the range for dynamic and/or + private ports (ephemeral ports) specified by iana.org. The algorithm + is deterministic. + """ + fac = 0xd2d84a61 + val = 0 + for c in name: + val += (val >> 3) + (ord(c) * fac) + val += (val >> 3) + (len(name) * fac) + return 49152 + (val % 2**14) + + +class FlexxHandler(RequestHandler): + """ Base class for Flexx' Flask request handlers. + """ + + def initialize(self, **kwargs): + # kwargs == dict set as third arg in url spec + pass + + def write_error(self, status_code, **kwargs): + if status_code == 404: # does not work? + self.write('flexx.ui wants you to connect to root (404)') + else: + if config.browser_stacktrace: + msg = 'Flexx.ui encountered an error:

' + try: # try providing a useful message; tough luck if this fails + type, value, tb = kwargs['exc_info'] + tb_str = ''.join(traceback.format_tb(tb)) + msg += '
%s\n%s
' % (tb_str, str(value)) + except Exception: + pass + self.write(msg) + super().write_error(status_code, **kwargs) + + def on_finish(self): + pass + + +class AppHandler(FlexxHandler): + """ Handler for http requests to get apps. + """ + + @gen.coroutine + def get(self, full_path): + + logger.debug('Incoming request at %r' % full_path) + + ok_app_names = '__main__', '__default__', '__index__' + parts = [p for p in full_path.split('/') if p] + + # Try getting regular app name + # Note: invalid part[0] can mean its a path relative to the main app + app_name = None + path = '/'.join(parts) + if parts: + if path.lower() == 'flexx': # reserved, redirect to other handler + return self.redirect('/flexx/') + if parts[0] in ok_app_names or manager.has_app_name(parts[0]): + app_name = parts[0] + path = '/'.join(parts[1:]) + + # If it does not look like an app, it might be that the request is for + # the main app. The main app can have sub-paths, but lets try to filter + # out cases that might make Flexx unnecessarily instantiate an app. + # In particular "favicon.ico" that browsers request by default (#385). + if app_name is None: + if len(parts) == 1 and '.' in full_path: + return self.redirect('/flexx/data/' + full_path) + # If we did not return ... assume this is the default app + app_name = '__main__' + + # Try harder to produce an app + if app_name == '__main__': + app_name = manager.has_app_name('__main__') + elif '/' not in full_path: + return self.redirect('/%s/' % app_name) # ensure slash behind name + + # Maybe the user wants an index? Otherwise error. + if not app_name: + if not parts: + app_name = '__index__' + else: + name = parts[0] if parts else '__main__' + return self.write('No app "%s" is currently hosted.' % name) + + # We now have: + # * app_name: name of the app, must be a valid identifier, names + # with underscores are reserved for special things like assets, + # commands, etc. + # * path: part (possibly with slashes) after app_name + if app_name == '__index__': + self._get_index(app_name, path) # Index page + else: + self._get_app(app_name, path) # An actual app! + + def _get_index(self, app_name, path): + if path: + return self.redirect('/flexx/__index__') + all_apps = ['
  • %s
  • ' % (name, name) for name in + manager.get_app_names()] + the_list = '
      %s
    ' % ''.join(all_apps) if all_apps else 'no apps' + self.write('Index of available apps: ' + the_list) + + def _get_app(self, app_name, path): + # Allow serving data/assets relative to app so that data can use + # relative paths just like exported apps. + if path.startswith(('flexx/data/', 'flexx/assets/')): + return self.redirect('/' + path) + + # Get case-corrected app name if the app is known + correct_app_name = manager.has_app_name(app_name) + + # Error or redirect if app name is not right + if not correct_app_name: + return self.write('No app "%s" is currently hosted.' % app_name) + if correct_app_name != app_name: + return self.redirect('/%s/%s' % (correct_app_name, path)) + + # Should we bind this app instance to a pre-created session? + session_id = self.get_argument('session_id', '') + + if session_id: + # If session_id matches a pending app, use that session + session = manager.get_session_by_id(session_id) + if session and session.status == session.STATUS.PENDING: + self.write(get_page(session).encode()) + else: + self.redirect('/%s/' % app_name) # redirect for normal serve + else: + # Create session - websocket will connect to it via session_id + session = manager.create_session(app_name, request=self.request) + self.write(get_page(session).encode()) + + +class MainHandler(RequestHandler): + """ Handler for assets, commands, etc. Basically, everything for + which the path is clear. + """ + + def _guess_mime_type(self, fname): + """ Set the mimetype if we can guess it from the filename. + """ + guess = mimetypes.guess_type(fname)[0] + if guess: + self.set_header("Content-Type", guess) + + @gen.coroutine + def get(self, full_path): + + logger.debug('Incoming request at %s' % full_path) + + # Analyze path to derive components + # Note: invalid app name can mean its a path relative to the main app + parts = [p for p in full_path.split('/') if p] + if not parts: + return self.write('Root url for flexx: assets, assetview, data, cmd') + selector = parts[0] + path = '/'.join(parts[1:]) + + if selector in ('assets', 'assetview', 'data'): + self._get_asset(selector, path) # JS, CSS, or data + elif selector == 'info': + self._get_info(selector, path) + elif selector == 'cmd': + self._get_cmd(selector, path) # Execute (or ignore) command + else: + return self.write('Invalid url path "%s".' % full_path) + + def _get_asset(self, selector, path): + + # Get session id and filename + session_id, _, filename = path.partition('/') + session_id = '' if session_id == 'shared' else session_id + + # Get asset provider: store or session + asset_provider = assets + if session_id and selector != 'data': + return self.write('Only supports shared assets, not ' % filename) + elif session_id: + asset_provider = manager.get_session_by_id(session_id) + + # Checks + if asset_provider is None: + return self.write('Invalid session %r' % session_id) + if not filename: + return self.write('Root dir for %s/%s' % (selector, path)) + + if selector == 'assets': + + # If colon: request for a view of an asset at a certain line + if '.js:' in filename or '.css:' in filename or filename[0] == ':': + fname, where = filename.split(':')[:2] + return self.redirect('/flexx/assetview/%s/%s#L%s' % + (session_id or 'shared', fname.replace('/:', ':'), where)) + + # Retrieve asset + try: + res = asset_provider.get_asset(filename) + except KeyError: + self.write('Could not load asset %r' % filename) + else: + self._guess_mime_type(filename) + self.write(res.to_string()) + + elif selector == 'assetview': + + # Retrieve asset + try: + res = asset_provider.get_asset(filename) + except KeyError: + return self.write('Could not load asset %r' % filename) + else: + res = res.to_string() + + # Build HTML page + style = ('pre {display:block; width: 100%; padding:0; margin:0;} ' + 'a {text-decoration: none; color: #000; background: #ddd;} ' + ':target {background:#ada;} ') + lines = ['' % style] + for i, line in enumerate(res.splitlines()): + table = {ord('&'): '&', ord('<'): '<', ord('>'): '>'} + line = line.translate(table).replace('\t', ' ') + lines.append('
    %s  %s
    ' % + (i+1, i+1, str(i+1).rjust(4).replace(' ', ' '), line)) + lines.append('') + return self.write('\n'.join(lines)) + + elif selector == 'data': + # todo: can/do we async write in case the data is large? + + # Retrieve data + res = asset_provider.get_data(filename) + if res is None: + return self.send_error(404) + else: + self._guess_mime_type(filename) # so that images show up + return self.write(res) + + else: + raise RuntimeError('Invalid asset type %r' % selector) + + def _get_info(self, selector, info): + """ Provide some rudimentary information about the server. + Note that this is publicly accesible. + """ + runtime = time.time() - IMPORT_TIME + napps = len(manager.get_app_names()) + nsessions = sum([len(manager.get_connections(x)) + for x in manager.get_app_names()]) + + info = [] + info.append('Runtime: %1.1f s' % runtime) + info.append('Number of apps: %i' % napps) + info.append('Number of sessions: %i' % nsessions) + + info = '\n'.join(['
  • %s
  • ' % i for i in info]) + self.write('
      ' + info + '
    ') + + def _get_cmd(self, selector, path): + """ Allow control of the server using http, but only from localhost! + """ + if not self.request.host.startswith('localhost:'): + self.write('403') + return + + if not path: + self.write('No command given') + elif path == 'info': + info = dict(address=self.application._flexx_serving, + app_names=manager.get_app_names(), + nsessions=sum([len(manager.get_connections(x)) + for x in manager.get_app_names()]), + ) + self.write(json.dumps(info)) + elif path == 'stop': + asyncio.get_event_loop().stop() + # loop = IOLoop.current() + # loop.add_callback(loop.stop) + self.write("Stopping event loop.") + else: + self.write('unknown command %r' % path) + + +class MessageCounter: + """ Simple class to count incoming messages and periodically log + the number of messages per second. + """ + + def __init__(self): + self._collect_interval = 0.2 # period over which to collect messages + self._notify_interval = 3.0 # period on which to log the mps + self._window_interval = 4.0 # size of sliding window + + self._mps = [(time.time(), 0)] # tuples of (time, count) + self._collect_count = 0 + self._collect_stoptime = 0 + + self._stop = False + self._notify() + + def trigger(self): + t = time.time() + if t < self._collect_stoptime: + self._collect_count += 1 + else: + self._mps.append((self._collect_stoptime, self._collect_count)) + self._collect_count = 1 + self._collect_stoptime = t + self._collect_interval + + def _notify(self): + mintime = time.time() - self._window_interval + self._mps = [x for x in self._mps if x[0] > mintime] + if self._mps: + n = sum([x[1] for x in self._mps]) + T = self._mps[-1][0] - self._mps[0][0] + self._collect_interval + else: + n, T = 0, self._collect_interval + logger.debug('Websocket messages per second: %1.1f' % (n / T)) + + if not self._stop: + loop = asyncio.get_event_loop() + loop.call_later(self._notify_interval, self._notify) + + def stop(self): + self._stop = True + + +class WSHandler(WebSocketHandler): + """ Handler for websocket. + """ + + # https://tools.ietf.org/html/rfc6455#section-7.4.1 + known_reasons = {1000: 'client done', + 1001: 'client closed', + 1002: 'protocol error', + 1003: 'could not accept data', + } + + # --- callbacks + + def open(self, path=None): + """ Called when a new connection is made. + """ + if not hasattr(self, 'close_code'): # old version of Flask? + self.close_code, self.close_reason = None, None + + self._session = None + self._mps_counter = MessageCounter() + + if isinstance(path, bytes): + path = path.decode() + self.app_name = path.strip('/') + + logger.debug('New websocket connection %s' % path) + if manager.has_app_name(self.app_name): + self.application._io_loop.spawn_callback(self.pinger1) + else: + self.close(1003, "Could not associate socket with an app.") + + # todo: @gen.coroutine? + def on_message(self, message): + """ Called when a new message is received from JS. + + This handles one message per event loop iteration. + + We now have a very basic protocol for receiving messages, + we should at some point define a real formalized protocol. + """ + self._mps_counter.trigger() + + try: + command = serializer.decode(message) + except Exception as err: + err.skip_tb = 1 + logger.exception(err) + + self._pongtime = time.time() + if self._session is None: + if command[0] == 'HI_FLEXX': + session_id = command[1] + try: + self._session = manager.connect_client(self, self.app_name, + session_id, + cookies=self.cookies) + except Exception as err: + self.close(1003, "Could not launch app: %r" % err) + raise + else: + try: + self._session._receive_command(command) + except Exception as err: + err.skip_tb = 1 + logger.exception(err) + + def on_close(self): + """ Called when the connection is closed. + """ + self.close_code = code = self.close_code or 0 + reason = self.close_reason or self.known_reasons.get(code, '') + logger.debug('Websocket closed: %s (%i)' % (reason, code)) + self._mps_counter.stop() + if self._session is not None: + manager.disconnect_client(self._session) + self._session = None # Allow cleaning up + + @gen.coroutine + def pinger1(self): + """ Check for timeouts. This helps remove lingering false connections. + + This uses the websocket's native ping-ping mechanism. On the + browser side, pongs work even if JS is busy. On the Python side + we perform a check whether we were really waiting or whether Python + was too busy to detect the pong. + """ + self._pongtime = time.time() + self._pingtime = pingtime = 0 + + while self.close_code is None: + dt = config.ws_timeout + + # Ping, but don't spam + if pingtime <= self._pongtime: + self.ping(b'x') + pingtime = self._pingtime = time.time() + iters_since_ping = 0 + + yield gen.sleep(dt / 5) + + # Check pong status + iters_since_ping += 1 + if iters_since_ping < 5: + pass # we might have missed the pong + elif time.time() - self._pongtime > dt: + # Delay is so big that connection probably dropped. + # Note that a browser sends a pong even if JS is busy + logger.warning('Closing connection due to lack of pong') + self.close(1000, 'Conection timed out (no pong).') + return + + def on_pong(self, data): + """ Implement the ws's on_pong() method. Called when our ping + is returned by the browser. + """ + self._pongtime = time.time() + + # --- methods + + def write_command(self, cmd): + assert isinstance(cmd, tuple) and len(cmd) >= 1 + bb = serializer.encode(cmd) + try: + self.write_message(bb, binary=True) + except WebSocketClosedError: + self.close(1000, 'closed by client') + + def close(self, *args): + try: + WebSocketHandler.close(self, *args) + except TypeError: + WebSocketHandler.close(self) # older Flask + + def close_this(self): + """ Call this to close the websocket + """ + self.close(1000, 'closed by server') + + def check_origin(self, origin): + """ Handle cross-domain access; override default same origin policy. + """ + # http://www.tornadoweb.org/en/stable/_modules/tornado/websocket.html + #WebSocketHandler.check_origin + + serving_host = self.request.headers.get("Host") + serving_hostname, _, serving_port = serving_host.partition(':') + connecting_host = urlparse(origin).netloc + connecting_hostname, _, connecting_port = connecting_host.partition(':') + + serving_port = serving_port or '80' + connecting_port = connecting_port or '80' + + if serving_hostname == 'localhost': + return True # Safe + elif serving_host == connecting_host: + return True # Passed most strict test, hooray! + elif serving_hostname == '0.0.0.0' and serving_port == connecting_port: + return True # host on all addressses; best we can do is check port + elif connecting_host in config.host_whitelist: + return True + else: + logger.warning('Connection refused from %s' % origin) + return False diff --git a/flexx/app/_server.py b/flexx/app/_server.py index b6440f9f..2626b445 100644 --- a/flexx/app/_server.py +++ b/flexx/app/_server.py @@ -42,13 +42,8 @@ def create_server(host=None, port=None, loop=None, backend='tornado', Returns: AbstractServer: The server object, see ``current_server()``. """ - # Lazy load tornado, so that we can use anything we want there without - # preventing other parts of flexx.app from using *this* module. - from ._tornadoserver import TornadoServer # noqa - circular dependency global _current_server - if backend.lower() != 'tornado': - raise RuntimeError('Flexx server can only run on Tornado (for now).') # Handle defaults if host is None: host = config.hostname @@ -58,12 +53,23 @@ def create_server(host=None, port=None, loop=None, backend='tornado', if _current_server: _current_server.close() # Start hosting - _current_server = TornadoServer(host, port, loop, **server_kwargs) + backend = backend.lower() + if backend == 'tornado': + # Lazy load tornado, so that we can use anything we want there without + # preventing other parts of flexx.app from using *this* module. + from ._tornadoserver import TornadoServer # noqa - circular dependency + _current_server = TornadoServer(host, port, loop, **server_kwargs) + elif backend == 'flask': + # Lazy load flask + from ._flaskserver import FlaskServer + _current_server = FlaskServer(host, port, loop, **server_kwargs) + else: + raise RuntimeError('Flexx server can only run on Tornado and Flask (for now).') assert isinstance(_current_server, AbstractServer) return _current_server -def current_server(create=True): +def current_server(create=True, **server_kwargs): """ Get the current server object. Creates a server if there is none and the ``create`` arg is True. Currently, this is always a @@ -77,7 +83,7 @@ def current_server(create=True): """ if create and not _current_server: - create_server() + create_server(**server_kwargs) return _current_server diff --git a/flexxamples/howtos/python_side_widget2.py b/flexxamples/howtos/python_side_widget2.py new file mode 100644 index 00000000..1947e21d --- /dev/null +++ b/flexxamples/howtos/python_side_widget2.py @@ -0,0 +1,49 @@ +from flexx import flx + +class UserInput(flx.PyWidget): + + def init(self): + with flx.VBox(): + self.edit = flx.LineEdit(placeholder_text='Your name') + flx.Widget(flex=1) + + @flx.reaction('edit.user_done') + def update_user(self, *events): + new_text = self.root.store.username + "\n" + self.edit.text + self.root.store.set_username(new_text) + self.edit.set_text("") + +class SomeInfoWidget(flx.PyWidget): + + def init(self): + with flx.FormLayout(): + self.label = flx.Label(title='name:') + flx.Widget(flex=1) + + @flx.reaction + def update_label(self): + self.label.set_text(self.root.store.username) + +class Store(flx.PyComponent): + + username = flx.StringProp(settable=True) + +class Example(flx.PyWidget): + + store = flx.ComponentProp() + + def init(self): + + # Create our store instance + self._mutate_store(Store()) + + # Imagine this being a large application with many sub-widgets, + # and the UserInput and SomeInfoWidget being used somewhere inside it. + with flx.HSplit(): + UserInput() + flx.Widget(style='background:#eee;') + SomeInfoWidget() + +if __name__ == '__main__': + m = flx.launch(Example, 'default-browser', backend='flask') + flx.run() \ No newline at end of file From ee3924486e041e362f4710ecc3181cc1fe2099db Mon Sep 17 00:00:00 2001 From: ceprio Date: Thu, 10 Dec 2020 12:29:05 -0500 Subject: [PATCH 04/14] Added flask server as backend --- flexx/app/_flaskhelpers.py | 58 ++++ flexx/app/_flaskserver.py | 331 +++++++++++++-------- flexx/app/_tornadoserver.py | 5 +- flexx/flx_flask.py | 1 + flexx/ui/widgets/__init__.py | 1 + flexx/ui/widgets/_markdown.py | 44 +++ flexxamples/howtos/flask_backend.py | 18 ++ flexxamples/howtos/flask_server.py | 80 +++++ flexxamples/ui_usage/combo_box.py | 23 ++ flexxamples/ui_usage/dropdown_container.py | 24 ++ flexxamples/ui_usage/group.py | 22 ++ flexxamples/ui_usage/label.py | 15 + flexxamples/ui_usage/markdown.py | 21 ++ flexxamples/ui_usage/stack.py | 24 ++ flexxamples/ui_usage/tabs.py | 12 + 15 files changed, 550 insertions(+), 129 deletions(-) create mode 100644 flexx/app/_flaskhelpers.py create mode 100644 flexx/flx_flask.py create mode 100644 flexx/ui/widgets/_markdown.py create mode 100644 flexxamples/howtos/flask_backend.py create mode 100644 flexxamples/howtos/flask_server.py create mode 100644 flexxamples/ui_usage/combo_box.py create mode 100644 flexxamples/ui_usage/dropdown_container.py create mode 100644 flexxamples/ui_usage/group.py create mode 100644 flexxamples/ui_usage/label.py create mode 100644 flexxamples/ui_usage/markdown.py create mode 100644 flexxamples/ui_usage/stack.py create mode 100644 flexxamples/ui_usage/tabs.py diff --git a/flexx/app/_flaskhelpers.py b/flexx/app/_flaskhelpers.py new file mode 100644 index 00000000..cfbd3ebe --- /dev/null +++ b/flexx/app/_flaskhelpers.py @@ -0,0 +1,58 @@ +import flask +from ._app import manager, App +from ._server import create_server, current_server + +flexxBlueprint = flask.Blueprint('FlexxApps', __name__, static_folder='static') +flexxWS = flask.Blueprint('flexxWS', __name__) + +_blueprints_registered = False # todo remove this and implement blueprint registration/deregistration? + +def register_blueprints(app, sockets): + """ + Register all flexx apps to flask. Flask will create one URL per application plus a + generic /flexx/ URL for serving assets and data. + + see flexxamples/howtos/flask_server.py for a full example. + """ + global _blueprints_registered + if _blueprints_registered: + return + ########### Register apps ########### + for name in manager._appinfo.keys(): + # Create blueprint + appBlueprint = flask.Blueprint(f'Flexx_{name}', __name__, static_folder='static') # This is specific to apps + from ._flaskserver import AppHandler # delayed import + # Create handler + def app_handler(path): + # AppHandler + return AppHandler(flask.request).run() + app_handler.__name__ = name + appBlueprint.route('/', defaults={'path': ''})( + appBlueprint.route('/')(app_handler)) + # register the app blueprint + app.register_blueprint(appBlueprint, url_prefix=f"/{name}") + app.register_blueprint(flexxBlueprint, url_prefix=r"/flexx") # This is for the shared flexx assets + ########### Register sockets ########### + sockets.register_blueprint(flexxWS, url_prefix=r"/flexx") + _blueprints_registered = True + return + +def serve(cls): + """ + This function registers the flexx Widget to the manager so the server can + serve them properly from the server. + """ + m = App(cls) + if not m._is_served: + m.serve() + +def start(): + """ + Start the flexx event loop only. This function generally does not + return until the application is stopped. + + In more detail, this calls ``run_forever()`` on the asyncio event loop + associated with the current server. + """ + server = current_server(backend='flask') + server.start_serverless() \ No newline at end of file diff --git a/flexx/app/_flaskserver.py b/flexx/app/_flaskserver.py index 97651ce1..6267e203 100644 --- a/flexx/app/_flaskserver.py +++ b/flexx/app/_flaskserver.py @@ -1,6 +1,12 @@ """ Serve web page and handle web sockets using Flask. """ +# Improvements to be done in the future: +# 1) Code from MainHandler, AppHandler and WSHandler should be moved in +# _serverHandlers.py. Only RequestHandler and MyWebSocketHandler +# 2) manager should be overloadable from _flaskserver.py to allow MainHandler, +# AppHandler and WSHandler to place manager tasks in the flexx app loop +# 3) The specification of the backend should be passed at run or start. Not at launch or before. import json import time @@ -10,25 +16,78 @@ import traceback import threading from urllib.parse import urlparse -# from concurrent.futures import ThreadPoolExecutor -from flask import Flask, render_template +import flask +from flask import Flask, request, Blueprint, current_app, url_for from flask_sockets import Sockets +from gevent import pywsgi +from geventwebsocket.handler import WebSocketHandler +import werkzeug.serving + from ._app import manager from ._session import get_page from ._server import AbstractServer from ._assetstore import assets from ._clientcore import serializer +from ._flaskhelpers import register_blueprints, flexxBlueprint, flexxWS from . import logger from .. import config - -app = Flask(__name__, static_folder='.') -app.debug = True - -sockets = Sockets(app) +app = Flask(__name__) +# app.debug = True + +@app.route('/favicon.ico') +def favicon(): + return '' # app.send_static_file(f'img/favicon.ico') + +if app.debug: + + def has_no_empty_params(rule): + defaults = rule.defaults if rule.defaults is not None else () + arguments = rule.arguments if rule.arguments is not None else () + return len(defaults) >= len(arguments) + + @app.route("/site-map") + def site_map(): + links = [] + for rule in current_app.url_map.iter_rules(): + # Filter out rules we can't navigate to in a browser + # and rules that require parameters + if "GET" in rule.methods and has_no_empty_params(rule): + url = url_for(rule.endpoint, **(rule.defaults or {})) + links.append((url, rule.endpoint)) + # links is now a list of url, endpoint tuples + html = [" URLs served by this server ", "
      "] + for link in links: + html.append(f'
    • {link[1]}
    • ') + html.append("
    ") + return '\n'.join(html) + +@flexxWS.route('/ws/') +def ws_handler(ws, path): + # WSHandler + wshandler = WSHandler(ws) + async def flexx_msg_handler(ws, path): + wshandler.open(path) + + future = asyncio.run_coroutine_threadsafe(flexx_msg_handler(ws, path), loop=manager.loop) + future.result() + while not ws.closed: + message = ws.receive() + if message is None: + break + manager.loop.call_soon_threadsafe(wshandler.on_message, message) + manager.loop.call_soon_threadsafe(wshandler.ws_closed) #, 1000, "closed by client") # add connection close reason? + +@flexxBlueprint.route('/', defaults={'path': ''}) +@flexxBlueprint.route('/') +def flexx_handler(path): + # if path.startswith('assets'): + # path = f"flexx/{path}" + # MainHandler + return MainHandler(flask.request).run() IMPORT_TIME = time.time() @@ -37,61 +96,20 @@ def is_main_thread(): """ Get whether this is the main thread. """ return isinstance(threading.current_thread(), threading._MainThread) -********************* here class FlaskServer(AbstractServer): """ Flexx Server implemented in Flask. """ - def __init__(self, *args, **kwargs): - self._app = None + global app + self._app = app self._server = None - super().__init__(*args, **kwargs) + self._serving = None # needed for AbstractServer + super().__init__(*args, **kwargs) # this calls self._open def _open(self, host, port, **kwargs): # Note: does not get called if host is False. That way we can # run Flexx in e.g. JLab's application. - - # Hook Flask up with asyncio. Flexx' BaseServer makes sure - # that the correct asyncio event loop is current (for this thread). - # http://www.tornadoweb.org/en/stable/asyncio.html - # todo: Since Flask v5.0 asyncio is autom used, deprecating AsyncIOMainLoop - self._io_loop = AsyncIOMainLoop() - # I am sorry for this hack, but Flask wont work otherwise :( - # I wonder how long it will take before this will bite me back. I guess - # we will be alright as long as there is no other Flask stuff going on. - if hasattr(IOLoop, "_current"): - IOLoop._current.instance = None - else: - IOLoop.current().instance = None - self._io_loop.make_current() - - # handle ssl, wether from configuration or given args - if config.ssl_certfile: - if 'ssl_options' not in kwargs: - kwargs['ssl_options'] = {} - if 'certfile' not in kwargs['ssl_options']: - kwargs['ssl_options']['certfile'] = config.ssl_certfile - - if config.ssl_keyfile: - if 'ssl_options' not in kwargs: - kwargs['ssl_options'] = {} - if 'keyfile' not in kwargs['ssl_options']: - kwargs['ssl_options']['keyfile'] = config.ssl_keyfile - - if config.tornado_debug: - app_kwargs = dict(debug=True) - else: - app_kwargs = dict() - # Create tornado application - self._app = Application([(r"/flexx/ws/(.*)", WSHandler), - (r"/flexx/(.*)", MainHandler), - (r"/(.*)", AppHandler), ], **app_kwargs) - self._app._io_loop = self._io_loop - # Create tornado server, bound to our own ioloop - if tornado.version_info < (5, ): - kwargs['io_loop'] = self._io_loop - self._server = HTTPServer(self._app, **kwargs) - + # Start server (find free port number if port not given) if port: # Turn port into int, use hashed port number if a string was given @@ -99,30 +117,47 @@ def _open(self, host, port, **kwargs): port = int(port) except ValueError: port = port_hash(port) - self._server.listen(port, host) else: # Try N ports in a repeatable range (easier, browser history, etc.) + a_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + prefered_port = port_hash('Flexx') for i in range(8): port = prefered_port + i try: - self._server.listen(port, host) - break - except (OSError, IOError): - pass # address already in use + result_of_check = a_socket.bind((host,port)) + except: + continue + a_socket.close() + break else: - # Ok, lets figure out a port - [sock] = netutil.bind_sockets(None, host, family=socket.AF_INET) - self._server.add_sockets([sock]) - port = sock.getsockname()[1] - - # Notify address, so its easy to e.g. copy and paste in the browser - self._serving = self._app._flexx_serving = host, port - proto = 'http' - if 'ssl_options' in kwargs: - proto = 'https' - # This string 'Serving apps at' is our 'ready' signal and is tested for. - logger.info('Serving apps at %s://%s:%i/' % (proto, host, port)) + assert False, "No port found to start flask" + + # remember the loop we are in for the manager + manager.loop = asyncio.get_event_loop() + + # Keep flask application info + self._serving = (host, port) + + def start(self): + # Register blueprints for all apps: + sockets = Sockets(app) + register_blueprints(self._app, sockets) + + # Start flask application in background thread + def RunServer(): + self._server = pywsgi.WSGIServer(self._serving, self._app, handler_class=WebSocketHandler) + proto = self.protocol + # This string 'Serving apps at' is our 'ready' signal and is tested for. + logger.info('Serving apps at %s://%s:%i/' % (proto, *self._serving)) + self._server.serve_forever() + _thread = threading.Thread(target = RunServer) + _thread.daemon = True # end the thread if the main thread exits + _thread.start() + super().start() + + def start_serverless(self): + super().start() def _close(self): self._server.stop() @@ -140,9 +175,8 @@ def server(self): @property def protocol(self): """ Get a string representing served protocol.""" - if self._server.ssl_options is not None: - return 'https' - +# if self._server.ssl_options is not None: +# return 'https' return 'http' def port_hash(name): @@ -159,39 +193,40 @@ def port_hash(name): val += (val >> 3) + (len(name) * fac) return 49152 + (val % 2**14) - -class FlexxHandler(RequestHandler): - """ Base class for Flexx' Flask request handlers. - """ - - def initialize(self, **kwargs): - # kwargs == dict set as third arg in url spec - pass - - def write_error(self, status_code, **kwargs): - if status_code == 404: # does not work? - self.write('flexx.ui wants you to connect to root (404)') - else: - if config.browser_stacktrace: - msg = 'Flexx.ui encountered an error:

    ' - try: # try providing a useful message; tough luck if this fails - type, value, tb = kwargs['exc_info'] - tb_str = ''.join(traceback.format_tb(tb)) - msg += '
    %s\n%s
    ' % (tb_str, str(value)) - except Exception: - pass - self.write(msg) - super().write_error(status_code, **kwargs) - - def on_finish(self): - pass - - -class AppHandler(FlexxHandler): +class RequestHandler: + def __init__(self, request): + self.request = request + self.content = [] + self.values = {} + + def redirect(self, location): + return flask.redirect(location) + + def write(self, string_or_bytes): + self.content = string_or_bytes + + def send_error(self, error_no): + return "Error", error_no + + def run(self): + if self.request.method == 'GET': + ret = self.get(request.path) + if ret is not None: + return ret + else: + return self.content, 200, self.values + + def get_argument(self, key, default): + return self.request.values.get(key,default) + + def set_header(self, key, value): + self.values[key] = value + + +class AppHandler(RequestHandler): """ Handler for http requests to get apps. """ - @gen.coroutine def get(self, full_path): logger.debug('Incoming request at %r' % full_path) @@ -232,7 +267,7 @@ def get(self, full_path): app_name = '__index__' else: name = parts[0] if parts else '__main__' - return self.write('No app "%s" is currently hosted.' % name) + self.write('No app "%s" is currently hosted.' % name) # We now have: # * app_name: name of the app, must be a valid identifier, names @@ -240,9 +275,9 @@ def get(self, full_path): # commands, etc. # * path: part (possibly with slashes) after app_name if app_name == '__index__': - self._get_index(app_name, path) # Index page + return self._get_index(app_name, path) # Index page else: - self._get_app(app_name, path) # An actual app! + return self._get_app(app_name, path) # An actual app! def _get_index(self, app_name, path): if path: @@ -263,7 +298,7 @@ def _get_app(self, app_name, path): # Error or redirect if app name is not right if not correct_app_name: - return self.write('No app "%s" is currently hosted.' % app_name) + self.write('No app "%s" is currently hosted.' % app_name) if correct_app_name != app_name: return self.redirect('/%s/%s' % (correct_app_name, path)) @@ -279,9 +314,13 @@ def _get_app(self, app_name, path): self.redirect('/%s/' % app_name) # redirect for normal serve else: # Create session - websocket will connect to it via session_id - session = manager.create_session(app_name, request=self.request) - self.write(get_page(session).encode()) + async def run_in_flexx_loop(app_name, request): + session = manager.create_session(app_name, request=request) + return session + future = asyncio.run_coroutine_threadsafe(run_in_flexx_loop(app_name, request=self.request), loop=manager.loop) + session = future.result() + self.write(get_page(session).encode()) class MainHandler(RequestHandler): """ Handler for assets, commands, etc. Basically, everything for @@ -295,16 +334,16 @@ def _guess_mime_type(self, fname): if guess: self.set_header("Content-Type", guess) - @gen.coroutine def get(self, full_path): logger.debug('Incoming request at %s' % full_path) # Analyze path to derive components # Note: invalid app name can mean its a path relative to the main app - parts = [p for p in full_path.split('/') if p] + parts = [p for p in full_path.split('/') if p][1:] if not parts: - return self.write('Root url for flexx: assets, assetview, data, cmd') + self.write('Root url for flexx, missing selector: assets, assetview, data, info or cmd') + return selector = parts[0] path = '/'.join(parts[1:]) @@ -315,7 +354,7 @@ def get(self, full_path): elif selector == 'cmd': self._get_cmd(selector, path) # Execute (or ignore) command else: - return self.write('Invalid url path "%s".' % full_path) + self.write('Invalid url path "%s".' % full_path) def _get_asset(self, selector, path): @@ -326,15 +365,15 @@ def _get_asset(self, selector, path): # Get asset provider: store or session asset_provider = assets if session_id and selector != 'data': - return self.write('Only supports shared assets, not ' % filename) + self.write('Only supports shared assets, not ' % filename) elif session_id: asset_provider = manager.get_session_by_id(session_id) # Checks if asset_provider is None: - return self.write('Invalid session %r' % session_id) + self.write('Invalid session %r' % session_id) if not filename: - return self.write('Root dir for %s/%s' % (selector, path)) + self.write('Root dir for %s/%s' % (selector, path)) if selector == 'assets': @@ -359,7 +398,7 @@ def _get_asset(self, selector, path): try: res = asset_provider.get_asset(filename) except KeyError: - return self.write('Could not load asset %r' % filename) + self.write('Could not load asset %r' % filename) else: res = res.to_string() @@ -374,7 +413,7 @@ def _get_asset(self, selector, path): lines.append('
    %s  %s
    ' % (i+1, i+1, str(i+1).rjust(4).replace(' ', ' '), line)) lines.append('') - return self.write('\n'.join(lines)) + self.write('\n'.join(lines)) elif selector == 'data': # todo: can/do we async write in case the data is large? @@ -385,7 +424,7 @@ def _get_asset(self, selector, path): return self.send_error(404) else: self._guess_mime_type(filename) # so that images show up - return self.write(res) + self.write(res) else: raise RuntimeError('Invalid asset type %r' % selector) @@ -475,8 +514,52 @@ def _notify(self): def stop(self): self._stop = True - -class WSHandler(WebSocketHandler): +from typing import ( + TYPE_CHECKING, + cast, + Any, + Optional, + Dict, + Union, + List, + Awaitable, + Callable, + Tuple, + Type, +) + +class MyWebSocketHandler(): + """ + This class is designed to mimic the tornado WebSocketHandler to + allow glue in code from WSHandler. + """ + class Application: + pass + class IOLoop: + def __init__(self, loop): + self._loop = loop + def spawn_callback(self, func, *args): + self._loop.call_soon_threadsafe(func, *args) + + def __init__(self, ws): + self._ws = ws + self.application = MyWebSocketHandler.Application() + self.application._io_loop = MyWebSocketHandler.IOLoop(manager.loop) + self.cookies = {} + + def write_message( + self, message: Union[bytes, str, Dict[str, Any]], binary: bool = False + ) -> "Future[None]": + self._ws.send(message) + + def close(self, code: int = None, reason: str = None) -> None: + if not self._ws.closed: + self._ws.close(code, reason) + + def ws_closed(self): + self.on_close() + +class WSHandler(MyWebSocketHandler): """ Handler for websocket. """ @@ -554,7 +637,6 @@ def on_close(self): manager.disconnect_client(self._session) self._session = None # Allow cleaning up - @gen.coroutine def pinger1(self): """ Check for timeouts. This helps remove lingering false connections. @@ -605,10 +687,7 @@ def write_command(self, cmd): self.close(1000, 'closed by client') def close(self, *args): - try: - WebSocketHandler.close(self, *args) - except TypeError: - WebSocketHandler.close(self) # older Flask + super().close(*args) def close_this(self): """ Call this to close the websocket diff --git a/flexx/app/_tornadoserver.py b/flexx/app/_tornadoserver.py index 77a2c450..70f25ae4 100644 --- a/flexx/app/_tornadoserver.py +++ b/flexx/app/_tornadoserver.py @@ -171,7 +171,6 @@ def port_hash(name): class FlexxHandler(RequestHandler): """ Base class for Flexx' Tornado request handlers. """ - def initialize(self, **kwargs): # kwargs == dict set as third arg in url spec pass @@ -617,9 +616,9 @@ def write_command(self, cmd): def close(self, *args): try: - WebSocketHandler.close(self, *args) + super().close(self, *args) except TypeError: - WebSocketHandler.close(self) # older Tornado + super().close(self) # older Tornado def close_this(self): """ Call this to close the websocket diff --git a/flexx/flx_flask.py b/flexx/flx_flask.py new file mode 100644 index 00000000..d531f55f --- /dev/null +++ b/flexx/flx_flask.py @@ -0,0 +1 @@ +from .app._flaskhelpers import register_blueprints, start, serve diff --git a/flexx/ui/widgets/__init__.py b/flexx/ui/widgets/__init__.py index 509481f8..6f7c492f 100644 --- a/flexx/ui/widgets/__init__.py +++ b/flexx/ui/widgets/__init__.py @@ -22,3 +22,4 @@ from ._plotwidget import PlotWidget from ._plotly import PlotlyWidget from ._bokeh import BokehWidget +from ._markdown import Markdown diff --git a/flexx/ui/widgets/_markdown.py b/flexx/ui/widgets/_markdown.py new file mode 100644 index 00000000..226bd776 --- /dev/null +++ b/flexx/ui/widgets/_markdown.py @@ -0,0 +1,44 @@ +""" + +Simple example: + +.. UIExample:: 200 + + # Define data. This can also be generated with the plotly Python library + data = [{'type': 'bar', + 'x': ['giraffes', 'orangutans', 'monkeys'], + 'y': [20, 14, 23]}] + + # Show + p = ui.PlotlyWidget(data=data) + +Also see examples: :ref:`plotly_gdp.py`. + +""" + +from ... import app, event +from . import Widget + +app.assets.associate_asset(__name__, 'https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js') + + +class Markdown(Widget): + """ A widget that shows a rendered Markdown content. + """ + CSS = """ + + .flx-Markdown { + height: min(100vh,100%); + overflow-y: auto; + } + """ + + content = event.StringProp(settable=True, doc=""" + The markdown content to be rendered + """) + + @event.reaction + def __content_change(self): + global showdown + conv = showdown.Converter(); + self.node.innerHTML = conv.makeHtml(self.content); diff --git a/flexxamples/howtos/flask_backend.py b/flexxamples/howtos/flask_backend.py new file mode 100644 index 00000000..48661470 --- /dev/null +++ b/flexxamples/howtos/flask_backend.py @@ -0,0 +1,18 @@ +""" +Simple use of a the markdown widget, +using a custom widget that is populated in its ``init()``. +""" + +from flexx import app, event, ui, flx + + +class Example(flx.Widget): + + def init(self): + content = "# Welcome\n\n" \ + "This flexx app is now served with flask! " + ui.Markdown(content=content, style='background:#EAECFF;') + +if __name__ == '__main__': + m = flx.launch(Example, 'default-browser', backend='flask') + flx.run() diff --git a/flexxamples/howtos/flask_server.py b/flexxamples/howtos/flask_server.py new file mode 100644 index 00000000..26a3f626 --- /dev/null +++ b/flexxamples/howtos/flask_server.py @@ -0,0 +1,80 @@ +from flask import Flask, current_app, url_for +from flask_sockets import Sockets +app = Flask(__name__) + +from flexx import ui, flx, flx_flask + + +######################## The flexx application ######################### +class Example(flx.Widget): + + def init(self): + content = "# Welcome\n\n" \ + "This flexx app is served within flask! " + ui.Markdown(content=content, style='background:#EAECFF;') + +flx_flask.serve(Example) + +@app.route("/") +def site_map(): # list available applications and URLs + """ + This function lists all the URLs server by the flask application + including the flexx application that have been registered. + """ + def has_no_empty_params(rule): + defaults = rule.defaults if rule.defaults is not None else () + arguments = rule.arguments if rule.arguments is not None else () + return len(defaults) >= len(arguments) + links = [] + for rule in current_app.url_map.iter_rules(): + # Filter out rules we can't navigate to in a browser + # and rules that require parameters + if "GET" in rule.methods and has_no_empty_params(rule): + url = url_for(rule.endpoint, **(rule.defaults or {})) + links.append((url, rule.endpoint)) + # links is now a list of url, endpoint tuples + html = [" URLs served by this server ", "
      "] + for link in links: + html.append(f'
    • {link[1]}
    • ') + html.append("
    ") + return '\n'.join(html) + +####################### Registration of blueprints ##################### +sockets = Sockets(app) # keep at the end +flx_flask.register_blueprints(app, sockets) + +####################### Start flexx in thread ##################### +import threading +import asyncio +def flexx_thread(): + """ + Function to start a thread containing the main loop of flexx. + This is needed as flexx is an asyncio application which is not + compatible with flexx/gevent. + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + flx_flask.start() # starts flexx loop without server +thread1 = threading.Thread(target = flexx_thread) +thread1.daemon = True +thread1.start() + +######### Start flask server (using gevent that supports web sockets) ######### +if __name__ == "__main__": + + @app.errorhandler(Exception) + def internal_error(e): + import traceback; traceback.print_exc() # get the trace stack + err_str = str(traceback.format_exc()) # to get the string + err_str = err_str.replace("\n", "
    ") + return "

    " + str(e) + "


    " + err_str + + from gevent import pywsgi + from geventwebsocket.handler import WebSocketHandler + + def RunServer(): + server = pywsgi.WSGIServer(('127.0.0.1', 5000), app, handler_class=WebSocketHandler) + print("Server Started!") + server.serve_forever() + + RunServer() diff --git a/flexxamples/ui_usage/combo_box.py b/flexxamples/ui_usage/combo_box.py new file mode 100644 index 00000000..13092370 --- /dev/null +++ b/flexxamples/ui_usage/combo_box.py @@ -0,0 +1,23 @@ +""" +Simple use of a dropdown, +""" + +from flexx import app, event, ui, flx + +class Example(ui.Widget): + + def init(self): + # A combobox + self.combo = ui.ComboBox(editable=True, + options=('foo', 'bar', 'spaaaaaaaaam', 'eggs')) + self.label = ui.Label() + +# @event.connect('combo.text') +# def on_combobox_text(self, *events): +# self.label.text = 'Combobox text: ' + self.combo.text +# if self.combo.selected_index is not None: +# self.label.text += ' (index %i)' % self.combo.selected_index + +if __name__ == '__main__': + m = flx.launch(Example, 'default-browser') + flx.run() diff --git a/flexxamples/ui_usage/dropdown_container.py b/flexxamples/ui_usage/dropdown_container.py new file mode 100644 index 00000000..9b18fcf1 --- /dev/null +++ b/flexxamples/ui_usage/dropdown_container.py @@ -0,0 +1,24 @@ +""" +Simple use of a dropdown, +""" + +from flexx import app, event, ui, flx + +class Example(ui.Widget): + + CSS = ''' + .flx-DropdownContainer > .flx-TreeWidget { + min-height: 150px; + } + ''' + + def init(self): + # A nice and cosy tree view + with ui.DropdownContainer(text='Scene graph'): + with ui.TreeWidget(max_selected=1): + for i in range(20): + ui.TreeItem(text='foo %i' % i, checked=False) + +if __name__ == '__main__': + m = flx.launch(Example) + flx.run() diff --git a/flexxamples/ui_usage/group.py b/flexxamples/ui_usage/group.py new file mode 100644 index 00000000..83feae17 --- /dev/null +++ b/flexxamples/ui_usage/group.py @@ -0,0 +1,22 @@ +""" +Simple use of a group, +using a custom widget that is populated in its ``init()``. +""" + +from flexx import app, event, ui, flx + +class Example(flx.Widget): + def init(self): + with ui.GroupWidget(title='A silly panel'): + with ui.VBox(): + self.progress = ui.ProgressBar(min=0, max=9, + text='Clicked {value} times') + self.but = ui.Button(text='click me') + + @event.reaction('but.pointer_down') + def _button_pressed(self, *events): + self.progress.set_value(self.progress.value + 1) + +if __name__ == '__main__': + m = flx.launch(Example) + flx.run() diff --git a/flexxamples/ui_usage/label.py b/flexxamples/ui_usage/label.py new file mode 100644 index 00000000..dbffefc0 --- /dev/null +++ b/flexxamples/ui_usage/label.py @@ -0,0 +1,15 @@ +""" +Simple use of a dropdown, +""" + +from flexx import app, event, ui, flx + +class Example(ui.Widget): + + def init(self): + self.label = ui.Label(text="Number:") + self.label = ui.Label(html=" 45 ") + +if __name__ == '__main__': + m = flx.launch(Example, 'default-browser') + flx.run() diff --git a/flexxamples/ui_usage/markdown.py b/flexxamples/ui_usage/markdown.py new file mode 100644 index 00000000..a135e1f0 --- /dev/null +++ b/flexxamples/ui_usage/markdown.py @@ -0,0 +1,21 @@ +""" +Simple use of a the markdown widget, +using a custom widget that is populated in its ``init()``. +""" + +from flexx import app, event, ui, flx + + +class Example(flx.Widget): + + def init(self): + content = "# Welcome\n\n" \ + "Hello. Welcome to my **website**. This is an example of a widget container for markdown content. " \ + "The content can be text or a link.\n\n" + content += "\n\n".join(["a new line" for a in range(100)]) + ui.Markdown(content=content, style='background:#EAECFF;height:60%;') + + +if __name__ == '__main__': + m = flx.launch(Example, 'default-browser') + flx.run() diff --git a/flexxamples/ui_usage/stack.py b/flexxamples/ui_usage/stack.py new file mode 100644 index 00000000..6a730d87 --- /dev/null +++ b/flexxamples/ui_usage/stack.py @@ -0,0 +1,24 @@ +from flexx import app, event, ui, flx + +class Example(ui.Widget): + + def init(self): + with ui.VBox(): + with ui.HBox(): + self.buta = ui.Button(text='red') + self.butb = ui.Button(text='green') + self.butc = ui.Button(text='blue') + ui.Widget(flex=1) # space filler + with ui.StackLayout(flex=1) as self.stack: + self.buta.w = ui.Widget(style='background:#a00;') + self.butb.w = ui.Widget(style='background:#0a0;') + self.butc.w = ui.Widget(style='background:#00a;') + + @event.reaction('buta.pointer_down', 'butb.pointer_down', 'butc.pointer_down') + def _stacked_current(self, *events): + button = events[-1].source + self.stack.set_current(button.w) + +if __name__ == '__main__': + m = flx.launch(Example, 'default-browser') + flx.run() diff --git a/flexxamples/ui_usage/tabs.py b/flexxamples/ui_usage/tabs.py new file mode 100644 index 00000000..f6daf543 --- /dev/null +++ b/flexxamples/ui_usage/tabs.py @@ -0,0 +1,12 @@ +from flexx import app, ui, flx + +class Example(ui.Widget): + def init(self): + with ui.TabLayout() as self.t: + self.a = ui.MultiLineEdit(title='input', style='background:#a00;') + self.b = ui.Widget(title='green', style='background:#0a0;') + self.c = ui.Widget(title='blue', style='background:#00a;') + +if __name__ == '__main__': + m = flx.launch(Example, 'default-browser') + flx.run() From bf619c4a6607dd1d85e85ea39a8bacb42687b223 Mon Sep 17 00:00:00 2001 From: ceprio Date: Thu, 10 Dec 2020 17:05:00 -0500 Subject: [PATCH 05/14] shuffled things a bit for understandability --- flexx/app/_flaskhelpers.py | 64 ++++++++++++++++++++++++------ flexx/flx_flask.py | 2 +- flexxamples/howtos/flask_server.py | 27 ++++++------- 3 files changed, 65 insertions(+), 28 deletions(-) diff --git a/flexx/app/_flaskhelpers.py b/flexx/app/_flaskhelpers.py index cfbd3ebe..fde0940c 100644 --- a/flexx/app/_flaskhelpers.py +++ b/flexx/app/_flaskhelpers.py @@ -5,9 +5,14 @@ flexxBlueprint = flask.Blueprint('FlexxApps', __name__, static_folder='static') flexxWS = flask.Blueprint('flexxWS', __name__) -_blueprints_registered = False # todo remove this and implement blueprint registration/deregistration? +_blueprints_registered = False # todo remove this and implement blueprint registration/deregistration? -def register_blueprints(app, sockets): +import os +import sys +import inspect + + +def register_blueprints(app, sockets, **kwargs): """ Register all flexx apps to flask. Flask will create one URL per application plus a generic /flexx/ URL for serving assets and data. @@ -16,27 +21,42 @@ def register_blueprints(app, sockets): """ global _blueprints_registered if _blueprints_registered: - return + return + # Find the callers path + frame = inspect.stack()[1] + p = frame[0].f_code.co_filename + caller_path = os.path.dirname(p) + # Convert the paths in arguments to absolute paths + for key, value in kwargs.items(): + if key in ["static_folder", 'static_url_path', 'template_folder']: + kwargs[key] = os.path.abspath(os.path.join(caller_path, value)) ########### Register apps ########### for name in manager._appinfo.keys(): # Create blueprint - appBlueprint = flask.Blueprint(f'Flexx_{name}', __name__, static_folder='static') # This is specific to apps - from ._flaskserver import AppHandler # delayed import - # Create handler - def app_handler(path): + appBlueprint = flask.Blueprint(f'Flexx_{name}', __name__, **kwargs) # This is specific to apps + from ._flaskserver import AppHandler # delayed import + + # Create handlers + def app_handler(): # AppHandler return AppHandler(flask.request).run() + + appBlueprint.route('/')(app_handler) app_handler.__name__ = name - appBlueprint.route('/', defaults={'path': ''})( - appBlueprint.route('/')(app_handler)) + + def app_static_handler(path): + return appBlueprint.send_static_file(path) + + appBlueprint.route('/')(app_static_handler) # register the app blueprint app.register_blueprint(appBlueprint, url_prefix=f"/{name}") - app.register_blueprint(flexxBlueprint, url_prefix=r"/flexx") # This is for the shared flexx assets + app.register_blueprint(flexxBlueprint, url_prefix=r"/flexx") # This is for the shared flexx assets ########### Register sockets ########### sockets.register_blueprint(flexxWS, url_prefix=r"/flexx") _blueprints_registered = True return + def serve(cls): """ This function registers the flexx Widget to the manager so the server can @@ -46,7 +66,8 @@ def serve(cls): if not m._is_served: m.serve() -def start(): + +def _start(): """ Start the flexx event loop only. This function generally does not return until the application is stopped. @@ -55,4 +76,23 @@ def start(): associated with the current server. """ server = current_server(backend='flask') - server.start_serverless() \ No newline at end of file + server.start_serverless() + + +def start_thread(): + import threading + import asyncio + + def flexx_thread(): + """ + Function to start a thread containing the main loop of flexx. + This is needed as flexx is an asyncio application which is not + compatible with flexx/gevent. + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + _start() # starts flexx loop without server + + thread1 = threading.Thread(target=flexx_thread) + thread1.daemon = True + thread1.start() diff --git a/flexx/flx_flask.py b/flexx/flx_flask.py index d531f55f..eaa04975 100644 --- a/flexx/flx_flask.py +++ b/flexx/flx_flask.py @@ -1 +1 @@ -from .app._flaskhelpers import register_blueprints, start, serve +from .app._flaskhelpers import register_blueprints, serve, start_thread diff --git a/flexxamples/howtos/flask_server.py b/flexxamples/howtos/flask_server.py index 26a3f626..8b683c8c 100644 --- a/flexxamples/howtos/flask_server.py +++ b/flexxamples/howtos/flask_server.py @@ -13,18 +13,22 @@ def init(self): "This flexx app is served within flask! " ui.Markdown(content=content, style='background:#EAECFF;') + flx_flask.serve(Example) + @app.route("/") def site_map(): # list available applications and URLs """ This function lists all the URLs server by the flask application including the flexx application that have been registered. """ + def has_no_empty_params(rule): defaults = rule.defaults if rule.defaults is not None else () arguments = rule.arguments if rule.arguments is not None else () return len(defaults) >= len(arguments) + links = [] for rule in current_app.url_map.iter_rules(): # Filter out rules we can't navigate to in a browser @@ -39,25 +43,18 @@ def has_no_empty_params(rule): html.append("") return '\n'.join(html) + +@app.route('/favicon.ico') +def ico_file(): + return "None" + + ####################### Registration of blueprints ##################### sockets = Sockets(app) # keep at the end -flx_flask.register_blueprints(app, sockets) +flx_flask.register_blueprints(app, sockets, static_folder='static') ####################### Start flexx in thread ##################### -import threading -import asyncio -def flexx_thread(): - """ - Function to start a thread containing the main loop of flexx. - This is needed as flexx is an asyncio application which is not - compatible with flexx/gevent. - """ - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - flx_flask.start() # starts flexx loop without server -thread1 = threading.Thread(target = flexx_thread) -thread1.daemon = True -thread1.start() +flx_flask.start_thread() ######### Start flask server (using gevent that supports web sockets) ######### if __name__ == "__main__": From 60190741c84f4513fff4264465f34a8acf057a6c Mon Sep 17 00:00:00 2001 From: ceprio Date: Mon, 14 Dec 2020 17:08:35 -0500 Subject: [PATCH 06/14] Added comments and correction of a Bug specific to Python 3.8 --- flexx/app/_flaskhelpers.py | 44 ++++++++++++++--- flexx/app/_flaskserver.py | 56 +++++++++++++++------- flexx/app/_server.py | 4 +- flexx/ui/widgets/_markdown.py | 14 +++--- flexxamples/howtos/flask_server.py | 11 +++++ flexxamples/ui_usage/combo_box.py | 23 --------- flexxamples/ui_usage/dropdown_container.py | 4 +- flexxamples/ui_usage/group.py | 6 ++- flexxamples/ui_usage/label.py | 4 +- flexxamples/ui_usage/markdown.py | 2 +- flexxamples/ui_usage/stack.py | 6 +++ flexxamples/ui_usage/tabs.py | 7 +++ 12 files changed, 120 insertions(+), 61 deletions(-) delete mode 100644 flexxamples/ui_usage/combo_box.py diff --git a/flexx/app/_flaskhelpers.py b/flexx/app/_flaskhelpers.py index fde0940c..4a88e27a 100644 --- a/flexx/app/_flaskhelpers.py +++ b/flexx/app/_flaskhelpers.py @@ -1,3 +1,29 @@ +""" +This file contains the main functions used to implement a flask/gevent server +hosting a flexx application. + +The chain of initialisation is the following: + +# Import +from flexx import flx_flask +# Define one or multiple classes +class Example1(flx.Widget): + ... +# Register the class to the server (you can define more than one) +flx_flask.serve(Example1) + +# Instantiate the Socket class and then register all flexx apps. +# The flexx apps are individually registered as one Blueprint each. +sockets = Sockets(app) # keep at the end +flx_flask.register_blueprints(app, sockets, static_folder='static') + +# Start the flexx thread to manage the flexx asyncio worker loop. +flx_flask.start_thread() + +# You can then start the flask/gevent server. + +See the howtos/flask_server.py example for a working example. +""" import flask from ._app import manager, App from ._server import create_server, current_server @@ -67,7 +93,7 @@ def serve(cls): m.serve() -def _start(): +def _start(loop): """ Start the flexx event loop only. This function generally does not return until the application is stopped. @@ -75,24 +101,28 @@ def _start(): In more detail, this calls ``run_forever()`` on the asyncio event loop associated with the current server. """ - server = current_server(backend='flask') + server = current_server(backend='flask', loop=loop) server.start_serverless() def start_thread(): + """ + Starts the flexx thread that manages the flexx asyncio worker loop. + """ import threading import asyncio - def flexx_thread(): + flexx_loop = asyncio.new_event_loop() # assign the loop to the manager so it can be accessed later. + + def flexx_thread(loop): """ Function to start a thread containing the main loop of flexx. This is needed as flexx is an asyncio application which is not - compatible with flexx/gevent. + compatible with flask/gevent. """ - loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - _start() # starts flexx loop without server + _start(loop) # starts flexx loop without http server - thread1 = threading.Thread(target=flexx_thread) + thread1 = threading.Thread(target=flexx_thread, args=(flexx_loop,)) thread1.daemon = True thread1.start() diff --git a/flexx/app/_flaskserver.py b/flexx/app/_flaskserver.py index 6267e203..03f59d17 100644 --- a/flexx/app/_flaskserver.py +++ b/flexx/app/_flaskserver.py @@ -38,9 +38,11 @@ app = Flask(__name__) # app.debug = True + @app.route('/favicon.ico') def favicon(): - return '' # app.send_static_file(f'img/favicon.ico') + return '' # app.send_static_file(f'img/favicon.ico') + if app.debug: @@ -65,11 +67,13 @@ def site_map(): html.append("") return '\n'.join(html) + @flexxWS.route('/ws/') def ws_handler(ws, path): # WSHandler wshandler = WSHandler(ws) - async def flexx_msg_handler(ws, path): + + async def flexx_msg_handler(ws, path): wshandler.open(path) future = asyncio.run_coroutine_threadsafe(flexx_msg_handler(ws, path), loop=manager.loop) @@ -79,7 +83,8 @@ async def flexx_msg_handler(ws, path): if message is None: break manager.loop.call_soon_threadsafe(wshandler.on_message, message) - manager.loop.call_soon_threadsafe(wshandler.ws_closed) #, 1000, "closed by client") # add connection close reason? + manager.loop.call_soon_threadsafe(wshandler.ws_closed) + @flexxBlueprint.route('/', defaults={'path': ''}) @flexxBlueprint.route('/') @@ -89,6 +94,7 @@ def flexx_handler(path): # MainHandler return MainHandler(flask.request).run() + IMPORT_TIME = time.time() @@ -96,15 +102,18 @@ def is_main_thread(): """ Get whether this is the main thread. """ return isinstance(threading.current_thread(), threading._MainThread) + class FlaskServer(AbstractServer): """ Flexx Server implemented in Flask. """ + def __init__(self, *args, **kwargs): global app self._app = app self._server = None - self._serving = None # needed for AbstractServer - super().__init__(*args, **kwargs) # this calls self._open + self._serving = None # needed for AbstractServer + super().__init__(*args, **kwargs) # this calls self._open and + # create the loop if not specified def _open(self, host, port, **kwargs): # Note: does not get called if host is False. That way we can @@ -125,7 +134,7 @@ def _open(self, host, port, **kwargs): for i in range(8): port = prefered_port + i try: - result_of_check = a_socket.bind((host,port)) + result_of_check = a_socket.bind((host, port)) except: continue a_socket.close() @@ -133,8 +142,8 @@ def _open(self, host, port, **kwargs): else: assert False, "No port found to start flask" - # remember the loop we are in for the manager - manager.loop = asyncio.get_event_loop() + # Remember the loop we are in for the manager + manager.loop = self._loop # Keep flask application info self._serving = (host, port) @@ -151,7 +160,8 @@ def RunServer(): # This string 'Serving apps at' is our 'ready' signal and is tested for. logger.info('Serving apps at %s://%s:%i/' % (proto, *self._serving)) self._server.serve_forever() - _thread = threading.Thread(target = RunServer) + + _thread = threading.Thread(target=RunServer) _thread.daemon = True # end the thread if the main thread exits _thread.start() super().start() @@ -179,6 +189,7 @@ def protocol(self): # return 'https' return 'http' + def port_hash(name): """ Given a string, returns a port number between 49152 and 65535 @@ -191,9 +202,11 @@ def port_hash(name): for c in name: val += (val >> 3) + (ord(c) * fac) val += (val >> 3) + (len(name) * fac) - return 49152 + (val % 2**14) + return 49152 + (val % 2 ** 14) + class RequestHandler: + def __init__(self, request): self.request = request self.content = [] @@ -217,7 +230,7 @@ def run(self): return self.content, 200, self.values def get_argument(self, key, default): - return self.request.values.get(key,default) + return self.request.values.get(key, default) def set_header(self, key, value): self.values[key] = value @@ -313,6 +326,7 @@ def _get_app(self, app_name, path): else: self.redirect('/%s/' % app_name) # redirect for normal serve else: + # Create session - websocket will connect to it via session_id async def run_in_flexx_loop(app_name, request): session = manager.create_session(app_name, request=request) @@ -322,6 +336,7 @@ async def run_in_flexx_loop(app_name, request): session = future.result() self.write(get_page(session).encode()) + class MainHandler(RequestHandler): """ Handler for assets, commands, etc. Basically, everything for which the path is clear. @@ -380,7 +395,7 @@ def _get_asset(self, selector, path): # If colon: request for a view of an asset at a certain line if '.js:' in filename or '.css:' in filename or filename[0] == ':': fname, where = filename.split(':')[:2] - return self.redirect('/flexx/assetview/%s/%s#L%s' % + return self.redirect('/flexx/assetview/%s/%s#L%s' % (session_id or 'shared', fname.replace('/:', ':'), where)) # Retrieve asset @@ -410,8 +425,8 @@ def _get_asset(self, selector, path): for i, line in enumerate(res.splitlines()): table = {ord('&'): '&', ord('<'): '<', ord('>'): '>'} line = line.translate(table).replace('\t', ' ') - lines.append('
    %s  %s
    ' % - (i+1, i+1, str(i+1).rjust(4).replace(' ', ' '), line)) + lines.append('
    %s  %s
    ' % + (i + 1, i + 1, str(i + 1).rjust(4).replace(' ', ' '), line)) lines.append('') self.write('\n'.join(lines)) @@ -514,6 +529,7 @@ def _notify(self): def stop(self): self._stop = True + from typing import ( TYPE_CHECKING, cast, @@ -528,16 +544,21 @@ def stop(self): Type, ) + class MyWebSocketHandler(): """ This class is designed to mimic the tornado WebSocketHandler to allow glue in code from WSHandler. """ + class Application: pass + class IOLoop: + def __init__(self, loop): self._loop = loop + def spawn_callback(self, func, *args): self._loop.call_soon_threadsafe(func, *args) @@ -548,17 +569,18 @@ def __init__(self, ws): self.cookies = {} def write_message( - self, message: Union[bytes, str, Dict[str, Any]], binary: bool = False + self, message: Union[bytes, str, Dict[str, Any]], binary: bool=False ) -> "Future[None]": self._ws.send(message) - def close(self, code: int = None, reason: str = None) -> None: + def close(self, code: int=None, reason: str=None) -> None: if not self._ws.closed: self._ws.close(code, reason) def ws_closed(self): self.on_close() + class WSHandler(MyWebSocketHandler): """ Handler for websocket. """ @@ -698,7 +720,7 @@ def check_origin(self, origin): """ Handle cross-domain access; override default same origin policy. """ # http://www.tornadoweb.org/en/stable/_modules/tornado/websocket.html - #WebSocketHandler.check_origin + # WebSocketHandler.check_origin serving_host = self.request.headers.get("Host") serving_hostname, _, serving_port = serving_host.partition(':') diff --git a/flexx/app/_server.py b/flexx/app/_server.py index 2626b445..2cf9a88f 100644 --- a/flexx/app/_server.py +++ b/flexx/app/_server.py @@ -114,7 +114,9 @@ class AbstractServer: def __init__(self, host, port, loop=None, **kwargs): # First off, create new event loop and integrate event.loop if sys.version_info > (3, 8) and sys.platform.startswith('win'): - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + # watch out: this resets any previous set_event_loop + # Please add comment: What is this used for?? + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) if loop is None: self._loop = asyncio.get_event_loop() else: diff --git a/flexx/ui/widgets/_markdown.py b/flexx/ui/widgets/_markdown.py index 226bd776..854dd20c 100644 --- a/flexx/ui/widgets/_markdown.py +++ b/flexx/ui/widgets/_markdown.py @@ -4,15 +4,13 @@ .. UIExample:: 200 - # Define data. This can also be generated with the plotly Python library - data = [{'type': 'bar', - 'x': ['giraffes', 'orangutans', 'monkeys'], - 'y': [20, 14, 23]}] + def init(self): + content = "# Welcome\n\n" \ + "Hello. Welcome to my **website**. This is an example of a widget container for markdown content. " \ + "The content can be text or a link.\n\n" + ui.Markdown(content=content, style='background:#EAECFF;height:60%;') - # Show - p = ui.PlotlyWidget(data=data) - -Also see examples: :ref:`plotly_gdp.py`. +Also see example: :ref:`ui_usage/markdown.py`. """ diff --git a/flexxamples/howtos/flask_server.py b/flexxamples/howtos/flask_server.py index 8b683c8c..4836125c 100644 --- a/flexxamples/howtos/flask_server.py +++ b/flexxamples/howtos/flask_server.py @@ -1,3 +1,14 @@ +""" +Example showing an implementation of a flask server serving a flexx application. + +If assets are needed (jpg, files, etc.) they can be placed a folder called static +and accessed through each flexx blueprints (e.g. http://my_flexx/picture.jpg). The +name of that folder can be changed when registering the blueprint. + +All functions needed for the implementation are found in flx_flask. More help can +be found in flexx/app/_flaskhelpers.py. +""" + from flask import Flask, current_app, url_for from flask_sockets import Sockets app = Flask(__name__) diff --git a/flexxamples/ui_usage/combo_box.py b/flexxamples/ui_usage/combo_box.py deleted file mode 100644 index 13092370..00000000 --- a/flexxamples/ui_usage/combo_box.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Simple use of a dropdown, -""" - -from flexx import app, event, ui, flx - -class Example(ui.Widget): - - def init(self): - # A combobox - self.combo = ui.ComboBox(editable=True, - options=('foo', 'bar', 'spaaaaaaaaam', 'eggs')) - self.label = ui.Label() - -# @event.connect('combo.text') -# def on_combobox_text(self, *events): -# self.label.text = 'Combobox text: ' + self.combo.text -# if self.combo.selected_index is not None: -# self.label.text += ' (index %i)' % self.combo.selected_index - -if __name__ == '__main__': - m = flx.launch(Example, 'default-browser') - flx.run() diff --git a/flexxamples/ui_usage/dropdown_container.py b/flexxamples/ui_usage/dropdown_container.py index 9b18fcf1..0e7ad986 100644 --- a/flexxamples/ui_usage/dropdown_container.py +++ b/flexxamples/ui_usage/dropdown_container.py @@ -1,9 +1,10 @@ """ -Simple use of a dropdown, +Simple use of a dropdown containing a tree widget """ from flexx import app, event, ui, flx + class Example(ui.Widget): CSS = ''' @@ -19,6 +20,7 @@ def init(self): for i in range(20): ui.TreeItem(text='foo %i' % i, checked=False) + if __name__ == '__main__': m = flx.launch(Example) flx.run() diff --git a/flexxamples/ui_usage/group.py b/flexxamples/ui_usage/group.py index 83feae17..210e55de 100644 --- a/flexxamples/ui_usage/group.py +++ b/flexxamples/ui_usage/group.py @@ -1,11 +1,12 @@ """ -Simple use of a group, -using a custom widget that is populated in its ``init()``. +Simple use of a group containing a few widgets """ from flexx import app, event, ui, flx + class Example(flx.Widget): + def init(self): with ui.GroupWidget(title='A silly panel'): with ui.VBox(): @@ -16,6 +17,7 @@ def init(self): @event.reaction('but.pointer_down') def _button_pressed(self, *events): self.progress.set_value(self.progress.value + 1) + if __name__ == '__main__': m = flx.launch(Example) diff --git a/flexxamples/ui_usage/label.py b/flexxamples/ui_usage/label.py index dbffefc0..a8a52fc7 100644 --- a/flexxamples/ui_usage/label.py +++ b/flexxamples/ui_usage/label.py @@ -1,15 +1,17 @@ """ -Simple use of a dropdown, +Simple use of a label """ from flexx import app, event, ui, flx + class Example(ui.Widget): def init(self): self.label = ui.Label(text="Number:") self.label = ui.Label(html=" 45 ") + if __name__ == '__main__': m = flx.launch(Example, 'default-browser') flx.run() diff --git a/flexxamples/ui_usage/markdown.py b/flexxamples/ui_usage/markdown.py index a135e1f0..b236e98d 100644 --- a/flexxamples/ui_usage/markdown.py +++ b/flexxamples/ui_usage/markdown.py @@ -12,7 +12,7 @@ def init(self): content = "# Welcome\n\n" \ "Hello. Welcome to my **website**. This is an example of a widget container for markdown content. " \ "The content can be text or a link.\n\n" - content += "\n\n".join(["a new line" for a in range(100)]) + content += "\n\n".join(["a new line to test long files" for a in range(100)]) ui.Markdown(content=content, style='background:#EAECFF;height:60%;') diff --git a/flexxamples/ui_usage/stack.py b/flexxamples/ui_usage/stack.py index 6a730d87..5a6126ff 100644 --- a/flexxamples/ui_usage/stack.py +++ b/flexxamples/ui_usage/stack.py @@ -1,5 +1,10 @@ +""" +Example of VBox, HBox and StackLayout +""" + from flexx import app, event, ui, flx + class Example(ui.Widget): def init(self): @@ -19,6 +24,7 @@ def _stacked_current(self, *events): button = events[-1].source self.stack.set_current(button.w) + if __name__ == '__main__': m = flx.launch(Example, 'default-browser') flx.run() diff --git a/flexxamples/ui_usage/tabs.py b/flexxamples/ui_usage/tabs.py index f6daf543..18f8c76e 100644 --- a/flexxamples/ui_usage/tabs.py +++ b/flexxamples/ui_usage/tabs.py @@ -1,12 +1,19 @@ +""" +Simple example of TabLayout +""" + from flexx import app, ui, flx + class Example(ui.Widget): + def init(self): with ui.TabLayout() as self.t: self.a = ui.MultiLineEdit(title='input', style='background:#a00;') self.b = ui.Widget(title='green', style='background:#0a0;') self.c = ui.Widget(title='blue', style='background:#00a;') + if __name__ == '__main__': m = flx.launch(Example, 'default-browser') flx.run() From e0d3c6e67e96e28dfd89a734091dfde2e0762c81 Mon Sep 17 00:00:00 2001 From: ceprio Date: Mon, 14 Dec 2020 20:47:09 -0500 Subject: [PATCH 07/14] correction for python 3.8 --- flexx/app/_flaskserver.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/flexx/app/_flaskserver.py b/flexx/app/_flaskserver.py index 03f59d17..164b929d 100644 --- a/flexx/app/_flaskserver.py +++ b/flexx/app/_flaskserver.py @@ -13,7 +13,6 @@ import asyncio import socket import mimetypes -import traceback import threading from urllib.parse import urlparse @@ -23,7 +22,6 @@ from gevent import pywsgi from geventwebsocket.handler import WebSocketHandler -import werkzeug.serving from ._app import manager from ._session import get_page @@ -142,11 +140,25 @@ def _open(self, host, port, **kwargs): else: assert False, "No port found to start flask" + # Keep flask application info + self._serving = (host, port) + # Remember the loop we are in for the manager manager.loop = self._loop + + # Create a thread frendly coroutine (especially for python 3.8) + asyncio.run_coroutine_threadsafe(self._thread_switch(), self._loop) - # Keep flask application info - self._serving = (host, port) + @staticmethod + async def _thread_switch(): + """ + Python 3.8 is very unfrendly to thread as it does not leave any chances for + a Thread switch when no tasks are left to run. This function just let other + Threads some time to run. + """ + while True: + time.sleep(0) + await asyncio.sleep(0) def start(self): # Register blueprints for all apps: From b28bc12955b87a9303a035ce716269bb45dd794c Mon Sep 17 00:00:00 2001 From: ceprio Date: Wed, 16 Dec 2020 17:29:42 -0500 Subject: [PATCH 08/14] Rearanged _markdown comments for doc-export rendering. --- flexx/ui/widgets/_markdown.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/flexx/ui/widgets/_markdown.py b/flexx/ui/widgets/_markdown.py index 854dd20c..29114137 100644 --- a/flexx/ui/widgets/_markdown.py +++ b/flexx/ui/widgets/_markdown.py @@ -1,6 +1,10 @@ -""" +""" Markdown widget + +Widget containing a string which content gets rendered and shown as markdown text. -Simple example: +See the working example from `flexxamples/ui_usage/markdown.py`. + +Simple usage: .. UIExample:: 200 @@ -10,8 +14,6 @@ def init(self): "The content can be text or a link.\n\n" ui.Markdown(content=content, style='background:#EAECFF;height:60%;') -Also see example: :ref:`ui_usage/markdown.py`. - """ from ... import app, event From 3d0516004d5d240baa263a68bb150bc828970d3c Mon Sep 17 00:00:00 2001 From: "christian.pronovost" Date: Mon, 8 Feb 2021 21:02:30 -0500 Subject: [PATCH 09/14] Correction to reduce CPU usage on flask server thread. --- flexx/app/_flaskserver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexx/app/_flaskserver.py b/flexx/app/_flaskserver.py index 164b929d..fdacbdbe 100644 --- a/flexx/app/_flaskserver.py +++ b/flexx/app/_flaskserver.py @@ -158,7 +158,7 @@ async def _thread_switch(): """ while True: time.sleep(0) - await asyncio.sleep(0) + await asyncio.sleep(1e-9) # any number above 0 will keep low CPU usage def start(self): # Register blueprints for all apps: From c9ebc953d9ced2fc8854e1b2b0019ec14fd2e117 Mon Sep 17 00:00:00 2001 From: ceprio Date: Wed, 26 May 2021 08:53:29 -0400 Subject: [PATCH 10/14] Added support to style PyWidget at instanciation (e.g. PyWidget(style=...)) --- flexx/app/_component2.py | 4 +++- flexx/app/_flaskserver.py | 2 +- flexx/event/_component.py | 17 +++++++++++++---- flexx/ui/_widget.py | 6 ++++-- flexxamples/howtos/bokehdemo.py | 2 +- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/flexx/app/_component2.py b/flexx/app/_component2.py index 8f1d0af2..01cd93b4 100644 --- a/flexx/app/_component2.py +++ b/flexx/app/_component2.py @@ -353,7 +353,9 @@ def _comp_init_property_values(self, property_values): # This is a good time to register with the session, and # instantiate the proxy class. Property values have been set at this # point, but init() has not yet been called. - + # Property values must be poped when consumed so that the remainer is used for + # instantiation of the Widget + # Keep track of what events are registered at the proxy self.__event_types_at_proxy = [] diff --git a/flexx/app/_flaskserver.py b/flexx/app/_flaskserver.py index 164b929d..fdacbdbe 100644 --- a/flexx/app/_flaskserver.py +++ b/flexx/app/_flaskserver.py @@ -158,7 +158,7 @@ async def _thread_switch(): """ while True: time.sleep(0) - await asyncio.sleep(0) + await asyncio.sleep(1e-9) # any number above 0 will keep low CPU usage def start(self): # Register blueprints for all apps: diff --git a/flexx/event/_component.py b/flexx/event/_component.py index b8ad5528..1b1f485e 100644 --- a/flexx/event/_component.py +++ b/flexx/event/_component.py @@ -203,7 +203,7 @@ def __init__(self, *init_args, **property_values): for name in self.__properties__: self.__handlers.setdefault(name, []) - # With self as the active component (and thus mutatable), init the + # With self as the active component (and thus mutable), init the # values of all properties, and apply user-defined initialization with self: self._comp_init_property_values(property_values) @@ -219,6 +219,8 @@ def _comp_init_property_values(self, property_values): """ Initialize property values, combining given kwargs (in order) and default values. """ + # Property values must be poped when consumed so that the remainer is used for + # instantiation of the Widget values = [] # First collect default property values (they come first) for name in self.__properties__: # is sorted by name @@ -227,18 +229,25 @@ def _comp_init_property_values(self, property_values): if name not in property_values: values.append((name, prop._default)) # Then collect user-provided values - for name, value in property_values.items(): # is sorted by occurance in py36 + for name, value in list(property_values.items()): # is sorted by occurance in py36 if name not in self.__properties__: if name in self.__attributes__: raise AttributeError('%s.%s is an attribute, not a property' % (self._id, name)) else: - raise AttributeError('%s does not have property %s.' % + # if the proxy instance does not exist, we want the attribute + # to be passed through to the Widget instantiation. + # No exception if the proxy does not exists. + if self._has_proxy is True: + raise AttributeError('%s does not have property %s.' % (self._id, name)) if callable(value): self._comp_make_implicit_setter(name, value) + property_values.pop(name) continue - values.append((name, value)) + if name in self.__properties__: + values.append((name, value)) + property_values.pop(name) # Then process all property values self._comp_apply_property_values(values) diff --git a/flexx/ui/_widget.py b/flexx/ui/_widget.py index 96bef7de..6110bc31 100644 --- a/flexx/ui/_widget.py +++ b/flexx/ui/_widget.py @@ -1158,7 +1158,9 @@ def _comp_init_property_values(self, property_values): # when this is the active component, and after the original # version of this has been called, everything related to session # etc. will work fine. - + # Property values must be poped when consumed so that the remainer is used for + # instantiation of the Widget + # First extract the kwargs kwargs_for_real_widget = {} for name in list(property_values.keys()): @@ -1168,7 +1170,7 @@ def _comp_init_property_values(self, property_values): # Call original version, sets _session, amongst other things super()._comp_init_property_values(property_values) # Create widget and activate it - w = self._WidgetCls(**kwargs_for_real_widget) + w = self._WidgetCls(**kwargs_for_real_widget, **property_values) self.__exit__(None, None, None) self._jswidget = w self.__enter__() diff --git a/flexxamples/howtos/bokehdemo.py b/flexxamples/howtos/bokehdemo.py index e3bb7092..de37f163 100644 --- a/flexxamples/howtos/bokehdemo.py +++ b/flexxamples/howtos/bokehdemo.py @@ -35,7 +35,7 @@ def init(self): self.plot1 = flx.BokehWidget.from_plot(p1, title='Scatter') with flx.VFix(title='Sine'): Controls() - with flx.Widget(style='overflow-y:auto;', flex=1): + with flx.PyWidget(style='overflow-y:auto;', flex=1): self.plot2 = flx.BokehWidget.from_plot(p2) self.plot3 = flx.BokehWidget.from_plot(p3) From fbcef269797745ac635c8b0870f699672a92a7a8 Mon Sep 17 00:00:00 2001 From: ceprio Date: Tue, 1 Jun 2021 22:52:10 -0400 Subject: [PATCH 11/14] Improved comments on changes --- flexx/event/_component.py | 6 ++++-- flexx/ui/_widget.py | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/flexx/event/_component.py b/flexx/event/_component.py index 1b1f485e..e1afef5a 100644 --- a/flexx/event/_component.py +++ b/flexx/event/_component.py @@ -206,6 +206,8 @@ def __init__(self, *init_args, **property_values): # With self as the active component (and thus mutable), init the # values of all properties, and apply user-defined initialization with self: + # This call will pop some of the consumed property values + # will remain properties used for the DOM component initialisation self._comp_init_property_values(property_values) self.init(*init_args) @@ -218,9 +220,9 @@ def __repr__(self): def _comp_init_property_values(self, property_values): """ Initialize property values, combining given kwargs (in order) and default values. + Property values are popped when consumed so that the remainer is used for + other initialisations without mixup. """ - # Property values must be poped when consumed so that the remainer is used for - # instantiation of the Widget values = [] # First collect default property values (they come first) for name in self.__properties__: # is sorted by name diff --git a/flexx/ui/_widget.py b/flexx/ui/_widget.py index 6110bc31..78bf2920 100644 --- a/flexx/ui/_widget.py +++ b/flexx/ui/_widget.py @@ -1158,8 +1158,8 @@ def _comp_init_property_values(self, property_values): # when this is the active component, and after the original # version of this has been called, everything related to session # etc. will work fine. - # Property values must be poped when consumed so that the remainer is used for - # instantiation of the Widget + # Property values are poped when consumed so that the remainer is used for + # instantiation of the js Widget # First extract the kwargs kwargs_for_real_widget = {} @@ -1168,8 +1168,11 @@ def _comp_init_property_values(self, property_values): if name in self._WidgetCls.__properties__: kwargs_for_real_widget[name] = property_values.pop(name) # Call original version, sets _session, amongst other things + # it also pops the consumed property values so the remainer + # can be used to initialize the js _WidgetCls super()._comp_init_property_values(property_values) - # Create widget and activate it + # Create widget and activate it - at this point, all property values + # should be intended for the js Widget. w = self._WidgetCls(**kwargs_for_real_widget, **property_values) self.__exit__(None, None, None) self._jswidget = w From a614a7b035b4988727487e03eda6db272b29bf5d Mon Sep 17 00:00:00 2001 From: ceprio Date: Wed, 12 Jan 2022 22:43:29 -0500 Subject: [PATCH 12/14] Added DynamicWidgetContainer class --- flexx/app/_flaskserver.py | 2 +- flexx/ui/pywidgets/__init__.py | 2 + flexx/ui/pywidgets/_dynamicwidgetcontainer.py | 81 ++++++++++++ flexxamples/howtos/dynamic_container_app.py | 80 ++++++++++++ .../howtos/dynamic_container_in_background.py | 121 ++++++++++++++++++ 5 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 flexx/ui/pywidgets/_dynamicwidgetcontainer.py create mode 100644 flexxamples/howtos/dynamic_container_app.py create mode 100644 flexxamples/howtos/dynamic_container_in_background.py diff --git a/flexx/app/_flaskserver.py b/flexx/app/_flaskserver.py index 6782ca8f..ded76c24 100644 --- a/flexx/app/_flaskserver.py +++ b/flexx/app/_flaskserver.py @@ -711,7 +711,7 @@ def write_command(self, cmd): bb = serializer.encode(cmd) try: self.write_message(bb, binary=True) - except flask.Exception: # Note: is there a more specific error we could use? + except WebSocketClosedError: self.close(1000, 'closed by client') def close(self, *args): diff --git a/flexx/ui/pywidgets/__init__.py b/flexx/ui/pywidgets/__init__.py index 863b9d09..ec8c14ae 100644 --- a/flexx/ui/pywidgets/__init__.py +++ b/flexx/ui/pywidgets/__init__.py @@ -4,3 +4,5 @@ from .. import PyWidget from ._filebrowser import FileBrowserWidget +from ._dynamicwidgetcontainer import DynamicWidgetContainer + diff --git a/flexx/ui/pywidgets/_dynamicwidgetcontainer.py b/flexx/ui/pywidgets/_dynamicwidgetcontainer.py new file mode 100644 index 00000000..53401340 --- /dev/null +++ b/flexx/ui/pywidgets/_dynamicwidgetcontainer.py @@ -0,0 +1,81 @@ +from ... import event +from .._widget import PyWidget +import threading + + +class DynamicWidgetContainer(PyWidget): + """ Widget container to allow dynamic insertion and disposal of widgets. + """ + + DEFAULT_MIN_SIZE = 0, 0 + + def init(self, *init_args, **property_values): + # TODO: figure out if init_args is needed for something + super(DynamicWidgetContainer, self).init() # call to _component.py -> Component.init(self) + # the page + self.pages = [] # pages are one on top of another + + def _init_events(self): + pass # just don't use standard events + + def clean_pages(self): # remove empty pages from the top + while self.pages[-1] == None: + del self.pages[-1] + + @event.reaction("remove") + def __remove(self, *events): + if self.pages: + page = self.pages[events[0]['page_position']] + page.dyn_stop_event.set() + page.dyn_id = None + page.dispose() + page._jswidget.dispose() # <-- added + self.pages[events[0]['page_position']] = None + + @event.emitter + def remove(self, page_position): + return dict(page_position=page_position) + + @event.reaction("_emit_instantiate") + def __instantiate(self, *events): + with self: + with events[0]['widget_type'](events[0]['style']) as page: + page.parent = self + page.dyn_id = events[0]['page_position'] # TODO use a class attribute to allow non pyWidget + page.dyn_stop_event = threading.Event() + task = self.pages[page.dyn_id] # the location contains a task + self.pages[page.dyn_id] = page # record the instance + task.set() # set the task as done as the instantiation is done + self.clean_pages() # only clean after instanciation so it does not delete future location + + @event.emitter + def _emit_instantiate(self, widget_type, page_position, options): # can't put default arguments + return dict(widget_type=widget_type, page_position=page_position, style=options['style']) + + def instantiate(self, widget_type, options=None): + """ Send an instantiate command and return the widget instance id. + This function is thread safe. """ + if options == None: + options = dict({'style':"width: 100%; height: 100%;"}) + + async_task = threading.Event() + pos = len(self.pages) + self.pages.append(async_task) # this is the new location for this instance + while self.pages[pos] is not async_task: + pos += 1 # in case some other thread added to the list + + def out_of_thread_call(): + nonlocal pos + self._emit_instantiate(widget_type, pos, options) + + event.loop.call_soon(out_of_thread_call) + return pos + + def get_instance(self, page_position): + """ returns None if not yet instanciated """ + ret = self.pages[page_position] + if isinstance(ret, threading.Event): + ret.wait() # wait until event would be .set() + return self.pages[page_position] + else: + return ret \ No newline at end of file diff --git a/flexxamples/howtos/dynamic_container_app.py b/flexxamples/howtos/dynamic_container_app.py new file mode 100644 index 00000000..deb54b9b --- /dev/null +++ b/flexxamples/howtos/dynamic_container_app.py @@ -0,0 +1,80 @@ +from flexx import flx +from flexx import event + +import threading +import asyncio + + +class PyWidget1(flx.PyWidget): + frame = None + + def init(self, additional_style="width: 100%; height: 100%;"): + with flx.VFix(flex=1, style=additional_style) as self.frame: + with flx.VFix(flex=1) as self.page: + self.custom = flx.VFix(flex=1, style="width: 100%; height: 100%; border: 5px solid green;") + with flx.HFix(flex=1): + self.but = flx.Button(text="Replace by PyWidget2") + self.but_close = flx.Button(text="Close") + self.input = flx.LineEdit(text="input") + + def dispose(self): + self.frame.dispose() + self.frame = None + super().dispose() + + @flx.reaction("but.pointer_click") + def delete_function(self, *events): + self.parent.remove(self.dyn_id) + self.parent.instantiate(PyWidget2) + + @flx.reaction("but_close.pointer_click") + def close_function(self, *events): + self.parent.remove(self.dyn_id) + + +class PyWidget2(flx.PyWidget): + frame = None + + def init(self, additional_style="width: 100%; height: 100%;"): + with flx.VFix(flex=1, style=additional_style) as self.frame: + with flx.VFix(flex=1) as self.page: + self.custom = flx.VFix(flex=1, style="width: 100%; height: 100%; border: 5px solid blue;") + self.but = flx.Button(text="Swap back to a PyWidget1") + + def dispose(self): + self.frame.dispose() + self.frame = None + super().dispose() + + @flx.reaction("but.pointer_click") + def delete_function(self, *events): + self.parent.remove(self.dyn_id) + self.parent.instantiate(PyWidget1) + + +class Example(flx.PyWidget): + + # The CSS is not used by flex in PyWiget but it should be applied to the top div: TODO + CSS = """ + .flx-DynamicWidgetContainer { + white-space: nowrap; + padding: 0.2em 0.4em; + border-radius: 3px; + color: #333; + } + """ + + def init(self): + with flx.VFix(flex=1) as self.frame_layout: + self.dynamic = flx.DynamicWidgetContainer( + style="width: 100%; height: 100%; border: 5px solid black;", flex=1 + ) + self.but = flx.Button(text="Instanciate a PyWidget1 in the dynamic container") + + @flx.reaction("but.pointer_click") + def click(self, *events): + self.dynamic.instantiate(PyWidget1) + + +m = flx.launch(Example) +flx.run() \ No newline at end of file diff --git a/flexxamples/howtos/dynamic_container_in_background.py b/flexxamples/howtos/dynamic_container_in_background.py new file mode 100644 index 00000000..aee4a257 --- /dev/null +++ b/flexxamples/howtos/dynamic_container_in_background.py @@ -0,0 +1,121 @@ +# Following line may be needed for step by step debugging into threads +# from gevent import monkey; monkey.patch_all() # do it before modules like requests gets imported + +from flexx import flx +from flexx import event + +import threading +import asyncio + + +class PyWidget1(flx.PyWidget): + frame = None + + def init(self, additional_style="width: 100%; height: 100%;"): + with flx.VFix(flex=1, style=additional_style) as self.frame: + with flx.VFix(flex=1) as self.page: + self.custom = flx.VFix(flex=1, style="width: 100%; height: 100%; border: 5px solid green;") + with flx.HFix(flex=1): + self.but = flx.Button(text="Replace by PyWidget2") + self.but_close = flx.Button(text="Close") + self.input = flx.LineEdit(text="input") + + def dispose(self): + self.frame.dispose() + self.frame = None + super().dispose() + + @flx.reaction("but.pointer_click") + def delete_function(self, *events): + self.parent.remove(self.dyn_id) + self.parent.instantiate(PyWidget2) + + @flx.reaction("but_close.pointer_click") + def close_function(self, *events): + self.parent.remove(self.dyn_id) + + +class PyWidget2(flx.PyWidget): + frame = None + + def init(self, additional_style="width: 100%; height: 100%;"): + with flx.VFix(flex=1, style=additional_style) as self.frame: + with flx.VFix(flex=1) as self.page: + self.custom = flx.VFix(flex=1, style="width: 100%; height: 100%; border: 5px solid blue;") + self.but = flx.Button(text="Swap back to a PyWidget1") + + def dispose(self): + self.frame.dispose() + self.frame = None + super().dispose() + + @flx.reaction("but.pointer_click") + def delete_function(self, *events): + self.parent.remove(self.dyn_id) + self.parent.instantiate(PyWidget1) + + +class Example(flx.PyWidget): + + # The CSS is not used by flex in PyWiget but it should be applied to the top div: TODO + CSS = """ + .flx-DynamicWidgetContainer { + white-space: nowrap; + padding: 0.2em 0.4em; + border-radius: 3px; + color: #333; + } + """ + + def init(self): + with flx.VFix(flex=1) as self.frame_layout: + self.dynamic = flx.DynamicWidgetContainer( + style="width: 100%; height: 100%; border: 5px solid black;", flex=1 + ) + self.but = flx.Button(text="Instanciate a PyWidget1 in the dynamic container") + + @flx.reaction("but.pointer_click") + def click(self, *events): + self.dynamic.instantiate(PyWidget1) + + +flexx_app = threading.Event() +flexx_thread = None + + +def start_flexx_app(): + """ + Starts the flexx thread that manages the flexx asyncio worker loop. + """ + + flexx_loop = asyncio.new_event_loop() # assign the loop to the manager so it can be accessed later. + + def flexx_run(loop): + """ + Function to start a thread containing the main loop of flexx. + """ + global flexx_app + asyncio.set_event_loop(loop) + + event = flexx_app # flexx_app was initialized with an Event() + flexx_app = flx.launch(Example, loop=loop) + event.set() + flx.run() + + global flexx_thread + flexx_thread = threading.Thread(target=flexx_run, args=(flexx_loop,)) + flexx_thread.daemon = True + flexx_thread.start() + + +start_flexx_app() +app = flexx_app +if isinstance(app, threading.Event): # check if app was instanciated + app.wait() # wait for instanciation +# At this point flexx_app contains the Example application +pos = flexx_app.dynamic.instantiate(PyWidget1) +instance = flexx_app.dynamic.get_instance(pos) +instance.but.set_text("it worked") +# instance.dyn_stop_event.wait() # This waits for the instance to be removed +flexx_thread.join() # Wait for the flexx event loop to terminate. +print(instance.input.text) From 5e7608cfcea77c186fa2152014cdb50f9fb66361 Mon Sep 17 00:00:00 2001 From: ceprio <21286761+ceprio@users.noreply.github.com> Date: Wed, 26 Jan 2022 08:06:07 -0500 Subject: [PATCH 13/14] Update flexx/ui/pywidgets/_dynamicwidgetcontainer.py Change to None comparision Co-authored-by: Almar Klein --- flexx/ui/pywidgets/_dynamicwidgetcontainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexx/ui/pywidgets/_dynamicwidgetcontainer.py b/flexx/ui/pywidgets/_dynamicwidgetcontainer.py index 53401340..9e469f9d 100644 --- a/flexx/ui/pywidgets/_dynamicwidgetcontainer.py +++ b/flexx/ui/pywidgets/_dynamicwidgetcontainer.py @@ -19,7 +19,7 @@ def _init_events(self): pass # just don't use standard events def clean_pages(self): # remove empty pages from the top - while self.pages[-1] == None: + while self.pages[-1] is None: del self.pages[-1] @event.reaction("remove") From 7e4b59a6eda68dd6113956d951cfdc0b70bc8e1e Mon Sep 17 00:00:00 2001 From: ceprio <21286761+ceprio@users.noreply.github.com> Date: Wed, 26 Jan 2022 08:07:44 -0500 Subject: [PATCH 14/14] Update flexx/ui/pywidgets/_dynamicwidgetcontainer.py Co-authored-by: Almar Klein --- flexx/ui/pywidgets/_dynamicwidgetcontainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexx/ui/pywidgets/_dynamicwidgetcontainer.py b/flexx/ui/pywidgets/_dynamicwidgetcontainer.py index 9e469f9d..8100a085 100644 --- a/flexx/ui/pywidgets/_dynamicwidgetcontainer.py +++ b/flexx/ui/pywidgets/_dynamicwidgetcontainer.py @@ -55,7 +55,7 @@ def _emit_instantiate(self, widget_type, page_position, options): # can't put d def instantiate(self, widget_type, options=None): """ Send an instantiate command and return the widget instance id. This function is thread safe. """ - if options == None: + if options is None: options = dict({'style':"width: 100%; height: 100%;"}) async_task = threading.Event()