diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 00000000..fe96485e
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,10 @@
+github: samuelhwilliams
+patreon: # Replace with a single Patreon username
+open_collective: # Replace with a single Open Collective username
+ko_fi: # Replace with a single Ko-fi username
+tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
+community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+liberapay: # Replace with a single Liberapay username
+issuehunt: # Replace with a single IssueHunt username
+otechie: # Replace with a single Otechie username
+custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 90c947ba..caa98832 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -23,13 +23,13 @@ Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
+**System Information**
+ - OS: [e.g. Windows 10 x64, Linux Ubuntu, macOS 12]
+ - Browser: [e.g. Chrome 108.0.5359.99 (Official Build) (64-bit), Safari 16, Firefox 107.0.1]
+ - Python Distribution: [e.g. Python.org 3.9, Anaconda3 2021.11 3.9, ActivePython 3.9]
+
**Screenshots**
If applicable, add screenshots to help explain your problem.
-**Desktop (please complete the following information):**
- - OS: [e.g. iOS]
- - Browser [e.g. chrome, safari]
- - Version [e.g. 22]
-
**Additional context**
Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/help-me.md b/.github/ISSUE_TEMPLATE/help-me.md
index a4be9245..07bab9c5 100644
--- a/.github/ISSUE_TEMPLATE/help-me.md
+++ b/.github/ISSUE_TEMPLATE/help-me.md
@@ -11,6 +11,9 @@ assignees: ''
A clear and concise description of what you're trying to accomplish, and where you're having difficulty.
**Code snippet(s)**
+Here is some code that can be easily used to reproduce the problem or understand what I need help with.
+
+- [ ] I know that if I don't provide sample code that allows someone to quickly step into my shoes, I may not get the help I want or my issue may be closed.
```python
import eel
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 00000000..a1621116
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,38 @@
+name: "CodeQL"
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [main]
+ schedule:
+ - cron: '0 11 * * 0'
+
+jobs:
+ analyse:
+ name: Analyse
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+ with:
+ # We must fetch at least the immediate parents so that if this is
+ # a pull request then we can checkout the head.
+ fetch-depth: 2
+
+ # If this run was triggered by a pull request event, then checkout
+ # the head of the pull request instead of the merge commit.
+ - run: git checkout HEAD^2
+ if: ${{ github.event_name == 'pull_request' }}
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v1
+ # Override language selection by uncommenting this and choosing your languages
+ with:
+ languages: javascript, python
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v1
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 78198ac1..8f67eff8 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -17,10 +17,12 @@ jobs:
with:
python-version: 3.x
architecture: x64
+ - name: Install setuptools
+ run: pip install setuptools==76.1.0
- name: Build a source distribution
run: python setup.py sdist
- name: Publish to prod PyPI
- uses: pypa/gh-action-pypi-publish@master
+ uses: pypa/gh-action-pypi-publish@4f4304928fc886cd021893f6defb1bd53d0a1e5a
with:
user: __token__
password: ${{ secrets.pypi_token }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 00000000..3ae373a8
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,55 @@
+name: Test Eel
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [main]
+ workflow_dispatch:
+
+jobs:
+ test:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-24.04, windows-latest, macos-latest]
+ python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"]
+ exclude:
+ - os: macos-latest
+ python-version: 3.7
+ - os: ubuntu-24.04
+ python-version: 3.7
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+ - name: Setup python
+ uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Setup test execution environment.
+ run: pip3 install -r requirements-meta.txt
+ - name: Run tox tests
+ run: tox -- --durations=0 --timeout=240
+
+ typecheck:
+ strategy:
+ matrix:
+ os: [windows-latest]
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+ - name: Setup python
+ uses: actions/setup-python@v2
+ with:
+ python-version: "3.x"
+ - name: Setup test execution environment.
+ run: pip3 install -r requirements-meta.txt
+ - name: Run tox tests
+ run: tox -e typecheck
diff --git a/.gitignore b/.gitignore
index 74584727..781d9ca4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,11 @@
__pycache__
dist
build
+Drivers
Eel.egg-info
.tmp
.DS_Store
*.pyc
*.swp
venv/
+.tox
diff --git a/.python-version b/.python-version
new file mode 100644
index 00000000..c8cfe395
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.10
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e1f8dc3a..b91f88d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,72 @@
# Change log
-### v0.11.0
-* Added support for `app` parameter to `eel.start`, which will override the bottle app instance used to run eel. This
+### 0.18.2
+
+* Switch from using `pkg_resources` to `importlib.resources`: https://github.com/python-eel/Eel/pull/766
+
+### 0.18.1
+
+* Fix: Include `typing_extensions` in install requirements.
+
+### 0.18.0
+* Added support for MS Internet Explorer in #744.
+* Added supported for app_mode in the Edge browser in #744.
+* Improved type annotations in #683.
+
+### 0.17.0
+* Adds support for Python 3.11 and Python 3.12
+
+### v0.16.0
+* Drop support for Python versions below 3.7
+
+### v0.15.3
+* Comprehensive type hints implement by @thatfloflo in https://github.com/python-eel/Eel/pull/577.
+
+### v0.15.2
+* Adds `register_eel_routes` to handle applying Eel routes to non-Bottle custom app instances.
+
+### v0.15.1
+* Bump bottle dependency from 0.12.13 to 0.12.20 to address the critical CVE-2022-31799 and moderate CVE-2020-28473.
+
+### v0.15.0
+* Add `shutdown_delay` as a `start()` function parameter ([#529](https://github.com/python-eel/Eel/pull/529))
+
+### v0.14.0
+* Change JS function name parsing to use PyParsing rather than regex, courtesy @KyleKing.
+
+### v0.13.2
+* Add `default_path` start arg to define a default file to retrieve when hitting the root URL.
+
+### v0.13.1
+* Shut down the Eel server less aggressively when websockets get closed (#337)
+
+## v0.13.0
+* Drop support for Python versions below 3.6
+* Add `jinja2` as an extra for pip installation, e.g. `pip install eel[jinja2]`.
+* Bump dependencies in examples to dismiss github security notices. We probably want to set up a policy to ignore example dependencies as they shouldn't be considered a source of vulnerabilities.
+* Disable edge on non-Windows platforms until we implement proper support.
+
+### v0.12.4
+* Return greenlet task from `spawn()` ([#300](https://github.com/samuelhwilliams/Eel/pull/300))
+* Set JS mimetype to reduce errors on Windows platform ([#289](https://github.com/samuelhwilliams/Eel/pull/289))
+
+### v0.12.3
+* Search for Chromium on macOS.
+
+### v0.12.2
+* Fix a bug that prevents using middleware via a custom Bottle.
+
+### v0.12.1
+* Check that Chrome path is a file that exists on Windows before blindly returning it.
+
+## v0.12.0
+* Allow users to override the amount of time Python will wait for Javascript functions running via Eel to run before bailing and returning None.
+
+### v0.11.1
+* Fix the implementation of #203, allowing users to pass their own bottle instances into Eel.
+
+## v0.11.0
+* Added support for `app` parameter to `eel.start`, which will override the bottle app instance used to run eel. This
allows developers to apply any middleware they wish to before handing over to eel.
* Disable page caching by default via new `disable_cache` parameter to `eel.start`.
* Add support for listening on all network interfaces via new `all_interfaces` parameter to `eel.start`.
@@ -11,7 +76,7 @@ allows developers to apply any middleware they wish to before handing over to ee
* Fix PyPi project description.
### v0.10.3
-* Fix a bug that prevented using Eel without Jinja templating.
+* Fix a bug that prevented using Eel without Jinja templating.
### v0.10.2
* Only render templates from within the declared jinja template directory.
diff --git a/README-developers.md b/README-developers.md
new file mode 100644
index 00000000..a0d00d6c
--- /dev/null
+++ b/README-developers.md
@@ -0,0 +1,51 @@
+# Eel Developers
+
+## Setting up your environment
+
+In order to start developing with Eel you'll need to checkout the code, set up a development and testing environment, and check that everything is in order.
+
+### Clone the repository
+```bash
+git clone git@github.com:python-eel/Eel.git
+```
+
+### (Recommended) Create a virtual environment
+It's recommended that you use virtual environments for this project. Your process for setting up a virutal environment will vary depending on OS and tool of choice, but might look something like this:
+
+```bash
+python3 -m venv venv
+source venv/bin/activate
+```
+
+**Note**: `venv` is listed in the `.gitignore` file so it's the recommended virtual environment name
+
+
+### Install project requirements
+
+```bash
+pip3 install -r requirements.txt # eel's 'prod' requirements
+pip3 install -r requirements-test.txt # pytest and selenium
+pip3 install -r requirements-meta.txt # tox
+```
+
+### (Recommended) Run Automated Tests
+Tox is configured to run tests against each major version we support (3.7+). In order to run Tox as configured, you will need to install multiple versions of Python. See the pinned minor versions in `.python-version` for recommendations.
+
+#### Tox Setup
+Our Tox configuration requires [Chrome](https://www.google.com/chrome) and [ChromeDriver](https://chromedriver.chromium.org/home). See each of those respective project pages for more information on setting each up.
+
+**Note**: Pay attention to the version of Chrome that is installed on your OS because you need to select the compatible ChromeDriver version.
+
+#### Running Tests
+
+To test Eel against a specific version of Python you have installed, e.g. Python 3.7 in this case, run:
+
+```bash
+tox -e py36
+```
+
+To test Eel against all supported versions, run the following:
+
+```bash
+tox
+```
diff --git a/README.md b/README.md
index 1f6ab9ac..a2dfc4dc 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,20 @@
# Eel
+> [!CAUTION]
+> This project is effectively unmaintained. It has not received regular update in a number of years, and there are no plans by active maintainers for this to change. Please treat this project in that light and use it with careful consideration towards how you will secure and maintain anything you build using it.
+
+[](https://pypi.org/project/Eel/)
+[](https://pypistats.org/packages/eel)
+
+[](https://pypi.org/project/Eel/)
+
Eel is a little Python library for making simple Electron-like offline HTML/JS GUI apps, with full access to Python capabilities and libraries.
> **Eel hosts a local webserver, then lets you annotate functions in Python so that they can be called from Javascript, and vice versa.**
Eel is designed to take the hassle out of writing short and simple GUI applications. If you are familiar with Python and web development, probably just jump to [this example](https://github.com/ChrisKnott/Eel/tree/master/examples/04%20-%20file_access) which picks random file names out of the given folder (something that is impossible from a browser).
-

+
@@ -39,6 +47,8 @@ Eel is not as fully-fledged as Electron or cefpython - it is probably not suitab
For some reason many of the best-in-class number crunching and maths libraries are in Python (Tensorflow, Numpy, Scipy etc) but many of the best visualization libraries are in Javascript (D3, THREE.js etc). Hopefully Eel makes it easy to combine these into simple utility apps for assisting your development.
+Join Eel's users and maintainers on [Discord](https://discord.com/invite/3nqXPFX), if you like.
+
## Install
Install from pypi with `pip`:
@@ -47,6 +57,12 @@ Install from pypi with `pip`:
pip install eel
```
+To include support for HTML templating, currently using [Jinja2](https://pypi.org/project/Jinja2/#description):
+
+```shell
+pip install eel[jinja2]
+```
+
## Usage
### Directory Structure
@@ -86,8 +102,8 @@ Additional options can be passed to `eel.start()` as keyword arguments.
Some of the options include the mode the app is in (e.g. 'chrome'), the port the app runs on, the host name of the app, and adding additional command line flags.
-As of Eel v0.11.0, the following options are available to `start()`:
- - **mode**, a string specifying what browser to use (e.g. `'chrome'`, `'electron'`, `'edge'`, `'custom'`). Can also be `None` or `False` to not open a window. *Default: `'chrome'`*
+As of Eel v0.12.0, the following options are available to `start()`:
+ - **mode**, a string specifying what browser to use (e.g. `'chrome'`, `'electron'`, `'edge'`,`'msie'`, `'custom'`). Can also be `None` or `False` to not open a window. *Default: `'chrome'`*
- **host**, a string specifying what hostname to use for the Bottle server. *Default: `'localhost'`)*
- **port**, an int specifying what port to use for the Bottle server. Use `0` for port to be picked automatically. *Default: `8000`*.
- **block**, a bool saying whether or not the call to `start()` should block the calling thread. *Default: `True`*
@@ -97,8 +113,8 @@ As of Eel v0.11.0, the following options are available to `start()`:
- **position**, a tuple of ints specifying the (left, top) of the main window in pixels *Default: `None`*
- **geometry**, a dictionary specifying the size and position for all windows. The keys should be the relative path of the page, and the values should be a dictionary of the form `{'size': (200, 100), 'position': (300, 50)}`. *Default: {}*
- **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.
+ - **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. If your `app` is not a Bottle instance, you will need to call `eel.register_eel_routes(app)` on your custom app instance.
+ - **shutdown_delay**, timer configurable for Eel's shutdown detection mechanism, whereby when any websocket closes, it waits `shutdown_delay` seconds, and then checks if there are now any websocket connections. If not, then Eel closes. In case the user has closed the browser and wants to exit the program. By default, the value of **shutdown_delay** is `1.0` second
@@ -145,6 +161,12 @@ print('Calling Javascript...')
eel.my_javascript_function(1, 2, 3, 4) # This calls the Javascript function
```
+The exposed name can also be overridden by passing in a second argument. If your app minifies JavaScript during builds, this may be necessary to ensure that functions can be resolved on the Python side:
+
+```javascript
+eel.expose(someFunction, "my_javascript_function");
+```
+
When passing complex objects as arguments, bear in mind that internally they are converted to JSON and sent down a websocket (a process that potentially loses information).
### Eello, World!
@@ -221,9 +243,12 @@ While we want to think of our code as comprising a single application, the Pytho
Eel supports two ways of retrieving _return values_ from the other side of the app, which helps keep the code concise.
+To prevent hanging forever on the Python side, a timeout has been put in place for trying to retrieve values from
+the JavaScript side, which defaults to 10000 milliseconds (10 seconds). This can be changed with the `_js_result_timeout` parameter to `eel.init`. There is no corresponding timeout on the JavaScript side.
+
#### Callbacks
-When you call an exposed function, you can immediately pass a callback function afterwards. This callback will automatically be called asynchrounously with the return value when the function has finished executing on the other side.
+When you call an exposed function, you can immediately pass a callback function afterwards. This callback will automatically be called asynchronously with the return value when the function has finished executing on the other side.
For example, if we have the following function defined and exposed in Javascript:
@@ -260,7 +285,7 @@ n = eel.js_random()() # This immediately returns the value
print('Got this from Javascript:', n)
```
-You can only perform synchronous returns after the browser window has started (after calling `eel.start()`), otherwise obviously the call with hang.
+You can only perform synchronous returns after the browser window has started (after calling `eel.start()`), otherwise obviously the call will hang.
In Javascript, the language doesn't allow us to block while we wait for a callback, except by using `await` from inside an `async` function. So the equivalent code from the Javascript side would be:
diff --git a/eel/__init__.py b/eel/__init__.py
index ddb982d3..34fc7ce1 100644
--- a/eel/__init__.py
+++ b/eel/__init__.py
@@ -1,51 +1,57 @@
-from __future__ import print_function # Python 2 compatibility stuff
+from __future__ import annotations
from builtins import range
+import traceback
from io import open
-
+from typing import Union, Any, Dict, List, Set, Tuple, Optional, Callable
+from typing_extensions import Literal
+from eel.types import OptionsDictT, WebSocketT
import gevent as gvt
import json as jsn
import bottle as btl
-import bottle.ext.websocket as wbs
+try:
+ import bottle_websocket as wbs
+except ImportError:
+ import bottle.ext.websocket as wbs
import re as rgx
import os
import eel.browsers as brw
+import pyparsing as pp
import random as rnd
import sys
-import pkg_resources as pkg
+import importlib_resources
import socket
-
-_eel_js_file = pkg.resource_filename('eel', 'eel.js')
-_eel_js = open(_eel_js_file, encoding='utf-8').read()
-_websockets = []
-_call_return_values = {}
-_call_return_callbacks = {}
-_call_number = 0
-_exposed_functions = {}
-_js_functions = []
-_mock_queue = []
-_mock_queue_done = set()
-
-# All start() options must provide a default value and explanation here
-_start_args = {
- 'mode': 'chrome', # What browser is used
- 'host': 'localhost', # Hostname use for Bottle server
- 'port': 8000, # Port used for Bottle server (use 0 for auto)
- 'block': True, # Whether start() blocks calling thread
- 'jinja_templates': None, # Folder for jinja2 templates
- 'cmdline_args': ['--disable-http-cache'], # Extra cmdline flags to pass to browser start
- 'size': None, # (width, height) of main window
- 'position': None, # (left, top) of main window
- 'geometry': {}, # Dictionary of size/position for all windows
- 'close_callback': None, # Callback for when all windows have closed
- 'app_mode': True, # (Chrome specific option)
- 'all_interfaces': False, # Allow bottle server to listen for connections on all interfaces
- 'disable_cache': True, # Sets the no-store response header when serving assets
- 'app': None, # Allows passing in a custom Bottle instance, e.g. with middleware
-}
-
-# == Temporary (suppressable) error message to inform users of breaking API change for v1.0.0 ===
-_start_args['suppress_error'] = False
-api_error_message = '''
+import mimetypes
+
+
+mimetypes.add_type('application/javascript', '.js')
+
+# https://setuptools.pypa.io/en/latest/pkg_resources.html
+# Use of pkg_resources is deprecated in favor of importlib.resources
+# Migration guide: https://importlib-resources.readthedocs.io/en/latest/migration.html
+_eel_js_reference = importlib_resources.files('eel') / 'eel.js'
+with importlib_resources.as_file(_eel_js_reference) as _eel_js_path:
+ _eel_js: str = _eel_js_path.read_text(encoding='utf-8')
+
+_websockets: List[Tuple[Any, WebSocketT]] = []
+_call_return_values: Dict[Any, Any] = {}
+_call_return_callbacks: Dict[float, Tuple[Callable[..., Any], Optional[Callable[..., Any]]]] = {}
+_call_number: int = 0
+_exposed_functions: Dict[Any, Any] = {}
+_js_functions: List[Any] = []
+_mock_queue: List[Any] = []
+_mock_queue_done: Set[Any] = set()
+_shutdown: Optional[gvt.Greenlet] = None # Later assigned as global by _websocket_close()
+root_path: str # Later assigned as global by init()
+
+# The maximum time (in milliseconds) that Python will try to retrieve a return value for functions executing in JS
+# Can be overridden through `eel.init` with the kwarg `js_result_timeout` (default: 10000)
+_js_result_timeout: int = 10000
+
+# Attribute holding the start args from calls to eel.start()
+_start_args: OptionsDictT = {}
+
+# == Temporary (suppressible) error message to inform users of breaking API change for v1.0.0 ===
+api_error_message: str = '''
----------------------------------------------------------------------------------
'options' argument deprecated in v1.0.0, see https://github.com/ChrisKnott/Eel
To suppress this error, add 'suppress_error=True' to start() call.
@@ -54,17 +60,53 @@
'''
# ===============================================================================================
+
# Public functions
-def expose(name_or_function=None):
+
+def expose(name_or_function: Optional[Callable[..., Any]] = None) -> Callable[..., Any]:
+ '''Decorator to expose Python callables via Eel's JavaScript API.
+
+ When an exposed function is called, a callback function can be passed
+ immediately afterwards. This callback will be called asynchronously with
+ the return value (possibly `None`) when the Python function has finished
+ executing.
+
+ Blocking calls to the exposed function from the JavaScript side are only
+ possible using the :code:`await` keyword inside an :code:`async function`.
+ These still have to make a call to the response, i.e.
+ :code:`await eel.py_random()();` inside an :code:`async function` will work,
+ but just :code:`await eel.py_random();` will not.
+
+ :Example:
+
+ In Python do:
+
+ .. code-block:: python
+
+ @expose
+ def say_hello_py(name: str = 'You') -> None:
+ print(f'{name} said hello from the JavaScript world!')
+
+ In JavaScript do:
+
+ .. code-block:: javascript
+
+ eel.say_hello_py('Alice')();
+
+ Expected output on the Python console::
+
+ Alice said hello from the JavaScript world!
+
+ '''
# Deal with '@eel.expose()' - treat as '@eel.expose'
if name_or_function is None:
return expose
- if type(name_or_function) == str: # Called as '@eel.expose("my_name")'
+ if isinstance(name_or_function, str): # Called as '@eel.expose("my_name")'
name = name_or_function
- def decorator(function):
+ def decorator(function: Callable[..., Any]) -> Any:
_expose(name, function)
return function
return decorator
@@ -74,9 +116,45 @@ def decorator(function):
return function
-def init(path, allowed_extensions=['.js', '.html', '.txt', '.htm',
- '.xhtml', '.vue']):
- global root_path, _js_functions
+# PyParsing grammar for parsing exposed functions in JavaScript code
+# Examples: `eel.expose(w, "func_name")`, `eel.expose(func_name)`, `eel.expose((function (e){}), "func_name")`
+EXPOSED_JS_FUNCTIONS: pp.ZeroOrMore = pp.ZeroOrMore(
+ pp.Suppress(
+ pp.SkipTo(pp.Literal('eel.expose('))
+ + pp.Literal('eel.expose(')
+ + pp.Optional(
+ pp.Or([pp.nestedExpr(), pp.Word(pp.printables, excludeChars=',')]) + pp.Literal(',')
+ )
+ )
+ + pp.Suppress(pp.Regex(r'["\']?'))
+ + pp.Word(pp.printables, excludeChars='"\')')
+ + pp.Suppress(pp.Regex(r'["\']?\s*\)')),
+)
+
+
+def init(
+ path: str,
+ allowed_extensions: List[str] = ['.js', '.html', '.txt', '.htm', '.xhtml', '.vue'],
+ js_result_timeout: int = 10000) -> None:
+ '''Initialise Eel.
+
+ This function should be called before :func:`start()` to initialise the
+ parameters for the web interface, such as the path to the files to be
+ served.
+
+ :param path: Sets the path on the filesystem where files to be served to
+ the browser are located, e.g. :file:`web`.
+ :param allowed_extensions: A list of filename extensions which will be
+ parsed for exposed eel functions which should be callable from python.
+ Files with extensions not in *allowed_extensions* will still be served,
+ but any JavaScript functions, even if marked as exposed, will not be
+ accessible from python.
+ *Default:* :code:`['.js', '.html', '.txt', '.htm', '.xhtml', '.vue']`.
+ :param js_result_timeout: How long Eel should be waiting to register the
+ results from a call to Eel's JavaScript API before before timing out.
+ *Default:* :code:`10000` milliseconds.
+ '''
+ global root_path, _js_functions, _js_result_timeout
root_path = _get_real_path(path)
js_functions = set()
@@ -89,12 +167,8 @@ def init(path, allowed_extensions=['.js', '.html', '.txt', '.htm',
with open(os.path.join(root, name), encoding='utf-8') as file:
contents = file.read()
expose_calls = set()
- finder = rgx.findall(r'eel\.expose\(([^\)]+)\)', contents)
- for expose_call in finder:
- # If name specified in 2nd argument, strip quotes and store as function name
- if ',' in expose_call:
- expose_call = rgx.sub(r'["\']', '', expose_call.split(',')[1])
- expose_call = expose_call.strip()
+ matches = EXPOSED_JS_FUNCTIONS.parseString(contents).asList()
+ for expose_call in matches:
# Verify that function name is valid
msg = "eel.expose() call contains '(' or '='"
assert rgx.findall(r'[\(=]', expose_call) == [], msg
@@ -107,15 +181,120 @@ def init(path, allowed_extensions=['.js', '.html', '.txt', '.htm',
for js_function in _js_functions:
_mock_js_function(js_function)
-
-def start(*start_urls, **kwargs):
- _start_args.update(kwargs)
-
- if 'options' in kwargs:
- if _start_args['suppress_error']:
- _start_args.update(kwargs['options'])
- else:
- raise RuntimeError(api_error_message)
+ _js_result_timeout = js_result_timeout
+
+
+def start(
+ *start_urls: str,
+ mode: Optional[Union[str, Literal[False]]] = 'chrome',
+ host: str = 'localhost',
+ port: int = 8000,
+ block: bool = True,
+ jinja_templates: Optional[str] = None,
+ cmdline_args: List[str] = ['--disable-http-cache'],
+ size: Optional[Tuple[int, int]] = None,
+ position: Optional[Tuple[int, int]] = None,
+ geometry: Dict[str, Tuple[int, int]] = {},
+ close_callback: Optional[Callable[..., Any]] = None,
+ app_mode: bool = True,
+ all_interfaces: bool = False,
+ disable_cache: bool = True,
+ default_path: str = 'index.html',
+ app: btl.Bottle = btl.default_app(),
+ shutdown_delay: float = 1.0,
+ suppress_error: bool = False) -> None:
+ '''Start the Eel app.
+
+ Suppose you put all the frontend files in a directory called
+ :file:`web`, including your start page :file:`main.html`, then the app
+ is started like this:
+
+ .. code-block:: python
+
+ import eel
+ eel.init('web')
+ eel.start('main.html')
+
+ This will start a webserver on the default settings
+ (http://localhost:8000) and open a browser to
+ http://localhost:8000/main.html.
+
+ If Chrome or Chromium is installed then by default it will open that in
+ *App Mode* (with the `--app` cmdline flag), regardless of what the OS's
+ default browser is set to (it is possible to override this behaviour).
+
+ :param mode: What browser is used, e.g. :code:`'chrome'`,
+ :code:`'electron'`, :code:`'edge'`, :code:`'custom'`. Can also be
+ `None` or `False` to not open a window. *Default:* :code:`'chrome'`.
+ :param host: Hostname used for Bottle server. *Default:*
+ :code:`'localhost'`.
+ :param port: Port used for Bottle server. Use :code:`0` for port to be
+ picked automatically. *Default:* :code:`8000`.
+ :param block: Whether the call to :func:`start()` blocks the calling
+ thread. *Default:* `True`.
+ :param jinja_templates: Folder for :mod:`jinja2` templates, e.g.
+ :file:`my_templates`. *Default:* `None`.
+ :param cmdline_args: A list of strings to pass to the command starting the
+ browser. For example, we might add extra flags to Chrome with
+ :code:`eel.start('main.html', mode='chrome-app', port=8080,
+ cmdline_args=['--start-fullscreen', '--browser-startup-dialog'])`.
+ *Default:* :code:`[]`.
+ :param size: Tuple specifying the (width, height) of the main window in
+ pixels. *Default:* `None`.
+ :param position: Tuple specifying the (left, top) position of the main
+ window in pixels. *Default*: `None`.
+ :param geometry: A dictionary of specifying the size/position for all
+ windows. The keys should be the relative path of the page, and the
+ values should be a dictionary of the form
+ :code:`{'size': (200, 100), 'position': (300, 50)}`. *Default:*
+ :code:`{}`.
+ :param close_callback: A lambda or function that is called when a websocket
+ or 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 the other websockets that are still open.
+ *Default:* `None`.
+ :param app_mode: Whether to run Chrome/Edge in App Mode. You can also
+ specify *mode* as :code:`mode='chrome-app'` as a shorthand to start
+ Chrome in App Mode.
+ :param all_interfaces: Whether to allow the :mod:`bottle` server to listen
+ for connections on all interfaces.
+ :param disable_cache: Sets the no-store response header when serving
+ assets.
+ :param default_path: The default file to retrieve for the root URL.
+ :param app: An instance of :class:`bottle.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. If *app* is not a :class:`bottle.Bottle` instance,
+ you will need to call :code:`eel.register_eel_routes(app)` on your
+ custom app instance.
+ :param shutdown_delay: Timer configurable for Eel's shutdown detection
+ mechanism, whereby when any websocket closes, it waits *shutdown_delay*
+ seconds, and then checks if there are now any websocket connections.
+ If not, then Eel closes. In case the user has closed the browser and
+ wants to exit the program. *Default:* :code:`1.0` seconds.
+ :param suppress_error: Temporary (suppressible) error message to inform
+ users of breaking API change for v1.0.0. Set to `True` to suppress
+ the error message.
+ '''
+ _start_args.update({
+ 'mode': mode,
+ 'host': host,
+ 'port': port,
+ 'block': block,
+ 'jinja_templates': jinja_templates,
+ 'cmdline_args': cmdline_args,
+ 'size': size,
+ 'position': position,
+ 'geometry': geometry,
+ 'close_callback': close_callback,
+ 'app_mode': app_mode,
+ 'all_interfaces': all_interfaces,
+ 'disable_cache': disable_cache,
+ 'default_path': default_path,
+ 'app': app,
+ 'shutdown_delay': shutdown_delay,
+ 'suppress_error': suppress_error,
+ })
if _start_args['port'] == 0:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -123,27 +302,47 @@ def start(*start_urls, **kwargs):
_start_args['port'] = sock.getsockname()[1]
sock.close()
- if _start_args['jinja_templates'] != None:
+ if _start_args['jinja_templates'] is not None:
from jinja2 import Environment, FileSystemLoader, select_autoescape
+ if not isinstance(_start_args['jinja_templates'], str):
+ raise TypeError("'jinja_templates' start_arg/option must be of type str")
templates_path = os.path.join(root_path, _start_args['jinja_templates'])
- _start_args['jinja_env'] = Environment(loader=FileSystemLoader(templates_path),
- autoescape=select_autoescape(['html', 'xml']))
-
+ _start_args['jinja_env'] = Environment(
+ loader=FileSystemLoader(templates_path),
+ autoescape=select_autoescape(['html', 'xml'])
+ )
+
+ # verify shutdown_delay is correct value
+ if not isinstance(_start_args['shutdown_delay'], (int, float)):
+ raise ValueError(
+ '`shutdown_delay` must be a number, '
+ 'got a {}'.format(type(_start_args['shutdown_delay']))
+ )
# Launch the browser to the starting URLs
show(*start_urls)
- def run_lambda():
- if _start_args['all_interfaces'] == True:
+ def run_lambda() -> None:
+ if _start_args['all_interfaces'] is True:
HOST = '0.0.0.0'
else:
+ if not isinstance(_start_args['host'], str):
+ raise TypeError("'host' start_arg/option must be of type str")
HOST = _start_args['host']
- return btl.run(
+
+ app = _start_args['app']
+
+ if isinstance(app, btl.Bottle):
+ register_eel_routes(app)
+ else:
+ register_eel_routes(btl.default_app())
+
+ btl.run(
host=HOST,
port=_start_args['port'],
server=wbs.GeventWebSocketServer,
quiet=True,
- app=_start_args.get('app'))
+ app=app) # Always returns None
# Start the webserver
if _start_args['block']:
@@ -152,21 +351,80 @@ def run_lambda():
spawn(run_lambda)
-def show(*start_urls):
- brw.open(start_urls, _start_args)
+def show(*start_urls: str) -> None:
+ '''Show the specified URL(s) in the browser.
+
+ Suppose you have two files in your :file:`web` folder. The file
+ :file:`hello.html` regularly includes :file:`eel.js` and provides
+ interactivity, and the file :file:`goodbye.html` does not include
+ :file:`eel.js` and simply provides plain HTML content not reliant on Eel.
+
+ First, we defien a callback function to be called when the browser
+ window is closed:
+
+ .. code-block:: python
+
+ def last_calls():
+ eel.show('goodbye.html')
+
+ Now we initialise and start Eel, with a :code:`close_callback` to our
+ function:
+ ..code-block:: python
-def sleep(seconds):
+ eel.init('web')
+ eel.start('hello.html', mode='chrome-app', close_callback=last_calls)
+
+ When the websocket from :file:`hello.html` is closed (e.g. because the
+ user closed the browser window), Eel will wait *shutdown_delay* seconds
+ (by default 1 second), then call our :code:`last_calls()` function, which
+ opens another window with the :file:`goodbye.html` shown before our Eel app
+ terminates.
+
+ :param start_urls: One or more URLs to be opened.
+ '''
+ brw.open(list(start_urls), _start_args)
+
+
+def sleep(seconds: Union[int, float]) -> None:
+ '''A non-blocking sleep call compatible with the Gevent event loop.
+
+ .. note::
+ While this function simply wraps :func:`gevent.sleep()`, it is better
+ to call :func:`eel.sleep()` in your eel app, as this will ensure future
+ compatibility in case the implementation of Eel should change in some
+ respect.
+
+ :param seconds: The number of seconds to sleep.
+ '''
gvt.sleep(seconds)
-def spawn(function, *args, **kwargs):
- gvt.spawn(function, *args, **kwargs)
+def spawn(function: Callable[..., Any], *args: Any, **kwargs: Any) -> gvt.Greenlet:
+ '''Spawn a new Greenlet.
+
+ Calling this function will spawn a new :class:`gevent.Greenlet` running
+ *function* asynchronously.
+
+ .. caution::
+ If you spawn your own Greenlets to run in addition to those spawned by
+ Eel's internal core functionality, you will have to ensure that those
+ Greenlets will terminate as appropriate (either by returning or by
+ being killed via Gevent's kill mechanism), otherwise your app may not
+ terminate correctly when Eel itself terminates.
+
+ :param function: The function to be called and run as the Greenlet.
+ :param *args: Any positional arguments that should be passed to *function*.
+ :param **kwargs: Any key-word arguments that should be passed to
+ *function*.
+ '''
+ return gvt.spawn(function, *args, **kwargs)
+
# Bottle Routes
-@btl.route('/eel.js')
-def _eel():
+
+def _eel() -> str:
start_geometry = {'default': {'size': _start_args['size'],
'position': _start_args['position']},
'pages': _start_args['geometry']}
@@ -180,10 +438,17 @@ def _eel():
return page
-@btl.route('/')
-def _static(path):
+def _root() -> btl.Response:
+ if not isinstance(_start_args['default_path'], str):
+ raise TypeError("'default_path' start_arg/option must be of type str")
+ return _static(_start_args['default_path'])
+
+
+def _static(path: str) -> btl.Response:
response = None
if 'jinja_env' in _start_args and 'jinja_templates' in _start_args:
+ if not isinstance(_start_args['jinja_templates'], str):
+ raise TypeError("'jinja_templates' start_arg/option must be of type str")
template_prefix = _start_args['jinja_templates'] + '/'
if path.startswith(template_prefix):
n = len(template_prefix)
@@ -197,8 +462,7 @@ def _static(path):
return response
-@btl.get('/eel', apply=[wbs.websocket])
-def _websocket(ws):
+def _websocket(ws: WebSocketT) -> None:
global _websockets
for js_function in _js_functions:
@@ -223,13 +487,47 @@ def _websocket(ws):
_websocket_close(page)
+
+BOTTLE_ROUTES: Dict[str, Tuple[Callable[..., Any], Dict[Any, Any]]] = {
+ "/eel.js": (_eel, dict()),
+ "/": (_root, dict()),
+ "/": (_static, dict()),
+ "/eel": (_websocket, dict(apply=[wbs.websocket]))
+}
+
+
+def register_eel_routes(app: btl.Bottle) -> None:
+ '''Register the required eel routes with `app`.
+
+ .. note::
+
+ :func:`eel.register_eel_routes()` is normally invoked implicitly by
+ :func:`eel.start()` and does not need to be called explicitly in most
+ cases. Registering the eel routes explicitly is only needed if you are
+ passing something other than an instance of :class:`bottle.Bottle` to
+ :func:`eel.start()`.
+
+ :Example:
+
+ >>> app = bottle.Bottle()
+ >>> eel.register_eel_routes(app)
+ >>> middleware = beaker.middleware.SessionMiddleware(app)
+ >>> eel.start(app=middleware)
+
+ '''
+ for route_path, route_params in BOTTLE_ROUTES.items():
+ route_func, route_kwargs = route_params
+ app.route(path=route_path, callback=route_func, **route_kwargs)
+
+
# Private functions
-def _safe_json(obj):
+
+def _safe_json(obj: Any) -> str:
return jsn.dumps(obj, default=lambda o: None)
-def _repeated_send(ws, msg):
+def _repeated_send(ws: WebSocketT, msg: str) -> None:
for attempt in range(100):
try:
ws.send(msg)
@@ -238,92 +536,119 @@ def _repeated_send(ws, msg):
sleep(0.001)
-def _process_message(message, ws):
+def _process_message(message: Dict[str, Any], ws: WebSocketT) -> None:
if 'call' in message:
- return_val = _exposed_functions[message['name']](*message['args'])
+ error_info = {}
+ try:
+ return_val = _exposed_functions[message['name']](*message['args'])
+ status = 'ok'
+ except Exception as e:
+ err_traceback = traceback.format_exc()
+ traceback.print_exc()
+ return_val = None
+ status = 'error'
+ error_info['errorText'] = repr(e)
+ error_info['errorTraceback'] = err_traceback
_repeated_send(ws, _safe_json({ 'return': message['call'],
- 'value': return_val }))
+ 'status': status,
+ 'value': return_val,
+ 'error': error_info,}))
elif 'return' in message:
call_id = message['return']
if call_id in _call_return_callbacks:
- callback = _call_return_callbacks.pop(call_id)
- callback(message['value'])
+ callback, error_callback = _call_return_callbacks.pop(call_id)
+ if message['status'] == 'ok':
+ callback(message['value'])
+ elif message['status'] == 'error' and error_callback is not None:
+ error_callback(message['error'], message['stack'])
else:
_call_return_values[call_id] = message['value']
+
else:
print('Invalid message received: ', message)
-def _get_real_path(path):
+def _get_real_path(path: str) -> str:
if getattr(sys, 'frozen', False):
- return os.path.join(sys._MEIPASS, path)
+ return os.path.join(sys._MEIPASS, path) # type: ignore # sys._MEIPASS is dynamically added by PyInstaller
else:
return os.path.abspath(path)
-def _mock_js_function(f):
+def _mock_js_function(f: str) -> None:
exec('%s = lambda *args: _mock_call("%s", args)' % (f, f), globals())
-def _import_js_function(f):
+def _import_js_function(f: str) -> None:
exec('%s = lambda *args: _js_call("%s", args)' % (f, f), globals())
-def _call_object(name, args):
+def _call_object(name: str, args: Any) -> Dict[str, Any]:
global _call_number
_call_number += 1
call_id = _call_number + rnd.random()
return {'call': call_id, 'name': name, 'args': args}
-def _mock_call(name, args):
+def _mock_call(name: str, args: Any) -> Callable[[Optional[Callable[..., Any]], Optional[Callable[..., Any]]], Any]:
call_object = _call_object(name, args)
global _mock_queue
_mock_queue += [call_object]
return _call_return(call_object)
-def _js_call(name, args):
+def _js_call(name: str, args: Any) -> Callable[[Optional[Callable[..., Any]], Optional[Callable[..., Any]]], Any]:
call_object = _call_object(name, args)
for _, ws in _websockets:
_repeated_send(ws, _safe_json(call_object))
return _call_return(call_object)
-def _call_return(call):
+def _call_return(call: Dict[str, Any]) -> Callable[[Optional[Callable[..., Any]], Optional[Callable[..., Any]]], Any]:
+ global _js_result_timeout
call_id = call['call']
- def return_func(callback=None):
+ def return_func(callback: Optional[Callable[..., Any]] = None,
+ error_callback: Optional[Callable[..., Any]] = None) -> Any:
if callback is not None:
- _call_return_callbacks[call_id] = callback
+ _call_return_callbacks[call_id] = (callback, error_callback)
else:
- for w in range(10000):
+ for w in range(_js_result_timeout):
if call_id in _call_return_values:
return _call_return_values.pop(call_id)
sleep(0.001)
return return_func
-def _expose(name, function):
+def _expose(name: str, function: Callable[..., Any]) -> None:
msg = 'Already exposed function with name "%s"' % name
assert name not in _exposed_functions, msg
_exposed_functions[name] = function
-def _websocket_close(page):
+def _detect_shutdown() -> None:
+ if len(_websockets) == 0:
+ sys.exit()
+
+
+def _websocket_close(page: str) -> None:
+ global _shutdown
+
close_callback = _start_args.get('close_callback')
if close_callback is not None:
+ if not callable(close_callback):
+ raise TypeError("'close_callback' start_arg/option must be callable or None")
sockets = [p for _, p in _websockets]
close_callback(page, sockets)
else:
- # Default behaviour - wait 1s, then quit if all sockets are closed
- sleep(1.0)
- if len(_websockets) == 0:
- sys.exit()
+ if isinstance(_shutdown, gvt.Greenlet):
+ _shutdown.kill()
+
+ _shutdown = gvt.spawn_later(_start_args['shutdown_delay'], _detect_shutdown)
-def _set_response_headers(response):
+def _set_response_headers(response: btl.Response) -> None:
if _start_args['disable_cache']:
# https://stackoverflow.com/a/24748094/280852
response.set_header('Cache-Control', 'no-store')
diff --git a/eel/__main__.py b/eel/__main__.py
index 3a4af07c..74910995 100644
--- a/eel/__main__.py
+++ b/eel/__main__.py
@@ -1,24 +1,40 @@
-from __future__ import print_function
-import sys
+from __future__ import annotations
import pkg_resources as pkg
import PyInstaller.__main__ as pyi
import os
+from argparse import ArgumentParser, Namespace
+from typing import List
-args = sys.argv[1:]
-main_script = args.pop(0)
-web_folder = args.pop(0)
+parser: ArgumentParser = ArgumentParser(description="""
+Eel is a little Python library for making simple Electron-like offline HTML/JS GUI apps,
+ with full access to Python capabilities and libraries.
+""")
+parser.add_argument(
+ "main_script",
+ type=str,
+ help="Main python file to run app from"
+)
+parser.add_argument(
+ "web_folder",
+ type=str,
+ help="Folder including all web files including file as html, css, ico, etc."
+)
+args: Namespace
+unknown_args: List[str]
+args, unknown_args = parser.parse_known_args()
+main_script: str = args.main_script
+web_folder: str = args.web_folder
print("Building executable with main script '%s' and web folder '%s'...\n" %
(main_script, web_folder))
-eel_js_file = pkg.resource_filename('eel', 'eel.js')
-js_file_arg = '%s%seel' % (eel_js_file, os.pathsep)
-web_folder_arg = '%s%s%s' % (web_folder, os.pathsep, web_folder)
-
-needed_args = ['--hidden-import', 'bottle_websocket',
- '--add-data', js_file_arg, '--add-data', web_folder_arg]
-full_args = [main_script] + needed_args + args
+eel_js_file: str = pkg.resource_filename('eel', 'eel.js')
+js_file_arg: str = '%s%seel' % (eel_js_file, os.pathsep)
+web_folder_arg: str = '%s%s%s' % (web_folder, os.pathsep, web_folder)
+needed_args: List[str] = ['--hidden-import', 'bottle_websocket',
+ '--add-data', js_file_arg, '--add-data', web_folder_arg]
+full_args: List[str] = [main_script] + needed_args + unknown_args
print('Running:\npyinstaller', ' '.join(full_args), '\n')
pyi.run(full_args)
diff --git a/eel/browsers.py b/eel/browsers.py
index 79639141..183dd905 100644
--- a/eel/browsers.py
+++ b/eel/browsers.py
@@ -1,53 +1,68 @@
+from __future__ import annotations
import subprocess as sps
import webbrowser as wbr
+from typing import Union, List, Dict, Iterable, Optional
+from types import ModuleType
+from eel.types import OptionsDictT
import eel.chrome as chm
import eel.electron as ele
import eel.edge as edge
+import eel.msIE as ie
#import eel.firefox as ffx TODO
#import eel.safari as saf TODO
-_browser_paths = {}
-_browser_modules = {'chrome': chm,
- 'electron': ele,
- 'edge': edge}
+_browser_paths: Dict[str, str] = {}
+_browser_modules: Dict[str, ModuleType] = {'chrome': chm,
+ 'electron': ele,
+ 'edge': edge,
+ 'msie':ie}
-def _build_url_from_dict(page, options):
+def _build_url_from_dict(page: Dict[str, str], options: OptionsDictT) -> str:
scheme = page.get('scheme', 'http')
host = page.get('host', 'localhost')
port = page.get('port', options["port"])
path = page.get('path', '')
- return '%s://%s:%d/%s' % (scheme, host, port, path)
+ if not isinstance(port, (int, str)):
+ raise TypeError("'port' option must be an integer")
+ return '%s://%s:%d/%s' % (scheme, host, int(port), path)
-def _build_url_from_string(page, options):
- base_url = 'http://%s:%d/' % (options['host'], options['port'])
+def _build_url_from_string(page: str, options: OptionsDictT) -> str:
+ if not isinstance(options['port'], (int, str)):
+ raise TypeError("'port' option must be an integer")
+ base_url = 'http://%s:%d/' % (options['host'], int(options['port']))
return base_url + page
-def _build_urls(start_pages, options):
- urls = []
+def _build_urls(start_pages: Iterable[Union[str, Dict[str, str]]], options: OptionsDictT) -> List[str]:
+ urls: List[str] = []
for page in start_pages:
- method = _build_url_from_dict if isinstance(
- page, dict) else _build_url_from_string
- url = method(page, options)
+ if isinstance(page, dict):
+ url = _build_url_from_dict(page, options)
+ else:
+ url = _build_url_from_string(page, options)
urls.append(url)
return urls
-def open(start_pages, options):
+def open(start_pages: Iterable[Union[str, Dict[str, str]]], options: OptionsDictT) -> None:
# Build full URLs for starting pages (including host and port)
start_urls = _build_urls(start_pages, options)
mode = options.get('mode')
- if mode in [None, False]:
+ if not isinstance(mode, (str, type(None))) and mode is not False:
+ raise TypeError("'mode' option must by either a string, False, or None")
+ if mode is None or mode is False:
# Don't open a browser
pass
elif mode == 'custom':
# Just run whatever command the user provided
+ if not isinstance(options['cmdline_args'], list):
+ raise TypeError("'cmdline_args' option must be of type List[str]")
sps.Popen(options['cmdline_args'],
stdout=sps.PIPE, stderr=sps.PIPE, stdin=sps.PIPE)
elif mode in _browser_modules:
@@ -69,10 +84,10 @@ def open(start_pages, options):
wbr.open(url)
-def set_path(browser_name, path):
+def set_path(browser_name: str, path: str) -> None:
_browser_paths[browser_name] = path
-def get_path(browser_name):
+def get_path(browser_name: str) -> Optional[str]:
return _browser_paths.get(browser_name)
diff --git a/eel/chrome.py b/eel/chrome.py
index ed189558..a8112f5d 100644
--- a/eel/chrome.py
+++ b/eel/chrome.py
@@ -1,67 +1,89 @@
-import sys, subprocess as sps, os
+from __future__ import annotations
+import sys
+import os
+import subprocess as sps
+from shutil import which
+from typing import List, Optional
+from eel.types import OptionsDictT
# Every browser specific module must define run(), find_path() and name like this
-name = 'Google Chrome/Chromium'
+name: str = 'Google Chrome/Chromium'
-def run(path, options, start_urls):
+def run(path: str, options: OptionsDictT, start_urls: List[str]) -> None:
+ if not isinstance(options['cmdline_args'], list):
+ raise TypeError("'cmdline_args' option must be of type List[str]")
if options['app_mode']:
for url in start_urls:
sps.Popen([path, '--app=%s' % url] +
options['cmdline_args'],
stdout=sps.PIPE, stderr=sps.PIPE, stdin=sps.PIPE)
else:
- args = options['cmdline_args'] + start_urls
+ args: List[str] = options['cmdline_args'] + start_urls
sps.Popen([path, '--new-window'] + args,
stdout=sps.PIPE, stderr=sys.stderr, stdin=sps.PIPE)
-def find_path():
+def find_path() -> Optional[str]:
if sys.platform in ['win32', 'win64']:
return _find_chrome_win()
elif sys.platform == 'darwin':
- return _find_chrome_mac()
+ return _find_chrome_mac() or _find_chromium_mac()
elif sys.platform.startswith('linux'):
return _find_chrome_linux()
else:
return None
-def _find_chrome_mac():
+def _find_chrome_mac() -> Optional[str]:
default_dir = r'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
if os.path.exists(default_dir):
return default_dir
# use mdfind ci to locate Chrome in alternate locations and return the first one
name = 'Google Chrome.app'
- alternate_dirs = [x for x in sps.check_output(["mdfind", name]).decode().split('\n') if x.endswith(name)]
+ alternate_dirs = [x for x in sps.check_output(["mdfind", name]).decode().split('\n') if x.endswith(name)]
if len(alternate_dirs):
return alternate_dirs[0] + '/Contents/MacOS/Google Chrome'
return None
-def _find_chrome_linux():
- import whichcraft as wch
+def _find_chromium_mac() -> Optional[str]:
+ default_dir = r'/Applications/Chromium.app/Contents/MacOS/Chromium'
+ if os.path.exists(default_dir):
+ return default_dir
+ # use mdfind ci to locate Chromium in alternate locations and return the first one
+ name = 'Chromium.app'
+ alternate_dirs = [x for x in sps.check_output(["mdfind", name]).decode().split('\n') if x.endswith(name)]
+ if len(alternate_dirs):
+ return alternate_dirs[0] + '/Contents/MacOS/Chromium'
+ return None
+
+
+def _find_chrome_linux() -> Optional[str]:
chrome_names = ['chromium-browser',
'chromium',
'google-chrome',
'google-chrome-stable']
for name in chrome_names:
- chrome = wch.which(name)
+ chrome = which(name)
if chrome is not None:
return chrome
return None
-def _find_chrome_win():
+def _find_chrome_win() -> Optional[str]:
import winreg as reg
reg_path = r'SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe'
+ chrome_path: Optional[str] = None
for install_type in reg.HKEY_CURRENT_USER, reg.HKEY_LOCAL_MACHINE:
try:
reg_key = reg.OpenKey(install_type, reg_path, 0, reg.KEY_READ)
chrome_path = reg.QueryValue(reg_key, None)
reg_key.Close()
+ if not os.path.isfile(chrome_path):
+ continue
except WindowsError:
chrome_path = None
else:
diff --git a/eel/edge.py b/eel/edge.py
index eebe805e..7d785233 100644
--- a/eel/edge.py
+++ b/eel/edge.py
@@ -1,14 +1,28 @@
+from __future__ import annotations
+import platform
import subprocess as sps
import sys
+from typing import List
-name = 'Edge'
+from eel.types import OptionsDictT
+name: str = 'Edge'
-def run(_path, options, start_urls):
- cmd = 'start microsoft-edge:{}'.format(start_urls[0])
- sps.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr, stdin=sps.PIPE, shell=True)
+def run(_path: str, options: OptionsDictT, start_urls: List[str]) -> None:
+ if not isinstance(options['cmdline_args'], list):
+ raise TypeError("'cmdline_args' option must be of type List[str]")
+ args: List[str] = options['cmdline_args']
+ if options['app_mode']:
+ cmd = 'start msedge --app={} '.format(start_urls[0])
+ cmd = cmd + (" ".join(args))
+ sps.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr, stdin=sps.PIPE, shell=True)
+ else:
+ cmd = "start msedge --new-window "+(" ".join(args)) +" "+(start_urls[0])
+ sps.Popen(cmd,stdout=sys.stdout, stderr=sys.stderr, stdin=sps.PIPE, shell=True)
-def find_path():
- # Path isn't necessary. Edge is launched with a CLI argument
- return True
+def find_path() -> bool:
+ if platform.system() == 'Windows':
+ return True
+
+ return False
diff --git a/eel/eel.js b/eel/eel.js
index 75ca2fc3..cc824206 100644
--- a/eel/eel.js
+++ b/eel/eel.js
@@ -76,10 +76,10 @@ eel = {
_call_return: function(call) {
return function(callback = null) {
if(callback != null) {
- eel._call_return_callbacks[call.call] = callback;
+ eel._call_return_callbacks[call.call] = {resolve: callback};
} else {
- return new Promise(function(resolve) {
- eel._call_return_callbacks[call.call] = resolve;
+ return new Promise(function(resolve, reject) {
+ eel._call_return_callbacks[call.call] = {resolve: resolve, reject: reject};
});
}
}
@@ -131,13 +131,27 @@ eel = {
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}));
+ 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) {
+ debugger
+ eel._websocket.send(eel._toJSON(
+ {'return': message.call,
+ 'status':'error',
+ 'error': err.message,
+ 'stack': err.stack}));
+ }
}
} else if(message.hasOwnProperty('return')) {
// Python returning a value to us
if(message['return'] in eel._call_return_callbacks) {
- eel._call_return_callbacks[message['return']](message.value);
+ 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 {
throw 'Invalid message ' + message;
@@ -146,7 +160,7 @@ eel = {
};
});
}
-}
+};
eel._init();
diff --git a/eel/electron.py b/eel/electron.py
index 7a443025..4ff8fcc1 100644
--- a/eel/electron.py
+++ b/eel/electron.py
@@ -1,24 +1,29 @@
+from __future__ import annotations
import sys
import os
import subprocess as sps
-import whichcraft as wch
+from shutil import which
+from typing import List, Optional
-name = 'Electron'
+from eel.types import OptionsDictT
-def run(path, options, start_urls):
+name: str = 'Electron'
+
+def run(path: str, options: OptionsDictT, start_urls: List[str]) -> None:
+ if not isinstance(options['cmdline_args'], list):
+ raise TypeError("'cmdline_args' option must be of type List[str]")
cmd = [path] + options['cmdline_args']
cmd += ['.', ';'.join(start_urls)]
sps.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr, stdin=sps.PIPE)
-def find_path():
+def find_path() -> Optional[str]:
if sys.platform in ['win32', 'win64']:
# It doesn't work well passing the .bat file to Popen, so we get the actual .exe
- bat_path = wch.which('electron')
- return os.path.join(bat_path, r'..\node_modules\electron\dist\electron.exe')
+ bat_path = which('electron')
+ if bat_path:
+ return os.path.join(bat_path, r'..\node_modules\electron\dist\electron.exe')
elif sys.platform in ['darwin', 'linux']:
- # This should work find...
- return wch.which('electron')
- else:
- return None
-
+ # This should work fine...
+ return which('electron')
+ return None
diff --git a/eel/msIE.py b/eel/msIE.py
new file mode 100644
index 00000000..7f7f2e26
--- /dev/null
+++ b/eel/msIE.py
@@ -0,0 +1,20 @@
+import platform
+import subprocess as sps
+import sys
+from typing import List
+
+from eel.types import OptionsDictT
+
+name: str = 'MSIE'
+
+
+def run(_path: str, options: OptionsDictT, start_urls: List[str]) -> None:
+ cmd = 'start microsoft-edge:{}'.format(start_urls[0])
+ sps.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr, stdin=sps.PIPE, shell=True)
+
+
+def find_path() -> bool:
+ if platform.system() == 'Windows':
+ return True
+
+ return False
diff --git a/eel/py.typed b/eel/py.typed
new file mode 100644
index 00000000..e69de29b
diff --git a/eel/types.py b/eel/types.py
new file mode 100644
index 00000000..0cd2d6ee
--- /dev/null
+++ b/eel/types.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+from typing import Union, Dict, List, Tuple, Callable, Optional, Any, TYPE_CHECKING
+from typing_extensions import Literal, TypedDict, TypeAlias
+from bottle import Bottle
+
+# This business is slightly awkward, but needed for backward compatibility,
+# because Python <3.10 doesn't support TypeAlias, jinja2 may not be available
+# at runtime, and geventwebsocket.websocket doesn't have type annotations so
+# that direct imports will raise an error.
+if TYPE_CHECKING:
+ from jinja2 import Environment
+ JinjaEnvironmentT: TypeAlias = Environment
+ from geventwebsocket.websocket import WebSocket
+ WebSocketT: TypeAlias = WebSocket
+else:
+ JinjaEnvironmentT: TypeAlias = Any
+ WebSocketT: TypeAlias = Any
+
+OptionsDictT = TypedDict(
+ 'OptionsDictT',
+ {
+ 'mode': Optional[Union[str, Literal[False]]],
+ 'host': str,
+ 'port': int,
+ 'block': bool,
+ 'jinja_templates': Optional[str],
+ 'cmdline_args': List[str],
+ 'size': Optional[Tuple[int, int]],
+ 'position': Optional[Tuple[int, int]],
+ 'geometry': Dict[str, Tuple[int, int]],
+ 'close_callback': Optional[Callable[..., Any]],
+ 'app_mode': bool,
+ 'all_interfaces': bool,
+ 'disable_cache': bool,
+ 'default_path': str,
+ 'app': Bottle,
+ 'shutdown_delay': float,
+ 'suppress_error': bool,
+ 'jinja_env': JinjaEnvironmentT,
+ },
+ total=False
+)
diff --git a/examples/01 - hello_world/hello.py b/examples/01 - hello_world/hello.py
index d2cb2f09..eef64833 100644
--- a/examples/01 - hello_world/hello.py
+++ b/examples/01 - hello_world/hello.py
@@ -1,4 +1,3 @@
-from __future__ import print_function # For Py2/3 compatibility
import eel
# Set web files folder
@@ -11,4 +10,4 @@ def say_hello_py(x):
say_hello_py('Python World!')
eel.say_hello_js('Python World!') # Call a Javascript function
-eel.start('hello.html', size=(300, 200)) # Start
+eel.start('hello.html', size=(300, 200)) # Start
diff --git a/examples/02 - callbacks/callbacks.py b/examples/02 - callbacks/callbacks.py
index 64769257..408496bd 100644
--- a/examples/02 - callbacks/callbacks.py
+++ b/examples/02 - callbacks/callbacks.py
@@ -1,4 +1,3 @@
-from __future__ import print_function # For Py2/3 compatibility
import eel
import random
@@ -8,13 +7,31 @@
def py_random():
return random.random()
+@eel.expose
+def py_exception(error):
+ if error:
+ raise ValueError("Test")
+ else:
+ return "No Error"
+
def print_num(n):
print('Got this from Javascript:', n)
+
+def print_num_failed(error, stack):
+ print("This is an example of what javascript errors would look like:")
+ print("\tError: ", error)
+ print("\tStack: ", stack)
+
# Call Javascript function, and pass explicit callback function
eel.js_random()(print_num)
# Do the same with an inline callback
eel.js_random()(lambda n: print('Got this from Javascript:', n))
+# Show error handling
+eel.js_with_error()(print_num, print_num_failed)
+
+
eel.start('callbacks.html', size=(400, 300))
+
diff --git a/examples/02 - callbacks/web/callbacks.html b/examples/02 - callbacks/web/callbacks.html
index 17aea7d8..1bfa8e3b 100644
--- a/examples/02 - callbacks/web/callbacks.html
+++ b/examples/02 - callbacks/web/callbacks.html
@@ -1,4 +1,4 @@
-
+
Callbacks Demo
@@ -10,6 +10,12 @@
function js_random() {
return Math.random();
}
+
+ eel.expose(js_with_error);
+ function js_with_error() {
+ var test = 0;
+ test.something("does not exist");
+ }
function print_num(n) {
console.log('Got this from Python: ' + n);
@@ -20,11 +26,29 @@
// Do the same with an inline callback
eel.py_random()(n => console.log('Got this from Python: ' + n));
-
+
+ // show usage with promises
+ // show no error
+ eel.py_exception(false)().then((result) => {
+ // this will execute since we said no error
+ console.log("No Error")
+ }).catch((result) => {
+ console.log("This won't be seen if no error")
+ }
+ );
+ // show if an error occurrs
+ eel.py_exception(true)().then((result) => {
+ // this will not execute
+ console.log("No Error")
+ }).catch((result) => {
+ console.log("This is the repr(e) for an exception " + result.errorText);
+ console.log("This is the full traceback:\n" + result.errorTraceback);
+ }
+ )
Callbacks demo
-
\ No newline at end of file
+