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

Commit ee74005

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 ee74005

File tree

9 files changed

+167
-40
lines changed

9 files changed

+167
-40
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: 47 additions & 0 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
@@ -59,3 +64,45 @@ def test_06_jinja_templates(driver: webdriver.Remote):
5964

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

tests/utils.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import contextlib
22
import os
3+
import random
4+
import socket
5+
import string
36
import subprocess
47
import tempfile
58
import time
@@ -21,6 +24,11 @@ def get_eel_server(example_py, start_html):
2124
"""Run an Eel example with the mode/port overridden so that no browser is launched and a random port is assigned"""
2225
test = None
2326

27+
# Find a port for Eel to run on
28+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
29+
sock.bind(("localhost", 0))
30+
eel_port = sock.getsockname()[1]
31+
2432
try:
2533
with tempfile.NamedTemporaryFile(mode='w', dir=os.path.dirname(example_py), delete=False) as test:
2634
# 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):
3240
import eel
3341
3442
eel._start_args['mode'] = None
35-
eel._start_args['port'] = 0
43+
eel._start_args['port'] = {eel_port}
3644
3745
import {os.path.splitext(os.path.basename(example_py))[0]}
3846
""")
3947

40-
proc = subprocess.Popen(['python', test.name], cwd=os.path.dirname(example_py))
41-
eel_port = get_process_listening_port(proc)
48+
proc = subprocess.Popen(['python', test.name], cwd=os.path.dirname(example_py), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
4249

4350
yield f"http://localhost:{eel_port}/{start_html}"
4451

0 commit comments

Comments
 (0)