Skip to content
This repository was archived by the owner on Jun 22, 2025. It is now read-only.

Commit ed9b880

Browse files
Reload Eel on Python file changes
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.
1 parent e6db3f0 commit ed9b880

File tree

9 files changed

+177
-49
lines changed

9 files changed

+177
-49
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ As of Eel v0.12.0, the following options are available to `start()`:
118118
- **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`*
119119
- **app**, an instance of Bottle which will be used rather than creating a fresh one. This can be used to install middleware on the
120120
instance before starting eel, e.g. for session management, authentication, etc.
121+
- **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.
121122

122123

123124

eel/__init__.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
'disable_cache': True, # Sets the no-store response header when serving assets
5151
'default_path': 'index.html', # The default file to retrieve for the root URL
5252
'app': btl.default_app(), # Allows passing in a custom Bottle instance, e.g. with middleware
53+
'reload_python_on_change': False, # Start bottle server in reloader mode for easier development
5354
}
5455

5556
# == Temporary (suppressable) error message to inform users of breaking API change for v1.0.0 ===
@@ -128,6 +129,12 @@ def start(*start_urls, **kwargs):
128129
else:
129130
raise RuntimeError(api_error_message)
130131

132+
if _start_args['reload_python_on_change'] and _start_args['port'] == 0:
133+
raise ValueError(
134+
"Eel must be started on a fixed port in order to reload Python code on file changes. "
135+
"For example, to start on port 8000, add `port=8000` to the `eel.start` call."
136+
)
137+
131138
if _start_args['port'] == 0:
132139
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
133140
sock.bind(('localhost', 0))
@@ -140,9 +147,9 @@ def start(*start_urls, **kwargs):
140147
_start_args['jinja_env'] = Environment(loader=FileSystemLoader(templates_path),
141148
autoescape=select_autoescape(['html', 'xml']))
142149

143-
144150
# Launch the browser to the starting URLs
145-
show(*start_urls)
151+
if not _start_args['reload_python_on_change'] or not os.environ.get('BOTTLE_CHILD'):
152+
show(*start_urls)
146153

147154
def run_lambda():
148155
if _start_args['all_interfaces'] == True:
@@ -160,7 +167,8 @@ def run_lambda():
160167
port=_start_args['port'],
161168
server=wbs.GeventWebSocketServer,
162169
quiet=True,
163-
app=app)
170+
app=app,
171+
reloader=_start_args['reload_python_on_change'])
164172

165173
# Start the webserver
166174
if _start_args['block']:

eel/eel.js

Lines changed: 55 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -103,61 +103,85 @@ eel = {
103103
}
104104
},
105105

106-
_init: function() {
107-
eel._mock_py_functions();
106+
_connect: function() {
107+
let page = window.location.pathname.substring(1);
108+
eel._position_window(page);
108109

109-
document.addEventListener("DOMContentLoaded", function(event) {
110-
let page = window.location.pathname.substring(1);
111-
eel._position_window(page);
110+
let websocket_addr = (eel._host + '/eel').replace('http', 'ws');
111+
websocket_addr += ('?page=' + page);
112112

113-
let websocket_addr = (eel._host + '/eel').replace('http', 'ws');
114-
websocket_addr += ('?page=' + page);
115-
eel._websocket = new WebSocket(websocket_addr);
113+
eel._websocket = new WebSocket(websocket_addr);
116114

117-
eel._websocket.onopen = function() {
118-
for(let i = 0; i < eel._py_functions.length; i++){
119-
let py_function = eel._py_functions[i];
120-
eel._import_py_function(py_function);
121-
}
115+
eel._websocket.onopen = function() {
116+
for(let i = 0; i < eel._py_functions.length; i++){
117+
let py_function = eel._py_functions[i];
118+
eel._import_py_function(py_function);
119+
}
122120

123-
while(eel._mock_queue.length > 0) {
124-
let call = eel._mock_queue.shift();
125-
eel._websocket.send(eel._toJSON(call));
121+
while(eel._mock_queue.length > 0) {
122+
let call = eel._mock_queue.shift();
123+
eel._websocket.send(eel._toJSON(call));
124+
}
125+
};
126+
127+
eel._websocket.onmessage = function (e) {
128+
let message = JSON.parse(e.data);
129+
if(message.hasOwnProperty('call') ) {
130+
// Python making a function call into us
131+
if(message.name in eel._exposed_functions) {
132+
let return_val = eel._exposed_functions[message.name](...message.args);
133+
eel._websocket.send(eel._toJSON({'return': message.call, 'value': return_val}));
126134
}
127135
};
128136

129137
eel._websocket.onmessage = function (e) {
130138
let message = JSON.parse(e.data);
131-
if(message.hasOwnProperty('call') ) {
139+
if (message.hasOwnProperty('call')) {
132140
// Python making a function call into us
133-
if(message.name in eel._exposed_functions) {
141+
if (message.name in eel._exposed_functions) {
134142
try {
135143
let return_val = eel._exposed_functions[message.name](...message.args);
136-
eel._websocket.send(eel._toJSON({'return': message.call, 'status':'ok', 'value': return_val}));
137-
} catch(err) {
144+
eel._websocket.send(eel._toJSON({
145+
'return': message.call,
146+
'status': 'ok',
147+
'value': return_val
148+
}));
149+
} catch (err) {
138150
debugger
139151
eel._websocket.send(eel._toJSON(
140-
{'return': message.call,
141-
'status':'error',
142-
'error': err.message,
143-
'stack': err.stack}));
152+
{
153+
'return': message.call,
154+
'status': 'error',
155+
'error': err.message,
156+
'stack': err.stack
157+
}));
144158
}
145159
}
146-
} else if(message.hasOwnProperty('return')) {
160+
} else if (message.hasOwnProperty('return')) {
147161
// Python returning a value to us
148-
if(message['return'] in eel._call_return_callbacks) {
149-
if(message['status']==='ok'){
162+
if (message['return'] in eel._call_return_callbacks) {
163+
if (message['status'] === 'ok') {
150164
eel._call_return_callbacks[message['return']].resolve(message.value);
151-
}
152-
else if(message['status']==='error' && eel._call_return_callbacks[message['return']].reject) {
153-
eel._call_return_callbacks[message['return']].reject(message['error']);
165+
} else if (message['status'] === 'error' && eel._call_return_callbacks[message['return']].reject) {
166+
eel._call_return_callbacks[message['return']].reject(message['error']);
154167
}
155168
}
156169
} else {
157170
throw 'Invalid message ' + message;
158171
}
172+
}
173+
};
159174

160-
};
175+
eel._websocket.onclose = function (e) {
176+
setTimeout(eel._connect, 200)
177+
};
178+
},
179+
180+
_init: function() {
181+
eel._mock_py_functions();
182+
183+
document.addEventListener("DOMContentLoaded", function(event) {
184+
eel._connect();
161185
});
162186
}
163187
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import eel
2+
3+
eel.init("web")
4+
5+
6+
@eel.expose
7+
def updating_message():
8+
return "Change this message in `reloader.py` and see it available in the browser after a few seconds/clicks."
9+
10+
11+
eel.start("reloader.html", size=(320, 120), reload_python_on_change=True)
14.7 KB
Binary file not shown.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Reloader Demo</title>
5+
<script type='text/javascript' src='/eel.js'></script>
6+
<script type='text/javascript'>
7+
8+
async function updating_message() {
9+
let file_div = document.getElementById('updating-message');
10+
11+
// Call into Python so we can access the file system
12+
let message = await eel.updating_message()();
13+
file_div.innerHTML = message;
14+
}
15+
16+
</script>
17+
</head>
18+
19+
<body>
20+
<form onsubmit="updating_message(); return false;" >
21+
<button type="submit">Run Python code</button>
22+
</form>
23+
<div id='updating-message'>---</div>
24+
</body>
25+
</html>

tests/conftest.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@ def driver():
1414
options = webdriver.ChromeOptions()
1515
options.headless = True
1616
capabilities = DesiredCapabilities.CHROME
17-
capabilities['goog:loggingPrefs'] = {"browser": "ALL"}
17+
capabilities["goog:loggingPrefs"] = {"browser": "ALL"}
1818

19-
driver = webdriver.Chrome(options=options, desired_capabilities=capabilities, service_log_path=os.path.devnull)
19+
driver = webdriver.Chrome(
20+
options=options,
21+
desired_capabilities=capabilities,
22+
service_log_path=os.path.devnull,
23+
)
2024

2125
# Firefox doesn't currently supported pulling JavaScript console logs, which we currently scan to affirm that
2226
# JS/Python can communicate in some places. So for now, we can't really use firefox/geckodriver during testing.
2327
# This may be added in the future: https://github.com/mozilla/geckodriver/issues/284
24-
28+
2529
# elif TEST_BROWSER == "firefox":
2630
# options = webdriver.FirefoxOptions()
2731
# options.headless = True

tests/integration/test_examples.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import os
2+
import re
3+
import shutil
4+
import tempfile
5+
import time
26
from tempfile import TemporaryDirectory, NamedTemporaryFile
37

8+
import pytest
49
from selenium import webdriver
510
from selenium.webdriver.common.by import By
611
from selenium.webdriver.support import expected_conditions
@@ -47,9 +52,12 @@ def test_04_file_access(driver: webdriver.Remote):
4752
with TemporaryDirectory() as temp_dir, NamedTemporaryFile(dir=temp_dir) as temp_file:
4853
driver.find_element_by_id('input-box').clear()
4954
driver.find_element_by_id('input-box').send_keys(temp_dir)
50-
driver.find_element_by_css_selector('button').click()
5155

52-
assert driver.find_element_by_id('file-name').text == os.path.basename(temp_file.name)
56+
fname = driver.find_element_by_id('file-name').text
57+
while fname != os.path.basename(temp_file.name):
58+
driver.find_element_by_css_selector('button').click()
59+
time.sleep(0.05)
60+
fname = driver.find_element_by_id('file-name').text
5361

5462

5563
def test_06_jinja_templates(driver: webdriver.Remote):
@@ -59,3 +67,44 @@ def test_06_jinja_templates(driver: webdriver.Remote):
5967

6068
driver.find_element_by_css_selector('a').click()
6169
WebDriverWait(driver, 2.0).until(expected_conditions.presence_of_element_located((By.XPATH, '//h1[text()="This is page 2"]')))
70+
71+
72+
@pytest.mark.timeout(30)
73+
def test_10_reload_file_changes(driver: webdriver.Remote):
74+
with tempfile.TemporaryDirectory() as tmp_root:
75+
tmp_dir = shutil.copytree(
76+
os.path.join("examples", "10 - reload_code"), os.path.join(tmp_root, "test_10")
77+
)
78+
79+
with get_eel_server(
80+
os.path.join(tmp_dir, "reloader.py"), "reloader.html"
81+
) as eel_url:
82+
driver.get(eel_url)
83+
assert driver.title == "Reloader Demo"
84+
85+
msg = driver.find_element_by_id("updating-message").text
86+
assert msg == "---"
87+
88+
while msg != (
89+
"Change this message in `reloader.py` and see it available in the browser after a few seconds/clicks."
90+
):
91+
time.sleep(0.05)
92+
driver.find_element_by_xpath("//button").click()
93+
msg = driver.find_element_by_id("updating-message").text
94+
95+
# Update the test code file and change the message.
96+
reloader_code = open(os.path.join(tmp_dir, "reloader.py")).read()
97+
reloader_code = re.sub(
98+
'^ {4}return ".*"$', ' return "New message."', reloader_code, flags=re.MULTILINE
99+
)
100+
101+
with open(os.path.join(tmp_dir, "reloader.py"), "w") as f:
102+
f.write(reloader_code)
103+
104+
# Nudge the dev server to give it a chance to reload
105+
driver.get(eel_url)
106+
107+
while msg != "New message.":
108+
time.sleep(0.05)
109+
driver.find_element_by_xpath("//button").click()
110+
msg = driver.find_element_by_id("updating-message").text

tests/utils.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,30 @@
11
import contextlib
22
import os
3+
import random
4+
import socket
5+
import string
36
import subprocess
47
import tempfile
58
import time
69

710
import psutil
811

912

10-
def get_process_listening_port(proc):
11-
psutil_proc = psutil.Process(proc.pid)
12-
while not any(conn.status == 'LISTEN' for conn in psutil_proc.connections()):
13-
time.sleep(0.01)
14-
15-
conn = next(filter(lambda conn: conn.status == 'LISTEN', psutil_proc.connections()))
16-
return conn.laddr.port
13+
def is_port_open(port):
14+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
15+
return sock.connect_ex(("localhost", port)) == 0
1716

1817

1918
@contextlib.contextmanager
2019
def get_eel_server(example_py, start_html):
2120
"""Run an Eel example with the mode/port overridden so that no browser is launched and a random port is assigned"""
2221
test = None
2322

23+
# Find a port for Eel to run on
24+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
25+
sock.bind(("localhost", 0))
26+
eel_port = sock.getsockname()[1]
27+
2428
try:
2529
with tempfile.NamedTemporaryFile(mode='w', dir=os.path.dirname(example_py), delete=False) as test:
2630
# We want to run the examples unmodified to keep the test as realistic as possible, but all of the examples
@@ -32,13 +36,15 @@ def get_eel_server(example_py, start_html):
3236
import eel
3337
3438
eel._start_args['mode'] = None
35-
eel._start_args['port'] = 0
39+
eel._start_args['port'] = {eel_port}
3640
3741
import {os.path.splitext(os.path.basename(example_py))[0]}
3842
""")
3943

40-
proc = subprocess.Popen(['python', test.name], cwd=os.path.dirname(example_py))
41-
eel_port = get_process_listening_port(proc)
44+
proc = subprocess.Popen(['python', test.name], cwd=os.path.dirname(example_py), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
45+
46+
while not is_port_open(eel_port):
47+
time.sleep(0.01)
4248

4349
yield f"http://localhost:{eel_port}/{start_html}"
4450

0 commit comments

Comments
 (0)