diff --git a/README.md b/README.md index a445cc84..e751e62f 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ As of Eel v0.12.0, the following options are available to `start()`: - **close_callback**, a lambda or function that is called when a websocket to a window closes (i.e. when the user closes the window). It should take two arguments; a string which is the relative path of the page that just closed, and a list of other websockets that are still open. *Default: `None`* - **app**, an instance of Bottle which will be used rather than creating a fresh one. This can be used to install middleware on the instance before starting eel, e.g. for session management, authentication, etc. + - **reload_python_on_change**, a boolean that enables Bottle server reloading when Python file changes are detected. Using this option may make local development of your application easier. An explicit port must be set when using this option to ensure that the Eel can effectively reconnect to the updated server. diff --git a/eel/__init__.py b/eel/__init__.py index 2f28ce89..570075eb 100644 --- a/eel/__init__.py +++ b/eel/__init__.py @@ -50,6 +50,7 @@ 'disable_cache': True, # Sets the no-store response header when serving assets 'default_path': 'index.html', # The default file to retrieve for the root URL 'app': btl.default_app(), # Allows passing in a custom Bottle instance, e.g. with middleware + 'reload_python_on_change': False, # Start bottle server in reloader mode for easier development } # == Temporary (suppressable) error message to inform users of breaking API change for v1.0.0 === @@ -128,6 +129,12 @@ def start(*start_urls, **kwargs): else: raise RuntimeError(api_error_message) + if _start_args['reload_python_on_change'] and _start_args['port'] == 0: + raise ValueError( + "Eel must be started on a fixed port in order to reload Python code on file changes. " + "For example, to start on port 8000, add `port=8000` to the `eel.start` call." + ) + if _start_args['port'] == 0: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(('localhost', 0)) @@ -140,9 +147,9 @@ def start(*start_urls, **kwargs): _start_args['jinja_env'] = Environment(loader=FileSystemLoader(templates_path), autoescape=select_autoescape(['html', 'xml'])) - # Launch the browser to the starting URLs - show(*start_urls) + if not _start_args['reload_python_on_change'] or not os.environ.get('BOTTLE_CHILD'): + show(*start_urls) def run_lambda(): if _start_args['all_interfaces'] == True: @@ -160,7 +167,8 @@ def run_lambda(): port=_start_args['port'], server=wbs.GeventWebSocketServer, quiet=True, - app=app) + app=app, + reloader=_start_args['reload_python_on_change']) # Start the webserver if _start_args['block']: diff --git a/eel/eel.js b/eel/eel.js index cc824206..f40385ed 100644 --- a/eel/eel.js +++ b/eel/eel.js @@ -103,61 +103,85 @@ eel = { } }, - _init: function() { - eel._mock_py_functions(); + _connect: function() { + let page = window.location.pathname.substring(1); + eel._position_window(page); - document.addEventListener("DOMContentLoaded", function(event) { - let page = window.location.pathname.substring(1); - eel._position_window(page); + let websocket_addr = (eel._host + '/eel').replace('http', 'ws'); + websocket_addr += ('?page=' + page); - let websocket_addr = (eel._host + '/eel').replace('http', 'ws'); - websocket_addr += ('?page=' + page); - eel._websocket = new WebSocket(websocket_addr); + eel._websocket = new WebSocket(websocket_addr); - eel._websocket.onopen = function() { - for(let i = 0; i < eel._py_functions.length; i++){ - let py_function = eel._py_functions[i]; - eel._import_py_function(py_function); - } + eel._websocket.onopen = function() { + for(let i = 0; i < eel._py_functions.length; i++){ + let py_function = eel._py_functions[i]; + eel._import_py_function(py_function); + } - while(eel._mock_queue.length > 0) { - let call = eel._mock_queue.shift(); - eel._websocket.send(eel._toJSON(call)); + while(eel._mock_queue.length > 0) { + let call = eel._mock_queue.shift(); + eel._websocket.send(eel._toJSON(call)); + } + }; + + eel._websocket.onmessage = function (e) { + let message = JSON.parse(e.data); + if(message.hasOwnProperty('call') ) { + // Python making a function call into us + if(message.name in eel._exposed_functions) { + let return_val = eel._exposed_functions[message.name](...message.args); + eel._websocket.send(eel._toJSON({'return': message.call, 'value': return_val})); } }; eel._websocket.onmessage = function (e) { let message = JSON.parse(e.data); - if(message.hasOwnProperty('call') ) { + if (message.hasOwnProperty('call')) { // Python making a function call into us - if(message.name in eel._exposed_functions) { + if (message.name in eel._exposed_functions) { try { let return_val = eel._exposed_functions[message.name](...message.args); - eel._websocket.send(eel._toJSON({'return': message.call, 'status':'ok', 'value': return_val})); - } catch(err) { + eel._websocket.send(eel._toJSON({ + 'return': message.call, + 'status': 'ok', + 'value': return_val + })); + } catch (err) { debugger eel._websocket.send(eel._toJSON( - {'return': message.call, - 'status':'error', - 'error': err.message, - 'stack': err.stack})); + { + 'return': message.call, + 'status': 'error', + 'error': err.message, + 'stack': err.stack + })); } } - } else if(message.hasOwnProperty('return')) { + } else if (message.hasOwnProperty('return')) { // Python returning a value to us - if(message['return'] in eel._call_return_callbacks) { - if(message['status']==='ok'){ + if (message['return'] in eel._call_return_callbacks) { + if (message['status'] === 'ok') { eel._call_return_callbacks[message['return']].resolve(message.value); - } - else if(message['status']==='error' && eel._call_return_callbacks[message['return']].reject) { - eel._call_return_callbacks[message['return']].reject(message['error']); + } else if (message['status'] === 'error' && eel._call_return_callbacks[message['return']].reject) { + eel._call_return_callbacks[message['return']].reject(message['error']); } } } else { throw 'Invalid message ' + message; } + } + }; - }; + eel._websocket.onclose = function (e) { + setTimeout(eel._connect, 200) + }; + }, + + _init: function() { + eel._mock_py_functions(); + + document.addEventListener("DOMContentLoaded", function(event) { + eel._connect(); }); } }; diff --git a/examples/10 - reload_code/reloader.py b/examples/10 - reload_code/reloader.py new file mode 100644 index 00000000..06f32c86 --- /dev/null +++ b/examples/10 - reload_code/reloader.py @@ -0,0 +1,11 @@ +import eel + +eel.init("web") + + +@eel.expose +def updating_message(): + return "Change this message in `reloader.py` and see it available in the browser after a few seconds/clicks." + + +eel.start("reloader.html", size=(320, 120), reload_python_on_change=True) diff --git a/examples/10 - reload_code/web/favicon.ico b/examples/10 - reload_code/web/favicon.ico new file mode 100644 index 00000000..c9efc584 Binary files /dev/null and b/examples/10 - reload_code/web/favicon.ico differ diff --git a/examples/10 - reload_code/web/reloader.html b/examples/10 - reload_code/web/reloader.html new file mode 100644 index 00000000..bd36758b --- /dev/null +++ b/examples/10 - reload_code/web/reloader.html @@ -0,0 +1,25 @@ + + + + Reloader Demo + + + + + +
+ +
+
---
+ + diff --git a/tests/conftest.py b/tests/conftest.py index b525ce94..5d7b1bca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,14 +14,18 @@ def driver(): options = webdriver.ChromeOptions() options.headless = True capabilities = DesiredCapabilities.CHROME - capabilities['goog:loggingPrefs'] = {"browser": "ALL"} + capabilities["goog:loggingPrefs"] = {"browser": "ALL"} - driver = webdriver.Chrome(options=options, desired_capabilities=capabilities, service_log_path=os.path.devnull) + driver = webdriver.Chrome( + options=options, + desired_capabilities=capabilities, + service_log_path=os.path.devnull, + ) # Firefox doesn't currently supported pulling JavaScript console logs, which we currently scan to affirm that # JS/Python can communicate in some places. So for now, we can't really use firefox/geckodriver during testing. # This may be added in the future: https://github.com/mozilla/geckodriver/issues/284 - + # elif TEST_BROWSER == "firefox": # options = webdriver.FirefoxOptions() # options.headless = True diff --git a/tests/integration/test_examples.py b/tests/integration/test_examples.py index 63119ef7..6a36734e 100644 --- a/tests/integration/test_examples.py +++ b/tests/integration/test_examples.py @@ -1,6 +1,11 @@ import os +import re +import shutil +import tempfile +import time from tempfile import TemporaryDirectory, NamedTemporaryFile +import pytest from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions @@ -59,3 +64,45 @@ def test_06_jinja_templates(driver: webdriver.Remote): driver.find_element_by_css_selector('a').click() WebDriverWait(driver, 2.0).until(expected_conditions.presence_of_element_located((By.XPATH, '//h1[text()="This is page 2"]'))) + + +@pytest.mark.timeout(30) +def test_10_reload_file_changes(driver: webdriver.Remote): + with tempfile.TemporaryDirectory() as tmp_root: + tmp_dir = shutil.copytree( + "examples/10 - reload_code", os.path.join(tmp_root, "test_10") + ) + + with get_eel_server( + os.path.join(tmp_dir, "reloader.py"), "reloader.html" + ) as eel_url: + while driver.title != "Reloader Demo": + time.sleep(0.05) + driver.get(eel_url) + + msg = driver.find_element_by_id("updating-message").text + assert msg == "---" + + while msg != ( + "Change this message in `reloader.py` and see it available in the browser after a few seconds/clicks." + ): + time.sleep(0.05) + driver.find_element_by_xpath("//button").click() + msg = driver.find_element_by_id("updating-message").text + + # Update the test code file and change the message. + reloader_code = open(os.path.join(tmp_dir, "reloader.py")).read() + reloader_code = re.sub( + '^ {4}return ".*"$', ' return "New message."', reloader_code, flags=re.MULTILINE + ) + + with open(os.path.join(tmp_dir, "reloader.py"), "w") as f: + f.write(reloader_code) + + # Nudge the dev server to give it a chance to reload + driver.get(eel_url) + + while msg != "New message.": + time.sleep(0.05) + driver.find_element_by_xpath("//button").click() + msg = driver.find_element_by_id("updating-message").text diff --git a/tests/utils.py b/tests/utils.py index 9febc834..1620752c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,8 @@ import contextlib import os +import random +import socket +import string import subprocess import tempfile import time @@ -21,6 +24,11 @@ def get_eel_server(example_py, start_html): """Run an Eel example with the mode/port overridden so that no browser is launched and a random port is assigned""" test = None + # Find a port for Eel to run on + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("localhost", 0)) + eel_port = sock.getsockname()[1] + try: with tempfile.NamedTemporaryFile(mode='w', dir=os.path.dirname(example_py), delete=False) as test: # We want to run the examples unmodified to keep the test as realistic as possible, but all of the examples @@ -32,13 +40,12 @@ def get_eel_server(example_py, start_html): import eel eel._start_args['mode'] = None -eel._start_args['port'] = 0 +eel._start_args['port'] = {eel_port} import {os.path.splitext(os.path.basename(example_py))[0]} """) - proc = subprocess.Popen(['python', test.name], cwd=os.path.dirname(example_py)) - eel_port = get_process_listening_port(proc) + proc = subprocess.Popen(['python', test.name], cwd=os.path.dirname(example_py), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) yield f"http://localhost:{eel_port}/{start_html}"