Skip to content

Commit

Permalink
Reload Eel on Python file changes
Browse files Browse the repository at this point in the history
Add support for reloading the Bottle server during development. Eel on
the JS side will now try to automatically reconnect to the Python/Bottle
server when the websocket dies. This allows us to let the Bottle server
die and restart to pull in new changes.

An explicit port must be set when we want to use the reloading server to
make sure that it restarts on the same port, as that is the port that
the JS side will be trying to connect to.
  • Loading branch information
samuelhwilliams committed Jul 26, 2020
1 parent e6db3f0 commit ee74005
Show file tree
Hide file tree
Showing 9 changed files with 167 additions and 40 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.



Expand Down
14 changes: 11 additions & 3 deletions eel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===
Expand Down Expand Up @@ -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))
Expand All @@ -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:
Expand All @@ -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']:
Expand Down
86 changes: 55 additions & 31 deletions eel/eel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
};
Expand Down
11 changes: 11 additions & 0 deletions examples/10 - reload_code/reloader.py
Original file line number Diff line number Diff line change
@@ -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)
Binary file added examples/10 - reload_code/web/favicon.ico
Binary file not shown.
25 changes: 25 additions & 0 deletions examples/10 - reload_code/web/reloader.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<title>Reloader Demo</title>
<script type='text/javascript' src='/eel.js'></script>
<script type='text/javascript'>

async function updating_message() {
let file_div = document.getElementById('updating-message');

// Call into Python so we can access the file system
let message = await eel.updating_message()();
file_div.innerHTML = message;
}

</script>
</head>

<body>
<form onsubmit="updating_message(); return false;" >
<button type="submit">Run Python code</button>
</form>
<div id='updating-message'>---</div>
</body>
</html>
10 changes: 7 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions tests/integration/test_examples.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
13 changes: 10 additions & 3 deletions tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import contextlib
import os
import random
import socket
import string
import subprocess
import tempfile
import time
Expand All @@ -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
Expand All @@ -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}"

Expand Down

0 comments on commit ee74005

Please sign in to comment.